Changing how multi-container applications are managed from a sequence of docker run commands into one declarative file: understanding the Compose Specification, image vs build, depends_on + healthcheck, and DNS between services, plus assembling a web + API + PostgreSQL + Redis stack with a single docker compose up -d.

After dissecting networking in episode 9 — custom bridges, embedded DNS at 127.0.0.11, and inter-container communication by name — in this episode we answer the question that's been hanging since the end of that episode: how do you manage many containers cleanly? So far every container is born from one long docker run command: volumes, networks, env, ports, healthchecks. Multiply that by five services — web, API, database, cache, reverse proxy — and the commands become tens of lines that are impossible to remember, let alone share with the team and review.
This isn't just a convenience issue. In a team, what isn't documented is considered nonexistent. A set of docker run commands living only in one engineer's head is a single point of failure: that person goes on leave, and no one knows how the stack runs. Compose changes that: the entire application becomes code that can be committed, reviewed, and reproduced identically on anyone's machine. It's also the foundation of everything we build afterward — even the Swarm deployment in episodes 16-17 still uses a Compose file.
In this episode we'll understand what Compose and the Compose Specification are, dissect the structure of compose.yaml — services, image/build, ports, environment, env_file, volumes, networks, depends_on, healthcheck — then master the Compose v2 CLI and assemble one real stack: web + API + PostgreSQL + Redis. We start from concepts, because what matters isn't memorizing syntax, but understanding what you're describing.
Docker Compose is a tool for defining and running multi-container applications in a single file — compose.yaml (the old name docker-compose.yml is still supported). Its way of thinking is totally different from docker run: in docker run you type steps (imperative); in Compose you write the desired end state (declarative) — and Docker decides how to reach that state.
Compare two approaches for the same small stack:
docker network create webnet
docker volume create pgdata
docker run -d --name postgres \
--network webnet -e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data postgres:16-alpine
docker run -d --name redis --network webnet redis:7-alpine
docker run -d --name api --network webnet \
-e DATABASE_URL=postgres://postgres:secret@postgres:5432/app \
-e REDIS_URL=redis://redis:6379 -p 3000:3000 my-api:1.0
docker run -d --name web --network webnet -p 8080:80 my-web:1.0Both blocks produce the same end state, but one runs with five commands and you have to remember the order; the other runs with a single docker compose up -d and is documented forever. Also note that in Compose, the webnet network and pgdata volume are declared as top-level entities — no need to create them manually.
compose.yaml follows the Compose Specification — a standard document co-maintained by the community, not owned by a single vendor. The practical consequence: a file you write today can be run by Docker Compose, and by other tools that follow the same specification. That's why fields like services, volumes, and networks are consistent across all official documentation.
The core structure of a Compose file consists of three top-level keys you'll use most:
services — the description of every container to run (web, api, db, and so on).volumes — declarations of named volumes shared between services.networks — declarations of networks connecting those services.Services are the star of the show. Every entry under services is equivalent to one docker run — and nearly every docker run flag has a matching field in Compose. The fastest trick to mastering Compose is to keep asking: "which docker run flag does this field replace?"
Every service must know which image it uses. There are two ways — and they can be combined:
image: <name> — pull an already-built image from a registry (e.g. postgres:16-alpine). For third-party services: databases, caches, reverse proxies.build: ... — build the image from a Dockerfile when up is run. For your own applications.For services that are built, the build field supports options corresponding to docker build:
services:
api:
build:
context: ./api
dockerfile: Dockerfile.prod
target: production
args:
NODE_ENV: production
image: my-api:1.0
ports:
- "3000:3000"context — the build context directory (what's sent to the daemon; remember episode 5).dockerfile — an alternative Dockerfile name if it isn't Dockerfile.target — which stage to build. This is the power of the episode 7 multi-stage build: you build the production image without carrying its build tools.args — equivalent to --build-arg; only valid during build, doesn't enter runtime.Note the build + image combination: Docker builds from the context, then tags the result my-api:1.0. With only image and no build, the service is just pulled; with only build and no image, the image is built without an explicit tag. Using both gives an advantage: a cleanly named image ready to push to a registry (episode 12).
The next three fields handle things beginners most often misunderstand.
ports — publishes container ports to the host, equivalent to -p. The short syntax "3000:3000" means host:3000 → container:3000. The long syntax splits everything out explicitly:
services:
web:
build: ./web
ports:
- "8080:80"
api:
build: ./api
ports:
- target: 3000
published: 3000
protocol: tcpOne crucial thing: a host port can only be used by one service. If web publishes 8080:80 and another service also publishes 8080, up fails with "port is already allocated". In production, the right pattern is actually to limit who publishes ports — only the reverse proxy faces the outside world (episode 20), while the API and database are only visible on the internal network.
environment — env variables injected at runtime, equivalent to -e. Values can be literals, or refer to host env with ${VAR} — an interpolation mechanism we'll dissect fully in episode 11.
env_file — reads variables from a file, equivalent to --env-file. Great for moving many variables out of the Compose file:
services:
api:
build: ./api
env_file:
- .env.api
environment:
NODE_ENV: production
LOG_LEVEL: infoRemember the hierarchy between them: env_file loads variables from a file, environment sets them directly in the Compose file and wins on name conflicts. Both also differ from .env — the .env file is used by Compose for interpolating values within the Compose file itself, not for injecting into containers. This is one of the biggest sources of confusion in the Compose ecosystem; we'll dissect the full comparison in episode 11.
Two things we built manually in episodes 8 and 9 are now declared:
services:
postgres:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- dbnet
api:
build: ./api
depends_on:
postgres:
condition: service_healthy
networks:
- dbnet
- webnet
volumes:
pgdata:
networks:
dbnet:
webnet:volumes on a service: pgdata:/var/lib/postgresql/data attaches the named volume pgdata to the PostgreSQL data directory. Named volumes are declared once at the top level (volumes: pgdata:), usable by many services. This is exactly the episode 8 lesson: without this, database data disappears when the stack is recreated.networks on a service: a service can join one or more networks. Note that api is on two networks — the zone pattern from episode 9: the database is only on dbnet, web and api on webnet. You design security through this network topology.If you don't declare networks, Compose automatically creates a default default network and attaches all services to it — all services can see each other. That's practical for prototypes; for production, declare explicitly so the zones are clear.
The most classic multi-container problem: a startup race condition. The API needs a database ready to accept connections — but "started" doesn't mean "ready". A PostgreSQL container can already be running while its server is still recovering. If the API tries to connect at that instant, the result is connection refused, and without retries, the application crashes.
depends_on manages ordering, but there are two forms with different strengths:
# Bentuk sederhana: hanya urutan start — TIDAK menunggu kesiapan
services:
api:
build: ./api
depends_on:
- postgres
- redis
# Bentuk lengkap: menunggu status sehat
services:
api:
build: ./api
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthyThe first form only ensures postgres and redis are created and started before api — it doesn't guarantee the database accepts connections at all. The second form waits until a service's healthcheck status is healthy. For that, the target service must define a healthcheck:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10stest — the command run; pg_isready is PostgreSQL's built-in tool for checking readiness. Exit code 0 = healthy.interval — the gap between checks (5 seconds).timeout — the limit for a single check before it's considered failed (3 seconds).retries — how many consecutive failures before status unhealthy (5).start_period — the grace period: for the first 10 seconds, failures aren't counted, giving the database time to boot.With the healthcheck + condition: service_healthy pairing, the order becomes ready, then proceed — not just start first. This is what separates a stack that crash-loops at boot from one that's calm every time you up it.
Compose v2 is the built-in docker compose command (no longer the separate, outdated docker-compose). The core command set you must master:
docker compose up -d
docker compose ps
docker compose logs -f api
docker compose down
docker compose down -vdocker compose up -d — builds (if build is present), creates networks/volumes, then runs all services in the background. -d = detach; without -d, all services' logs are merged into the terminal.docker compose ps — each service's status: running, exited, or health.docker compose logs -f — follow logs; can be limited to one service (-f api) or with --tail 50.docker compose down — stops and removes containers + networks (not images, not volumes).docker compose down -v — as above plus removes named volumes. Be careful: this permanently deletes database data.docker compose stop/start — stop and restart without deleting anything (containers remain).docker compose restart api — restart one service.docker compose exec api sh — enter the api service's container, equivalent to docker exec.docker compose build — explicitly rebuild images, useful when a Dockerfile changes.Important
Every time you change compose.yaml, the change doesn't automatically apply to already-running containers. You must rerun docker compose up -d — Compose detects configuration changes and recreates affected services. This is the number one source of confusion: "I changed env, why no effect?" — the answer is always: you haven't up-ed again.
Now let's assemble the example that will accompany us through the rest of the series: web (Nginx serving static files), API (Node.js), PostgreSQL, and Redis — complete with healthchecks and depends_on:
services:
web:
build:
context: ./web
ports:
- "8080:80"
depends_on:
api:
condition: service_healthy
networks:
- webnet
api:
build:
context: ./api
dockerfile: Dockerfile.prod
target: production
environment:
DATABASE_URL: postgres://postgres:secret@postgres:5432/app
REDIS_URL: redis://redis:6379
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
networks:
- webnet
- dbnet
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
networks:
- dbnet
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- dbnet
volumes:
pgdata:
networks:
webnet:
dbnet:Note the depends_on flow forming a readiness chain: postgres and redis must be healthy → api starts; api must be healthy → web starts. This stack won't crash at boot just because the database connection isn't ready — exactly the problem we touched on at the start.
Now take a look at the most interesting lines in the file above:
environment:
DATABASE_URL: postgres://postgres:secret@postgres:5432/app
REDIS_URL: redis://redis:6379There's no IP anywhere. postgres and redis are service names — and this works thanks to the episode 9 lesson. Compose creates user-defined bridge networks (webnet, dbnet), and on those networks every service is automatically registered as a DNS name. The embedded DNS at 127.0.0.11 resolves that name to the relevant container IP.
Prove it from inside the api container:
docker compose exec api getent hosts postgres
docker compose exec api getent hosts redis172.19.0.2 postgres
172.19.0.3 redisThe IPs may even differ from the earlier docker network inspect — and that's fine. Because the application always says postgres, not 172.19.0.2, when postgres is recreated (IP changes), the application keeps working without configuration changes. This is the most important lesson of episode 9 now made real in Compose: never write an IP, write the service name.
One note: the service name differs from the container name. Compose names containers automatically (<directory>-<service>-1); what's registered as DNS is the service name — that's what you must write in the application code. Setting a manual container_name should be avoided because it makes the service unscalable (two replicas can't share one container name).
depends_on without condition: service_healthy. Only sets startup order, not readiness. An API that starts before the database is ready will crash-loop. Use the full form + healthcheck on every dependency.
docker compose down -v deletes data. -v removes named volumes — including pgdata. One typo and all the database data is gone. Understand when to use down (safe) vs down -v (risky).
Host port clashes. Two services publishing the same host port make up fail. Limit who faces the host.
Services can't call each other. If the API can't reach postgres, they're most likely not on the same network, or the name used is wrong. Make sure services are on one network and use the service name, not the IP.
Changed compose.yaml but forgot to up again. Configuration changes aren't applied to running containers. After editing, always run docker compose up -d.
All services default to one network. Without a networks declaration, all services join the default network and see each other — the database becomes accessible from the web. In production, separate the zones (the webnet/dbnet pattern above).
In this episode 10 we've changed the way we work from commands to declarations: understanding that Docker Compose turns a multi-container app into one declarative file that's documented and reproducible, dissecting the Compose Specification — services (with image or build + context/dockerfile/target/args), ports, environment, env_file, volumes, networks — understanding the crucial difference between simple depends_on and depends_on with condition: service_healthy, mastering the Compose v2 CLI (up -d, ps, logs -f, down -v, stop/start/restart, exec), assembling a web + API + PostgreSQL + Redis stack complete with healthchecks, and proving that DNS between services uses names, not IPs.
Core takeaways:
docker run = imperative. Declarative is documented, reviewable, reproducible.image for ready-made services, build for your own apps; combine them for cleanly named images.depends_on must pair with healthcheck + condition: service_healthy to wait for readiness, not just order.down -v removes volumes — watch out for data.up again.Now you have one file describing the entire application. But this file is still static: its values are hardcoded, and there's no way to use different configuration for development vs production. In the next episode, episode 11, we'll remove that limitation: Advanced Docker Compose Features — .env interpolation, multi-file compose (compose.prod.yaml), profiles for optional services, extends and YAML anchors, and validation with docker compose config. Make sure this episode's stack is still running — we're going to build on it. See you in episode 11!