AI EngineeringZero to ProductionHome·About·What’s new·Contact
Containers & Deployment · Project CD

Containerize, ship & operate an app

The capstone: a FastAPI + Postgres app from code to a live, observable, reversible service — Dockerized, Compose-run, tested/scanned/built/deployed by CI/CD with readiness gates, canary, health, SLOs, rollback, and DORA ownership.

⏱️ ~5 hours🏗️ Capstone project🎯 Beginner→Tech-lead
What you'll buildShip a real FastAPI + Postgres app end to end and operate it: containerized (CD1/CD2), run locally with Compose (CD3), tested + scanned + built + deployed by a CI/CD pipeline (CD5) to the cloud (CD4) with a canary rollout, dependency-aware health, SLO monitoring, and automatic rollback. Code → live, observable, reversible.

Learning objectives

  • Containerize a multi-service app to the production checklist.
  • Run the whole stack locally with one command.
  • Automate test→scan→build→deploy with readiness + canary.
  • Operate live: health, SLOs, rollback, and delivery metrics.
▶ Runnable companionCode saved under code/proj-cd-ship/. Python runs offline; configs are ready to use.

1 · The app + production Dockerfile essential

Here's the failure this whole project exists to prevent. A team deploys a shiny new service. The container starts, the process is running, the orchestrator sees a live PID and starts sending it real user traffic — while behind the scenes the database connection never came up. Every request 500s. The dashboard is green because "the process is alive", but customers are getting errors. The root cause is almost always the same: the app told the platform it was fine when it wasn't.

So we start where every reliable deploy starts: an app that can honestly answer one question — "am I actually able to serve traffic right now?" Think of it like a pilot's pre-flight checklist versus just "the engine turned on". The engine running is necessary but nowhere near sufficient; you check fuel, controls, and instruments before you're allowed to taxi. A container's health check is that checklist, and the precise point is this: health must reflect real dependency reachability, not process liveness. If the DB or cache is down, /healthz should return 503 ("I'm degraded, don't route to me"), not 200. That single distinction is what lets every later gate — Compose ordering, canary, rollback — do its job, because they all read that signal to decide whether traffic is safe.

The production Dockerfile is the other half of this foundation, and the common mistake is treating it as an afterthought: a bloated single-stage image running as root, with secrets baked in and no HEALTHCHECK. A production image is small (multi-stage, slim base), runs as a non-root user, pins its base and dependency versions so the build is reproducible, and declares a HEALTHCHECK that hits /healthz so the runtime can detect a sick container on its own. Get these two things right — an honest health endpoint and a disciplined image — and everything downstream becomes possible; get them wrong and no amount of fancy pipeline machinery will save you.

/healthz answers one question honestly: can I actually serve traffic right now? request GET /healthz db reachable? cache reachable? 200 · healthy platform routes traffic 503 · degraded platform holds traffic both up any down liveness ≠ readiness "the process is running" is NOT the same as "I can serve" — health must reflect dependencies Health = the AND of every dependency, not process liveness. /healthz returns 200 only when the DB and cache are reachable; if either is down it returns 503 degraded so the orchestrator stops routing to a container that can't actually serve.
🗺️ How to read this diagram

Follow the request left to right: it arrives at /healthz, the endpoint tests each dependency, and the result decides which status code — and which traffic decision — you land on.

  • The blue box on the left is one incoming GET /healthz. The two white boxes are the checks it runs: is the database reachable and is the cache reachable.
  • The blue "both up" arrow leads to the green 200 · healthy box — the platform routes real traffic. The red dashed "any down" arrow leads to the red 503 · degraded box — the platform holds traffic back.
  • The amber box underneath is the payoff to internalize: a running process (liveness) is not the same as being able to serve (readiness). Health must reflect real dependency reachability.

In short: one down dependency flips 200 to 503, and that single honest signal is what every later gate — Compose ordering, canary, rollback — reads to keep traffic safe.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · app routing + dependency health (runs)
app.pydef handle(path, db_ok=True, cache_ok=True):
    if path == "/":        return 200, {"service": "orders", "status": "ok"}
    if path == "/healthz":
        healthy = db_ok and cache_ok
        return (200 if healthy else 503,
                {"status": "healthy" if healthy else "degraded",
                 "checks": {"db": db_ok, "cache": cache_ok}})
    return 404, {"error": "not found"}

for p in ["/", "/healthz", "/missing"]:
    print(p, "->", handle(p))
print("db down ->", handle("/healthz", db_ok=False))
/ -> (200, {'service': 'orders', 'status': 'ok'})
/healthz -> (200, {'status': 'healthy', 'checks': {'db': True, 'cache': True}})
/missing -> (404, {'error': 'not found'})
db down -> (503, {'status': 'degraded', 'checks': {'db': False, 'cache': True}})
▶ How this works

