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.
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.
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.
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
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.
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
- 1Open a terminal in the
brew-bean-stackfolder, next tocompose.yaml. - 2Start everything with
docker compose up --build -d. - 3Check state with
docker compose psand wait until the database shows healthy. - 4Call the API twice with curl and watch the answer change from database to cache.
- 5Stop with
docker compose down(add-vonly if you want to delete the data).
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/countup --build -d builds the image and starts everything in the background. We create two orders, then ask for the count twice.
{"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.
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 -vlogs -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
| Feature | What it does | Example |
|---|---|---|
docker compose up -d --build | Rebuild images and restart changed services | after editing code |
docker compose logs -f service | Follow one service's logs | docker compose logs -f db |
docker compose exec service cmd | Run a command in a running service | docker compose exec api sh |
docker compose run --rm service cmd | One-off container, e.g. a migration | docker compose run --rm api python -c "print(1)" |
env_file: | Load many variables from a file | env_file: .env |
profiles: | Start optional services only when asked | a debug tool service |
volumes: - ./app:/app/app | Bind-mount source for live editing in development | hot 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.
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
- Only services that must be public publish ports
- Services connect by service name, not localhost
- Databases use named volumes
- Secrets live in .env, which is git-ignored
- Healthchecks exist for services others depend on
Key takeaways
compose.yamldescribes services, networks and volumes;docker compose upruns them all.- Services reach each other by service name on the private Compose network.
- Healthchecks plus
depends_on: condition: service_healthymake start-up order reliable. - Named volumes keep data;
down -vdeletes them. - 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.