Cloud & DevOps
Hands-onBeginner 40 min· Containers and pipelines · 5 of 16

Docker Compose for multi-service apps

Run an API, a PostgreSQL database and Redis together with one command. Learn Compose files, service-name networking, healthchecks, volumes and `.env` secrets, plus how Compose differs from production setups.

What you'll build: a three-service app run with one command: a FastAPI order service, a PostgreSQL database and a Redis cache. You will learn how Compose files work, how containers find each other by name, how to keep database data, how to wait for services to be healthy and how to manage settings.

Prerequisites: the previous lesson, *Docker: images and containers*, and Docker with the Compose plugin (check docker compose version; it comes with Docker Desktop). Note the command is docker compose with a space; the old docker-compose is the previous generation.

Why Compose

Real apps are several processes: an API, a database, a cache, maybe a worker. Starting each with a long docker run command and connecting them by hand is slow and error-prone. Docker Compose describes all of them in one YAML file, compose.yaml, and starts, stops and connects them with one command. It is perfect for local development and small deployments. (Production at scale usually uses managed services or Kubernetes; Compose files are still a good way to describe what an app needs.)

Compose creates a private network for the project. Each service is reachable from the others by its service name as the hostname. So the API connects to the database at db:5432, not localhost.

Step 1: the app

Make a folder brew-bean-stack containing app/main.py, requirements.txt, Dockerfile and compose.yaml. The API has three endpoints: create an order (saved in Postgres), count orders (cached in Redis for 30 seconds) and a health check.

requirements.txt
fastapi==0.115.6
uvicorn[standard]==0.34.0
psycopg[binary]==3.2.3
redis==5.2.1

If any pinned version cannot be installed in the future, use the current version from pypi.org.

app/main.py
import os
from contextlib import asynccontextmanager

import psycopg
import redis
from fastapi import FastAPI

DATABASE_URL = os.environ["DATABASE_URL"]
cache = redis.Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)


@asynccontextmanager
async def lifespan(app: FastAPI):
    with psycopg.connect(DATABASE_URL) as conn:
        conn.execute(
            "CREATE TABLE IF NOT EXISTS orders ("
            "id SERIAL PRIMARY KEY, "
            "customer_name TEXT NOT NULL, "
            "created_at TIMESTAMPTZ DEFAULT now())"
        )
    yield


app = FastAPI(lifespan=lifespan)


@app.get("/health")
def health():
    return {"status": "ok"}


@app.post("/orders")
def create_order(customer_name: str):
    with psycopg.connect(DATABASE_URL) as conn:
        row = conn.execute(
            "INSERT INTO orders (customer_name) VALUES (%s) RETURNING id",
            (customer_name,),
        ).fetchone()
    cache.delete("order_count")
    return {"id": row[0]}


@app.get("/orders/count")
def order_count():
    cached = cache.get("order_count")
    if cached is not None:
        return {"count": int(cached), "source": "cache"}
    with psycopg.connect(DATABASE_URL) as conn:
        n = conn.execute("SELECT count(*) FROM orders").fetchone()[0]
    cache.set("order_count", n, ex=30)
    return {"count": n, "source": "database"}

On start-up the app creates the table if needed. POST /orders inserts a row and clears the cached count. GET /orders/count answers from Redis when it can and falls back to the database, which is the classic cache pattern (see *Caching with Redis* in Backend Development). The with psycopg.connect(...) block commits on success and closes the connection.

Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN useradd --create-home appuser
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Step 2: the Compose file

compose.yaml
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://brew:${DB_PASSWORD}@db:5432/brewbean
      REDIS_URL: redis://cache:6379/0
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    restart: unless-stopped

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

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:

Three services. api is built from our Dockerfile and published on port 8000. db and cache use ready-made images. Only the API publishes a port: the database and cache are reachable only inside the Compose network, which is safer. ${DB_PASSWORD} is read from the environment or a .env file. The named volume pgdata keeps database files when containers are recreated. depends_on with service_healthy makes the API wait until Postgres and Redis pass their healthchecks.

.env
DB_PASSWORD=choose-a-long-random-password

Compose automatically reads a .env file next to compose.yaml to fill ${...} values. Add .env to .gitignore and never commit it. Commit a .env.example with fake values instead.

Started is not ready

depends_on without a healthcheck only waits until the other container has STARTED, not until the database accepts connections. A healthcheck turns 'started' into 'ready'. Many 'connection refused on first start' errors come from missing this.

Step 3: run it

