How to Set Up and Use Docker Compose
Learn how to write Compose files, define networks and volumes, manage environment variables, and control multi-container Docker stacks with the modern Compose CLI plugin.
Before you start
- ▸A Linux system with internet access and sudo privileges
- ▸Basic familiarity with the Docker CLI and container concepts
- ▸At least 2 GB of free disk space for images and volumes
Docker Compose turns multi-container applications into a single declarative file. Instead of typing long docker run commands with ports, volumes, and environment variables threaded together, you describe every service in a compose.yaml file and manage the whole stack with one command. This guide covers installation, writing a real Compose file, networking, persistent volumes, environment variables, and day-to-day lifecycle operations.
Installing Docker Compose
Modern Docker ships Compose as a CLI plugin (docker compose, no hyphen). The legacy standalone binary (docker-compose) is deprecated. Use the plugin unless you have a specific reason not to.
Debian / Ubuntu
sudo apt update
sudo apt install -y docker.io docker-compose-plugin
Fedora / RHEL 9 / Rocky 9
sudo dnf install -y docker docker-compose-plugin
sudo systemctl enable --now docker
Arch Linux
sudo pacman -S docker docker-compose
sudo systemctl enable --now docker
Verify the plugin is available:
docker compose version
Output will look similar to: Docker Compose version v2.27.0 — exact version will vary.
Add your user to the docker group so you can run commands without sudo:
sudo usermod -aG docker $USER
newgrp docker
Compose File Structure
Compose reads compose.yaml (preferred) or the legacy docker-compose.yml from the current directory. The top-level keys are services, networks, and volumes.
Here is a realistic example: a Node.js application backed by PostgreSQL and a Redis cache.
mkdir myapp && cd myapp
nano compose.yaml
services:
app:
image: node:20-alpine
working_dir: /usr/src/app
volumes:
- ./src:/usr/src/app
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://appuser:secret@db:5432/appdb
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
command: node server.js
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
networks:
default:
name: myapp-net
Key points in this file:
- depends_on with condition — the app service waits for the database healthcheck to pass before starting, preventing connection errors on startup.
- Named volumes (
pgdata,redisdata) persist data acrossdocker compose downand container rebuilds. - Service DNS — services reach each other by service name. The app connects to Postgres at host
db, notlocalhost.
Managing Environment Variables
Hardcoding secrets in compose.yaml is fine for local dev but wrong for production or shared repos. Use an .env file instead.
cat > .env <<'EOF'
POSTGRES_USER=appuser
POSTGRES_PASSWORD=s3cur3p4ss
POSTGRES_DB=appdb
NODE_ENV=production
EOF
Compose automatically loads .env from the project directory. Reference variables with ${VAR} syntax in the Compose file:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
Add .env to .gitignore immediately:
echo ".env" >> .gitignore
You can also use env_file to load variables from a named file per service, which is useful when services need different secret sets:
app:
env_file:
- ./config/app.env
Networks
Compose creates a default bridge network for each project. All services on that network resolve each other by service name. The explicit networks block in the example above just gives it a predictable name — useful when external tools or other Compose stacks need to join.
To connect services across two separate Compose stacks, declare one network as external:
networks:
shared-net:
external: true
Create it once manually:
docker network create shared-net
Services on isolated internal networks (no internet access) use internal: true:
networks:
backend:
internal: true
Volumes in Depth
Compose supports three volume types:
- Named volumes — managed by Docker, survive
compose down, best for databases. Declared under the top-levelvolumes:key. - Bind mounts — map a host path directly into the container. Ideal for source code during development (
./src:/usr/src/app). - tmpfs — in-memory only, lost on container stop. Useful for sensitive temporary data.
List all named volumes:
docker volume ls
Inspect where a named volume actually lives on the host:
docker volume inspect myapp_pgdata
Warning: docker compose down -v deletes named volumes permanently. Use it only when you want a clean slate.
Bringing Services Up and Down
Start the stack
docker compose up -d
The -d flag runs containers in detached mode. Compose pulls missing images, creates networks and volumes, and starts services in dependency order.
View running services and logs
docker compose ps
docker compose logs -f
docker compose logs -f app
Restart a single service
docker compose restart app
Rebuild images after a Dockerfile change
docker compose up -d --build app
Scale a stateless service
docker compose up -d --scale app=3
Only works cleanly when the service does not bind a fixed host port.
Stop without removing containers
docker compose stop
Stop and remove containers and networks (keep volumes)
docker compose down
Full teardown including volumes
docker compose down -v
Running One-Off Commands
Execute a command inside a running service container:
docker compose exec db psql -U appuser -d appdb
Spin up a temporary container for a service (does not start dependent services):
docker compose run --rm app node --version
Verification
After docker compose up -d, confirm everything is healthy:
docker compose ps
All services should show running (or healthy for services with a healthcheck). Check the app is reachable:
curl -I http://localhost:3000
Inspect inter-service DNS resolution from inside a container:
docker compose exec app ping -c 2 db
Troubleshooting
- Service exits immediately — run
docker compose logs <service>to see the error. Often a missing environment variable or failed entrypoint script. - Port already in use — another process owns the host port. Find it with
ss -tlnp | grep :3000and stop it, or change the host port incompose.yaml. - depends_on not waiting long enough — use the
condition: service_healthyform with a properhealthcheckblock, not the defaultservice_started. - Volume permission errors — the container process UID may not match the bind-mount directory owner. Fix with
chownon the host or auser:directive in the service definition. - Changes to compose.yaml not picked up — always run
docker compose up -dagain after edits; Compose detects and recreates only changed services.
Frequently asked questions
- What is the difference between `docker compose down` and `docker compose stop`?
- `stop` halts running containers but leaves them and their networks in place so you can restart them quickly. `down` removes the containers and networks entirely; named volumes are kept unless you add `-v`.
- How do I use a custom Dockerfile instead of a prebuilt image?
- Replace `image:` with a `build:` block pointing to the directory containing your Dockerfile, e.g. `build: ./app`. Run `docker compose up -d --build` to rebuild when the Dockerfile changes.
- Can I use Docker Compose in production?
- Yes for single-host deployments. For multi-host orchestration or high-availability workloads, consider Docker Swarm or Kubernetes. Compose files are compatible with Swarm stacks via `docker stack deploy`.
- Why does my app container start before the database is ready even with depends_on?
- `depends_on` with the default `service_started` condition only waits for the container process to launch, not for the application inside it to be ready. Add a `healthcheck` to the database service and set `condition: service_healthy` on the dependency.
- How do I pass different configurations for development vs production?
- Use a base `compose.yaml` and override files such as `compose.override.yaml` for dev and `compose.prod.yaml` for production. Merge them at runtime: `docker compose -f compose.yaml -f compose.prod.yaml up -d`.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.