PythonMastery
intermediate 26 min read · lesson 1 of 4 in DevOps & Deploy

Docker for Python Apps

1 · The lesson

read

Examples 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:

bash
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:

dockerfile
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-slim is a Debian slim with Python 3.13. Everything you build sits on top.
  • WORKDIR — cd /app for 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-dir skips 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:

bash
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

TagSizeWhen to pick it
python:3.13~1 GBAvoid. Full Debian, mostly stuff you don't need.
python:3.13-slim~120 MBThe default pick. Debian slim, glibc, compatible with every wheel on PyPI.
python:3.13-alpine~50 MBSmallest. 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 MBDistroless — 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:

dockerfile
# GOOD — requirements rarely change, so the pip install layer stays cached
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
dockerfile
# 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:

text
.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.

dockerfile
# ---- 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:

dockerfile
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:

dockerfile
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.

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -fsS http://localhost:8000/health || exit 1

Pair 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

SizeVerdict
< 200 MBGreat. Aim here for most Python web apps.
200-500 MBAcceptable. ML apps with PyTorch live here.
500 MB - 1 GBSmell. Check for forgotten build tools or vendored caches.
> 1 GBSomething is wrong. Almost always a missed .dockerignore or a base-image mistake.

Inspect what's in your image:

bash
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:

yaml
# 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:

bash
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:

RegistryWhen
Docker HubPublic 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 ACRCloud-native deployments
bash
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:

bash
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:

TagMeaning
myapp:1.2.3A specific release. Immutable. The thing you deploy.
myapp:1.2Floating to the latest patch of the 1.2 minor — useful for "give me the latest bugfix."
myapp:git-abc1234The commit-SHA tag. Always pushed alongside the release tag. Lets you trace an image back to source.
myapp:latestA 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:

bash
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.

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:

dockerfile
# ---- 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
dockerfile
# 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:

text
.git
.venv
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
.env
.env.*
tests/
docs/
*.md
dist/
build/

Build and run:

bash
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>   # healthy

What this Dockerfile is doing well:

  • Multi-stage — poetry never ships in the runtime image.
  • Cached layers — pyproject.toml/poetry.lock change 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, so docker logs shows output live.
  • COPY --chown — files are owned by appuser from the start; no separate chown layer.

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-slim is 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.
  • .dockerignore keeps .git, .venv, node_modules, and .env out of the image. Mirror your .gitignore essentials.
  • 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.
  • HEALTHCHECK lets the orchestrator distinguish "process alive" from "app responding."
  • docker compose for multi-service local dev (Postgres, Redis, your app). Volume-mount source for live-reload.
  • Tag deliberately. Pin to immutable 1.2.3 or git-sha in production. latest is 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.