The standalone docker-compose binary is dead. If you’re still installing it separately or writing version: '3' at the top of your compose files, you’re working with deprecated tooling. Docker Compose is now a CLI plugin, invoked as docker compose (space, not hyphen), and modern compose files no longer need or want the version: key.
This guide covers Docker Compose from the ground up using the current plugin-based architecture. It walks through compose file syntax, multi-container deployments, networking, environment management, custom builds, and production patterns. Every example uses the modern format and has been tested on real systems.
Tested April 2026 with Docker Compose v5.1.1 (plugin) on Debian 13, Rocky Linux 10, and Fedora 42
Prerequisites
Docker Engine with the Compose plugin must be installed. The docker-compose-plugin package ships automatically when you install Docker CE from the official repository.
If Docker isn’t set up yet, follow the guide for your distribution:
Confirm both Docker and Compose are working:
docker --version
docker compose version
You should see version output for both:
Docker version 29.4.0, build b4526ef
Docker Compose version v5.1.1
If docker compose version returns “docker: ‘compose’ is not a docker command,” the plugin isn’t installed. Install it explicitly:
sudo apt install docker-compose-plugin # Debian/Ubuntu
sudo dnf install docker-compose-plugin # Rocky/Fedora/RHEL
Compose File Basics
A compose file is a YAML file (typically named docker-compose.yml) that defines services, networks, and volumes. The modern format starts directly with services:. The old version: key (version: ‘2’, version: ‘3.8’, etc.) was deprecated in Compose v2 and is now ignored entirely. Remove it if you see it in old files.
Here’s the simplest possible compose file:
services:
web:
image: nginx:alpine
ports:
- "8080:80"
That defines a single service called web using the nginx:alpine image, mapping port 8080 on the host to port 80 in the container. Save this as docker-compose.yml and start it:
docker compose up -d
The -d flag runs containers in the background. Compose creates a network, pulls the image, and starts the container:
[+] Running 2/2
✔ Network compose-test_default Created 0.1s
✔ Container compose-test-web-1 Started 0.4s
Check the running services:
docker compose ps
The output confirms the container is up and the port mapping is active:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
compose-test-web-1 nginx:alpine "/docker-entrypoint.…" web 5 seconds ago Up 5 seconds 0.0.0.0:8080->80/tcp
To tear everything down:
docker compose down
This stops and removes the containers and the default network. Volumes are preserved unless you pass -v.
Multi-Container Application
Compose shines when you need multiple services that talk to each other. A typical setup includes an application, a database, and a cache. Each runs in its own container, and Compose handles the networking between them automatically.
Create a project directory and the compose file:
mkdir -p ~/myapp && cd ~/myapp
vi docker-compose.yml
Add the following configuration:
services:
app:
image: node:22-alpine
working_dir: /app
volumes:
- ./app:/app
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://appuser:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:
Several things worth noting here. The app service uses depends_on with a health condition, so it won’t start until PostgreSQL passes its readiness check. The database stores data in a named volume (pgdata) that persists across restarts. Services reference each other by name: the app connects to db and cache because Compose creates DNS entries for each service on the shared network.
Bring the stack up:
docker compose up -d
Compose pulls all images, creates the network, starts PostgreSQL first (because of the dependency), waits for the health check to pass, then starts the app and cache services.
Key Compose Directives
Understanding the core directives lets you handle most real-world compose files. Here’s what each one does.
image vs build
Use image to pull a pre-built image from a registry. Use build when you need a custom image from a Dockerfile:
services:
# Pre-built image
redis:
image: redis:7-alpine
# Custom build
api:
build:
context: ./api
dockerfile: Dockerfile
ports:
- "8080:8080"
The context sets the build directory. Compose looks for a Dockerfile in that directory by default, but you can specify a different filename with the dockerfile key.
ports
Maps host ports to container ports. The format is HOST:CONTAINER:
ports:
- "8080:80" # Host 8080 -> Container 80
- "443:443" # Same port on both
- "127.0.0.1:3306:3306" # Bind to localhost only
Binding to 127.0.0.1 restricts access to the host machine. Without it, the port is exposed on all interfaces, which matters on servers with public IPs.
volumes
Two types: named volumes for persistent data, and bind mounts for sharing files between host and container.
services:
db:
volumes:
- dbdata:/var/lib/postgresql/data # Named volume
- ./init.sql:/docker-entrypoint-initdb.d/init.sql # Bind mount
volumes:
dbdata: # Declare named volumes at the top level
Named volumes survive docker compose down. They’re only removed when you explicitly pass -v. Bind mounts are useful for config files and development code that you want to edit on the host.
environment
Set environment variables inline or load them from a file:
services:
app:
environment:
DB_HOST: db
DB_PORT: 5432
env_file:
- .env
Both methods work. Inline variables are visible in the compose file, which is fine for non-sensitive values. Secrets belong in .env files that stay out of version control.
depends_on with health checks
Plain depends_on only controls startup order, not readiness. The database container might be running but not yet accepting connections. Use the condition field to wait for a healthy state:
services:
app:
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
The app container won’t start until pg_isready returns successfully. This catches the most common race condition in compose deployments.
networks
Compose creates a default bridge network for every project. All services can reach each other by name on that network. Custom networks give you isolation between service groups:
services:
frontend:
networks:
- frontend-net
backend:
networks:
- frontend-net
- backend-net
db:
networks:
- backend-net
networks:
frontend-net:
backend-net:
The frontend service can reach backend, and backend can reach db, but frontend cannot talk to db directly. This is useful for limiting blast radius in multi-tier applications.
restart
Controls what happens when a container exits:
services:
web:
restart: unless-stopped # Restart always, except when manually stopped
worker:
restart: on-failure # Restart only on non-zero exit code
cron:
restart: "no" # Never restart (default)
For production services, unless-stopped is almost always what you want. It survives host reboots (assuming Docker is enabled at boot) and container crashes, but respects manual docker compose stop commands.
Resource limits
Set CPU and memory constraints to prevent a single container from consuming all host resources:
services:
app:
deploy:
resources:
limits:
cpus: "2.0"
memory: 512M
reservations:
cpus: "0.5"
memory: 256M
Limits are hard caps. Reservations guarantee minimum resources. On a shared host running multiple services, these prevent one misbehaving container from starving the others.
Essential Commands Reference
These are the commands you’ll use daily when managing compose deployments.
| Command | Description |
|---|---|
docker compose up -d | Start all services in background |
docker compose down | Stop and remove containers |
docker compose down -v | Stop and remove containers + volumes |
docker compose ps | List running services |
docker compose logs -f | Follow logs from all services |
docker compose logs app | Logs from a specific service |
docker compose exec app sh | Shell into a running container |
docker compose build | Build or rebuild images |
docker compose pull | Pull latest images |
docker compose restart | Restart all services |
docker compose config | Validate and view resolved config |
A few that deserve extra attention: docker compose config is invaluable for debugging. It resolves all variables, merges overrides, and shows the final computed YAML. If something isn’t working, run it first. And docker compose down -v is destructive, since it deletes named volumes. Use it only when you actually want to wipe persistent data.
Environment Variables and .env Files
Hardcoding credentials in a compose file works for development but is a bad habit to carry into production. Use .env files instead.
Create a .env file in the same directory as your compose file:
vi .env
Add your variables:
POSTGRES_USER=appuser
POSTGRES_PASSWORD=strongpassword123
POSTGRES_DB=myapp
APP_PORT=3000
Reference them in the compose file with ${VARIABLE} syntax:
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
app:
ports:
- "${APP_PORT}:3000"
Verify that variables resolve correctly:
docker compose config
The output shows the fully resolved YAML with actual values substituted. If a variable is missing, Compose warns you here rather than failing silently at runtime.
Keep .env out of version control. Add it to your .gitignore and commit a .env.example with placeholder values so other developers know which variables are required.
Custom Dockerfiles with Compose
When pre-built images don’t fit your needs, use the build directive to build custom images directly from Compose.
Create a project structure:
mkdir -p ~/myproject/api
vi ~/myproject/api/Dockerfile
Write a Dockerfile for the API service:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]
Reference it in the compose file:
services:
api:
build:
context: ./api
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
NODE_ENV: production
Build and start:
docker compose up -d --build
The --build flag forces a rebuild even if the image already exists. Without it, Compose reuses cached images, which can cause confusion when you’ve changed the Dockerfile or source code.
Networking in Compose
Every Compose project gets a default bridge network named <project>_default. Services on the same network resolve each other by service name. If your compose file defines a db service, other containers on that network can connect to it using db as the hostname.
For most setups, the default network is sufficient. Custom networks become useful when you want to isolate services from each other, for example separating a frontend tier from a backend tier so the frontend can’t reach the database directly.
A three-tier setup with network isolation:
services:
frontend:
image: nginx:alpine
ports:
- "80:80"
networks:
- frontend-net
backend:
build: ./backend
networks:
- frontend-net
- backend-net
db:
image: postgres:17-alpine
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- backend-net
networks:
frontend-net:
backend-net:
volumes:
pgdata:
The backend service sits on both networks, acting as a bridge. The frontend can reach backend over frontend-net, and backend can reach db over backend-net. There is no path from frontend to db.
Inspect the networks Compose creates:
docker network ls | grep myproject
You’ll see both custom networks listed along with the default one:
NETWORK ID NAME DRIVER SCOPE
a1b2c3d4e5f6 myproject_backend-net bridge local
f6e5d4c3b2a1 myproject_frontend-net bridge local
Production Considerations
Compose works well beyond development if you follow a few practices that prevent the most common production failures.
Pin image versions. Using postgres:latest in production means your database image can change between pulls without warning. A minor version bump might be fine, but a major version upgrade can break schemas and clients. Always pin to a specific version like postgres:17-alpine or redis:7.4-alpine.
Set restart policies. Every production service should have restart: unless-stopped. Without it, containers stay dead after a crash or host reboot. You’ll only discover the problem when users start complaining.
Limit resources. A memory leak in one container shouldn’t take down the entire host. Set deploy.resources.limits.memory on every service. If a container hits the limit, Docker kills it and the restart policy brings it back, which is far better than an OOM killer taking out random processes on the host.
Keep secrets out of compose files. Use .env files for sensitive values and add .env to .gitignore. Commit a .env.example with placeholder values so teammates know which variables to set.
Use named volumes for data. Bind mounts are convenient for development but fragile in production. Named volumes are managed by Docker, survive container recreation, and can be backed up with docker volume inspect to find the mount point. The key thing to remember: docker compose down preserves volumes. Only docker compose down -v removes them.
Update safely. Pull new images and recreate containers without downtime:
docker compose pull
docker compose up -d
Compose only recreates containers whose images have changed. Services with unchanged images keep running. For zero-downtime deploys with health checks and rolling updates, consider Docker Swarm mode or Kubernetes.
Migrating from docker-compose (Hyphenated)
If you’re coming from the standalone docker-compose binary (V1), the migration is straightforward. The command syntax is almost identical, with a space replacing the hyphen:
| Old (deprecated) | New (current) |
|---|---|
docker-compose up -d | docker compose up -d |
docker-compose down | docker compose down |
docker-compose logs | docker compose logs |
docker-compose exec | docker compose exec |
Remove the version: key from your compose files. It served a purpose in Compose V1 to select the schema version, but V2+ determines the format automatically. Leaving it in produces a warning on every run.
One behavioral difference to watch for: container naming changed. Compose V1 used underscores (project_service_1), while V2 uses hyphens (project-service-1). If you have scripts or monitoring that reference containers by name, update the patterns.
Uninstall the old standalone binary to avoid confusion:
sudo rm /usr/local/bin/docker-compose
The plugin-based docker compose is the only supported path going forward.
Troubleshooting Common Issues
Error: “port is already allocated”
Another process on the host is already using the port you’re trying to map. Find it:
sudo ss -tlnp | grep 8080
Either stop the conflicting service or change the host port in your compose file. The container port (right side of the colon) stays the same.
Error: “network not found”
This happens after renaming the project directory or changing the project name. Compose prefixes network names with the project name, and stale references don’t match. Clean up orphan networks:
docker network prune
Then bring the stack up again. Compose recreates the networks with the correct names.
Container exits immediately after starting
Check the container logs for the reason:
docker compose logs app
Common causes include missing environment variables, wrong image tags, or application errors. If the process exits with code 0, it completed successfully but had nothing to keep running. Web servers, databases, and background workers need a foreground process. A container running a one-shot script exits when the script finishes.
Service can’t connect to another service by name
DNS resolution between services only works within the same Docker network. If you’re using custom networks, verify that both services are on the same network. Run docker compose config and check the networks section for each service. A service on frontend-net cannot resolve the hostname of a service that only exists on backend-net.