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.
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):
| Term | What it actually means |
|---|---|
| registry | where images live to be pulled (GHCR, ECR, Docker Hub). |
| managed service | runs your container for you (Cloud Run, ECS, App Runner). |
| revision | one immutable deployed version; rollback = repoint to a prior one. |
| health check | an endpoint the platform polls to know you're alive. |
| rollout | how 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.
code/cd4-deploy/. Python runs offline; configs are ready to use.1 · The deploy path, end to end essential
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).
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
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.
docker build -t myapp .builds the image from theDockerfilein the current folder (.) and names itmyapplocally.docker tag myapp ghcr.io/you/myapp:1.0gives it the full registry address:registry / your-name / image-name : version. The1.0is the version so you always know exactly what is running.docker loginproves who you are to the registry. The$TOKEN(a secret password) is piped in with--password-stdinso it never shows on screen or in your command history.docker push …:1.0uploads 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.
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)
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.
gcloud run deploy myappcreates/updates a service calledmyappon Google Cloud Run. The backslashes\just let one command span several lines.--image …:1.0is the exact image to pull from the registry.--regionpicks which data center, and--port 8000tells the service which port inside the container your app listens on.--allow-unauthenticatedmakes the URL public (anyone can reach it).--set-env-vars ENV=prodpasses a setting into the container at runtime.--memory 512Miand--max-instances 10cap 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.
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).
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
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.
--set-env-vars "ENV=prod,LOG_LEVEL=info"passes ordinary, non-secret settings as environment variables. Your app reads them withos.environat startup.--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.- 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.
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}})
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.
def health_check(deps):takesdeps— a dictionary mapping each dependency name toTrue(ok) orFalse(down), e.g.{"db": True, "cache": False}.all(deps.values())isTrueonly if every dependency is ok. If even one isFalse,healthybecomesFalse.status = 200 if healthy else 503picks 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.- 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:
| Strategy | How | Trade-off |
|---|---|---|
| Rolling | replace instances gradually | simple; brief mixed versions |
| Blue-green | stand up new (green) fully, then switch traffic | instant switch + rollback; 2x resources briefly |
| Canary | send 5% → 50% → 100% to new version | safest; catches issues on few users; slower |
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
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.
stepsis 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).- The
for pct in steps:loop walks each stage in turn, checking the error rate before widening traffic further. if err > threshold:— if this stage's error rate exceeds the limit (0.02= 2%), it printsABORT,return False(stop and roll back), and never widens traffic.- If every stage stays under the threshold, the loop finishes and it prints
promoted to 100%andreturn 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.
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']
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.
def ready_to_deploy(state):takesstate, a dictionary describing the build (did tests pass? image quality score? health endpoint present? etc.).blockers = []starts an empty list. Eachifchecks 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.return (not blockers), blockershands back two things:Trueonly when there are zero blockers (an empty list is falsy, sonot blockersisTrue), 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.
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
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.
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.used_pct = 100 * observed_errors / allowedis what fraction of that budget the real errors used. Over 100% means you've blown past the promise.- It returns that percentage plus
observed_errors <= allowed—Trueif you're still within the SLO,Falseif 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.
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']
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.
class Deployerstores two things:revisions(a history list of every tag it tried) andlive(the version currently serving traffic).deploy(self, tag, healthy)always records the tag in history. IfhealthyisTrue, it setsself.live = tagand reports the new version is live.- If
healthyisFalse, it does not changeself.live— so the previous good version keeps serving. That is the automatic rollback. - The calls deploy
v1.0andv1.1(both healthy), thenv1.2unhealthy — so it stays onv1.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.
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
/healthzand inject a runtime secret (not baked in) - Wire
ready_to_deployas the gate before promotion - Simulate a canary with
canary.pyfor both a healthy and a failing release - Prove automatic rollback with the
Deployermodel
💡 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.
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:VERSIONpath (e.g.ghcr.io/you/myapp:1.0) - Log in with a token piped via
--password-stdinso 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
:latestyou 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.
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
--memoryand--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.
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.
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.
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.
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
Why does the chapter insist you pin a deploy to a specific version tag (or git SHA) instead of relying on :latest?
Show answer
:latest you can't identify what's live or reliably roll back.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'?