Most real apps don’t run alone. We have our app, a database, maybe a cache, a reverse proxy. Running each container manually with long docker run commands gets painful fast. Docker Compose lets us define all our services in a single YAML file and bring everything up with one command.
Instead of running five docker run commands with flags we’ll definitely mistype, we write a docker-compose.yml once and run docker compose up. Done.
The docker-compose.yml Structure
A compose file defines services (containers), networks (how they talk to each other), and volumes (persistent data). Here’s the anatomy:
# docker-compose.yml
services:
# Each service becomes a container
app:
build: . # build from Dockerfile in current dir
ports:
- "3000:3000" # host:container port mapping
environment:
- NODE_ENV=production # env vars
- DATABASE_URL=postgres://user:pass@db:5432/mydb
volumes:
- ./src:/app/src # bind mount for hot reload
depends_on:
- db # start db before app
- redis
db:
image: postgres:15 # pull from Docker Hub
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data # named volume
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
# Named volumes (Docker manages the storage location)
volumes:
pgdata:
That’s it. Three services, networking, persistence — all in one file.
A Complete Practical Example
Let’s say we’re building a Node.js API with PostgreSQL and Redis. Here’s a real-world compose file:
services:
api:
build:
context: . # Dockerfile location
dockerfile: Dockerfile # explicit Dockerfile name
ports:
- "3000:3000"
env_file:
- .env # load vars from .env file
volumes:
- ./src:/app/src # hot reload during development
depends_on:
- postgres
- redis
restart: unless-stopped # auto-restart on crash
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: ${DB_USER} # reads from .env or shell env
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
command: redis-server --maxmemory 256mb # override default command
ports:
- "6379:6379"
volumes:
pgdata:
Essential Commands
These are the compose commands we use daily.
# Start all services in the background
docker compose up -d
# Stop and remove all containers, networks
docker compose down
# Stop and remove everything INCLUDING volumes (careful — deletes data)
docker compose down -v
# Rebuild images before starting (after Dockerfile changes)
docker compose build
docker compose up -d --build # shorthand: build + start
# View logs from all services
docker compose logs -f # follow all logs
docker compose logs -f api # follow only the api service
# Run a command inside a running service
docker compose exec postgres psql -U user -d mydb
# List running services
docker compose ps
Networking — Services Talk by Name
This is one of the best parts of Compose. All services defined in the same compose file are automatically on the same network. They can reach each other using the service name as the hostname.
postgres://user:pass@postgres:5432/mydbapi connects to redis via
redis://redis:6379
Notice we use postgres and redis as hostnames — not localhost. Inside the Compose network, each service is reachable by its name. This is why the database URL uses @db:5432 instead of @localhost:5432.
Environment Variables in Compose
There are three ways to pass env vars to services:
services:
app:
# 1. Inline — directly in the compose file
environment:
- NODE_ENV=production
- API_KEY=abc123
# 2. From a file — load all vars from a .env file
env_file:
- .env
- .env.local # can load multiple files
# 3. Variable substitution — reference host env or .env file
environment:
- DB_HOST=${DB_HOST:-localhost} # default value if not set
Compose automatically reads a .env file in the same directory as the compose file. So ${DB_USER} in the YAML will be replaced with whatever DB_USER is set to in .env.
The depends_on Gotcha
depends_on controls startup order — it makes sure db starts before app. But here’s the catch: it only waits for the container to start, not for the service inside it to be ready.
PostgreSQL takes a few seconds to initialize. If our app tries to connect immediately, it might fail because Postgres isn’t accepting connections yet.
services:
app:
depends_on:
db:
condition: service_healthy # wait for healthcheck to pass
db:
image: postgres:15
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
timeout: 5s
retries: 5
With condition: service_healthy, Compose waits until the database healthcheck passes before starting our app. This is the proper way to handle it.
Dev vs Production Compose Files
For development we want hot reload, exposed ports for debugging, and verbose logging. For production, none of that. We can use multiple compose files:
# Development — uses both files, dev overrides base
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Production — base file is usually enough
docker compose up -d
In simple language, Docker Compose lets us define our entire multi-container stack in one YAML file — services talk to each other by name, volumes keep data alive, and one command brings everything up or tears it down.