Skip to main content
Back to module
DevOps & Cloud Delivery · Containers

Multi-container apps

Reading46mLesson 2 of 3 in this module

Run a web app and a PostgreSQL database together with Docker Compose, using service names, volumes, health checks and an uncommitted .env file.

Why it matters

Real applications are rarely one container. A web app usually needs a database, often a cache, sometimes a background worker. Starting each one by hand with long docker run commands, creating networks and volumes, and remembering the right order quickly becomes error-prone. Docker Compose lets you describe the whole stack in one YAML file and start it with one command.

Compose is the standard way to run a realistic local development environment, and it is a common way to run integration tests in CI. The ideas it teaches (services, networks, volumes, health checks, configuration from the environment) are the same ideas Kubernetes uses at larger scale.

The core idea: services in one file

A compose.yaml file lists services. Each service becomes one or more containers built from an image. Compose also creates a private network for the project, so services can reach each other by name, and named volumes that keep data when containers are recreated.

Here is the web app from the previous lesson plus PostgreSQL:

# compose.yaml
services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      DB_HOST: db
      DB_PORT: "5432"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  db-data:
# .env  (listed in .gitignore, never committed)
POSTGRES_PASSWORD=change-me-locally

Key points:

  • Service names are hostnames. The web container reaches the database at db:5432, not localhost:5432. Compose's built-in DNS resolves the name to the right container.
  • Only web publishes a port. The database is reachable from other services on the project network but not from outside your machine. Publish only what you need.
  • ${POSTGRES_PASSWORD} is read from a .env file next to compose.yaml or from your shell. The password never appears in the YAML you commit. Commit a .env.example with placeholder values instead, so teammates know which variables to set.
  • depends_on with service_healthy waits until the database's health check passes before starting the web app. Without the health check, depends_on only waits for the container to start, not for Postgres to accept connections.
  • The named volume db-data stores the database files outside the container, so data survives docker compose down and rebuilds.

Proving the connection

To show that the web app can reach the database, add a check to app.py that opens a TCP connection to DB_HOST:DB_PORT using the standard library:

import os
import socket


def db_reachable():
    host = os.environ.get("DB_HOST", "localhost")
    port = int(os.environ.get("DB_PORT", "5432"))
    try:
        with socket.create_connection((host, port), timeout=2):
            return True
    except OSError:
        return False

Then make the handler include db_reachable() in its response. A real app would use a database driver, but this keeps the image dependency-free.

Everyday commands

docker compose up -d --build     # build images and start everything in the background
docker compose ps                # status and health of each service
docker compose logs -f web       # follow one service's logs
docker compose exec db psql -U app -d appdb -c "select version();"
docker compose down              # stop and remove containers and the network
docker compose down -v           # ...and also delete named volumes (data is gone)

The difference between down and down -v matters: the first keeps your data, the second wipes it. Use -v deliberately, for example to reset a test database.

Common mistakes

  • Using localhost between services. Inside a container, localhost is that container. Use the service name.
  • Committing .env. Add it to .gitignore before you create it. If a secret is ever committed, treat it as leaked: rotate it, then remove it from history.
  • Publishing the database port to the world. "5432:5432" is handy for a local GUI client, but on a cloud VM it can expose your database to the internet. Bind to loopback ("127.0.0.1:5432:5432") if you need it.
  • Expecting depends_on alone to wait for readiness. Add a health check and condition: service_healthy, and still make your app retry connections, because databases restart in production too.
  • Thinking Compose is a production orchestrator. It runs on one host. For many hosts, self-healing and rolling updates you need an orchestrator such as Kubernetes or a managed container service.

Quick recap

  • compose.yaml declares services, networks and volumes; docker compose up starts the stack.
  • Services reach each other by service name on a private project network.
  • Named volumes keep data; down -v deletes it.
  • Secrets come from an uncommitted .env file or the environment, never from the YAML in Git.

Try it

The Do tab walks you through running the web app and PostgreSQL together on your own machine and proving that they can talk to each other.

Also in this lesson: