AI EngineeringZero to ProductionHome·About·What’s new·Contact
Containers & Deployment · Chapter CD5

CI/CD pipelines

The automation that ties it together: a gated test→build→scan→deploy pipeline, staging + approvals, supply-chain security, and owning delivery via the DORA metrics — modeled in Python.

⏱️ ~3 hours🧪 9 labs🎯 Beginner→Tech-lead

Learning objectives

  • Explain CI/CD and why automation beats manual deploys.
  • Build a test → build → scan → deploy pipeline.
  • Stage with environments, approvals, and secrets.
  • Own delivery: the maturity ladder and DORA metrics.
📋 Before you startThis page builds on: Why containers. Jumping in? Skim those first — or open the recap.
30-second recap
  • Why containers: A container packages an app with its dependencies so it runs the same everywhere; an image is the blueprint, a container is a running copy.
▶ Runnable companionCode saved under code/cd5-cicd/. Python runs offline; configs are ready to use.

1 · What CI/CD is (and why) essential

Picture the Friday-afternoon deploy that goes wrong. Someone SSHes into the server, pulls the latest code, forgets to run the migrations, restarts the wrong process, and goes home. Monday morning the app is down and nobody remembers exactly which of the eleven manual steps was skipped. Every team that ships by hand has lived some version of this — and CI/CD is the discipline that makes it structurally impossible.

Here's the whole idea in one sentence: let a machine do the boring, dangerous parts the exact same way every single time. A human deploying is like a chef cooking from memory — brilliant on a good day, disastrous when tired or interrupted. A pipeline is the written recipe that a robot follows to the letter: same ingredients, same order, same result, whether it's the first deploy of the day or the fiftieth at midnight. It never forgets a step because forgetting isn't something it can do.

The two halves have names worth pinning down, because people blur them. CI — Continuous Integration is the testing half: every time you push code, a fresh machine checks it out and runs your test suite, so you find out within minutes whether your change broke anything. CD — Continuous Delivery/Deployment is the shipping half: when the tests pass, the same automation builds the release and pushes it out. Together they form a chain where each link only fires if the one before it succeeded.

The mistake beginners make is treating automation as a convenience rather than a safety mechanism. "It just saves me typing" misses the point entirely. The value isn't speed — it's that a pipeline turns your quality bar into a wall. If you decide tests must pass before code ships, and you encode that in the pipeline, then it is no longer a rule people can quietly skip under deadline pressure; it's a gate that physically will not open. That shift — from good intentions to enforced invariants — is why this chapter exists.

The formal version, now that you have the feel for it: CI runs tests on every push; CD builds and ships automatically when they pass. Manual deploys are slow and error-prone — someone forgets a step, deploys the wrong tag, skips tests. A pipeline does the same steps identically every time, gated on quality.

Push/merge trigger Test (CI) TQ suite Build+scan+push CD2 image Deploy (CD) CD4 deploy
🗺️ How to read this diagram

This is the whole idea of CI/CD in one line: the instant code lands in the main branch, an automated assembly line runs — no human clicking buttons. CI (Continuous Integration) is the testing part; CD (Continuous Delivery/Deployment) is the shipping part. Read the boxes left to right; each arrow only fires if the box before it succeeded.

  • Push/merge (trigger) — someone merges code. That event alone kicks off everything to the right; nobody runs steps by hand.
  • Test (CI) — the automated test suite runs first. This is the gate: if tests fail, the arrow to the next box never fires and nothing ships.
  • Build + scan + push — only reached when tests pass. It packages the app into a container image, scans that image for security problems, and pushes it to an image registry.
  • Deploy (CD) — the final box takes the freshly built image and puts it live. Because it sits last, code can only reach production after passing every earlier stage.

In short: A pipeline is just steps in a fixed order, each guarding the next. The single most important guard is 'tests must pass before deploy' — that is what makes automated shipping safe instead of scary.

2 · A first pipeline essential

Enough theory — let's look at the actual recipe. The abstract diagram above becomes a real, running pipeline through a small text file that lives inside your repository. This is the moment the idea gets concrete: you're about to see the exact YAML that GitHub reads and obeys, and it's shorter than most people expect.

The tool here is GitHub Actions, and the mental model is simple: you drop a file into a magic folder — .github/workflows/ — and GitHub treats it as standing orders. "Whenever this happens, do that." The file isn't a program you execute; it's a declaration you hand to GitHub's servers, which spin up fresh throwaway Linux machines to carry it out. Think of it like leaving a note for a very literal assistant: it will do precisely what the note says, nothing more, nothing less.

Three questions unlock any workflow file, and it pays to read every one you meet in that order. When does it run? (the on: trigger — here, a push to main). What separate units of work are there? (the jobs:, each on its own fresh machine). And what steps does each job take, top to bottom? Once you can answer those three, the scariest-looking pipeline config becomes readable. Like Python, YAML uses indentation to show what belongs to what, so the shape on the page mirrors the structure of the work.

The common trap is skimming past the one line that matters most. In the file below there are two jobs, and the second carries needs: test. That single line is the entire safety story — it wires the deploy job to depend on the test job, so a red test suite stops everything cold. Beginners tend to admire the docker build commands and miss that needs: is where the real engineering lives. Watch for it.

