The problem: keeping dev and prod consistent
The real risk isn't that development and production run on different machines — it's that their infrastructure quietly stops being the same thing. A Postgres version gets bumped in one place and not the other, a healthcheck interval gets tuned in dev and never carried over, and nobody notices until a bug only reproduces in one environment. Docker Compose already solves consistency at the container level; the missing piece is making sure the definitions stay consistent too, instead of hand-maintaining near-duplicate files that are free to drift apart.
Backups fall out of the same idea. If the backup routine is just another service in the same compose file, versioned and reviewed the same way as everything else, restoring or auditing it doesn't require separate infrastructure or a bespoke script living outside the stack — running a backup is the same docker compose command as running anything else.
Development and production need the same underlying engines — the same database, the same cache — but almost nothing else the same:
- Development needs the database reachable directly from a laptop while application code runs natively, with hot reload and a debugger attached.
- Production needs the application fully containerized, tightly locked down, network-isolated, and backed up on a schedule.
Writing two fully separate compose files — one per environment — guarantees drift: a Postgres version gets bumped in dev and forgotten in prod. Three months later, "works in dev" and "works in prod" are quietly different claims.
How Compose file layering works
Docker Compose solves this natively with the -f flag. When multiple files are passed, Docker merges them key by key, in the order listed — a later file can add a new service, add a key to an existing one, or override a value, without restating everything that didn't change. The strategy follows directly from that mechanism: one base file holds what every environment agrees on, and a small override file per environment holds only the delta.
Here, the base file (docker-compose.yml) defines exactly two services — Postgres and Redis — pinned to specific patch versions, on a shared bridge network, each with a healthcheck. It contains no application code and exposes no host ports:
services:
db:
image: postgres:15.13-alpine3.22
container_name: db
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
backend_network:
aliases:
- db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 20s
timeout: 5s
retries: 3
start_period: 20s
redis:
image: redis:7.4.9-alpine3.21
container_name: redis
restart: unless-stopped
volumes:
- redis_data:/data
networks:
backend_network:
aliases:
- redis
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 20s
timeout: 5s
retries: 3
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
This file is pure, immutable infrastructure. It stays identical no matter where or how the stack is running.
The dev override: native code, containerized dependencies
In development the application itself is never containerized — rebuilding an image on every minor code change ruins the feedback loop. Instead the app runs natively on the host, and Docker is used only for its dependencies. The dev override adds exactly two things to the two existing services: an env file, and host ports so tooling running directly on the laptop (a database client, the app's own connection string) can reach the containers:
# docker-compose.dev.yml
services:
db:
env_file:
- ../env/dev.env
ports:
- "5432:5432"
redis:
env_file:
- ../env/dev.env
command: ["redis-server", "--appendonly", "yes", "--requirepass", "devpassword"]
ports:
- "6379:6379"
Notice what isn't here: no API service, no frontend service. Containers handle what should behave identically everywhere — the database and cache; native execution handles what changes several times an hour — the application code. Bringing dev up is two files and two services, nothing else running:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db redis
The production override: the full stack and its security details
Production merges over the same base file, but its override is doing more work: it points db at a different env file, and it adds the services dev doesn't need at all — the API, the frontend, and a backup job:
# docker-compose.prd.yml
services:
db:
env_file:
- ../env/prd.env
api:
build:
context: ../
dockerfile: infra/docker/backend.Dockerfile
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
env_file:
- ../env/prd.env
ports:
- "127.0.0.1:8000:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend_network
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
interval: 20s
timeout: 5s
retries: 3
frontend:
build:
context: ../
dockerfile: infra/docker/frontend.Dockerfile
volumes:
- /var/www/frontend:/usr/share/nginx/html
env_file:
- ../env/prd.env
- Loopback binding (
127.0.0.1:8000:8000) — a security decision disguised as a port binding. The API is never exposed publicly; it's bound exclusively to the host's loopback interface, so only an on-host reverse proxy can reach it directly — in our case, nginx. - Decoupled frontend assets — the frontend service doesn't serve anything itself. It builds static assets straight into a volume the edge server reads from, with no runtime container in the request path.
One-shot tasks: Compose profiles for backups
Database backups need to be version-controlled, tested, and reviewed alongside the rest of the infrastructure — but they shouldn't run continuously as part of the core stack. A Compose profile solves this cleanly: the service is defined in the file like everything else, but it doesn't start on a plain up:
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"]
It only runs when explicitly requested by cron or a manual command:
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 tested like the rest of the stack, but shouldn't be part of the steady-state footprint of every deploy — one-shot jobs, backups, migrations.
The image contains code, not configuration — promoting a build is re-pointing the same containers at a different env file, not rebuilding them.
How the backup itself works
The backup_postgres service runs a disposable Postgres container — same image and version as db, so pg_dump is always talking to itself across matching client/server versions — that reaches db over the internal network, dumps in custom format, compresses it, and ships it out:
- Custom format, not plain SQL —
pg_dump -F cproduces Postgres's own compressed archive format, whichpg_restorecan replay selectively (one table, one schema) instead of forcing an all-or-nothing restore of a giant SQL file. - 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 is on the same internal network as
db, so the dump never crosses a public network boundary before it's compressed and encrypted in transit to object storage. - 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.
Restoring is the same idea in reverse: pull the dated object back from storage and run pg_restore against a target database — routine enough, given the format, that it doesn't need its own bespoke tooling beyond what shipped the dump in the first place.
Why not just one file with everything in it
A single compose file with conditionals or duplicated service blocks per environment was the naive alternative, and it was rejected for the same reason two full copies would be: nothing forces the shared parts — the Postgres version, the network name, the healthcheck intervals — to actually stay identical once environment-specific logic is interleaved with them. Splitting into a base file plus overrides makes that impossible to get wrong by construction:
- Infrastructure parity — bumping the Postgres or Redis version in
docker-compose.ymlupdates both dev and prod on their next deploy, since the database definition exists in exactly one place. - Clean secret separation — the compose files live in version control and contain no secrets at all; every credential is referenced through
env_file, never written into the file itself. In dev that's an uncommitteddev.envon disk, which is fine for a laptop only one person touches. Production currently runs the same way: this is a single VPS, so today that means SSH-ing in and hand-editingprd.envdirectly on the box — a manual step that works, but is also the one part of this setup that doesn't get the audit trail or review the rest of the stack gets. The target shape is CI/CD writing that env file at deploy time, from secrets and variables held by the git provider, so production credentials never sit on disk between deploys, never get hand-edited over SSH, and never appear in a diff. That's a known gap, not an oversight — worth naming even though the manual version is what's actually running today. - Artifact promotion — the image itself never changes between environments; promoting a build is pointing the same containers at a different env file, not rebuilding.
The outcome
A deploy is the same handful of commands regardless of environment — the difference is only which -f flags and which env file are passed:
| Environment | Files | Command | Purpose |
|---|---|---|---|
| Development | docker-compose.yml + .dev.yml |
up -d db redis |
Fast feedback, exposed host ports, native app debugging |
| Production | docker-compose.yml + .prd.yml |
up -d |
Locked-down network, containerized app, reverse-proxy in front |
| Maintenance | docker-compose.yml + .prd.yml |
--profile backup run backup_postgres |
On-demand database dump to object storage |
Dev gets a database and cache it can hit from a laptop with a debugger attached to native code; production gets the exact same infrastructure plus the containerized app, a loopback-only API, and a backup job that only runs when asked — all without a single line of shared configuration duplicated between them.