Back to blog
Software Architecture

Docker Compose in Production: A Full Stack in Four Services

Abdeldjalile Lafkir2026-06-123 min read

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.