Docker Compose
By the end of this lesson
Define and run a multi-service setup from one file.
The employees API needs a database. The frontend needs the API. Starting that by hand means three docker run commands with a network, a volume, published ports and a set of environment variables in the right order, typed correctly, every time. It works, and it lives in one person's shell history.
Compose replaces those commands with a file. You describe the services, the volumes and the networks you want; Compose works out what is missing and creates it. The file goes into version control next to the code, so the setup is reviewable, repeatable and the same for everyone who clones the repository.
One limit is worth stating before the example, because it prevents a familiar argument. Compose runs containers on one machine. It is a strong fit for development, for automated tests in a pipeline, and for small single-host deployments. It does not schedule work across several machines, it does not replace a failed host, and its update behaviour is basic. That work belongs to an orchestrator such as Kubernetes.
name: employees
services:
db:
image: postgres:16
environment:
POSTGRES_DB: employees
POSTGRES_USER: api_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- employees-db-data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
api:
build:
context: ./api
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_HTTP_PORTS: "8080"
ConnectionStrings__Employees: Host=db;Port=5432;Database=employees;Username=api_user;Password=${POSTGRES_PASSWORD}
ports:
- "8080:8080"
depends_on:
- db
web:
build:
context: ./web
environment:
# Rendering on the server happens inside this container
API_BASE_URL: http://api:8080
# This one is read by the browser, which is not on this network
NEXT_PUBLIC_API_BASE_URL: http://localhost:8080
ports:
- "3000:3000"
depends_on:
- api
volumes:
employees-db-data:- name sets the project name. Compose prefixes the containers, the network and the volumes with it, so two projects on one machine do not collide. Leave it out and the directory name is used, which is fine until someone renames the folder.
- There is no version: line at the top. Older Compose file formats required one and current Compose ignores it — if you meet one in an existing file, it is doing nothing.
- image: runs a published image. build: points at a directory containing a Dockerfile and Compose builds it. A service uses one or the other.
- The database publishes to 127.0.0.1 only, so a database client on this machine can connect and the surrounding network cannot. The API does not need the published port at all.
- Host=db in the connection string is the service name. Compose puts every service on one network and resolves those names, which is the mechanism from the Networks lesson, set up for you.
- employees-db-data is declared at the bottom and mounted where PostgreSQL stores its data. It is a named volume, so it outlives every container in this file.
- The frontend needs two addresses for one API, and this catches people. Code running inside the web container reaches http://api:8080. Code running in the browser cannot resolve api at all, because the browser is on your machine rather than on the Compose network, so it needs the published address.
- depends_on fixes the start order and nothing else. Read the callout below before relying on it.
The keys in that file, and what each one is responsible for:
- services
- Each entry becomes one container. The key is the service name, and that name is also the hostname other services use to reach it.
- image / build
- Where the image comes from: pulled from a registry, or built from a directory in this repository. build takes a context and optionally a dockerfile path and build arguments.
- environment / env_file
- Configuration for the container, exactly as the Environment Variables lesson described. environment overrides the same name from env_file.
- ports
- host:container, the same as -p. Needed only for services you reach from your own machine, and you can bind to 127.0.0.1 to keep a port off the network.
- volumes (on a service)
- A named volume or a bind mount, with the same syntax as -v. Bind mounts are written relative to the Compose file, which makes them portable between checkouts.
- volumes (at the top level)
- Declares the named volumes the project owns. Compose creates them on first use and prefixes them with the project name.
- depends_on
- Start order. In its long form it can also wait for a dependency's health check to pass. Neither form knows whether the application inside is ready.
- networks
- Optional. Compose creates a default network and attaches every service, which is what most projects want. Declare extra networks when you want one service to have no route to another.
Everything in the file is scoped to the project. A project named employees produces containers called employees-db-1, employees-api-1 and employees-web-1, a network called employees_default, and a volume called employees_employees-db-data. Two checkouts with different project names run side by side without interfering, which is how you keep a feature branch's database away from your main one.
Because every service is on that default network, no addresses appear anywhere in the configuration. The API connects to db, the frontend's server-side code connects to api, and Compose resolves both. Published ports are a separate path that exists only for traffic from your machine.
Compose also compares the file with what is already running. Change one service's configuration and run up again: that container is replaced, the others are left alone. The comparison is on configuration, not on source code, which matters for the services Compose builds.
# Build what needs building, create the network and volume, start everything
docker compose up -d
# What the project is running, and on which ports
docker compose ps
# Logs for one service, followed
docker compose logs -f api
# Every service interleaved, which shows the order things happened in
docker compose logs
# A shell in a running service, addressed by service name
docker compose exec api sh
# Rebuild the API image after a code change and replace only that container
docker compose up -d --build api
# Stop and remove the containers and the network. Named volumes are kept
docker compose down
# The same, and delete this project's named volumes. The database goes with them
docker compose down -v- up -d creates whatever is missing and starts it in the background. Without -d your terminal stays attached to the interleaved logs and Ctrl+C stops the project, which is a reasonable way to work while you are editing the file.
- Commands take service names rather than container names, so they keep working after a container is recreated with a new numbered name.
- logs for one service is the narrow question; logs for all of them is how you see that the API tried to connect before the database had finished starting.
- --build is needed after a source change, because Compose reuses the image it built earlier. A code change that appears to do nothing is usually a missing --build.
- down removes the containers and the project network, and deliberately leaves named volumes in place. Running up -d afterwards gives you a clean set of containers with the same data.
- down -v adds the volumes to that list. Read the warning below before typing it.
Summary
- Compose describes services, volumes and networks in one file that lives with the code
- Every service joins a project network and is reachable at its service name, so no addresses appear in the configuration
- depends_on controls start order, not readiness, so the application still needs connection retry logic
- up -d, ps, logs and down are the daily commands; --build is needed after a source change
- down keeps named volumes and down -v deletes them, permanently
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Watch the readiness gap, then close it
Bring the project up from a cold start with docker compose up -d, then read docker compose logs api immediately. Look for connection attempts that failed before the database was accepting them.
Now add a healthcheck to the db service and change api to depend on it with condition: service_healthy. Bring the project down and up again, and compare the logs.
Show solution
On the first run the API usually logs at least one failed connection, and possibly exits, because the database container was running while PostgreSQL was still initialising. With the health condition in place that gap closes and the cold start is clean.
The reason to do both halves is to see what the health condition does and does not buy you. It fixes the start-up race on this machine. It does nothing for a database that becomes unavailable an hour later, because Compose has already finished orchestrating anything.
That is why the same project wants both: ordering so a cold start is predictable, and retry logic in the application so a running system tolerates a dependency that comes and goes.
db:
image: postgres:16
environment:
POSTGRES_DB: employees
POSTGRES_USER: api_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- employees-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U api_user -d employees"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
api:
build:
context: ./api
depends_on:
db:
condition: service_healthyThink about it
Think about it
The frontend service has two API addresses: API_BASE_URL set to http://api:8080 and NEXT_PUBLIC_API_BASE_URL set to http://localhost:8080. A new developer removes the second one, on the grounds that duplicating a value is untidy.
What breaks, and why does the tidy-looking version not work?
Show solution
Pages that render on the server keep working, because that code runs inside the web container, where api resolves. Anything the browser fetches fails, because the browser is a process on your machine and has no access to the Compose network or its names.
The two values are not a duplicate. They are two different vantage points on the same service: one request is made from inside the network, the other from outside it. The address that works depends on who is asking, which is the same rule the Networks lesson used for localhost.
There is a broader point for containerised frontends. Any configuration that reaches the browser has to be expressed in terms the browser can use — a public URL, or a relative path served through a reverse proxy that fronts both services. The proxy option removes the duplication properly, by giving both callers one address, and it costs you an extra service to run.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.