Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~27 min· topic 9 of 10

Topic 15.9

Shipping Python: Docker and Deployment

In one line

To ship a Python service, you package the code and its exact dependencies into a container image, run it as a non-root user with its settings from environment variables, give it a health check, and let a platform start, restart and scale it.

Think of it like this

A lunch box. You do not carry the kitchen to work; you pack the meal, the cutlery and the napkin into a box that opens the same way anywhere. A container image is a lunch box for your program: your code, the right Python, and every library, sealed together, so it runs the same on your laptop, a teammate's laptop and a server. (A server here is just a computer that stays on and answers requests from other computers.)

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Image
A packaged, read-only snapshot containing your code, Python and libraries.
Container
A running copy of an image, isolated from other programs on the machine.
Dockerfile
The text recipe of steps that builds an image.
Layer
One step's result in an image. Unchanged layers are reused from a cache.
Slim image
A smaller base image with fewer extras, such as python:3.13-slim.
Lock file
A file recording the exact version of every dependency, so installs repeat exactly.
Non-root user
An ordinary account with limited rights, used to run the service.
Health check
A cheap request the platform makes to see if the service is working.
Serverless
A way to run code per request without managing servers.
Stateless
Keeping no important data inside the process, so any copy can be replaced.

Step by step

01From code to a running container

Your project folder plus a Dockerfile goes to docker build, which produces an image. docker run starts a container from it. A registry is an online store for images, so servers can pull (download) them.

From code to a running containerdiagram
Rendering diagram…

02Install dependencies before copying code

Docker caches each step. If you copy only the dependency files first and install, that expensive step is reused until the dependency list changes. Your code changes (copied afterwards) rebuild only the last, fast layers.

Dockerfilewhole filedockerfile
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY src ./src

03The whole Dockerfile

This is a typical shape for a FastAPI service. It copies the uv binary from its official image, installs only locked, non-development dependencies, creates an ordinary user, and starts the server as the main process. The code section shows the full file.

04Settings come in at run time

The image has no secrets. You pass them when starting the container: docker run -e LLM_API_KEY=... -p 8000:8000 todo-api. In Kubernetes or a cloud service you set them in the service configuration or reference a secret store.

terminalwhole filebash
docker build -t todo-api .
docker run --rm -p 8000:8000 -e LLM_API_KEY=fake-key-abcdef123456 todo-api
curl http://127.0.0.1:8000/health

05Health and structured logs

The runnable examples below show both ideas without a server: a function that returns a status code and a body from real checks, and a logger that writes one JSON object per line to stdout with extra fields. Log tools and cloud platforms parse such lines directly.

06Pick where it runs

A container platform is best when the service runs all the time or needs steady throughput. Serverless suits spiky, short requests. Your own virtual machine is simple but you patch and monitor it yourself.

Pick where it runsdiagram
Rendering diagram…

Try it yourself

  1. 1

    Build and run it yourself

    With Docker installed, put the todo API from Topic 15.5 into src/main.py with a pyproject.toml and uv.lock (create them with uv init and uv add fastapi uvicorn), add the Dockerfile above, then docker build -t todo-api . and docker run --rm -p 8000:8000 todo-api. Open /docs and /health.

  2. 2

    Change a log level

    In the logging example set os.environ["LOG_LEVEL"] = "DEBUG" before the logger is created, and see the debug line appear. Log level from the environment is how you turn on detail in production without a new build.

  3. 3

    Add a check

    Add a third check to health for a free-disk or queue condition of your own (a fake function returning True or False) and see the status code follow.

Code & diagrams

Dockerfile for a Python service with uvdockerfile

Not runnable here: a browser cannot build images. This follows the pattern in uv's own Docker guide; check the current uv and Docker documentation for flag details before copying.

FROM python:3.13-slim

# uv: a fast package installer, copied from its official image
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    UV_LINK_MODE=copy

WORKDIR /app

# 1) dependencies first: this layer is cached until the lock file changes
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

