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

Deploy a container to the cloud

From laptop to a live URL, safely: registries, managed services, config/secrets, dependency health checks, rollout strategies (rolling/blue-green/canary), readiness gates, SLOs, and rollback.

⏱️ ~3.5 hours🧪 11 labs🎯 Beginner→Tech-lead
🌱 Start here — from zero Deployment, from scratch — getting your container off your laptop and onto the internet — and doing it safely.

You have a working image; now real users must reach it. This chapter is the full path: push to a registry, run on a managed service, wire config, secrets, and health checks, then the production concerns — rollout strategies, rollback, and observability. Cloud commands run in your account; every Python block (readiness gates, rollout, health, canary analysis) runs offline.

The words you'll hear (in plain terms):

TermWhat it actually means
registrywhere images live to be pulled (GHCR, ECR, Docker Hub).
managed serviceruns your container for you (Cloud Run, ECS, App Runner).
revisionone immutable deployed version; rollback = repoint to a prior one.
health checkan endpoint the platform polls to know you're alive.
rollouthow new code replaces old: all-at-once, blue-green, or canary.

What you need before starting:

  • A working image (CD1–CD2) + a cloud account (free tiers exist).
  • Docker + the cloud CLI (gcloud/aws) for the deploy labs.
  • Python blocks model the logic and run offline.

New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.

Learning objectives

  • Push an image to a registry and run it on a managed service.
  • Configure env, secrets, and dependency-aware health checks.
  • Choose a rollout strategy: rolling, blue-green, or canary.
  • Gate deploys on readiness and recover with automatic rollback.
📋 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/cd4-deploy/. Python runs offline; configs are ready to use.

1 · The deploy path, end to end essential

Here's the moment every new engineer dreads. Your app runs beautifully on your laptop — docker run, open localhost:8000, everything works. Then your boss says "great, ship it," and you freeze. Because "deploying" sounds like one mysterious button, and nobody ever explained what actually happens between your machine and a URL a customer can type. The good news: it's not mysterious at all. It's four plain steps, and by the end of this section you'll be able to draw them from memory.