push to main on: trigger job: test fresh ubuntu · pytest -q job: build-deploy fresh ubuntu · needs: test needs: test gate build → push → deploy Two jobs, one dependency. The test job runs on its own fresh machine; build-deploy declares needs: test, so it only fires once tests go green — the deploy path is blocked until then.
🗺️ How to read this diagram

Read it left to right: a push to main triggers the workflow, which fans out into the two jobs: from the file below.

  • The left box is the trigger — on: { push: { branches: [main] } }. One event starts everything.
  • The two right-hand boxes are the two jobs, each on its own fresh Linux machine. The top (solid) is test; the bottom (dashed) is build-deploy, dashed to show it is waiting.
  • The curved brand arrow from test down into build-deploy is the needs: test line — the only thing that lets the deploy path run. The grey arrow from the trigger to build-deploy is faint because that job cannot actually proceed until the gate is satisfied.

In short: One trigger, two jobs, one needs: arrow — that arrow is the gate that makes a red test suite stop the build cold.

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.
config · .github/workflows/deploy.yml
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
  build-deploy:
    needs: test                    # ONLY runs if tests pass
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          IMG=ghcr.io/${{ github.repository }}:${{ github.sha }}
          echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker build -t "$IMG" .
          docker push "$IMG"
      - run: echo "deploy the tagged image (gcloud run deploy / aws ecs update-service)"
▶ How this works

This is a GitHub Actions workflow — a YAML config file (not a program you run) that lives in your repo at .github/workflows/. GitHub reads it and does what it says. YAML uses indentation to show what belongs to what, just like Python. Read it as three questions: when should this run, what jobs are there, and what steps does each job take.

  1. on: { push: { branches: [main] } } is the trigger: run this workflow whenever code is pushed to the main branch.
  2. jobs: lists the units of work. The first job, test, runs-on: ubuntu-latest — GitHub spins up a fresh Linux machine for it.
  3. A job's steps: run top to bottom. uses: pulls in a prebuilt action (check out the code, install Python); run: executes a shell command. Here the steps install dependencies then run pytest -q — that is the test stage.
  4. The second job, build-deploy, has needs: test — it waits for the test job and only starts if tests passed. Its steps log in to the registry, docker build an image tagged with the exact commit (github.sha), docker push it, then deploy.

What the output means: Nothing prints locally — GitHub runs this in the cloud on every push. In the Actions tab you'd see two boxes: test (green), then build-deploy (green). If pytest failed, build-deploy would be skipped and nothing would ship.

Try this: Notice the values wrapped in ${{ ... }} — those are filled in by GitHub at run time (the repo name, the commit SHA, and a secret token). secrets.GHCR_TOKEN is a password stored safely by GitHub, never written in the file.

needs: test is the whole pointThe deploy job depends on the test job — a red suite stops the pipeline before deploy. Broken code physically cannot reach production. That one line encodes safe delivery.

3 · Model a pipeline in Python intermediate

A pipeline can feel like magic until you build a toy one yourself — then the mystery evaporates. GitHub Actions, GitLab CI, Jenkins, and every custom deploy script you'll ever meet are, at heart, the same twenty lines of Python. Writing those lines strips away the YAML and the cloud and leaves you with the one idea underneath: run a list of steps in order, and bail out the instant one fails.

The behaviour that makes a pipeline a pipeline (rather than just a list of tasks) is short-circuiting. Think of a bouncer checking IDs at a door with several checkpoints: fail the first check and you never reach the second. The pipeline doesn't heroically try to deploy after the tests failed and hope for the best — it stops, tells you which checkpoint rejected you, and refuses to run anything downstream. That "stop at the first failure" rule is the difference between a gate and a suggestion.

Why bother modeling it in plain Python at all, when GitHub does this for you? Two reasons. First, it makes the control flow you can't see in the cloud completely visible on your screen — you'll never again wonder what "the pipeline stopped at test" actually means. Second, it's genuinely how real tooling works: a deploy script, a Makefile target, an orchestration library — all of them are ultimately a loop over stages with an early exit. Once you've written the loop, you own the concept, not just the vocabulary.

The classic beginner mistake here is writing a pipeline that reports failures but keeps going anyway — collecting every error and running deploy at the end regardless. That's not a pipeline; it's a pipeline-shaped bug that ships broken code with a warning attached. The single return on first failure in the code below is what keeps it honest.

A pipeline is stages that short-circuit on failure. Modeling it makes the control flow explicit — and it's how a custom deploy tool works under the hood.

run in order — stop at the first FAIL lint PASS test FAIL ✕ build never runs deploy never runs return (False, 'test') — the loop exits the instant a stage fails Short-circuit on first failure. Stages run in order; the moment one returns False the loop returns and everything downstream is skipped. That single early return is what makes it a gate, not a to-do list.
🗺️ How to read this diagram

Read the boxes left to right — the same order the for loop in the code walks the stages list.

  • lint (green, PASS) runs and succeeds, so the arrow continues to the next stage.
  • test (red, FAIL ✕) returns False. This is where the loop hits if not ok: return False, name and stops.
  • The dashed grey boxes — build and deploy — are the stages that come after the failure. The faded arrows show they are never reached: no work runs downstream of a failed gate.

In short: A pipeline runs stages in order and bails out at the first False — so a failed test means build and deploy simply never happen.

