Running one container is easy. But real apps have multiple services — a web server, a database, a cache, maybe a worker process. Running each one manually with long docker run commands gets painful fast.
Docker Compose lets us define all our services in a single docker-compose.yml file and manage them together. One command to start everything. One command to stop everything.
The anatomy of a compose file
services:
web: # service name (also the DNS hostname)
build: . # build from Dockerfile in current dir
ports:
- "3000:3000" # host:container port mapping
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/myapp
depends_on:
- db
- redis
restart: unless-stopped
db:
image: postgres:16-alpine # use a pre-built image
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: myapp
volumes:
- pg-data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pg-data: # named volume for DB persistence
Notice how the web service connects to the database using db as the hostname. Compose automatically creates a custom bridge network and sets up DNS for all services. The service name is the hostname.
Key configuration options
build — tells Compose to build an image from a Dockerfile instead of pulling one.
services:
app:
build:
context: . # build context directory
dockerfile: Dockerfile.prod # non-default Dockerfile name
depends_on — controls startup order. But it only waits for the container to start, not for the service inside to be ready. A database container starting doesn’t mean PostgreSQL is accepting connections yet.
environment — two syntax options that do the same thing.
# List syntax
environment:
- NODE_ENV=production
- PORT=3000
# Map syntax
environment:
NODE_ENV: production
PORT: "3000"
env_file — loads environment variables from a file. Keeps secrets out of the compose file.
services:
app:
env_file:
- .env
Essential commands
# Start all services (detached mode)
docker compose up -d
# Start and rebuild images
docker compose up -d --build
# Stop all services
docker compose down
# Stop and remove volumes too (careful — deletes data!)
docker compose down -v
# View logs
docker compose logs # all services
docker compose logs -f web # follow logs for one service
# Run a command in a running service
docker compose exec db psql -U user -d myapp
# Check status
docker compose ps
# Restart a single service
docker compose restart web
Profiles — optional services
Sometimes we want services that only run in certain situations. Profiles let us group services and start them selectively.
services:
app:
build: .
ports:
- "3000:3000"
db:
image: postgres:16-alpine
volumes:
- pg-data:/var/lib/postgresql/data
adminer:
image: adminer
ports:
- "8080:8080"
profiles:
- debug # only starts with --profile debug
mailhog:
image: mailhog/mailhog
ports:
- "8025:8025"
profiles:
- debug
# Normal start — adminer and mailhog won't run
docker compose up -d
# Start with debug services included
docker compose --profile debug up -d
A practical example
Here’s a compose file for a typical full-stack app — a Node.js API, PostgreSQL, and Redis. This is close to what real production setups look like.
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://app:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
NODE_ENV: production
depends_on:
- db
- cache
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- pg-data:/var/lib/postgresql/data
restart: unless-stopped
cache:
image: redis:7-alpine
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
restart: unless-stopped
volumes:
pg-data:
The api service uses db and cache as hostnames — Compose’s built-in DNS handles the rest. The database data is persisted in a named volume so it survives restarts and deploys.