Before you can containerize or deploy anything, you need an app that answers requests. This tiny model of a web service takes a URL path (like / or /healthz) and returns an HTTP status code plus a little JSON reply. The star of the show is /healthz — a health check the platform will ping to decide if your app is safe to send traffic to.

  1. def handle(path, db_ok=True, cache_ok=True): is the function. path is which URL was requested; db_ok and cache_ok say whether the database and cache are reachable (they default to healthy).
  2. if path == "/": the home route returns status 200 (HTTP for "OK") and a small status object. 200 is the code every healthy response uses.
  3. The /healthz route computes healthy = db_ok and cache_ok — it's only healthy if both dependencies are up. If healthy it returns 200; if not, it returns 503 ("Service Unavailable") so the platform stops routing to it.
  4. Anything unrecognized falls through to return 404 — HTTP for "not found". The loop at the bottom calls handle on three paths, then calls it once more with db_ok=False to show the degraded case.

What the output means: Each line prints the path and the (status, body) it returned. / and /healthz give 200; /missing gives 404; and the last line — with the DB down — flips to 503 and "degraded", exactly what a deploy system needs to see to hold back traffic.

Try this: Change the last call to handle("/healthz", cache_ok=False) and predict the output: it should also be 503, because health needs both checks to pass. This /healthz endpoint is what the Compose healthcheck and canary later rely on.

2 · Local stack with Compose intermediate

You've almost certainly hit this exact flake. You run the app locally, it crash-loops for ten seconds, then works. Or worse: it works on your machine and crash-loops in CI. The logs show a database connection error at startup, but by the time you look, the database is up and everything's fine. It's maddening precisely because it's a timing bug — the app booted faster than Postgres finished initializing, fired its first query at a database that wasn't accepting connections yet, and died.

The real app isn't one process — it's an API and a database (and usually a cache), and those pieces have to come up in the right order. Docker Compose is how you describe that whole cast in one file and bring it up with a single command, so "clone the repo and run it" actually works for the next person. The analogy is a restaurant opening for the day: the kitchen has to be genuinely ready — ovens hot, prep done — before the front of house starts seating customers. "The lights are on" isn't the signal to open; "the kitchen reports ready" is.

The precise mechanism that fixes the flake is depends_on with condition: service_healthy. The subtle-but-critical point most people miss: waiting for a dependency to have started (service_started) is not the same as waiting for it to be healthy. Compose can only know the difference if the dependency defines a healthcheck — for Postgres, pg_isready — that flips it to "healthy" once it truly accepts connections. Gate on health, not existence, and the race disappears. As a bonus, giving the database a named volume means its data survives restarts, so you're not re-seeding an empty database every time you bounce the stack.

depends_on: condition — gate on HEALTHY, not merely STARTED db postgres booting… healthy pg_isready passes ✓ api correct api starts, query works naïve service_started api starts early → crash-loop Wait for healthy, not for started. service_healthy holds the api until Postgres' pg_isready healthcheck passes, so its first query succeeds; the naïve service_started path releases the api the instant the DB process exists — before it accepts connections — and it crash-loops.
🗺️ How to read this diagram

Read left to right along the dashed timeline. The top row is the database's life; the two rows below are two ways the api could wait for it.

  • The amber "postgres booting…" box is the DB still initializing — it exists but can't yet answer queries. It flips to the green "healthy" box only when pg_isready passes.
  • The correct path: the blue arrow shows depends_on: service_healthy releasing the api at that healthy moment, so its first query works (green box).
  • The naïve path (red): service_started releases the api the instant the DB process appears — while it's still booting — so the api fires a query too early and crash-loops (red box).

In short: the healthcheck is what lets Compose tell "started" from "ready"; gate on service_healthy and the startup race disappears.

