CoursesDocker in depthRecipe: Python app

Recipe: Python app

Slim base, venv, non-root, pinned wheels.

Intermediate12 min · lesson 27 of 30

Two people pack for the same trip. One tips the whole wardrobe into the suitcase. The other lays out what they will actually wear. Python images split the same way. Start FROM python, run pip (Python's package installer) against the system Python, run as root, and you ship well over a gigabyte, including a C compiler you needed for about five minutes during the build. Pack the image on purpose and the same app lands slim, repeatable, and running as an ordinary user. The tool for that is a multi-stage build: the first stage does the messy install work, the final stage keeps only the parts that run. Flask, FastAPI, Django, a background worker, the shape is the same for all of them.

The app and what it needs

Start with something real to ship. Here is a small Flask service with a health endpoint, so you have something to curl once the container is up. Then pin every dependency to an exact version. Writing a bare flask in your requirements file is like writing "bread" on a shopping list: you get whatever is on the shelf that day, and two builds a week apart can pull different code. flask==3.0.3 names the exact loaf. Same one today, same one next year.

requirements.txt
flask==3.0.3
gunicorn==22.0.0
app.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/health")
def health():
return jsonify(status="ok", service="payments")

The Dockerfile

A virtualenv (short for virtual environment) sounds grander than it is. It is a folder holding installed packages plus a python symlink, and nothing else. It is the toolbox you fill in the workshop and then carry to the job site. Build it in the first stage, let pip download and unpack everything into it, then copy that single folder into a clean slim image. The downloads, the wheel cache, and any compiler toolchain that heavier packages drag along all stay behind in the stage you throw away. One thing crosses the line: the venv, with your dependencies ready to run.

Dockerfile
FROM python:3.12-slim AS build
WORKDIR /app
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install -r requirements.txt
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PATH="/venv/bin:$PATH"
RUN groupadd -r -g 10001 appuser && useradd -r -u 10001 -g appuser appuser
COPY --from=build /venv /venv
WORKDIR /app
COPY . .
USER appuser
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "2", "app:app"]

The order of those lines is doing real work. requirements.txt is copied and installed before the application code arrives, so Docker stores the whole pip install as its own layer. Edit a line in app.py and the rebuild reuses that layer instead of downloading Flask all over again. Swap the two lines around and every code change re-runs pip. That is the difference between a two-second rebuild and a two-minute one, forty times a day.

Two details in that CMD carry more weight than their length suggests. First, run gunicorn, not flask run. Flask's built-in server handles one request at a time and prints a warning asking you not to use it in production. It means that warning. Second, bind to -b 0.0.0.0:8000 rather than 127.0.0.1 (0.0.0.0 means every network interface inside the container; 127.0.0.1 means the container's own loopback and nothing else). A server on loopback is a phone extension that only rings inside the building. Nobody outside gets through, so your -p 8000:8000 publish hands requests to a door that never opens and the host sees connection refused. Bind 0.0.0.0 and the published port lands on the app. EXPOSE 8000 is documentation and nothing more; it records the port for whoever reads the image, opens nothing, and -p at run time is what actually publishes. The -w 2 starts two worker processes. For real traffic, size that to roughly two times your CPU cores plus one.

Slim, not alpine

Alpine is tempting because it is tiny. For Python it usually costs you. Alpine ships musl, a stripped-down version of the C library that programs call for basic system work, in place of the usual glibc (the GNU C library). Packages with compiled C extensions, numpy, pandas, psycopg, are built against glibc, so the ready-made downloads on PyPI (the Python Package Index, the site pip fetches from) do not fit. Wrong plug, wrong socket. pip falls back to compiling from source, which buys you long builds and the occasional runtime bug you get to debug at 2am. python:3.12-slim is Debian-based, uses glibc, and installs those prebuilt wheels (a wheel is a ready-to-install package archive), so it often ends up smaller in practice and far less trouble. Pin the minor version while you are here, so a surprise 3.13 does not turn up on your next rebuild. For builds you can truly repeat, generate a fully pinned, hashed requirements file with pip-tools or uv. pip then checks each downloaded file against its recorded hash before installing it, the Python version of a lockfile.

Build it and prove it responds

A recipe you cannot verify is a suggestion. Build the image, run it in the background with -d, and curl the health endpoint. If JSON comes back, the whole chain held: the venv copied over cleanly, gunicorn bound to an address the host can reach, and appuser can read the application files.

terminal
$ docker build -t payments-py:1.0 .
=> [build 7/7] RUN pip install -r requirements.txt 6.1s
=> [stage-1 4/9] COPY --from=build /venv /venv 0.4s
=> exporting to image 0.5s
=> => naming to docker.io/library/payments-py:1.0 0.0s
$ docker run -d --name payments -p 8000:8000 payments-py:1.0
7f3c9a1b8e2d4c6a0f1e5b9d3a7c2e84b1f0d9c8a7e6f5d4c3b2a1908f7e6d5c
$ curl -s localhost:8000/health
{"service":"payments","status":"ok"}

Now check the two things that make this image worth shipping. It runs as a real user with its own uid, and there is no C compiler left in the final layer for an intruder to borrow. Read the logs while you are in there; gunicorn should say it is listening on 0.0.0.0. Then run docker image ls payments-py and you will see a couple of hundred megabytes where a careless FROM python build would have handed you a gigabyte and change.

