Lab 11 — Compose Multi-Service App
Learner Orientation
Lab Metadata
| Item | Value |
|---|---|
| Difficulty | Beginner to intermediate |
| Estimated time | 60-90 minutes |
| VMs | 1 |
| Minimum VM RAM | 2048 MB |
| SSH ports | 2217 |
| Internet required | Yes |
Before You Start, You Should Be Able To
- Yeast installed on a Linux/KVM host
- Comfort opening a terminal and changing directories
- Ability to run
yeast up,yeast ssh <instance>, andyeast destroy - Basic comfort with
curl,systemctl, and reading command output - Basic understanding that Docker commands run inside the VM unless stated otherwise
- Comfort using forwarded Yeast host URLs from
ACCESS.mdfor browser-based tools
Where Commands Run
- Run
yeastcommands from this lab folder on your laptop. - Run Linux service commands only after you SSH into the target VM.
- When a command says "from your laptop", leave the VM shell first with
exit. - When a browser URL uses
localhost, check whether Yeast already forwarded that port for you. - Run Docker commands inside the VM unless the lab explicitly says otherwise.
Expected Checkpoints
- After
yeast up,yeast statusshould show the expected VM or VMs as running. - After the main setup steps, the service, tool, or workflow introduced by the lab should respond to the verification commands.
- After
bash assets/validate.sh, the script should report all checks passed. - After
yeast destroy, the lab should be cleaned up before you start the next one.
Common Mistakes To Avoid
- Running a VM command on your laptop, or a laptop command inside the VM.
- Ignoring the forwarded port shown by
yeast uporyeast status. - Skipping validation because the final page or command "looked fine".
- Forgetting to run
yeast destroybefore moving to the next lab. - Confusing laptop
localhost, VMlocalhost, and containerlocalhost.
The Story
In Lab 10 you ran containers one at a time. A real application is not one container — it is several. A Python app, a PostgreSQL database, maybe a Redis cache, an Nginx proxy in front of all of it. Starting each one manually with docker run, wiring up the networks and volumes by hand, remembering which environment variables go where — that becomes unmanageable fast.
Docker Compose is the answer. You describe your entire application stack in a single YAML file — compose.yaml — and bring it all up with one command. Services discover each other by name. Volumes are defined once. Environment variables are loaded from .env. The whole stack starts, stops, and scales as a unit.
This is how virtually every development environment works today. And it is the same mental model as Kubernetes (which you will use in Lab 27) — just simpler and local.
Before You Start — Understanding The Concepts
What Is Docker Compose?
Docker Compose is a tool for defining and running multi-container applications. It reads a compose.yaml file that describes your services, their images, environment, ports, volumes, and dependencies.
Key commands:
- docker compose up -d — start all services in the background
- docker compose down — stop and remove containers (volumes survive by default)
- docker compose down -v — stop, remove containers AND volumes
- docker compose ps — show status of services
- docker compose logs <service> — read a service's logs
- docker compose exec <service> <cmd> — run a command inside a running service
- docker compose restart <service> — restart one service
What Is Service Discovery?
In a Compose stack, each service is reachable by its service name as a hostname. If your database service is named db, the app connects to db:5432. If your cache is named redis, the app connects to redis:6379.
Docker handles the DNS automatically. You never need to hardcode IP addresses.
What Is depends_on?
depends_on tells Compose which services must start before this one. app: depends_on: [db] means Docker starts db first, then app. But it only waits for the container to start — not for the service inside to be ready. The database might take a few seconds to initialize. This is why apps need retry logic for the initial database connection.
What Is A Compose Network?
Compose creates a default network for each project. All services in the same compose.yaml are automatically on the same network and can reach each other by service name.
You can also define named networks and control which services join which network — useful for isolating the database tier from the proxy tier.
Environment Variables In Compose
Compose reads a .env file in the same directory as compose.yaml. Values in .env are available as variables in the YAML using ${VAR} syntax. This is how you inject secrets without putting them in the compose file itself.
What You Are Building
Your Laptop
│
│ HTTP port 8080 → proxy container port 80
│ SSH port 2217 → VM port 22
▼
┌──────────────────────────────────────────────────────────┐
│ compose VM │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ proxy │───▶│ app │───▶│ db │ │
│ │ nginx │ │ python:8000 │ │ postgres:5432│ │
│ │ :80 │ │ │ │ │ │
│ └──────────┘ └──────────────┘ └───────────────┘ │
│ │
│ All on compose internal network │
│ Volume: pgdata (postgres data) │
└──────────────────────────────────────────────────────────┘
Starting The Lab
Creating The Application
Create the project directory:
The Python App
cat > app.py << 'EOF'
#!/usr/bin/env python3
import json
import os
import time
import psycopg2
import psycopg2.extras
from http.server import HTTPServer, BaseHTTPRequestHandler
DB_CONFIG = {
"host": os.environ["DB_HOST"],
"port": int(os.environ.get("DB_PORT", "5432")),
"dbname": os.environ["DB_NAME"],
"user": os.environ["DB_USER"],
"password": os.environ["DB_PASS"],
}
def get_conn():
return psycopg2.connect(**DB_CONFIG)
def wait_for_db(retries=10, delay=2):
for i in range(retries):
try:
conn = get_conn()
conn.close()
print("Database ready")
return
except Exception as e:
print(f"Waiting for database... ({i+1}/{retries}): {e}")
time.sleep(delay)
raise RuntimeError("Database never became ready")
def init_db():
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
""")
conn.commit()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/healthz":
self._respond(200, {"status": "ok"})
return
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM items ORDER BY id")
rows = cur.fetchall()
self._respond(200, {"items": [dict(r) for r in rows]})
except Exception as e:
self._respond(500, {"error": str(e)})
def do_POST(self):
if self.path != "/items":
self._respond(404, {"error": "not found"})
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("INSERT INTO items (name) VALUES (%s) RETURNING id", (body["name"],))
row_id = cur.fetchone()[0]
conn.commit()
self._respond(201, {"id": row_id, "name": body["name"]})
except Exception as e:
self._respond(500, {"error": str(e)})
def _respond(self, code, data):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print(f"{self.address_string()} - {fmt % args}")
if __name__ == "__main__":
wait_for_db()
init_db()
print("App listening on :8000")
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
EOF
The Dockerfile
The app needs a container image. A Dockerfile describes how to build it:
cat > Dockerfile << 'EOF'
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir psycopg2-binary
COPY app.py .
EXPOSE 8000
CMD ["python3", "app.py"]
EOF
Let's read every line:
FROM python:3.11-slim — the base image. python:3.11-slim is the official Python 3.11 image, "slim" variant (smaller than the full image, no extra tools). Every image builds on top of a base.
WORKDIR /app — set the working directory inside the container. All subsequent commands run from here.
RUN pip install --no-cache-dir psycopg2-binary — run a command during the image build. This installs the PostgreSQL driver. --no-cache-dir prevents pip from caching packages, keeping the image smaller.
COPY app.py . — copy app.py from the build context (your current directory) into /app in the image.
EXPOSE 8000 — documentation: this container listens on port 8000. Does not actually open a port — that is done with -p or in the compose file.
CMD ["python3", "app.py"] — the default command to run when the container starts. Uses exec form (array) not shell form (string) so signals are delivered directly to the process.
Build the image:
-t myapp:latest tags the image with the name myapp and tag latest. Watch the build output — you can see each layer being built.
The Nginx Config
cat > nginx.conf << 'EOF'
upstream app {
server app:8000;
}
server {
listen 80;
server_name _;
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /healthz {
proxy_pass http://app;
}
}
EOF
app:8000 — app is the service name from compose.yaml. On the Compose network, Nginx resolves app to the app container's IP automatically.
The Environment File
cat > .env << 'EOF'
DB_HOST=db
DB_PORT=5432
DB_NAME=appdb
DB_USER=appuser
DB_PASS=composelab11
POSTGRES_DB=appdb
POSTGRES_USER=appuser
POSTGRES_PASSWORD=composelab11
EOF
chmod 600 .env
Two sets of variables: DB_* for the app, POSTGRES_* for the official PostgreSQL image's initialization.
The Compose File
cat > compose.yaml << 'EOF'
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
app:
image: myapp:latest
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
proxy:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
restart: unless-stopped
volumes:
pgdata:
EOF
Let's read the key parts:
image: postgres:15-alpine — use the official PostgreSQL 15 image, Alpine variant (very small). No Dockerfile needed — the image is pre-built on Docker Hub.
volumes: pgdata:/var/lib/postgresql/data — mount the named volume pgdata at the path where Postgres stores its data files. The data survives docker compose down (but not docker compose down -v).
healthcheck — Compose polls this command to determine if the service is healthy. pg_isready returns 0 when Postgres is ready to accept connections. The app service will not start until db is healthy — that is condition: service_healthy in depends_on.
restart: unless-stopped — automatically restart the container if it exits, unless you explicitly stopped it.
volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro — bind mount: the nginx.conf from the current directory on the host is mounted read-only inside the proxy container. Changes to the file on the host appear in the container instantly (though Nginx still needs a reload to pick them up).
volumes: pgdata: at the bottom — this declares the named volume. Named volumes must be declared in this top-level volumes: section.
Starting The Stack
Watch what happens:
Wait for all three services to show running (healthy) or running. The db service takes a few seconds to initialize.
Follow the logs to watch startup:
You should see:
- db initializing and becoming ready
- app printing "Waiting for database... Database ready" then "App listening on :8000"
- proxy starting nginx
Press Ctrl+C to stop following logs.
Testing The Stack
From inside the VM:
From your laptop, first create an SSH tunnel to the Compose VM:
Keep that tunnel terminal open, then test from your laptop:
curl http://localhost:8080/items
curl -X POST http://localhost:8080/items \
-H "Content-Type: application/json" \
-d '{"name": "first compose item"}'
curl http://localhost:8080/items
You have a full app → proxy → database stack running from one compose file.
Service Logs
# All services
docker compose logs
# Just one service, follow
docker compose logs -f app
# Last 20 lines of db
docker compose logs --tail 20 db
Exec Into A Service
# Shell in the app container
docker compose exec app bash
# Run a command in the db container
docker compose exec db psql -U appuser -d appdb -c "SELECT * FROM items;"
No SSH required — docker compose exec opens a shell or runs a command in a running service container.
Redeploying After A Change
Change the app — add a /status endpoint:
cat >> app.py << 'PYEOF'
# (Already handled in do_GET via /healthz — this simulates a code change)
PYEOF
Rebuild the image and restart just the app service:
--no-deps means "restart only this service, not its dependencies." This is the redeploy pattern for iterating on one service without disturbing the database.
Data Persistence Across Restarts
Create some data:
Stop and recreate the stack:
Check the data:
The item is still there. The pgdata volume persisted across the down/up cycle.
Now destroy the volumes too:
Empty — {"items": []}. The -v flag removes volumes. This is a complete reset.
Validate Your Work
Clean Up
Quick Recap
In Lab 11 — Compose Multi-Service App, you moved from explanation to a working lab environment, verified the result, and practiced the operational habit that matters most: do the work, prove it works, then clean it up.
Keep this pattern for every lab:
- Build the thing.
- Verify it from the right place.
- Read the logs or status when it fails.
- Run the validation script.
- Destroy the lab before moving on.
What You Learned
- What Docker Compose is and why single-container
docker rundoes not scale compose.yamlstructure: services, volumes, networks- Service discovery by name:
app,db,proxyresolve by hostname inside the stack depends_onwithcondition: service_healthy— proper startup ordering- Healthchecks: how Compose knows a service is actually ready, not just started
restart: unless-stopped— automatic recovery from crashes.envfiles and${VAR}substitution in compose files- Named volumes vs bind mounts in the compose context
docker compose logs,exec,up -d,down,down -v--no-depsfor redeploying one service without touching the others- The difference between
down(removes containers) anddown -v(also removes volumes)
What Is Next
Lab 12 — Container Build, Scan, And Hardening
You built a Dockerfile in this lab. Lab 12 goes deeper: what makes a container image safe and small? You will learn Dockerfile best practices, build smaller images, run containers as non-root users, scan for known vulnerabilities with Trivy, and understand what an SBOM is and why it matters.