Docker Compose: Running Multi-Container Apps Locally
Compose declares a whole multi-service app in one file so a single command brings the stack up. The file anatomy, service DNS, the depends_on healthcheck gotcha, and the dev loop.
A real application is almost never one container. It is an API, a database, a cache, maybe a queue and a worker, all talking to each other. You can run each of those with a separate docker run command, wiring up ports and networks and env vars by hand, and after the third one you will start keeping the commands in a scratch file because nobody can remember them. Docker Compose is that scratch file done properly: you describe the whole stack - every service, its config, how they connect - in one YAML file, and a single command brings it all up. It is declarative desired state for a local environment, the same idea Kubernetes uses for a cluster, but scoped to one machine and aimed squarely at development and CI. This guide covers what goes in the file, how services find each other, the one gotcha that trips up everyone (depends_on), and the daily workflow.
The problem it solves
Say your app is a Node API, a Postgres database, and a Redis cache. To run that by hand you are looking at something like this:
docker network create myapp-net
docker run -d --name db --network myapp-net \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=myapp \
-v pgdata:/var/lib/postgresql/data postgres:16
docker run -d --name cache --network myapp-net redis:7
docker run -d --name api --network myapp-net -p 3000:3000 \
-e DATABASE_URL=postgres://postgres:secret@db:5432/myapp \
-e REDIS_URL=redis://cache:6379 myapp:latest
That is fragile, unmemorable, and impossible to hand to a teammate as "just run this." Order matters, the flags are easy to fumble, and tearing it down cleanly means remembering every name and the network and the volume. Compose replaces the whole thing with one file and docker compose up. The file is the documentation, it lives in git next to the code, and a new hire can clone the repo and have the entire stack running in one command without knowing any of the plumbing.
The mental model is exactly the one from the Kubernetes guide: you do not issue start commands, you declare the state you want and let the tool make reality match. Compose reads the file, creates the network, creates the volumes, starts the containers in dependency order, and gives them names it manages. Change the file and run up again and it reconciles the difference - recreating only the containers whose config changed.
The compose file anatomy
A Compose file is a compose.yaml (the modern name; docker-compose.yml still works) at the root of your project. The top-level keys you care about are services, volumes, and networks. Here is a full stack for the example above:
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
- db
- cache
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
cache:
image: redis:7
volumes:
pgdata:
Each entry under services is one container the stack will run. Walk through the pieces that matter.
image vs build. A service either pulls a prebuilt image (image: postgres:16) or builds one from a Dockerfile (build: ., where . is the build context directory). Off-the-shelf dependencies like databases use image; your own application code uses build so Compose builds it from your Dockerfile (see the dockerfiles guide for what goes in one). You can give build a longer form to point at a specific Dockerfile or pass build args:
api:
build:
context: .
dockerfile: Dockerfile.dev
args:
NODE_ENV: development
ports. "3000:3000" maps host port 3000 to container port 3000, so localhost:3000 on your laptop reaches the API. The format is HOST:CONTAINER. Crucially, you only need to publish ports you want to reach from your host machine - the API needs it so you can hit it in a browser, but db and cache have no ports here because nothing outside the stack talks to them directly. Services reach each other over the internal network without any published ports at all, which is both simpler and safer.
environment and env_file. Config goes in as environment variables, either inline under environment or from a file with env_file. Do not hardcode secrets in the committed file. The common pattern is a .env file (git-ignored) plus variable substitution:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
env_file:
- .env
There is a subtlety worth knowing: a .env file sitting next to your compose file is read automatically by Compose itself for ${VAR} substitution in the YAML. That is different from env_file:, which passes variables into the container's environment. The auto-loaded .env fills in ${...} placeholders in the file; env_file: sets the running process's env. They often overlap but they are not the same mechanism.
volumes. Two kinds, and the distinction is the whole game. A named volume (pgdata:/var/lib/postgresql/data, backed by the pgdata entry under top-level volumes) is Docker-managed persistent storage - it survives down and container recreation, which is exactly what you want for a database's data. A bind mount maps a host directory straight into the container (./src:/app/src) and is how you get live code reload in development: edit a file on your laptop and the container sees the change immediately, no rebuild. Use named volumes for data that must persist, bind mounts for source you are actively editing.
api:
build: .
volumes:
- ./src:/app/src # bind mount: live code reload
- /app/node_modules # anonymous volume: keep container's node_modules
That second line is a classic trick: bind-mounting ./src would otherwise let your host's (possibly empty or wrong-arch) node_modules shadow the ones installed in the image, so you carve out node_modules with an anonymous volume to keep the container's copy.
networks. By default, Compose creates one network for your project and attaches every service to it. You rarely need to declare networks explicitly. What matters is what that default network buys you, which is the next section.
How services find each other: Compose DNS
This is the feature that makes Compose feel like magic the first time. Every service on the shared network is reachable from every other service by its service name, which acts as a hostname. In the file above, the api service connects to Postgres at postgres://postgres:secret@db:5432/myapp - that db is not a magic string, it is literally the service name, and Compose's built-in DNS resolves it to the database container's current IP.
You never hardcode IP addresses, and you never need to publish the database's port to the host just so the API can reach it. Container-to-container traffic stays on the internal network and uses service names. If you rename a service, you update the connection string; that is the only coupling. This is the same idea as a Kubernetes Service giving pods a stable name, scaled down to a single Compose project.
The rule to remember: service name is the hostname inside the stack; published ports are only for reaching a service from your host. Half the "why can't my API connect to the database" confusion comes from people trying to use localhost:5432 from inside the API container. Inside a container, localhost is that container itself - the database is at db:5432, not localhost:5432.
depends_on and its limit
Here is the gotcha that bites everyone, and it is worth getting completely right because it is a favorite interview question and a real source of flaky startups.
depends_on controls start order. In the file above, depends_on: [db, cache] tells Compose to start db and cache before it starts api. Reasonable. But read the promise carefully: it waits for the dependency container to start, not to be ready. Postgres takes a few seconds to initialize and begin accepting connections. Compose starts the db container, immediately considers the dependency satisfied, and launches api - which then tries to connect to a database that is still booting, gets connection-refused, and crashes. It works on a warm machine and fails on a cold one, which is the worst kind of bug.
depends_on in its plain list form does not solve readiness. The real fix is a healthcheck on the dependency plus a depends_on condition that waits for it:
services:
api:
build: .
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
Now the sequence is correct: Compose starts db, runs pg_isready on a loop until it passes, marks the service healthy, and only then starts api. The condition: service_healthy is doing the real work; without a healthcheck defined on the target, that condition has nothing to wait on. start_period gives the container a grace window during which failing checks do not count against retries, so a slow-booting service is not marked unhealthy prematurely - the same idea as a Kubernetes startup probe.
Two honest caveats. First, a healthcheck proves the dependency is up, but your application should still handle a dropped database connection at runtime and retry, because dependencies restart while the app is running, not just at boot. Healthchecks fix the startup race; they do not remove the need for connection resilience. Second, condition: service_healthy only helps for services that have a meaningful readiness check - for something like cache where "started" is close enough, service_started is fine.
The command workflow
Compose is a small set of subcommands under docker compose. These are the ones you run every day.
docker compose up # start the stack, stream all logs to the terminal
docker compose up -d # start detached, run in the background
docker compose up --build # rebuild images before starting (after code changes)
docker compose down # stop and remove containers + the network
docker compose down -v # ...and delete named volumes too (wipes the db!)
docker compose ps # what is running, and its status/ports
docker compose logs -f # follow logs from all services
docker compose logs -f api # follow just one service
docker compose exec api sh # open a shell inside the running api container
docker compose build # build (or rebuild) images without starting
docker compose restart api # restart one service
docker compose stop # stop containers but keep them (down removes them)
The daily development loop with a bind-mounted source directory looks like this. You run docker compose up once and leave it running in a terminal. You edit code on your laptop; because ./src is bind-mounted into the container and your dev server (nodemon, next dev, whatever) watches for file changes, the app reloads automatically - no restart, no rebuild. You only touch Compose again when something structural changes: a new dependency in package.json means docker compose up --build to rebuild the image; a change to the compose file itself means docker compose up -d to reconcile. docker compose down at the end of the day tears the whole thing down cleanly, and because your database uses a named volume, its data is still there tomorrow.
Be careful with down -v: the -v deletes named volumes, which for a database means throwing away all its data. It is the right command when you want a clean slate and the wrong one when you forget what it does. Plain down leaves volumes intact.
Override files and profiles
Two features handle the "same stack, different setups" problem without duplicating YAML.
Override files. Compose automatically merges compose.yaml with compose.override.yaml if the latter exists. The base file holds what is common; the override holds local-dev tweaks - bind mounts for live reload, a dev target in the build, extra debug env. This keeps the base file clean and lets each developer's machine layer on local changes. You can also point at a specific override explicitly:
docker compose -f compose.yaml -f compose.prod.yaml up -d
Compose merges the files left to right. One sharp edge worth knowing, borrowed from hard experience: when the same key appears in both files, most values are replaced, but lists like ports are concatenated, not replaced. If both files define ports, you get both entries, which can silently re-add a port you meant to drop. When you need mutually exclusive port setups, use separate files and pick one, rather than relying on override merging.
Profiles. Profiles let you keep optional services in the same file but only start them when asked. Tag a service with profiles and it stays dormant unless its profile is active:
services:
api:
build: .
db:
image: postgres:16
seed:
build: .
command: npm run seed
profiles: ["tools"] # only runs when the "tools" profile is on
docker compose up # starts api + db, NOT seed
docker compose --profile tools up # starts api + db + seed
Use this for one-off helpers, seeders, admin UIs, or a monitoring stack you only sometimes want - all defined in one file, activated on demand.
"docker compose" vs "docker-compose"
A naming thing that causes real confusion. The original tool was docker-compose (with a hyphen), a separate Python program you installed alongside Docker. That is Compose v1 and it is deprecated. The modern tool is Compose v2, rewritten in Go and shipped as a plugin to the Docker CLI, invoked as docker compose (a space, a subcommand, no hyphen). It comes bundled with current Docker Desktop and Docker Engine installs.
For any new work, use docker compose (v2). The file format is the same; the v2 file no longer needs the old top-level version: "3.8" line (it is ignored now and safe to delete). If you see docker-compose in old docs or scripts, it is the legacy binary - the commands are nearly identical, but v2 is the one that is maintained.
The honest boundary: Compose is not production orchestration
This is the most important thing to be clear-eyed about, because people reach for Compose in production and then wonder why it hurts.
Compose is built for local development, CI pipelines, and simple single-host setups. That is where it shines: reproducible dev environments, spinning up real dependencies for an integration test in CI, running a small stack on one box. Within that scope it is excellent and you should use it freely.
What Compose is not is a production orchestrator. It has no answer for the things production actually demands: it runs on one host, so there is no scheduling across a fleet of machines and no failover if that host dies. There is no rolling update that shifts traffic gradually and rolls back on failure; a up with a changed image just recreates the container with a blip of downtime. There is no self-healing across nodes, no horizontal autoscaling, no built-in load balancing across replicas of a service on different machines, no secrets management beyond env vars and files. The moment you need "keep this running across machine failures, roll out new versions without downtime, and scale horizontally," you have described the job of a real orchestrator.
That job is Kubernetes. The mental model transfers almost directly - declarative desired state, services reachable by name, health checks gating readiness - which is exactly why learning Compose first makes Kubernetes click faster. A Compose service maps conceptually to a Kubernetes Deployment; Compose's service-name DNS maps to a Kubernetes Service; the depends_on: service_healthy pattern is the same instinct as readiness probes. If you want the production picture, read the Kubernetes fundamentals guide - it picks up exactly where Compose's single-host limit ends. Use Compose to develop and test the stack; use Kubernetes to run it in production.
The shape of it
Compose takes a multi-container app and makes it one file and one command. You declare each service (image for dependencies, build for your code), publish only the ports you need from your host, inject config through environment and .env, and choose named volumes for data that must persist versus bind mounts for source you are actively editing. Services find each other by service name over Compose's built-in DNS - never by IP, never through localhost. The one trap to internalize is depends_on: it waits for start, not readiness, so pair it with a healthcheck and condition: service_healthy for anything that takes time to boot. The daily loop is up once, edit with live reload, --build when dependencies change, down when you are done. And the boundary is firm: Compose is for local dev, CI, and simple single-host runs, not for production orchestration - that is Kubernetes, which uses the same desired-state idea at cluster scale.