terminal
$ docker exec payments id
uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
$ docker exec payments sh -c 'which gcc || echo "no compiler in final image"'
no compiler in final image
$ docker logs payments
[2026-07-17 09:14:22 +0000] [1] [INFO] Starting gunicorn 22.0.0
[2026-07-17 09:14:22 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1)
[2026-07-17 09:14:22 +0000] [1] [INFO] Using worker: sync
[2026-07-17 09:14:22 +0000] [8] [INFO] Booting worker with pid: 8
[2026-07-17 09:14:22 +0000] [9] [INFO] Booting worker with pid: 9
COPY . . will sweep up your local venv
That COPY . . takes everything in the build context the way a shovel takes everything in front of it: a local .venv/, your .git/ history, __pycache__/, and any .env file holding secrets. A virtualenv built on your Mac or Windows laptop was compiled for the wrong architecture anyway, so it either bloats the image or breaks it outright. Write a .dockerignore listing .venv, venv, __pycache__, .git, and *.env before your first build. Skip that file and you ship megabytes of rubbish and, quite possibly, your credentials, baked into a layer that anyone who pulls the image can read back.
Multi-stage build to a verified response
1Build stage
python:3.12-slim, pip installs wheels into /venv
2COPY --from=build /venv
only the venv crosses to the final stage
3Final image
slim base, no C compiler, USER appuser (uid 10001)
4gunicorn :8000
bound to 0.0.0.0, two workers, logs flushing
5curl /health -> 200
{"status":"ok"} proves the chain works

What actually makes a Python image fat

Bloat arrives two ways. You copy the whole working directory in, and you install dependencies with no pins, so that layer changes on every build and the cache never gets a chance to help. A slim base plus a venv, or any single install prefix you can name, deals with both. It also sets you up for a distroless final stage later (distroless images carry your app and its runtime but no shell and no package manager), because everything the app needs already sits in one folder you can copy. The compiler that native wheels need belongs in the builder stage and nowhere else.

On the alpine question, you are trading musl friction against a slightly larger glibc image, and for anything in the scientific stack slim wins that argument quickly. PYTHONUNBUFFERED=1 earns its line because Python holds stdout in a buffer when it is not talking to a terminal, and buffered logs are logs you do not have during an incident. For a healthcheck, hit an HTTP route such as /health, or run a python -c import check. A container that answers is worth more than a container that is merely up.

In CI (continuous integration, the pipeline that builds and tests on every push), mount a pip cache with BuildKit (Docker's build engine) so downloads stay fast without leaving a cache inside the image, scan the finished image, and fail the build on critical CVEs (Common Vulnerabilities and Exposures, the public catalogue of known flaws) in the base or in your dependencies. Never pass a token through ARG. Build arguments land in the image history, and docker history will read them straight back to anyone who pulls.

When you put this image into a real environment, write down the digest of the image you replaced and the digest of the one you deployed, plus the machine that ran the build. Rolling back then means re-tagging one digest instead of guessing which commit produced the good build. Record the two signals you expect from a healthy container as well: id reporting uid 10001, and gunicorn logging Listening at: http://0.0.0.0:8000. The next person on call can then tell in ten seconds whether the container came up right.

Try this

Run these on a lab engine; Docker 24 or newer is fine. Read the sample output first, so you know what a healthy run looks like before you lean on any of it somewhere that matters.

terminal
$ docker build -t py-demo:1 .
$ docker run -d --name py-demo -p 8000:8000 py-demo:1
$ curl -s http://127.0.0.1:8000/healthz
ok
$ docker exec py-demo id
uid=1000 gid=1000
# STATUS: READY — non-root, healthz ok

Takeaway

If one line from this recipe sticks with you, make it COPY --from=build /venv /venv. That single line is what leaves pip, the wheel cache and the compiler in a stage that never ships. Everything around it is supporting cast: pinned versions so today's build matches next quarter's, uid 10001 so nothing runs as root, and unbuffered output so docker logs payments has something to show you when the pager goes off.

Quick check
01The image builds and the app is plainly serving traffic, but docker logs payments shows nothing at all. What fixes it?
Correct — Yes. When stdout is not a terminal, Python keeps output in a buffer, so gunicorn's lines sit in memory instead of reaching docker logs. PYTHONUNBUFFERED=1 flushes each line as it happens, which is why the recipe sets it.
Incorrect — No. The base image has nothing to say about buffering, and alpine adds compile-from-source pain for Python on top.
Incorrect — No. Root does not flush a buffer, and you would give up the non-root protection you built in.
Incorrect — No. Publishing a port changes what can reach the app, not whether its output turns up in the log.
02The recipe picks python:3.12-slim over an alpine base. What is the reason that applies to Python in particular?
Incorrect — Non-root users work fine on alpine, so that is not the problem.
Incorrect — Multi-stage builds work with any base image, alpine included.
Incorrect — The lesson makes a narrower claim: slim often ends up smaller in practice for Python, not smaller everywhere.
Correct — That is the one. musl in place of glibc means numpy, pandas or psycopg build themselves from source, costing you build time and the odd runtime surprise.
03A teammate edits the CMD so gunicorn binds 127.0.0.1:8000, then runs docker run -p 8000:8000 .... From the host, curl localhost:8000/health comes back connection refused. What happened?
Incorrect — How many workers you run has no bearing on which interface the server listens on.
Correct — That is it. 127.0.0.1 is the container's own loopback address, so bind 0.0.0.0 and the published port has something to deliver to.
Incorrect — EXPOSE is documentation. It opens nothing, and -p is the flag that publishes a port.
Incorrect — Ports from 1024 upwards need no privilege, so appuser binds 8000 without complaint.

Related