Python · a stop-on-failure pipeline (runs)
pipeline.pydef run_pipeline(stages):
    """stages: [(name, fn)]. Stop at the first failure; report where."""
    for name, fn in stages:
        ok = fn()
        print(f"{'PASS' if ok else 'FAIL'}  {name}")
        if not ok:
            return False, name          # nothing after a failure runs
    return True, None

ok = [("lint",lambda:True),("test",lambda:True),("build",lambda:True),("deploy",lambda:True)]
print("result:", run_pipeline(ok))
broken = [("lint",lambda:True),("test",lambda:False),("deploy",lambda:True)]
print("result:", run_pipeline(broken))   # stops at test; deploy never runs
PASS  lint
PASS  test
PASS  build
PASS  deploy
result: (True, None)
PASS  lint
FAIL  test
result: (False, 'test')
▶ How this works

This tiny Python program models what a pipeline does: run a list of stages in order and stop the moment one fails. Seeing it as plain code makes the 'gate' idea concrete — a real deploy tool does exactly this under the hood.

  1. run_pipeline(stages) takes a list of (name, fn) pairs — each stage has a label and a function to call. fn returns True for pass, False for fail.
  2. The for loop runs each stage in turn: ok = fn() runs it, then it prints PASS or FAIL next to the stage name.
  3. if not ok: return False, name is the short-circuit — the first failing stage stops the loop immediately and reports which stage broke. Any stages listed after it never run.
  4. The two example lists show both paths: ok passes every stage; broken fails at test, so deploy is never reached.

What the output means: First run prints PASS for all four stages and (True, None) — the whole pipeline succeeded. Second run prints PASS lint, FAIL test, then (False, 'test') — it stopped at test and deploy never ran, exactly like a real CI gate.

Try this: Flip the first lambda:True to lambda:False and run again — the pipeline should stop at lint and never print the later stages.

4 · Security scanning in the pipeline advanced

Your tests can be all green and your image can still be a liability. Passing tests prove your code does what you meant — they say nothing about the pile of third-party libraries and base-OS packages baked into the container alongside it. A known vulnerability in an old version of some logging library you've never heard of is exactly the kind of thing that ends up on the news, and no unit test will ever catch it. That gap is what security scanning fills.

The analogy is airport security. Your bags are packed and you personally trust everything in them — but you still go through the scanner, because the point isn't to catch you, it's to catch the things you didn't know were dangerous. A scanner like Trivy or Grype reads your image, looks up every package inside it against public databases of known flaws (CVEs — Common Vulnerabilities and Exposures), and hands back a list graded by severity: LOW, MEDIUM, HIGH, CRITICAL. Separate scanners do the same for your dependency manifest and hunt through your code for accidentally-committed secrets like API keys.

A scan on its own is just a report, though — and a report nobody acts on is worthless. The engineering move is to turn the report into a gate: a rule that says "if there are any HIGH or CRITICAL findings, fail the build and do not deploy." This is where scanning stops being a nice dashboard and becomes a wall in your pipeline, exactly like the test gate from §2. The lab below models that gate as a tiny function, because underneath the tooling that's all it is: count the serious findings, compare against a threshold, return pass or fail.

The mistake teams make is running scans but never wiring them to block anything — the findings pile up in a tab someone glances at quarterly, and vulnerable images ship anyway. Equally common is the opposite over-correction: setting zero tolerance, drowning in noise from unfixable base-image CVEs, and then disabling the scan out of frustration. The honest middle ground is configurable thresholds you set deliberately, which is precisely why the gate below takes max_high and max_critical as arguments rather than hardcoding them.

Professional pipelines scan before deploy: the image for known CVEs (Trivy/Grype), dependencies for vulnerabilities, and code for leaked secrets. Fail the build on high-severity findings.

scan findings LOW MEDIUM HIGH CRITICAL scan_gate high≤max? crit≤max? within limits→ deploy LOW/MEDIUM only over limit → block any HIGH/CRITICAL thresholds max_high / max_critical decide where the line sits (default 0) A scan is only a gate once you wire it to block. Findings are counted by severity; if HIGH or CRITICAL exceed your thresholds the build fails and the image never ships. LOW/MEDIUM pass through.
🗺️ How to read this diagram

Follow it left to right: raw findings on the left, the decision in the middle, the two possible outcomes on the right.

  • The stacked boxes on the left are the scan's findings sorted by severity — LOW, MEDIUM, HIGH, CRITICAL. Amber and red mark the two the gate actually counts.
  • The middle box is scan_gate: it counts the HIGH and CRITICAL findings and compares each against its threshold (max_high, max_critical).
  • The two right boxes are the branch: a green "deploy" when both counts stay within limits, a red "block" when either is exceeded. The dotted line at the bottom is the reminder that you set where that line sits — the defaults are zero tolerance.

In short: Count the serious findings, compare against a threshold, and either let the image through or stop the build — that comparison is the whole gate.

Python · a scan-result gate (runs)
scan_gate.pydef scan_gate(findings, max_high=0, max_critical=0):
    highs = sum(1 for f in findings if f["severity"] == "HIGH")
    crits = sum(1 for f in findings if f["severity"] == "CRITICAL")
    passed = highs <= max_high and crits <= max_critical
    return passed, {"high": highs, "critical": crits}

