The problem
A backup routine that lives outside the rest of the infrastructure — a cron entry on the host, a script nobody reviews, credentials typed into a shell once and forgotten — doesn't get the things version-controlled infrastructure gets for free: a diff when it changes, a review before it ships, and a single place to read to know exactly what it does. If the backup is just another service definition in the same Compose stack described in a companion piece on Docker Compose from dev to production, running or auditing it is the same docker compose command as running anything else in that stack, not a separate piece of infrastructure with its own rules.
The constraint that shapes the rest of this is that a backup job shouldn't be part of the stack's steady-state footprint. It has no business starting on every docker compose up, holding a connection open, or running as a long-lived process at all — it needs to exist, be reviewable, and run on demand or on a schedule, and otherwise not be there.
Compose profiles: defined once, not always running
A Compose profile is the primitive for exactly that: the service is defined in the file like everything else, but it doesn't start on a plain up. It only runs when a profile is explicitly requested:
backup_postgres:
image: postgres:15.13-alpine3.22
profiles: ["backup"]
env_file:
- ../env/prd.env
depends_on:
db:
condition: service_healthy
environment:
DAY: ${DAY}
MONTH: ${MONTH}
YEAR: ${YEAR}
entrypoint: ["/bin/sh", "-c", "
apk add --no-cache aws-cli &&
pg_dump -h db -U $$POSTGRES_USER -F c $$POSTGRES_DB | gzip > /backups/$$POSTGRES_DB_$$YEAR-$$MONTH-$$DAY.dump.gz &&
aws s3 cp /backups/$$POSTGRES_DB_$$YEAR-$$MONTH-$$DAY.dump.gz s3://$${AWS_S3_BUCKET_NAME}/postgres/$$YEAR/$$MONTH/$${DAY}/$$POSTGRES_DB.dump.gz"]
Triggering it, whether from cron or by hand, is one command against the same files and env layering the rest of the stack already uses:
docker compose -f docker-compose.yml -f docker-compose.prd.yml --profile backup run backup_postgres
That's the right primitive for anything that should be defined once and reviewed like the rest of the stack, but shouldn't be part of every deploy's steady-state footprint — one-shot jobs, backups, migrations all fit the same shape.
A profile is infrastructure that exists on paper all the time and as a running process almost none of the time — which is exactly what a backup job should be.
How the backup itself works
The backup_postgres service runs a disposable Postgres container that reaches db over the internal network, dumps in custom format, compresses it, and ships it out:
- Same image as the database — the backup container uses the same PostgreSQL image and version as
db, which keeps thepg_dumpclient version aligned with the server version rather than leaving that to whatever Postgres client happens to be installed wherever the job runs. - Custom format, not plain SQL —
pg_dump -F cproduces Postgres's own compressed archive format, designed to be restored selectively (one table, one schema) rather than only as an all-or-nothing SQL script. - Date-partitioned object keys —
$YEAR/$MONTH/$DAY/in the S3 key means listing or expiring backups by age is a prefix operation, not something that requires parsing filenames. - Runs where the data already is — the container sits on the same internal network as
db, so the dump never crosses a public network boundary while it's being produced;pg_dump -F calready compresses it before it ever leaves the container, and the separateaws s3 cptransfer to object storage is encrypted in transit over TLS — two different mechanisms doing two different jobs, not one. - Disposable by design — the container installs its own tooling (
aws-cli) on start and exits when the dump finishes; there's no long-running backup agent to patch or babysit.
The image contains code, not configuration — the same profile definition, pointed at a different env file, is what makes this job identical in dev, staging, or production; only which bucket and which database it talks to changes.
The outcome — and the part that isn't finished
What exists today is a backup that's version-controlled, reviewable, and runs identically wherever the stack runs: one Compose profile, one image, one command, a dated object landing in S3 on schedule. That's a real improvement over a bespoke script living outside the rest of the infrastructure, and it's honest to say so.
What doesn't exist yet is a tested restore path. The dump lands in S3 and is trusted to be usable, but that trust has never actually been exercised end-to-end — nobody has pulled one of these archives back down and restored it into a database to confirm it works. That's the same kind of gap as the manual prd.env editing named in the companion piece: a known, named limitation of what's running today rather than something quietly assumed to be fine. A backup that has never been restored is a belief, not a guarantee, and closing that gap — proving the restore path works, not just the backup path — is the next real piece of work here, not an afterthought.