You will assemble a production-shaped app: a reverse proxy in front of an API, a background worker, and a database with persistent storage. By the end you will run a realistic topology with config via environment and secrets.

This is the topology you see in almost every real deployment: one public entry point that terminates client connections, a stateless application tier that can be scaled horizontally, an asynchronous worker tier that drains a queue, and a stateful data tier that must never be exposed directly. Building it by hand forces you to confront the questions tutorials skip: which service may talk to which, where do credentials live, and what happens to your data when a container dies.

Learning Objectives

  • Compose a 4-tier app (proxy, API, worker, DB).
  • Inject configuration with environment and secrets.
  • Scale a stateless service horizontally.
  • Segment traffic with internal networks so only the proxy is reachable.
  • Persist database state across container restarts with a named volume.
  • Time: ~4 hours · Difficulty: Intermediate · Prereqs: the Compose lab completed.

Architecture Overview

Read the diagram as a set of trust boundaries. The Nginx proxy is the only container that publishes a port to the host; everything else lives on internal networks. The API is stateless, which is what makes horizontal scaling safe: any replica can answer any request because all state is pushed out to PostgreSQL and Redis. The worker never receives HTTP traffic at all, it blocks on a Redis queue, picks up jobs the API enqueues, and writes results back to the database. That decoupling means a slow job (report generation, image processing, email delivery) never holds an API connection open.

Two design rules drive everything below:

  • Least exposure: if a service does not need to be reached from the host, it gets no ports: mapping. The database and Redis are reachable only by name on the internal network.
  • State lives outside containers: PostgreSQL data goes on a named volume, and the API holds no session state. You should be able to docker compose up -d --scale api=5 or destroy an API container at any moment without losing anything.

Environment Setup

Prerequisites

  • Docker Engine 24+ with Compose v2.
  • The lab's sample API, worker, database, and proxy configs (or equivalents you write).
  • Assumed skills: Docker Compose basics and how HTTP reverse proxies route.

You will need: Docker Compose v2 and the proxy/API/worker images (or simple placeholders).

Before you begin: never bake secrets into images; pass them at runtime.

Verify your toolchain first. Compose v2 ships as a Docker CLI plugin, so the command is docker compose (space, not hyphen). The legacy Python-based docker-compose v1 reached end-of-life and behaves differently around networking and secrets, so confirm what you have:

$ docker compose version
Docker Compose version v2.29.2

Create a clean project directory. Every command below assumes you run it from this directory, because Compose derives the project name (and therefore container and network names) from the folder name unless you override it with -p or COMPOSE_PROJECT_NAME.

mkdir -p multiapp/api multiapp/worker multiapp/proxy multiapp/secrets && cd multiapp
The api/ and worker/ folders hold each service's Dockerfile and code; proxy/ holds the Nginx config; secrets/ holds runtime secret files.

By the end of the lab the directory will look like this. Keep it small and boring, every file here is something you could recreate by hand, which is exactly the point of infrastructure-as-configuration:

multiapp/
├── compose.yaml          # the whole topology, declaratively
├── .env                  # non-secret config (gitignored)
├── .env.example          # committed template with dummy values
├── api/
│   ├── Dockerfile          # builds the API image locally
│   └── app.py              # stdlib HTTP API (Redis + PostgreSQL)
├── worker/
│   ├── Dockerfile          # builds the worker image locally
│   └── worker.py           # pops Redis jobs, writes results to PostgreSQL
├── proxy/
│   └── nginx.conf        # reverse proxy config, mounted read-only
└── secrets/
    └── db_password       # runtime secret file, chmod 600, gitignored

Two hygiene rules before you write anything else: add .env and secrets/ to .gitignore before the first commit, and confirm the Docker daemon has at least 2 GB of memory available, PostgreSQL plus three API replicas and Redis will not start comfortably on a starved VM.

Step-by-Step Execution

01
Define the multi-service stack

Each tier is a service; the proxy is the only one exposed to the host.

The compose file below encodes the whole architecture. Notice what is not there: the API, worker, database, and Redis have no ports: section at all. Two user-defined networks split the topology, frontend carries proxy-to-API traffic and backend carries API/worker-to-data traffic. Nginx is attached only to frontend, so even if the proxy were compromised, an attacker could not reach PostgreSQL directly from it.

