docker run is fine for one container. Two containers that need to find each other (a web app talking to a database) is where it gets awkward — you start writing shell scripts to orchestrate the order, the networks, the volumes, the rebuilds. Docker Compose is the tool that replaces those scripts with a single declarative file, docker-compose.yml, plus a small set of commands (up, down, logs, exec) that act on the whole stack at once.
This article is the working starting point: the file, what each section does, the commands you use daily, the V1-to-V2 change that breaks a lot of older tutorials, and the pitfalls that catch people the first time.
How does Docker Compose work?
You write a docker-compose.yml file describing one or more services (each service becomes a container), plus optional volumes (for data persistence) and networks (Compose creates one by default). Then docker compose up -d starts the whole stack, docker compose down stops it, and docker compose logs -f tails the output. A two-service web + db stack fits in 20 lines:
services:
web:
image: nginx:alpine
ports:
- "8080:80"
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: change-me
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:Save that as docker-compose.yml, run docker compose up -d in the same directory, and you have an nginx server on port 8080 and a Postgres running alongside it with persistent storage. The rest of this article is what each piece does.
Jump to:
- What changed in Compose V2
- The shape of docker-compose.yml
- services — the heart of the file
- volumes — persistent storage
- networks — container DNS
- Daily commands
- A working example: Nginx + Postgres
- Common pitfalls
- FAQ
What changed in Compose V2
Compose V2 is the only supported version of Docker Compose in 2026. The standalone docker-compose (with a hyphen) binary was Python-based, has been deprecated for years, and was removed from official Docker packages mid-2024. The replacement is docker compose (with a space) — a Go-based CLI plugin that ships with Docker Engine.
In practice:
- Command:
docker compose up, notdocker-compose up. Everything else is the same. - File name:
docker-compose.yml(orcompose.yaml) — unchanged. version:field at the top: no longer required and warned-about. Drop it from new files. Old files withversion: "3.8"still work but you can safely delete the line.- Built-in: if
docker compose versiondoes not work, your Docker install is missing the plugin. On Ubuntu:sudo apt install docker-compose-plugin. On Docker Desktop: it ships built-in.
The Compose file format itself is largely the same as V1; most stack files from 2020-2022 work with no changes once you drop the version: line.
The shape of docker-compose.yml
services: # one or more containers
service-name:
# ... container config ...
volumes: # named volumes (optional)
volume-name:
networks: # custom networks (optional)
network-name:Three top-level keys: services, volumes, networks. Of those, only services is required. Compose auto-creates a default network for the stack so containers can find each other by service name without you defining anything.
services — the heart of the file
Each service is a container. The fields under it are the Compose equivalents of docker run flags.
services:
web:
image: nginx:alpine # use an existing image (alternative: build)
container_name: my-web # explicit name (optional; default is project_service_1)
ports:
- "8080:80" # -p 8080:80
- "443:443"
environment:
NODE_ENV: production # -e NODE_ENV=production
DATABASE_URL: postgres://...
env_file:
- .env # --env-file .env
volumes:
- ./html:/usr/share/nginx/html # bind mount (host path : container path)
- app-data:/data # named volume
networks:
- app-net
depends_on:
- db # start db before web
restart: unless-stopped # --restart unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/healthz"]
interval: 30s
timeout: 5s
retries: 3
app:
build: # build an image from a Dockerfile
context: ./app # path to the Dockerfile + build context
dockerfile: Dockerfile # default; can omit
image: my-app:latest # tag the built imageTwo ways to get an image: image: pulls an existing one, build: builds one from a Dockerfile. You can use both at once — build produces an image and tags it with whatever image: says, which is the common pattern when you want to push the built image to a registry.
depends_on controls start order but not readiness. Compose starts db before web, but web boots before Postgres has finished initializing. For real "wait until the DB is ready" semantics, combine depends_on with a healthcheck:
web:
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5Without the condition: service_healthy, depends_on is just order-of-start. Full breakdown in Docker Restart Policies and Health Checks.
volumes — persistent storage
services:
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data # named volume — survives container removal
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro # bind mount, read-only
volumes:
db-data:The top-level volumes: declares named volumes that Compose manages. Without that block, named volumes referenced in a service still get created, but Compose names them with the project prefix (projectname_db-data); declaring them at the top level keeps things explicit.
Why named volumes for databases: a container's writable layer disappears when you remove the container. docker compose down removes the containers. Without a volume, all your database tables are gone with the next down/up cycle. The named volume persists across container removal. Full breakdown in Docker Volumes vs Bind Mounts.
docker compose down -v removes the named volumes too. That is the data-loss command; do not run it on a stack with state you care about.
networks — container DNS
Compose auto-creates one bridge network for the stack and attaches every service to it. On that network, services reach each other by their service name:
services:
web:
image: my-web
environment:
DATABASE_URL: postgres://user:pass@db:5432/app
db:
image: postgres:16From inside the web container, db resolves to the db container's IP. You do not publish ports for container-to-container traffic; ports: is only for traffic from the host or outside world.
You can declare additional networks if you want isolation (e.g., a frontend net and a backend net so the web service can reach both but the db sits only on the backend net):
services:
web:
networks: [frontend, backend]
db:
networks: [backend]
networks:
frontend:
backend:Full picture in Docker Networking Basics.
Daily commands
# Start the stack in the current directory
docker compose up -d
# Stop and remove containers + networks (volumes survive)
docker compose down
# Stop, remove, AND delete named volumes (data loss)
docker compose down -v
# Tail logs from all services
docker compose logs -f
# Tail logs from one service
docker compose logs -f web
# Shell into a service
docker compose exec web bash
docker compose exec db psql -U postgres
# Restart one service
docker compose restart web
# Rebuild images (after Dockerfile changes)
docker compose build
docker compose up -d --build
# Pull image updates and recreate containers
docker compose pull
docker compose up -d
# Run a one-off command in the service's image
docker compose run --rm web npm test
# Show the merged, validated config
docker compose config
# Stack-scoped container list
docker compose psTwo notes:
- All Compose commands act on the file in the current directory. Either run them from the directory containing
docker-compose.yml, or pass-f path/to/file.yml. - The project name is the directory name by default. That is what Compose prefixes container, volume, and network names with. Override with
-p projectnameor by settingCOMPOSE_PROJECT_NAMEin an.envfile.
A working example: Nginx + Postgres
services:
web:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: change-me-please
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
db-data:Start it:
docker compose up -d
docker compose ps
docker compose logs -f webwebis reachable athttp://localhost:8080. Dropindex.htmlfiles into./htmlon the host and they appear immediately (read-only bind mount).dbis not published to the host. Other containers on this stack reach it asdb:5432. To connect from the host, addports: ["5432:5432"]to thedbservice.- Stop the stack with
docker compose down. Thedb-datavolume survives. Bring it back up withdocker compose up -dand Postgres still has your data.
Common pitfalls
- Using
docker-compose(hyphen) and gettingcommand not found. Compose V1 is gone. Usedocker compose(space). Ifdocker composeitself is missing, install thedocker-compose-pluginpackage. version: "3.x"at the top of the file. No longer needed and warned-about. Delete the line.depends_onwithoutservice_healthy— start order, not readiness. Your app boots before the DB is ready and fails to connect. Add a healthcheck.- Forgetting the named volume on the database. First
docker compose downremoves the container, the writable layer goes with it, and all your tables are gone. Always pin database paths to a named volume. - Publishing the database port unnecessarily. Compose's default network already lets
webreachdb. Only publish if you want to connect from the host (psql, DBeaver, your local app). - YAML indentation. Two spaces, not tabs. The most common "Compose says my file is invalid" cause.
- Env vars from
.envare not in the container. The.envfile in the project directory feeds variable substitution indocker-compose.yml(e.g.,${POSTGRES_PASSWORD}), not the container's environment. To pass env vars into the container, useenvironment:orenv_file:inside a service block.
What to do next
- Docker Cheat Sheet — the single-page reference for all Docker commands, including
docker compose. - How to Run Anything on Your Computer Without Installing It — the hub for the per-tool recipes (MySQL, Postgres, Redis, etc.) using either
docker runor Compose. - Docker Volumes vs Bind Mounts — when to use each in a Compose file.
- Docker Restart Policies and Health Checks — how
depends_on: condition: service_healthyworks in detail.
FAQ
Sources
Authoritative references this article was fact-checked against.
- Docker Compose — official docsdocs.docker.com
- Migrate to Compose V2 — officialdocs.docker.com
- Compose file referencedocs.docker.com

