Docker for Python Apps
1 · The lesson
readExamples require Docker / a GitHub repo / a deployment target. Not browser-runnable. Commands shown with the expected output in comments.
"Works on my machine" is the oldest disease in software. The cure was invented in 2013 and is called a container. Containers freeze your code plus its runtime, libraries, and OS plumbing into a single artifact that runs identically on your laptop, your colleague's laptop, a CI runner, staging, and prod. One build, one binary-shaped thing, deployed everywhere.
This lesson is the practical Docker entry point for a Python engineer — the minimum Dockerfile, layer caching, multi-stage builds, non-root users, compose for local dev, and the tagging discipline that keeps you out of trouble. By the end you'll have a Dockerfile you'd be happy to ship.
1. The Mental Model
Three nouns, in order:
- Dockerfile — a recipe. A text file with build steps.
- Image — a frozen artifact. Built once from a Dockerfile. Immutable.
- Container — a running instance of an image. Spin up, tear down, spin up again. The image stays untouched.
That separation is the whole point. Source code lives in git; the image is the build output; the container is the runtime. If a container misbehaves, you kill it and start a new one from the same image — no drift, no "what did someone tweak on this server."
Install Docker Desktop on macOS/Windows or Docker Engine on Linux. Verify:
docker --version # Docker version 27.x docker run --rm hello-world # prints a confirmation message
2. The Minimum Dockerfile
For a Python app with a requirements.txt and a main.py entry point:
FROM python:3.13-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "main.py"]
Five instructions. Walking through them:
FROM— the base image.python:3.13-slimis a Debian slim with Python 3.13. Everything you build sits on top.WORKDIR—cd /appfor every subsequent instruction. Avoids littering the root filesystem.COPY requirements.txt .— copy only the requirements file first. Why? See Section 4.RUN pip install ...— install deps.--no-cache-dirskips pip's wheel cache, which you don't need inside an image.COPY . .— the rest of the code.CMD— the default command when a container starts.
Build and run:
docker build -t myapp . # tag the image "myapp" docker run --rm -p 8000:8000 myapp # -p maps host:container
-p 8000:8000 exposes the container's port 8000 on your host's 8000. --rm removes the container when it exits — keeps your docker ps -a list clean.
3. Choosing a Base Image
| Tag | Size | When to pick it |
|---|---|---|
python:3.13 | ~1 GB | Avoid. Full Debian, mostly stuff you don't need. |
python:3.13-slim | ~120 MB | The default pick. Debian slim, glibc, compatible with every wheel on PyPI. |
python:3.13-alpine | ~50 MB | Smallest. Musl libc — many compiled wheels (numpy, cryptography) don't ship Alpine wheels and have to compile from source, which is slow and fragile. |
gcr.io/distroless/python3 | ~50 MB | Distroless — no shell, no package manager. Hardened for production, painful to debug. |
Default to slim until you have a specific reason to switch. Alpine looks attractive for size but the rebuild-from-source pain on common scientific packages costs more than the bytes you save.
Pin the minor version (3.13-slim), not the major (python:3 will silently jump from 3.13 to 3.14 the day after Python releases).
4. Layer Caching — Order Matters
Every instruction creates a layer. Docker caches each layer by its inputs. When you rebuild, Docker reuses cached layers up to the first one whose inputs changed, then rebuilds everything after.
The slow step in a Python image is pip install. The fast-changing input is your application code. So you order the Dockerfile to keep the expensive layer cached:
# GOOD — requirements rarely change, so the pip install layer stays cached COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . .
# BAD — every code change invalidates the install layer COPY . . RUN pip install --no-cache-dir -r requirements.txt
The rule: anything that changes rarely goes near the top; anything that changes every commit goes near the bottom. Get this right and your local rebuilds drop from minutes to seconds.
5. .dockerignore — The Twin of .gitignore
By default COPY . . ships your entire working tree into the image — including .git, __pycache__, .venv, node_modules, secrets in .env. All bloat, much of it dangerous.
Create a .dockerignore at the repo root:
.git .gitignore .venv venv __pycache__ *.pyc .pytest_cache .mypy_cache .ruff_cache node_modules .env .env.* *.log dist build *.egg-info .DS_Store
Roughly the same content as .gitignore plus a few build artefacts. The image gets smaller, the build gets faster, and you stop accidentally baking .env into a published image.
6. Multi-Stage Builds — Separate Build from Runtime
Compiling a wheel needs gcc, build headers, and 200 MB of toolchain. Running the resulting code needs none of that. Multi-stage builds let you install in a "builder" image and copy only the artefacts into a "runtime" image — the bulk stays behind.
# ---- stage 1: builder ---- FROM python:3.13 AS builder WORKDIR /app RUN pip install --user poetry COPY pyproject.toml poetry.lock ./ RUN poetry export -f requirements.txt --without-hashes > /tmp/req.txt # ---- stage 2: runtime ---- FROM python:3.13-slim WORKDIR /app COPY --from=builder /tmp/req.txt . RUN pip install --no-cache-dir -r req.txt COPY src/ ./src CMD ["python", "-m", "src.main"]
The runtime image has no poetry, no build tools, no source archives — just the installed packages and your code. For a typical web app this drops the final image by 300-500 MB.
Multi-stage is also how you build C extensions: install gcc and libpq-dev in the builder, compile the wheel, copy only the compiled wheel into the slim runtime. Production stays lean.
7. Don't Run as Root
By default, container processes run as UID 0 (root). If a process gets compromised, the attacker has root inside the container, with a much shorter path to the host than you'd like. The fix is two lines:
RUN useradd -m -u 1000 appuser USER appuser
Place these after the package installs (those need root) and before the CMD. Everything from that point runs as appuser. If your app writes files at runtime, make sure the target directory is writable by UID 1000:
RUN mkdir -p /app/data && chown -R appuser:appuser /app/data
This single change is the most important security improvement most Dockerfiles are missing.
8. Health Checks
A HEALTHCHECK tells the container runtime how to verify the app is alive — separately from "the process is running." A wedged process that returns 500s to every request is still "running" by PID standards.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1Pair this with a real /health endpoint in your app (covered in environments). Orchestrators (Kubernetes, ECS, Docker Swarm) use the same probe to decide when to restart or stop routing traffic to a sick container.
9. Image Size — Targets and Smells
| Size | Verdict |
|---|---|
| < 200 MB | Great. Aim here for most Python web apps. |
| 200-500 MB | Acceptable. ML apps with PyTorch live here. |
| 500 MB - 1 GB | Smell. Check for forgotten build tools or vendored caches. |
| > 1 GB | Something is wrong. Almost always a missed .dockerignore or a base-image mistake. |
Inspect what's in your image:
docker history myapp # layer-by-layer sizes docker run --rm myapp du -sh /app /usr/local/lib/python*
For real auditing, use dive — an interactive TUI that shows wasted bytes per layer.
10. docker compose — Multi-Service Local Dev
Most real apps need a database and probably a cache. Spinning up Postgres + Redis + your app by hand is busywork. docker compose declares the whole stack in one file:
# docker-compose.yml
services:
app:
build: .
ports: ["8000:8000"]
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
REDIS_URL: redis://redis:6379/0
depends_on: [db, redis]
volumes:
- ./src:/app/src # live-reload during development
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
ports: ["5432:5432"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes:
pgdata:One command to bring everything up:
docker compose up --build docker compose down # stop and remove containers (volumes survive) docker compose down -v # also drop volumes (nuke data)
The volumes: ["./src:/app/src"] line is the development quality-of-life win — your local src/ is mounted into the container, so saving a file triggers your app's auto-reloader. The image stays untouched.
Services reach each other by service name — your Python code connects to db:5432 and redis:6379 because compose puts everything on a shared network and resolves the names automatically.
11. Registries — Pushing the Image Somewhere
The image on your laptop is no use to production. Push it to a registry:
| Registry | When |
|---|---|
| Docker Hub | Public images, hobby projects |
| GitHub Container Registry (ghcr.io) | If your code lives on GitHub — free for public, generous limits for private |
| AWS ECR / GCP Artifact Registry / Azure ACR | Cloud-native deployments |
docker login ghcr.io # one-time docker tag myapp ghcr.io/surya/myapp:1.2.3 docker push ghcr.io/surya/myapp:1.2.3
CI does this for you (see github-actions). Pulling from production:
docker pull ghcr.io/surya/myapp:1.2.3 docker run -d -p 8000:8000 ghcr.io/surya/myapp:1.2.3
12. Image Tagging — A Strategy That Survives Prod
docker push myapp:latest is the entry-level mistake. latest is a moving pointer — if production pulls latest and you push a new one, prod silently gets the new image with no rollback path.
A tagging scheme that works:
| Tag | Meaning |
|---|---|
myapp:1.2.3 | A specific release. Immutable. The thing you deploy. |
myapp:1.2 | Floating to the latest patch of the 1.2 minor — useful for "give me the latest bugfix." |
myapp:git-abc1234 | The commit-SHA tag. Always pushed alongside the release tag. Lets you trace an image back to source. |
myapp:latest | A floating "tip of main" alias. Useful for docker pull myapp:latest in dev. Never referenced in production deployment manifests. |
Production deployments should pin to a specific immutable tag (1.2.3 or the SHA). Rollback is then kubectl set image ...=myapp:1.2.2. Trying to roll back latest is impossible — the bytes have moved.
13. Diagnostics
Three commands to know:
docker ps # running containers docker logs <container> # stdout/stderr of a container docker exec -it <container> /bin/sh # a shell INSIDE a running container
docker exec is the closest thing to "ssh into the container" — handy for "is the env var actually set?" or "can this container even reach the database?" debugging. Distroless images don't have a shell, which is intentional — debug from a sidecar instead.
Common Mistakes
1. Secrets in the Dockerfile.
ENV STRIPE_KEY=sk_live_AbCdEf... # PERMANENTLY in the image
Anyone with docker pull can read it. Layers are cached and pushed; even deleting the ENV line in a later layer doesn't remove it from the image's history. Inject secrets at runtime via env vars or a secret manager — see secrets.
2. latest in production. No rollback. The deploy that ran fine yesterday is a different artefact today. Pin to immutable tags.
3. Running as root. Container escapes are rarer than they used to be, but a vulnerable Python library running as UID 0 inside a container has a much shorter path to the host than as UID 1000. Two lines to fix.
4. COPY . . before installing deps. Every code change busts the pip-install cache. Build times balloon. Order matters — see Section 4.
5. Not pinning the base image. FROM python:3 is "give me whatever Python 3 the registry has right now." That image changes weekly. Pin to 3.13-slim (or a digest, python:3.13-slim@sha256:..., for true reproducibility).
6. Skipping .dockerignore. Your .git directory in the image. .venv in the image. .env in the image. All preventable with five minutes of .dockerignore.
7. One giant image with everything. Build tools, test tools, app code, prod code — all in one. Use multi-stage and ship only the runtime layer.
8. No HEALTHCHECK. The orchestrator can't tell a wedged container from a healthy one. Restarts don't happen when they should.
🎯 Your Turn — A Production-Grade Dockerfile
Write a multi-stage Dockerfile for a Flask app that:
1. Uses python:3.13 as the builder stage to install poetry and export a requirements.txt.
2. Uses python:3.13-slim as the runtime stage.
3. Installs only the exported requirements at runtime — no poetry in the final image.
4. Copies only src/ into the runtime image (no tests, no docs, no caches).
5. Creates a non-root appuser (UID 1000) and switches to it before CMD.
6. Runs the app with gunicorn bound to port 8000.
7. Includes a HEALTHCHECK that hits http://localhost:8000/health.
Skeleton:
# ---- builder ---- FROM python:3.13 AS builder WORKDIR /app # TODO 1: install poetry and export requirements.txt # ---- runtime ---- FROM python:3.13-slim WORKDIR /app # TODO 2: copy requirements from builder; pip install # TODO 3: create non-root user # TODO 4: copy src/ # TODO 5: drop to appuser # TODO 6: EXPOSE + HEALTHCHECK + CMD gunicorn ...
Hint 1 — Exporting from poetry
poetry export -f requirements.txt --without-hashes --output /tmp/req.txt. Copy pyproject.toml and poetry.lock into the builder before exporting — without the lockfile, poetry resolves fresh, which defeats the point.
Hint 2 — gunicorn invocation
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "src.app:app"] — assumes a Flask app object in src/app.py. Bind to 0.0.0.0, not 127.0.0.1, or the host port mapping doesn't reach it.
Show full solution
# syntax=docker/dockerfile:1.7
# ---- builder ----
FROM python:3.13 AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry==1.8.3
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt --without-hashes --output /tmp/req.txt
# ---- runtime ----
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Install runtime deps from the exported requirements
COPY --from=builder /tmp/req.txt /tmp/req.txt
RUN pip install --no-cache-dir -r /tmp/req.txt && rm /tmp/req.txt
# Non-root user
RUN useradd --create-home --uid 1000 appuser
# Application code — owned by appuser so the process can read it
COPY --chown=appuser:appuser src/ ./src
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; \
sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status == 200 else 1)"
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "src.app:app"]Companion .dockerignore:
.git .venv __pycache__ *.pyc .pytest_cache .mypy_cache .ruff_cache .env .env.* tests/ docs/ *.md dist/ build/
Build and run:
docker build -t myflask:1.0.0 .
docker run --rm -p 8000:8000 myflask:1.0.0
# expected: gunicorn starts, /health returns 200, container stays HEALTHY
docker inspect --format '{{.State.Health.Status}}' <container> # healthyWhat this Dockerfile is doing well:
- Multi-stage — poetry never ships in the runtime image.
- Cached layers —
pyproject.toml/poetry.lockchange rarely; the export and install layers stay cached across code changes. - Non-root — the gunicorn process runs as
appuser, not root. - No bash dependency in HEALTHCHECK — uses Python's stdlib so the slim image doesn't need
curl. (If you add curl, you've added 8 MB and a CVE surface for no gain.) PYTHONUNBUFFERED=1— stdout flushes immediately, sodocker logsshows output live.COPY --chown— files are owned byappuserfrom the start; no separatechownlayer.
Final image size: ~150-180 MB depending on your dependencies. That's the target.
What You Learned
- Dockerfile → image → container. Recipe, frozen artefact, running instance — keep the three nouns straight.
python:3.13-slimis the right default base. Avoid:3.13(too big),:alpine(musl quirks),:3(unpinned).- Order Dockerfile instructions so slow-changing inputs (requirements) come before fast-changing ones (source code) — layer caching pays for itself in seconds-not-minutes rebuilds.
.dockerignorekeeps.git,.venv,node_modules, and.envout of the image. Mirror your.gitignoreessentials.- Multi-stage builds isolate build-only tools from the runtime image. Final image: 150-200 MB instead of 800.
USER appuser— never run as root. Two-line fix, biggest single security win.HEALTHCHECKlets the orchestrator distinguish "process alive" from "app responding."docker composefor multi-service local dev (Postgres, Redis, your app). Volume-mount source for live-reload.- Tag deliberately. Pin to immutable
1.2.3orgit-shain production.latestis a development convenience, never a deployment target.
Next: GitHub Actions for Python — the CI/CD pipeline that builds, tests, and pushes those Docker images on every commit.