A full example
A typical web app: a Node service, a Postgres database, and a Redis cache, wired together and persisted.
services:
web:
build: .
ports:
- "8080:3000"
environment:
DATABASE_URL: postgres://app:app@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7-alpine
volumes:
db-data:Running it
docker compose up -d # build if needed, start everything in the background
docker compose ps # see what's running
docker compose logs -f web # follow logs for one service
docker compose exec web sh # shell into a running service
docker compose down # stop and remove containers + network
docker compose down -v # also remove named volumes (deletes data!)Why "web" can reach "db" by name
Compose creates a dedicated bridge network for the project and attaches every service to it, registering each service name in Docker's embedded DNS. That's the whole mechanism behind postgres://app:app@db:5432/app — db resolves to whichever container is running the db service, even after it's recreated with a new IP. See volumes & networking for the same mechanism without Compose managing it for you.
depends_on and health
Plain depends_on: [db] only waits for the container to start, not for Postgres inside it to be ready to accept connections. A condition: service_healthy check (paired with a healthcheck on that service) makes Compose actually wait until the dependency reports healthy.
Environment-specific overrides
Compose automatically merges docker-compose.yml with docker-compose.override.yml if present — the standard way to keep local-dev tweaks (bind-mounting source, exposing extra ports) out of the file you ship to production.
services:
web:
volumes:
- .:/app
environment:
NODE_ENV: development
command: npm run devOr keep environments fully separate and pick the file explicitly:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dProfiles
Tag optional services with a profile so they only start when asked for — handy for a debugging tool or a seed job you don't want running by default.
services:
adminer:
image: adminer
profiles: ["debug"]
ports:
- "8081:8080"docker compose --profile debug up -d.env files and variable substitution
Compose automatically reads a .env file next to the compose file and substitutes ${VAR} anywhere in the YAML — the standard way to keep secrets and per-environment values out of a file you commit to git.
POSTGRES_PASSWORD=change-me
APP_PORT=8080services:
web:
ports:
- "${APP_PORT}:3000"
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}Add a default so the file still works if the variable isn’t set: ${APP_PORT:-8080}.
Resource limits
The same CPU/memory limits available to docker run are set per-service under deploy.resources — useful even outside Swarm, since docker compose up applies the limits block too.
services:
web:
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256MTaking this to Kubernetes
Compose and Kubernetes solve the same problem for very different environments -- a services: block doesn’t translate line for line into manifests. See coming from Compose for how each piece of a compose file maps across, and where it doesn't.
Using an existing network or volume
By default Compose creates its own network and any named volumes from scratch. external: true instead points a service at a network or volume that already exists — created by another compose project, or by hand — rather than creating a new one.
services:
web:
networks:
- shared
networks:
shared:
external: true
name: shared-edge-network