Running a Multi-Container App With Compose
A web app, a database and a cache started with one command. Writing compose.yaml, service dependencies, and the difference between up, down and down --volumes.
A web app, a database and a cache started with one command. Writing compose.yaml, service dependencies, and the difference between up, down and down --volumes.
Starting your application by hand now takes four commands in a particular order: create the network, start the database with its volume, wait for it, then start the application with the right environment pointing at the right hostname. Get the order wrong and it fails. Onboard a colleague and you are pasting commands into chat.
Compose replaces all of it with one file and one command. By the end
of this lesson you will have a compose.yaml describing a web
application, a database and a cache, started together with
docker compose up, and you will know which of its features are
worth reaching for early.
Compose takes a description of several containers and manages them as one unit. Nothing new happens underneath: it creates the same networks, volumes and containers you were creating by hand, in dependency order, with names derived from your project.
The value is that the description is a file. It lives in the repository next to the code, it is reviewed like code, and running the whole system is one command that behaves identically on every machine.
Create compose.yaml next to your Dockerfile:
services:
api:
build: .
ports:
- '8000:8000'
environment:
DATABASE_HOST: db
DATABASE_PORT: '5432'
LOG_LEVEL: debug
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: devpass
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:Start it:
docker compose upThat one command does everything you were doing by hand, in order.
Create the project network
User-defined, so service names resolve as hostnames. You did not have to mention it in the file.
Create the pgdata volume
Only on the first run. After that it is found and reused, which is why your data survives.
Build the api image
From your Dockerfile, because the service said build: .
Start db, then api
In that order, because of depends_on. The next section is
about how much that promise is actually worth.
Stream both logs into your terminal
Colour-coded by service. Ctrl+C stops everything.
Every concept from the previous four lessons is in those twenty-odd
lines. build: . is your Dockerfile. image: postgres:16 is a
pulled image. ports is -p. environment is -e. volumes is
-v. There is nothing new to learn — only a place to write it down.
Notice what is not in that file: any mention of a network. Compose creates one for the project and puts every service on it, and because it is a user-defined network, Docker's DNS resolves each service name as a hostname.
So DATABASE_HOST: db works because the service is called db. If
you renamed the service to database, the hostname would be
database. That is the whole discovery mechanism.
The same rule about ports applies, and it is worth repeating because Compose makes it easy to get wrong:
The API talks to db:5432 whether or not that ports line exists —
services on the same network reach each other on the container's own
port. Publishing is only for you, connecting from your laptop with
psql or a GUI client.
Two of these deserve emphasis.
docker compose up --build is the one people forget. Plain up
reuses the image it built last time; a change to your source code
therefore does nothing until you ask for a rebuild. If your code
change appears to have no effect, this is nearly always why.
docker compose down removes the containers and the network and
leaves your volumes alone. Adding -v removes the volumes too,
which means your database:
Growing the system is now an edit rather than a new command to remember. Adding a Redis cache:
Two details worth naming. command: overrides the image's default
command, exactly like putting a command after the image name in
docker run. And redis:7-alpine is the same Redis on a much
smaller base image — Alpine Linux — which the next lesson explains.
For local work you want your source code bind-mounted so edits take effect without a rebuild:
That combination — bind mount plus a reloading dev server — is what makes container-based development feel normal rather than slow.
Configuration belongs in a file rather than inline once there is
more than a little of it. Compose reads .env from the project
directory automatically and substitutes into the compose file:
The :?required form fails immediately with a clear message if the
variable is missing, and :-app supplies a default. Between them
you can make a compose file that refuses to start half-configured.
There is also env_file:, which passes a whole file into the
container rather than substituting into the compose file itself:
This is the Compose behaviour that produces the most confusion, so it is worth a pair.
Bad — the API starts before the database can accept connections and exits:
Good — waits until the database reports itself healthy:
Plain depends_on only orders starting: Compose starts db
first, then immediately starts api. Postgres takes a few seconds
to initialise, so on a cold start the API's first connection is
refused and the container exits before the database is ready. It
then works on the second up, because the database is already
warm — which is exactly the shape of bug that survives for months
and only fails in CI.
A health check fixes the start-up race and does not make the problem go away, because databases also restart after start-up. Three layers, and they are cheap enough to have all of them.
Fixes the cold-start race.
Compose waits until the database says it is ready, instead of only waiting until its container has started.
Fixes restarts during normal running.
Databases restart in production too, and no orchestrator can promise otherwise. This one lives in your application code.
Fixes the case where you lost anyway.
If the container does exit, it comes back — on crash, and on machine boot.
Two mechanisms handle "the same system, slightly different". Both are worth knowing exist before you need them.
Profiles keep optional services out of the default up:
Overrides layer files on top of each other. A base
compose.yaml describes the system; compose.override.yaml is
picked up automatically and holds development-only changes like
bind mounts and debug flags. Deployment then uses the base plus its
own file:
docker compose config is worth running now: it prints the file
with every variable substituted and every override merged, which is
the fastest way to see what Compose thinks you asked for.
Next comes the lesson you will use most often in practice — diagnosing a container that will not start, with a method rather than a guess. After that, making your images smaller and safer, and publishing them so somebody else can run what you built.
# .env, untracked
POSTGRES_PASSWORD=devpass
POSTGRES_DB=appdb:
image: postgres:16
ports:
- '5432:5432' # only needed to reach it from YOUR machinedocker compose up # start, attached, logs in terminal
docker compose up -d # start in the background
docker compose up --build # rebuild images first
docker compose ps # what is running in this project
docker compose logs -f api # follow one service's output
docker compose exec api sh # a shell in a running service
docker compose restart api # restart one service
docker compose stop # stop, keep the containers
docker compose down # stop and remove containers+networkdocker compose down # safe: data survives
docker compose down -v # deletes pgdata, permanentlyservices:
api:
build: .
ports:
- '8000:8000'
environment:
DATABASE_HOST: db
REDIS_URL: redis://cache:6379/0
depends_on:
- db
- cache
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: devpass
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
cache:
image: redis:7-alpine
command: redis-server --save 60 1
volumes:
pgdata:services:
api:
build: .
ports:
- '8000:8000'
volumes:
- .:/app # your code, live
- /app/node_modules # keep the image's copy, if Node
environment:
DATABASE_HOST: db
FLASK_DEBUG: '1'
command: flask --app app run --host 0.0.0.0 --port 8000services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
POSTGRES_DB: ${POSTGRES_DB:-app}services:
api:
build: .
env_file: .env.developmentapi:
build: .
depends_on:
- dbapi:
build: .
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 3s
retries: 5services:
mailhog:
image: mailhog/mailhog
profiles: ['dev-tools']docker compose up # no mailhog
docker compose --profile dev-tools up # with itdocker compose -f compose.yaml -f compose.prod.yaml up -d# compose.yaml — the keys you will use constantly
services:
api:
build: . # build from ./Dockerfile
image: myapp:0.1.0 # ...or use a prebuilt image
ports:
- '8000:8000' # host:container
environment: # inline variables
DATABASE_HOST: db # a service name is a hostname
env_file: .env.development # ...or a whole file
volumes:
- .:/app # bind mount, for dev
- pgdata:/var/lib/data # named volume, for data
command: python app.py # override the image's CMD
restart: unless-stopped # restart on crash and on boot
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
retries: 5
volumes:
pgdata: # declare every named volume# Lifecycle
docker compose up # start attached
docker compose up -d # start detached
docker compose up --build # rebuild first (forgetting this
# is why your change did nothing)
docker compose down # remove containers + network
docker compose down -v # ...and DELETE the volumes
# Working with a running project
docker compose ps # services in this project
docker compose logs -f api # follow one service
docker compose exec api sh # shell into a running service
docker compose run --rm api pytest # one-off task in a new container
docker compose restart api
docker compose config # the merged file, fully resolved