100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Docker & Containers
70 minbeginner

Applied Practice — Full-stack App with Docker Compose

In this capstone you will build and run a complete full-stack cricket scorecard application orchestrated entirely with Docker Compose, tying together every concept from this module. The stack has four moving parts: a PostgreSQL database storing matches and innings, a Redis cache holding the live run rate, a Python API backend that reads and writes scores, and an Nginx reverse proxy that serves the frontend and routes API calls. You will wire these together with custom networks for isolation, health checks and conditional dependencies for reliable startup, env files and secrets for configuration, and a one-shot migration job to prepare the schema. The finished project is genuine portfolio material: it demonstrates that you can architect, secure and operate a realistic multi-container system, which is exactly the skill hiring teams look for when they ask whether a candidate can ship containerised software end to end.

Analogy🏏Cricket
🏏 Think of it like cricket: assembling this stack is like fielding a complete playing eleven where every role is covered — openers, middle order, a wicketkeeper and specialist bowlers — rather than a handful of part-timers. Each specialist covers a job the others cannot, and the captain binds them into one functioning side. Just as a balanced eleven has a specialist for each job, your stack has a dedicated service for storage, caching, logic and routing. Just as the captain sets the batting order and field so the team functions as a unit, Compose sets the startup order and networks so the services function together. Just as a team that has practised together wins on the day, a stack whose services are health-gated and isolated starts cleanly under pressure. This reveals why the project matters: real systems, like real teams, succeed through coordinated specialists, not a pile of generalists.

Learning Objectives

  • Compose a four-service full-stack application — database, cache, API and reverse proxy — into a single reproducible compose.yaml.
  • Segment services across custom frontend and backend networks to enforce least-privilege connectivity between tiers.
  • Configure health checks and condition-based dependencies so the stack starts reliably without race conditions.
  • Externalise configuration with env files and inject the database password through the Compose secrets mechanism, not environment variables.
  • Run a one-shot migration job gated on database health and depended upon with service_completed_successfully before the API starts.
  • Apply container hardening — non-root users, dropped capabilities, read-only filesystems and resource limits — across the services.

Technical Requirements

  • A match-db PostgreSQL service that persists data to a named volume and exposes a pg_isready health check.
  • A score-cache Redis service used by the API to store and serve the current live run rate.
  • A scorecard-api Python service that connects to both the database and the cache and serves a JSON API plus a /healthz endpoint.
  • A schema-migrate one-shot service that creates the matches and innings tables and exits successfully before the API starts.
  • An edge Nginx reverse proxy that publishes port 8080 to the host and proxies /api requests to the scorecard-api service.
  • Custom frontend and backend networks isolating the database and cache from any host-published port.
  • The database password supplied via a Compose secret mounted at /run/secrets, never as a plain environment variable.
  • Health checks and depends_on conditions ensuring the order migrate-after-db, api-after-migrate, and proxy-after-api.

Architecture & Design

The design follows a layered, isolated topology. Only the edge Nginx proxy publishes a port to the host (8080); everything else is reachable solely over internal Compose networks. The proxy and the API share a frontend network so the proxy can route requests to the API, while the API, database, cache and migration job share a backend network. The database and cache therefore have no host-published ports and cannot be reached from outside the stack at all, which is the network-level least-privilege you learned earlier. Startup is orchestrated by readiness, not timing: match-db exposes a pg_isready health check; schema-migrate waits for the database to be healthy, runs the migration and exits; the API waits for both the database to be healthy and the migration to have completed successfully; and the proxy waits for the API's /healthz to report healthy. Configuration flows in through an env file for non-secret settings and a mounted secret for the database password. Data flows from the browser to the proxy, to the API, which reads cached run rate from Redis and durable scores from Postgres. This separation of routing, logic, cache and storage mirrors how production systems are structured and makes each tier independently restartable and scalable.

Analogy🏏Cricket
🏏 Think of it like cricket: a stadium is organised in concentric zones — the public turnstiles at the perimeter, the players' area inside, and the secure scorers' room deepest of all, each with tighter access. Movement between zones is allowed only for those whose role demands it, and never for the watching crowd. Just as only the turnstiles face the public while the scorers' room is sealed within, only the proxy faces the host while the database sits on an internal network. Just as a player can move between the dressing room and the pitch but a spectator cannot enter either, the API bridges frontend and backend networks while outside traffic reaches neither database nor cache. Just as the match cannot start until the pitch is declared fit, the API cannot start until the migration has prepared the schema. This reveals the design's logic: layered zones with controlled movement keep the valuable core protected while still letting play flow.
bash
# Project structure
scorecard-stack/
 compose.yaml
 .env                      # non-secret config (git-ignored)
 .env.example             # documents the keys (committed)
 secrets/
    db_password.txt      # the secret value (git-ignored)
 api/
    Dockerfile
    requirements.txt     # flask, psycopg2-binary, redis
    app.py               # the scorecard API + /healthz
    migrate.py           # one-shot schema migration
 proxy/
     nginx.conf           # routes / and /api to the API service

