Containers and deployment
Package an app as an image, configure it with the environment, and roll out safely.
Create a free account Try part of it here
Why it matters
"It works on my machine" is the oldest excuse in software. Containers end it by packaging your code with the exact system, language version and libraries it needs, so it runs the same on your laptop, in CI and in production. Nearly every modern deployment platform runs containers. Knowing how to build a good image, and roll it out safely, is a basic professional skill.
What you'll be able to do
- Explain what a container image is, and how it differs from a virtual machine
- Write a Dockerfile that builds fast, runs as a normal user and keeps secrets out
- Configure an app from environment variables, and fail fast on bad settings
- Choose health checks and a rollout strategy: rolling, blue-green or canary
What it covers
- Images and containers
- Configuration from the environment
- Alive isn't the same as ready
- Rolling out
- A canary that decides for itself
- Reading settings safely
- Mistakes that come back to bite
Images and containers
Before shipping containers, every cargo was loaded differently. With a standard box, any crane, ship or truck can handle it, whatever's inside. Software containers do the same for your code.
An image is a read-only package: a filesystem with your code, its libraries and a command to run. A container is a running copy of an image: an ordinary process, isolated by the operating system so it sees only its own files, network and resources.
A virtual machine is a house with its own plumbing. A container is an apartment that shares the building's plumbing. Here, the shared part is the host's kernel, the core of the operating system, so a container starts in a second and uses little memory.
Images live in a registry (Docker Hub, GitHub's registry, a cloud registry). Each has a name and a tag, like shop-api:1.4.2. Tags can be moved; a digest (@sha256:...) can't. Pin base images to a version, never latest, so builds don't change under you.
A good Dockerfile
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN useradd --create-home appuser USER appuser EXPOSE 8000 CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
Each instruction makes a layer, and Docker reuses unchanged layers from the last build. So:
- Copy the dependency list and install first, then copy your code. Editing code then skips the slow install step.
- Use a slim base to keep the image small and its attack surface low.
- Run as a normal user, not root, so a bug in your app can't easily take over the container.
- Use the exec form of
CMD(a JSON list). The shell form wraps your app in a shell, which may not pass on the stop signal, so shutdowns get killed instead of finishing cleanly. - Keep secrets out. Anything in
ENVor copied files is readable by anyone with the image. Add a.dockerignorelisting.env,.gitand local data.
Multi-stage builds go further: compile or build in one stage, and copy only the results into a small final image.
Show the answer
Changing any file invalidates the COPY layer, so the install layer after it runs again
Copy requirements.txt and install first. Then code changes only rebuild the last layers.
Configuration from the environment
A touring band plays the same songs in every city, and only the venue changes. The same image should run in development, staging and production. What differs, like database addresses, keys and log levels, comes from environment variables. This is one of the twelve-factor app rules.
Read and check all settings at start-up. A missing database address should stop the app at once with a clear message, not crash on the first request at 3 a.m. Secrets come from the platform's secret store and arrive as environment variables or mounted files, never baked into the image.
Health checks and shutdown
Platforms ask your app two questions:
- Liveness (
/healthz): is the process alive? If not, restart it. - Readiness (
/readyz): can it serve traffic now? Database reachable, caches warmed? If not, send no traffic yet, but don't restart it.
When stopping a container, the platform sends SIGTERM, which works like last orders at a bar. Stop accepting new requests, finish the ones in progress, then exit. After a grace period it sends SIGKILL.
Alive isn't the same as ready
Mixing up the two checks causes real outages. If the database goes down and liveness depends on it, the platform restarts healthy servers, which fixes nothing.
def liveness(state): return 200 if state["process_ok"] else 500 def readiness(state): return 200 if state["process_ok"] and state["database_ok"] and not state["shutting_down"] else 503 scenarios = { "normal": {"process_ok": True, "database_ok": True, "shutting_down": False}, "database down": {"process_ok": True, "database_ok": False, "shutting_down": False}, "shutting down": {"process_ok": True, "database_ok": True, "shutting_down": True}, "deadlocked": {"process_ok": False, "database_ok": True, "shutting_down": False}, } for name, state in scenarios.items(): print(f"{name:<14} live {liveness(state)} ready {readiness(state)}")
When the database is down, the server stops taking traffic but stays running, ready for when it returns. Only a process that's really stuck gets restarted. And failing readiness first is how you shut down without dropping requests.
Rolling out
There's more than one way to swap in a new version. You can change a car's tires one at a time. You can set up a second stage, then move the audience across. Or you can do what miners did, and send a canary in first to see whether the air is safe:
| strategy | how | good for |
|---|---|---|
| rolling | replace servers a few at a time | the default; cheap |
| blue-green | start a full new copy, switch all traffic at once | instant switch and instant rollback |
| canary | send a small share of traffic, say 5%, to the new version first | catching problems with few users affected |
Whatever the strategy, rollback should be one action: deploy the previous image tag. Database changes need care, since old code must still work with the new schema during the rollout.
In plain words: expect some releases to go wrong, and make going back the easiest thing you can do.
Platforms range from simple (Railway, Render, Fly.io, Google Cloud Run: give them an image) to powerful (Kubernetes: you describe deployments, services and replicas, and it keeps them running).
A canary that decides for itself
Send a little traffic to the new version and compare it with the old. Allow for noise, because a tiny sample can look bad or good by luck.
def decide(old_errors, old_total, new_errors, new_total, min_requests=500, margin=2.0): if new_total < min_requests: return "wait: not enough traffic yet" old_rate, new_rate = old_errors / old_total, new_errors / new_total if new_rate > max(old_rate * margin, 0.001): return f"roll back: {new_rate:.2%} against {old_rate:.2%}" return f"promote: {new_rate:.2%} against {old_rate:.2%}" print(decide(50, 10000, 1, 40)) print(decide(50, 10000, 9, 600)) print(decide(50, 10000, 3, 600))
One error in 40 requests looks alarming, yet it says almost nothing, so the check waits. The second case really is three times worse, and it rolls back with only a few percent of users affected.
Whichever you pick, rollback should be one action.
Show the answer
Blue-green
Blue-green keeps the old copy ready, so rolling back is flipping traffic back.
Reading settings safely
Turn strings from the environment into typed values, with defaults.
env = {"PORT": "8080", "ENABLE_CACHE": "yes"}
port = int(env.get("PORT", "8000"))
cache = env.get("ENABLE_CACHE", "false").lower() in ("1", "true", "yes")
log_level = env.get("LOG_LEVEL", "info")
print(port, cache, log_level)
print(type(env["PORT"]).__name__, "->", type(port).__name__)Environment variables are always strings. Convert and check them in one place, at start-up.
env = {"ENABLE_CACHE": "False"}
print(bool(env["ENABLE_CACHE"]), env["ENABLE_CACHE"].lower() in ("1", "true", "yes"))Show the answer
True False
Any non-empty string is truthy, even "False". Parse settings explicitly, in one place, at start-up.
Mistakes that come back to bite
Don't use latest for base images. FROM python:latest gives you a different image from one week to the next, so a build that worked on Monday can fail on Thursday with no code change. Pin the version, such as python:3.14-slim.
Keep secrets out of ENV lines. Everything in a Dockerfile is saved in the image's layers, and anyone who can pull the image can read them. Pass secrets in at run time from your platform's secret store.
Don't restart because of the database. If liveness checks your database, an outage there makes the platform restart all your healthy servers, over and over. That's why database checks belong in readiness.
Read your configuration at startup. If a required setting is missing, the app should crash straight away with a clear message. Reading it on first use means the deploy looks healthy, and then it fails hours later on the first real request.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
How does a container differ from a virtual machine?