Automated PostgreSQL backups with rolling retention. Stores backups on a local volume or a remote server via rsync. Optional age encryption and Slack notifications. Runs headless in Docker.
Retention tiers:
hourly (keep 6) → 6h (keep 4) → daily (keep 28)
Each tier promotes its oldest file to the next when full. Daily files are permanently deleted after 28 days (4 weeks).
The image ships pg_dump for every active PostgreSQL major version (13–17). At runtime, postback uses psql to ask the server its own version and invokes the matching /usr/lib/postgresql/<major>/bin/pg_dump — no version mismatch possible for any supported server.
When PostgreSQL 18 (or later) is released, add one line to the Dockerfile and rebuild:
postgresql-client-18 \- Docker + Docker Compose
- A PostgreSQL database reachable from your server
- Local backend: a directory or Docker volume to mount into the container
- Rsync backend: a remote Linux server you can SSH into
- A Slack workspace (optional, for notifications)
No extra infrastructure needed. postback writes directly to a directory inside the container; you mount a host path or Docker volume there.
STORAGE_BACKEND=local
LOCAL_BACKUP_PATH=./backups # host path mounted as /backup inside the containerThe three tier directories (hourly/, 6h/, daily/) are created automatically on first run.
To use a named Docker volume instead of a host path, edit docker-compose.yml:
volumes:
- postback_data:/backupSet STORAGE_BACKEND=rsync and follow the steps below.
Postback rsyncs backups over SSH using a dedicated key pair. No password — keys only.
# On the backup server
sudo useradd -m -s /bin/bash backups
sudo mkdir -p /var/backups/postgres
sudo chown backups:backups /var/backups/postgresRun this on the machine where you'll run postback (or locally, then copy):
ssh-keygen -t ed25519 -f ~/.ssh/postback_key -C "postback" -N ""This creates:
~/.ssh/postback_key— private key (goes into postback's container)~/.ssh/postback_key.pub— public key (goes onto the backup server)
# On the backup server
sudo -u backups mkdir -p /home/backups/.ssh
sudo -u backups tee -a /home/backups/.ssh/authorized_keys < ~/.ssh/postback_key.pub
sudo chmod 700 /home/backups/.ssh
sudo chmod 600 /home/backups/.ssh/authorized_keysssh -i ~/.ssh/postback_key backups@your-backup-server.com echo okYou should see ok with no password prompt.
sudo mkdir -p /etc/postback
sudo cp ~/.ssh/postback_key /etc/postback/rsync_key
sudo chmod 600 /etc/postback/rsync_keyBackups can be encrypted before upload using age. Asymmetric encryption means:
- The public key encrypts — safe to store anywhere, including the container config
- The private key decrypts — kept offline or in a vault, never touches the backup server
- A compromised backup server reveals nothing
Install age locally (releases) then:
age-keygen -o postback_age_key.txtOutput looks like:
# created: 2026-05-24T10:00:00Z
# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ
- Put the public key (
age1...) inENCRYPTION_PUBLIC_KEYin your.env - Store
postback_age_key.txt(the private key) somewhere safe and offline
# Decrypt and pipe directly into psql
age -d -i postback_age_key.txt hourly_2026-05-24T10-00-00Z.sql.gz.age \
| gunzip \
| psql -h db.example.com -U postgres -d mydb
# Or decrypt to a file first
age -d -i postback_age_key.txt hourly_2026-05-24T10-00-00Z.sql.gz.age \
> hourly_2026-05-24T10-00-00Z.sql.gz
gunzip hourly_2026-05-24T10-00-00Z.sql.gz
psql -h db.example.com -U postgres -d mydb < hourly_2026-05-24T10-00-00Z.sqlEncrypted files are stored with a .age extension (hourly_TIMESTAMP.sql.gz.age). Unencrypted files keep the .sql.gz extension. Both work with the same retention logic.
Skip this section if you don't want Slack notifications.
- Go to api.slack.com/apps
- Click Create New App → From scratch
- Name it (e.g.
postback) and pick your workspace, then click Create App
- In the left sidebar click OAuth & Permissions
- Scroll down to Scopes → Bot Token Scopes
- Click Add an OAuth Scope and add:
chat:write - If you want to post to channels the bot hasn't been invited to, also add:
chat:write.public
- Scroll back up on the OAuth & Permissions page
- Click Install to Workspace and allow it
- Copy the Bot User OAuth Token — it starts with
xoxb-
- For a public channel: use the channel name directly, e.g.
#db-alerts - For a private channel: open it in Slack, click the channel name at the top, scroll to the bottom of the popup — you'll see the Channel ID (e.g.
C1234567890) - If using a private channel, invite the bot first:
/invite @postback
Copy the example env file and fill in your values:
cp .env.example .envEdit .env:
# PostgreSQL — your external database
PGHOST=db.example.com
PGPORT=5432
PGDATABASE=mydb
PGUSER=postgres
PGPASSWORD=your_password
# rsync destination
RSYNC_HOST=backups.example.com
RSYNC_USER=backups
RSYNC_PATH=/var/backups/postgres
RSYNC_PORT=22
# Absolute path to the SSH private key on your host machine
RSYNC_SSH_KEY_PATH=/etc/postback/rsync_key
# Slack (optional)
SLACK_BOT_TOKEN=xoxb-...
SLACK_CHANNEL=#db-alerts
SLACK_NOTIFY_ON=success,failure| Variable | Required | Default | Description |
|---|---|---|---|
PGHOST |
Yes | — | PostgreSQL host |
PGPORT |
No | 5432 |
PostgreSQL port |
PGDATABASE |
Yes | — | Database name |
PGUSER |
Yes | — | Database user |
PGPASSWORD |
Yes | — | Database password |
RSYNC_HOST |
Yes | — | Backup server hostname or IP |
RSYNC_USER |
Yes | — | SSH user on the backup server |
RSYNC_PATH |
Yes | — | Remote path to store backups |
RSYNC_PORT |
No | 22 |
SSH port on the backup server |
RSYNC_SSH_KEY_PATH |
Yes | — | Host path to the SSH private key |
ENCRYPTION_PUBLIC_KEY |
No | — | age public key (age1...). If set, backups are encrypted before upload |
KEEP_HOURLY |
No | 6 |
Number of hourly backups to keep |
KEEP_6H |
No | 4 |
Number of 6-hour backups to keep |
KEEP_DAILY |
No | 28 |
Number of daily backups to keep |
SLACK_BOT_TOKEN |
No | — | Slack bot token (xoxb-...) |
SLACK_CHANNEL |
No | — | Slack channel name or ID |
SLACK_NOTIFY_ON |
No | success,failure |
Comma-separated list of events to notify on |
RUST_LOG |
No | info |
Log level (info or debug) |
SLACK_NOTIFY_ON events:
| Value | When it fires |
|---|---|
start |
When the backup run begins |
auth |
After SSH connection to backup server is established |
success |
After the dump is rsynced successfully |
failure |
If the dump or upload fails |
retention |
When files are promoted between tiers or deleted |
docker compose --env-file .env up -d --builddocker compose logs -f postbackdocker compose downdocker compose --env-file .env up -d --build --force-recreatedocker compose --env-file .env run --rm postback \
--db-host "$PGHOST" \
--db-port "$PGPORT" \
--db-name "$PGDATABASE" \
--db-user "$PGUSER" \
--rsync-host "$RSYNC_HOST" \
--rsync-user "$RSYNC_USER" \
--rsync-path "$RSYNC_PATH"Every GitHub release automatically builds a multi-platform image (linux/amd64 + linux/arm64) and pushes it to the GitHub Container Registry (ghcr.io). No secrets to configure — uses the built-in GITHUB_TOKEN.
git tag v1.0.0
git push origin v1.0.0Then go to your GitHub repo → Releases → Draft a new release, choose the tag, and click Publish release.
The workflow produces three tags:
| Tag | Example |
|---|---|
| Full version | ghcr.io/regisrex/postback:1.0.0 |
| Minor version | ghcr.io/regisrex/postback:1.0 |
| Latest | ghcr.io/regisrex/postback:latest |
Replace the build: block in docker-compose.yml with the pre-built image:
services:
postback:
image: ghcr.io/regisrex/postback:latest
restart: unless-stopped
# ... rest unchangedThen on any server:
docker compose --env-file /etc/postback/production.env pull
docker compose --env-file /etc/postback/production.env up -dPostback creates the tier directories on the remote server automatically:
/var/backups/postgres/
├── hourly/ ← new backups land here
├── 6h/ ← promoted from hourly
└── daily/ ← promoted from 6h, deleted after 28 days
Backup files are named by timestamp: hourly_2026-05-24T10-00-00Z.sql.gz
postback handles restore as a first-class subcommand — the same binary that backs up can restore.
See what's available before deciding what to restore:
docker compose --env-file .env run --rm postback listOutput:
hourly/ (6 files)
────────────────────────────────────────────────────────────
hourly_2026-05-24T10-00-00Z.sql.gz 12.4 MB ← newest
hourly_2026-05-24T09-00-00Z.sql.gz 12.3 MB
...
6h/ (4 files)
────────────────────────────────────────────────────────────
hourly_2026-05-24T04-00-00Z.sql.gz 12.1 MB
daily/ (3 files)
────────────────────────────────────────────────────────────
hourly_2026-05-23T22-00-00Z.sql.gz 11.9 MB
# Restore latest from hourly tier into the configured PGDATABASE
docker compose --env-file .env run --rm postback restore
# Restore latest from the daily tier
docker compose --env-file .env run --rm postback restore --tier daily
# Restore into a different database
docker compose --env-file .env run --rm postback restore --target-db mydb_recoveredPick the exact filename from postback list and pass it with --file:
docker compose --env-file .env run --rm postback restore \
--tier daily \
--file hourly_2026-05-23T22-00-00Z.sql.gz \
--target-db mydb_recoveredThis is the full recovery workflow for a specific point in time:
# 1. See what's available
docker compose --env-file .env run --rm postback list
# 2. Create a fresh target database
docker compose --env-file .env run --rm postback restore \
--tier daily \
--file hourly_2026-05-23T10-00-00Z.sql.gz \
--target-db mydb_2026_05_23Mount your age private key into the container and pass it with --private-key:
docker compose --env-file .env run --rm \
-v /path/to/age_private_key.txt:/secrets/age_key.txt:ro \
postback restore \
--tier hourly \
--private-key /secrets/age_key.txt
# Restore a specific encrypted version
docker compose --env-file .env run --rm \
-v /path/to/age_private_key.txt:/secrets/age_key.txt:ro \
postback restore \
--tier daily \
--file hourly_2026-05-23T22-00-00Z.sql.gz.age \
--private-key /secrets/age_key.txt \
--target-db mydb_recoveredOr set AGE_PRIVATE_KEY_PATH in your .env so you don't have to pass it every time.
| Goal | Command |
|---|---|
| See all backups | postback list |
| Restore latest hourly | postback restore |
| Restore latest daily | postback restore --tier daily |
| Restore specific file | postback restore --tier daily --file <name> |
| Restore to different DB | postback restore --target-db mydb_recovered |
| Restore encrypted backup | postback restore --private-key /secrets/age_key.txt |
After the first run, check the logs:
docker compose logs postbackExpected output:
=== postback starting ===
DB: postgres@db.example.com:5432/mydb
Remote: backups@backups.example.com:/var/backups/postgres
Remote dirs ready: /var/backups/postgres/{hourly,6h,daily}
Server is PostgreSQL 17 — using /usr/lib/postgresql/17/bin/pg_dump
Dumping database `mydb` → /tmp/postback/hourly_2026-05-24T10-00-00Z.sql.gz
Dump complete — 12.3 MB
Uploading hourly_2026-05-24T10-00-00Z.sql.gz to /var/backups/postgres/hourly/ …
=== postback complete ===
SSH onto the backup server and confirm:
ls -lh /var/backups/postgres/hourly/Use docker-compose.prod.yml on your server — it pulls the pre-built image from ghcr.io instead of building from source.
curl -fsSL https://get.docker.com | sh
sudo apt-get install docker-compose-pluginmkdir -p /opt/postback && cd /opt/postback
# Download the production compose file
curl -fsSL https://raw.githubusercontent.com/regisrex/postback/main/docker-compose.prod.yml \
-o docker-compose.prod.yml
# Create your env file
curl -fsSL https://raw.githubusercontent.com/regisrex/postback/main/.env.example -o .env
# edit .env with your valuesLocal backend — create the backup directory:
sudo mkdir -p /var/backups/postbackSet in .env:
STORAGE_BACKEND=local
LOCAL_BACKUP_PATH=/var/backups/postbackRsync backend — place your SSH private key:
sudo mkdir -p /etc/postback
sudo cp /path/to/rsync_key /etc/postback/rsync_key
sudo chmod 600 /etc/postback/rsync_keySet in .env:
STORAGE_BACKEND=rsync
RSYNC_SSH_KEY_PATH=/etc/postback/rsync_keydocker compose -f docker-compose.prod.yml --env-file .env pull
docker compose -f docker-compose.prod.yml --env-file .env up -ddocker compose -f docker-compose.prod.yml --env-file .env logs -f# List all available backups
docker compose -f docker-compose.prod.yml --env-file .env run --rm postback list
# Restore latest hourly backup
docker compose -f docker-compose.prod.yml --env-file .env run --rm postback restore
# Restore latest daily backup
docker compose -f docker-compose.prod.yml --env-file .env run --rm postback restore --tier daily
# Restore a specific file into a recovery database
docker compose -f docker-compose.prod.yml --env-file .env run --rm postback restore \
--tier daily \
--file hourly_2026-05-23T22-00-00Z.sql.gz \
--target-db mydb_recovereddocker compose -f docker-compose.prod.yml --env-file .env pull
docker compose -f docker-compose.prod.yml --env-file .env up -drestart: unless-stopped ensures the container comes back up after a reboot automatically.