clean = scan_gate([{"severity":"LOW"},{"severity":"MEDIUM"}])
print("clean image:", clean)
vuln = scan_gate([{"severity":"CRITICAL"},{"severity":"HIGH"},{"severity":"LOW"}])
print("vulnerable image:", vuln, "-> block deploy")
clean image: (True, {'high': 0, 'critical': 0})
vulnerable image: (False, {'high': 1, 'critical': 1}) -> block deploy
▶ How this works

Before shipping, professional pipelines scan the container image for known security holes (CVEs). This function is the gate that decides: given a list of findings, is the image safe enough to deploy? It counts the serious ones and says pass or fail.

  1. scan_gate(findings, max_high=0, max_critical=0) takes the scan results plus two thresholds — how many HIGH and CRITICAL issues you will tolerate. The defaults are 0: zero tolerance.
  2. highs = sum(1 for f in findings if f["severity"] == "HIGH") counts how many findings are HIGH severity; the next line counts CRITICAL the same way.
  3. passed = highs <= max_high and crits <= max_critical is the decision — True only if both counts stay within the allowed limits. It returns that verdict plus the counts.
  4. The two calls show both outcomes: a clean image with only LOW/MEDIUM findings passes; a vuln image with a CRITICAL and a HIGH fails.

What the output means: clean image: (True, {'high': 0, 'critical': 0}) — safe to ship. vulnerable image: (False, {'high': 1, 'critical': 1}) -> block deploy — the gate said no, so the pipeline stops before deploy.

Try this: Raise the bar with scan_gate(findings, max_high=5) and see the vulnerable image pass — that is how teams temporarily accept known risks. Setting limits to 0 is the strict, safe default.

5 · Advanced — environments & approvals advanced

Fully automatic deploy-straight-to-production sounds like the dream — right up until it ships a bug to a million users at 2am with nobody watching. Speed is only virtue when you can trust what you're shipping, and some risks are worth a human pause. The grown-up version of a pipeline doesn't remove the humans; it puts them at exactly the one point where judgment matters and automates everything else.

The key concept is the environment: a named target you deploy to, each with its own settings, secrets, and rules. The pattern nearly every serious team converges on is a two-stage rocket. First, staging — a production-like clone with fake or copied data, where the freshly built release lands automatically the moment tests go green, and smoke tests confirm it actually boots and serves traffic. Only after staging is happy does production come into play. Think of staging as the dress rehearsal in full costume on the real stage, and production as opening night: you never open cold.

Between the two sits the human gate — an approval. In GitHub Actions you mark an environment (say, production) as protected, and the job targeting it simply waits until an authorized person clicks "approve." Everything up to that click is machine-fast and machine-reliable; the click itself is the deliberate, reversible-decision moment where someone with context can say "not during the sale" or "let's wait for the on-call to be awake." Automation handles the toil; the human owns the risk. The jobs are chained in strict order with the same needs: keyword you already met — prod literally cannot start until staging has succeeded.

The trap is treating staging as a rubber stamp — deploying to it but never actually testing there, so it gives false confidence, or conversely gating everything behind manual approval until the friction pushes people to bypass the pipeline entirely. The sweet spot the config below encodes is deliberate: automatic all the way to staging, one approval to cross into prod. That's enough control to catch disasters and little enough friction that people actually use it.

test green automatic deploy-staging smoke tests approve? 👤 human manual gate deploy-prod canary needs: chains each step — prod cannot start until staging succeeds and a person approves Automatic to staging, approved to prod. Everything up to production is machine-fast; the one human click sits at the single point where judgment matters — the last chance to say "not yet."
🗺️ How to read this diagram

Read it left to right as a two-stage rocket, with each box chained to the next by needs:.

  • test → deploy-staging is fully automatic (brand arrow, "automatic"): the moment tests go green the release lands in a production-like staging environment and smoke tests confirm it boots.
  • The amber "approve?" box is the human gate — a named production environment GitHub marks as protected. The amber arrow into it signals "waiting for a person."
  • deploy-prod only runs after someone clicks approve. Because it sits last and is chained with needs: deploy-staging, code physically cannot reach real users until staging succeeded and a human said yes.

In short: Automate everything up to staging; put one deliberate human approval at the door to production — enough control to catch disasters, little enough friction that people keep using the pipeline.

config · staged deploy: staging then gated prod
staged.yml  deploy-staging:
    needs: test
    environment: staging           # auto-deploy to staging on green
    steps: [ ... deploy to staging + run smoke tests ... ]
  deploy-prod:
    needs: deploy-staging
    environment:
      name: production             # GitHub requires a human approval here
    steps: [ ... deploy to prod (canary via CD4) ... ]
▶ How this works

This config adds a safety layer for real deploys: ship to a staging copy automatically, but require a human to click 'approve' before touching production. It is two more jobs chained onto the pipeline with needs:.

  1. deploy-staging has needs: test and environment: staging — once tests are green it auto-deploys to a staging environment (a production-like sandbox) and runs smoke tests there.
  2. deploy-prod has needs: deploy-staging — it can only start after staging succeeded. This chains the jobs into a strict order.
  3. Under environment: the name production is special: GitHub can be configured so a named environment requires a human approval before its job runs. That is the manual gate before real users are affected.
  4. The steps: [ ... ] are shown as placeholders — the real deploy commands (a canary rollout, from lesson CD4) would go there.

