Phase 2 · CONTAINERS & CI/CD
docker compose — multi-container apps
By the end of today
- Define a multi-service web+db stack in one docker-compose.yml file
- Run up -d, ps, logs and down to control the whole stack
- Wire services with ports, volumes, depends_on and a shared network
docker compose: one file for the whole stack
Real apps are never one container. A web app needs a database; the database needs somewhere to keep its data; both need to find each other on a network. On Day 25 you wired two containers together by hand — docker network create, docker run twice, -p flags, --network flags, remembering the order. It works, but it lives in your shell history and nobody else can reproduce it. Docker Compose replaces that pile of flags with one file.
A docker-compose.yml is a declarative description of your whole stack: every container becomes a service, and the flags you used to pass on the command line become keys under it.
- services — the top-level map; each entry (
web,db) becomes one container.image:names what to run, orbuild: .builds from a local Dockerfile. - ports — the
-pflag from Day 25:"8080:80"publishes container port 80 on host port 8080. - volumes — Day 26’s persistence: a named volume like
db-data:/var/lib/postgresql/datakeeps the database alive across restarts. - depends_on — start order:
webwaits fordbto be created before it starts (not until it is ready — that catch is below). - networks — Compose creates one network per project automatically and joins every service to it, so
webreachesdbby the namedbalone. You only declare networks when you want more than that default.
Real world: A
docker-compose.ymlis the cast-and-set list handed to a stagehand before a play. It names every actor (services), where each one stands (ports), the props that must survive between shows (volumes), and who has to be on stage before a scene can start (depends_on). One sheet, and any stagehand sets the whole production up identically — instead of you whispering directions from the wings every night.
A named example makes it concrete. The official WordPress Docker image documents a two-service docker-compose.yml — a wordpress service and a mysql service, joined on the default network with a named volume on the database — as the canonical way to run it. Anyone who clones that file gets the identical stack on the first up: that reproducibility is the whole point.
Controlling the stack: up, ps, logs, down
The file is only half of it; four commands drive it — all in the space form (docker compose, the v2 plugin, never the hyphenated docker-compose v1 script):
docker compose up -d— read the file, create the network, the volumes and every service, and run them in the background (-d= detached).docker compose ps— list the stack’s containers with their state and published ports.docker compose logs -f— stream the combined logs of every service, prefixed by service name (-ffollows live).docker compose down— stop and remove the containers and the project network in one go; it keeps named volumes unless you add-v.
One file, four verbs — and your whole stack goes up and down as a unit instead of a container at a time.
Hands-On Lab
Budget about 25 minutes. Open your WSL2 Ubuntu 24.04 terminal with Docker Desktop running (Docker 27+; check with docker compose version). You will write one compose file for an nginx web service plus a Postgres database, bring the whole stack up, prove the services find each other by name, then tear it down — typing each command yourself and reading every line of output.
# 1. Make a project directory and write the compose file (the project name comes from the dir).
mkdir -p ~/myapp && cd ~/myapp
cat > docker-compose.yml <<'EOF'
services:
web:
image: nginx:1.27
ports:
- "8080:80"
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: example
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
EOF
cat docker-compose.yml
# Output: the file you just wrote is printed back — two services, one named volume.
# 2. Validate before running: --services lists the service names Compose parsed from the file.
docker compose config --services
# Output (alphabetical):
# db
# web
# 3. Bring the whole stack up in the background. Compose creates the network, volume and both containers.
docker compose up -d
# Output (your timings will differ; first run also pulls the images):
# [+] Running 4/4
# ✔ Network myapp_default Created 0.1s
# ✔ Volume myapp_db-data Created 0.0s
# ✔ Container myapp-db-1 Started 0.6s
# ✔ Container myapp-web-1 Started 0.7s
# 4. List the stack. Note web publishes 8080->80 to the host; db's 5432 stays internal.
docker compose ps
# Output (container IDs, created/status times will differ):
# NAME IMAGE COMMAND SERVICE STATUS PORTS
# myapp-db-1 postgres:16 "docker-entrypoint.s…" db Up 9 seconds 5432/tcp
# myapp-web-1 nginx:1.27 "/docker-entrypoint.…" web Up 8 seconds 0.0.0.0:8080->80/tcp
# 5. Confirm Postgres finished starting — grep the readiness line (logs are prefixed by service name).
docker compose logs db | grep 'ready to accept connections'
# Output (timestamp and point release will differ):
# db-1 | 2026-07-11 09:12:04.997 UTC [1] LOG: database system is ready to accept connections
# 6. Hit the published port from the host — nginx answers on 8080 because of the ports mapping.
curl -I localhost:8080
# Output (nginx point release and Date will differ):
# HTTP/1.1 200 OK
# Server: nginx/1.27.0
# Content-Type: text/html
# 7. Prove service discovery: from inside web, the name "db" resolves on the project network.
docker compose exec web getent hosts db
# Output (the container IP will differ; the name is what stays constant):
# 172.18.0.2 db
# 8. The named volume was created and belongs to this project (prefixed with the project name).
docker volume ls
# Output (trimmed; other volumes on your machine will also appear):
# DRIVER VOLUME NAME
# local myapp_db-data
# 9. Tear the stack down. Containers and the network go; the named volume is deliberately kept.
docker compose down
# Output (your timings will differ):
# [+] Running 3/3
# ✔ Container myapp-web-1 Removed 0.4s
# ✔ Container myapp-db-1 Removed 0.3s
# ✔ Network myapp_default Removed 0.1s
# 10. The volume survived the down — your database data is still there for the next up.
# To also delete it you'd run: docker compose down -v (never near real data).
docker volume ls
# Output (myapp_db-data is still listed):
# DRIVER VOLUME NAME
# local myapp_db-data
Read the sequence back: one file described a web service and a db service; up -d built the network, the volume and both containers; ps and logs showed them running; web reached db by name alone; and down removed the containers and network while leaving your data safe on the volume — the whole stack managed as one unit.
Common Errors & Fixes
These are the mistakes that trip people up the first time they run a multi-service Compose stack on Ubuntu 24.04. Read the error text slowly — learning to parse it is the actual skill.
Common error: Running
docker compose up -dwhile an old stack (or another process) still holds host port 8080:Error response from daemon: driver failed programming external connectivity on endpoint myapp-web-1: Bind for 0.0.0.0:8080 failed: port is already allocatedWhy: Only one process can bind a given host port at a time. A container from a previous
upthat was never broughtdown, or an unrelated service already on 8080, still owns it — so Docker cannot publishwebthere and refuses to start the container.Fix: Bring the old stack down first with
docker compose down, or find the holder —docker ps --filter publish=8080for a container,ss -tlnp | grep ':8080'for anything else — and stop it. If the port genuinely must stay in use, change the host side of the mapping to a free port like"8081:80".How you’d spot it in prod: A deploy that fails to start with “port is already allocated” usually means the previous version is still running or a stale container survived a crash. Check for orphaned containers before assuming the config is wrong.
Common error: A web/app service that connects to the database on the very first boot of a fresh stack and immediately fails:
web-1 | could not connect to server: Connection refused web-1 | Is the server running on host "db" (172.18.0.2) and accepting TCP connections on port 5432?Why:
depends_on: [db]only guarantees thedbcontainer has started, not that Postgres inside it is ready to accept connections. Postgres takes a second or two to initialise on first boot, andwebraces ahead and connects too early.Fix: Add a healthcheck to
db(for Postgres,pg_isready -U postgres) and gatewebon it with the long form —depends_on:withdb:set tocondition: service_healthy— sowebwaits until the database reports healthy. Better still, make the app retry its connection, since a database can also drop at runtime, not only at startup.How you’d spot it in prod: An app container that crash-loops on the first boot of a fresh stack but is fine after a restart is the classic tell — it lost the startup race and only succeeded once the database was already warm.
Common error: Writing the compose file with a stray tab or a misaligned key, then running any
docker composecommand:yaml: line 6: did not find expected keyWhy: YAML structure is defined entirely by indentation, and tabs are illegal for it — only spaces count. A key indented by the wrong number of spaces (or with a tab) breaks the mapping, so the parser reaches a line expecting a key and finds something it can’t place.
Fix: Indent with spaces only, two per level, consistently down the tree. Run
docker compose configbeforeup— it either prints the fully resolved configuration or points at the offending line, catching the slip before you try to start anything.How you’d spot it in prod: A pipeline step that parses YAML failing with “did not find expected key” or “mapping values are not allowed here” is nearly always an indentation or tab slip. Run the file through a linter like
yamllintin CI so it’s caught before deploy, not during it.
Docker Compose Interview Questions
The four questions below are among the most common container screening questions once you can run a stack — Compose’s purpose, the depends_on readiness trap, service discovery by name, and the down versus stop distinction. The answer bank renders right after this note. Cover each answer, say your own version out loud first, then compare — recalling before revealing is what makes it stick for interview day.
Go Deeper
Optional extras if you have ~30 more minutes today:
- 5 min — Run
docker compose configon today’s file (no--services) and read the fully resolved YAML — the defaults Compose fills in, including the network it adds for you, become visible. - 10 min — Add a
healthcheckto thedbservice (test: ["CMD-SHELL", "pg_isready -U postgres"]) and switchwebtodepends_onwithcondition: service_healthy; bring the stack up and watchwebwait for the database to report healthy. - 15 min — Read the Docker Compose section of the Docker for DevOps guide for how multi-service stacks fit the wider build-ship-run picture you’ll automate in the coming weeks.
What problem does Docker Compose solve? Both
Compose replaces a pile of docker run flags and docker network create commands with one declarative file. A real app is more than one container — a web service, a database, maybe a cache — each needing ports, volumes and a network to find the others. Doing that by hand is fragile and lives only in your shell history. A docker-compose.yml describes the whole stack: every container is a service, and one docker compose up -d creates the network, the volumes and every container in the right order. Anyone who has the file reproduces the identical environment, which is why Compose is the standard way to run multi-container apps in local development.
Does depends_on wait for a service to be ready? Both
No, and this trips everyone up. depends_on only controls start order — it waits for the dependency's container to be created and started, not for the process inside to be ready to serve. So web can start the instant the db container exists while Postgres is still initialising, and web's first connection gets refused. To wait for readiness you add a healthcheck to the db — something like pg_isready — and gate the dependent with the long form depends_on with condition: service_healthy. The alternative, which I prefer for resilience, is to make the app retry its connection, because the database can also disappear at runtime, not just at startup.
How do services in a Compose file talk to each other? Service
Compose creates one network for the project automatically and attaches every service to it, so they reach each other by service name. If I have web and db services, web connects to the host db on its container port — no IP addresses, no links, no published ports needed for internal traffic. Docker's embedded DNS resolves db to the container's current IP, which matters because that IP changes on restart while the name never does. I only publish ports with the ports key when I want traffic from the host or the outside world; service-to-service traffic stays on the internal network. I declare custom networks only when I need isolation between groups of services.
What's the difference between docker compose down and docker compose stop? Product
stop halts the running containers but leaves them, the network and the volumes in place, so a later start brings the same containers back quickly. down is the teardown: it stops and removes the containers and the project network, giving you a clean slate. The key detail for anyone with data: down keeps named volumes by default, so your database survives — you only lose them if you add -v, which is a foot-gun in the wrong directory. In practice I use stop to pause work I'll resume and down to reset an environment, and I'm careful never to run down -v anywhere near real data.
Mark Day 27 complete
Tomorrow you play: Docker Rescue
Stuck on today’s lab? Ask in Mission 90 Q&A