config · docker-compose.yml (health-gated)
docker-compose.ymlservices:
  api:
    build: .
    ports: ["8000:8000"]
    depends_on: { db: { condition: service_healthy } }
  db:
    image: postgres:16
    environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: secret, POSTGRES_DB: app }
    volumes: ["dbdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 5
volumes: { dbdata: {} }
▶ How this works

A real app is more than one process — here it's an API plus a Postgres database. Docker Compose lets you describe both in one YAML file and start the whole stack with a single command (docker compose up). Indentation matters in YAML: nested lines belong to the key above them.

  1. services: lists the containers. api is your app: build: . means "build the image from the Dockerfile in this folder", and ports: ["8000:8000"] maps port 8000 on your machine to 8000 in the container so you can reach it in a browser.
  2. depends_on: { db: { condition: service_healthy } } is the key line: the API won't start until the database reports healthy. This prevents the classic bug where the app boots faster than the DB and crashes on the first query.
  3. The db service uses the official postgres:16 image and sets the user, password, and database name through environment: variables that Postgres reads on first boot.
  4. volumes: ["dbdata:/var/lib/postgresql/data"] plus the bottom volumes: { dbdata: {} } give the database a named volume — disk that survives restarts, so your data isn't wiped every time the container stops.
  5. The healthcheck: runs pg_isready -U app every 5 seconds (up to 5 retries). That command is what flips the DB to "healthy" and unblocks the API's depends_on.

What the output means: There's no console output here — this is configuration. Running docker compose up would start Postgres, wait until pg_isready passes, then start the API on http://localhost:8000.

Try this: Imagine deleting the depends_on block. The API could start before the DB is ready and error out. Keeping it is why one command reliably brings up the entire stack.

3 · The full CI/CD pipeline advanced

Every team that ships by hand eventually ships the outage. Someone builds on their laptop, skips the tests "just this once", pushes the image, and a bug that a test would have caught goes straight to production on a Friday afternoon. The problem isn't that engineers are careless — it's that manual, order-dependent, easy-to-skip steps will get skipped under pressure. The fix is to take the careful sequence a good engineer follows and make it automatic and unskippable.

That sequence is a gate chain: test → scan → build → deploy, run on every push, where any failure halts everything downstream. Think of it as a factory assembly line with inspection stations — a part that fails inspection at station one never reaches station two. The ordering isn't arbitrary either; it follows a "cheap checks first" principle. Running the fast unit tests before the slow container build means you fail in seconds on a typo instead of wasting minutes building an image you're about to throw away.

The precise wiring that enforces this in GitHub Actions is the needs: dependency between jobs — ship declares needs: test, so it physically cannot run until tests are green. That's what turns "we should test before deploying" from a good intention into a rule the machine enforces. Two more habits make the pipeline trustworthy: a scan gate (fail the build on HIGH/CRITICAL vulnerabilities, e.g. with Trivy) so known-vulnerable images can't ship, and tagging every image with the commit SHA rather than a floating latest. The SHA tag is what makes a deploy traceable to an exact line of code — and, crucially, makes rollback nothing more than re-pointing to an older, already-built tag.

every push runs the chain in order — first failure halts everything to its right push test fast · seconds scan HIGH/CRIT? build slow · image deploy SHA-tagged any red → pipeline halts, nothing ships An ordered gate chain, cheap checks first. needs: test means ship can't run until tests are green; the fast unit tests gate the slow image build, and any stage failing halts everything downstream so nothing broken reaches deploy.
🗺️ How to read this diagram

Read the top row left to right — that's the order stages run on every push, each blue arrow handing off only if the previous stage passed.

  • The blue push box triggers the chain. It flows into test → scan → build → deploy. The ordering is deliberate: the fast, cheap test runs before the slow build, so a typo fails in seconds instead of after minutes of building.
  • The green deploy box on the right ships the image tagged with the commit SHA — that tag is what makes the deploy traceable and rollback a one-step repoint.
  • The red dashed arrows dropping into the red box are the halt path: if any stage goes red, the pipeline stops there and nothing downstream — including deploy — runs.

In short: needs: turns "test before you ship" into a rule the machine enforces; the first red stage stops the line.

config · deploy.yml (test → scan → build → deploy)
deploy.ymlname: CI/CD
on: { push: { branches: [main] } }
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements.txt pytest
      - run: pytest -q
  ship:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          IMG=ghcr.io/${{ github.repository }}:${{ github.sha }}
          docker build -t "$IMG" .
          # trivy image --exit-code 1 --severity HIGH,CRITICAL "$IMG"   # scan gate
          docker push "$IMG"
      - run: echo "canary deploy the SHA-tagged image with health + rollback"
▶ How this works

This is a GitHub Actions pipeline — the automation that runs every time you push code. It does what a careful engineer would do by hand, but automatically and in order: test → build → scan → push. If any step fails, the pipeline stops and nothing ships.

  1. on: { push: { branches: [main] } } is the trigger: this whole file runs whenever someone pushes to the main branch.
  2. The test job checks out your code, sets up Python 3.12, installs dependencies, and runs pytest -q. If tests fail, the job fails — the gate that stops broken code from going further.
  3. The ship job has needs: test, meaning it only runs after tests pass. That dependency is what enforces "never deploy red code".
  4. Inside ship: IMG=ghcr.io/${{ github.repository }}:${{ github.sha }} builds an image name tagged with the exact commit (github.sha). Tagging by commit means every deploy is traceable and rollback is just re-deploying an older tag.
  5. docker build creates the image; the commented trivy image line is a security scan gate (fail on HIGH/CRITICAL vulnerabilities); docker push uploads it to the registry. The final echo stands in for the canary deploy step.

What the output means: No local output — this runs on GitHub's servers. Green checkmarks appear on your commit when test then ship succeed; a red X means the pipeline stopped at the failing step and nothing was deployed.

Try this: Uncomment the trivy image line in your head: now a build with a critical CVE would --exit-code 1 and fail the job, blocking the docker push. That's how a scan becomes a hard gate rather than a suggestion.

4 · Professional — readiness + canary before prod professional

A green pipeline is not a promise that the release is good. Tests can pass and the build can be clean, and the release can still be broken — a config mismatch, a dependency that behaves differently under real load, a subtle regression no test covered. The painful version of this is the release that looks perfect in CI, ships to 100% of users at once, and takes the whole service down before anyone can react. The blast radius is the entire user base, instantly.

Professionals add two more checkpoints that a normal pipeline doesn't have, and they sit on either side of the traffic boundary. The first is a readiness gate: the last automated check before any real user sees the release — are tests green, is the image scan score high enough, does a healthcheck exist, and are there no secrets baked into the image? If any of those fail, the build is blocked with a specific reason. The second is a canary: the first check with real traffic, but deliberately limited. Instead of flipping everyone over at once, you route a small slice — say 5% — to the new version, watch the error rate, and only ramp to 50% then 100% if it stays healthy.

The analogy is a restaurant testing a new dish on a few tables before putting it on every plate: if those tables send it back, you pull it — you haven't ruined the whole night's service. The load-bearing detail is auto-abort: the canary compares each stage's error rate against a threshold and rolls back automatically the instant it's exceeded, rather than waiting for a human to notice a dashboard. The common mistake is a "canary" that ramps traffic but has no automated abort — that's just a slow-motion full deploy. Together, the readiness gate blocks known-bad artifacts and the canary catches the ones that only misbehave under real traffic, turning a scary all-or-nothing launch into a routine, low-risk event.

readiness gate = last check BEFORE traffic · canary = first check WITH traffic readiness gate tests green image score ≥ bar healthcheck exists no secrets in image pass 5% 50% 100% err<thr err<thr promoted err > threshold auto-abort → roll back at this % ramp WITHOUT auto-abort = just a slow-motion full deploy Block bad artifacts, then limit blast radius. The readiness gate is the last check before any traffic; the canary then ramps 5% → 50% → 100%, promoting only while the error rate stays under threshold and auto-aborting the instant a stage exceeds it — so a bad release hits a slice, not everyone.
🗺️ How to read this diagram

Read left to right: the blue gate on the left must pass first, then the green boxes are the traffic ramp, with the drop-down showing what happens on a spike.

  • The blue readiness gate lists the four things that must all hold — tests green, image score above the bar, a healthcheck exists, no secrets baked in — before any user sees the release.
  • The green 5% → 50% → 100% boxes are the canary ramp. Each blue "err<thr" arrow means that stage's error rate stayed under the threshold, so traffic advances; clearing all three reaches the white promoted box.
  • The red dashed drop-down is auto-abort: the instant any stage's error rate exceeds the threshold, the rollout rolls back at that percentage. The amber strip is the warning — a ramp with no auto-abort is just a slow full deploy.

In short: the gate stops known-bad builds; the canary catches the ones that only fail under real traffic, and auto-abort keeps the blast radius to a slice.

Python · readiness gate → canary → decision (runs)
gate.pydef ready(tests, image_score, health, secrets_clean):
    problems=[]
    if not tests: problems.append("tests red")
    if image_score<70: problems.append("image score low")
    if not health: problems.append("no healthcheck")
    if not secrets_clean: problems.append("secrets in image")
    return (not problems), problems

def canary(err_by_stage, threshold=0.02):
    for pct, err in err_by_stage:
        if err>threshold: return f"aborted at {pct}% (err {err:.0%})"
    return "promoted to 100%"

ok, why = ready(True, 92, True, True)
print("ready:", ok, why)
print("healthy release:", canary([(5,0.004),(50,0.006),(100,0.005)]))
print("bad release:    ", canary([(5,0.004),(50,0.09)]))
ready: True []
healthy release: promoted to 100%
bad release:     aborted at 50% (err 9%)
▶ How this works

Before promoting a release to all users, professionals run two checks: a readiness gate (is this build allowed to deploy at all?) and a canary (roll out to a few users first, watch for errors, abort if it spikes). This code models both decisions in plain Python.

  1. ready(tests, image_score, health, secrets_clean) collects problems into a list: tests must be green, the image scan score must be ≥70, a healthcheck must exist, and no secrets may be baked into the image. It returns (not problems), problems — True only when the problem list is empty.
  2. canary(err_by_stage, threshold=0.02) walks through rollout stages like (5%, 50%, 100%). At each stage it checks the error rate err; if it exceeds the 2% threshold it returns "aborted" immediately instead of continuing.
  3. If every stage stays under the threshold, the loop finishes and returns "promoted to 100%" — the release is safe for everyone.
  4. The three calls at the bottom exercise it: a clean readiness check, a healthy release that passes every canary stage, and a bad release whose error rate jumps to 9% at the 50% stage.

What the output means: ready: True [] means no problems, so deploy is allowed. The healthy release prints promoted to 100%. The bad release prints aborted at 50% (err 9%) — the canary caught the spike and stopped the rollout before it hit everyone.

Try this: Lower image_score in the first call to 60 and re-read: ready now returns False with ["image score low"]. This is exactly how a pipeline blocks a risky build automatically.

5 · Tech-lead — go live & own delivery tech-lead

The gap between "we deployed it" and "we own it" is where most teams quietly leak reliability. A junior engineer's job ends when the green checkmark appears. A tech lead's job is the opposite: it starts there. The failure mode this section guards against is the team that can ship but can't recover — no rehearsed rollback, no agreed definition of "too many errors", so every incident becomes an improvised, high-adrenaline scramble at 2am while everyone argues about whether to roll back.

Owning delivery means two things become objective instead of debated. The first is that rollback is a rehearsed, one-command operation — because you tagged images by SHA (§3), reverting is just re-pointing to the last-good tag, measured in seconds, not a panicked rebuild. A rollback you've never practiced is a rollback you don't actually have. The second is the SLO error budget: you agree up front on a reliability target (say 99.9%), which implies a budget of allowable errors over a window. When that budget is being burned too fast, "should we ship this risky change today?" stops being a matter of opinion and becomes a number — and when the budget is exhausted, deploys freeze until reliability recovers.

Above the individual release sits the scoreboard a tech lead is accountable for: the four DORA metrics — deploy frequency, lead time for changes, change-failure rate, and time to restore (MTTR). These turn the vague question "are we shipping well?" into four numbers you can track and deliberately move. The mindset to internalize is that these metrics reinforce each other rather than trade off: smaller, more frequent batches raise deploy frequency and lower change-failure rate (less risk per deploy), while rehearsed rollback cuts MTTR. The tech-lead skill isn't just reading the scoreboard — it's picking the weakest metric, making a deliberate change, and proving the improvement with the numbers.

own delivery = a scoreboard you move on purpose, gated by an error budget DORA scoreboard deploy freq ↑ higher lead time ↓ lower change-fail % ↓ lower MTTR / restore ↓ lower SLO error budget gates deploys budget left deploys OPEN budget spent deploys FREEZE burned rollback → previous SHA image one command · seconds → cuts MTTR tech-lead move: pick the weakest metric, change one thing, prove it with the numbers Ship it, then own it. The four DORA metrics are the scoreboard; the SLO error budget keeps deploys open only while budget remains and freezes them once it's spent; a one-command rollback to the previous SHA image restores fast — cutting MTTR.
🗺️ How to read this diagram

Read the left half as a scoreboard and the right half as a gate: the four metrics are what you measure, the error budget decides whether you're allowed to ship.

  • The four blue boxes on the left are the DORA metrics with the direction you want each to move — deploy frequency up, and lead time, change-failure rate, and MTTR down.
  • The green "budget left" box means the SLO error budget still has room, so deploys are OPEN; the red arrow to "budget spent" shows what happens once it's burned — deploys FREEZE until reliability recovers.
  • The green rollback strip is the payoff of SHA-tagging from §3: reverting is a one-command repoint to the previous image — seconds, not a rebuild — which is exactly what drives the MTTR metric down (the blue arrow ties it back to the scoreboard).

In short: measure the four numbers, let the error budget gate risky deploys, and use rehearsed SHA rollback to keep restore-time low — that's owning delivery, not just triggering it.

Ship it, own it

  1. Deploy the SHA-tagged image with /healthz wired (CD4).
  2. Confirm the public URL responds and health is green.
  3. Roll out by canary; abort automatically on error spike.
  4. Watch the four signals + SLO error budget; alert on burn.
  5. Rehearse rollback; document deploy + rollback in the README so anyone can operate it.
  6. Track DORA (deploy freq, lead time, change-fail, restore) and improve the weakest.
Python · the whole delivery loop, modeled (runs)
deliver.pydef deliver(commit):
    stages = ["lint","test","scan","build"]
    for s in stages:
        if not commit.get(s, True):
            return f"blocked at {s}"
    return "live (canary passed)" if commit.get("canary_ok", True) else "rolled back"

print(deliver({"test": True, "scan": True, "canary_ok": True}))
print(deliver({"test": False}))                 # tests fail -> never deploys
print(deliver({"test": True, "canary_ok": False}))   # canary fails -> rollback
live (canary passed)
blocked at test
rolled back
▶ How this works

This ties the entire lesson together into one function: a commit flows through the delivery pipeline stage by stage, and the outcome is either live, blocked, or rolled back. It's the mental model for everything you built above.

  1. stages = ["lint","test","scan","build"] lists the ordered gates every commit must clear before it can deploy.
  2. The loop uses commit.get(s, True) — it looks up each stage's result, defaulting to True (passing) if not mentioned. The first stage that is False causes return f"blocked at {s}", stopping the pipeline right there.
  3. If all gates pass, the last line checks canary_ok: a passing canary returns "live (canary passed)"; a failing one returns "rolled back" — the safety net that reverses a bad release automatically.
  4. The three print calls show each path: a fully passing commit, a commit whose test is False, and a commit that builds fine but fails the canary.

What the output means: Three lines: live (canary passed) (everything green), blocked at test (failing tests never reach deploy), and rolled back (deployed but the canary tripped the automatic reversal). Code → live, observable, reversible.

Try this: Add "scan": False to the first commit and predict the result — it should now print blocked at scan, because scan comes before build in the stage list and the loop stops at the first failure.

This is the full professional loopClone → docker compose up to develop → PR (DF3) runs tests+scan (TQ/CD) in CI → merge auto-builds and canary-deploys (CD) with health + rollback → measured by DORA. Delivering and owning that end-to-end loop is what a production-ready tech lead does.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · A health-checkable app and a production DockerfileBeginner

Context: Every safe-delivery gate later in this project stands on one thing: an app that can honestly report whether it's healthy. A health check that ignores its dependencies is worse than none — it hides outages.

Your task: Implement handle(path, db_ok, cache_ok) serving / and /healthz, plus a production Dockerfile.

Requirements:

  • / returns a 200 service response
  • /healthz returns 200 only when every dependency is up
  • /healthz returns 503 when a dependency is down
  • An unknown path returns 404
  • A production Dockerfile with a HEALTHCHECK hitting /healthz (labelled as needing Docker to build)

💡 Hint: Let the health branch reflect the db_ok/cache_ok inputs directly so an orchestrator can stop routing to a broken pod.

Show solution

A real /healthz that reflects dependencies is what makes every later gate possible. Handler is runnable; Dockerfile labeled:

def handle(path, db_ok=True, cache_ok=True):
    if path == "/":
        return 200, {"service": "app", "status": "ok"}
    if path == "/healthz":
        if db_ok and cache_ok:
            return 200, {"status": "healthy"}
        return 503, {"status": "unhealthy",
                     "db": db_ok, "cache": cache_ok}
    return 404, {"error": "not found"}

print(handle("/healthz"))                       # (200, {'status': 'healthy'})
print(handle("/healthz", db_ok=False))          # (503, ... db False)

# Dockerfile --- needs Docker to build ---
# FROM python:3.12-slim
# WORKDIR /app
# COPY requirements.txt .
# RUN pip install --no-cache-dir -r requirements.txt
# COPY . .
# HEALTHCHECK CMD curl -f http://localhost:8000/healthz || exit 1
# CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

A health check that returns 503 when the DB is down lets orchestrators stop routing traffic to a broken pod. A 200 that ignores dependencies is worse than none — it hides outages.

Exercise 2 · Local stack with healthcheck-gated dependenciesIntermediate

Context: The classic startup flake is the app crash-looping because the database isn't ready yet. Gating the app on a dependency's health, not just its existence, prevents it.

Your task: Write a docker-compose that gates the app on a Postgres healthcheck, and model the startup-ordering guarantee offline.

Requirements:

  • Compose gates the app with depends_on: condition: service_healthy
  • The Postgres service defines a healthcheck (e.g. pg_isready)
  • An offline model of the rule: the app can't start while a dependency is unhealthy
  • The model reports which dependency it's waiting on
  • Label the compose file as needing Docker Compose

💡 Hint: Waiting on service_healthy (not just service_started) is the whole point — model that distinction in the offline check.

Show solution

Ordering startup on health, not just existence, prevents the classic "app crashes because DB isn't ready yet" flake:

# docker-compose.yml --- needs Docker Compose ---
# services:
#   db:
#     image: postgres:16
#     healthcheck:
#       test: ["CMD", "pg_isready", "-U", "app"]
#       interval: 5s
#       retries: 5
#   app:
#     build: .
#     depends_on:
#       db:
#         condition: service_healthy      # wait for DB health, not just start

# offline model of the ordering guarantee:
def can_start(app, deps_health):
    unhealthy = [d for d, ok in deps_health.items() if not ok]
    if unhealthy:
        return False, f"waiting on {unhealthy}"
    return True, "starting"

print(can_start("app", {"db": False}))   # (False, "waiting on ['db']")
print(can_start("app", {"db": True}))    # (True, 'starting')

condition: service_healthy makes Compose wait for pg_isready to pass before launching the app, so you never see the app crash-loop against a DB that's still initializing.

Exercise 3 · A CI/CD pipeline: test -> scan -> build -> deployAdvanced

Context: The spine of safe delivery is an ordered gate chain: test → scan → build → deploy, where any failure stops everything downstream and you never ship code that failed a cheaper earlier check.

Your task: Model the pipeline stages and their pass/fail gating, and write the GitHub Actions skeleton with SHA-tagged images.

Requirements:

  • An ordered stage list where the first failure halts the pipeline
  • The result reports whether it deployed and, if not, which stage failed
  • The Actions skeleton runs test, then vulnerability scan, then build, then deploy
  • Images are tagged with the commit SHA for traceability and rollback
  • Cheaper checks run before expensive ones (test before build)

💡 Hint: Iterate the stages in order and short-circuit on the first that didn't pass; the SHA tag is what makes every deploy traceable to an exact commit.

Show solution

An ordered gate chain — no stage runs unless the prior passed — is the spine of safe delivery:

def pipeline(stages):
    # stages: ordered list of (name, passed_bool)
    for name, passed in stages:
        if not passed:
            return {"deployed": False, "failed_at": name}
    return {"deployed": True, "failed_at": None}

print(pipeline([("test",True),("scan",True),("build",True),("deploy",True)]))
print(pipeline([("test",True),("scan",False),("build",True)]))  # stops at scan

# .github/workflows/deploy.yml --- needs GitHub Actions ---
# jobs:
#   ship:
#     steps:
#       - run: pytest -q                              # test
#       - run: trivy image --exit-code 1 $IMAGE       # scan (fail on vuln)
#       - run: docker build -t $IMAGE .               # build
#       # image tag pins the exact commit:
#       # IMAGE=ghcr.io/${{ github.repository }}:${{ github.sha }}
#       - run: ./deploy.sh $IMAGE                      # deploy

SHA-tagged images make every deploy traceable to an exact commit and trivially rollback-able. The gate order (test before scan before build before deploy) means you never ship code that failed a cheaper earlier check.

Exercise 4 · A readiness gate and a canary rollout with auto-abortExpert

Context: Even a green pipeline can ship a bad build. Two more checks catch it: a readiness gate that blocks known-bad artifacts, and a canary that limits blast radius by ramping traffic and aborting on an error spike.

Your task: Add a ready() gate (tests + image score + health + secrets clean) and a canary() that ramps 5%→50%→100% and auto-aborts.

Requirements:

  • ready() returns pass/fail plus the specific problems found
  • It checks tests passing, an image score bar, health, and no secrets detected
  • canary() steps through 5%, 50%, 100% traffic
  • It aborts at the first stage whose error rate exceeds the threshold
  • Report which stage aborted so the failure is diagnosable

💡 Hint: The readiness gate is the last check before any traffic; the canary is the first check with real traffic — abort before full exposure.

Show solution

The readiness gate is the last check before traffic; the canary is the check with real traffic — abort before full exposure:

def ready(tests, image_score, health, secrets_clean, min_score=80):
    problems = []
    if not tests:            problems.append("tests failing")
    if image_score < min_score: problems.append(f"image score {image_score}")
    if not health:           problems.append("health check red")
    if not secrets_clean:    problems.append("secrets detected")
    return (len(problems) == 0, problems)

def canary(err_by_stage, threshold=0.02):
    # err_by_stage: dict {5:err,50:err,100:err} error rate per traffic %
    for pct in (5, 50, 100):
        if err_by_stage[pct] > threshold:
            return {"promoted": False, "aborted_at": f"{pct}%"}
    return {"promoted": True, "aborted_at": None}

print(ready(tests=True, image_score=92, health=True, secrets_clean=True))
print(canary({5:0.005, 50:0.03, 100:0.0}))   # aborts at 50% -> error spike

The readiness gate blocks known-bad builds; the canary catches the ones that only fail under real traffic, limiting blast radius to 5-50% before auto-aborting. Together they turn deploys from scary to routine.

Exercise 5 · Rehearsed rollback and SLO trackingProfessional

Context: A deploy isn't done until you can undo it fast. Rollback to the previous SHA image is seconds, not a rebuild; an error budget makes 'should we deploy?' an objective call instead of a debate.

Your task: Model a rollback to the previous SHA-tagged image and an SLO error-budget tracker that decides when to freeze deploys.

Requirements:

  • Rollback repoints to the last good SHA image (not a rebuild)
  • Rollback errors cleanly when there's no previous version
  • The budget tracker computes remaining error budget from actual success rate
  • It signals a deploy freeze once the budget is exhausted
  • Both are runnable offline over a deploy history / success numbers

💡 Hint: Keep deploys as an append-only history and roll back to the second-to-last entry; freeze when burned error exceeds the window's budget.

Show solution

Fast rollback + an error budget turn incidents into non-events and gate risky deploys automatically:

DEPLOYS = ["sha-aaa", "sha-bbb", "sha-ccc"]     # append-only history

def rollback():
    if len(DEPLOYS) < 2:
        raise RuntimeError("no previous version to roll back to")
    return DEPLOYS[-2]                          # repoint to prior good SHA

def budget_status(slo=0.999, actual_success=0.9985, window_budget=0.001):
    burned = (1 - actual_success)
    remaining = window_budget - burned
    return {"remaining_budget": round(remaining, 5),
            "freeze_deploys": remaining <= 0}

print("rollback to:", rollback())              # sha-bbb
print(budget_status(actual_success=0.9985))    # budget left, no freeze
print(budget_status(actual_success=0.997))     # budget blown -> freeze

Rollback is repointing to the last good SHA image — seconds, not a rebuild. The error budget makes "should we deploy?" objective: burn it down and deploys freeze until reliability recovers, protecting the SLO from a risky-change spree.

Exercise 6 · Own the DORA metrics and drive themIndustry scenario

Context: A tech lead owns delivery health, and 'are we shipping well?' becomes four measurable numbers. Owning the DORA metrics means driving deliberate changes and proving the improvement with the numbers.

Your task: Compute the four DORA metrics from a deploy log and classify the team's performance tier.

Requirements:

  • Deploy frequency, lead time, change-failure rate, and restore time (MTTR)
  • Computed from a deploy log of {lead_hours, failed, restore_hours} entries
  • MTTR averages only the failed deploys' restore times
  • Classify into a tier (e.g. elite / high / needs-work) from the numbers
  • Runs offline over a sample log

💡 Hint: Change-failure rate is failures over total deploys; restore time only counts the deploys that actually failed.

Show solution

DORA turns "are we shipping well?" into four measurable numbers a tech lead is accountable for:

def dora(deploys):
    # deploys: list of {lead_hours, failed(bool), restore_hours}
    n = len(deploys)
    freq_per_week = n            # over a 1-week window, say
    lead = sum(d["lead_hours"] for d in deploys)/n
    cfr = sum(d["failed"] for d in deploys)/n
    fails = [d["restore_hours"] for d in deploys if d["failed"]]
    mttr = sum(fails)/len(fails) if fails else 0.0
    tier = ("elite" if freq_per_week >= 7 and lead < 24 and cfr <= 0.15
            else "high" if cfr <= 0.30 else "needs work")
    return {"deploys/wk": freq_per_week, "lead_h": round(lead,1),
            "cfr": round(cfr,2), "mttr_h": round(mttr,1), "tier": tier}

log = [{"lead_hours":4,"failed":False,"restore_hours":0},
       {"lead_hours":6,"failed":True, "restore_hours":0.5},
       {"lead_hours":3,"failed":False,"restore_hours":0}]
print(dora(log))

Elite teams deploy often, with short lead time, low change-failure rate, and fast restore. Owning these metrics means driving deliberate changes — smaller batches lift frequency and cut CFR; rehearsed rollback cuts MTTR — and proving the improvement with the numbers.

✓ Checkpoint — you can move on when you can…

  • Containerize a multi-service app to the checklist.
  • Run the full stack locally with Compose.
  • Automate test→scan→build→deploy with readiness + canary.
  • Operate live: health, SLOs, rollback, DORA ownership.
📋 Grade your build — is this service production-ready?
DimensionMeets the barAbove the bar (staff)
Build reproducibilityA pinned, multi-stage Dockerfile builds the same image from a clean checkout; base image and deps are version-locked.Image is tagged by commit SHA, builds are byte-reproducible, and a fresh clone → docker compose up works with zero manual steps.
CI/CD correctnessPipeline runs test → scan → build → deploy in order; a red test blocks the deploy (needs: test).Every gate is a hard gate (scan fails on HIGH/CRITICAL), deploys are idempotent, and the pipeline is green from a clean fork.
Deploy safety & rollbackReleases roll out by canary with an error-rate threshold; you can roll back to a prior SHA-tagged image.Rollback is rehearsed and one-command; canary aborts automatically on error/latency spike and the abort path is tested.
Health & readiness/healthz reports dependency health (DB/cache) and returns 503 when degraded; Compose gates on service_healthy.Readiness vs liveness are distinguished, health drives traffic routing, and a downed dependency is proven to hold back traffic.
Secrets & configNo secrets baked into the image or committed; config comes from env/secret store; the readiness gate checks for leaked secrets.Secrets are scanned in CI and rotated out of history; least-privilege credentials are used and the image is verified clean.
Observability & SLOsYou name the signals to watch (latency, error rate, saturation, traffic) and track the DORA four.Dashboards + alerts are wired to an SLO error budget with burn-rate alerts, and the weakest DORA metric has an improvement plan.

Score each dimension 0 (missing), 1 (meets bar), or 2 (above bar). 0–4: keep building. 5–8: a solid, defensible submission. 9–12: staff-level — you could hand this to a reviewer and defend every call. Any dimension at 0 blocks shipping regardless of the total.

Knowledge check check yourself

✓ Knowledge check

Why does the /healthz endpoint return 503 when a dependency (db or cache) is down instead of a plain 200?

Show answer
The deploy platform pings /healthz to decide whether to route traffic; a 503 "degraded" tells it to hold traffic back from an instance that can't actually serve requests. Health must reflect real dependency reachability, not just that the process is running.
✓ Knowledge check

What bug does depends_on: { db: { condition: service_healthy } } in Compose prevent?

Show answer
It stops the API container from starting until Postgres reports healthy (via its pg_isready healthcheck), preventing the classic race where the app boots faster than the DB and crashes on its first query.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in