Phase 1 — Core Implementation

Phase 1 builds the API service and its migration job — the brain of the application. The API is a small Flask app that exposes a /healthz endpoint for the proxy's health check, an endpoint to record an innings score, and an endpoint to fetch the live run rate, reading the database password from the mounted secret file rather than an environment variable. The migration script creates the matches and innings tables. Writing these first means the rest of the stack has something concrete to connect to, and it forces you to handle the secret-file convention and the database connection up front.

Analogy🏏Cricket
🏏 Think of it like cricket: before a match you prepare the pitch and mark the creases — the playing surface must exist before anyone can bat. No batsman takes guard and no run is scored until that surface and its markings are ready. Just as the groundsman prepares the surface before play, the migration prepares the schema before the API serves traffic. Just as the batsman then takes guard on the ready pitch, the API then operates on the ready database. Just as creases define where runs are legally scored, the table schema defines where scores are validly stored. This shows why core implementation comes first: there is no game until the surface and its markings are in place.
python
# api/app.py — Flask scorecard API reading its password from a secret file
import os, psycopg2, redis
from flask import Flask, jsonify, request

app = Flask(__name__)

def db_password():
    # Read the secret from the mounted file, never an env var
    with open(os.environ['DB_PASSWORD_FILE']) as fh:
        return fh.read().strip()

def get_db():
    return psycopg2.connect(
        host='match-db', dbname='cricket', user='admin',
        password=db_password())

cache = redis.Redis(host='score-cache', port=6379, decode_responses=True)

@app.get('/healthz')
def healthz():
    return jsonify(status='healthy'), 200

@app.post('/api/innings')
def record_innings():
    data = request.get_json()              # {batsman, runs, overs}
    conn = get_db(); cur = conn.cursor()
    cur.execute(
        'INSERT INTO innings (batsman, runs, overs) VALUES (%s, %s, %s)',
        (data['batsman'], data['runs'], data['overs']))
    conn.commit(); cur.close(); conn.close()
    run_rate = round(data['runs'] / max(data['overs'], 0.1), 2)
    cache.set('live_run_rate', run_rate)   # cache the latest run rate
    return jsonify(batsman=data['batsman'], run_rate=run_rate), 201

@app.get('/api/run-rate')
def run_rate():
    return jsonify(live_run_rate=cache.get('live_run_rate') or '0')

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

# api/migrate.py — one-shot schema migration, then exits
# import os, psycopg2; reuse the same db_password() pattern
#   cur.execute('''CREATE TABLE IF NOT EXISTS innings (
#       id SERIAL PRIMARY KEY, batsman TEXT, runs INT, overs NUMERIC)''')

Phase 2 — Feature Completion

Phase 2 assembles the full Compose file that wires the four services together with the networks, health checks, dependencies, env file and secret described in the architecture. This is where the individual pieces become a coordinated system: the migration is gated on the database, the API on the migration, and the proxy on the API. The Nginx proxy is the only service publishing a host port, and the database and cache sit on the isolated backend network. Completing this phase yields a stack you can bring up with a single command and reach in a browser.

