Docker Compose lets you define and run a multi-container application—your app plus its database, cache, and anything else—from a single YAML file with one command, instead of juggling a dozen docker run commands and wiring them together by hand. Using Docker Compose is the standard way developers run multi-service stacks locally, and it's usually the first tool you reach for once a single container isn't enough. This guide covers the Compose file, the essential commands, how services find each other, and the gotchas that trip people up.
What Docker Compose is and when to use it
Real applications are rarely a single container. A typical web app might need a web server, a database, a cache like Redis, and maybe a background worker—four separate containers that all have to start, connect, and share configuration. Running each one with its own docker run command, complete with port mappings, environment variables, and manual network setup, is tedious and easy to get wrong.
Docker Compose solves this by letting you declare your entire stack in one file and bring it all up with a single command. You describe each service, its configuration, and how they relate; Compose handles creating the containers, networking them together, and managing their lifecycle. It builds directly on the concepts in Docker for beginners—images, containers, volumes—so it helps to be comfortable with those first.
The key thing to know about when to use it: Compose is built for single-host scenarios—local development, testing, CI, and simple single-server deployments. It is not a production orchestrator for running containers across many machines with auto-scaling and self-healing; that's the job of Kubernetes, covered in Kubernetes explained simply. Reaching for Compose in local development and Kubernetes in large-scale production is the standard division of labor.
The Compose file: services, networks, and volumes
Everything lives in a file named compose.yaml (the older docker-compose.yml still works). It has a few top-level keys, the most important being services—each service is one container in your stack. Here's a complete example for a web app with a Postgres database and a Redis cache:
services:
web:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://app:secret@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=secret
volumes:
- db-data:/var/lib/postgresql/data
cache:
image: redis:7
volumes:
db-data:
Reading it top to bottom: the web service is built from a Dockerfile in the current directory, publishes port 3000, gets a database URL via an environment variable, and declares it depends_on the database. The db service uses the official postgres:16 image, sets its credentials, and mounts a named volume so the data survives container restarts. The cache service just runs Redis. The top-level volumes key declares the named volume db-data.
Notice what's not here: there's no version: key (it's obsolete in modern Compose and should be omitted), and there's no explicit network—Compose automatically creates one for the project, which is what lets these services find each other.
The essential commands
Modern Compose is invoked as docker compose (two words—it's a built-in plugin now, replacing the old standalone docker-compose command). A handful of commands cover almost everything:
docker compose up -d # create and start all services, in the background
docker compose ps # list the running services
docker compose logs -f web # follow the logs of one service
docker compose exec web sh # open a shell inside the web service
docker compose down # stop and remove containers and the network
docker compose up --build # rebuild images before starting
The core lifecycle is up and down. docker compose up reads your file, creates the network and volumes, and starts every service in dependency order; adding -d runs them detached. docker compose down tears it all back down, removing the containers and network (but keeping named volumes by default, so your data is safe—add -v only when you genuinely want to wipe volumes too). When you've changed a Dockerfile, --build forces a rebuild so you're not running stale images.
How services connect, and using Compose for local development
The feature that makes Compose click is automatic networking. Because Compose puts all your services on a shared network, each service can reach another using its service name as a hostname. In the example above, the web app connects to the database at db:5432—db resolves to the database container automatically, no IP addresses or manual links required. This is why published ports are only needed for services you want to reach from your host machine; services talk to each other internally without publishing anything.
There's one gotcha here that bites nearly everyone: depends_on controls start order, not readiness. It guarantees the db container starts before web, but not that Postgres inside it is actually ready to accept connections—databases take a moment to initialize. So your app can start, immediately try to connect, and fail. The fix is a healthcheck on the database combined with depends_on using condition: service_healthy, or simply building connection-retry logic into your app. Assuming depends_on means "ready" is the single most common Compose mistake.
This networking is what makes Compose superb for local development. With one docker compose up, your whole stack—app, database, cache—spins up, identically for every developer on the team, ending environment drift. Mount your source code as a volume (a bind mount like .:/app) and the container sees your edits live, enabling hot reload without rebuilding. Newer Compose versions go further with docker compose watch, which automatically syncs changed files into running containers (and rebuilds when needed) based on rules you define—turning Compose into a genuinely smooth inner-loop dev tool. This pattern is the heart of local development with containers. It's also how much of the best self-hosted open source software ships—a ready-made Compose file you run to stand up the whole application at once.
Best practices and common mistakes
A few habits keep Compose setups clean and reliable:
- Use a
.envfile for configuration. Keep secrets and environment-specific values out of the committedcompose.yaml. Compose automatically reads a.envfile in the project directory, and you can reference variables in the YAML. - Use named volumes for persistent data. Anything stateful—databases, uploads—belongs in a named volume so it survives
docker compose down. Forgetting this means losing your data. - Don't treat
depends_onas readiness. Add healthchecks or retry logic; start order is not the same as a service being ready. - Pin image tags. Use
postgres:16, notpostgres:latest, so your stack is reproducible and doesn't silently change underneath you. - Use override files for environments. A
compose.override.yamllets you layer development-specific settings (like bind mounts and debug ports) on top of a base file, keeping dev and production configurations separate.
The recurring mistakes mirror these: hardcoding secrets in the YAML, losing data by not using volumes, assuming depends_on waits for readiness, and relying on :latest. One bigger-picture mistake is reaching for Compose to run production workloads across multiple servers—that's a job for an orchestrator. And note that Compose manages your application's containers, not the cloud servers underneath them; provisioning that infrastructure reproducibly is the domain of Terraform and infrastructure as code.
Frequently asked questions
What is the difference between Docker and Docker Compose?
Docker runs individual containers; Docker Compose orchestrates multiple containers as one application. Instead of running and wiring up several docker run commands manually, you define all your services in a single Compose file and manage them together with commands like docker compose up and down.
Is Docker Compose used in production? It can be for simple, single-server deployments, but it's designed for single-host use—local development, testing, and CI. For production at scale, where you need to run containers across many machines with auto-scaling and self-healing, Kubernetes is the standard orchestrator instead.
What's the difference between docker compose and docker-compose?
docker-compose (with a hyphen) is the original standalone Python tool, now superseded. docker compose (with a space) is the modern version, built into the Docker CLI as a plugin. Use the spaced version; the hyphenated one is legacy and no longer maintained.
How do containers in Compose talk to each other?
Compose puts all services on a shared network and lets each one reach another by its service name as a hostname—so a web service connects to a database service at db:5432, for example. You only need to publish ports for services you want to access from your host machine.
Why does my app fail to connect to the database on startup?
Almost always because depends_on controls start order, not readiness—the database container starts but isn't ready for connections yet. Fix it with a healthcheck and depends_on: condition: service_healthy, or add connection-retry logic to your app so it waits for the database to come up.
The takeaway
Using Docker Compose turns a fiddly collection of containers into a single declarative file you can bring up with one command, which is exactly what makes it the default tool for local multi-service development. Master the Compose file's services, networks, and volumes, learn the up/down lifecycle, and respect the depends_on-isn't-readiness gotcha, and you can spin up an entire application stack in seconds. Your next step is to write a compose.yaml for your current project—app plus its database—and run docker compose up; once your whole stack starts with one command, you won't go back.