Docker Compose Tutorial: compose.yaml, Services and Commands
Run an app and Postgres from one compose.yaml: services, networks, volumes, secrets, health checks, profiles and watch, checked with docker compose config.
By the end of Part 7, running hello-api took a network, a volume, two long docker run commands and a list of environment variables to remember. Docker Compose puts all of that in one file, compose.yaml, and gives you one command to start, stop and rebuild the whole application. In this part you write that file for hello-api and Postgres, validate it without starting anything, and learn the commands and features you will use every day.
Prerequisites
- Docker with the Compose plugin:
docker compose versionprints v2 or newer (Part 2). This guide uses Compose v5.6.1. - The hello-api project from Part 5:
app.py,requirements.txt, the multi-stageDockerfileand.dockerignore.
The docker compose config output in this part is real: Compose parses and validates files without a Docker daemon. Output of commands that start containers is shown as examples.
docker compose vs docker-compose
Compose started as a separate Python program, docker-compose, with files that began with version: "3.8". That program (Compose v1) stopped getting updates in July 2023. Today Compose is a plugin of the Docker CLI written in Go, and you run it as docker compose, with a space. Compose v2 became v5 in December 2025 (the project skipped v3 and v4 to avoid confusion with the old file versions), and since v5 it hands image builds to Docker Bake, the same build path as docker build.
What this means for your files:
- Name the file
compose.yaml. Compose also findscompose.yml,docker-compose.yamlanddocker-compose.yml, butcompose.yamlis the preferred name. - Drop the
version:line. The file follows the Compose Specification, which has no versions. Compose ignores the key and warns about it. A real run on a file from an old tutorial:
The file is still valid; delete the line and the warning goes away.
The compose.yaml for hello-api
Create the database password as a file. Compose mounts it into the containers as a secret, which keeps its value out of the image and out of the container configuration that docker inspect shows. (The postgres entrypoint reads the file into a variable while it initializes the database, and removes it before the server starts.)
mkdir -p secrets && chmod 700 secrets
openssl rand -base64 24 > secrets/db_password.txt
chmod 644 secrets/db_password.txt # readable inside the container; the 700 directory keeps other host users out
echo "secrets/" >> .gitignore
Then add compose.yaml next to the Dockerfile:
services:
api:
build: .
image: hello-api:${HELLO_API_TAG:-1.0}
ports:
# Only the host can reach the API; a reverse proxy publishes it in production
- '127.0.0.1:${HELLO_API_PORT:-8000}:8000'
environment:
DB_HOST: db
DB_USER: hello
DB_NAME: hello
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
depends_on:
db:
condition: service_healthy
restart: true
restart: unless-stopped
networks:
- frontend
- backend
develop:
watch:
# Rebuild the image and recreate the container when the code or the dependencies change
- action: rebuild
path: ./app.py
- action: rebuild
path: ./requirements.txt
db:
image: postgres:18
environment:
POSTGRES_USER: hello
POSTGRES_DB: hello
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db-data:/var/lib/postgresql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -U hello -d hello']
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
networks:
- backend
adminer:
image: adminer:6
profiles: [debug]
ports:
- '127.0.0.1:8080:8080'
networks:
- frontend
- backend
volumes:
db-data:
secrets:
db_password:
file: ./secrets/db_password.txt
networks:
frontend:
backend:
internal: true
Everything from Parts 5 to 7 is here:
services: one entry per container.apibuilds from the Dockerfile in.and tags the imagehello-api:1.0;dbuses the official image.ports: the same-psyntax, published on127.0.0.1only.dbpublishes nothing.environmentandsecrets: the app reads the password from/run/secrets/db_passwordthroughDB_PASSWORD_FILE, and the postgres image does the same throughPOSTGRES_PASSWORD_FILE.volumes: the named volumedb-dataat/var/lib/postgresql, the Postgres 18 path from Part 6.networks:backendis internal, so the database has no route to the internet;apijoins both networks, like the--internalsetup at the end of Part 7.POSTGRES_USER: hellomakes the image createhelloas a database superuser, which is convenient for development. Part 9 replaces it with an ordinary role that owns thehellodatabase.depends_onwithcondition: service_healthy: Compose startsapionly after thedbhealth check passes. The check uses-h 127.0.0.1on purpose: while the image initializes a new database, it runs a temporary server that listens only on a Unix socket, and a plainpg_isreadywould report ready before the real server accepts TCP connections fromapi.restart: truerestartsapiwhen Compose restartsdb.restart: unless-stopped: the restart policy from Part 4.profiles: [debug]:adminer, a small web UI for databases, starts only when you ask for thedebugprofile.develop.watch: used bydocker compose watch, below.
Compose creates one default network per project if you declare none, and names everything after the project, which defaults to the directory name. In a directory called hello-api, the containers are hello-api-api-1 and hello-api-db-1, the volume is hello-api_db-data, and the networks are hello-api_frontend and hello-api_backend. Inside the project, services reach each other by service name: api connects to db.
Compose mounts a file secret like a read-only bind mount, with the file's permissions from the host. The api container runs as UID 10001, so the file must be readable by it, which is why the commands above set mode 644 explicitly (a strict umask would otherwise create it as 600). The 700 directory keeps other users on the host away from it. Keep the directory out of Git and out of the build context (.dockerignore already excludes secrets/). On a server, prefer a secret store or an orchestrator's secrets (Part 10).
Validate Before You Start
docker compose config parses the file, applies defaults and variables, and prints the result. It catches typos and wrong indentation before anything runs. These are real runs on the file above:
docker compose config api prints the normalized definition of a service and the services it depends on, with every default and variable filled in. With --format json and jq you can pick out the parts you care about:
Note what Compose filled in: the absolute build context, the default Dockerfile name, the secret's target path under /run/secrets/, required: true on the dependency, and the image tag and port from the ${...:-default} defaults.
A typo fails fast, because Compose checks every key against the Compose Specification schema. Here healthcheck is misspelled:
Run docker compose config --quiet in CI for every change to a Compose file.
Variables and the .env File
${HELLO_API_PORT:-8000} is interpolation: Compose replaces it with the variable's value from your shell or from a .env file in the project directory, or with the default after :-. ${VAR:?message} makes a variable required. List the variables a file uses:
Set them in .env to change them per machine without editing compose.yaml:
# Local settings for this machine; not committed
HELLO_API_PORT=8001
Delete the file again (rm .env) before you continue, so the rest of this part uses port 8000.
Interpolation happens when Compose reads the file. That is different from the environment: section, which sets variables inside the container. A .env file is not passed to containers unless you also list it under env_file:. Keep secrets out of both: use secrets:.
Start the Application
docker compose up -d --build
--build builds the api image from the current Dockerfile and code. Without it, Compose builds only when the image is missing, and the hello-api:1.0 image from Part 5 already exists. Compose then creates the networks and the volume, starts db, waits for its health check and starts api. Example output after the build output:
[+] up 6/6
✔ Image hello-api:1.0 Built 21.7s
✔ Network hello-api_backend Created 0.1s
✔ Network hello-api_frontend Created 0.1s
✔ Volume hello-api_db-data Created 0.0s
✔ Container hello-api-db-1 Healthy 6.4s
✔ Container hello-api-api-1 Started 6.6s
hello-api_db-data is a new, empty volume: Compose prefixes names with the project, so it does not reuse hello-db-data from Part 6. Remove the old one with docker volume rm hello-db-data when you no longer need it. Check the result (example output):
docker compose ps
curl -s localhost:8000/visits
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
hello-api-api-1 hello-api:1.0 "gunicorn --bind 0.0…" api 40 seconds ago Up 33 seconds (healthy) 127.0.0.1:8000->8000/tcp
hello-api-db-1 postgres:18 "docker-entrypoint.s…" db 40 seconds ago Up 39 seconds (healthy) 5432/tcp
{"visits":1}
Both containers report healthy: db from the pg_isready check in compose.yaml, api from the HEALTHCHECK in its Dockerfile.
Everyday Commands
| Command | What it does |
|---|---|
docker compose up -d |
Create or update and start everything; only changed services are recreated |
docker compose up -d --build |
Rebuild images first |
docker compose up -d --wait |
Return only when services are running and healthy (useful in CI) |
docker compose ps |
Status of the project's containers |
docker compose logs -f api |
Follow one service's logs |
docker compose exec db psql -U hello |
Run a command in a running service container |
docker compose run --rm api python -V |
Run a one-off command in a new container of a service |
docker compose restart api |
Restart without recreating |
docker compose stop / start |
Stop or start containers without removing them |
docker compose pull |
Pull newer versions of the images |
docker compose down |
Stop and remove containers and networks; volumes stay |
docker compose down -v |
Also remove the named volumes declared in the file: deletes the data |
docker compose logs prefixes each line with the container (example output):
api-1 | [2026-10-23 21:05:12 +0000] [1] [INFO] Starting gunicorn 26.2.0
api-1 | [2026-10-23 21:05:12 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1)
api-1 | [2026-10-23 21:05:12 +0000] [1] [INFO] Using worker: sync
api-1 | [2026-10-23 21:05:12 +0000] [7] [INFO] Booting worker with pid: 7
api-1 | [2026-10-23 21:05:12 +0000] [8] [INFO] Booting worker with pid: 8
docker compose down -v deletes hello-api_db-data and the database in it. Use plain docker compose down unless you really want a fresh database.
Develop with Compose Watch
docker compose watch (or docker compose up --watch) starts the project and watches the paths listed under develop.watch. When you save app.py or requirements.txt, Compose rebuilds the image and recreates the api container. Thanks to the layer cache from Part 5, a code change rebuilds only the last layers, which takes seconds.
Watch has faster actions too. sync copies changed files into the running container, and sync+restart also restarts it (restart and sync+exec exist as well). Both are faster than a rebuild, but they need a container user that can write to the target path. hello-api's code is owned by root and the app runs as UID 10001 (and Part 9 makes the filesystem read-only), so rebuild is the action that works here. Use sync with development images that run as a user who owns the code, typically with a framework that reloads code by itself.
Unlike a bind mount, watch works the same on Linux, macOS and Windows, and it does not expose your whole project directory to the container.
Override Files
Compose merges several files. If a compose.override.yaml exists next to compose.yaml, docker compose loads it automatically, which makes it the usual place for local development settings. This override bind-mounts the code and turns on gunicorn's auto-reload instead of using watch:
services:
api:
command: ['gunicorn', '--bind', '0.0.0.0:8000', '--reload', '--no-control-socket', 'app:app']
volumes:
- ./app.py:/app/app.py:ro
Delete the override again (rm compose.override.yaml) before you continue: Compose would otherwise apply it to every command in this directory, including the hardened setup in Part 9.
Values in later files win, lists like ports are merged, and command replaces the earlier one. With explicit -f flags, Compose loads only the files you name, in order: docker compose -f compose.yaml -f compose.prod.yaml up -d is the usual way to apply production settings (Part 10). For bigger projects, include: pulls in other Compose files as separate sub-projects, and extends: reuses one service definition in another.
Scale a Service
docker compose up -d --scale api=3
This fails as long as api publishes a fixed host port, because three containers cannot all bind 127.0.0.1:8000. To run several copies, remove the host port from the service and put a reverse proxy in front of it, as Part 10 shows. Without the fixed port, the scaled project looks like this (example output):
[+] up 4/4
✔ Container hello-api-db-1 Running 0.0s
✔ Container hello-api-api-1 Started 0.4s
✔ Container hello-api-api-2 Started 0.4s
✔ Container hello-api-api-3 Started 0.5s
Inside the project, the name api then resolves to all three containers, and the hostname field in hello-api's / response shows which one answered.
Common Mistakes
version:at the top. Obsolete; delete it.depends_onwithout a condition. A plain list only orders the start; the app starts before the database accepts connections. Usecondition: service_healthywith a health check.localhostbetween services. Use the service name (db).- Secrets in
environment:or in a committed.env. Usesecrets:and keep secret files out of Git. docker compose down -vby habit. It deletes your data volumes.- Expecting
docker compose upto pull newer images. With a version tag and the default pull policy, it uses the local image if one exists (onlylatesttags are always checked). Rundocker compose pull(orup --pull always) first. - Editing files and expecting running containers to change. After a change to
compose.yaml, rundocker compose up -d; it recreates only the services whose configuration changed. After a change to the code or the Dockerfile, rundocker compose up -d --build, or use watch.
Summary
compose.yamldescribes services, networks, volumes and secrets;docker compose up -dcreates them,downremoves them.- Validate with
docker compose config --quiet; it needs no daemon. - Use health checks with
depends_on: condition: service_healthy, file-based secrets, and internal networks for databases. - Variables come from your shell or
.env; override files and profiles adapt one file to several environments. - Watch rebuilds and recreates a service when its code changes during development.
Next, harden the image and the container in Docker Best Practices.
Keep Learning
Tags
Found an issue?