# compose.yaml
services:
  proxy:
    image: nginx:1.27-alpine
    volumes:
      - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "8080:80"
    depends_on:
      api:
        condition: service_started
    networks: [frontend]

  api:
    build: ./api
    env_file: .env
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
      queue:
        condition: service_started
    networks: [frontend, backend]

  worker:
    build: ./worker
    env_file: .env
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
      queue:
        condition: service_started
    networks: [backend]

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
    networks: [backend]

  queue:
    image: redis:7-alpine
    networks: [backend]

networks:
  frontend:
  backend:

volumes:
  pgdata:

secrets:
  db_password:
    file: ./secrets/db_password

The API and worker are real code you build locally. Placeholder image names would leave the stack unrunnable, so each custom service gets a small Python application and a Dockerfile. Compose builds both images on first up.

# api/Dockerfile
FROM python:3.12-alpine
RUN pip install --no-cache-dir "redis" "psycopg[binary]"
COPY app.py /srv/app.py
EXPOSE 8000
CMD ["python", "/srv/app.py"]
# api/app.py - stdlib HTTP API backed by Redis and PostgreSQL
import json, os, socket, time
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse

import redis, psycopg

def read_secret(name):
    with open(f"/run/secrets/{name}") as f:
        return f.read().strip()

DB = {
    "host": os.environ.get("APP_DB_HOST", "db"),
    "user": os.environ.get("APP_DB_USER", "appuser"),
    "dbname": os.environ.get("APP_DB_NAME", "appdb"),
    "password": read_secret("db_password"),
}
_redis_url = urlparse(os.environ.get("REDIS_URL", "redis://queue:6379/0"))
QUEUE = os.environ.get("QUEUE_NAME", "jobs")
rq = redis.Redis(host=_redis_url.hostname, port=_redis_url.port or 6379, decode_responses=True)

def init_db():
    for _ in range(30):
        try:
            with psycopg.connect(**DB) as conn:
                conn.execute(
                    "CREATE TABLE IF NOT EXISTS results ("
                    "job_id BIGINT PRIMARY KEY, task TEXT NOT NULL, "
                    "duration_ms INTEGER NOT NULL, created_at TIMESTAMPTZ DEFAULT now())"
                )
            return
        except Exception:
            time.sleep(2)
    raise SystemExit("database never became ready")

class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        fwd = self.headers.get("X-Forwarded-For", "").split(",")[0].strip()
        print(f"{fwd or self.client_address[0]} - {fmt % args}", flush=True)

    def _json(self, code, obj):
        body = json.dumps(obj).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 do_GET(self):
        if self.path == "/api/whoami":
            self._json(200, {"served_by": socket.gethostname()})
        elif self.path == "/api/health":
            self._json(200, {"status": "ok"})
        else:
            self._json(404, {"error": "not found"})

    def do_POST(self):
        if self.path != "/api/jobs":
            return self._json(404, {"error": "not found"})
        length = int(self.headers.get("Content-Length", 0))
        payload = json.loads(self.rfile.read(length) or b"{}")
        job = {"id": int(payload.get("id", 0)), "task": payload.get("task", "")}
        rq.rpush(QUEUE, json.dumps(job))
        self._json(200, {"queued": True, "job_id": job["id"]})

if __name__ == "__main__":
    init_db()
    HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
# worker/Dockerfile
FROM python:3.12-alpine
RUN pip install --no-cache-dir "redis" "psycopg[binary]"
COPY worker.py /srv/worker.py
CMD ["python", "/srv/worker.py"]
# worker/worker.py - pops jobs from Redis, writes results to PostgreSQL
import json, os, time
from urllib.parse import urlparse

import redis, psycopg

def read_secret(name):
    with open(f"/run/secrets/{name}") as f:
        return f.read().strip()

DB = {
    "host": os.environ.get("APP_DB_HOST", "db"),
    "user": os.environ.get("APP_DB_USER", "appuser"),
    "dbname": os.environ.get("APP_DB_NAME", "appdb"),
    "password": read_secret("db_password"),
}
_redis_url = urlparse(os.environ.get("REDIS_URL", "redis://queue:6379/0"))
QUEUE = os.environ.get("QUEUE_NAME", "jobs")
rq = redis.Redis(host=_redis_url.hostname, port=_redis_url.port or 6379, decode_responses=True)

