Docker & ContainersIX Β· Docker ComposeCompose primitives
Services, networks, volumes β the three primitives
What you'll learn
- Write a minimal compose.yml and read what each primitive becomes on the daemon
- Predict which edits to a Compose file force a container replacement
- Use `internal:` and per-service network attachment to build a real trust boundary
- Recognise the anti-patterns that survive review because they still work
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
A compose.yml describes a multi-container application. Three primitives do
most of the work: services, networks and volumes. Other primitives β configs
and secrets β build on them.
A minimal example
name: shop
services:
web:
image: nginx:1.27
ports:
- "8080:80"
volumes:
- web-data:/usr/share/nginx/html
networks:
- app-net
depends_on:
api:
condition: service_started
api:
image: myorg/api:1.0.0
environment:
DATABASE_URL: postgres://db:5432/app
networks:
- app-net
- db-net
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db-data:/var/lib/postgresql/data
networks:
- db-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
start_period: 30s
networks:
app-net:
db-net:
internal: true
volumes:
web-data:
db-data:
secrets:
db_password:
file: ./secrets/db_password.txt
The shape matters more than the detail. web is on app-net only, so it
cannot reach the database at all β not βshould notβ, cannot, because
Dockerβs embedded DNS will not resolve db for a container that shares no
network with it. api bridges the two. db-net is internal: true, which
means the daemon installs no default route out of it: the database can talk
to the API and to nothing else, including the internet.
That last property is the cheapest containment control in Compose and almost nobody sets it.
What each primitive becomes
# Should fail: web is not on db-net, so the name does not resolve
docker compose exec web getent hosts db || echo 'OK: db is not resolvable from web'
# Should succeed: api bridges both networks
docker compose exec api getent hosts db
# Confirm db-net really has no route out
docker network inspect shop_db-net --format '{{.Internal}} {{.Driver}} {{(index .IPAM.Config 0).Subnet}}'A test that can fail is the point. βWe put them on separate networksβ is a
claim; getent hosts db returning nothing from web is evidence.
The reconciliation: what up actually does
This is the part that separates people who use Compose from people who
understand it. docker compose up on a stack that is already running is not
a restart and not a no-op. It is a diff.
The corollary catches people out. Changes that force a replacement include
anything create-time: image, command, entrypoint, environment, ports,
volumes, networks, user, labels, resource limits, restart policy. Changes
that do not force a replacement include editing the contents of a file
that a bind mount points at, or editing a .env value that the service does
not actually reference.
docker compose --dry-run up -d
# The hash currently on disk, per container
docker compose ps -q | xargs -r docker inspect \
--format '{{index .Config.Labels "com.docker.compose.service"}} {{index .Config.Labels "com.docker.compose.config-hash"}}'Two flags override the diff when you need them to. --force-recreate
replaces every container even when nothing changed β useful after a manual
docker exec that dirtied a writable layer. --no-recreate leaves existing
containers alone even when the spec changed, which is occasionally right
during an incident and always wrong in a deploy pipeline.
The service keys worth knowing
services:
NAME:
image: IMAGE # or a digest pin: IMAGE@sha256:...
build: . # build context, if this service is built locally
pull_policy: missing # always | never | missing | build | daily | weekly
command: [...] # override CMD
entrypoint: [...] # override ENTRYPOINT
environment: {} # env vars
env_file: .env # load from file
ports: ["8080:80"] # host:container publish
expose: ["9090"] # documented, not published
volumes: [...] # mounts
networks: [...] # networks to attach
depends_on: {} # ordering plus health conditions
healthcheck: {} # readiness signal
restart: unless-stopped
user: "1001:1001"
read_only: true # read-only root filesystem
tmpfs: ["/tmp"] # writable scratch when read_only is set
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
stop_grace_period: 30s
profiles: [...]
deploy:
resources:
limits:
cpus: "2.0"
memory: 1g
pids: 200
Anti-patterns that still work
Each of these produces a stack that comes up cleanly, which is why they survive review.
image: myapp:latest. The container is bound to whatever digestlatestresolved to at create time, so the running container and the tag drift apart silently. Pin a version, and pin a digest for anything you would have to explain in an incident review.- Short-syntax
depends_on. It waits for the container to start, not to be ready. This is the single most common Compose defect; it has its own lesson. - No healthcheck. Without one,
service_healthyis unavailable,--waitdegrades to βis it runningβ, and your reverse proxy has nothing to route on. - No resource limits. One container with a memory leak takes the host down, and the OOM killer picks its victim by score, not by whose fault it was.
- Every service on one network. Convenient, and it means a compromised
frontend can reach the database directly. Segment, then set
internal: trueon the data network. - Mounting the Docker socket. A container with
/var/run/docker.sockcan start a privileged container mounting the host root filesystem. It is host root, spelled differently.
Knowledge check
Knowledge check Β· 5 questions
Q1. In a Compose file, `networks:` at the top level:
Q2. Named volumes declared in Compose persist across `docker compose down`.
Q3. You edit an nginx.conf that is bind-mounted into a container, then run `docker compose up -d`. Compose reports the container as Running and changes nothing. Why?
Q4. Which of these edits force Compose to stop, remove and recreate a container on the next `up`? Select all that apply.
Q5. What does `internal: true` on a top-level network do?
Passing score: 75%. Answers are checked in this browser.