Database Backup to S3 with Kubernetes CronJobs
Automated database backups are non-negotiable in production. This guide shows how to build a Kubernetes CronJob that streams PostgreSQL backups directly to S3, with a complete local testing environment using KIND and LocalStack.
+-------------+ +-------------+ +-------------+
| PostgreSQL |---->| K8s CronJob|---->| S3 |
| (source) | | (pg_dump) | | (storage) |
+-------------+ +-------------+ +-------------+
TL;DR
Code Repository: All code from this post is available at github.com/moabukar/blog-code/database-backup-s3-kubernetes
- Kubernetes CronJob runs pg_dump on schedule
- Streams backup directly to S3 (no local disk needed)
- LocalStack for local S3 testing
- KIND cluster for Kubernetes testing
- Full verify/restore workflow
- One-command setup:
make setup && make test-working && make verify
Architecture
+-----------------------+
| Kubernetes Cluster |
| |
+----------+ | +----------------+ | +----------+
| Postgres |<-------+--| CronJob Pod |---+------->| S3 |
| Primary | read | | (backup runner)| | upload | (bucket) |
+----------+ | +----------------+ | +----------+
| | | | |
v | v | v
+----------+ | +----------------+ | +----------+
| Postgres | | | Secrets | | | Lifecycle|
| Replica | | | (DB + AWS creds)| | | Policy |
+----------+ | +----------------+ | +----------+
+-----------------------+
Components:
COMPONENT FUNCTION
========= ========
PostgreSQL Source database (primary + replica)
CronJob Scheduled backup execution
pg_dump PostgreSQL native backup tool
aws-cli S3 upload handler
LocalStack Local S3 simulation
KIND Local Kubernetes cluster
Project Structure
db-backup-s3/
├── Makefile # Build and test automation
├── docker-compose.yml # LocalStack configuration
├── kind-config.yaml # KIND cluster setup
├── postgres-deployment.yaml # PostgreSQL deployments
├── backup-cron.yaml # CronJob definition
├── local-backup-secret.yaml # Secrets for testing
├── setup-localstack.sh # S3 bucket setup
├── test-lab.sh # Full environment setup
├── verify-backup.sh # Backup verification
└── README.md
Prerequisites
TOOL INSTALLATION
==== ============
Docker https://docs.docker.com/get-docker/
KIND brew install kind
kubectl brew install kubectl
awslocal pip install awscli-local
Quick Start
# Clone the repository
git clone https://github.com/moabukar/db-backup-s3.git
cd db-backup-s3
# One-command setup and test
make quick
# Or step by step:
make setup # Create KIND cluster + LocalStack + PostgreSQL
make test-working # Run manual backup
make verify # Verify backup integrity
Makefile Reference
RDS Backup Test Makefile
========================
Available targets:
setup - Setup KIND cluster, LocalStack, and PostgreSQL
test - Run manual backup test (cronjob-based)
test-working - Run working backup test (simple job)
verify - Verify backup integrity and restore
status - Show current environment status
logs - Show logs from most recent backup job
logs-follow - Follow logs of running backup job
test-db - Test database connectivity
test-s3 - Test LocalStack S3 connectivity
e2e - Complete end-to-end test
quick - Quick test cycle (cleanup -> setup -> test -> verify)
cleanup - Remove all lab resources
help - Show this help
Quick start: make setup && make test-working && make verify
CronJob Configuration
The heart of the system - a Kubernetes CronJob that handles the backup:
# backup-cron.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: rds-backup-cronjob
namespace: default
spec:
schedule: "0 2 * * *" # Daily at 2 AM UTC
timeZone: "UTC"
concurrencyPolicy: Forbid
failedJobsHistoryLimit: 3
successfulJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: rds-backup
image: amazon/aws-cli:latest
command:
- /bin/bash
- -c
- |
set -euo pipefail
echo "Starting streaming backup..."
# Install PostgreSQL client
yum update -y
yum install -y postgresql15
# Set up environment
export PGPASSWORD=$(cat /secrets/db-password)
export AWS_ACCESS_KEY_ID=$(cat /aws-secrets/aws-access-key-id)
export AWS_SECRET_ACCESS_KEY=$(cat /aws-secrets/aws-secret-access-key)
export AWS_DEFAULT_REGION="us-east-1"
DB_HOST="postgres-replica-service.default.svc.cluster.local"
TIMESTAMP=$(date +%F-%H-%M)
S3_PATH="s3://rds-db-backups/\$TIMESTAMP/backup.dump"
echo "Target: \$DB_HOST"
echo "S3 destination: \$S3_PATH"
# Verify database connection
pg_isready -h \$DB_HOST -p 5432 -U root
# Create and upload backup
START_TIME=\$(date +%s)
pg_dump -h \$DB_HOST -U root -p 5432 \
--format=custom \
--blobs \
--no-password \
langfuse | aws s3 cp - \$S3_PATH
END_TIME=\$(date +%s)
DURATION=\$((END_TIME - START_TIME))
echo "Backup completed in \${DURATION}s"
# Verify upload
aws s3 ls \$S3_PATH
echo "BACKUP SUCCESSFUL"
unset PGPASSWORD
volumeMounts:
- name: db-secrets
mountPath: /secrets
readOnly: true
- name: aws-secrets
mountPath: /aws-secrets
readOnly: true
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
volumes:
- name: db-secrets
secret:
secretName: rds-db-root-secret
- name: aws-secrets
secret:
secretName: aws-credentials
Key configuration points:
SETTING VALUE NOTES
======= ===== =====
schedule "0 2 * * *" Daily at 2 AM UTC
concurrencyPolicy Forbid Prevent overlapping jobs
failedJobsHistory 3 Keep last 3 failed jobs
successfulJobsHistory 3 Keep last 3 successful jobs
restartPolicy OnFailure Retry on transient errors
PostgreSQL Deployment
# postgres-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres-replica
labels:
app: postgres-replica
spec:
replicas: 1
selector:
matchLabels:
app: postgres-replica
template:
metadata:
labels:
app: postgres-replica
spec:
containers:
- name: postgres
image: postgres:15
env:
- name: POSTGRES_DB
value: langfuse
- name: POSTGRES_USER
value: root
- name: POSTGRES_PASSWORD
value: testpassword123
ports:
- containerPort: 5432
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumes:
- name: postgres-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres-replica-service
spec:
type: ClusterIP
ports:
- port: 5432
targetPort: 5432
selector:
app: postgres-replica
Secrets Configuration
# local-backup-secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: rds-db-root-secret
namespace: default
type: Opaque
stringData:
db-password: testpassword123
---
apiVersion: v1
kind: Secret
metadata:
name: aws-credentials
namespace: default
type: Opaque
stringData:
aws-access-key-id: test
aws-secret-access-key: test
For production: Use external secrets management (AWS Secrets Manager, HashiCorp Vault, or Kubernetes External Secrets Operator).
LocalStack Setup
LocalStack simulates AWS S3 for local testing:
# docker-compose.yml
services:
localstack:
container_name: localstack
image: localstack/localstack:3.0
ports:
- "4566:4566"
- "4510-4559:4510-4559"
environment:
- DEBUG=1
- SERVICES=s3,iam,sts
- DOCKER_HOST=unix:///var/run/docker.sock
volumes:
- "/tmp/localstack:/var/lib/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"
S3 bucket setup script:
#!/bin/bash
# setup-localstack.sh
set -euo pipefail
echo "Setting up LocalStack S3..."
# Wait for LocalStack
while ! curl -s http://localhost:4566/health > /dev/null; do
sleep 2
done
# Configure credentials (fake for LocalStack)
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
# Create bucket
awslocal s3 mb s3://rds-db-backups-co-create
# Add lifecycle policy (move to Glacier after 30 days, delete after 365)
cat > lifecycle-policy.json << EOF
{
"Rules": [
{
"ID": "move-to-glacier",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"Transitions": [
{ "Days": 30, "StorageClass": "GLACIER" }
],
"Expiration": { "Days": 365 }
}
]
}
EOF
awslocal s3api put-bucket-lifecycle-configuration \
--bucket rds-db-backups-co-create \
--lifecycle-configuration file://lifecycle-policy.json
echo "LocalStack S3 setup complete!"
KIND Cluster Configuration
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: backup-test
nodes:
- role: control-plane
image: kindest/node:v1.28.0
extraPortMappings:
- containerPort: 30080
hostPort: 8080
protocol: TCP
- containerPort: 30432
hostPort: 5432
protocol: TCP
Backup Verification
Never trust a backup you haven’t tested. The verification script:
#!/bin/bash
# verify-backup.sh
set -euo pipefail
echo "Verifying backup integrity..."
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
# List available backups
echo "Available backups:"
awslocal s3 ls s3://rds-db-backups-co-create/ --recursive --human-readable
# Get latest backup
LATEST_BACKUP=$(awslocal s3 ls s3://rds-db-backups-co-create/ --recursive \
| sort | tail -n 1 | awk '{print $4}')
if [ -z "$LATEST_BACKUP" ]; then
echo "ERROR: No backups found!"
exit 1
fi
# Download and restore
echo "Downloading: $LATEST_BACKUP"
awslocal s3 cp s3://rds-db-backups-co-create/$LATEST_BACKUP ./test-restore.dump
echo "Testing restore..."
kubectl exec deployment/postgres-replica -- dropdb -U root test_restore --if-exists
kubectl exec deployment/postgres-replica -- createdb -U root test_restore
kubectl exec -i deployment/postgres-replica -- pg_restore \
-U root \
-d test_restore \
--verbose \
--clean \
--if-exists < ./test-restore.dump
# Verify data
echo "Verifying restored data..."
kubectl exec deployment/postgres-replica -- psql -U root -d test_restore -c "
SELECT 'Records restored:' as status, count(*) as count FROM test_backup;
"
echo "========================================"
echo "BACKUP VERIFICATION COMPLETE"
echo "========================================"
echo " Streaming backup: SUCCESSFUL"
echo " S3 upload: SUCCESSFUL"
echo " Data integrity: VERIFIED"
echo " Restore process: WORKING"
echo "========================================"
Testing Commands
# LocalStack configuration (fake credentials)
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
# List all backups with sizes
awslocal s3 ls s3://rds-db-backups-co-create/ --recursive --human-readable
# List backup folders only
awslocal s3 ls s3://rds-db-backups-co-create/
# Check specific backup (replace timestamp)
awslocal s3 ls s3://rds-db-backups-co-create/2025-09-02-09-10/ --human-readable
# Get file metadata
awslocal s3api head-object \
--bucket rds-db-backups-co-create \
--key "2025-09-02-09-10/langfuse_backup.dump"
# Test database connectivity
make test-db
# Test S3 connectivity
make test-s3
# View latest job logs
make logs
# Follow running job logs
make logs-follow
# Check environment status
make status
Production Considerations
1. Use Read Replicas
APPROACH BENEFIT
======== =======
Backup from replica No impact on primary performance
Dedicated backup user Minimal privileges required
Connection pooling Handle connection limits
2. Streaming to S3
For large databases, stream directly without local disk:
pg_dump -h $DB_HOST -U $USER -d $DATABASE \
--format=custom \
--blobs \
| aws s3 cp - s3://bucket/path/backup.dump
3. Encryption
# Encrypt with GPG before upload
pg_dump ... | gpg --encrypt --recipient backup@company.com \
| aws s3 cp - s3://bucket/backup.dump.gpg
# Or use S3 server-side encryption
aws s3 cp backup.dump s3://bucket/backup.dump \
--sse aws:kms \
--sse-kms-key-id alias/backup-key
4. Monitoring
# Add Prometheus metrics
- name: backup-exporter
image: backup-exporter:latest
ports:
- containerPort: 9090
5. Alerting
# Alert on failed jobs
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: backup-alerts
spec:
groups:
- name: backup
rules:
- alert: BackupJobFailed
expr: kube_job_status_failed{job_name=~"rds-backup.*"} > 0
for: 5m
Cleanup
# Remove all lab resources
make cleanup
# This will:
# - Stop LocalStack container
# - Delete KIND cluster
# - Remove temporary files
Troubleshooting
CronJob not running:
kubectl get cronjobs
kubectl describe cronjob rds-backup-cronjob
Pod failing:
kubectl get pods
kubectl logs <pod-name>
kubectl describe pod <pod-name>
S3 upload failing:
# Test LocalStack connectivity
curl http://localhost:4566/health
awslocal s3 ls
Database connection failing:
kubectl exec deployment/postgres-replica -- \
pg_isready -h localhost -p 5432 -U root
Repository
Full source code: https://github.com/moabukar/db-backup-s3
========================================
PostgreSQL --> K8s CronJob --> S3
========================================
Automated. Verified. Production-ready.
========================================