Try this: The pattern is automatic to staging, approved to prod. Staging catches problems safely; the human approval is your last chance to say 'not yet' before customers see a change.

6 · Professional — secrets & supply chain professional

Stop and reframe what your pipeline actually is: it's a robot that holds the keys to production and runs code from the internet. It has the credentials to deploy, the token to push images, and it happily executes third-party actions written by strangers. If an attacker wanted one thing in your whole organization, this is it — the pipeline is a skeleton key. Most teams guard production like a vault and leave the machine that can rewrite production wide open.

Start with secrets — the passwords, tokens, and keys your pipeline needs. The cardinal sin is hardcoding them in the workflow file, because that file lives in git, and git history is forever; a secret committed once is a secret leaked permanently, even after you "delete" it. The fix is the platform's secret store: an encrypted vault where you register secrets by name, and the pipeline references them (${{ secrets.TOKEN }}) so the real value is injected at run time and never written down. Pair that with least privilege — a token that can only push to one registry can't also delete your database, so a leak of it is a scratch rather than a fatality.

Then the subtler danger: the supply chain. Your pipeline is built from parts you didn't write — actions/checkout, some community action that posts to Slack — and each is code that runs with your secrets in scope. If the author of a popular action is compromised (or turns malicious) and you've told your workflow to always grab the "latest" version, you've handed your credentials to whoever controls that action, automatically, on your next run. This is not hypothetical; it's how several real breaches happened. The defence is to pin every third-party action to an exact, reviewed version (ideally a commit hash), so nobody can swap the code under you.

The mistake even experienced engineers make is thinking about supply-chain risk only for their application and forgetting the pipeline is downstream of the same threat. You scanned your image (§4) — good — but did you verify the action that built it? The professional endgame closes the loop: pin and verify what runs, scope what it can touch, and sign or attest your images so that later you can cryptographically prove this exact artifact came from this exact pipeline and wasn't tampered with in between. Provenance turns "we think we shipped the right thing" into "we can prove it."

CI has powerful credentials — a prime target. Use the platform's secret store (never hardcode), scope tokens to least privilege, pin action versions (a compromised third-party action can steal secrets), and sign/attest images so you can prove what you shipped.

pipeline runner holds keys to prod secret store encrypted · by name ${{ secrets.X }} 3rd-party action pinned @hash signed image attested provenance ✕ hardcode in git = leaked forever The runner is a skeleton key. It reads secrets from an encrypted store by name (never from the file, which git remembers forever), runs only pinned third-party actions, and signs the image so you can later prove what shipped.
🗺️ How to read this diagram

The centre box is the pipeline runner — the machine that can deploy to production. Everything else is what flows into or out of it.

  • Left, green — the secret store. Tokens and passwords live encrypted and are injected by name (${{ secrets.X }}) at run time. The red note below is the cardinal sin: hardcoding a secret in the workflow file commits it to git history, where it is leaked permanently.
  • Top-right, amber — a third-party action. It runs with your secrets in scope, so pin it to an exact reviewed version (a commit hash) — never "latest," which lets its author swap code under you.
  • Bottom-right — the signed image. Signing/attesting the artifact lets you later prove this exact image came from this exact pipeline, untampered.

In short: Inject secrets from a vault (never git), pin every third-party action to a fixed version, and sign what you ship — the runner is as sensitive as prod itself.

Your pipeline is production infrastructureA pipeline that can deploy to prod is as sensitive as prod. Protect its secrets, restrict who can edit workflows, and pin/verify every third-party action — supply-chain attacks target exactly this.

7 · Tech-lead — measure delivery with DORA tech-lead

Ask two engineers whether their team is "good at shipping" and you'll get two vibes, not two answers. One says "we're fast," the other says "we're careful," and neither can be checked. The moment you're leading delivery rather than just doing it, gut feel stops being enough — you need numbers that let you compare this quarter to last, your team to the industry, and say honestly whether you're improving. That's the problem the DORA metrics solve.

DORA (DevOps Research and Assessment — a multi-year research program that studied thousands of teams) found that elite software delivery boils down to just four measurable things, and they split cleanly into two tensions. Speed: how often you deploy (deploy frequency) and how long a change takes to go from commit to live (lead time). Stability: what fraction of deploys break something (change-failure rate) and how fast you recover when they do (time-to-restore). Think of it as a car dashboard: speed and safety, on one panel, at a glance.

The counter-intuitive finding — and the reason DORA matters more than most metrics — is that speed and stability are not a trade-off. The naive assumption is that shipping faster must mean breaking more, so you should slow down to be safe. The data says the opposite: elite teams are both the fastest and the most reliable, because the same practices that let you ship often (small changes, strong automation, easy rollback) are exactly what make each ship safe. A team that deploys once a quarter isn't being careful; it's building up a terrifying pile of untested change for one big scary release.

The trap for a new lead is optimizing one number and congratulating yourself. "We deploy ten times a day!" means little if a third of those deploys break and take a day to fix. The four metrics are read together — and, crucially, your weakest one is your ceiling. A team that's fast but recovers slowly is still fragile; the honest move is to find the metric dragging you down and fix that first, not to polish the one you're already winning at. The classifier below encodes exactly this logic: it demands all four conditions clear the bar to earn a tier, so a single weak metric caps the whole score — pointing you straight at what to improve.

