Docker Compose
Docker Compose
A production application is almost never a single container. A typical web service runs at minimum three processes — a backend API, a relational database, and a cache — plus possibly a background worker, a message broker, and a reverse proxy. Running each with a bare docker run command means manually wiring networks, volumes, environment variables, and startup order. Docker Compose solves this by letting you declare the entire stack as a single YAML file and control it with one command.
What Compose Actually Does
Compose reads a docker-compose.yml (or compose.yaml — both are recognized) and translates it into a coordinated set of docker API calls: it creates a dedicated bridge network, pulls or builds each image, starts containers in dependency order, mounts volumes, and wires environment variables. Critically, it gives every service a DNS name matching its service key, so your API container reaches the database simply by hostname db — no IP management, no service discovery infrastructure needed for local development.
docker-compose Python binary (V1) is end-of-life. Modern Docker Desktop and Docker Engine ship docker compose (space, no hyphen) as a CLI plugin. Use docker compose in all new work; V1 syntax is largely compatible but the plugin is faster and maintained.
Anatomy of a Compose File
The following file defines a three-tier web stack: a Node.js API, a PostgreSQL database, and a Redis cache. Read every section carefully — each field maps to a docker run flag you already know.
Key design choices in this file:
depends_onwithcondition: service_healthy— without this, the API starts immediately after the container starts, not after PostgreSQL is actually ready to accept connections. That race condition causes startup failures in CI pipelines every single day.- Named volumes (
pg_data,redis_data) instead of bind mounts for database data — they survivedocker compose downbut are destroyed bydocker compose down -v. Never store database files in a bind mount; it creates file-permission mismatches between the host and container UID. - Explicit network — isolating services on a named network means they cannot reach containers in other Compose projects by accident, a real security boundary in shared environments.
Essential Commands
Most day-to-day Compose work uses a handful of commands:
docker compose down -v destroys all named volumes. Running this against a stack with a real database wipes all data permanently. Always confirm which environment you are in before adding -v. On CI this is fine; on a staging environment it is catastrophic.
Environment Files and Variable Substitution
Hard-coding secrets in compose.yaml is a bad practice even for local development. Compose automatically loads a .env file from the project directory, and you can reference its variables using ${VAR} syntax:
For multiple environments you can maintain separate override files and merge them at runtime:
compose.yaml— base definitions, committed to gitcompose.override.yaml— auto-merged by Compose when present; ideal for local dev extras like volume bind-mounts of source code and debug portscompose.ci.yaml— merge explicitly in CI withdocker compose -f compose.yaml -f compose.ci.yaml up -d
Profiles: Conditional Services
Profiles let you define services that are not started by default and only activated on demand. This is the clean solution to the "I only want the monitoring stack in CI" or "run the seeder only once" problem:
debug profile — they start on demand and are invisible otherwise. This pattern eliminates the "oops, I left Adminer exposed" class of security incident.
Local Dev Stacks: Bind Mounts for Hot Reload
For active development you almost always want your source code mounted into the container so the process sees file changes without rebuilding. Use compose.override.yaml for this so the bind mount never reaches CI or production:
The /app/node_modules anonymous volume is a well-known pattern: it prevents the host's node_modules (built for macOS/Windows) from shadowing the container's (built for Linux), which causes native binary failures. The same pattern applies to Python's __pycache__, Ruby's bundle, and Go's module cache.
Production Considerations
Compose is excellent for local development and small-scale deployments (a single VM running a complete stack). At production scale, teams graduate to Kubernetes. However, Docker Compose is still used in production at many companies for:
- Single-server deployments with
docker compose up -dmanaged by a CI pipeline - Integration test environments in CI — spinning up a real database and cache instead of mocking them
- Internal tooling stacks that do not justify Kubernetes overhead
When running Compose in CI, always pin image tags to digests or specific versions (never latest) so your tests are reproducible. Use --wait (docker compose up -d --wait) in CI — it blocks until all services with health checks report healthy before the next pipeline step runs.