Analogy🏏Cricket
🏏 Think of it like cricket: with the pitch ready and the players warmed up, the captain now sets the field and confirms the batting order so the innings can flow. Every fielder now knows their position and every batsman knows when they are due in. Just as the captain arranges every fielder into a coherent plan, the Compose file arranges every service into a coherent stack. Just as the order ensures the right batsman faces the right bowler, the depends_on conditions ensure each service starts only after its prerequisites are ready. Just as the field placement controls which shots are risky, the network segmentation controls which connections are possible. This shows why this phase is the turning point: individual readiness becomes a working team only once the plan binds them together.
yaml
# compose.yaml — the complete orchestrated stack
services:
  edge:                              # Nginx reverse proxy (only public port)
    image: nginx:1.27
    ports: ["${EDGE_PORT:-8080}:80"]
    volumes: ["./proxy/nginx.conf:/etc/nginx/nginx.conf:ro"]
    networks: [frontend]
    read_only: true
    tmpfs: ["/var/cache/nginx", "/var/run"]
    security_opt: ["no-new-privileges:true"]
    depends_on:
      scorecard-api: { condition: service_healthy }

  scorecard-api:                     # Flask API (logic tier)
    build: ./api
    networks: [frontend, backend]    # bridges the two tiers
    env_file: [./.env]
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    cap_drop: ["ALL"]
    security_opt: ["no-new-privileges:true"]
    healthcheck:
      test: ["CMD", "python", "-c",
             "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5000/healthz').status==200 else 1)"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s
    depends_on:
      match-db: { condition: service_healthy }
      schema-migrate: { condition: service_completed_successfully }

  schema-migrate:                    # one-shot migration, then exits
    build: ./api
    command: ["python", "migrate.py"]
    networks: [backend]
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    depends_on:
      match-db: { condition: service_healthy }

  match-db:                          # PostgreSQL (storage tier, isolated)
    image: postgres:16
    networks: [backend]              # no host port published
    environment:
      POSTGRES_USER: admin
      POSTGRES_DB: cricket
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    volumes: ["match-data:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U admin -d cricket"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

  score-cache:                       # Redis (cache tier, isolated)
    image: redis:7
    networks: [backend]

networks:
  frontend:
  backend:

volumes:
  match-data:

secrets:
  db_password:
    file: ./secrets/db_password.txt

Phase 3 — Polish & Production Readiness

Phase 3 makes the stack robust and operable. Add restart: unless-stopped to long-running services so they self-heal after crashes, set memory and CPU limits to protect the host, and confirm the database and cache are genuinely unreachable from outside. Then write a smoke test that brings the stack up, waits for the proxy to report healthy, posts an innings and reads back the run rate, so regressions are caught automatically. Finally, verify the hardening: the API runs non-root with no capabilities and the proxy filesystem is read-only. This phase is what separates a demo from something you would be comfortable running.

Analogy🏏Cricket
🏏 Think of it like cricket: Phase 3 is the difference between a scratch team and a professional outfit ready for a real season. Just as a squad has a standing rule to send in the next batter the instant a wicket falls, `restart: unless-stopped` makes long-running services self-heal after a crash. Just as a fitness panel caps every player's workload so no one burns out and drags the whole side down, memory and CPU limits protect the host from any one container's overreach. Just as the dressing room is sealed so no outsider can wander in, you confirm the database and cache are genuinely unreachable from outside. And just as a team runs a full dress-rehearsal fixture — bringing everyone up, checking the scoreboard reports healthy, batting an innings and reading back the run rate — you write a smoke test that brings the stack up and exercises it end to end. The payoff: a robust, operable stack that catches regressions before match day.
bash
# api/Dockerfile — hardened, non-root API image
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN adduser --system --no-create-home --uid 10001 scorer
USER scorer                           # never run as root
EXPOSE 5000
CMD ["python", "app.py"]

# smoke_test.sh — bring up, verify, tear down
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --build
echo 'Waiting for the API to become healthy...'
for i in $(seq 1 30); do
  status=$(docker inspect --format '{{.State.Health.Status}}' \
    "$(docker compose ps -q scorecard-api)")
  [ "$status" = healthy ] && break
  sleep 2
done
# Post an innings through the public proxy and read the run rate back
curl -fsS -X POST localhost:8080/api/innings \
  -H 'Content-Type: application/json' \
  -d '{"batsman":"Virat Kohli","runs":82,"overs":10}'
echo
curl -fsS localhost:8080/api/run-rate     # {"live_run_rate":"8.2"}
# Confirm the database is NOT reachable from the host (should fail):
! curl -fsS --max-time 2 localhost:5432 2>/dev/null && echo 'db correctly isolated'
docker compose down -v

Evaluation Rubric

  • Stack starts cleanly with a single docker compose up on a fresh machine, with no manual ordering or sleep hacks required.
  • Only the edge proxy publishes a host port; match-db and score-cache are unreachable from the host, proving network isolation.
  • Startup order is enforced by health checks and conditions: migration after db-healthy, API after migration, proxy after API-healthy.
  • The database password is delivered via a mounted secret file and never appears in any service's environment or docker inspect output.
  • Posting an innings through the proxy persists to Postgres and updates the Redis-cached run rate, returned correctly by /api/run-rate.
  • API runs as a non-root user with cap_drop ALL and no-new-privileges; the proxy runs with a read-only root filesystem.
  • A smoke test reproducibly brings the stack up, exercises the API end to end, verifies db isolation, and tears the stack down.

Extension Challenges: (1) Add a second API replica with docker compose up --scale scorecard-api=2 and confirm Nginx load-balances across both by service name. (2) Replace the file-based secret source with an external secret and add image vulnerability scanning (docker scout or Trivy) as a build gate. (3) Add a Prometheus exporter sidecar and a /metrics endpoint so you can graph request counts and live run rate, turning the stack into an observable mini-platform.

Submit your capstone project

Checking submission status…
Lesson 18 of 35
0% complete