A delivery lead manages by the DORA metrics: deploy frequency, lead time for changes, change-failure rate, and time-to-restore. They tell you whether delivery is elite or struggling, and where to invest.

SPEED deploy frequency · lead time STABILITY change-fail % · time-to-restore ✓ ✓ ✕ weak ✓ tier capped: Medium/Low all four must clear the bar (and) — the weakest wins Your weakest metric is your ceiling. Elite needs all four to clear the bar at once, so one red metric — here a slow time-to-restore — caps the whole tier and points straight at what to fix first.
🗺️ How to read this diagram

Read it top to bottom: the four metrics up top, the verdict at the bottom.

  • The two top bands group the four metrics into the two tensions DORA measures — SPEED (deploy frequency, lead time) on the left, STABILITY (change-failure rate, time-to-restore) on the right.
  • The row of four small boxes is one team's scorecard: three green checks, but one red "✕ weak" (a slow time-to-restore).
  • The red box at the bottom is the payoff: because the tier test joins all four with and, a single failing metric caps the team at Medium/Low no matter how strong the other three are. That red box is the metric to improve first.

In short: Elite = all four clear the bar together, so read the four as one score — your weakest metric is the ceiling, and fixing it is where the leverage is.

Python · classify delivery performance (runs)
dora.pydef dora_tier(deploys_per_week, lead_time_h, change_fail_pct, restore_h):
    elite = deploys_per_week>=7 and lead_time_h<=24 and change_fail_pct<=15 and restore_h<=1
    high  = deploys_per_week>=1 and lead_time_h<=168 and change_fail_pct<=30 and restore_h<=24
    return "Elite" if elite else "High" if high else "Medium/Low"

print("team A:", dora_tier(14, 4, 10, 0.5))     # ships daily, recovers in minutes
print("team B:", dora_tier(0.5, 336, 40, 72))   # rare big releases, slow recovery
print("improve B: raise deploy freq (smaller changes) + cut restore time (rollback)")
team A: Elite
team B: Medium/Low
improve B: raise deploy freq (smaller changes) + cut restore time (rollback)
▶ How this works

The DORA metrics are the industry-standard way to score how well a team delivers software. This function takes four numbers and classifies the team as Elite, High, or Medium/Low — turning a fuzzy 'are we good at shipping?' into a concrete answer.

  1. The four inputs are the four DORA metrics: deploys_per_week (how often you ship), lead_time_h (hours from commit to live), change_fail_pct (percent of deploys that break something), and restore_h (hours to recover from a failure).
  2. elite = ... checks all four against the toughest bar at once with and — every condition must be true to qualify as Elite. high = ... checks a looser bar the same way.
  3. return "Elite" if elite else "High" if high else "Medium/Low" picks the best tier the team qualifies for, top-down.
  4. The two example teams contrast the extremes: team A ships often and recovers in minutes; team B does rare, large releases and recovers slowly.

What the output means: team A: Elite (frequent, fast, reliable) and team B: Medium/Low. The last line names the fix for B: ship smaller changes more often and make recovery (rollback) faster.

Try this: Change team B's deploys_per_week from 0.5 to 3 and its restore_h from 72 to 10 — watch it climb toward High. Small, frequent, reversible changes are what move the needle.

Everything converges in the pipelineGit triggers it (DF3), tests gate it (TQ5), the image is built & scanned well (CD2), it deploys with canary + rollback (CD4), and DORA measures it. Owning this whole path — small, frequent, reversible changes — is the professional delivery endgame.

Exercise CD5.1 — Build & measure a pipeline

Context: The whole Containers & Deployment track converges in the pipeline: git triggers it, tests gate it, the image is built and scanned, it deploys with canary + rollback, and DORA measures it. Building and then measuring your own pipeline is the professional delivery endgame.

Your task: Write a workflow that tests → builds+pushes a SHA-tagged image → deploys, gated on green, add a scan_gate step, model your release with run_pipeline, and score your team with dora_tier.

Requirements:

  • Write a green-gated workflow: test, then build+push a SHA-tagged image, then deploy
  • Add a scan_gate step that blocks on high-severity findings
  • Model the release control flow with run_pipeline
  • Score your team (or a hypothetical one) with dora_tier
  • Name the one DORA metric you'd improve first and exactly how

💡 Hint: Tie the chapter's pieces together — the needs: gate, the scan gate, and DORA — then let the lowest DORA metric point at your first improvement.

🪜 Practice ladder beginner → industry

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

Exercise 1 · The CI/CD pipeline & its one guardBeginner

Context: CI runs tests on every push; CD builds and ships automatically when they pass. The pipeline is just steps in a fixed order, each guarding the next — and one guard is what makes automated shipping safe instead of scary.

Your task: Explain the core CI/CD pipeline (push → test → build+scan+push → deploy) and identify the single most important guard the lesson calls 'the whole point'.

Requirements:

  • Describe the four stages in order: trigger, test, build+scan+push, deploy
  • Explain that each arrow fires only if the stage before it succeeded
  • Identify 'tests must pass before deploy' as the critical guard
  • Name the GitHub Actions mechanism that encodes it: the deploy job's needs: test
  • State the payoff: broken code physically cannot reach production

💡 Hint: Everything hinges on the test stage gating the deploy stage — without that dependency, automation just ships bugs faster.