print(f"worker up, watching queue {QUEUE!r}", flush=True)
while True:
    item = rq.blpop(QUEUE, timeout=5)
    if not item:
        continue
    job = json.loads(item[1])
    started = time.time()
    time.sleep(0.3)  # stand-in for real work
    duration_ms = int((time.time() - started) * 1000)
    with psycopg.connect(**DB) as conn:
        conn.execute(
            "INSERT INTO results (job_id, task, duration_ms) VALUES (%s, %s, %s) "
            "ON CONFLICT (job_id) DO UPDATE SET task = EXCLUDED.task, duration_ms = EXCLUDED.duration_ms",
            (job["id"], job["task"], duration_ms),
        )
    print(f'job {job["id"]} dequeued from "{QUEUE}", processed in {duration_ms}ms, result written to {DB["dbname"]}', flush=True)
Secret handling, for real: both applications read the database password from /run/secrets/db_password at startup and never from the environment. Compose file-backed secrets are mounted files, not Swarm or Kubernetes secrets: anyone who can read the project directory can read them. That is fine for a lab, but production needs a real secret manager.

Expected output:

$ docker compose config --quiet && echo "stack definition is valid"
stack definition is valid

Three details deserve attention. First, depends_on with condition: service_healthy makes the API and worker wait until PostgreSQL actually accepts connections, plain depends_on only waits for the container to start, which is a classic source of “connection refused” crashes on first boot. Second, the database reads its password from POSTGRES_PASSWORD_FILE rather than a plain environment variable, so the secret never appears in docker inspect output. Third, pgdata is a named volume: the database files survive docker compose down and container replacement.

docker compose -f compose.yaml config
Validates and renders the merged configuration.

Always run config before up. It parses the YAML, resolves variable interpolation and env_file references, and prints the final merged model. A typo like a missing network or a malformed healthcheck fails here with a line number instead of halfway through a deployment. Skim the rendered output to confirm each service landed on the networks you intended.

Common mistake: editing the file and running docker compose up without re-checking config. Compose caches nothing dangerous, but a YAML indentation error only surfaces at parse time, validate after every edit.

What this teaches: The stack definition is the architecture as data; every service, network, and secret declared in one place.

02
Write the reverse proxy configuration

Nginx is the single public entry point. Its job is to accept client connections and forward them to the API tier by service name. Docker's embedded DNS resolves api to the IPs of all healthy API containers, and Nginx re-resolves it per request when you proxy to a variable, but for this lab the simple form below is enough, because step 05 scales the API in the same command that starts the proxy, so Nginx normally resolves all three replicas at startup. There is a startup race: if the proxy resolves before every replica registers with Docker DNS, some replicas get no traffic until you run docker compose restart proxy. Know the limitation: Nginx resolves upstream hostnames once at startup and does not track later topology changes. If you scale the API after the proxy is already up, new replicas receive no traffic until you run docker compose restart proxy.

# proxy/nginx.conf
upstream api_backend {
    server api:8000;
}

server {
    listen 80;
    server_name _;

    location /api/ {
        proxy_pass http://api_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location /healthz {
        return 200 "proxy ok\n";
    }
}

Expected output:

$ docker compose exec proxy nginx -t
nginx: configuration file /etc/nginx/nginx.conf test is successful

The X-Forwarded-For header matters more than it looks: without it, every request arriving at the API appears to come from the proxy's IP, which breaks rate limiting, audit logging, and any IP-based access control downstream. The /healthz endpoint gives you a way to tell “proxy is up” apart from “API is up” when debugging.

How to verify: after the stack is up, curl http://localhost:8080/healthz should return proxy ok even if the API containers are stopped. If it does not, the problem is in the proxy layer, not the app.

What this teaches: The reverse proxy is the only public face; routing rules decide which internal service answers which path.

03
Configure the app through the environment

Anything that varies between your laptop, CI, and production belongs in the environment, not in the image. Create a .env file (Compose reads it automatically) with the non-sensitive configuration:

# .env - non-secret config only; never commit real secrets
APP_ENV=production
APP_DB_HOST=db
APP_DB_USER=appuser
APP_DB_NAME=appdb
REDIS_URL=redis://queue:6379/0
QUEUE_NAME=jobs
LOG_LEVEL=info

Expected output:

