Docker Compose in Production: A Full Stack in Four Services
Docker Compose in Production
"Docker Compose is for development" is one of those statements that sounds confident and is quietly wrong. For a single-server deployment, Compose is a legitimate production tool, provided you treat it with the same seriousness as a full orchestration platform.
Here is the production stack I run for a full web application: an app container, a database, an nginx reverse proxy, and a certbot sidecar. Four services, one docker-compose.yml, zero wasted infrastructure.
The Anatomy of the Stack
services:
app:
image: ghcr.io/example/app:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
volumes:
- ./public/upload:/app/public/upload
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./production/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
depends_on:
app:
condition: service_healthy
certbot:
image: certbot/certbot
profiles: ['certbot']
volumes:
pgdata:
Healthchecks: The Missing Dependency Logic
The depends_on block with condition: service_healthy is the difference between a fragile stack and a reliable one. The app refuses to start until Postgres is actually accepting connections, not just "running". The database gets its own healthcheck:
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app']
interval: 10s
timeout: 5s
retries: 5
And the app advertises readiness through an API endpoint. nginx waits for the app, the app waits for the database, a proper dependency chain instead of startup races.
Networks and Volumes
The stack runs on a private bridge network, so containers talk to each other by name and nothing is exposed to the host network except nginx on ports 80 and 443. The database persists in a named volume, while user uploads live in a bind mount the proxy can also serve directly, the container user's UID (1001) matching the host directory permissions avoids the classic "permission denied" dance.
TLS Without the Pain
Certificates are handled by a certbot sidecar that shares two volumes with nginx: the challenge directory and the Let's Encrypt config. Because certbot runs as a separate service, renewals are just docker compose run certbot renew, the proxy picks up the new certificates without reinstalling anything.
The Deploy Flow
Deployment follows a strict order: pull the new image, run database migrations with docker compose run --rm app npm run db:migrate, then restart the app with up -d --no-deps. Old images get pruned after every deploy so the server never fills up with dead weight.
Conclusion
Docker Compose in production works because it encodes the whole stack in one reviewable file. Healthchecks give it real orchestration behavior, named volumes make data survive restarts, and a certbot sidecar keeps TLS automatic.
The stack is boring by design, and that is exactly what production should be.