# 2) then the code
COPY src ./src
RUN uv sync --frozen --no-dev

# run as an ordinary user, not root
RUN useradd --create-home --uid 10001 app
USER app

ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"

# exec form: uvicorn is the main process and receives SIGTERM
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
.dockerignore keeps secrets and clutter out of the imagebash

Not runnable here. One pattern per line.

.git
.venv
__pycache__/
.env
*.pyc
tests/
Structured JSON logs to stdout New tab

A log is the running diary a service writes about what it does. One JSON object per line is easy for log tools to read. The DEBUG line is hidden because the level is INFO (Topic 7.9 teaches logging).

Sign in to run this example in your browser.

Expected output

{"level": "INFO", "logger": "service", "message": "service starting", "port": 8000}
{"level": "WARNING", "logger": "service", "message": "slow request", "ms": 840, "path": "/todos"}
A health check that tests real dependencies New tab

The function returns the status code and body a /health endpoint would send. Close the connection and watch it turn to 503.

Sign in to run this example in your browser.

Expected output

(200, {'status': 'ok', 'checks': {'database': True, 'config': True}})
(503, {'status': 'unavailable', 'checks': {'database': True, 'config': False}})
(503, {'status': 'unavailable', 'checks': {'database': False, 'config': True}})

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Copy the code first, then install

Put COPY . . before the dependency install.

Dockerfilewhole filedockerfile
COPY . .
RUN uv sync --frozen --no-dev
terminal
$ docker build -t todo-api .
── what you'll see ──
(no error: the build works, but every build re-installs every dependency)

Myth vs fact

Myth

A container is a small virtual machine.

Fact

A container shares the host's operating system kernel and only isolates processes, files and networking. It starts in a moment and is much lighter than a full virtual machine.

Myth

Putting the API key in the Dockerfile is fine, the image is private.

Fact

Layers keep every value, images get pushed and shared, and anyone who can pull the image can read it. Inject secrets at run time.

Myth

Running as root inside a container is harmless.

Fact

It makes any bug in your code or a library more dangerous, especially if a container setting is loose. Use a non-root user.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Multi-stage builds compile or install in one stage and copy only the finished virtual environment into a clean final stage, giving smaller images with no build tools. Pin the base image to a version or digest for reproducible builds, and scan images in CI for known vulnerabilities (see /devsecops).

  • ▸

    Native packages (some data and AI libraries) ship pre-built wheels for common platforms. On slim Debian images that usually works; on Alpine (musl) wheels are often missing and builds get slow, which is why slim is the usual Python base.

  • ▸

    Match worker count to the platform: one process per container is the container-friendly default, scaled by running more containers. If you must use several workers, remember each has its own memory (Topic 15.5).

  • ▸

    Details in the Dockerfile above that matter: --host 0.0.0.0 is needed because uvicorn's default 127.0.0.1 is the container's own loopback, which -p 8000:8000 cannot reach. The health check uses urllib because slim images have no curl. uv:latest is convenient for learning, but pin a version tag (or digest) in real builds so a new uv release cannot change your build. Copying the project in two steps means a code change reuses the cached dependency layer. docker run -e KEY is visible to docker inspect, so on a shared host use the platform's secret mechanism.

  • ▸

    A Docker HEALTHCHECK is only a Docker feature. Kubernetes ignores it and uses its own liveness probe (restart me if I am stuck) and readiness probe (do not send me traffic yet). Keep them different: readiness may check the database, but a liveness probe that checks the database makes every container restart when the database blips, turning a small outage into a big one. On shutdown, a platform sends SIGTERM and then SIGKILL after a grace period (30 seconds in Kubernetes by default), so finish or hand off in-flight work in time. With the exec-form CMD, uvicorn is PID 1 and gets the signal itself. docker run --init adds a tiny init process that also reaps child processes.

  • ▸

    Know the cold-start and size limits of serverless platforms before moving a service with heavy imports to one. Large AI libraries inflate start-up time and package size; calling a model API instead keeps the function light.

