Docker Compose is a tool for defining several related containers in one file and running them with one command. Instead of writing three long docker run commands with dozens of flags for your app, database and cache, and remembering which network and volume connect to which, you describe everything in a file called compose.yaml and bring the whole stack up with docker compose up -d. Compose creates a shared network, creates the volumes and starts the services in the right order.
This article builds a real example: a small web app with PostgreSQL and Redis. Along the way you'll see how named volumes keep your data, what a healthcheck is and why depends_on isn't enough without one, and which commands you'll use day to day, including the one that can wipe your entire database. If you haven't installed Docker yet, start with How to install Docker on Ubuntu 24.04, which installs the Compose plugin as well.
Short answer: Docker Compose defines several related containers, such as an app, a database and a cache, in a single
compose.yamlfile and starts them all withdocker compose up -d. It creates a shared network and volumes for the services, makes each service name its hostname, and uses healthchecks with depends_on to start services in the right order.
docker-compose or docker compose?
First, a common point of confusion, since you'll see both in examples online:
docker-compose(with a hyphen) was Compose v1, a separate program written in Python. It stopped receiving updates in summer 2023.docker compose(with a space) is Compose v2, rewritten in Go and installed as a plugin to thedockercommand itself (thedocker-compose-pluginpackage).
The file format is nearly the same, and most old files work unchanged. Two visible differences: the default file name is now compose.yaml (docker-compose.yml is still read), and the top-level version: key is obsolete and only produces a warning, so leave it out. This article uses docker compose throughout.
Example: web app + PostgreSQL + Redis
The project layout:
myapp/
├── compose.yaml
├── .env
├── Dockerfile
└── src/...
Assume your app has a Dockerfile, listens on port 8000, and has a /health endpoint that returns 200 when the app is healthy. The language and framework don't matter: Laravel, Django or Node all work the same way.
The .env file
POSTGRES_DB=myapp
POSTGRES_USER=myapp
POSTGRES_PASSWORD=a-long-random-password
DB_HOST=db
DB_PORT=5432
REDIS_HOST=redis
This file contains a password: add it to .gitignore and never commit it. Keep a .env.example without real values next to it.
The compose.yaml file
services:
app:
build: .
env_file: .env
ports:
- "127.0.0.1:8000:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
restart: unless-stopped
db:
image: postgres:17
env_file: .env
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
redis:
image: redis:7
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
pgdata:
redisdata:
Let's go through it piece by piece.
Services and networking
Each key under services is a service, and Compose creates a container for each. All services in a file are automatically placed on a shared network, and each service's name is its hostname. That's why .env says DB_HOST=db: the app reaches the database at db:5432, not localhost. This is the most common mistake people make when starting with Compose; inside the app container, localhost means that container itself.
Notice there's no ports entry for db or redis. None is needed: the app reaches them over the internal network, and nobody outside the server can connect to them. Even the app's port is published only on 127.0.0.1. Docker writes iptables rules directly for published ports and bypasses ufw, so any port you publish without an address is open to the internet.
Named volumes: where does the data live?
Containers are ephemeral. Every time you recreate one (after an image change, for instance), whatever was written inside it is gone. A database can't work that way, so PostgreSQL's data directory is mounted on a volume:
volumes:
- pgdata:/var/lib/postgresql/data
pgdata is a named volume, declared in the volumes section at the bottom of the file. Docker stores it under /var/lib/docker/volumes/, and it isn't deleted when the container is. Its actual name is the project name plus the volume name, for example myapp_pgdata:
docker volume ls
docker volume inspect myapp_pgdata
The other kind is a bind mount, which puts a specific host folder inside the container (./src:/app). Bind mounts are good for code in development, because changes show up immediately. For database data, a named volume is the better choice: no file ownership and permission issues, and Docker manages it for you.
A note on versions: starting with PostgreSQL 18, the official image changed its data path, and the volume must be mounted at /var/lib/postgresql. That's why the example pins postgres:17 explicitly. Always pin the major version and never use latest for a database; a PostgreSQL major upgrade requires a data migration and shouldn't happen silently on a pull.
And remember: a volume is not a backup. If the disk fails or the volume is deleted, the data is gone. Dump the database separately:
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' | gzip > backup-$(date +%F).sql.gz
You can schedule this with cron (Cron jobs in Linux) and keep copies elsewhere following the 3-2-1 rule.
env_file vs. .env: two different jobs
There are two similar-looking concepts here that are worth keeping apart:
env_file: .envin a service definition puts variables inside the container. PostgreSQL creates the user and password fromPOSTGRES_USERandPOSTGRES_PASSWORD, and the app reads the database address fromDB_HOST.- Separately, a file named
.envnext tocompose.yamlis also read for variable substitution in the Compose file itself. So ifcompose.yamlsaysimage: postgres:${PG_VERSION}, the value comes from.env.
That's why the healthcheck uses $${POSTGRES_USER} with two dollar signs. $$ means "don't substitute this", so the variable reaches the shell inside the container untouched and gets its value there.
healthcheck and depends_on
On its own, depends_on only controls start order: the db container is created first, then app. But "the container has started" is not the same as "PostgreSQL is ready to accept connections". On first run, PostgreSQL spends a few seconds initialising its data directory, and an app that connects in that window gets an error.
The fix is these two parts working together:
healthcheckis a command Docker runs inside the container at intervals:pg_isreadyfor PostgreSQL,redis-cli pingfor Redis. If the command exits with code zero, the container ishealthy.condition: service_healthyunderdepends_ontells Compose not to startappuntildbandredisare healthy.
Healthcheck parameters:
| Parameter | Meaning |
|---|---|
interval |
How often to run the check |
timeout |
If the command takes longer than this, it counts as a failure |
retries |
How many consecutive failures before the container becomes unhealthy |
start_period |
Initial grace period during which failures aren't counted |
The app healthcheck needs curl inside the image. If your image doesn't have curl (slim or distroless images, for example), either install it in the Dockerfile or use another command that exists in the image.
You can see health status in the output of docker compose ps. One important point: Docker does not restart an unhealthy container on its own; the healthcheck only reports status and is used by depends_on.
Restart policy
restart: unless-stopped means that if the container crashes or the server reboots, Docker starts it again, unless you stopped it manually. The other options are no (the default), always and on-failure. For always-on services on a server, unless-stopped is usually the right choice. It requires the Docker service itself to start on boot, which it does by default on Ubuntu.
Everyday commands
Run all of these from the folder containing compose.yaml:
docker compose up -d # build and start all services in the background
docker compose up -d --build # rebuild images after changing code or the Dockerfile
docker compose ps # service status and health
docker compose logs -f app # follow one service's logs live
docker compose logs --tail=100 # last 100 log lines from all services
docker compose exec db psql -U myapp myapp # run a command in a running container
docker compose exec app sh # shell inside the app container
docker compose restart app # restart one service
docker compose pull # pull newer image versions
docker compose config # final file after variable substitution; useful for debugging
docker compose down # stop and remove containers and the network
stop vs. down: stop only stops the containers, which stay where they are; down removes them and the network too. In both cases volumes are left untouched and no data is lost.
The danger of docker compose down -v
docker compose down -v
The -v flag also deletes the project's named volumes. That means pgdata and everything in the database is gone, with no confirmation. In development, when you want to start from scratch, it's a handy command. On a real server you'll almost never need it. If you do, make sure first that you have a recent, working backup.
A short checklist for Compose on a server
- Pin image versions (
postgres:17, notpostgresorlatest). - Publish only the ports that truly need outside access, and bind them to
127.0.0.1wherever possible. - Keep passwords in
.env, and keep.envout of git. - Define a named volume for every service that holds data, and back it up separately.
- Add healthchecks for the database and cache, and use
service_healthyindepends_on. - Don't forget
restart: unless-stopped.
Frequently asked questions
What is the difference between a Dockerfile and Docker Compose?
A Dockerfile is the recipe for building one application's image: what gets installed and run inside a container. Docker Compose defines and runs several services together, and can build one of them from that same Dockerfile, like the app service in this example with build: .
Does docker compose down delete my database data?
No. docker compose down removes the containers and the network, but named volumes and the data in them stay put. Only if you add the -v flag are the volumes, and all the database data, deleted without confirmation.
Why can't my app connect to the database on localhost inside a container?
Because inside each container, localhost means that container itself. In Compose, each service name is its hostname, so the app should connect to an address like db:5432, not localhost.
Do I still need the version key in a Compose file?
No. In Compose v2 the version key is obsolete and only produces a warning. Write the file without it and name it compose.yaml; the old docker-compose.yml name is still read.
How do I update containers after changing my code?
Run docker compose up -d --build in the project folder to rebuild the image and recreate only the containers that changed. For prebuilt images such as PostgreSQL and Redis, run docker compose pull first and then docker compose up -d.
Wrap-up
Docker Compose puts your whole application environment in one readable file that you can keep in git, review, and recreate on any other server. In this example, the app reaches the database and Redis by service name, data lives in named volumes, and healthchecks combined with depends_on keep the app from starting before the database is ready. The everyday commands are just a handful: up -d, ps, logs -f, exec and down. Just take the -v flag seriously and think twice before using it.