Learn every important part of a compose.yaml file: YAML basics, services, images, builds, ports, environment, volumes, networks and health checks.

Bishal Biswas
Self Learner
Reading a Compose file with confidence
In What is Docker Compose? you ran the notes-api and a database with a short compose.yaml. In this lesson you will take a Compose file apart, piece by piece, so you can read any Compose file you find and write your own.
You will learn:
- just enough YAML to avoid the common errors,
- the top-level sections of a Compose file,
- the service settings you will use most:
image,build,ports,environment,volumes,networks,restart,healthcheckand more, - how to check your file with
docker compose config.
Just enough YAML
YAML is a text format for settings. Compose files are YAML. It has only a few rules.
Maps: key: value
A map is a list of names with values. Indentation (spaces at the start of a line) shows what belongs to what:
db:
image: postgres:17-alpine # "image" belongs to "db"
restart: unless-stopped # so does "restart"
Lists: lines starting with -
A list is several items in order:
ports:
- "3000:3000"
- "9229:9229"
The rules that trip people up
| Rule | Example |
|---|---|
| Use spaces, never tabs | Two spaces per level is normal |
| A space after the colon | image: nginx:1.29-alpine, not image:nginx |
Comments start with # |
# this is a comment |
| Quote port mappings | "3000:3000", not 3000:3000 |
Quote values like yes, no, on, off |
ENABLED: "yes", or YAML may read them as true or false |
Note: Why quote ports? Old YAML rules can read numbers with a colon, like
22:22, as a time value in base 60 and turn them into a single number. Quotes make sure Compose sees text. It is a good habit for every port line.
The top-level sections
A Compose file has a few top-level keys, written with no indentation. Only services is required.
name: notes # optional project name (default: the folder name)
services: # the containers to run (required)
api: ...
db: ...
volumes: # named volumes used by the services
notes-data:
networks: # extra networks, if the default one is not enough
backend:
secrets: # sensitive values, mounted as files
db_password:
file: ./db_password.txt
| Key | What it holds |
|---|---|
name |
The project name. Sets the prefix for containers, networks and volumes |
services |
One entry per part of your app |
volumes |
Named volumes. A service can only use a named volume listed here |
networks |
Custom networks. Without this, every service joins one network called default |
secrets |
Secret files that services can read at /run/secrets/<name>. Covered in Environment Variables and Secrets in Compose |
Warning: Do not add a
version:line. It belonged to the old Compose v1. Today it is ignored and only causes a warning.
Services: the heart of the file
Each entry under services describes one part of your app. The name you give it (like api or db) is also its DNS name on the project network.
image: use a ready-made image
services:
cache:
image: redis:8-alpine
Compose pulls the image from a registry if it is not on your machine yet. Always use a specific tag, not latest (you learned why in Image Names, Tags and Digests).
build: build your own image
The short form points at a folder with a Dockerfile:
services:
api:
build: .
The long form lets you set more options:
services:
api:
build:
context: . # the build context folder
dockerfile: Dockerfile # the Dockerfile to use (this is the default name)
target: production # build only up to this stage of a multi-stage file
args:
NODE_VERSION: "24" # build arguments for ARG lines
image: notes-api:1.0.0 # name the built image
If you give both build and image, Compose builds the image and tags it with that name. args sets values for ARG lines, which you met in Build Arguments and Environment Variables: ARG vs ENV. target picks a stage from a multi-stage Dockerfile.
ports: publish ports
services:
api:
ports:
- "3000:3000" # host 3000 to container 3000
- "127.0.0.1:9229:9229" # only this machine can reach the debugger port
The format is the same as -p with docker run: HOST:CONTAINER. There is also a long form, which is easier to read:
ports:
- target: 3000 # port inside the container
published: "3000" # port on the host
host_ip: 127.0.0.1 # optional: listen only on this address
Tip: Only publish what you need to reach from outside. Services reach each other on the project network without any
ports. The database usually has noportsat all.
environment and env_file: settings for the app
You can write environment variables as a map:
services:
api:
environment:
PORT: "3000"
DATABASE_URL: postgres://notes:devpassword@db:5432/notes
Or as a list:
environment:
- PORT=3000
- DATABASE_URL=postgres://notes:devpassword@db:5432/notes
Both do the same thing. Pick one style and keep it. You can also load variables from a file:
env_file:
- .env.api
You will learn the details, including ${VAR} values and secrets, in Environment Variables and Secrets in Compose.
volumes: keep and share data
The short form looks like -v:
services:
db:
volumes:
- notes-data:/var/lib/postgresql/data # named volume (listed at the top level)
web:
volumes:
- ./public:/usr/share/nginx/html:ro # bind mount, read-only
volumes:
notes-data:
How does Compose know which is which? A source that starts with . or / is a bind mount (a folder on your computer, see Bind Mounts: Sharing Folders with a Container). A plain name is a named volume (see Docker Volumes: Keeping Data Safe). In Compose, relative paths like ./public are relative to the folder of the Compose file, which is much nicer than $(pwd).
The long form spells everything out:
volumes:
- type: bind
source: ./public
target: /usr/share/nginx/html
read_only: true
networks: choose networks
Without a networks key, every service joins the project’s default network. To separate parts of your app, define networks and list them per service:
services:
web:
image: nginx:1.29-alpine
networks: [frontend]
api:
build: .
networks: [frontend, backend] # the api joins both
db:
image: postgres:17-alpine
networks: [backend]
networks:
frontend:
backend:
internal: true # no internet access for this network
This is the same idea you built by hand in Bridge Networks and Container DNS: web can reach api, but not db.
command and entrypoint: change what runs
services:
api:
build: .
command: ["node", "--watch", "src/server.js"] # replaces the image's CMD
command replaces the image’s CMD, and entrypoint replaces its ENTRYPOINT. You learned the difference in CMD vs ENTRYPOINT: Choosing What a Container Runs. Use the list form, like in a Dockerfile, so signals reach your app.
restart: what to do when a container stops
services:
api:
restart: unless-stopped
The values are the same as with docker run: "no", on-failure, always and unless-stopped. Quote "no", because a bare no is read as false. See Restart Policies and Resource Limits.
healthcheck: is the service really working?
services:
db:
image: postgres:17-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 10s # how often to check
timeout: 3s # how long one check may take
retries: 5 # failures in a row before "unhealthy"
start_period: 10s # grace time while the service starts
This works like the HEALTHCHECK instruction from Health Checks: Telling Docker Your App Is Working. CMD runs a program directly. CMD-SHELL runs the text through a shell.
depends_on: start order
services:
api:
depends_on:
db:
condition: service_healthy # wait until db's health check passes
With condition: service_healthy, Compose starts the API only after the database reports healthy. The full story is in Startup Order with depends_on and Health Checks.
Other useful settings
| Setting | What it does | Example |
|---|---|---|
container_name |
Fixed container name instead of project-service-1 |
container_name: notes-db |
user |
Run as this user | user: "1000:1000" |
working_dir |
Working folder inside the container | working_dir: /app |
expose |
Document a port for other services (does not publish) | expose: ["3000"] |
deploy.resources.limits |
Limit memory and CPU | memory: 256M, cpus: "0.50" |
profiles |
Only start this service when a profile is chosen | profiles: [debug] |
read_only |
Read-only root file system | read_only: true |
tmpfs |
Memory-only folders | tmpfs: [/tmp] |
network_mode |
Use host or none mode |
network_mode: none |
Tip: Avoid
container_nameunless you really need it. A fixed name stops you from running two copies of the project, and Compose’s own names already work well.
Variables in the Compose file
You can use ${VAR} anywhere in the file. Compose fills in values from your shell or from a file called .env next to compose.yaml:
services:
api:
image: notes-api:${API_TAG:-dev} # use API_TAG, or "dev" if it is not set
# .env (read automatically by Compose)
API_TAG=1.2.0
${API_TAG:-dev} means “the value of API_TAG, or dev if it is empty or missing”. Different setups for development and production are covered in Compose Profiles, Override Files and Multiple Environments.
Checking your file: docker compose config
Before you start anything, ask Compose to read and print the file:
docker compose config
It shows the final file after filling in variables and defaults. If there is a mistake, it tells you where:
yaml: line 7: mapping values are not allowed in this context
This error usually means a missing space after a colon or a wrong indent near that line. To check the file without printing it, use docker compose config --quiet. No output means the file is valid.
Putting it all together
The code panel shows a complete compose.yaml for notes-api with an API, a PostgreSQL database and a Redis cache, using most of the settings from this lesson. Read it top to bottom. Every line has a comment explaining it.
Common mistakes
Mixing tabs and spaces
YAML fails with confusing errors. Set your editor to insert spaces for YAML files.
Forgetting to list a named volume at the top level
If a service uses notes-data:/var/lib/postgresql/data, the top-level volumes: must include notes-data:. Otherwise Compose says the volume is undefined.
Writing restart: no without quotes
YAML reads no as false. Write restart: "no".
Using container ports and host ports the wrong way round
"8080:80" means host 8080 to container 80. The container port is always on the right.
Putting real secrets in the file
The Compose file is usually committed to Git. Keep real passwords out of it. Use placeholders for development and secrets for anything real.
Quick recap
- YAML uses indentation with spaces,
key: valuemaps and-lists. Quote ports and values like"no". - Top-level keys:
name,services(required),volumes,networks,secrets. Noversion:. - Each service uses
imageorbuild, and oftenports,environment,volumes,networks,restart,healthcheckanddepends_on. - Paths starting with
.or/are bind mounts. Plain names are named volumes. ${VAR:-default}fills values from your shell or.env.- Run
docker compose configto check and see the final file.
Next, you will learn the commands to run, watch and manage your Compose app in Docker Compose Commands: up, down, logs and exec.
# compose.yaml for notes-api: API + PostgreSQL + Redis
# Check it: docker compose config
# Start it: docker compose up -d
# Project name (default would be the folder name)
name: notes-api
services:
# ---------- The API, built from the Dockerfile in this folder ----------
api:
build:
context: .
dockerfile: Dockerfile
# Name the image Compose builds. API_TAG comes from the shell or .env, default "dev"
image: notes-api:${API_TAG:-dev}
# Host port 3000 to container port 3000 (quoted, always)
ports:
- "3000:3000"
environment:
PORT: "3000"
# Service names work as host names on the project network
DATABASE_URL: postgres://notes:devpassword@db:5432/notes
REDIS_URL: redis://cache:6379
# Start only after the database health check passes
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: unless-stopped
# Limit memory and CPU so one service cannot starve the others
deploy:
resources:
limits:
memory: 256M
cpus: "0.50"
networks: [frontend, backend]
# ---------- PostgreSQL: no ports, only the api reaches it ----------
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: notes
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: notes
# Named volume keeps the data when containers are removed
volumes:
- notes-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
restart: unless-stopped
networks: [backend]
# ---------- Redis cache ----------
cache:
image: redis:8-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
networks: [backend]
# ---------- Named volumes ----------
volumes:
notes-data:
# ---------- Networks: frontend for public traffic, backend for data ----------
networks:
frontend:
backend:
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready