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.
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.
Database backups get the same treatment — defined once as another versioned service, not a bespoke script living outside the stack — covered separately in a companion piece on turning Postgres backups into code.
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 |
- 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 and a loopback-only API.
- All without a single line of shared configuration duplicated between them.