Docker Compose
Defining a multi-container app in docker-compose.yml, environment variables, and .env files.
Why Compose exists
Running a realistic app by hand means juggling several long docker run commands — one for the app, one for the database, one for a cache — each with its own flags for networks, volumes, ports, and environment variables, run in the right order, every single time. Docker Compose replaces all of that with a single declarative YAML file describing every service and how it fits together, plus two commands to bring the whole stack up or down.
A complete multi-container example
Here's a realistic docker-compose.yml for a Node.js app backed by PostgreSQL and Redis:
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://appuser:${DB_PASSWORD}@db:5432/appdb
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
cache:
image: redis:7-alpine
volumes:
- redisdata:/data
restart: unless-stopped
volumes:
pgdata:
redisdata:
A few things worth calling out:
build: .on theappservice tells Compose to build an image from the Dockerfile in the current directory, rather than pulling a pre-built image likedbandcachedo.- Service names double as hostnames. Compose automatically creates a network for the whole file and gives each service DNS resolution by its service name — that's exactly why
DATABASE_URLcan just saydbandREDIS_URLcan just saycache, with no manual network setup needed (this is the same mechanism covered in Volumes and Networking, just wired up for you automatically). depends_onwithcondition: service_healthymakes theappservice wait until the database actually reports healthy (via itshealthcheck), not just "started" — a plaindepends_on: [db]only waits for the container to start, which is often too early for a database that's still initializing.- Named top-level
volumes:declarespgdataandredisdataas Docker-managed named volumes, persisting data acrossdocker compose downand backupagain (as long as you don't pass-v, covered below). ${DB_PASSWORD}references an environment variable — covered next.
Environment variables and .env files
Hardcoding secrets like POSTGRES_PASSWORD directly into docker-compose.yml is a bad habit — that file is typically committed to version control. Compose automatically reads a .env file sitting next to docker-compose.yml and substitutes ${VARIABLE} references with its values:
# .env (add this file to .gitignore — never commit real secrets)
DB_PASSWORD=s3cr3t-local-only
$ docker compose config # renders the final config with variables substituted, without starting anything
For values an individual service's application code needs directly (not just Compose's own substitution), the environment: key under that service — as used for NODE_ENV and the constructed DATABASE_URL above — is what actually injects them into the container's process environment.
Bringing the stack up and down
# Build images (if needed) and start every service, in the background
$ docker compose up -d
[+] Running 4/4
✔ Network myapp_default Created
✔ Container myapp-db-1 Healthy
✔ Container myapp-cache-1 Started
✔ Container myapp-app-1 Started
# See the status of every service defined in the file
$ docker compose ps
NAME IMAGE STATUS
myapp-app-1 myapp-app Up 10 seconds
myapp-db-1 postgres:16-alpine Up 15 seconds (healthy)
myapp-cache-1 redis:7-alpine Up 15 seconds
# Follow logs across all services, or just one
$ docker compose logs -f
$ docker compose logs -f app
# Rebuild the app image after a Dockerfile or source change, then restart it
$ docker compose up -d --build app
# Stop and remove containers and the network (volumes are kept by default)
$ docker compose down
# Stop and remove containers, network, AND named volumes — deletes persisted data
$ docker compose down -v
docker compose down (no -v) is the safe default — your database's actual data survives a down/up cycle, since it lives in the pgdata named volume, not in the container itself. Only reach for -v when you genuinely want to wipe persisted data, such as resetting a local dev database to a clean state.
Common mistakes
- Committing a
.envfile with real secrets to version control — it should be listed in.gitignore, with only a.env.example(showing which variables are needed, without real values) committed instead. - Using a plain
depends_on: [db]for a service that needs the database to be truly ready, not just started — the container can be "up" while Postgres is still initializing internally, causing intermittent connection failures on the firstdocker compose up. - Running
docker compose down -vout of habit and being surprised that local development data (like seeded test users) is gone —-vexplicitly deletes named volumes along with the containers. - Forgetting that changing a Dockerfile requires
--build(or a separatedocker compose build) —docker compose upalone won't rebuild an image just because its Dockerfile changed.
Interview questions
Q: What problem does Docker Compose solve that plain docker run doesn't?
It replaces a set of manually-run, easy-to-get-wrong docker run commands (with their networking, volume, and environment flags) with one declarative YAML file describing the whole multi-container application, brought up or down with a single command. It also automatically creates a shared network with name-based DNS resolution between services, which otherwise has to be set up manually.
Q: Why might depends_on alone not be enough to guarantee a database is ready before the app starts?
Plain depends_on only waits for the dependency's container to start, not for the application inside it (like Postgres) to finish initializing and actually be ready to accept connections. Adding a healthcheck to the database service and using depends_on: { db: { condition: service_healthy } } makes Compose wait for the database to report itself healthy first.
Q: What's the difference between docker compose down and docker compose down -v?
Both stop and remove the containers and the network Compose created. docker compose down -v additionally removes any named volumes declared in the file, permanently deleting persisted data (like a database's contents) — without -v, that data survives and is reattached the next time you run docker compose up.