Run the stack

  1. 1Open a terminal in the brew-bean-stack folder, next to compose.yaml.
  2. 2Start everything with docker compose up --build -d.
  3. 3Check state with docker compose ps and wait until the database shows healthy.
  4. 4Call the API twice with curl and watch the answer change from database to cache.
  5. 5Stop with docker compose down (add -v only if you want to delete the data).
Terminal
docker compose up --build -d
docker compose ps
curl -X POST "http://localhost:8000/orders?customer_name=Asha"
curl -X POST "http://localhost:8000/orders?customer_name=Ravi"
curl http://localhost:8000/orders/count
curl http://localhost:8000/orders/count

up --build -d builds the image and starts everything in the background. We create two orders, then ask for the count twice.

The first count comes from Postgres, the second from Redis
{"id":1}
{"id":2}
{"count":2,"source":"database"}
{"count":2,"source":"cache"}

The second answer says cache: Redis served it for the next 30 seconds. Creating another order clears the cached value, so the next count reads the database again.

Terminal
docker compose logs -f api
docker compose exec db psql -U brew -d brewbean -c "SELECT * FROM orders;"
docker compose down
docker compose up -d
curl http://localhost:8000/orders/count
docker compose down -v

logs -f api follows the API's logs. exec db psql ... runs a SQL query inside the database container. down removes the containers but keeps the volume, so after up the count is still 2 (from the database; the cache starts empty). down -v also deletes volumes, which wipes the data. Use it only when you want a fresh start.

Volumes hold your data

docker compose down -v deletes your named volumes, including the database. Do not run it against anything you care about. Plain docker compose down is safe for data.

Useful Compose features

Handy commands and keys

FeatureWhat it doesExample
docker compose up -d --buildRebuild images and restart changed servicesafter editing code
docker compose logs -f serviceFollow one service's logsdocker compose logs -f db
docker compose exec service cmdRun a command in a running servicedocker compose exec api sh
docker compose run --rm service cmdOne-off container, e.g. a migrationdocker compose run --rm api python -c "print(1)"
env_file:Load many variables from a fileenv_file: .env
profiles:Start optional services only when askeda debug tool service
volumes: - ./app:/app/appBind-mount source for live editing in developmenthot reload

Development versus production: for local work you may bind-mount your code and run Uvicorn with --reload. In production you bake the code into the image, use secrets from the platform and usually run the database as a managed service (for example AWS RDS) rather than a container. A common pattern is a base compose.yaml plus a compose.override.yaml for development settings, which Compose merges automatically.

Prompt · Write a Compose file for my stack
Write a Docker Compose `compose.yaml` for this app: [describe services, e.g. a Node API on port 3000, a Postgres 16 database and a Redis cache]. Requirements: only the API publishes a port; the database uses a named volume; passwords come from a `.env` file; each dependency has a healthcheck and the API waits for them with `depends_on` conditions. After the file, list the commands to start it, check status and view logs.

What to expect: Verify: only one service has ports, service names are used as hostnames in connection strings, healthchecks use commands that exist in that image, and secrets are not hard-coded.

Troubleshooting: docker compose ps shows state and health. If the API keeps restarting, run docker compose logs api. If you get 'password authentication failed' after changing DB_PASSWORD, the volume still holds the old password: remove it with docker compose down -v (development only) or change the password inside Postgres. If port 8000 is busy, change the left number in ports.

How this comes up in interviews

You may be asked: what problem Compose solves, how services find each other (service names on a shared network), why depends_on is not enough without healthchecks, how data survives container restarts (volumes), and when you would move from Compose to Kubernetes or a managed platform.

Try it: Add a background worker

Add a fourth service worker that uses the same image as api but runs a different command, for example python -m app.worker, which prints the order count every 10 seconds. Give it the same environment variables and make it wait for db.

Self-check

  1. Only services that must be public publish ports
  2. Services connect by service name, not localhost
  3. Databases use named volumes
  4. Secrets live in .env, which is git-ignored
  5. Healthchecks exist for services others depend on

Key takeaways

  1. compose.yaml describes services, networks and volumes; docker compose up runs them all.
  2. Services reach each other by service name on the private Compose network.
  3. Healthchecks plus depends_on: condition: service_healthy make start-up order reliable.
  4. Named volumes keep data; down -v deletes them.
  5. Keep secrets in a git-ignored .env, publish only the ports you need, and use managed databases in production.

Check yourself

Finishing the quiz marks the lesson as done.

1. Inside the api container, which host name should the database URL use?

2. What does depends_on with condition: service_healthy add over plain depends_on?

3. You run docker compose down and then docker compose up -d. Where does the Postgres data come from?

4. Why does only the api service have a ports entry?

5. After changing DB_PASSWORD in .env, the API fails with 'password authentication failed'. Why?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free