$ docker compose exec api env | grep APP_
APP_ENV=production
APP_DB_HOST=db

Notice the hostnames: db and queue are Compose service names, resolved by Docker's internal DNS on the backend network. You never hardcode container IPs, they change on every restart.

Add .env to your .gitignore. A common pattern is to commit a .env.example with dummy values so teammates (and CI) can see which variables are required without seeing real values.

How to verify: run docker compose config again and confirm the values from .env appear in the rendered environment: section of the API and worker services. If they do not, the file is misnamed, in the wrong directory, or the service is missing the env_file: key.

What this teaches: Environment-driven config keeps the image portable; the same image behaves differently per environment.

04
Provide secrets at runtime

Environment variables are convenient but leaky: they show up in docker inspect, in crash dumps, and sometimes in logs. For the database password, use Compose's file-based secrets instead. Compose mounts each secret as a read-only file under /run/secrets/ inside the container, and nothing about it appears in the container's environment or metadata.

On plain Compose (this lab), the secret is a local file that the secrets: block in compose.yaml points at. Do not use docker secret create here: it only works on a Swarm manager, and on one it would create a real Swarm secret as a side effect.

Create the secret file directly:

$ printf 'S3cure-Lab-Passw0rd!' > secrets/db_password
$ chmod 600 secrets/db_password
# no output; the file is now readable only by you
Passwords do not rotate this way on existing data. POSTGRES_USER, POSTGRES_DB, and POSTGRES_PASSWORD_FILE only take effect when PostgreSQL initializes an empty data directory. Once pgdata exists, editing secrets/db_password changes nothing inside the running cluster. To rotate on existing data, connect and run ALTER USER appuser WITH PASSWORD 'newvalue', then update the secret file to match.

Because compose.yaml declares db_password: { file: ./secrets/db_password }, the API, worker, and db containers each get /run/secrets/db_password mounted at startup. The API reads that file at boot and builds its connection string; PostgreSQL does the same via POSTGRES_PASSWORD_FILE.

How to verify: once the stack is running, docker compose exec api ls -l /run/secrets/ should list db_password, and docker compose exec api env | grep -i pass should return nothing. If the password shows up in env, you accidentally passed it as a variable, remove it and use the file.

Common mistake: a trailing newline in the secret file. echo "pw" > secrets/db_password appends \n, and PostgreSQL will then reject the password the API reads. Use printf (no newline) or echo -n.

Expected output:

$ docker compose exec api ls -l /run/secrets/
-r-------- 1 root root 32 Sep 29 10:31 db_password

What this teaches: Runtime secrets arrive as files, not env vars; the application reads them from the mount path.

05
Bring it up and scale the API

Start the whole stack detached, and scale the stateless API to three replicas in the same command. Compose creates the networks and volume first, waits for the database healthcheck to pass, then starts the API replicas, worker, and proxy.

$ docker compose up -d --scale api=3
[+] Running 7/7 multiapp-api-1 Started multiapp-api-2 Started multiapp-api-3 Started

Confirm the full picture with ps. You should see one proxy with a published port, three API containers, one worker, one database reporting healthy, and one Redis container:

$ docker compose ps
NAME SERVICE STATUS PORTS multiapp-proxy-1 proxy Up 0.0.0.0:8080->80/tcp multiapp-api-1 api Up multiapp-api-2 api Up multiapp-api-3 api Up multiapp-worker-1 worker Up multiapp-db-1 db Up (healthy) multiapp-queue-1 queue Up

Scaling works here because the API is stateless and the scale happens in the same command that starts the proxy, so Nginx resolves all three container IPs at startup. The worker and database are deliberately not scaled: two workers would race on the same queue (fine for some workloads, but requires idempotent job handling), and scaling PostgreSQL is a replication problem, not a Compose flag.

Common mistake: trying to scale a service that publishes a fixed host port. --scale proxy=3 fails with “port already allocated” because all three would claim 8080. Only services without host port mappings scale freely.

Expected output:

$ docker compose up -d --scale api=3
[+] Running 7/7
 Container multiapp-api-1  Started
 Container multiapp-api-2  Started
 Container multiapp-api-3  Started

What this teaches: Scaling stateless services is safe because replicas share nothing; the proxy balances across them.

06
Watch the wiring: logs and the job queue

