Docker · lesson 6 of 9
Intermediate
3Also part of Docker
5- + Deploying FastAPI with Docker and Kubernetes: Images, Signals, and Probes from FastAPI
- + Dockerizing Spring Boot Applications from Java
- + Bash Process Management from Bash
- + CIS Docker Benchmark Checks with docker-bench-security from Cloud Security
- + Signing Container Images with Cosign: Verify the Digest, Not the Tag from DevSecOps (AppSec in the pipeline)
Also part of: Microservices
Docker Compose
Define and run multi-container applications with Docker Compose. Services, networks, volumes, and environment configuration.
By SkillByExample · Updated
What Is Docker Compose?
Docker Compose lets you define a multi-container application in a single docker-compose.yml file and manage it with one command. Instead of running multiple docker run commands with flags, you declare all services, their configuration, networks, and volumes in one place.
docker-compose.yml Structure
services: # containers to run
web:
image: nginx
ports:
- "8080:80"
api:
build: ./api # build from a local Dockerfile
environment:
- NODE_ENV=production
depends_on:
- db
db:
image: postgres:15
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: secret
volumes: # named volumes
pgdata:
networks: # custom networks (optional — Compose creates a default)
backend:
Core Commands
A tiny stack — a web server, a cache, and a worker — is enough to see what each command does.
# docker-compose.yml
services:
web:
image: nginx:1.27-alpine
ports:
- "8081:80"
cache:
image: redis:7-alpine
worker:
image: busybox:1.36
command: ["sh", "-c", "while true; do echo waiting for work; sleep 30; done"]
docker compose up -d
docker compose ps
$ docker compose up -d
Network core_default Created
Container core-cache-1 Started
Container core-web-1 Started
Container core-worker-1 Started
$ docker compose ps
NAME IMAGE SERVICE STATUS PORTS
core-cache-1 redis:7-alpine cache Up 8 seconds 6379/tcp
core-web-1 nginx:1.27-alpine web Up 8 seconds 0.0.0.0:8081->80/tcp
core-worker-1 busybox:1.36 worker Up 8 seconds
docker compose up -d --build adds a rebuild step for any service with a build: key —
skip it and Compose reuses an existing image. docker compose logs <service> tails one
service’s output instead of interleaving all of them:
docker compose logs worker
$ docker compose logs worker
worker-1 | waiting for work
--scale runs more than one container for a service that has no fixed host port:
docker compose up -d --scale worker=3
docker compose ps
$ docker compose up -d --scale worker=3
Container core-worker-2 Started
Container core-worker-3 Started
$ docker compose ps
NAME IMAGE SERVICE STATUS PORTS
core-cache-1 redis:7-alpine cache Up 25 seconds 6379/tcp
core-web-1 nginx:1.27-alpine web Up 25 seconds 0.0.0.0:8081->80/tcp
core-worker-1 busybox:1.36 worker Up 25 seconds
core-worker-2 busybox:1.36 worker Up 6 seconds
core-worker-3 busybox:1.36 worker Up 6 seconds
worker has no ports: mapping, so three replicas can coexist — web publishes 8081 on
the host and can’t be scaled past 1 without a port clash. docker compose down -v reverses
all of it, removing the containers, the network, and any named volumes:
docker compose down -v
$ docker compose down -v
Container core-web-1 Removed
Container core-worker-1 Removed
Container core-worker-2 Removed
Container core-worker-3 Removed
Container core-cache-1 Removed
Network core_default Removed
Full Example: Node API + PostgreSQL + Redis
# docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- ./src:/app/src # live reload in development
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
Environment Variables
From a .env file (default)
Compose automatically loads a .env file in the same directory and substitutes ${VAR}
references in docker-compose.yml:
# .env
DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
NODE_ENV=development
# docker-compose.yml
services:
app:
image: busybox:1.36
environment:
- DATABASE_URL=${DATABASE_URL}
- NODE_ENV=${NODE_ENV}
command: ["sh", "-c", "echo DATABASE_URL=$$DATABASE_URL; echo NODE_ENV=$$NODE_ENV"]
docker compose run --rm app
$ docker compose run --rm app
DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
NODE_ENV=development
Multiple env files
--env-file swaps in a different file instead of .env, without touching
docker-compose.yml:
docker compose --env-file .env.staging run --rm app
$ docker compose --env-file .env.staging run --rm app
DATABASE_URL=postgresql://postgres:secret@staging-db:5432/myapp
NODE_ENV=staging
Service Dependencies and Health Checks
# docker-compose.yml
services:
api:
image: busybox:1.36
command: ["sh", "-c", "echo api started; sleep 60"]
depends_on:
db:
condition: service_healthy # wait until healthy, not just started
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
docker compose up -d
$ docker compose up -d
Network health_default Created
Container health-db-1 Created
Container health-api-1 Created
Container health-db-1 Started
Container health-db-1 Waiting
Container health-db-1 Healthy
Container health-api-1 Started
db goes through Waiting while pg_isready keeps failing, then Healthy once Postgres
accepts connections — only then does Compose start api. Without condition: service_healthy, depends_on would start api the moment db’s container exists, which
can be seconds before Postgres is ready to accept a connection.
Running Commands
docker compose exec api node --version
$ docker compose exec api node --version
v18.20.8
exec runs inside a container that’s already up. run --rm instead starts a fresh,
throwaway container for a one-off job, like a migration:
docker compose run --rm api npm run migrate
$ docker compose run --rm api npm run migrate
> migrate-demo@1.0.0 migrate
> echo 'Running migrations...' && echo 'Applied 001_create_users_table' && echo 'Migrations complete'
Running migrations...
Applied 001_create_users_table
Migrations complete
psql without -c opens an interactive shell, which has no captured output — pass a query
to run it non-interactively:
docker compose exec db psql -U postgres -c "select version();"
$ docker compose exec db psql -U postgres -c "select version();"
version
------------------------------------------------------------------------------------------
PostgreSQL 15.19 on x86_64-pc-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit
(1 row)
Overriding with Multiple Files
Three files, one service, each layer adding or replacing settings:
# docker-compose.yml — base config (shared)
services:
web:
image: nginx:1.27-alpine
environment:
- ENV_LABEL=base
# docker-compose.override.yml — dev overrides, merged in automatically
services:
web:
ports:
- "8082:80"
environment:
- ENV_LABEL=development
# docker-compose.prod.yml — production overrides, opted into with -f
services:
web:
environment:
- ENV_LABEL=production
restart: unless-stopped
# Development — base + override merge automatically
docker compose up -d
docker compose exec web printenv ENV_LABEL
docker compose ps
$ docker compose up -d
Network override_default Created
Container override-web-1 Started
$ docker compose exec web printenv ENV_LABEL
development
$ docker compose ps
NAME IMAGE SERVICE STATUS PORTS
override-web-1 nginx:1.27-alpine web Up 5 seconds 0.0.0.0:8082->80/tcp
# Production — base + prod.yml instead, named explicitly
docker compose down -v
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker compose exec web printenv ENV_LABEL
docker compose ps
$ docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Network override_default Created
Container override-web-1 Started
$ docker compose exec web printenv ENV_LABEL
production
$ docker compose ps
NAME IMAGE SERVICE STATUS PORTS
override-web-1 nginx:1.27-alpine web Up 6 seconds 80/tcp
docker-compose.override.yml only merges when no -f flags are given — naming
docker-compose.prod.yml explicitly replaces it instead of adding to it, which is why the
production container has no published port: 8082:80 lived only in the override file.
Practice
- In the “Core Commands” stack, scale
workerto 3 and back down to 1. Confirm withdocker compose psthat only oneworkercontainer is left.
Solution
docker compose up -d --scale worker=3
docker compose up -d --scale worker=1
docker compose ps
$ docker compose ps
NAME IMAGE SERVICE STATUS PORTS
core-cache-1 redis:7-alpine cache Up 10 seconds 6379/tcp
core-web-1 nginx:1.27-alpine web Up 10 seconds 0.0.0.0:8081->80/tcp
core-worker-1 busybox:1.36 worker Up 10 seconds
up -d --scale worker=1 removes the extra replicas rather than erroring — Compose
reconciles the running containers to match the number you asked for.
- Override
NODE_ENVfor a single run of the env-varsappservice without editing.envordocker-compose.yml.
Solution
NODE_ENV=test docker compose run --rm app
$ NODE_ENV=test docker compose run --rm app
DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
NODE_ENV=test
A variable already set in the shell environment takes priority over the same name in
.env — Compose only uses .env to fill in values that aren’t already set.
- Check whether
db’s healthcheck in the “Service Dependencies” stack is currently passing, without re-reading the fulldocker compose pstable.
Solution
docker inspect --format '{{.State.Health.Status}}' health-db-1
$ docker inspect --format '{{.State.Health.Status}}' health-db-1
healthy
docker inspect reads a single container’s state directly — useful in a script where
parsing docker compose ps output would be more fragile.
- List the npm scripts defined in the “Running Commands”
apiservice’spackage.jsonwithout running any of them.
Solution
docker compose run --rm api npm run
$ docker compose run --rm api npm run
Scripts available in migrate-demo@1.0.0 via `npm run-script`:
migrate
echo 'Running migrations...' && echo 'Applied 001_create_users_table' && echo 'Migrations complete'
npm run with no script name lists what’s available instead of running anything —
handy for checking what a service’s image actually supports before calling it.
- Print the fully merged configuration Compose would use for the production deploy in “Overriding with Multiple Files”, without starting any containers.
Solution
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
$ docker compose -f docker-compose.yml -f docker-compose.prod.yml config
name: override
services:
web:
environment:
ENV_LABEL: production
image: nginx:1.27-alpine
networks:
default: null
restart: unless-stopped
networks:
default:
name: override_default
config resolves every -f file, substitutes ${VAR} references, and prints the result —
the same merge Compose uses internally, without the side effect of starting anything.