Docker 📖 11 min read

Docker Compose: Multi-Container Apps

Compose turns a long docker run command, or several of them, into one file you can read, edit and keep in version control. This guide builds a real two-container project, an RSS reader with a PostgreSQL database, and explains each line of its compose.yaml: where the passwords go, how to make the app wait for its database, how to update images without losing data, and how to keep machine-specific changes in a separate file.

Jump to: the compose.yaml, everyday commands, updating or troubleshooting.

Before you start

You need Docker Engine with the Compose plugin. If docker compose version prints a version number, you're ready. If it says 'compose' is not a docker command, install the docker-compose-plugin package from Docker's repository as described in the Docker Installation Guide.

docker compose version     # Docker Compose version v2.x or later
docker info --format '{{.ServerVersion}}'

Everything here uses Compose V2, the docker compose subcommand with a space. The older Python tool, docker-compose with a hyphen, stopped receiving updates in 2023. Most files written for it still work, but the commands and some error messages differ, so don't mix the two on one host.

The examples run Miniflux, a small RSS reader, with a PostgreSQL database. It's a realistic pair: an application that can't start until its database accepts connections, a secret that both containers need, and data that has to survive upgrades. The same pattern fits most self-hosted apps.

How Compose thinks about a project

A Compose project is a directory with a compose.yaml in it. The project name defaults to the directory name, and Compose prefixes everything it creates with that name: containers, the default network and named volumes. Two copies of the same file in two directories are two independent projects, which is useful for running a test copy next to the real one.

  • Services describe containers: the image, its settings and how many copies to run.
  • Networks connect services. Every project gets a network called project_default, and each service can reach the others by service name on it.
  • Volumes hold data that must outlive containers. Compose recreates containers freely when you change settings or images; anything not in a volume or bind mount is thrown away.

That last point is the one that catches people. Treat containers as disposable and decide up front where each service keeps its state.

Project layout

mkdir -p ~/stacks/miniflux && cd ~/stacks/miniflux
touch compose.yaml .env
chmod 600 .env

Keeping each stack in its own directory under one parent, such as ~/stacks or /opt/stacks, makes backups and upgrades predictable: one directory, one project, one set of files to copy.

The .env file

Compose reads .env from the project directory automatically and uses it to fill in ${VARIABLE} references in compose.yaml. Put values here that differ between machines or that shouldn't be committed to a repository.

.env
MINIFLUX_TAG=2.3.4
POSTGRES_TAG=17-alpine
POSTGRES_PASSWORD=replace-with-a-long-random-string
MINIFLUX_ADMIN_USER=admin
MINIFLUX_ADMIN_PASSWORD=replace-with-another-long-random-string

Generate the passwords rather than inventing them, for example with openssl rand -base64 30. Stick to letters and digits if you can; a $, quote or # in a value needs quoting and is a common source of "the password is wrong" problems.

Three different mechanisms are easy to confuse:

MechanismWhat it does
.env in the project directorySubstitutes values into compose.yaml itself. The containers never see the file.
environment: on a serviceSets environment variables inside that container.
env_file: on a serviceLoads every line of a file into that container's environment.

The file below uses .env for substitution and passes only the variables each container needs through environment:, so the database never sees the Miniflux admin password.

The compose.yaml

compose.yaml
name: miniflux

services:
  db:
    image: postgres:${POSTGRES_TAG}
    restart: unless-stopped
    environment:
      POSTGRES_USER: miniflux
      POSTGRES_DB: miniflux
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  miniflux:
    image: miniflux/miniflux:${MINIFLUX_TAG}
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      DATABASE_URL: postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
      RUN_MIGRATIONS: "1"
      CREATE_ADMIN: "1"
      ADMIN_USERNAME: ${MINIFLUX_ADMIN_USER}
      ADMIN_PASSWORD: ${MINIFLUX_ADMIN_PASSWORD:?set MINIFLUX_ADMIN_PASSWORD in .env}

volumes:
  db-data:

Line by line, the parts that matter:

  • No version: key. The Compose Specification doesn't use it, and current releases print a warning that it's obsolete. Delete it from older files.
  • name: miniflux fixes the project name, so renaming the directory later doesn't orphan the volume. Without it, moving the directory creates a fresh, empty db-data volume under a new name.
  • Image tags come from .env. postgres:17-alpine follows PostgreSQL 17 patch releases but never jumps to 18 on its own, and miniflux/miniflux:2.3.4 is an exact release (Miniflux publishes no floating 2 tag, so check its releases page for the current version). That matters because a new PostgreSQL major version can't open the old version's data directory; see Updating.
  • ${VAR:?message} makes Compose stop with your message when the variable is empty, instead of starting a database with a blank password.
  • restart: unless-stopped brings containers back after a crash or reboot, but respects a deliberate docker compose stop.
  • The port is bound to 127.0.0.1. Only a reverse proxy on the same host can reach it. Plain "8080:8080" listens on every interface, and Docker's own firewall rules mean ufw or firewalld won't block it. The Docker Networking Explained guide covers why.
  • The database has no ports: at all. Miniflux reaches it as db on the project network. Nothing outside the project needs to.

Healthchecks and startup order

