Docker Compose is the tool that lets you define multiple containers in a single YAML file and bring them all up with one command. A real application rarely consists of a single process: a web server, a database and, more often than not, a cache layer all run together. Starting them one by one with docker run is both tedious and error-prone. In this article we will build a typical stack made of web, PostgreSQL and Redis services from scratch, covering inter-service communication, persistent data and environment variables through practical examples.
What does Docker Compose solve?
Running a single container is easy, but in real projects things get complicated fast. Each service has its own image, port, network and environment variables — and they often need to start in a specific order. Compose moves that complexity into a declarative file:
- Single source: The whole stack lives in
compose.yaml; a teammate clones the repo and just typesdocker compose up. - Automatic networking: Compose creates a shared network for your services, and containers reach one another by service name.
- Reproducibility: The same file produces the same stack on a developer machine and in CI.
Note: in modern Docker the command is docker compose (with a space); the old docker-compose (hyphenated) binary still works but has been superseded by Compose V2.
Your first compose.yaml file
Create a folder and add a compose.yaml inside it. Let's start with just a web service and a database:
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
A few important details: the "8080:80" entry under ports maps port 8080 on the host to port 80 inside the container. depends_on makes the web service start after db (though it does not guarantee db is actually ready — more on that shortly). The volumes block creates a persistent volume that keeps the database files even if the container is removed.
Adding a cache layer: Redis
Most applications use Redis for sessions, queues or caching. Adding a third service is just a matter of one more block:
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
depends_on:
- db
- cache
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
cache:
image: redis:7-alpine
command: ["redis-server", "--save", "60", "1"]
volumes:
db-data:
Here's the nice part: from inside the web container you reach the database via the db hostname and Redis via cache. The internal DNS that Compose sets up resolves the service name to the correct IP automatically. So in your application config the connection strings become postgres://app:secret@db:5432/appdb and redis://cache:6379 — no hard-coded IPs needed.
Environment variables and the .env file
Hard-coding passwords into YAML is a bad idea. Compose automatically reads a neighbouring .env file and substitutes variables with the ${...} syntax:
# .env
POSTGRES_PASSWORD=a-very-secret-value
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: appdb
Add .env to your .gitignore so secrets never land in the repository. Leaving an example .env.example for the rest of the team is a good habit.
Real readiness with health checks
depends_on only controls start order; it does not guarantee that the database is accepting connections. The web service may try to connect while db is still booting and fail. The fix is to define a healthcheck and combine depends_on with condition: service_healthy:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 5
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
depends_on:
db:
condition: service_healthy
Now the web container starts only after the pg_isready command returns successfully. This eliminates most of those "the database isn't ready yet" errors.
Day-to-day use: the core commands
These are the commands you'll reach for most often to run and manage the stack:
docker compose up -d— starts all services in the background.docker compose ps— lists the running services and their state.docker compose logs -f web— streams the logs of a specific service live.docker compose exec db psql -U app appdb— runs a command inside the running db container.docker compose down— stops and removes the services. Add-vto remove the volumes too.
If you changed an image (for example, you build your own Dockerfile), rebuild with docker compose up -d --build.
Frequently Asked Questions
What's the difference between docker-compose and docker compose?
docker-compose (hyphenated) is the old V1 tool written in Python. docker compose (with a space) is the Go-based V2 plugin integrated into the Docker CLI. On current installations you should use the spaced version; the YAML syntax is largely the same.
Will my data be lost when the container is removed?
No — named volumes defined under volumes are independent of the container lifecycle. docker compose down removes the containers, but the volume stays; your data is still there on the next up. Only down -v deletes the volumes as well.
Is Compose suitable for production?
It's perfectly fine for small to medium single-server setups. For multi-node, auto-scaling and self-healing systems, an orchestrator like Kubernetes is the better choice. Think of Compose for development and simple production scenarios, and Kubernetes for large scale.
Want to consolidate your stack into one file? To simplify your development and deployment with Docker Compose, get in touch with me — let's plan your project's infrastructure together.