Docker Compose: Running a Multi-Container App
Docker & Containers

Docker Compose: Running a Multi-Container App

1 September 20268 min read1589 words
Tags#docker#docker-compose#postgres#devops

You can already build an image and run a container. The next problem is that real applications are rarely one container: a web app needs a database, and often a cache or a queue alongside it. This guide shows how Docker Compose describes that whole stack in one file, so a single command brings it up and a single command tears it down. By the end you will have a working web plus Postgres setup with a private network, a persistent volume, sane secret handling and a healthcheck.

If containers themselves are still new, start with the Docker and containers tutorial. This post assumes you know what an image, a container and a Dockerfile are.

Why Compose instead of a longer docker run

Running the stack by hand means typing something like this, twice, in the right order, every time:

docker network create app-net
docker volume create db-data
docker run -d --name db --network app-net -v db-data:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=... -e POSTGRES_USER=appuser -e POSTGRES_DB=appdb postgres:17
docker run -d --name web --network app-net -p 8000:8000 -e DATABASE_URL=... myapp

That works, and it is unreproducible. The flags live in your shell history, the order is implicit, and a colleague cannot run it without you dictating it. Compose moves all of that into a file you commit to the repository, so the stack is described once and started identically by anyone.

One note on the command name before we go further. The modern form is docker compose, with a space: it is a plugin built into the Docker CLI. The older standalone docker-compose with a hyphen is a separate Python program that is no longer maintained. Use the space form throughout. If docker compose version reports nothing, your Docker installation is old enough to be worth updating.

Step 1: Write compose.yaml

Create compose.yaml in the root of your project, next to the Dockerfile.

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

  web:
    build: .
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://appuser:${POSTGRES_PASSWORD}@db:5432/appdb
    ports:
      - "8000:8000"
    networks:
      - backend

volumes:
  db-data:

networks:
  backend:

Notice what is not there: a top-level version: key. It was required by the old Compose file formats and is obsolete in the Compose Specification. Current versions of Docker Compose print a warning if you include it. Delete it from any file you inherit.

POSTGRES_PASSWORD is not optional for the official Postgres image. Leave it unset and the container refuses to initialise with an error telling you exactly that. The ${VAR:?message} syntax makes Compose fail fast with your own message instead, before anything starts.

Step 2: What the three top-level blocks actually do

Services are your containers. Each one either pulls an image or has Compose build it from a Dockerfile. The service name matters more than you might expect, because it becomes the hostname on the shared network.

Networks are private bridges between containers. Compose creates a default network even if you declare none, so the explicit backend network above is mainly documentation and a hook for later, when you want a separate frontend network that the database is not attached to. The important consequence: web reaches the database at db:5432, using the service name. Not localhost, which inside a container means that container itself.

Because they share a network, the database does not need a ports entry at all. Publishing 5432 to the host exposes your database to anything that can reach the machine. Add it only when you genuinely want to connect with a desktop client, and prefer binding to loopback: "127.0.0.1:5432:5432".

Volumes are where data survives. Containers are disposable, and everything written inside one disappears when it is removed. Mapping the named volume db-data onto /var/lib/postgresql/data keeps your tables when you rebuild or upgrade the web service. Named volumes are managed by Docker; bind mounts such as ./src:/app/src map a host folder instead, which is handy for live-reloading source in development and generally the wrong choice for database files.

Step 3: Keep environment variables and secrets out of the repository

Compose reads a file called .env in the project directory and substitutes those values into compose.yaml. So the password lives here:

POSTGRES_PASSWORD=a-long-random-string-you-generated

And .env goes in .gitignore immediately, before you commit anything. Check in a .env.example with the variable names and empty or placeholder values, so the next person knows what to fill in.

Two habits worth adopting. Run docker compose config to see the fully resolved file, which is the quickest way to confirm interpolation did what you expected. And remember that anything in environment: is visible to anyone who can run docker inspect on the container, and lands in the process environment. For a local development database that is acceptable. For production credentials it is not.

The better mechanism for real secrets is Compose's secrets block, which mounts the value as a file inside the container instead of putting it in the environment. The official Postgres image supports this through its _FILE convention:

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

The file still lives on disk, so it belongs in .gitignore too, but it is no longer in the environment of every process in the container, and it moves cleanly to a real secret store when you deploy.

Step 4: Add a healthcheck and make startup order mean something

A container being "up" tells you the process started, not that it is ready to serve. Postgres in particular accepts no connections for the first few seconds while it initialises its data directory, which is why so many stacks fail on the first boot and work on the second.

The healthcheck in the file above runs pg_isready inside the database container every ten seconds. start_period: 30s gives the container a grace window where failures do not count against the retry budget, which matters on that very first run when Postgres is creating the cluster from scratch.

The healthcheck alone changes nothing about ordering. The part that does is on the web service:

    depends_on:
      db:
        condition: service_healthy

Plain depends_on: [db] only waits for the container to be created. With condition: service_healthy, Compose holds the web service back until the database reports healthy. Check the current state with docker compose ps, which shows the health status per service.

This is a convenience, not a guarantee. Databases restart, networks blink, and a long-running application should still retry its connection rather than crash on the first refusal. Treat the healthcheck as a way to make the common case pleasant.

Step 5: The commands you will use daily

docker compose up -d           # start everything in the background
docker compose ps              # what is running, and is it healthy
docker compose logs -f web     # follow one service's output
docker compose logs -f         # follow all services, interleaved
docker compose exec db psql -U appuser -d appdb
docker compose up -d --build   # rebuild images, then start
docker compose down            # stop and remove containers and networks

up -d runs detached and returns your terminal. Dropping the -d streams the logs in the foreground, which is a reasonable way to watch a first boot; Ctrl+C then stops the stack.

down removes containers and networks but leaves named volumes alone, so your data survives. docker compose down -v also deletes the volumes. That is the command that wipes your database, and there is no confirmation prompt. It is genuinely useful when you want a clean slate, and worth typing carefully.

exec runs a command in a container that is already running, which is how you open a psql shell or check a file. run starts a fresh one-off container instead, which is what you want for a migration or a management command.

Common failures and what they mean

"connection refused" from the web service to the database. Almost always localhost in a connection string. Inside a container, localhost is that container. Use the service name, db.

Changing a Postgres environment variable does nothing. The official image only reads POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB when it initialises an empty data directory. Once the volume exists, those variables are ignored. To change the password later, do it with SQL, or remove the volume and start over if the data is disposable.

"database files are incompatible with server". You bumped the Postgres image to a new major version over an existing volume. Major upgrades need a dump and restore, or the pg_upgrade path. Pin the major version in your image tag so this happens when you decide, not when a tag moves.

"port is already allocated". Something else on the host holds that port, often an older stack you forgot to bring down. Find it with docker compose ls, or change the host side of the mapping to a free port.

Edited compose.yaml, nothing changed. Re-run docker compose up -d. Compose compares the desired state against what is running and recreates only the services that need it. Code changes baked into an image need --build.

Where to go next

Stuck on a step, or does a service refuse to reach the database no matter what you change? Write to the desk with your compose file and the error text.

Comments

No comments yet. Be the first to share your thoughts.

Docker Compose: Running a Multi-Container App