Chapter 10 — Docker Compose: Running Multi-Container Apps
Every multi-container scenario in this course — Apache plus Cloudflare
Tunnel, FastAPI plus a database, Claude API scripts with supporting services —
required you to type several docker run commands, remember the
right flags, create networks manually, and tear everything down in the right
order. Docker Compose replaces all of that with a single YAML file and two
commands: docker compose up and docker compose down.
Compose isn't just a convenience wrapper. It gives your infrastructure a
permanent written record, makes it reproducible on any machine, and becomes
the foundation for everything more advanced — Swarm, Kubernetes, CI pipelines.
It's the natural final step of this course.
1. Before and After Compose
Without Compose — Chapter 7 startup
docker network create osztromok-net
docker run -d \
--name osztromok-apache \
--network osztromok-net \
-v "C:\...\website":\
/usr/local/apache2/htdocs:ro \
httpd:2.4
docker run -d \
--name osztromok-tunnel \
--network osztromok-net \
cloudflare/cloudflared:latest \
tunnel --no-autoupdate run \
--token TOKEN_HERE
# Remember to stop both, in order, to clean up
With Compose — same thing
docker compose up -d
# That's it. Compose reads docker-compose.yml,
# creates the network, starts both containers
# in the right order, names everything
# consistently, and remembers every flag.
# To stop and clean up everything:
docker compose down
A Compose file is a YAML file named docker-compose.yml
(or compose.yml) that lives in your project root. It has three
top-level sections: services, volumes, and
networks.
# docker-compose.yml — annotated skeleton
services: # one entry per container
web: # service name (used for DNS inside the network)
image: nginx:1.27 # use an existing image...
build: ./app # ...or build from a Dockerfile in ./app
ports:
- "8080:80" # host:container port mapping
volumes:
- ./site:/usr/share/nginx/html:ro # bind mount
- app-data:/var/lib/data # named volume
environment: # env vars (avoid secrets here)
- APP_ENV=production
env_file: # load from a .env file (use for secrets)
- .env
networks:
- app-net
restart: unless-stopped # restart policy
depends_on: # start db before web
- db
db:
image: mysql:8.0
volumes:
- db-data:/var/lib/mysql # persist database across restarts
environment:
- MYSQL_ROOT_PASSWORD=secret
- MYSQL_DATABASE=myapp
networks:
- app-net
volumes: # declare named volumes
app-data:
db-data:
networks: # declare custom networks
app-net:
Services communicate by name. Inside a Compose network,
every service is reachable at its service name as a hostname. The
web service can connect to MySQL at db:3306 —
no IP addresses, no manual network creation, Compose handles all of it.
Let's rewrite the Chapter 7 fallback (Apache + Cloudflare Tunnel) as a
Compose file. This replaces start-fallback.ps1 entirely.
# docker-compose.yml (osztromok fallback)
services:
apache:
image: httpd:2.4
container_name: osztromok-apache
volumes:
- C:\Users\emuba\OneDrive\My Learning\WebSites\website:/usr/local/apache2/htdocs:ro
networks:
- osztromok-net
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 5s
retries: 3
tunnel:
image: cloudflare/cloudflared:latest
container_name: osztromok-tunnel
command: tunnel --no-autoupdate run --token ${CF_TUNNEL_TOKEN}
env_file:
- .env
networks:
- osztromok-net
restart: unless-stopped
depends_on:
apache:
condition: service_healthy # wait until Apache passes healthcheck
networks:
osztromok-net:
# .env (same file as before — Compose reads it automatically)
CF_TUNNEL_TOKEN=eyJ...
# Start the whole stack in the background
$ docker compose up -d
[+] Running 3/3
✔ Network osztromok-net Created
✔ Container osztromok-apache Started
✔ Container osztromok-tunnel Started
# Check both containers are running
$ docker compose ps
NAME IMAGE STATUS
osztromok-apache httpd:2.4 Up (healthy)
osztromok-tunnel cloudflare/cloudflared:latest Up
# Follow logs from both containers at once
$ docker compose logs -f
apache | AH00094: Command line: 'httpd -D FOREGROUND'
tunnel | INF Registered tunnel connection connIndex=0
# Stop and remove containers + network (volumes preserved by default)
$ docker compose down
[+] Running 3/3
✔ Container osztromok-tunnel Removed
✔ Container osztromok-apache Removed
✔ Network osztromok-net Removed
This is the natural evolution of the Chapter 8 Python dev environment —
adding a real database so you can develop with something closer to production.
api (port 8000)
Build: ./Dockerfile · --reload · bind-mount source
↕ connects to db:5432
db (PostgreSQL)
Named volume: pg-data · env_file for credentials
Both on internal network — db not exposed to host
# docker-compose.yml (FastAPI + PostgreSQL)
services:
api:
build: .
ports:
- "8000:8000"
volumes:
- .:/workspace # bind-mount source for --reload
env_file:
- .env
environment:
- DATABASE_URL=postgresql://appuser:${DB_PASSWORD}@db:5432/appdb
networks:
- app-net
depends_on:
db:
condition: service_healthy
restart: on-failure
db:
image: postgres:16-alpine
volumes:
- pg-data:/var/lib/postgresql/data
env_file:
- .env
environment:
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=appdb
networks:
- app-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 3s
retries: 5
# db is NOT exposed with ports: — only reachable inside app-net
volumes:
pg-data:
networks:
app-net:
# .env
DB_PASSWORD=localdevpassword123
Terminal — dev stack workflow
# First time: build the api image and pull postgres
$ docker compose up -d --build
[+] Building 14.2s api image...
[+] Running 4/4
✔ Network app-net Created
✔ Volume pg-data Created
✔ Container db Started (healthy after 8s)
✔ Container api Started
# Run database migrations inside the api container
$ docker compose exec api alembic upgrade head
# Connect to postgres directly for inspection
$ docker compose exec db psql -U appuser -d appdb
appdb=# \dt
# Rebuild only the api image (after changing Dockerfile or requirements)
$ docker compose up -d --build api
# Stop stack but KEEP the pg-data volume (data survives)
$ docker compose down
# Stop stack AND delete volumes (fresh database next time)
$ docker compose down -v
depends_on with condition: service_healthy waits for the
healthcheck to pass before starting the dependent service. Without this, the
API container starts immediately, tries to connect to PostgreSQL before it's
ready, crashes, and Docker restarts it — which usually works eventually but
produces confusing error logs. The healthcheck approach is cleaner.
| Command | What it does |
| docker compose up -d |
Create and start all services in the background (-d = detach) |
| docker compose up -d --build |
Same but rebuild images first — use after changing Dockerfile or requirements |
| docker compose down |
Stop and remove containers and networks. Volumes are preserved. |
| docker compose down -v |
As above, also delete named volumes. Use when you want a completely fresh state. |
| docker compose ps |
List containers in this Compose project with their status and ports |
| docker compose logs -f |
Follow logs from all services. Add a service name to filter: logs -f api |
| docker compose exec api bash |
Open a shell inside the running api service container |
| docker compose exec db psql ... |
Run a command inside a running service (here: psql in the db container) |
| docker compose restart api |
Restart just one service without touching the others |
| docker compose stop |
Stop containers without removing them — faster to restart than down/up |
| docker compose pull |
Pull latest versions of all images defined with image: |
| docker compose config |
Validate and print the resolved Compose file — useful for debugging variable substitution |
Compose supports layering files with -f. A common pattern is
a base docker-compose.yml with shared config, and a
docker-compose.override.yml that Compose applies automatically
in development:
# docker-compose.yml (shared base — commit this)
services:
api:
image: myapp:latest
networks:
- app-net
db:
image: postgres:16-alpine
volumes:
- pg-data:/var/lib/postgresql/data
networks:
- app-net
volumes:
pg-data:
networks:
app-net:
# docker-compose.override.yml (dev additions — applied automatically)
services:
api:
build: . # build from source in dev
ports:
- "8000:8000"
volumes:
- .:/workspace # bind-mount for live reload
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
db:
ports:
- "5432:5432" # expose postgres to host only in dev
# Production — explicit file selection (no override applied)
docker compose -f docker-compose.yml up -d
# Development — automatic (override.yml applied on top)
docker compose up -d
docker compose config shows you exactly what the merged
result looks like before you run it — essential for debugging when you have
multiple override files or complex variable substitution.
Profiles let you define services that are off by default and only started
when explicitly requested — useful for debugging tools, admin UIs, or services
only needed occasionally:
services:
api:
image: myapp:latest # always started
db:
image: postgres:16-alpine # always started
adminer: # web-based DB admin UI
image: adminer
ports:
- "8080:8080"
profiles:
- tools # only start with --profile tools
# Normal start — adminer NOT started
$ docker compose up -d
✔ Container api Started
✔ Container db Started
# Start with tools profile — adminer now included
$ docker compose --profile tools up -d
✔ Container api Started
✔ Container db Started
✔ Container adminer Started
Exercises
- Convert the Chapter 7 fallback to Compose. Create a folder called
osztromok-fallback and write the docker-compose.yml from Step 3. Add a .env file with your Cloudflare tunnel token as CF_TUNNEL_TOKEN. Run docker compose up -d and confirm both containers appear in docker compose ps. Visit osztromok.com to confirm the site loads. Then run docker compose down and confirm both containers and the network are gone. Compare how much simpler this is than the PowerShell startup script.
- Build the FastAPI + PostgreSQL stack. Set up the full stack from Step 4. Start it with
docker compose up -d --build and watch it wait for PostgreSQL's healthcheck before starting the API. Run docker compose logs -f and observe the interleaved log output from both services. Connect to the database with docker compose exec db psql -U appuser -d appdb and run \conninfo to confirm you're inside the container's postgres. Stop and restart — confirm the volume persists (data survives). Then run docker compose down -v and confirm the volume is gone.
- Use docker compose exec for development tasks. With the FastAPI stack running, use
docker compose exec api to run each of the following without opening a separate terminal: pip list to see installed packages, python -c "import fastapi; print(fastapi.__version__)", and ruff check app/. Notice how Compose knows which container to target just from the service name.
- Try override files. Split the FastAPI compose file into a base
docker-compose.yml (no ports, no volumes, image-based) and a docker-compose.override.yml (adds ports, bind-mount, build, --reload). Run docker compose config and read the merged output to confirm the override is being applied. Then run docker compose -f docker-compose.yml config to see what production would look like without the override.
- Add an optional service with profiles. Add
adminer to your FastAPI stack under a tools profile. Run docker compose up -d (without the profile) and confirm adminer isn't started. Then run docker compose --profile tools up -d and visit http://localhost:8080 — log in to your PostgreSQL database using the credentials from your .env file. This is a useful pattern for admin tools you don't want running all the time.
🐳
Docker for Beginners — Complete
You've covered everything from containers vs VMs all the way to multi-service
Compose stacks. You can now pull and run images, manage volumes and networks,
write Dockerfiles, build custom images, run a real web server with a Cloudflare
Tunnel fallback, develop Python apps in a containerised environment, call the
Claude API safely, and orchestrate multi-container stacks with a single
YAML file.
What's next: A Docker Intermediate course would cover
multi-stage builds, Docker Swarm, container registries, CI/CD pipelines with
Docker, and production security hardening. When you're ready, ask for
a course outline.