Before trusting the stack, watch each tier do its job. Stream logs across all services (or filter to one):

docker compose logs -f --tail=20 api worker
Follow API and worker logs; Ctrl+C stops following but leaves the stack running.

Enqueue a test job through the API and watch the worker pick it up. The API pushes onto the Redis queue; the worker blocks on BLPOP, processes the job, and writes the result row to PostgreSQL:

$ curl -s -X POST http://localhost:8080/api/jobs -d '{"task":"resize","id":42}'
{"queued": true, "job_id": 42}
$ docker compose logs --tail=5 worker
multiapp-worker-1 | job 42 dequeued from "jobs", processed in 301ms, result written to appdb

How to verify: if the API says queued: true but the worker log stays silent, the two sides are pointed at different queues or different Redis instances, compare QUEUE_NAME and REDIS_URL in the rendered config for both services.

Expected output:

$ docker compose logs worker --tail=1
multiapp-worker-1  | job 42 dequeued from "jobs", processed in 301ms, result written to appdb

What this teaches: Logs plus the queue depth are the observability pair; they show what happened and what is waiting.

07
Prove persistence across a recreate

The whole point of the named volume is that data outlives containers. Prove it: write a row, stop the stack, bring it back, and read the row again.

$ docker compose exec db psql -U appuser -d appdb -c "SELECT count(*) FROM results;"
count ------- 1 (1 row)
$ docker compose down && docker compose up -d --scale api=3
[+] Running 7/7 ... (networks, volume, containers recreated)

Re-run the SELECT count(*), the row is still there because pgdata survived. Only docker compose down -v deletes named volumes, and that flag is how you reset the lab to a blank slate. Treat -v like rm -rf for your data tier: never run it against anything you care about.

Expected output:

$ docker compose exec db psql -U appuser -d appdb -c "SELECT count(*) FROM results;"
 count 
-------
     1
(1 row)

What this teaches: Proving persistence before teardown is the discipline; the volume surviving down is the test.

Step Flow Map

Each box is one step in the lab; work them in order, since later steps build on earlier state.

Progress So Far

The milestones map to the three skills this lab is really about: describing a topology declaratively, keeping secrets out of images, and scaling the tier that is safe to scale.

How It Works

Twelve-factor config says configuration travels in the environment, not in the image: the same image runs in dev and prod with different variables. The reverse proxy is the single public face; it terminates client connections and routes by path or host to the internal services, which never publish ports themselves. This is the shape of nearly every production web deployment, from a compose stack to Kubernetes ingress.

Secrets are not environment variables. Env vars leak into logs, process tables, and child processes; Docker secrets are mounted as files with restricted permissions and exist only where mounted. The application reads the secret from the file path, which keeps the value out of the places env vars inevitably wander.

Stateless services scale horizontally; stateful ones do not. The API replicas share nothing, so scaling the API to three just works behind the proxy's load balancing, while the database stays at one replica with its data on a volume. The job queue decouples the API from the worker: requests enqueue fast, workers drain at their own pace, and adding a second worker visibly shortens the queue.

Testing & Validation

for i in 1 2 3; do curl -s http://localhost:8080/api/whoami; done

You should see responses from different API replicas, confirming load distribution behind the proxy.

On the port this lab publishes, the loop looks like this, note the rotating container hostname in each reply:

$ for i in 1 2 3 4 5 6; do curl -s http://localhost:8080/api/whoami; echo; done
{"served_by": "multiapp-api-2"} {"served_by": "multiapp-api-1"} {"served_by": "multiapp-api-3"} {"served_by": "multiapp-api-2"} {"served_by": "multiapp-api-1"} {"served_by": "multiapp-api-3"}

Run the remaining checks as a quick acceptance pass:

  • Proxy health independent of the app: curl -s http://localhost:8080/healthz returns proxy ok. Stop the API with docker compose stop api and confirm the proxy still answers while /api/whoami returns 502, now you know which layer a failure lives in. Then bring it back with docker compose start api before the remaining checks, they need a live API.
  • Internal services are not exposed: curl --max-time 2 http://localhost:5432 and http://localhost:6379 should fail to connect. If either responds, you published a port on a data-tier service by mistake.
  • End-to-end job flow: POST a job, see it in the worker logs, and confirm the result row with docker compose exec db psql -U appuser -d appdb -c "SELECT * FROM results;".
  • Headers survive the proxy: curl -si http://localhost:8080/api/whoami | head -5, the API should log your real client IP (via X-Forwarded-For), not the proxy's container IP.
  • Persistence: docker compose down && docker compose up -d --scale api=3, then wait for the database to accept connections (until docker compose exec db pg_isready -U appuser -d appdb; do sleep 2; done) before re-checking the row count. Data present = volume wired correctly. Do not query immediately after up: the new database container needs a few seconds before it answers, and an instant check races that startup.

