Multi-Container Applications
A team's "run the stack locally" instructions are a 12-step README with
docker runcommands, a handwritten network, shell variables for container IPs, and notes like "if the database was running before the API you need to restart the API." A new hire joins, follows the steps, gets a connection-refused error on step 7, and spends three hours debugging. Another engineer says "just use the compose file" and points to a 30-line YAML. One command —docker compose up— starts all five services, creates the network, mounts the volumes, injects the env vars, and waits until everything is healthy. Setup time: 30 seconds. Mystery eliminated.Docker Compose is the difference between "individual
docker runcommands glued together with README steps" and "one YAML file describes the whole application." For local development, CI integration tests, and small single-host deployments, Compose is the right tool. This lesson covers the v2 plugin (the one you should be using), the shape of a compose file, and the patterns that make multi-container apps actually work.
docker compose vs docker-compose: The Hyphen Matters
There are two Compose binaries in the wild. You need to know which one you are looking at.
docker-compose(with the hyphen) — the v1 Python implementation. Separate binary, separate install, slow startup, effectively deprecated. You still see it in older systems and legacy CI images.docker compose(space, no hyphen) — the v2 Go plugin bundled with modern Docker installs. Faster, integrated into thedockerCLI, actively maintained. Use this one.
# Check which one you have
docker compose version
# Docker Compose version v2.24.6
# ← v2 plugin, the correct one
docker-compose --version
# docker-compose version 1.29.2, build ...
# ← v1, legacy; try to avoid
# Docker Desktop and modern docker-ce install v2 automatically
which docker-compose
# (often not found — v2 is the only thing installed)
Functionally, v1 and v2 accept the same compose files with minor differences. But the commands are docker compose (v2) vs docker-compose (v1) in CI scripts — and that is where teams get bitten. A CI image that only has docker-compose installed (v1, Python) will fail on a v2-specific feature; an image with docker compose (v2 plugin) will behave differently than a developer's local v1 install.
Standardize on v2 (docker compose with a space) and make sure your CI images include it. Delete docker-compose v1 from developer machines to avoid the "works locally, fails in CI" divergence. Every example in this lesson uses v2 syntax — when you see docker compose in this course, that is the v2 plugin.
The Shape of a Compose File
A compose file (canonically compose.yaml, legacy docker-compose.yml) describes an application stack as a collection of:
- services — containers
- networks — user-defined networks the services attach to
- volumes — named volumes
- configs / secrets — config files and secrets (more Swarm-focused; limited use in
compose up)
A minimal example:
# compose.yaml
services:
web:
image: nginx:1.25-alpine
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
One command:
docker compose up -d
# [+] Running 2/2
# ✔ Network myapp_default Created
# ✔ Container myapp-web-1 Started
Done. An nginx is serving on 8080 with the local ./html mounted in. To tear down:
docker compose down
# [+] Running 2/2
# ✔ Container myapp-web-1 Removed
# ✔ Network myapp_default Removed
A Realistic Stack
Here is the canonical shape you will use over and over — web app + database + cache:
# compose.yaml
name: myapp
services:
web:
build: . # build image from ./Dockerfile
image: myapp/web:dev
ports:
- "127.0.0.1:8080:8080" # localhost-only
environment:
NODE_ENV: development
DATABASE_URL: postgres://user:pass@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- .:/app
- node_modules:/app/node_modules # volume — don't overlay w/ host
develop:
watch:
- action: sync
path: ./src
target: /app/src
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "user"]
interval: 5s
timeout: 5s
retries: 10
ports:
- "127.0.0.1:5432:5432" # expose locally for psql
cache:
image: redis:7-alpine
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
node_modules:
A lot happens here. Let us decode it piece by piece.
name
Top-level name: myapp sets the project name. Compose uses this to prefix container names, network names, and volume names (myapp-web-1, myapp_default, myapp_pgdata). Without name:, Compose uses the directory name. Explicit is better — your compose file works identically from any directory.
services
Each key under services: becomes one container (or more with deploy.replicas). Inside each service:
| Field | What it does |
|---|---|
image | Image to run (or to tag after build) |
build | Path to a Dockerfile context; Compose builds the image |
ports | Equivalent to -p; list of host:container[/proto] |
expose | Internal ports only (no host binding); documentation |
environment | Env vars (map or list form) |
env_file | Path(s) to .env files |
volumes | Bind mounts and named volume refs |
depends_on | Startup ordering (and health-gating with condition) |
healthcheck | Same as Dockerfile's HEALTHCHECK, overridable here |
restart | no / on-failure / always / unless-stopped |
networks | Which networks to attach to (default: default) |
command | Override image's CMD |
entrypoint | Override image's ENTRYPOINT |
user | Runtime UID |
working_dir | Equivalent to -w |
deploy | Swarm-only in v1; some fields work in v2 (resources) |
networks
If you do not declare any network, Compose creates a default one named <project>_default and attaches every service to it. You rarely need explicit networks in compose — the default is fine for most single-stack apps.
Explicit networks matter when you want isolation inside a single compose file:
services:
frontend:
networks: [public, internal]
backend:
networks: [internal] # not reachable from public
networks:
public:
internal:
internal: true # no outbound to host network
volumes
Named volumes declared at the top level. Compose creates them with <project>_<name> and manages their lifecycle.
volumes:
pgdata: # shortest form — uses local driver
sharedcache:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: "size=100m" # inline tmpfs
Essential Compose Commands
# Start everything in the foreground (Ctrl-C stops)
docker compose up
# Detached
docker compose up -d
# Rebuild images before starting
docker compose up -d --build
# Start only specific services (and their deps)
docker compose up -d web
# Scale a service
docker compose up -d --scale worker=3
# Stop (containers removed, networks and volumes kept)
docker compose down
# Stop + remove volumes (DESTRUCTIVE — careful)
docker compose down -v
# Status
docker compose ps
# NAME IMAGE STATUS PORTS
# myapp-web-1 myapp/web:dev Up 2 min 127.0.0.1:8080->8080/tcp
# myapp-db-1 postgres:16-alpine Up 2 min 127.0.0.1:5432->5432/tcp
# myapp-cache-1 redis:7-alpine Up 2 min 6379/tcp
# Logs
docker compose logs # all services, interleaved
docker compose logs -f web # follow one service
docker compose logs --tail=100 db
# Exec into a running service (like docker exec)
docker compose exec web sh
docker compose exec db psql -U user app
# Run a one-off command (new container, same image)
docker compose run --rm web npm test
# Restart a single service
docker compose restart web
# Validate your compose file (parse + resolve — does NOT run anything)
docker compose config
docker compose config is the single most useful command when a compose file is misbehaving. It prints the fully-resolved configuration with all variable substitutions, includes, and inheritance applied — the exact document Compose will act on. Most "this isn't working" bugs are visible in the config output as a misspelled env var, a missing volume definition, or an unresolved ${VAR}.
Environment Variables: The .env File
Compose auto-loads .env from the directory containing the compose file:
# .env
POSTGRES_PASSWORD=devpassword
API_PORT=8080
LOG_LEVEL=debug
# compose.yaml
services:
api:
environment:
DATABASE_URL: postgres://user:${POSTGRES_PASSWORD}@db:5432/app
LOG_LEVEL: ${LOG_LEVEL}
ports:
- "${API_PORT}:8080"
The .env file is for Compose-time substitution into the YAML. Values are baked into the resolved config before containers start.
To set env vars inside the container, use environment: or env_file::
services:
api:
env_file:
- ./secrets.env # each KEY=VALUE becomes a container env var
environment:
EXPLICIT: value # additional / overrides
The difference matters: .env is for "values you want to inject into the YAML" (ports, tags, feature flags). env_file: is for "container environment" (database URLs, API keys).
depends_on and Service Readiness
Naive depends_on:
services:
api:
depends_on:
- db # just ordering: start db before api
This only means "start db's container before api's container." It does not wait for db to be ready. If api starts connecting to db immediately and db is still initializing, api fails.
Health-gated depends_on (v2):
services:
api:
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD", "pg_isready", "-U", "user"]
interval: 5s
retries: 20
Now api waits until db's healthcheck passes. Conditions:
service_started— default; wait only for container creation.service_healthy— wait for the healthcheck to pass.service_completed_successfully— wait for a oneshot service to exit 0.
Even with service_healthy, your app should still retry transient connection failures at startup. Healthchecks have a window, and transient network blips happen. Graceful retry logic in the app is a superset of what Compose can guarantee.
Profiles: Optional Services
Profiles let you mark services as optional — they only start when a matching profile is selected:
services:
api:
image: myapp/api:dev
db:
image: postgres:16
jaeger:
image: jaegertracing/all-in-one:1.55
profiles:
- observability
docker compose up -d # api + db start; jaeger does NOT
docker compose --profile observability up -d # all three start
Good for optional dev tools (Mailhog, Jaeger, test data seeders) you do not want running every time.
Overrides: Different Files for Different Environments
Compose auto-merges compose.yaml with compose.override.yaml if both exist. This lets you commit a base config and an override used only for local development:
# compose.yaml (committed, "canonical")
services:
web:
image: myapp/web:${VERSION:-latest}
restart: unless-stopped
# compose.override.yaml (local dev only, often gitignored)
services:
web:
build: .
volumes:
- .:/app
environment:
LOG_LEVEL: debug
For explicit multi-env use:
docker compose -f compose.yaml -f compose.prod.yaml up -d
Multiple -f flags are merged left-to-right; later files win on conflicting keys.
Generating the Actual Docker Commands
Every docker compose up is equivalent to a sequence of docker network create + docker volume create + docker run commands. If you want to see exactly what Compose would do:
docker compose config
# prints the merged, resolved YAML that Compose will execute
docker compose up --dry-run # (v2.23+) show what would happen without running
Compose is not magic — it is an orchestration layer that parses YAML and drives the same Docker daemon you would drive by hand. Once you know what each service declaration maps to (a docker run with specific flags), compose files become trivial to read and write.
A Minimal Startup Pattern
Here is a production-quality compose file for a typical web-service + db + cache setup. Copy this as a starting point:
# compose.yaml
name: myapp
services:
web:
image: ghcr.io/myorg/myapp-web:${VERSION:-latest}
restart: unless-stopped
ports:
- "${HOST_IP:-127.0.0.1}:${HOST_PORT:-8080}:8080"
environment:
NODE_ENV: production
PORT: 8080
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
REDIS_URL: redis://cache:6379
depends_on:
db: {condition: service_healthy}
cache: {condition: service_started}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
interval: 15s
timeout: 3s
retries: 3
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
retries: 20
deploy:
resources:
limits:
memory: 1G
cache:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
And a matching .env:
# .env
VERSION=v1.2.3
HOST_IP=127.0.0.1
HOST_PORT=8080
POSTGRES_USER=myapp
POSTGRES_PASSWORD=changeme
POSTGRES_DB=myapp
One command gets you a full stack:
docker compose up -d
Key Concepts Summary
- Use
docker compose(v2 plugin), notdocker-compose(v1 Python). Check withdocker compose version. - A compose file declares services, networks, and volumes for a whole app. One YAML, one command.
docker compose up -dbrings the stack up detached;docker compose downstops and cleans (containers + networks).down -valso removes volumes..envis for YAML substitution;env_file:is for container env. Different jobs.depends_onwithcondition: service_healthygives you real readiness gating.profiles:mark optional services; enable with--profile.compose.override.yamlauto-merges overcompose.yamlfor local dev tweaks.docker compose configis your debugger: prints the fully resolved YAML.- Project name scopes container/network/volume names; set with
name:or-p. - Compose is an orchestration layer over
docker run. Every service maps to a specificdocker runinvocation.
Common Mistakes
- Mixing v1 and v2 Compose. One developer uses
docker-compose, another usesdocker compose; slightly different behaviors bite randomly. - Using
depends_onwithoutcondition: service_healthyand getting intermittent startup failures. Add healthchecks and gate on them. - Overwriting
/app/node_modulesby bind-mounting the host's project dir. Use a named volume fornode_modulesto avoid the issue. - Committing
.envwith production secrets..envshould be gitignored and committed as.env.examplewith placeholder values. - Running
docker compose down -vin production expecting it to only remove containers. It also deletes named volumes — your database is gone. - Forgetting that
docker compose upwill REBUILD images ifbuild:is set — unless you also pass--build. Rules:upuses cached builds;up --buildforces rebuild. - Using multiple compose files and mixing up merge order.
docker compose -f base.yaml -f override.yaml up— override wins; know which wins. - Skipping
docker compose configwhen a compose file "doesn't work." The resolved output almost always shows the bug. - Hardcoding service IPs instead of using service names. Compose's default network gives you DNS — rely on it.
- Port-conflicting on the host. Multiple compose projects all publishing 5432 on the host will conflict; use
127.0.0.1:<different-port>:5432per project.
Your team's compose file has `depends_on: [db]` on the API service. Locally, the stack works. In CI, about 20% of test runs fail with 'connection refused' at API startup. What is happening and how do you fix it?