▶Prefer to watch? This short video runs this page's main commands in a real lab, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
docker compose [options] <command> [service...]
docker compose up -d
Docker Compose runs a whole application made of several containers (a web app, a database, a cache) from one
YAML file. You describe the services, networks and volumes once, then start, stop and rebuild everything with a
single command. It is the easiest way to run multi-container setups on one machine: development, testing, home
labs and small servers. For a cluster of machines, look at Kubernetes instead.
This sheet uses the current Compose (v2, the "docker compose" command with a space). The old standalone
"docker-compose" command (with a hyphen) is Compose v1. It is retired, and most commands are the same.
Install and check
docker compose version # Compose v2 ships with Docker Desktop and current Docker Engine packages
sudo apt install docker-compose-plugin # Debian/Ubuntu (with Docker's apt repository set up)
sudo dnf install docker-compose-plugin # Fedora/RHEL family (with Docker's repository)
docker version # is the Docker daemon running and are you allowed to use it?
sudo usermod -aG docker $USER # run docker without sudo (log out and back in). Members of the docker group are effectively root
If "docker compose" is not found but "docker-compose" is, you have the old v1. Upgrade to the plugin.
The compose file
Name it compose.yaml (preferred) or docker-compose.yml in your project folder. Compose finds it automatically.
The old top-level "version:" line is obsolete: leave it out. A minimal file:
services:
web:
image: nginx:1.27
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
docker compose up -d # start it, then open http://localhost:8080
docker compose down # stop and remove it
Everyday commands
docker compose up # create and start everything (logs in the foreground, Ctrl+C stops)
docker compose up -d # detached: run in the background
docker compose up -d --build # rebuild images first
docker compose up -d web # start only one service (and what it depends on)
docker compose up -d --force-recreate # recreate containers even if nothing changed
docker compose up -d --remove-orphans # also remove containers for services no longer in the file
docker compose up -d --scale worker=3 # run three copies of a service
docker compose up -d --wait # wait until services are running/healthy before returning
docker compose down # stop and remove containers and networks (volumes are KEPT)
docker compose down -v # ALSO delete named volumes: your database data is gone
docker compose down --rmi local # also remove images built by Compose
docker compose stop # stop containers, keep them docker compose start start them again
docker compose restart # restart everything docker compose restart web one service
docker compose pause / unpause # freeze and unfreeze
docker compose kill # force stop
docker compose rm # remove stopped containers
See what is happening
docker compose ps # containers of this project and their state
docker compose ps -a # include stopped ones
docker compose ls # every Compose project on this machine
docker compose logs # all logs
docker compose logs -f web # follow one service's logs
docker compose logs --tail 100 -t web # last 100 lines, with timestamps
docker compose top # processes inside each container
docker compose images # images used
docker compose port web 80 # which host port maps to container port 80
docker compose events # live events
docker compose config # validate and print the final merged file (great for debugging)
docker compose config --services # list service names
docker compose config -q # quiet: only report errors
Run commands in containers
docker compose exec web sh # a shell inside a RUNNING container (bash if the image has it)
docker compose exec -u root web sh # as root
docker compose exec db psql -U postgres # run a client tool
docker compose run --rm web npm test # start a NEW one-off container for a command, then remove it
docker compose run --rm --no-deps web sh # without starting dependencies
docker compose cp web:/etc/nginx/nginx.conf ./nginx.conf # copy files out (or in)
exec runs inside an existing container. run creates a new temporary container from the service's definition.
Build and images
docker compose build # build all services that have a "build:" section
docker compose build --no-cache web # ignore the layer cache
docker compose build --pull # pull newer base images first
docker compose pull # download newer images for image: services
docker compose push # push built images to a registry
Update a stack to the latest images: docker compose pull && docker compose up -d && docker image prune -f
Options that go before the command
-f compose.prod.yaml # use this file (repeat -f to merge several files)
-p myproject # project name (default: the folder name). Sets container, network and volume prefixes
--env-file .env.prod # use another environment file
--profile debug # also start services in that profile
--project-directory ./app # where to find the file and relative paths
--dry-run # show what would happen without doing it (newer versions)
Example: docker compose -f compose.yaml -f compose.prod.yaml -p shop up -d
A realistic multi-service example
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
APP_ENV: production
image: myorg/web:1.0
ports:
- "127.0.0.1:8080:8000" # only reachable from this machine
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy # wait until the database reports healthy
cache:
condition: service_started
restart: unless-stopped
networks:
- backend
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
networks:
- backend # no "ports:" so the database is NOT exposed to the host
cache:
image: redis:7-alpine
restart: unless-stopped
networks:
- backend
networks:
backend:
volumes:
dbdata:
Service options you will use most
image: nginx:1.27 Image to run (pin a version, do not use latest)
build: ./app Build from a folder (or the long form: context, dockerfile, args, target)
container_name: web Fixed name (prevents scaling: usually leave it out)
command: ["npm", "start"] Override the image's default command
entrypoint: ["/entrypoint.sh"] Override the entrypoint
working_dir: /app Working directory
user: "1000:1000" Run as a non-root user
ports: "HOST:CONTAINER". "8080:80", "127.0.0.1:8080:80", "8080:80/udp"
expose: ["3000"] Document a port for other containers only (does not publish it)
environment: A map (KEY: value) or a list (- KEY=value)
env_file: [.env, web.env] Load variables from files
volumes: "name:/path" (named volume), "./dir:/path" (bind mount), add :ro for read-only
tmpfs: [/tmp] In-memory folder
networks: [frontend, backend] Networks this service joins
depends_on: Start order (see below)
restart: unless-stopped no | always | on-failure | unless-stopped
healthcheck: Command Docker runs to decide if the service is healthy
labels: { app: shop } Metadata (also used by reverse proxies like Traefik)
profiles: ["debug"] Only start when that profile is enabled
extra_hosts: ["host.docker.internal:host-gateway"] Let the container reach the host machine
dns: [1.1.1.1] Custom DNS servers
logging: driver: json-file, options: { max-size: "10m", max-file: "3" }
init: true Use a tiny init process that reaps zombies
stop_grace_period: 30s Time to wait after SIGTERM before killing
pull_policy: always always | missing | never | build
platform: linux/amd64 Force a CPU architecture
Networking (how containers find each other)
- Compose creates one default network per project. Every service joins it automatically
- Containers reach each other by SERVICE NAME as the hostname: db:5432, cache:6379, web:8000
- Use the container's own port, not the published one: db:5432 (not localhost:5432)
- "ports:" publishes to the host. Services that only other containers use need NO ports section
- "127.0.0.1:8080:80" keeps a published port private to the machine. "8080:80" opens it on all interfaces
- Extra networks isolate groups: put the database on a backend network that the reverse proxy cannot reach
- External network shared with other projects: networks: { proxy: { external: true } } (create it with: docker network create proxy)
docker compose exec web ping db # test name resolution and connectivity from inside a container
Start order and health (depends_on does NOT wait by itself)
depends_on:
db:
condition: service_started # only wait for the container to start (default)
cache:
condition: service_healthy # wait until its healthcheck passes
migrate:
condition: service_completed_successfully # wait for a one-shot job to finish OK
A container being "started" does not mean the database accepts connections. Add a healthcheck and use service_healthy.
Healthcheck examples:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] (write $$ to keep a literal $)
test: ["CMD", "redis-cli", "ping"]
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$${MYSQL_ROOT_PASSWORD}"]
Also give apps retry logic. Databases can restart at any time.
Volumes and data
volumes:
- dbdata:/var/lib/postgresql/data # named volume: managed by Docker, survives "down", best for databases
- ./config:/etc/app:ro # bind mount: a folder from your machine (great for code and config in development)
- /var/run/docker.sock:/var/run/docker.sock # DANGER: gives the container control of Docker (root on the host). Avoid
docker volume ls docker volume inspect myproject_dbdata docker volume rm myproject_dbdata
Named volumes get the project name as a prefix (myproject_dbdata). "docker compose down -v" deletes them.
Bind mount permissions: files created in a container may belong to root or another user on the host. Set user: "1000:1000".
Back up a named volume:
docker run --rm -v myproject_dbdata:/data -v "$PWD":/backup alpine tar czf /backup/dbdata.tgz -C /data .
Variables and secrets
Compose reads a file named .env in the project folder automatically and uses it for ${VARIABLE} substitution:
# .env
DB_PASSWORD=change-me
TAG=1.0
# in compose.yaml
image: myorg/web:${TAG}
${VAR} Value of VAR (empty and a warning if unset)
${VAR:-default} Use "default" if VAR is unset or empty
${VAR-default} Use "default" only if VAR is unset
${VAR:?message} Stop with an error message if VAR is unset or empty (great for required settings)
$$ A literal dollar sign
Variables set in your shell override the .env file. Check the result with: docker compose config
env_file: sends the variables INTO the container. .env feeds ${...} substitution in the file itself (they are different things)
Secrets (better than putting passwords in environment variables):
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt # keep this file out of git
Add .env and secrets/ to .gitignore. Commit an .env.example with fake values instead.
Multiple environments (override files)
compose.yaml The shared base
compose.override.yaml Merged AUTOMATICALLY on top of it (good for local development)
compose.prod.yaml Used only when you ask: docker compose -f compose.yaml -f compose.prod.yaml up -d
# compose.override.yaml (development)
services:
web:
build: .
volumes:
- ./src:/app/src # live code
environment:
DEBUG: "1"
ports:
- "8000:8000"
Merge rules: later files override single values, and lists (ports, volumes) are combined. Use "docker compose config" to see the result.
Include other files: include: [ ./db/compose.yaml ] (Compose 2.20+)
Reuse blocks with extensions and anchors:
x-common: &common
restart: unless-stopped
logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }
services:
web:
<<: *common
image: nginx:1.27
Profiles (optional services)
services:
web: { image: nginx:1.27 }
adminer:
image: adminer
profiles: ["debug"] # not started by default
docker compose up -d # starts web only
docker compose --profile debug up -d # starts web and adminer
COMPOSE_PROFILES=debug docker compose up -d # same, with an environment variable
Resource limits and hardening
services:
web:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 128M
read_only: true # read-only root filesystem
tmpfs: [/tmp]
cap_drop: [ALL] # remove Linux capabilities
security_opt:
- no-new-privileges:true
user: "1000:1000"
pids_limit: 200
Limits stop one service from taking the whole machine. Check them with: docker stats
Development helpers
docker compose up --watch # sync or rebuild automatically when files change (needs a develop: watch: section)
services:
web:
build: .
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: package.json
Or use a bind mount plus a dev server with hot reload, as in the override file above.
docker compose run --rm web sh # a throwaway shell with all the service's settings
docker compose logs -f --tail 50 # follow everything
Update, back up and clean up
docker compose pull && docker compose up -d # update to newer images (pinned tags only move when YOU change them)
docker compose down && docker compose up -d # a clean restart
docker compose config > full.yaml # save the fully resolved configuration
docker system df # what is using disk space
docker image prune -f # remove dangling images
docker compose down -v --rmi local --remove-orphans # remove EVERYTHING from this project, including data. Be sure
Practical one-liners
# What is running in this project, and is it healthy?
docker compose ps
# Restart one service and watch it come back
docker compose restart web && docker compose logs -f web
# Rebuild and restart only the service you changed
docker compose up -d --build web
# Open a shell in the database and run a query
docker compose exec db psql -U app -d app -c "SELECT count(*) FROM users;"
# Dump a Postgres or MySQL database from a container
docker compose exec -T db pg_dump -U app app > backup.sql
docker compose exec -T db mysqldump -u root -p"$DB_ROOT_PASSWORD" app > backup.sql
# Restore it
docker compose exec -T db psql -U app app < backup.sql
# Which ports are published?
docker compose ps --format "table {{.Name}}\t{{.Ports}}"
# Scale a worker up and down
docker compose up -d --scale worker=5
docker compose up -d --scale worker=1
# Start fresh (data preserved)
docker compose down && docker compose up -d --build
# Wipe the database and start over (data deleted)
docker compose down -v && docker compose up -d
# Validate a file in CI
docker compose -f compose.yaml config -q && echo OK
# Start on boot: restart: unless-stopped in the file, and make sure Docker itself starts at boot
sudo systemctl enable --now docker
# Follow one line of output from every service, with names
docker compose logs -f --no-log-prefix web | grep -i error
Move from docker-compose (v1) to docker compose (v2)
- Replace "docker-compose" with "docker compose" (a space, not a hyphen). The file format is the same
- Delete the old "version:" line. v2 ignores it and prints a warning
- v2 names containers with hyphens (project-web-1) where v1 used underscores (project_web_1). Update scripts that use the old names
- Compose v1 has reached end of life and no longer receives fixes
- Add this alias if you like typing it the old way: alias docker-compose='docker compose'
Common problems
"no configuration file provided: not found" You are in the wrong folder, or the file is not named compose.yaml. Use -f, or cd to the project
"yaml: line N: mapping values are not allowed" Indentation or a missing space after a colon. Use spaces (never tabs). Check with docker compose config
"services.web refers to undefined network/volume" Declare it under the top-level networks: or volumes: section
"port is already allocated" / "address already in use" Another program or container uses that host port. Change the left side of "8080:80", or stop the other one (ss -tulpn | grep 8080)
"Conflict. The container name is already in use" A leftover container. docker compose down --remove-orphans, or docker rm -f NAME. Avoid container_name
Changes to the compose file have no effect Run docker compose up -d again (it recreates changed services). Config in a bind-mounted file may need a restart
Code changes are not in the container You changed code but the image was built earlier: docker compose up -d --build, or use a bind mount for development
"depends_on" does not wait for the database It only waits for the container to START. Add a healthcheck and condition: service_healthy, and retry in the app
Old database password still works or new one fails The volume kept the old data. The env vars only apply the FIRST time a database is created. down -v resets it (deletes data)
Container cannot reach another by name Different networks, or wrong hostname. Both must share a network. Use the service name and the container port
"connection refused" to localhost from a container localhost inside a container means the container itself. Use the service name (db) or host.docker.internal
Permission denied on a bind mount User ID mismatch. Set user:, fix ownership with chown, or use a named volume. On SELinux systems add :z or :Z to the volume
Variable is empty / "variable is not set" warning Not in .env or your shell. Use ${VAR:?msg} for required ones. Check docker compose config
"$" in a value gets eaten Write $$ for a literal dollar sign
Service keeps restarting docker compose logs web. Often a crash, a bad command, or a missing setting. Temporarily remove restart: to see it fail
Out of disk space docker system df, then docker image prune and docker builder prune. Old volumes and build cache add up
"the attribute version is obsolete" Harmless warning. Delete the version: line
"docker: permission denied ... docker.sock" You are not in the docker group (or need sudo), or the daemon is not running
Tips
Use "docker compose config" whenever something looks wrong. It shows exactly what Compose will run.
Pin image versions (postgres:16, not latest) so an update never surprises you.
Do not publish database and cache ports. Let containers talk to them over the internal network, and bind anything you must publish to 127.0.0.1.
Use healthchecks with depends_on condition: service_healthy, and give your app retry logic.
Put data in named volumes, and remember that "down -v" deletes it. Back up volumes before you experiment.
Keep secrets out of git: use .env (in .gitignore), an .env.example with fake values, and Compose secrets for sensitive values.
Use compose.override.yaml for development conveniences and a separate prod file, instead of one file full of exceptions.
Add restart: unless-stopped, log rotation (max-size, max-file) and resource limits for anything that runs on a server.
Never mount /var/run/docker.sock into a container you do not fully trust. It is the same as handing over root.
Compose is great for one machine. When you need many machines, self-healing and rolling updates, move to Kubernetes.