Final teardown, only after every check above is green: nothing in the validation needs the stack anymore, so remove it completely, including the named volume:

docker compose down -v

Troubleshooting

Quick-reference for the failures you are most likely to hit in this lab.

SymptomLikely causeFix
Worker never picks up jobsThe queue URL or credentials in the worker's environment are wrongExec into the worker and print its effective config; compare against the compose file.
Secret file is empty in the containerThe secret was not attached to the service, or file permissions block readsCheck the service definition mounts the secret and the app reads the file path, not an env var.
Proxy returns 502 for the APIUpstream hostname wrong, or the API not yet healthyCurl the API by its service name from inside the proxy container to isolate DNS from proxy config.
Data lost after teardownThe database volume was not a named volume, or --volumes was usedInspect the volume before teardown; anonymous volumes are the usual culprit.
Scaling the API breaks routingOnly the proxy publishes ports; scaled replicas share the internal network fineVerify with docker compose ps; port conflicts only happen if each replica publishes.
Troubleshooting
  • Only one replica answers: ensure the proxy load-balances across the service name, not a fixed container.
  • Worker idle: confirm it consumes the same Redis queue the API publishes to.
  • Secret not found: Compose uses secrets: with files; Swarm uses docker secret.
  • API crashes with "connection refused" on first boot: PostgreSQL was still initializing. Add a healthcheck to the db service and use depends_on: { db: { condition: service_healthy } } so the API waits for a database that actually accepts connections, not just a started container.
  • 502 Bad Gateway from the proxy after scaling or restart: Nginx resolved api once at startup and the container IPs changed. Restart the proxy (docker compose restart proxy) or use a resolver directive with a variable-based proxy_pass so names are re-resolved per request.
  • Authentication failures despite the correct password: check for a trailing newline in secrets/db_password (od -c secrets/db_password | tail -2 should end without \n). Rewrite it with printf instead of echo.
  • Data vanished after docker compose down: you ran down -v, which deletes named volumes, or the volume was never declared under volumes:. Recreate the volume mapping and treat -v as the lab-reset switch only.

Extension Ideas

  • Translate this topology to Kubernetes Deployments and Services.
  • Add autoscaling and resource limits.
  • Front it with the Cloudflare Tunnel pattern.
  • Add a second internal network boundary: move Redis onto its own network so the database and queue cannot see each other at all.
  • Swap the placeholder API for a real framework (FastAPI, Express) and add a /metrics endpoint scraped by Prometheus.

Key Results

  • Ran a 4-tier app with only the proxy exposed.
  • Injected configuration and secrets at runtime, not in images.
  • Scaled the stateless API to 3 replicas behind the proxy.
  • Verified load distribution across replicas.

Key Takeaways

  • Publish ports only on the entry-point service; everything else communicates over internal Compose networks by service name.
  • Stateless tiers scale with one flag; stateful tiers scale with replication design. Know which of your services is which before you reach for --scale.
  • Configuration goes in the environment, secrets go in files mounted at runtime, and neither goes into an image.
  • depends_on plus a real healthcheck is the difference between a stack that starts cleanly and one that crashes on first boot.
  • Named volumes are the contract that data survives containers; down is safe, down -v is the reset button.

Practice Challenges

Challenge 1: Drain the queue faster

Goal: Scale to two workers and measure the queue drain time before and after.

Hint: The numbers are the deliverable; twice the workers should roughly halve the drain time.

Challenge 2: Rotate the secret

Goal: Update the database password secret and roll it through the stack without downtime.

Hint: Update the secret, recreate the database first, then the dependents; order matters.

Challenge 3: Terminate TLS at the proxy

Goal: Add a self-signed certificate to the proxy and serve the app over HTTPS.

Hint: Generate the cert once, mount it read-only, and redirect HTTP to HTTPS.