Think of it like mailing a parcel. The container image you built is the parcel. It's sitting in your house (your laptop) where no one else can get it. To send it, you first drop it at the post office — a place designed to hold parcels and hand them to whoever's authorized to collect (that's the registry). Then a courier picks it up and delivers it to a destination that will actually open and use it (the managed service that runs your container). Finally the recipient gets a street address others can visit (your public URL). You never hand the parcel directly to the customer; it always goes through storage and a delivery service. Deployment is the same handoff chain.

Why it matters that you see this as a pipeline, not a magic button: every one of the four stages is a place things can go wrong, and knowing the stages tells you exactly where to look. "The image built but the deploy failed" points at the registry (bad tag, no login). "It deployed but the URL 404s" points at the run stage (wrong port, crashed on startup). If you only know "I ran the deploy command and it broke," you're debugging blind. The pipeline is your mental map of the failure space.

The mistake beginners make is skipping the registry entirely — trying to somehow copy the local image straight onto a cloud server, or rebuilding it on the server from source. That's the equivalent of driving the parcel to the customer's door yourself: it works once, badly, and doesn't scale. The registry exists precisely so the image is built once, stored in a neutral place, and pulled by any number of machines identically. Learn the four-box flow below and you've learned the shape of every cloud deploy — AWS, Google, Azure — because they all do these same four things; only the command names change.

Local image docker build push → registry GHCR/ECR managed servicepulls Cloud Run/ECS public HTTPS URL users reach it
🗺️ How to read this diagram

This is the whole journey of "deploying" — moving your container off your laptop and onto the internet so real users can reach it. Read it left to right; each box hands off to the next.

  • Local image — the container you built on your machine with docker build. It only exists on your laptop right now, so nobody else can run it.
  • push → registry — you upload ("push") that image to a registry, an online storage service for images (GHCR, ECR, Docker Hub). Now any machine can download it.
  • managed service pulls — a cloud service (Cloud Run, ECS) downloads ("pulls") the image from the registry and runs it for you — you don't manage any servers.
  • public HTTPS URL — the service hands back a secure web address. Type it in a browser and your app answers. That's "live."

In short: Deploy = build → push to a registry → tell a service to run it → get a URL. Every cloud does these same four steps; only the exact commands change.

2 · Push to a registry essential

Ask yourself a scary question: right now, could you say exactly which version of your app is serving customers — down to the commit? For a lot of teams the honest answer is "no idea, whatever the last person pushed." That single gap is behind a huge share of production incidents, and the registry plus disciplined tagging is how you close it.

A registry is the shared warehouse for images. Picture a library: you don't lend your only copy of a book to every reader — you deposit it, and everyone borrows the same catalogued copy. A registry does that for container images. You push (deposit) your built image once, and every machine that needs it pulls (borrows) the identical bytes. GHCR, ECR, and Docker Hub are all just different libraries offering the same service. The key idea from the section before still holds: this neutral storage is what lets the same image run unchanged on your laptop, in CI, and in prod.

The part that earns its own paragraph is the tag — the label after the colon, like myapp:1.0. A tag is how you name a specific version so you can point at it later. And here's where nearly everyone gets burned exactly once: they tag everything :latest and move on. The problem is that :latest is a sticky note that keeps getting rewritten, not a permanent label. It means "whatever I pushed most recently" — which changes every time anyone pushes. Six weeks later, when prod is on fire, you look at the running service and it says myapp:latest… and you have no way to know whether that's the code from today, last Tuesday, or the intern's experiment. You can't reproduce it and you can't confidently roll back to a known-good state, because there's no name for the previous good build either.

The fix is a one-word discipline: pin. Tag every image with a real, immutable version — a semver like 1.4.2, or better, the git commit SHA (e.g. myapp:9f3c1a2) so the running image ties straight back to the exact source that produced it. A pinned tag is a promise that never changes: myapp:1.0 today is byte-for-byte myapp:1.0 next year. That promise is what makes a deploy reproducible (you can rebuild the exact situation) and roll-back-able (you can repoint traffic to 1.0 the instant 1.1 misbehaves). Use :latest for local scratch work if you like; never let it near production.

:latest — a sticky note that keeps moving img A img B img C :latest points at "whatever was pushed last" pinned — one name, one image, forever img A:1.0 img B:1.1 img C:1.2 each tag is a permanent label roll back = repoint to :1.1 (still exists) Pin, don't chase :latest. A moving :latest tag can't tell you what is live and leaves no named version to return to; an immutable version tag (or git SHA) makes every deploy reproducible and roll-back-able.
🗺️ How to read this diagram

Compare the two halves left vs right: the same three image builds (A, B, C), tagged two different ways.

  • On the left, the single red :latest tag is dragged along to whatever you pushed most recently — right now it points at img C, but there is no record that it used to point at A or B.
  • On the right, each build gets its own permanent tag — :1.0, :1.1, :1.2 (green = still addressable). The label never moves once written.
  • The payoff is the bottom-right line: because :1.1 still names a real image, rolling back is just repointing traffic to it — impossible on the left, where the previous build has no name.

In short: :latest tells you nothing and strands old builds; a pinned version is a name you can identify, reproduce, and roll back to.

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.
shell · tag & push
push.shdocker build -t myapp .
docker tag myapp ghcr.io/you/myapp:1.0        # registry/name:VERSION
echo "$TOKEN" | docker login ghcr.io -u you --password-stdin
docker push ghcr.io/you/myapp:1.0
# pin every deploy to a specific tag (or the git SHA) -> reproducible + rollback-able
▶ How this works

Before a cloud can run your image, the image has to live somewhere the cloud can download it from. These four commands build the image, give it a versioned name, log in to the registry, and upload it.

  1. docker build -t myapp . builds the image from the Dockerfile in the current folder (.) and names it myapp locally.
  2. docker tag myapp ghcr.io/you/myapp:1.0 gives it the full registry address: registry / your-name / image-name : version. The 1.0 is the version so you always know exactly what is running.
  3. docker login proves who you are to the registry. The $TOKEN (a secret password) is piped in with --password-stdin so it never shows on screen or in your command history.
  4. docker push …:1.0 uploads the image. Now the cloud can pull it by that exact name.

What the output means: Nothing dramatic prints — the image is now stored in the registry under the tag ghcr.io/you/myapp:1.0, ready for any machine to download.

Try this: Always tag a real version (or the git commit SHA), never rely on :latest. A pinned version is what makes a deploy reproducible and lets you roll back to a known-good image later.

3 · Run on a managed service essential

There's an old way to do this that will eat your weekend. You rent a virtual server, SSH in, install Docker, copy your image over, figure out systemd so it restarts on crash, install nginx for HTTPS, chase a TLS certificate, then set up a second server so one machine dying doesn't take you offline… and you haven't shipped a single feature yet. A managed service exists so you never do any of that. You hand it three things — image, port, config — and it hands you back a running, secure, auto-scaling URL.

The cleanest analogy is renting an apartment versus building a house. Self-managing servers is building a house: you're responsible for the plumbing, the wiring, the roof, and every leak at 3am. A managed service is renting a serviced apartment: you bring your furniture (your image) and the landlord handles utilities, security, and maintenance. You give up some control over the building's internals, but in exchange you get to just live there and focus on your own stuff. For the vast majority of apps, that trade is overwhelmingly worth it.

Why this is worth learning as one universal model rather than one product: Cloud Run, AWS App Runner, AWS ECS, and Azure Container Apps look like four different tools with four different CLIs, and beginners waste weeks thinking they must learn each from scratch. They don't. Every one of them accepts the same request — "here is my image, the port it listens on, and its environment; give me a scaling HTTPS endpoint" — and returns the same thing. Internalize that sentence and the specific product becomes a lookup of flag names, not a new concept. We use Cloud Run below only because it's the shortest to demonstrate.

The one detail that trips people up on their very first deploy is the port. Your container listens on some port inside its own little world — say 8000. The managed service needs to be told that number so it can forward incoming web traffic to the right door. Get it wrong and the deploy "succeeds" but every request hangs or 404s, because the platform is knocking on a door your app isn't behind. So the common failure here isn't dramatic — it's a silent mismatch between the port your app actually binds and the port you told the service about. Watch that they agree, and your first deploy will just work.

self-managed — you own everything provision VM · SSH in install Docker · systemd restart nginx · TLS certificate 2nd server for redundancy patch · monitor · scale by hand you maintain all of it managed service — you give 3 things image + port + config platform scaling HTTPS URL, TLS, restarts landlord handles the rest Managed > self-managed for most apps. Running your own servers means owning provisioning, restarts, TLS, and redundancy; a managed service takes image + port + config and hands back a scaling HTTPS endpoint.
🗺️ How to read this diagram

Two ways to get the same container serving traffic, side by side — the left is the "build a house" path, the right is the "rent an apartment" path.

  • The red stack on the left is everything you personally own when you self-manage: the VM, Docker, restart supervision, a web server for TLS, a second machine for redundancy, plus ongoing patching and scaling.
  • On the right, you hand the platform just three things (the blue box: image + port + config), the platform runs it, and returns the green box — a scaling HTTPS URL with TLS and automatic restarts.
  • The colour flip red → green is the payoff: the toil you owned on the left is now the landlord's problem on the right.

In short: Self-managing servers is a long red checklist you maintain forever; a managed service reduces your job to image + port + config and returns a live, secure URL.

shell · deploy to a managed service
deploy.shgcloud run deploy myapp \
  --image ghcr.io/you/myapp:1.0 \
  --region us-central1 --port 8000 \
  --allow-unauthenticated \
  --set-env-vars ENV=prod --memory 512Mi --max-instances 10
# -> Service URL: https://myapp-xxxx.a.run.app  (TLS + autoscaling included)
▶ How this works

Now you tell a managed service to run the image you just pushed. "Managed" means the cloud handles the servers, scaling, and HTTPS for you — you just describe what you want. This one command deploys the app and gives you a live URL.

  1. gcloud run deploy myapp creates/updates a service called myapp on Google Cloud Run. The backslashes \ just let one command span several lines.
  2. --image …:1.0 is the exact image to pull from the registry. --region picks which data center, and --port 8000 tells the service which port inside the container your app listens on.
  3. --allow-unauthenticated makes the URL public (anyone can reach it). --set-env-vars ENV=prod passes a setting into the container at runtime.
  4. --memory 512Mi and --max-instances 10 cap resources: how much RAM each copy gets, and the most copies it will scale up to under load.

What the output means: The service prints a Service URL like https://myapp-xxxx.a.run.app. That address is now live, with HTTPS (TLS) and autoscaling handled for you.

Try this: The same idea works on AWS App Runner/ECS and Azure Container Apps — "here's my image, port, and env; give me a scaling HTTPS endpoint." Learn the model once; only the flags differ.

The service model is universalCloud Run, AWS App Runner/ECS, Azure Container Apps all do the same thing: "here's my image + port + env, give me a scaling HTTPS endpoint." Learn the model once; only the CLI flags differ.

4 · Config, env & secrets intermediate

A true horror story, and it happens weekly. An engineer hardcodes the production database password into their code to "just get it working," commits it, and pushes to a public GitHub repo. Automated bots scanning for exactly this find it within minutes. By morning the database is ransomed. Nobody in that story was stupid — they just never learned the one rule this section teaches: your image must never contain a secret, and never contain environment-specific settings.

Start with why config and code must live apart at all. Think of your image as a sealed appliance — a coffee machine. The machine's design is fixed and identical for every unit shipped (that's your code). But each kitchen sets its own water hardness and cup size (that's config). You'd never manufacture a separate coffee machine per household just to change a setting; you build one machine and let each owner dial in their preferences. Baking config into the image is exactly that mistake: it forces you to rebuild a whole new image every time a URL or log level changes, and — worse — it means your "test" image and "prod" image are different builds, so testing one never truly tests the other. Injecting config at runtime keeps a single image that behaves differently based on what you feed it when it starts.

There are two flavours of injected config, and the distinction is the whole point. Plain settings — which environment, log level, feature flags — are non-sensitive; you pass them as environment variables and your app reads them from os.environ. Secrets — database passwords, API keys, tokens — are dangerous, and they need a secret manager: a locked vault the cloud provides that stores the value encrypted and hands it to your container only at runtime. It never touches your image layers, never lands in your git history, and never gets printed to logs. To your code, both arrive as ordinary environment variables, so you read a password the same way you read a log level — the app doesn't need to know one came from a vault.

The common mistake beyond hardcoding is subtler: treating a secret manager as optional bureaucracy and stuffing the password into a plain env var in your deploy command or a checked-in .env file. That's better than baking it into the image, but the value still sits in plaintext in your CI logs, your shell history, and your deploy scripts. The rule to burn in: plain settings → env vars; anything you'd be fired for leaking → the secret manager. Getting this split right is precisely what lets the identical image run on your laptop and in prod, with only the injected values differing.

one imagecode only, no config env varsENV, LOG_LEVEL (plain) secret managerDB password (vault) running containeros.environ Config is injected at runtime, not baked in. One image plus plain env vars (non-secret) and vault-managed secrets both arrive as environment variables — so the identical image runs anywhere, only the injected values differ.
🗺️ How to read this diagram

Follow the arrows into the container on the right — three inputs converge to make the app behave for a given environment.

  • The blue box on the left is your one image: pure code, with no config or secrets inside it. This same image ships everywhere.
  • The upper purple box is plain env vars (environment, log level) — non-sensitive settings. The lower red box is the secret manager, a vault that stores the password encrypted and hands it over only at runtime.
  • All three arrows land in the green running container, where your code reads them the same way — via os.environ. The app can't tell which value came from a vault.

In short: Ship one config-free image; inject plain settings as env vars and dangerous values via the secret manager — both surface as ordinary environment variables at runtime.

shell · runtime config + secret
config.shgcloud run deploy myapp --image ghcr.io/you/myapp:1.0 \
  --set-env-vars "ENV=prod,LOG_LEVEL=info" \
  --set-secrets "DATABASE_PASSWORD=db-password:latest"   # from the secret manager
# the app reads os.environ — same code locally and in prod, config differs
▶ How this works

Your app needs settings (like which environment it's in) and secrets (like a database password). You must never bake these into the image — instead you inject them when the container runs. This command shows the two kinds side by side.

  1. --set-env-vars "ENV=prod,LOG_LEVEL=info" passes ordinary, non-secret settings as environment variables. Your app reads them with os.environ at startup.
  2. --set-secrets "DATABASE_PASSWORD=db-password:latest" pulls a real secret from the cloud's secret manager and hands it to the container at runtime — the password is never stored in the image or printed in logs.
  3. Both become environment variables inside the container, so your code reads a password exactly the same way it reads a plain setting — it doesn't need to know which is which.

What the output means: The service redeploys with ENV, LOG_LEVEL, and DATABASE_PASSWORD available inside the container as environment variables.

Try this: This is why the same image runs on your laptop and in prod: the code is identical, only the injected config changes. Secrets live in the secret manager, not in your source code.

5 · Health checks that mean something intermediate

Imagine a smoke detector that only checks whether it has power. The battery's fine, the green light is on, everyone feels safe — and it happily ignores the smoke filling the room, because "am I powered?" was the only question it ever asked. That is the health check most people write on their first try: an endpoint that returns 200 OK as long as the process is running, while the app behind it is completely unable to serve a real request because its database vanished. A health check that only proves the process is alive is a smoke detector that only checks its own battery.

To fix it, you have to understand what the platform is actually doing with the answer. The managed service from the last sections doesn't just deploy your container and walk away — it repeatedly pokes a health check endpoint asking "are you okay to receive traffic?" Your reply is a signal it acts on: a 200 means "keep sending me users," and a 503 (Service Unavailable) means "stop routing to me, I can't cope — restart or replace me." So the endpoint isn't a status page for humans; it's a control input that decides whether broken instances stay in rotation. Answer honestly and the platform routes around your failures automatically. Answer "I'm fine!" while broken and it keeps feeding real users straight into the failure.

That's why a meaningful health check verifies the things your app truly depends on — can it reach the database? the cache? the queue? — and reports 503 the moment any critical dependency is down. This is the difference between a liveness check ("is my process running?") and a readiness check ("can I actually do my job right now?"). The database being unreachable doesn't crash your Python process, so a liveness check stays green and the platform keeps you live — and every user hits an error. A readiness check that actually pings the dependency catches it, returns 503, and the platform pulls you out until you recover.

The mistake to avoid lurks at the other extreme, though: don't turn a health check into a heavyweight test that runs expensive queries or checks everything on every poll. The platform calls this endpoint every few seconds, so a slow check becomes its own load problem, and marking yourself unhealthy over a non-critical dependency causes needless restarts. The sweet spot is a fast, cheap probe of the dependencies you genuinely can't function without — the ones where, if they're down, serving traffic would only produce errors anyway. The tiny function in the lab captures exactly that: healthy only if all the dependencies you list are ok.

liveness only — "is my process up?" platform poll DB downprocess alive 200 OK — kept in rotation users still hit errors ✗ readiness — "can I do my job?" platform poll DB downpings deps 503 — reroute + restart bad instance pulled out ✓ Readiness beats liveness. A process-alive check returns 200 while the DB is down and the platform keeps feeding it users; a dependency-aware check returns 503 so the platform reroutes traffic and restarts the instance.
🗺️ How to read this diagram

Both halves show the same failure — the database is down — and how each style of health check responds when the platform polls it.

  • On the left, a liveness-only check just asks "is my process running?" The process is alive, so it replies 200 OK — and the platform keeps routing users to a box that can't actually serve them (the red "users still hit errors" line).
  • On the right, a readiness check actually pings the dependency, sees the DB is unreachable, and replies 503.
  • The payoff is the amber box → green line: a 503 is a control signal the platform acts on — it stops routing traffic to the broken instance and restarts/replaces it, so the failure is contained.

In short: "Process alive" (200) hides real failures; "can I do my job right now?" (503 when a dependency is down) lets the platform route around the break automatically.

Python · a dependency-aware health check (runs)
healthz.pydef health_check(deps):
    """deps: name -> is_ok. Healthy only if ALL dependencies are ok."""
    healthy = all(deps.values())
    status = 200 if healthy else 503          # 503 -> platform reroutes/restarts
    return status, {"status": "healthy" if healthy else "degraded",
                    "checks": deps}

print(health_check({"db": True,  "cache": True}))
print(health_check({"db": True,  "cache": False}))   # cache down -> 503
print(health_check({"db": False, "cache": True}))    # db down   -> 503
(200, {'status': 'healthy', 'checks': {'db': True, 'cache': True}})
(503, {'status': 'degraded', 'checks': {'db': True, 'cache': False}})
(503, {'status': 'degraded', 'checks': {'db': False, 'cache': True}})
▶ How this works

The cloud platform repeatedly calls a health check endpoint to ask "are you okay?" A good one checks the things your app depends on (database, cache), not just "is the process running." If a dependency is down, you say so, and the platform stops sending traffic.

  1. def health_check(deps): takes deps — a dictionary mapping each dependency name to True (ok) or False (down), e.g. {"db": True, "cache": False}.
  2. all(deps.values()) is True only if every dependency is ok. If even one is False, healthy becomes False.
  3. status = 200 if healthy else 503 picks the HTTP status: 200 means "all good," 503 means "service unavailable." Platforms treat 503 as a signal to reroute traffic away and restart or replace the instance.
  4. It returns the status plus a small report saying "healthy" or "degraded" and which checks passed — useful when a human looks at it.

What the output means: The three prints show all-ok returning 200, then two cases where one dependency is down returning 503 with "degraded".

Try this: Add a third dependency, e.g. health_check({"db": True, "cache": True, "queue": False}), and confirm it returns 503 — any single failure fails the whole check.

6 · Advanced — rollout strategies advanced

Picture the naive deploy — the one everyone does first. New version's ready, so you kill all the old instances and start all the new ones at once. For thirty seconds there's nothing serving traffic, so every user gets an error. And if the new version is broken, you've just handed the bug to 100% of your customers simultaneously, with the old version already torn down so there's nothing to fall back to. The entire discipline of rollout strategies exists to answer one question: how do you swap old code for new without a moment where everyone's broken at once?

The core insight is that a deploy is a bet, and the smart move is to bound how much you lose if the bet is wrong — your "blast radius." Think of a restaurant putting a new dish on the menu. The reckless kitchen replaces every dish at once and hopes. The careful one offers it as a special to a few tables first, watches whether plates come back untouched, and only then rolls it out to everyone. Same food, wildly different risk. Rollout strategies are just formalized versions of "test it on a few tables first," and the three below sit on a spectrum from fast-and-risky to slow-and-safe.

Read the table as that spectrum. Rolling replaces instances a few at a time, so there's always something serving — simple and zero-downtime, but for a while old and new run side by side, which can bite if they're incompatible. Blue-green stands up the entire new version ("green") next to the old ("blue"), then flips all traffic in one instant — giving you an atomic switch and an equally instant rollback (flip back), at the cost of running two full copies briefly. Canary is the "few tables first" approach taken seriously: send 5% of traffic to the new version, watch your error metrics, then widen to 50% and 100% only if it stays healthy — the safest option, because a bad release hurts a sliver of users before you catch it, but the slowest to fully roll out.

The mistake isn't picking the "wrong" one — it's not picking deliberately, or doing a canary with no eyes on the metrics. A canary that ramps 5% → 100% without actually watching the error rate at each step is just a slow version of the reckless deploy; you've added the ceremony without the safety. The value is entirely in the gate: measure at each stage and abort automatically the moment things look wrong. The lab makes exactly that concrete — a canary that reads the error rate at every step and rolls back itself the instant it crosses a threshold, so the decision is data, not a human squinting at a dashboard at midnight.

fast / riskyslow / safe Rolling replace a few at a time · always serving Blue-green blue (old) green (new) stand up new in full, flip 100% at once Canary 5% → 50% → 100%, watch metrics each step zero-downtime; brief mixed versions instant switch + rollback; 2× resources safest; slowest to finish blast radius shrinks left → right — the gate is what watches each canary step Three ways to swap old for new, on a risk spectrum. Rolling replaces instances gradually, blue-green flips everything atomically, canary widens a small slice while watching errors — each trades speed for a smaller blast radius.
🗺️ How to read this diagram

Read it left → right along the top spectrum line: strategies go from fast-and-risky on the left to slow-and-safe on the right.

  • Rolling swaps instances a handful at a time (mixed green/amber/old boxes) — there's always something serving, but old and new run side by side for a while.
  • Blue-green stands up the entire new version (green) beside the old (blue), then flips all traffic in one instant — atomic switch and instant rollback, at the cost of running two full copies briefly.
  • Canary sends a thin green sliver (5%) first, then widens (50% → 100%) only while the metrics stay clean — the smallest blast radius, but the slowest to fully roll out.
  • The bottom line is the whole point: as you move right the blast radius shrinks, and the canary's safety lives entirely in the gate that watches each step.

In short: Rolling = gradual and simple; blue-green = atomic flip with a spare copy; canary = a watched sliver first — pick deliberately for the blast radius you can tolerate.

StrategyHowTrade-off
Rollingreplace instances graduallysimple; brief mixed versions
Blue-greenstand up new (green) fully, then switch trafficinstant switch + rollback; 2x resources briefly
Canarysend 5% → 50% → 100% to new versionsafest; catches issues on few users; slower
Python · a canary rollout with automatic abort (runs)
canary.pydef canary_rollout(steps, error_rate_at, threshold=0.02):
    """steps: list of traffic %; error_rate_at(pct)->observed error rate.
    Abort (roll back) if any stage exceeds the error threshold."""
    for pct in steps:
        err = error_rate_at(pct)
        print(f"  {pct:3}% traffic -> error rate {err:.1%}", end="")
        if err > threshold:
            print("  ABORT -> roll back"); return False
        print("  ok")
    print("  promoted to 100%"); return True

# a healthy release
print("release A:")
canary_rollout([5, 25, 50, 100], lambda p: 0.005)
# a release that spikes errors at 25%
print("release B:")
canary_rollout([5, 25, 50, 100], lambda p: 0.005 if p < 25 else 0.08)
release A:
    5% traffic -> error rate 0.5%  ok
   25% traffic -> error rate 0.5%  ok
   50% traffic -> error rate 0.5%  ok
  100% traffic -> error rate 0.5%  ok
  promoted to 100%
release B:
    5% traffic -> error rate 0.5%  ok
   25% traffic -> error rate 8.0%  ABORT -> roll back
▶ How this works

A canary rollout sends the new version to a small slice of users first (5%), watches for errors, then widens (25% → 50% → 100%) only if things stay healthy. If errors spike at any stage, it stops and rolls back — so a bad release hurts only a few users, not everyone.

  1. steps is the list of traffic percentages to try in order; error_rate_at(pct) is a stand-in that reports the observed error rate at each stage (in real life this comes from your monitoring).
  2. The for pct in steps: loop walks each stage in turn, checking the error rate before widening traffic further.
  3. if err > threshold: — if this stage's error rate exceeds the limit (0.02 = 2%), it prints ABORT, return False (stop and roll back), and never widens traffic.
  4. If every stage stays under the threshold, the loop finishes and it prints promoted to 100% and return True — the new version is fully live.

What the output means: release A holds 0.5% errors the whole way and is promoted to 100%. release B is fine at 5% but jumps to 8% at 25%, so it ABORTs — exactly the point of a canary.

Try this: Change release B's spike condition to trigger at 50% instead of 25% (0.08 if p >= 50 else 0.005) and watch it get further before aborting. Fewer users hit the bad version the earlier you catch it.

7 · Professional — readiness gate before promotion professional

"It's probably fine, ship it." Those four words, spoken by a tired engineer at 6pm on a Friday, have caused more outages than any bug. The problem isn't the engineer — it's that "ready to deploy?" was left as a judgment call, and human judgment is exactly what degrades when you're rushed, distracted, or optimistic. A readiness gate replaces that judgment with a checklist a machine runs the same way every single time, whether it's Tuesday morning or Friday night.

The right mental model is a pre-flight checklist. Pilots don't eyeball the plane and go "looks good" — no matter how experienced, they read a fixed list aloud: flaps, fuel, instruments, doors. Not because they're forgetful, but because the checklist doesn't get tired, doesn't get cocky, and doesn't skip the boring item that turns out to matter. A deploy-readiness gate is that checklist for shipping software: an objective, automated set of yes/no conditions that must all pass before a build is allowed near production. It turns "I think we're good" into "the gate says go," which is a very different — and far safer — statement.

What goes on the list is everything this chapter has taught, now enforced rather than hoped for: are the tests green? does the image pass policy (no critical vulnerabilities, small enough, non-root — the CD2 concerns)? does a real health endpoint exist so the platform can tell if you're alive? are there no secrets baked into the image? is the image pinned to a real version rather than :latest? Each is a binary check with no room for interpretation, and crucially the gate reports which checks failed — so a blocked deploy tells you exactly what to fix, not just "no."

The mistake teams make here is building a gate and then adding an override button they reach for constantly — "skip checks just this once." Every skipped gate is a promise to future-you that you'll pay for later, usually during the incident the gate would have prevented. The discipline is that the gate is the decision, not a suggestion: if it says no, you fix the blocker, you don't route around it. The lab models this precisely — a function that collects every blocker into a list and only returns "deploy" when that list is empty, which is exactly how CI systems make an automated, unemotional go/no-go call.

objective checks (each yes/no) tests green? image score ≥ 70? health endpoint? no baked-in secrets? tag pinned? the gateblockers empty? GO · deploylist is empty NO-GO · blocked+ reasons to fix A readiness gate replaces judgment with a checklist. Every check must pass; the gate emits GO only when the blocker list is empty, otherwise NO-GO plus the exact reasons — an automated, unemotional go/no-go call.
🗺️ How to read this diagram

Follow the arrows left → right: five checks feed one gate, and the gate branches to exactly one of two outcomes.

  • The column of blue boxes is the pre-flight checklist — tests green, image score ≥ 70, a health endpoint, no baked-in secrets, a pinned tag. Each is a binary yes/no with no room for interpretation.
  • They all flow into the purple gate, which asks one question: is the list of blockers empty?
  • The gate splits: green GO (deploy) only when every check passed, or red NO-GO (blocked) — and crucially it hands back which checks failed, so a blocked deploy tells you exactly what to fix.

In short: Turn "I think we're good" into "the gate says go" — deploy only when zero blockers remain, and get the reasons when it says no.

Python · a deploy-readiness gate (runs)
readiness.pydef ready_to_deploy(state):
    blockers = []
    if not state["tests_passed"]:     blockers.append("tests not green")
    if state["image_score"] < 70:     blockers.append(f"image score {state['image_score']} < 70")
    if not state["has_healthcheck"]:  blockers.append("no health endpoint")
    if state["secrets_in_image"]:     blockers.append("secrets baked into image")
    if not state["pinned_tag"]:       blockers.append("image not pinned to a version")
    return (not blockers), blockers

ok, why = ready_to_deploy(dict(tests_passed=True,image_score=95,has_healthcheck=True,secrets_in_image=False,pinned_tag=True))
print("deploy?", ok)
ok2, why2 = ready_to_deploy(dict(tests_passed=True,image_score=50,has_healthcheck=False,secrets_in_image=False,pinned_tag=True))
print("deploy?", ok2, "|", why2)
deploy? True
deploy? False | ['image score 50 < 70', 'no health endpoint']
▶ How this works

Before you promote a build to production, you want an objective checklist that says yes or no — no gut calls at 2am. This function collects every reason a build should be blocked; if the list is empty, it's safe to deploy.

  1. def ready_to_deploy(state): takes state, a dictionary describing the build (did tests pass? image quality score? health endpoint present? etc.).
  2. blockers = [] starts an empty list. Each if checks one rule and, when it fails, appends a human-readable reason — tests not green, image score under 70, no health endpoint, secrets baked in, or an unpinned image tag.
  3. return (not blockers), blockers hands back two things: True only when there are zero blockers (an empty list is falsy, so not blockers is True), plus the list of reasons so you can see why if it's blocked.

What the output means: The first call has a clean state, so it prints deploy? True. The second has a low image score and no health check, so it prints False followed by the exact list of blockers.

Try this: Flip one field to a bad value — e.g. set tests_passed=False in the first call — and watch a new reason appear in the blockers list. This is how CI decides go/no-go automatically.

8 · Professional — observability of a live service professional

The most dangerous state for a production service isn't "down" — it's "down and you don't know yet." A service can be quietly failing 5% of requests for hours while you sip coffee, blissfully unaware, because nothing is telling you. The users know. Twitter knows. Your dashboard, if you built one, knows. But if you didn't, the first you'll hear of it is an angry customer — the worst possible monitoring system. Observability is simply the practice of making your running service tell you how it's doing before anyone else has to.

Think of it as the dashboard in a car. You don't pop the hood at 70mph to check the engine — you glance at a handful of gauges that summarize everything you need: speed, fuel, temperature, warning lights. A well-instrumented service gives you the same at-a-glance health, and the industry has converged on four gauges worth watching, the "golden signals": latency (how slow are responses?), traffic (how much load?), errors (what fraction are failing?), and saturation (how full are CPU/memory/connections?). Master those four and you can spot almost any problem forming; ignore them and you're driving blind, hood down, hoping.

Raw gauges aren't enough on their own, though — you need to know what number counts as "too much," and that's what an SLO (Service Level Objective) gives you. An SLO is a stated promise like "99.9% of requests succeed." Its clever twin is the error budget: if 99.9% must succeed, then 0.1% are allowed to fail — and that 0.1% is a budget you get to spend. This reframes reliability from an impossible "zero errors ever" into an honest, measurable allowance. Over a million requests, a 99.9% SLO buys you a thousand failures; burn through more than that and you've broken your promise. The error budget turns a vague feeling of "are we reliable enough?" into arithmetic.

Why the budget framing matters — and the mistake it prevents — is that it settles the eternal fight between shipping features and keeping things stable. With budget left, you can afford to take risks: ship the ambitious release, run the canary, move fast. With the budget nearly spent, the same math tells you to slow down, freeze risky changes, and shore up reliability first. The mistake teams make without it is treating every alert as equally urgent and every error as a five-alarm fire — burning out on noise. The error budget tells you which failures you can absorb and which mean stop. The lab computes exactly this: given an SLO and the errors you've observed, how much of your budget is gone, and are you still within your promise?

four golden signals — the dashboard gauges latencyhow slow? traffichow much load? errorsfraction failing? saturationhow full? SLO 99.9% → 0.1% error budget 60% used budget left → ship, run canaries 150% — over budget budget spent → freeze risky changes Watch four gauges; spend an error budget. Latency, traffic, errors, and saturation summarize a service's health; an SLO turns "reliable enough?" into arithmetic — spare budget buys risk, a spent budget means slow down.
🗺️ How to read this diagram

Two panels: the left is what you watch, the right is how much failure you're allowed.

  • The four blue boxes on the left are the golden signals — latency, traffic, errors, saturation. Glance at these four gauges and you can spot almost any problem forming, like a car dashboard.
  • The right two bars are the error budget for a 99.9% SLO (0.1% of requests may fail). The top bar is amber-filled to ~60% — you're within budget, so you can afford to ship and run canaries.
  • The bottom bar is fully red at 150% — you've blown past the promise, and the payoff message is the cue: freeze risky changes and shore up reliability first.

In short: Latency/traffic/errors/saturation tell you how the service is doing; the error budget converts your SLO into a spendable allowance that decides whether to move fast or slow down.

Python · a simple SLO error-budget check (runs)
slo.pydef error_budget(slo_pct, window_requests, observed_errors):
    """SLO e.g. 99.9% success -> 0.1% error budget over the window."""
    allowed = window_requests * (1 - slo_pct/100)
    used_pct = 100 * observed_errors / allowed if allowed else 0
    return round(used_pct, 1), observed_errors <= allowed

used, ok = error_budget(slo_pct=99.9, window_requests=1_000_000, observed_errors=600)
print(f"error budget used: {used}% | within SLO: {ok}")   # 600 of 1000 allowed
used2, ok2 = error_budget(99.9, 1_000_000, 1500)
print(f"error budget used: {used2}% | within SLO: {ok2}")  # burned through
error budget used: 60.0% | within SLO: True
error budget used: 150.0% | within SLO: False
▶ How this works

An SLO (Service Level Objective) is a promise like "99.9% of requests succeed." The flip side is an error budget: the small share of failures you're allowed. This function measures how much of that budget you've spent so you know if you're still within your promise.

  1. allowed = window_requests * (1 - slo_pct/100) computes how many errors are permitted. A 99.9% SLO leaves 0.1% — so over 1,000,000 requests you may have up to 1,000 errors.
  2. used_pct = 100 * observed_errors / allowed is what fraction of that budget the real errors used. Over 100% means you've blown past the promise.
  3. It returns that percentage plus observed_errors <= allowed — True if you're still within the SLO, False if you've burned through the budget.

What the output means: 600 errors out of 1,000 allowed = 60.0% of budget used, still within SLO (True). 1,500 errors = 150.0% — over budget, so False.

Try this: These four numbers — latency, traffic, errors, saturation — are the signals you watch on a live service. When the error budget nears 100%, that's your cue to slow down risky releases.

9 · Tech-lead — safe rollout & instant rollback tech-lead

Two teams hit the identical production bug at the same minute. Team A spends the next ninety minutes writing a hotfix under pressure, testing it badly because everyone's panicking, and shipping a second bug on top of the first. Team B types one command, is back to the last good version in thirty seconds, and calmly fixes the real bug the next morning over coffee. Same bug, same severity — the only difference is that Team B's lead had made rollback the reflex. That difference is a leadership choice, and it's what this final section is about.

The instinct under fire is to fix forward — to frantically patch the broken thing while it's live. It feels productive, but it's usually the worst option, because you're writing your most consequential code in your least clear-headed state. The professional reflex is the opposite: roll back first, debug later. Get the system healthy by returning to a version you already know works, which removes the time pressure entirely, and then investigate the failure calmly with the fire out. Rolling back isn't admitting defeat; it's buying yourself the safety to think.

What makes that one-command rollback even possible is a property we've been building toward all chapter: immutable, version-tagged revisions. Because each deploy is a distinct, unchangeable image with its own pinned tag (§2), the previous good version still exists exactly as it was — nothing overwrote it. So "roll back" isn't a rebuild or a scramble; it's simply "point traffic at the revision that was live yesterday." Contrast this with the :latest anti-pattern from earlier: if every deploy overwrote the same tag, there'd be no previous version to return to. Immutability is what turns rollback from a project into a one-liner.

The mistake a lead must design out is leaving the rollback decision to a human in the moment. When someone has to notice the failure, decide it's bad enough, and choose to roll back, you've inserted human hesitation into your worst thirty seconds. The mature move is to make the decision automatic: define "healthy," and if a new revision fails the check, the system keeps the last good version live on its own — no page, no panic, no judgment call. That's what a lead really owns: not heroics during the incident, but the boring plumbing beforehand that makes incidents non-events. The lab models exactly this — a deployer that records every revision but only promotes a healthy one, automatically holding the last good version when a deploy goes bad.

immutable revision history (nothing overwrites the past) v1.0healthy v1.1healthy v1.2UNHEALTHY live pointer stays on v1.1 serving: v1.1 v1.2 recorded, not promoted Rollback is just not moving the live pointer. Every deploy is an immutable tagged revision; an unhealthy one (v1.2) is recorded in history but the live pointer stays on the last healthy version (v1.1) — automatic rollback, no scramble.
🗺️ How to read this diagram

Read the top row left → right as the deploy history, then look at where the live pointer ends up.

  • The two green boxes (v1.0, v1.1) are healthy deploys — each promoted in turn, each an immutable tagged image that never gets overwritten.
  • The red box (v1.2) is an unhealthy deploy. It's still recorded in history (the "recorded, not promoted" note), but the curved arrow shows the live pointer staying on v1.1 instead of advancing.
  • The purple box at the bottom is the payoff: serving: v1.1. Because v1.1 still exists exactly as it was, "roll back" is simply not moving the pointer — no rebuild, no hotfix under pressure.

In short: Immutable, version-tagged revisions turn rollback into "leave the live pointer on the last healthy one" — the system holds v1.1 automatically when v1.2 is bad.

Python · rollout manager with auto-rollback (runs)
rollout.pyclass Deployer:
    def __init__(self): self.revisions=[]; self.live=None
    def deploy(self, tag, healthy):
        self.revisions.append(tag)
        if healthy:
            self.live=tag; return f"deployed {tag} (now live)"
        return f"{tag} UNHEALTHY -> kept {self.live} live (auto-rollback)"

d = Deployer()
print(d.deploy("v1.0", True))
print(d.deploy("v1.1", True))
print(d.deploy("v1.2", False))     # bad deploy -> automatic rollback
print("serving:", d.live, "| history:", d.revisions)
deployed v1.0 (now live)
deployed v1.1 (now live)
v1.2 UNHEALTHY -> kept v1.1 live (auto-rollback)
serving: v1.1 | history: ['v1.0', 'v1.1', 'v1.2']
▶ How this works

This ties everything together: a tiny deployer that keeps a history of versions and only promotes a new one if it's healthy. If a deploy is unhealthy, it automatically keeps the last good version live — that's rollback, done for you instead of in a panic.

  1. class Deployer stores two things: revisions (a history list of every tag it tried) and live (the version currently serving traffic).
  2. deploy(self, tag, healthy) always records the tag in history. If healthy is True, it sets self.live = tag and reports the new version is live.
  3. If healthy is False, it does not change self.live — so the previous good version keeps serving. That is the automatic rollback.
  4. The calls deploy v1.0 and v1.1 (both healthy), then v1.2 unhealthy — so it stays on v1.1.

What the output means: You see v1.0 then v1.1 go live; v1.2 is UNHEALTHY so v1.1 stays live. The last line confirms serving: v1.1 even though all three tags are in the history.

Try this: Because images are immutable and version-tagged, rolling back is just "point traffic at the previous revision." A team that makes this automatic never scrambles to hotfix under pressure.

Rollback beats hotfix under pressureWhen prod breaks, the fastest safe move is roll back to the last good revision, then fix calmly. Immutable, version-tagged images make it a one-liner — a lead makes it the team's reflex, not a 2am scramble.

Exercise CD4.1 — Deploy with a full safety net

Context: Shipping to a live URL with a real safety net — readiness gate, canary, rollback — is what separates a demo deploy from a production one. Wiring these together on a free tier is the closest thing to the real job.

Your task: Push a versioned image and deploy it to a free-tier managed service with a dependency-checking /healthz and a runtime secret, then exercise the full safety net.

Requirements:

  • Push a pinned, versioned image and deploy it to a managed service
  • Serve a dependency-aware /healthz and inject a runtime secret (not baked in)
  • Wire ready_to_deploy as the gate before promotion
  • Simulate a canary with canary.py for both a healthy and a failing release
  • Prove automatic rollback with the Deployer model

💡 Hint: Reuse the chapter's building blocks together — the readiness gate decides go/no-go, the canary limits blast radius, and the deployer keeps the last good revision live.

🪜 Practice ladder beginner → industry

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

Exercise 1 · Tag & push a pinned imageBeginner

Context: Before any cloud can run your image, it has to live in a registry the cloud can pull from — and it needs a version you can identify. Relying on :latest in production means you can't tell what's running or roll back.

Your task: Give the shell commands to tag and push a local image to a registry with a pinned version, and explain why you must never rely on :latest in prod.

Requirements:

  • Build the image, then tag it with the full registry/name:VERSION path (e.g. ghcr.io/you/myapp:1.0)
  • Log in with a token piped via --password-stdin so it never lands in shell history
  • Push the pinned tag to the registry
  • Explain that a pinned version (or git SHA) makes a deploy reproducible and roll-back-able
  • Explain that with :latest you can't tell what's live or reliably return to a known-good image

💡 Hint: The version tag is the whole point — it's what lets you say exactly which image is running and repoint traffic to a prior one later.

Show solution

Build, tag with the full registry/name:VERSION path, log in with a token piped via stdin (so it never shows in history), then push.

docker build -t myapp .
docker tag myapp ghcr.io/you/myapp:1.0        # registry/name:VERSION
echo "$TOKEN" | docker login ghcr.io -u you --password-stdin
docker push ghcr.io/you/myapp:1.0

A pinned version (or the git SHA) is what makes a deploy reproducible and roll-back-able — with :latest you can't tell what's actually running or reliably return to a known-good image.

Exercise 2 · Deploy to a managed serviceIntermediate

Context: A managed container service pulls your image and runs it — no servers to manage — handing back a URL, TLS, and autoscaling. Config and secrets get injected at runtime, never baked into the image.

Your task: Deploy the pushed image to a managed service (Cloud Run) so it gets a public HTTPS URL, injecting a plain env var and a secret from the secret manager rather than baking them in.

Requirements:

  • Deploy the pinned image with gcloud run deploy, naming the region and container --port
  • Make the URL public with --allow-unauthenticated
  • Inject plain settings with --set-env-vars
  • Inject a credential from the secret manager with --set-secrets
  • Cap resources with --memory and --max-instances, and note the returned Service URL includes TLS + autoscaling

💡 Hint: Both env vars and secrets arrive as environment variables in the container, so the same image runs on your laptop and in prod — only the injected config differs.

Show solution

Managed services pull your image and run it — no servers to manage. Inject plain settings as env vars and credentials from the secret manager at runtime.

gcloud run deploy myapp \
  --image ghcr.io/you/myapp:1.0 \
  --region us-central1 --port 8000 \
  --allow-unauthenticated \
  --set-env-vars "ENV=prod,LOG_LEVEL=info" \
  --set-secrets "DATABASE_PASSWORD=db-password:latest" \
  --memory 512Mi --max-instances 10
# -> Service URL: https://myapp-xxxx.a.run.app  (TLS + autoscaling included)

Both become environment variables inside the container, so the same image runs on your laptop and in prod — only the injected config differs. The secret is never stored in the image or printed in logs. The model is identical across clouds; only the flags change.

Exercise 3 · A dependency-aware health checkAdvanced

Context: A health check that only reports 'process alive' will keep a broken instance in rotation while users hit failures. A real one verifies the dependencies the app needs and returns 503 when one is down so the platform reroutes and restarts.

Your task: Write a Python dependency-aware health check: given a map of dependency name to ok/down, return HTTP 200 only if all are ok, else 503 with a 'degraded' report.

Requirements:

  • Take a dict mapping each dependency name to True (ok) or False (down)
  • Be healthy only when every dependency is ok
  • Return status 200 when healthy, 503 when any dependency is down
  • Include a small report naming the status and which checks passed
  • Show that any single failed dependency fails the whole check

💡 Hint: all(deps.values()) is the healthy test; returning 503 is the signal a platform uses to stop routing traffic and replace the instance.

Show solution

A real health check verifies dependencies, not just "process alive." Returning 503 tells the platform to stop routing traffic and restart/replace the instance.

def health_check(deps):
    """deps: name -> is_ok. Healthy only if ALL dependencies are ok."""
    healthy = all(deps.values())
    status = 200 if healthy else 503          # 503 -> platform reroutes/restarts
    return status, {"status": "healthy" if healthy else "degraded",
                    "checks": deps}

print(health_check({"db": True,  "cache": True}))    # 200
print(health_check({"db": True,  "cache": False}))   # 503 - cache down
print(health_check({"db": False, "cache": True}))    # 503 - db down

Any single failed dependency fails the whole check. A liveness-only check would keep a broken instance in rotation because the process is technically running — users would still hit failures.

Exercise 4 · A canary rollout with abortExpert

Context: A canary sends a new version to a small slice of users first, watches for errors, and widens only if it stays healthy. If errors spike, it aborts — so a bad release hurts a few users, not everyone.

Your task: Implement a canary rollout in Python that widens traffic through stages (5% → 25% → 50% → 100%) but aborts and rolls back if any stage's error rate exceeds a threshold.

Requirements:

  • Take an ordered list of traffic percentages and a function giving the observed error rate at each
  • Check the error rate before widening traffic further
  • Abort and return failure the moment a stage exceeds the threshold (default 2%)
  • Promote to 100% only if every stage stays under the threshold
  • Demonstrate a healthy release promoting and a release that spikes at 25% aborting

💡 Hint: Walk the stages in order and return early on the first stage over threshold; the sooner you catch it, the fewer users saw the bad version.

Show solution

Send the new version to a small slice first, check the observed error rate, and only widen if it stays healthy. If it spikes, stop — a bad release hurts few users, not everyone.

def canary_rollout(steps, error_rate_at, threshold=0.02):
    """steps: traffic %; error_rate_at(pct)->observed error rate. Abort if a stage exceeds threshold."""
    for pct in steps:
        err = error_rate_at(pct)
        print(f"  {pct:3}% traffic -> error rate {err:.1%}", end="")
        if err > threshold:
            print("  ABORT -> roll back")
            return False
        print("  ok")
    print("  promoted to 100%")
    return True

print("release A:")
canary_rollout([5, 25, 50, 100], lambda p: 0.005)
print("release B:")
canary_rollout([5, 25, 50, 100], lambda p: 0.005 if p < 25 else 0.08)

Release A holds 0.5% and is promoted to 100%; release B is fine at 5% but jumps to 8% at 25%, so it aborts — exactly the point of a canary. Blue-green (instant switch + rollback, 2x resources) and rolling (gradual, brief mixed versions) are the other two strategies.

Exercise 5 · A deploy-readiness gateProfessional

Context: Promoting a build to prod at 2am should not be a judgment call. An objective readiness gate collects every reason a build should be blocked, so go/no-go is automatic.

Your task: Write a Python deploy-readiness gate that blocks promotion unless tests are green, image score ≥ 70 (from CD2), a health endpoint exists, no secrets are baked in, and the image tag is pinned.

Requirements:

  • Take a state dict describing the build (tests, image score, health endpoint, secrets, pinned tag)
  • Append a human-readable reason for each failing rule to a blockers list
  • Block when the image score is below 70 or a health endpoint is missing
  • Block on baked-in secrets or an unpinned image tag
  • Return a go/no-go boolean (true only when there are zero blockers) plus the reasons

💡 Hint: An empty list is falsy, so not blockers is true only when everything passes — collecting reasons is what lets CI report why it said no.

Show solution

Collect every blocking reason into a list; if it's empty, it's safe to deploy — objective, automated, no 2am judgment calls.

def ready_to_deploy(state):
    blockers = []
    if not state["tests_passed"]:     blockers.append("tests not green")
    if state["image_score"] < 70:     blockers.append(f"image score {state['image_score']} < 70")
    if not state["has_healthcheck"]:  blockers.append("no health endpoint")
    if state["secrets_in_image"]:     blockers.append("secrets baked into image")
    if not state["pinned_tag"]:       blockers.append("image not pinned to a version")
    return (not blockers), blockers

ok, why = ready_to_deploy(dict(tests_passed=True, image_score=95, has_healthcheck=True,
                               secrets_in_image=False, pinned_tag=True))
print("deploy?", ok)
ok2, why2 = ready_to_deploy(dict(tests_passed=True, image_score=50, has_healthcheck=False,
                                 secrets_in_image=False, pinned_tag=True))
print("deploy?", ok2, "|", why2)
# deploy? True   /   deploy? False | ['image score 50 < 70', 'no health endpoint']

An empty list is falsy, so not blockers is True only when everything passes. This is how CI decides go/no-go automatically.

Exercise 6 · Rollout manager with auto-rollbackIndustry scenario

Context: When a deploy is unhealthy, the fastest safe move is to keep the last good version live — rollback, not a panicked hotfix. Because images are immutable and version-tagged, a lead can make that automatic instead of a 2am scramble.

Your task: As a tech lead, model a rollout manager with automatic rollback: it records every version tried, but keeps the last healthy version live when a new deploy is unhealthy.

Requirements:

  • Keep a history list of every tag tried and a pointer to the currently-live tag
  • Always record the deployed tag in history
  • Promote to live only when the new deploy is healthy
  • On an unhealthy deploy, leave the live tag unchanged — that is the rollback
  • Demonstrate a bad deploy keeping the previous healthy version serving

💡 Hint: The rollback is simply not updating the live pointer on an unhealthy deploy; immutable tagged revisions make 'point traffic at the previous one' a one-liner.

Show solution

Track a history of revisions and the currently-live tag. Promote only healthy deploys; on an unhealthy one, leave live unchanged — that is rollback, done for you.

class Deployer:
    def __init__(self):
        self.revisions = []
        self.live = None
    def deploy(self, tag, healthy):
        self.revisions.append(tag)
        if healthy:
            self.live = tag
            return f"deployed {tag} (now live)"
        return f"{tag} UNHEALTHY -> kept {self.live} live (auto-rollback)"

d = Deployer()
print(d.deploy("v1.0", True))
print(d.deploy("v1.1", True))
print(d.deploy("v1.2", False))     # bad deploy -> automatic rollback
print("serving:", d.live, "| history:", d.revisions)
# serving: v1.1 | history: ['v1.0', 'v1.1', 'v1.2']

Because images are immutable and version-tagged, rolling back is just "point traffic at the previous revision." A team that makes this automatic never scrambles to hotfix under pressure — rollback beats hotfix.

✓ Checkpoint — you can move on when you can…

  • Push to a registry and run on a managed service.
  • Inject env/secrets; write a dependency-aware health check.
  • Choose rolling/blue-green/canary and run a canary with abort.
  • Gate on readiness, watch SLOs, and roll back instantly.

Knowledge check check yourself

✓ Knowledge check

Why does the chapter insist you pin a deploy to a specific version tag (or git SHA) instead of relying on :latest?

Show answer
Because a pinned tag makes the deploy reproducible and roll-back-able: you can always tell exactly which image is running and repoint traffic to a known-good prior revision. With :latest you can't identify what's live or reliably roll back.
✓ Knowledge check

A dependency-aware health check returns 503 when the database is unreachable rather than 200. What does the managed platform do in response, and why is that better than a check that only reports 'process alive'?

Show answer
On a 503 the platform stops routing traffic to that instance and restarts/replaces it. A liveness-only check would keep the broken instance in rotation because the process is technically running, so users would hit failures even though the app can't serve requests.
© 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