Writing docker-compose.yml - Services
LEVEL 0
The Problem
You know what Compose is. You know why you need it. Now you need to write a compose file.
But YAML is unforgiving. One wrong indentation, and your file won’t work. There are dozens of options—build, image, command, environment, depends_on, networks, volumes—and you’re not sure which ones you need or what they do.
Let’s build your understanding from scratch.
LEVEL 1
The Concept — The Blueprint
The Concept
Think of docker-compose.yml as a blueprint for a building.
A blueprint has different sections:
- Foundation — What the building stands on
- Rooms — The actual spaces people use
- Utilities — Electrical, plumbing, HVAC
- Connections — Hallways, doors, how rooms connect
A compose file has parallel sections:
version: '3.9' # Which blueprint standard we're using
services: # The "rooms" - your containers
web:
...
database:
...
networks: # The "hallways" - how containers connect
frontend:
backend:
volumes: # The "foundation" - persistent data storage
db-data:
Each section serves a purpose. You declare what you want, and Compose builds it.
LEVEL 2
The Mechanics — Top-Level Keys
The Mechanics
A compose file has a few top-level sections:
version: '3.9' # (Optional) Format version
services: # (Required) Container definitions
...
networks: # (Optional) Network definitions
...
volumes: # (Optional) Volume definitions
...
configs: # (Optional) Configuration files (Swarm mode)
...
secrets: # (Optional) Sensitive data (Swarm mode)
...
For most applications, you’ll use:
services(always)networks(often)volumes(often)
Let’s focus on services in this chapter.
LEVEL 3
Services — Defining Containers
Services
The services section defines your containers.
Each service is a container (or multiple replicas of a container). Each service has a name.
services:
web:
# Configuration for the "web" service
database:
# Configuration for the "database" service
The service name (web, database) becomes:
- The container name (prefixed with project name)
- The hostname that other containers use to connect
- The identifier in Compose commands (
docker compose logs web)
Service Configuration Keys
image — What image to use:
services:
web:
image: nginx:latest
build — Build an image from a Dockerfile:
services:
app:
build: ./app # Path to directory with Dockerfile
Or with more options:
services:
app:
build:
context: ./app # Directory path
dockerfile: Dockerfile.prod
args:
BUILD_VERSION: "1.0"
You use either image or build. Actually, you can use both—build builds the image, image is the name to tag it with:
services:
app:
build: ./app
image: myapp:latest # Tag the built image with this name
container_name — Override the auto-generated container name:
services:
db:
image: postgres:15
container_name: my-postgres # Instead of "project-db-1"
Warning: If you set a custom container name, you can’t scale this service (docker compose up --scale db=3 won’t work).
command — Override the default command:
services:
app:
image: python:3.11
command: python app.py # Override CMD from Dockerfile
Or as an array:
services:
app:
image: python:3.11
command: ["python", "app.py", "--debug"]
entrypoint — Override the entrypoint:
services:
app:
image: myapp
entrypoint: /bin/sh -c
ports — Port mapping (host:container):
services:
web:
image: nginx
ports:
- "80:80" # host port 80 → container port 80
- "443:443"
- "8080:80" # host port 8080 → container port 80
expose — Expose ports to other services (not to host):
services:
api:
image: myapi
expose:
- "8000" # Other services can connect to port 8000
# But it's not published to the host
environment — Environment variables:
services:
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
POSTGRES_USER: admin
Or as an array:
services:
db:
image: postgres:15
environment:
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=myapp
env_file — Load environment variables from a file:
services:
app:
image: myapp
env_file:
- .env # Load from .env file
- .env.production # Can specify multiple files
volumes — Mount volumes or bind mounts:
services:
app:
image: myapp
volumes:
- ./code:/app # Bind mount (local directory)
- app-data:/data # Named volume
- /var/run/docker.sock:/var/run/docker.sock # Absolute path
Volume syntax: <source>:<destination>:<options>
Options:
ro— read-onlyrw— read-write (default)
volumes:
- ./config:/etc/app:ro # Read-only mount
depends_on — Service startup order:
services:
app:
image: myapp
depends_on:
- db
- cache
This ensures db and cache start before app. But it doesn’t wait for them to be ready—just started. We’ll cover readiness in Chapter 11.5.
restart — Restart policy:
services:
app:
image: myapp
restart: unless-stopped
Options:
no— Don’t restart (default)always— Always restart if stoppedon-failure— Restart only if exit code indicates errorunless-stopped— Always restart unless explicitly stopped
healthcheck — Define health check:
services:
web:
image: nginx
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
labels — Add metadata:
services:
web:
image: nginx
labels:
- "com.example.description=Web server"
- "com.example.version=1.0"
working_dir — Set working directory:
services:
app:
image: python:3.11
working_dir: /app
user — Run as specific user:
services:
app:
image: myapp
user: "1000:1000" # UID:GID
LEVEL 4
Full Service Example
Here’s a realistic service definition with common options:
version: '3.9'
services:
web:
# Use official nginx image
image: nginx:alpine
# Custom container name
container_name: blog-web
# Map ports to host
ports:
- "80:80"
- "443:443"
# Mount configuration and SSL certs
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- static-content:/usr/share/nginx/html
# Connect to frontend network
networks:
- frontend
# Restart policy
restart: unless-stopped
# Health check
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 5s
retries: 3
# Wait for app service to start first
depends_on:
- app
# Labels for documentation
labels:
com.example.service: "web"
com.example.tier: "frontend"
volumes:
static-content:
networks:
frontend:
LEVEL 5
Service Scaling
You can run multiple instances of a service:
docker compose up --scale app=3
This creates 3 containers for the app service: project-app-1, project-app-2, project-app-3.
Requirements for scaling:
- Don’t use
container_name(each instance needs a unique name) - Don’t map to specific host ports (use random ports or don’t publish to host)
services:
app:
image: myapp
# NO container_name
# NO fixed port mapping like "8000:8000"
# This is OK:
ports:
- "8000" # Random host port → container port 8000
# Or just expose to other services:
expose:
- "8000"
When services are scaled, you typically use a load balancer (like nginx) in front:
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
depends_on:
- app
networks:
- web
app:
image: myapp
expose:
- "8000"
networks:
- web
Then scale app:
docker compose up --scale app=5
Nginx load-balances across all 5 app instances.
Engine status: planned. The shell remains visible while the artifact execution is prepared.