The compose.yaml File
compose.yaml at a glance
Section titled “compose.yaml at a glance”Every Compose file has one required top-level key: services. Everything else — networks, volumes, configs — is optional.
services: service-name: image: some-image:tag build: . ports: - "hostPort:containerPort" environment: - KEY=value command: ["executable", "arg"]Each key under services is a service name you choose. Compose starts one container per service by default.
The services key
Section titled “The services key”services is a map. Each entry is a named service. The name becomes both the container identifier and the DNS hostname other services can use to reach it on the internal network.
services: api: image: node:24-alpine db: image: postgres:18-alpineHere api and db are two services. Compose creates one container for each.
image vs build
Section titled “image vs build”Use image to pull a pre-built image from a registry:
services: web: image: nginx:alpineUse build to build an image from a local Dockerfile at build time:
services: app: build: .You can combine them — build says where to find the Dockerfile, and image names the resulting image:
services: app: build: . image: myapp:devports maps a host port to a container port in "host:container" format. Always quote the mapping to avoid YAML interpreting the colon:
services: web: image: nginx:alpine ports: - "8080:80" - "8443:443"environment
Section titled “environment”Pass environment variables into the container either as a list or a mapping:
# list formenvironment: - NODE_ENV=production - PORT=3000
# mapping formenvironment: NODE_ENV: production PORT: "3000"Both forms are equivalent. The list form mirrors shell export syntax and is most common.
command
Section titled “command”Override the default CMD from the image:
services: worker: image: myapp:latest command: ["node", "worker.js"]Pass a plain string for a shell-interpreted command:
services: worker: image: myapp:latest command: node worker.jsNo version key needed
Section titled “No version key needed”Older tutorials show a version: "3.9" key at the top of the file. This is obsolete. The Compose Specification no longer requires or recommends the version key. Modern tooling ignores it. Omit it from all new files.
A complete example
Section titled “A complete example”services: app: build: . ports: - "3000:3000" environment: - NODE_ENV=production - DATABASE_URL=postgres://db:5432/mydb command: ["node", "src/server.js"] db: image: postgres:18-alpine environment: - POSTGRES_PASSWORD=secretHands-on practice
Section titled “Hands-on practice”The snippet below writes a complete compose.yaml and starts it so you can inspect each field in action.
# Write a compose.yaml with two services
cat > compose.yaml <<'EOF'
services:
web:
image: nginx:alpine
ports:
- "8080:80"
environment:
- NGINX_HOST=localhost
command: ["nginx", "-g", "daemon off;"]
echo:
image: alpine
command: ["sh", "-c", "echo 'compose.yaml works!' && sleep 30"]
EOF
# Start both services
docker compose up -d
# Show running containers
docker compose ps
# Read echo output
docker compose logs echo
# Clean up
docker compose downTrade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
| Docker Compose | One compose.yaml file, runs locally right away, fits a dev loop well | Actually only runs on a single host — no self-healing across nodes |
| Kubernetes | Scales and self-heals across many nodes in a cluster | Requires learning manifests, controllers, and much higher operational overhead |
Common mistakes
Section titled “Common mistakes”- Forgetting that
servicesis the only required key, then trying to place service-level config in the wrong spot (like puttingimage:outside a service block). - Adding
version: "3.9"back into a new compose.yaml because it was copied from an old tutorial, when current tooling no longer reads that key. - Forgetting to quote a port mapping (writing
ports: - 8080:80instead of"8080:80"), which can make the YAML parser misread the colon in some cases.
💡 Real-world example
Many dev teams use the same compose.yaml as the starting point for both local development and the production image — getting the services structure right in the compose file first, then taking that same image to deploy on Kubernetes later.