Day 7 · Containers III — Docker Compose, Volumes & Networks¶
This is where Operation Go Live's web tier becomes one command. You have an app image (Day 6), a reverse proxy (Day 4), and you know volumes and networks (Day 5). Today you declare the whole stack — Nginx → app → Postgres — in a single file, wire the services together over a private network, keep the database's data in a volume, and bring it all up with
docker compose up. Reproducible infrastructure, checked into git. That's the finish line for Week 2.
Learning Objectives¶
- Define a multi-service application in a Compose file
- Wire services together over a private network using service-name DNS
- Persist state with named volumes; mount config with bind mounts
- Order startup and gate on readiness with
depends_on+ healthchecks - Manage the stack lifecycle and scale a service
Theory · ~20 min¶
1. Why Compose¶
A real app is several containers — an app, a database, a cache, a proxy. Wiring them by hand with many docker run flags is error-prone and impossible to reproduce. Docker Compose declares the entire stack in one YAML file you commit to git; docker compose up reconciles reality to match it.
📺 Watch — Docker Compose Tutorial (Programming with Mosh, ~33 min)
A gentle, end-to-end walkthrough of writing a docker-compose.yml and bringing a multi-service stack up and down — perfect companion to today's lab.
2. The Compose file¶
services:
web: # service name = its DNS hostname on the network
image: nginx:alpine
ports:
- "8080:80" # publish host:container
depends_on:
- app
app:
build: ./app # build from a Dockerfile instead of pulling
environment:
- DB_HOST=db # reach the 'db' service by name
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: secret
volumes:
- db_data:/var/lib/postgresql/data # named volume for persistence
volumes:
db_data: # declared here, managed by Docker
No version: key anymore
The modern Compose Specification dropped the top-level version: field — the old version: "3.9" line is legacy and ignored by Compose v2. Just start with services:.
3. Networking in Compose¶
Compose puts every service on a default network and gives each a DNS name equal to its service name — so app reaches the database at host db, no IPs required. For real isolation, define multiple networks and attach each service only where it belongs:
Put db on the backend network only, and Nginx — on frontend — literally cannot reach it. Isolation by topology, not by firewall rules.
4. Volumes vs bind mounts¶
| Bind mount | Named volume | |
|---|---|---|
| Location | A path you choose on the host | Managed by Docker |
| Best for | Config & live code in dev | Databases & app data in prod |
| Portability | Tied to the host's paths | Portable across machines |
| Example | ./nginx.conf:/etc/nginx/...:ro |
db_data:/var/lib/postgresql/data |
5. Startup order and health¶
depends_on controls start order but, by itself, does not wait for a service to be ready — Postgres's container can be "started" seconds before it accepts connections. Pair it with a healthcheck and condition: service_healthy so your app only starts once the database truly answers:
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d golive"]
interval: 5s
timeout: 3s
retries: 5
app:
depends_on:
db:
condition: service_healthy
6. Docker & container best practices¶
A quick checklist that pulls together the last three days — pin these to memory:
| Area | Do | Why |
|---|---|---|
| Images | Start from -slim/-alpine; use multi-stage builds; pin tags (:1.27, not :latest) |
Smaller, faster pulls, reproducible, smaller attack surface |
| Build cache | Order the Dockerfile stable → volatile; copy requirements.txt and install before copying app code |
Reuses cached layers, so edits don't re-run slow installs |
| Context | Add a .dockerignore (.git/, node_modules/, secrets) |
Keeps junk and secrets out of the image and speeds builds |
| Process | One concern per container; use ENTRYPOINT + CMD correctly; add a HEALTHCHECK |
Simpler to scale, restart, and reason about |
| Data | Keep state in named volumes, never the writable layer; never down -v on real data |
Data survives rm/recreate; avoids accidental wipes |
| Config & secrets | Inject via env vars / .env, not baked into the image; mount configs as read-only bind mounts |
Same image across environments; no secrets in image history |
| Networking | Use user-defined networks and reach services by name; expose only what's needed | DNS instead of IPs; isolation by topology |
| Security | Run as a non-root USER; drop unneeded packages; remember the docker group is root-equivalent |
Limits blast radius if a container is compromised |
Lab · ~50 min¶
Work inside your Vagrant VM. Reuse the app from Day 6 (or recreate it below).
Step 1 — Install the Compose plugin¶
sudo apt update && sudo apt install -y docker-compose-plugin
docker compose version # note: 'docker compose' (v2), not 'docker-compose'
Step 2 — Lay out the stack¶
mkdir -p ~/golive-stack/app ~/golive-stack/nginx && cd ~/golive-stack
# --- the app (Flask + a DB check) ---
cat > app/app.py << 'EOF'
from flask import Flask, jsonify
import os, socket, psycopg2
app = Flask(__name__)
@app.get("/")
def index():
return jsonify(service="golive", host=socket.gethostname(),
env=os.getenv("APP_ENV", "development"))
@app.get("/db")
def db():
conn = psycopg2.connect(
host=os.getenv("DB_HOST", "db"), dbname=os.getenv("DB_NAME", "golive"),
user=os.getenv("DB_USER", "admin"), password=os.getenv("DB_PASS", "secret"))
cur = conn.cursor(); cur.execute("SELECT version();")
v = cur.fetchone()[0]; conn.close()
return jsonify(connected_to=v)
@app.get("/health")
def health():
return jsonify(status="ok")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
EOF
cat > app/requirements.txt << 'EOF'
flask==3.0.0
psycopg2-binary==2.9.9
EOF
cat > app/Dockerfile << 'EOF'
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["python3", "app.py"]
EOF
# --- nginx reverse proxy (same idea as Day 4) ---
cat > nginx/default.conf << 'EOF'
server {
listen 80;
location / {
proxy_pass http://app:8000; # 'app' resolves via Compose DNS
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
EOF
Step 3 — Write the Compose file¶
cat > docker-compose.yml << 'EOF'
services:
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro # bind mount config
depends_on:
- app
networks:
- frontend
app:
build: ./app
environment:
APP_ENV: development
DB_HOST: db
DB_NAME: golive
DB_USER: admin
DB_PASS: secret
depends_on:
db:
condition: service_healthy # wait until Postgres is truly ready
networks:
- frontend
- backend
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: golive
POSTGRES_USER: admin
POSTGRES_PASSWORD: secret
volumes:
- db_data:/var/lib/postgresql/data # named volume — data persists
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d golive"]
interval: 5s
timeout: 3s
retries: 5
networks:
- backend
volumes:
db_data:
networks:
frontend:
backend:
EOF
Step 4 — Bring it up¶
docker compose up -d --build # build the app image, start all three services
docker compose ps # STATUS should show healthy/running
docker compose logs -f app # Ctrl+C to stop following
curl http://localhost:8080 # through Nginx → app
curl http://localhost:8080/db # app → Postgres, over the private network
Step 5 — Prove persistence¶
# Write a row into the database
docker compose exec db psql -U admin -d golive \
-c "CREATE TABLE t(msg text); INSERT INTO t VALUES ('i persist');"
docker compose down # stop & remove containers — but NOT the volume
docker compose up -d
docker compose exec db psql -U admin -d golive -c "SELECT * FROM t;" # still there ✅
down -v deletes your data
docker compose down removes containers and networks but keeps named volumes. docker compose down -v also deletes the volumes — your database is gone. Never run -v against a stack holding data you care about.
Step 6 — Prove network isolation¶
docker network ls | grep golive-stack # find the exact network names
# 'app' is on backend → it can resolve 'db'
docker run --rm --network golive-stack_backend alpine nslookup db # resolves
# a container on 'frontend' only → cannot
docker run --rm --network golive-stack_frontend alpine nslookup db # fails to resolve
Nginx sits on frontend and genuinely has no route to the database. If the proxy is ever compromised, the DB isn't one hop away.
Step 7 — Lifecycle & scaling¶
docker compose stop # pause everything (containers kept)
docker compose start # resume
docker compose up -d --build # rebuild after a code change and reconcile
docker compose up -d --scale app=3 # three app replicas behind Nginx
docker compose ps # see app-1, app-2, app-3
# Nginx load-balances across them via Compose's DNS round-robin
for i in $(seq 6); do curl -s http://localhost:8080 | grep -o '"host":"[^"]*"'; done
docker compose down # tear it all down (add -v to also drop the volume)
Advanced Topics¶
- Profiles — group optional services (e.g. a
debugtoolbox) and start them only when asked → docs.docker.com — Profiles - Override files &
.env— layercompose.override.ymlfor dev vs prod, and pull secrets/config from a.envfile → docs.docker.com — Multiple Compose files - Compose secrets — mount sensitive values as files instead of environment variables → docs.docker.com — Secrets in Compose
- Resource limits & restart policies — cap CPU/memory and auto-restart crashed services with
deploy.resourcesandrestart→ Compose deploy reference - From Compose to production — the same model scales up to Docker Swarm and Kubernetes when one host isn't enough → kubernetes.io — Basics
Assignment¶
-
Add a cache and lock it down. Extend the stack with a Redis service (
redis:7-alpine) on the backend network only, and add a/cacheendpoint (or just wire theREDIS_HOSTenv — a code change is optional). Give every service a healthcheck. Then prove your network isolation: show that theappcontainer can reach bothdbandredis, butnginxcan reach neither. Paste your finaldocker-compose.ymland the commands/output that prove isolation. -
Make it reproducible. Move all secrets (DB password, etc.) out of
docker-compose.ymlinto a.envfile, keep the database on a named volume, and add aMakefilewithup,down,logs, andrebuildtargets. Prove the data survives amake down && make upcycle. Stretch: scaleappto 3 replicas, hit Nginx a dozen times, and show requests fanning out across all three container hostnames — then explain the difference betweendocker compose downanddocker compose down -vfrom what you observed.
Further Reading¶
- Compose file reference — every key, precisely defined
- Docker networking overview — bridge, host, and user-defined networks
- Manage data in Docker — volumes, bind mounts, and tmpfs compared
- 📺 Docker Compose Tutorial (Programming with Mosh, ~33 min) — writing and running a multi-service stack
- The Twelve-Factor App — the design principles behind container-ready services