Remember this

  1. 1

    "It works on my machine" usually means the other machine has a different Python version or different library versions. A container (Docker is the best-known tool) runs your program inside an isolated environment built from an image. The image is built from a recipe called a Dockerfile: a list of steps, each making a layer. The Docker course on Hectal (/docker) teaches images, layers, networking and Compose in depth; this topic covers what is specific to Python. A container has its own numbered doors (ports, Topic 15.5), so to reach a service inside it you map one of the container's ports to a port on your own computer, written -p 8000:8000.

  2. 2

    A good Python image follows a few habits. Start from an official slim base image (python:3.13-slim), which is much smaller than the full one. Lock your dependencies (uv.lock, or pinned versions in requirements.txt) so a rebuild next month installs the same versions. Install dependencies before copying your code, so Docker's layer cache reuses the slow install step when only your code changed. Set PYTHONUNBUFFERED=1 so logs appear immediately, and PYTHONDONTWRITEBYTECODE=1 to avoid writing .pyc files.

  3. 3

    Run as a non-root user. By default, processes in a container run as root. If an attacker finds a bug in your service, root inside the container is a worse starting point. Create an ordinary user in the Dockerfile and switch to it with USER. Also keep secrets out of the image: they would be stored in its layers. Pass them at run time as environment variables (Topic 15.3), and use a .dockerignore file to keep .env, .git and local caches out of the build.

  4. 4

    A health check lets the platform know whether your service is working. Add a /health endpoint (Topic 15.5) that checks the things the service really needs, such as a database connection and required settings, and returns 200 when fine or 503 when not. Orchestrators (programs such as Kubernetes or a cloud service that start, stop and watch many containers for you) call it to decide whether to send traffic or restart the container. Keep it fast and make it check dependencies, not just return "ok" always. Write logs to standard output, one line per event, ideally as JSON; the platform collects them.

  5. 5

    The service must handle shutdown signals. When a platform stops a container it sends SIGTERM and waits a few seconds before killing it. Servers like uvicorn finish in-flight requests when they receive it. Run the server as the container's main process (the exec form of CMD, a JSON list), so it receives the signal. Keep the service stateless: store data in a database or cache, not in the container's disk or memory, so any copy can be started, stopped or replaced.

  6. 6

    Where does it run? A container platform (a managed container service, Kubernetes) keeps a number of copies running and restarts failures; a serverless function service runs your code per request and charges per use, with no server to manage but cold starts, time limits and size limits. For AI apps, the heavy models usually run behind a provider's API, so your service is a thin web layer that calls them with timeouts and retries (Topic 15.2). The CI/CD course (/cicd) shows how to build and deploy the image automatically on every merge.

Explain it without notes

01

Why copy pyproject.toml and the lock file before the source code in a Dockerfile?

02

Why run as a non-root user and keep secrets out of the image?

03

What is a health check and what should it check?

04

What does it mean for a service to be stateless, and why does that matter for containers?

Practice

01

Write a .dockerignore list that keeps .env, .git, virtual environments and caches out of the build.

02

Write a function ready(settings) that returns (503, {...}) unless settings has a non-empty api_key.

03

Write the docker run command that passes the key from your shell environment variable LLM_API_KEY and maps port 8000.

Trade-offs

  • ↔

    Container versus serverless. Containers give full control, long-lived connections and steady performance, but you size and pay for always-running capacity. Serverless scales to zero and needs no servers, but has start-up delay and limits.

  • ↔

    Slim versus full base images. Slim images are smaller and safer (fewer packages) but may lack tools your libraries need to build. Multi-stage builds give you both.

Done when you can

  • Done when you can read a Python Dockerfile and explain each instruction.

  • Done when you know why dependencies are installed before the code is copied.

  • Done when the image runs as a non-root user and contains no secrets.

  • Done when the service has a /health endpoint that checks real dependencies, and logs to stdout.

  • Done when you can choose between a container platform and serverless for a given workload.