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.yaml file and starts them all with docker 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 the docker command itself (the docker-compose-plugin package).

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:

text
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

.env
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

compose.yaml
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:

yaml
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:

bash
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:

bash
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:

  1. env_file: .env in a service definition puts variables inside the container. PostgreSQL creates the user and password from POSTGRES_USER and POSTGRES_PASSWORD, and the app reads the database address from DB_HOST.
  2. Separately, a file named .env next to compose.yaml is also read for variable substitution in the Compose file itself. So if compose.yaml says image: 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:

  • healthcheck is a command Docker runs inside the container at intervals: pg_isready for PostgreSQL, redis-cli ping for Redis. If the command exits with code zero, the container is healthy.
  • condition: service_healthy under depends_on tells Compose not to start app until db and redis are 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:

bash
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

bash
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, not postgres or latest).
  • Publish only the ports that truly need outside access, and bind them to 127.0.0.1 wherever possible.
  • Keep passwords in .env, and keep .env out 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_healthy in depends_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.