Index Ai Academy
Phase 11 · Professional practice · Shipping

Containers and deployment

Package an app as an image, configure it with the environment, and roll out safely.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Images and containers
  2. Configuration from the environment
  3. Alive isn't the same as ready
  4. Rolling out
  5. A canary that decides for itself
  6. Reading settings safely
  7. 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 ENV or copied files is readable by anyone with the image. Add a .dockerignore listing .env, .git and local data.

Multi-stage builds go further: compile or build in one stage, and copy only the results into a small final image.

Quick check
A Dockerfile copies all the code, then runs pip install. Every small code change makes builds take 4 minutes. Why?
  1. Docker never caches layers
  2. Changing any file invalidates the COPY layer, so the install layer after it runs again
  3. The base image is too small
  4. pip is slow on Mondays
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.

Worked example

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)}")
It runs right here in your browser, with nothing to install.

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:

strategyhowgood for
rollingreplace servers a few at a timethe default; cheap
blue-greenstart a full new copy, switch all traffic at onceinstant switch and instant rollback
canarysend a small share of traffic, say 5%, to the new version firstcatching 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).

Worked example

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))
In the lesson you can change this and run it.

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.

Try it now

Whichever you pick, rollback should be one action.

Quick check
You want to switch all traffic to a new version at once, with instant rollback, by running a full second copy. Which strategy is that?
  1. Recreate
  2. Blue-green
  3. Canary
  4. Rolling
Show the answer

Blue-green

Blue-green keeps the old copy ready, so rolling back is flipping traffic back.

Worked example

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__)
In the lesson you can change this and run it.

Environment variables are always strings. Convert and check them in one place, at start-up.

Quick check
Environment values are strings. What does this print?
env = {"ENABLE_CACHE": "False"}
print(bool(env["ENABLE_CACHE"]), env["ENABLE_CACHE"].lower() in ("1", "true", "yes"))
  1. False False
  2. True False
  3. True True
  4. False True
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

Create a free account

Try a quiz question

How does a container differ from a virtual machine?

  1. It shares the host's kernel, so it's lighter and starts faster
  2. It includes its own kernel, so it's more isolated
  3. It can only run on Windows, and needs a special host
  4. It can't use a network, unless it's run with extra rights

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson