Automated PostgreSQL backups with encrypted R2/Drive storage and Slack alerts.
28
A reusable Docker container for automated PostgreSQL database backups.
This image creates validated PostgreSQL custom-format backups using pg_dump -Fc, keeps local copies with configurable retention, encrypts cloud backups using rclone crypt, uploads them to Cloudflare R2 and/or Google Drive, and optionally sends Slack success/failure notifications.
The image is designed to be reusable across multiple projects and environments.
All project-specific configuration is supplied at runtime using environment variables. No project credentials, database passwords, cloud keys, Slack webhooks, or encryption passwords are baked into the Docker image.
The backup flow is:
PostgreSQL
|
| pg_dump -Fc
v
Validated .backup file
|
+---------------------+
| |
v v
Local Storage Encrypted Cloud Backup
|
+---------+---------+
| |
v v
Cloudflare R2 Google Drive
optional optional
|
v
Slack notification
optional
Each backup run performs the following:
1. Check PostgreSQL connectivity
2. Create a custom-format PostgreSQL backup
3. Verify the backup is not empty
4. Validate the archive with pg_restore
5. Upload to enabled cloud destinations
6. Verify the uploaded files exist
7. Apply local retention policy
8. Calculate backup statistics
9. Send success/failure notification
pg_dump -Fcpg_restore -lrclone cryptrclone.conf generationrclone config requiredBackups are created using:
pg_dump -Fc
This produces PostgreSQL custom-format archives.
Example logical backup file:
mydatabase_2026-09-27_08-00-00.backup
The backup is validated using:
pg_restore -l
A valid PostgreSQL custom-format archive begins with:
PGDMP
Cloud backups are encrypted using rclone crypt.
The default recommended configuration keeps filenames readable while encrypting file content.
Example logical file:
mydatabase_2026-09-27_08-00-00.backup
The raw object stored in Cloudflare R2 or Google Drive may appear as:
mydatabase_2026-09-27_08-00-00.backup.bin
This .bin file is encrypted.
Do not rename:
file.backup.bin
to:
file.backup
and try to restore it directly.
Renaming does not decrypt the content.
The backup must be downloaded through the matching rclone crypt configuration.
Every backup is first written to local persistent storage.
Local retention can be configured using:
LOCAL_RETENTION_HOURS=48
For example:
48 hours
72 hours
168 hours
Cloudflare R2 can be enabled with:
ENABLE_R2_BACKUP=true
Example configuration:
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=my-database-backups
R2_ROOT_PATH=Production
R2_REGION=auto
R2_ENDPOINT=https://ACCOUNT_ID.r2.cloudflarestorage.com
The container automatically creates the required rclone S3-compatible configuration.
Google Drive is optional.
Enable it with:
ENABLE_GDRIVE_BACKUP=true
Disable it with:
ENABLE_GDRIVE_BACKUP=false
When enabled, a previously authorized rclone-compatible Google OAuth token must be supplied.
Example:
GDRIVE_ROOT_PATH=Database Backups/Production
GDRIVE_SCOPE=drive
GDRIVE_CLIENT_ID=...
GDRIVE_CLIENT_SECRET=...
GDRIVE_TOKEN=...
The container does not perform interactive browser authorization.
Slack notifications are optional.
Enable:
ENABLE_SLACK_NOTIFICATION=true
Disable:
ENABLE_SLACK_NOTIFICATION=false
Provide:
SLACK_WEBHOOK_URL=https://hooks.slack.com/...
A successful backup notification can contain:
Environment
Database
Server
Backup file
Backup size
Local copy count
Cloudflare R2 backup count
Cloudflare R2 storage usage
Google Drive backup count
Google Drive storage usage
Completion time
Failure notifications include:
Database
Server
Backup file
Failed line
Exit code
Failure time
The container uses its own timezone configuration.
Example:
TZ=Asia/Dhaka
BACKUP_CRON=0 8 * * *
This means:
Daily at 08:00 Bangladesh time
even if the Docker host uses UTC or another timezone.
Example schedules:
Daily at 02:00:
BACKUP_CRON=0 2 * * *
Daily at 08:00:
BACKUP_CRON=0 8 * * *
Daily at 23:30:
BACKUP_CRON=30 23 * * *
All project-specific settings are supplied using environment variables.
A typical .env file looks like:
# Docker
BACKUP_IMAGE=yourdockerhubusername/postgres-cloud-backup:1.0.0
BACKUP_CONTAINER_NAME=postgres-cloud-backup
BACKUP_RESTART_POLICY=unless-stopped
APP_NETWORK_NAME=your-project-network
BACKUP_HOST_PATH=/opt/postgres-backups
# Environment / Schedule
TZ=Asia/Dhaka
ENVIRONMENT=DEV
BACKUP_CRON=0 8 * * *
RUN_ON_STARTUP=false
SERVER_NAME_OVERRIDE=
# PostgreSQL
DB_HOST=postgres
DB_PORT=5432
DB_NAME=mydatabase
DB_USER=postgres
DB_PASSWORD=CHANGE_ME
# Local Backup
BACKUP_DIR=/backups/daily
LOCAL_RETENTION_HOURS=48
# Destinations
ENABLE_R2_BACKUP=true
ENABLE_GDRIVE_BACKUP=false
ENABLE_SLACK_NOTIFICATION=true
REMOTE_DAILY_DIR=daily
# Cloudflare R2
R2_REMOTE_NAME=r2
R2_CRYPT_REMOTE_NAME=r2-crypt
R2_ACCESS_KEY_ID=CHANGE_ME
R2_SECRET_ACCESS_KEY=CHANGE_ME
R2_BUCKET=my-database-backups
R2_ROOT_PATH=Dev
R2_REGION=auto
R2_ENDPOINT=https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com
# Google Drive - Optional
GDRIVE_REMOTE_NAME=gdrive
GDRIVE_CRYPT_REMOTE_NAME=gdrive-crypt
GDRIVE_ROOT_PATH=Database Backups/Dev
GDRIVE_SCOPE=drive
GDRIVE_CLIENT_ID=
GDRIVE_CLIENT_SECRET=
GDRIVE_TOKEN=
# Encryption
CRYPT_FILENAME_ENCRYPTION=off
CRYPT_DIRECTORY_NAME_ENCRYPTION=false
CRYPT_PASSWORD=CHANGE_ME
CRYPT_PASSWORD2=CHANGE_ME
# Slack
SLACK_WEBHOOK_URL=CHANGE_ME
# Internal
RCLONE_CONFIG_PATH=/config/rclone/rclone.conf
# Healthcheck
HEALTHCHECK_INTERVAL=30s
HEALTHCHECK_TIMEOUT=5s
HEALTHCHECK_RETRIES=3
services:
postgres-backup:
image: ${BACKUP_IMAGE}
container_name: ${BACKUP_CONTAINER_NAME}
restart: ${BACKUP_RESTART_POLICY}
environment:
TZ: ${TZ}
ENVIRONMENT: ${ENVIRONMENT}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
BACKUP_DIR: ${BACKUP_DIR}
BACKUP_CRON: ${BACKUP_CRON}
LOCAL_RETENTION_HOURS: ${LOCAL_RETENTION_HOURS}
RUN_ON_STARTUP: ${RUN_ON_STARTUP}
ENABLE_R2_BACKUP: ${ENABLE_R2_BACKUP}
ENABLE_GDRIVE_BACKUP: ${ENABLE_GDRIVE_BACKUP}
ENABLE_SLACK_NOTIFICATION: ${ENABLE_SLACK_NOTIFICATION}
REMOTE_DAILY_DIR: ${REMOTE_DAILY_DIR}
R2_REMOTE_NAME: ${R2_REMOTE_NAME}
R2_CRYPT_REMOTE_NAME: ${R2_CRYPT_REMOTE_NAME}
R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID}
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY}
R2_BUCKET: ${R2_BUCKET}
R2_ROOT_PATH: ${R2_ROOT_PATH}
R2_REGION: ${R2_REGION}
R2_ENDPOINT: ${R2_ENDPOINT}
GDRIVE_REMOTE_NAME: ${GDRIVE_REMOTE_NAME}
GDRIVE_CRYPT_REMOTE_NAME: ${GDRIVE_CRYPT_REMOTE_NAME}
GDRIVE_ROOT_PATH: ${GDRIVE_ROOT_PATH}
GDRIVE_SCOPE: ${GDRIVE_SCOPE}
GDRIVE_CLIENT_ID: ${GDRIVE_CLIENT_ID}
GDRIVE_CLIENT_SECRET: ${GDRIVE_CLIENT_SECRET}
GDRIVE_TOKEN: ${GDRIVE_TOKEN}
CRYPT_FILENAME_ENCRYPTION: ${CRYPT_FILENAME_ENCRYPTION}
CRYPT_DIRECTORY_NAME_ENCRYPTION: ${CRYPT_DIRECTORY_NAME_ENCRYPTION}
CRYPT_PASSWORD: ${CRYPT_PASSWORD}
CRYPT_PASSWORD2: ${CRYPT_PASSWORD2}
SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL}
RCLONE_CONFIG_PATH: ${RCLONE_CONFIG_PATH}
volumes:
- ${BACKUP_HOST_PATH}:${BACKUP_DIR}
networks:
- backup-network
healthcheck:
test: ["CMD", "/app/healthcheck.sh"]
interval: ${HEALTHCHECK_INTERVAL}
timeout: ${HEALTHCHECK_TIMEOUT}
retries: ${HEALTHCHECK_RETRIES}
networks:
backup-network:
name: ${APP_NETWORK_NAME}
external: true
The backup container connects directly to PostgreSQL over the Docker network.
Example:
DB_HOST=postgres
The PostgreSQL container and backup container must share the same Docker network.
No Docker socket mount is required.
This image does not need access to:
/var/run/docker.sock
Create:
docker-compose.yml
.env
Then:
docker compose config
Review the configuration.
Start:
docker compose up -d
Check:
docker compose ps
Logs:
docker logs -f postgres-cloud-backup
Run:
docker exec postgres-cloud-backup /app/backup.sh
A successful backup should show something similar to:
Starting PostgreSQL backup
Backup created
Backup validation successful
Uploading to Cloudflare R2
Cloudflare R2 verified
BACKUP COMPLETED SUCCESSFULLY
For initial testing:
RUN_ON_STARTUP=true
Then:
docker compose up -d --force-recreate
The container will run one backup immediately and then continue with the normal cron schedule.
For normal operation:
RUN_ON_STARTUP=false
Example host path:
BACKUP_HOST_PATH=/opt/postgres-backups
The container uses:
BACKUP_DIR=/backups/daily
Docker maps:
Host:
/opt/postgres-backups
Container:
/backups/daily
This means local backup files survive container replacement.
ls -lh /opt/postgres-backups
From inside the running container:
docker exec postgres-cloud-backup \
rclone ls r2-crypt:daily
If custom names are used, replace:
r2-crypt
daily
with your configured values.
The cloud object stored by rclone crypt is encrypted.
Example cloud file:
mydatabase_2026-09-27_08-00-00.backup.bin
To restore it, use another rclone crypt configuration containing the same:
CRYPT_PASSWORD
CRYPT_PASSWORD2
filename encryption setting
directory encryption setting
remote path
Then:
rclone copyto \
"r2-restore-crypt:daily/mydatabase_2026-09-27_08-00-00.backup" \
"./mydatabase_2026-09-27_08-00-00.backup"
Verify:
head -c 5 mydatabase_2026-09-27_08-00-00.backup
Expected:
PGDMP
Then restore using:
pg_restore
or pgAdmin.
Preserve these values securely:
CRYPT_PASSWORD
CRYPT_PASSWORD2
If these values are lost, encrypted historical backups may become impossible to decrypt.
For important systems, keep an independent recovery copy outside the backup server.
The Docker image contains no project-specific secrets.
Secrets such as:
DB_PASSWORD
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
GDRIVE_CLIENT_SECRET
GDRIVE_TOKEN
CRYPT_PASSWORD
CRYPT_PASSWORD2
SLACK_WEBHOOK_URL
must be supplied at runtime.
Never commit the real .env file.
Recommended .gitignore:
.env
*.env
!.env.example
Recommended .dockerignore:
.env
*.env
.git
secrets/
Direct environment variables are convenient and supported.
However, users with sufficient Docker/root access can potentially inspect runtime environment variables.
For higher-security deployments, use external secret management or Docker/Kubernetes secrets.
Edit:
.env
Then recreate:
docker compose up -d --force-recreate
The container automatically regenerates its rclone configuration on startup.
No manual:
rclone config
is required.
Change:
BACKUP_IMAGE=yourdockerhubusername/postgres-cloud-backup:1.1.0
Then:
docker compose pull
docker compose up -d
For production systems prefer:
yourdockerhubusername/postgres-cloud-backup:1.0.0
instead of relying only on:
latest
This makes deployments reproducible.
ENABLE_R2_BACKUP=true
ENABLE_GDRIVE_BACKUP=false
ENABLE_SLACK_NOTIFICATION=true
This is a simple recommended configuration for many projects.
ENABLE_R2_BACKUP=true
ENABLE_GDRIVE_BACKUP=true
ENABLE_SLACK_NOTIFICATION=true
This stores encrypted backups in both cloud providers.
The container includes a basic healthcheck that confirms the internal cron daemon is running.
Check:
docker inspect \
--format='{{json .State.Health}}' \
postgres-cloud-backup
Check:
docker network inspect YOUR_NETWORK
Confirm both containers are connected.
Then verify:
DB_HOST=postgres
DB_PORT=5432
Check:
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET
R2_ENDPOINT
Also confirm the token has access to the configured bucket.
A restricted R2 token may not have permission to list all account buckets.
This does not necessarily indicate a backup failure.
Use the configured specific bucket/crypt remote instead.
.backup.bin cannot be restoredExpected.
The .bin file is encrypted.
Do not rename it.
Decrypt it through rclone crypt.
Check:
ENABLE_SLACK_NOTIFICATION=true
and:
SLACK_WEBHOOK_URL=...
Then inspect:
docker logs postgres-cloud-backup
Check:
TZ=Asia/Dhaka
BACKUP_CRON=0 8 * * *
Then recreate the container:
docker compose up -d --force-recreate
After initial setup:
[ ] PostgreSQL connection succeeds
[ ] Manual backup succeeds
[ ] Local .backup exists
[ ] pg_restore validation succeeds
[ ] Cloudflare R2 upload succeeds
[ ] Cloud backup appears encrypted
[ ] Slack success message arrives
[ ] Cron timezone is correct
[ ] Scheduled backup runs successfully
[ ] Restore test succeeds
[ ] Crypt password is stored securely outside the server
Application Project
|
v
Docker Network
|
+------------------+------------------+
| |
v v
PostgreSQL Backup Container
|
pg_dump -Fc
|
v
.backup
|
+------------------------------+------------------+
| |
v v
Local Storage rclone crypt
|
+--------------+-------------+
| |
v v
Cloudflare R2 Google Drive
optional optional
|
v
Slack Alert
optional
This image is designed to remain generic.
The Docker image should contain only:
backup logic
backup tools
rclone
scheduler
notification logic
The image should not contain:
database names
database passwords
cloud credentials
bucket names
project names
Slack webhooks
encryption passwords
environment-specific schedules
Those belong in runtime configuration.
This makes the same image reusable for:
DEV
QA
Production
multiple applications
multiple customers
multiple PostgreSQL databases
different Cloudflare R2 buckets
different Google Drive accounts
without rebuilding the Docker image.
Content type
Image
Digest
sha256:212122ce6…
Size
164.9 MB
Last updated
about 1 month ago
docker pull miran2369/postgres-cloud-backup:1.0.0