A plain depends_on: [db] only waits until the database container has started, which is a second or two before PostgreSQL is ready for connections. Applications that try to connect immediately then crash and restart a few times before things settle. The long form used above, condition: service_healthy, waits for the healthcheck to pass.

The healthcheck runs pg_isready inside the database container every 10 seconds. start_period gives PostgreSQL 30 seconds to initialise a new data directory before failed checks count against retries. You can watch it from the host:

docker compose ps                       # STATUS shows (health: starting) and then (healthy)
docker inspect --format '{{json .State.Health}}' miniflux-db-1

Two limits are worth knowing. The condition is only checked when Compose starts the stack; if the database restarts later, the app isn't stopped and restarted with it, so the app still needs to retry its own connections. And the check command has to exist in the image. pg_isready ships with the PostgreSQL image, but many minimal images have no curl or wget, so copy healthchecks from the image's own documentation where you can.

Check the file before you start it

docker compose config parses the file, fills in every variable from .env and prints the result. It's the quickest way to catch an indentation mistake or a variable that didn't resolve. It also prints your secrets, so don't paste its output anywhere public.

docker compose config --quiet && echo "compose.yaml is valid"
docker compose config | less

Start the stack and confirm it works

docker compose up -d
docker compose ps
docker compose logs -f miniflux      # Ctrl+C stops following; the containers keep running

On the first start, PostgreSQL initialises the volume, the healthcheck turns healthy, and Miniflux runs its migrations and creates the admin account. Then check it from the host:

curl -sI http://127.0.0.1:8080/healthcheck    # expect HTTP/1.1 200 OK

Once you've logged in with the admin account, remove CREATE_ADMIN, ADMIN_USERNAME and ADMIN_PASSWORD from the file and run docker compose up -d again. Compose notices the changed environment and recreates only the Miniflux container. The database keeps running.

Everyday commands

Run these from the project directory, or add -p miniflux to run them from anywhere.

CommandWhat it does
docker compose up -dCreates or updates containers to match the file. Safe to run repeatedly; unchanged services are left alone.
docker compose psLists the project's containers, their state and health.
docker compose logs -f --tail 100 minifluxFollows one service's log, starting from the last 100 lines.
docker compose exec db psql -U minifluxRuns a command inside a running container.
docker compose restart minifluxRestarts a container without recreating it. Environment changes in the file are not applied; use up -d for that.
docker compose stop / startStops and starts containers without removing them.
docker compose downStops and removes containers and the project network. Named volumes are kept.
docker compose pullDownloads newer images for the tags in the file.

Updating images

Updating is pull, then recreate:

docker compose pull
docker compose up -d
docker image prune          # removes the old, now-unused image layers; asks first

Before you pull, read the release notes for anything with a database or a migration step. Moving within a tag such as postgres:17-alpine is routine. Changing the tag to 18-alpine isn't: the new server refuses to start on a data directory created by version 17. A PostgreSQL major upgrade means dumping the database, starting the new version on an empty volume and restoring the dump. The Docker Volumes and Backups guide shows the dump and restore commands.

For the same reason, avoid :latest on stateful services. It tells you nothing about what you're running, and a pull can quietly move you across a major version.

Override files and profiles

If a file named compose.override.yaml sits next to compose.yaml, Compose merges it in automatically. That's a clean way to keep machine-specific changes out of the main file. On a laptop, for example, you might expose the database to a desktop client:

compose.override.yaml
services:
  db:
    ports:
      - "127.0.0.1:5432:5432"

Lists such as ports are merged, while single values such as image are replaced. Run docker compose config to see the merged result. To use different files explicitly, pass them in order with -f compose.yaml -f compose.prod.yaml.

Profiles keep optional services in the same file without starting them by default. A one-off database admin tool is a good candidate:

services:
  adminer:
    image: adminer:4
    profiles: ["tools"]
    ports:
      - "127.0.0.1:8081:8080"

docker compose up -d ignores it. docker compose --profile tools up -d starts it, and docker compose --profile tools stop adminer stops it again.

Troubleshooting

The "POSTGRES_PASSWORD" variable is not set

Compose couldn't find the variable. Check that you're in the project directory, that the file is named exactly .env, and that the line has no spaces around the =. Variables already exported in your shell take priority over .env, so env | grep POSTGRES is worth a look too.

Bind for 127.0.0.1:8080 failed: port is already allocated

Something else holds that port. sudo ss -ltnp | grep 8080 names the process. Change the left-hand number in ports:; the container's own port stays the same.

A container keeps restarting

docker compose ps shows Restarting, and docker compose logs --tail 50 miniflux shows why. Authentication errors right after you changed POSTGRES_PASSWORD are expected. The PostgreSQL image only reads that variable when it initialises an empty volume, so changing it later does nothing to the existing database. Either change the password inside PostgreSQL with ALTER USER, or, on a stack with no data you care about, remove the volume and start again.

Found orphan containers

You removed or renamed a service, and its old container is still running. docker compose up -d --remove-orphans cleans it up.

Changes to compose.yaml don't take effect

restart reuses the existing container. Run docker compose up -d, which recreates any container whose configuration changed.

Removing the stack

docker compose down          # containers and network go; the db-data volume stays
docker compose down -v       # also deletes named volumes: the database is gone for good

-v has no confirmation prompt. Take a database dump first if there's any chance you'll want the data back.

Next steps