AI EngineeringZero to ProductionHome·About·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 essentialexpert 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.
▶ Runnable companionCode saved under code/cd4-deploy/. Python runs offline; configs are ready to use.

1 · The deploy path, end to end essential

Local image docker build push → registry GHCR/ECR managed service pulls 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

A registry stores images so any machine can pull them. Tag with the registry path + a version (never rely on :latest in prod — you can't tell what's running or roll back).

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

Managed container services pull your image and run it — no servers to manage. They give you a URL, TLS, and autoscaling. The model is identical across clouds; Cloud Run is the simplest to show.

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

Never bake config into the image (CD2). Inject it at runtime: plain settings as env vars, credentials from a secret manager (mounted at runtime, never in the image or logs).

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

A real health check verifies dependencies, not just "process alive." If the DB is unreachable, return 503 so the platform stops routing traffic and can restart/replace the instance.

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

How does new code replace old? Three strategies trade speed against safety:

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

Before promoting a build to prod, verify it's ready: tests green, image passes policy (CD2), health endpoint exists, no secrets baked in. Objective, automated, no judgment calls.

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

Once deployed, you must see it: the four signals — latency, traffic, errors, saturation — plus structured logs and alerts. You can't operate what you can't observe.

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 <= allowedTrue 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

A lead owns how the team ships safely: immutable tagged revisions and rollback as a one-liner. Model the decision so it's automatic, not a panic.

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