Docker Compose — Running Multi-Container Apps

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
2
Anatomy of a Compose File

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.
3
Example: osztromok.com Fallback Stack

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...
Terminal — up and down
# 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
4
Example: FastAPI + PostgreSQL Dev Stack

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.
5
Compose Command Reference
CommandWhat 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
6
Override Files: dev vs prod

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.
7
Profiles: Optional Services

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
Terminal — profiles
# 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
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.