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.
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.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY src ./src03The 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.
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/health05Health 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.
Try it yourself
- 1
Build and run it yourself
With Docker installed, put the todo API from Topic 15.5 into
src/main.pywith apyproject.tomlanduv.lock(create them withuv initanduv add fastapi uvicorn), add the Dockerfile above, thendocker build -t todo-api .anddocker run --rm -p 8000:8000 todo-api. Open/docsand/health. - 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
Add a check
Add a third check to
healthfor a free-disk or queue condition of your own (a fake function returning True or False) and see the status code follow.
Code & diagrams
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"]Not runnable here. One pattern per line.
.git
.venv
__pycache__/
.env
*.pyc
tests/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).
Expected output
{"level": "INFO", "logger": "service", "message": "service starting", "port": 8000}
{"level": "WARNING", "logger": "service", "message": "slow request", "ms": 840, "path": "/todos"}The function returns the status code and body a /health endpoint would send. Close the connection and watch it turn to 503.
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.
COPY . .
RUN uv sync --frozen --no-devMyth 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
slimDebian images that usually works; on Alpine (musl) wheels are often missing and builds get slow, which is whyslimis 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.0is needed because uvicorn's default127.0.0.1is the container's own loopback, which-p 8000:8000cannot reach. The health check usesurllibbecauseslimimages have nocurl.uv:latestis 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 KEYis visible todocker inspect, so on a shared host use the platform's secret mechanism. - ▸
A Docker
HEALTHCHECKis 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 sendsSIGTERMand thenSIGKILLafter a grace period (30 seconds in Kubernetes by default), so finish or hand off in-flight work in time. With the exec-formCMD, uvicorn is PID 1 and gets the signal itself.docker run --initadds 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
"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
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 inrequirements.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. SetPYTHONUNBUFFERED=1so logs appear immediately, andPYTHONDONTWRITEBYTECODE=1to avoid writing.pycfiles. - 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 withUSER. 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.dockerignorefile to keep.env,.gitand local caches out of the build. - 4
A health check lets the platform know whether your service is working. Add a
/healthendpoint (Topic 15.5) that checks the things the service really needs, such as a database connection and required settings, and returns200when fine or503when 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
The service must handle shutdown signals. When a platform stops a container it sends
SIGTERMand 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 ofCMD, 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
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
Why copy pyproject.toml and the lock file before the source code in a Dockerfile?
Why run as a non-root user and keep secrets out of the image?
What is a health check and what should it check?
What does it mean for a service to be stateless, and why does that matter for containers?
Practice
Write a .dockerignore list that keeps .env, .git, virtual environments and caches out of the build.
Write a function ready(settings) that returns (503, {...}) unless settings has a non-empty api_key.
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
/healthendpoint that checks real dependencies, and logs to stdout.Done when you can choose between a container platform and serverless for a given workload.