Show solution

CI runs tests on every push; CD builds and ships automatically when they pass. A pipeline runs the same steps identically every time, each stage guarding the next.

Push/merge (trigger)
   -> Test (CI)              # the gate: if tests fail, nothing after runs
      -> Build + scan + push  # package image, scan for CVEs, push to registry
         -> Deploy (CD)       # put the built image live

The critical guard is tests must pass before deploy. In GitHub Actions that is the deploy job's needs: test line — broken code physically cannot reach production, which is what makes automated shipping safe instead of scary.

Exercise 2 · A GitHub Actions workflowIntermediate

Context: A GitHub Actions workflow is a YAML config GitHub reads and runs in the cloud on every push. Splitting test and deploy into separate jobs, with the deploy depending on the test, is the canonical safe-delivery shape.

Your task: Write a GitHub Actions workflow (.github/workflows/deploy.yml) that on push to main runs pytest, then in a separate job (only if tests pass) builds and pushes a SHA-tagged image to GHCR.

Requirements:

  • Trigger on push to the main branch
  • Give the test job steps that check out code, set up Python, install deps, and run pytest -q
  • Declare needs: test on the build-deploy job so it waits for and depends on the tests
  • Tag the image with the exact commit via ${{ github.sha }}
  • Read the registry token from secrets and log in with --password-stdin

💡 Hint: The ${{ ... }} values are filled in by GitHub at run time; a failing pytest skips the build-deploy job so nothing ships.

Show solution

Trigger on push to main; the build-deploy job declares needs: test so it waits for and depends on the tests. Tag the image with the exact commit via github.sha, and read the registry token from GitHub secrets.

name: 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
  build-deploy:
    needs: test                    # ONLY runs if tests pass
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          IMG=ghcr.io/${{ github.repository }}:${{ github.sha }}
          echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker build -t "$IMG" .
          docker push "$IMG"
      - run: echo "deploy the tagged image (gcloud run deploy / aws ecs update-service)"

If pytest fails, build-deploy is skipped and nothing ships. The ${{ ... }} values are filled in by GitHub at run time; the token is stored safely, never in the file.

Exercise 3 · A short-circuiting pipelineAdvanced

Context: A pipeline is stages that short-circuit on failure — and a real deploy tool does exactly this under the hood. Modeling it in plain Python makes the 'gate' idea concrete.

Your task: Model a CI pipeline in Python as stages that short-circuit on failure: run (name, fn) stages in order, stop at the first failing one, and report which stage broke.

Requirements:

  • Take a list of (name, fn) stages where each fn returns True (pass) or False (fail)
  • Run each stage in order and print PASS or FAIL next to its name
  • Stop immediately on the first failing stage — nothing after it runs
  • Return a success boolean plus the name of the stage that broke (or None)
  • Demonstrate a clean run and a run that fails at test so deploy never runs

💡 Hint: A single return inside the loop on the first failure is the short-circuit; it mirrors how a red test suite stops a real pipeline before deploy.

Show solution

This is how a real deploy tool works under the hood: run each stage, print pass/fail, and return immediately on the first failure so nothing after it runs.

def run_pipeline(stages):
    """stages: [(name, fn)]. Stop at the first failure; report where."""
    for name, fn in stages:
        ok = fn()
        print(f"{'PASS' if ok else 'FAIL'}  {name}")
        if not ok:
            return False, name          # nothing after a failure runs
    return True, None

ok = [("lint", lambda: True), ("test", lambda: True),
      ("build", lambda: True), ("deploy", lambda: True)]
print("result:", run_pipeline(ok))
broken = [("lint", lambda: True), ("test", lambda: False), ("deploy", lambda: True)]
print("result:", run_pipeline(broken))   # stops at test; deploy never runs

The clean run reports (True, None); the broken run prints FAIL at test and returns (False, 'test') — deploy never runs, exactly like a real CI gate.

Exercise 4 · A security-scan gateExpert

Context: Professional pipelines scan the image for known CVEs before deploy and fail the build on high-severity findings. The gate that decides pass or fail is a small, configurable function.

Your task: Add a security-scan gate to the pipeline in Python: given a list of scan findings, block the deploy if HIGH or CRITICAL counts exceed configurable thresholds (default zero tolerance).

Requirements:

  • Take the findings plus max_high and max_critical thresholds (both default 0)
  • Count HIGH-severity and CRITICAL-severity findings separately
  • Pass only when both counts stay within their allowed limits
  • Return the verdict plus the high/critical counts
  • Show a clean image passing and a vulnerable image (a CRITICAL and a HIGH) blocking the deploy

💡 Hint: Zero thresholds are the strict, safe default; raising max_high is how a team consciously accepts a known risk for a while.

Show solution

Professional pipelines scan the image for known CVEs (Trivy/Grype) before deploy. Count the serious findings and compare against thresholds.

def scan_gate(findings, max_high=0, max_critical=0):
    highs = sum(1 for f in findings if f["severity"] == "HIGH")
    crits = sum(1 for f in findings if f["severity"] == "CRITICAL")
    passed = highs <= max_high and crits <= max_critical
    return passed, {"high": highs, "critical": crits}

clean = scan_gate([{"severity":"LOW"}, {"severity":"MEDIUM"}])
print("clean image:", clean)
vuln = scan_gate([{"severity":"CRITICAL"}, {"severity":"HIGH"}, {"severity":"LOW"}])
print("vulnerable image:", vuln, "-> block deploy")
# clean: (True, {'high': 0, 'critical': 0})
# vuln:  (False, {'high': 1, 'critical': 1}) -> block deploy

Zero thresholds are the strict, safe default; raising max_high is how a team temporarily accepts a known risk. The gate fails the build on high-severity findings before deploy.

Exercise 5 · Staged deploy with approvalProfessional

Context: Real deploys ship to a staging copy automatically but require a human to approve before touching production. And because a pipeline that can deploy to prod is as sensitive as prod, its credentials and third-party actions are a prime attack target.

Your task: Write the staged-deploy config: auto-deploy to staging on green, then a production job that requires a human approval, chained with needs: — and state why the pipeline itself must be treated as production infrastructure.

Requirements:

  • Give deploy-staging needs: test and an environment: staging so it auto-deploys on green and runs smoke tests
  • Chain deploy-prod with needs: deploy-staging so it can't start until staging succeeds
  • Use a named production environment that GitHub can require a human approval for
  • Summarize the pattern: automatic to staging, approved to prod
  • Explain the supply-chain risk: use the secret store, scope tokens to least privilege, and pin every third-party action version

💡 Hint: The named production environment is the manual gate — the last chance to say 'not yet' before customers see the change.

Show solution

Ship to a staging copy automatically, but require a human to approve before touching production. Two jobs chained with needs: enforce the order.

  deploy-staging:
    needs: test
    environment: staging           # auto-deploy to staging on green
    steps: [ ... deploy to staging + run smoke tests ... ]
  deploy-prod:
    needs: deploy-staging
    environment:
      name: production             # GitHub requires a human approval here
    steps: [ ... deploy to prod (canary via CD4) ... ]

The pattern is automatic to staging, approved to prod. A named production environment can be configured to require manual approval — the last chance to say "not yet." Because a pipeline that can deploy to prod is as sensitive as prod, use the platform's secret store (never hardcode), scope tokens to least privilege, and pin every third-party action version — supply-chain attacks target exactly this.

Exercise 6 · Classify a team by DORA metricsIndustry scenario

Context: A delivery lead manages by the four DORA metrics — they turn a fuzzy 'are we good at shipping?' into a concrete tier and show where to invest. The fix for a struggling team is almost always smaller, more frequent, reversible changes.

Your task: As a delivery lead, write a Python function that classifies a team's delivery performance by the four DORA metrics (deploy frequency, lead time, change-failure rate, time-to-restore) into Elite / High / Medium-Low.

Requirements:

  • Take the four metrics as inputs: deploys per week, lead time (h), change-failure %, restore time (h)
  • Check all four against the toughest bar with and for Elite
  • Check a looser bar the same way for High
  • Return the best tier the team qualifies for, chosen top-down
  • Contrast a frequent/fast/reliable team (Elite) with a rare-big-release team (Medium/Low)

💡 Hint: All four conditions must hold to reach a tier, so a single weak metric (slow restore, rare deploys) caps the team — that's the metric to improve first.

Show solution

Check all four metrics against the toughest bar with and for Elite, a looser bar for High, and pick the best tier top-down.

def dora_tier(deploys_per_week, lead_time_h, change_fail_pct, restore_h):
    elite = deploys_per_week >= 7   and lead_time_h <= 24  and change_fail_pct <= 15 and restore_h <= 1
    high  = deploys_per_week >= 1   and lead_time_h <= 168 and change_fail_pct <= 30 and restore_h <= 24
    return "Elite" if elite else "High" if high else "Medium/Low"

print("team A:", dora_tier(14, 4, 10, 0.5))     # ships daily, recovers in minutes
print("team B:", dora_tier(0.5, 336, 40, 72))   # rare big releases, slow recovery
print("improve B: raise deploy freq (smaller changes) + cut restore time (rollback)")
# team A: Elite   /   team B: Medium/Low

Team A is Elite (frequent, fast, reliable); team B is Medium/Low. The two changes that most directly move B toward Elite are shipping smaller changes far more often (raising deploy frequency, cutting lead time) and making recovery fast via rollback (cutting time-to-restore) — small, frequent, reversible changes are what move the needle.

✓ Checkpoint — you can move on when you can…

  • Explain CI vs CD and the needs: test gate.
  • Build a test→build→scan→deploy pipeline.
  • Stage with environments/approvals; secure the supply chain.
  • Measure delivery with DORA and drive improvement.

Knowledge check check yourself

✓ Knowledge check

In the GitHub Actions workflow, the build-deploy job declares needs: test. Why is that one line described as 'the whole point' of the pipeline?

Show answer
It makes the deploy job depend on the test job, so if the test suite fails the deploy is skipped and nothing ships. That dependency is what makes automated delivery safe — broken code physically cannot reach production.
✓ Knowledge check

A team scores Medium/Low on the DORA metrics with rare, large releases and slow recovery. Which two changes most directly move it toward Elite, and why?

Show answer
Ship smaller changes far more often (raising deploy frequency and cutting lead time) and make recovery fast via rollback (cutting time-to-restore). Small, frequent, reversible changes are what move the DORA needle; big infrequent releases both fail more and take longer to recover from.
© 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