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

Docker for Python Apps

1 · The lesson

read

The examples need Docker, a GitHub repository or a deployment target. They don't run in the browser. The commands are shown with the expected output in comments.

"It works on my machine" is one of the oldest problems in software. The most common solution is the container, which became popular when Docker was released in 2013. A container packages your code together with its runtime, libraries and operating system files into a single artifact. That artifact runs the same way on your laptop, your colleague's laptop, a CI runner, staging and production. You build it once and deploy the same thing everywhere.

This lesson is a practical introduction to Docker for Python developers: the minimum Dockerfile, layer caching, multi-stage builds, non-root users, compose for local development, and a tagging scheme that avoids common problems. By the end you'll have a Dockerfile that is ready for production.


1. The Mental Model

Three terms, in order:

  • Dockerfile: a recipe. A text file with build steps.
  • Image: a fixed artifact, built once from a Dockerfile. It is immutable.
  • Container: a running instance of an image. You can start it, remove it and start a new one. The image does not change.

This separation is the main idea. Source code lives in git, the image is the build output, and the container is the runtime. If a container has a problem, you stop it and start a new one from the same image. Any change made by hand inside the old container is gone, so every new container starts the same way.

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"]

Six instructions. Here is what each one does:

  • FROM: the base image. python:3.13-slim is a Debian slim with Python 3.13. Everything else is built on top of it.
  • WORKDIR: works like cd /app for every instruction after it. This keeps your files out of the root directory.
  • COPY requirements.txt .: copy only the requirements file first. Why? See Section 4.
  • RUN pip install ...: install dependencies. --no-cache-dir skips pip's wheel cache, which you don't need inside an image.
  • COPY . .: 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 a host port to a container port

-p 8000:8000 exposes the container's port 8000 on your host's 8000. --rm removes the container when it exits, so your docker ps -a list stays clean.


3. Choosing a Base Image

TagSizeWhen to pick it
python:3.13~1 GBAvoid. Full Debian, mostly things you don't need.
python:3.13-slim~120 MBThe default choice. Debian slim with glibc, so standard Linux wheels from PyPI work.
python:3.13-alpine~50 MBSmallest. Uses musl libc. Some packages with compiled code have no musl wheels, so pip must compile them from source, which is slow and often fails.
gcr.io/distroless/python3~50 MBDistroless: no shell, no package manager. More secure for production, but hard to debug.

Default to slim until you have a specific reason to switch. Alpine is smaller, but building scientific packages from source costs you more time than the space you save is worth.

Pin the minor version (3.13-slim), not just the major version (python:3 will move from 3.13 to 3.14 without warning soon after the new Python is released).


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 makes Docker redo the install layer
COPY . .
RUN pip install --no-cache-dir -r requirements.txt

The rule: anything that changes rarely goes near the top, and anything that changes every commit goes near the bottom. If you get this right, your local rebuilds take seconds instead of minutes.


5. .dockerignore: Like .gitignore, for Docker

By default, COPY . . copies your entire working directory into the image, including .git, __pycache__, .venv, node_modules and secrets in .env. All of this makes the image bigger, and some of it is 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 no longer risk putting .env into a published image by accident.


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 results into a "runtime" image. Everything else stays behind.

dockerfile
# ---- stage 1: builder ----
FROM python:3.13 AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry==1.8.3      # pinned: Poetry 2 moved `export` into a plugin
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: only 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. The production image stays small.


7. Don't Run as Root

By default, container processes run as UID 0 (root). If an attacker takes control of the process, they have root inside the container, and it is much easier for them to reach the host from there. 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

Many Dockerfiles are missing this change, and it is one of the most important security improvements you can make.


8. Health Checks

A HEALTHCHECK tells the container runtime how to check that the app is working, which is not the same as "the process is running." A stuck process that returns 500 errors to every request still counts as "running".

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=4)" || 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 a container or stop sending traffic to it.


9. Image Size: Targets and Warning Signs

SizeVerdict
< 200 MBGreat. Aim here for most Python web apps.
200-500 MBAcceptable for apps with heavier dependencies.
500 MB - 1 GBWarning sign. Check for forgotten build tools or caches.
> 1 GBSomething is wrong for a web app: almost always a missed .dockerignore or a base-image mistake. ML images with PyTorch are the exception: the default PyTorch wheel from PyPI is over 500 MB before it is even unpacked.

Inspect what's in your image:

bash
docker history myapp                             # the size of each layer
docker run --rm myapp du -sh /app /usr/local/lib/python*

For a detailed check, use dive, an interactive terminal tool that shows the wasted space in each layer.


10. docker compose: Multi-Service Local Development

Most real apps need a database and probably a cache. Starting Postgres, Redis and your app by hand every time is slow and repetitive. 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                           # code changes show up live 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 remove volumes (this deletes the data)

The volumes: ["./src:/app/src"] line makes development much easier: 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

Production servers can't use an image that is only on your laptop. Push it to a registry:

RegistryWhen
Docker HubPublic images, hobby projects
GitHub Container Registry (ghcr.io)If your code is on GitHub: free for public images, generous limits for private ones
AWS ECR / GCP Artifact Registry / Azure ACRCloud-native deployments
bash
docker login ghcr.io                             # needed only once
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 Works in Production

docker push myapp:latest is a common beginner mistake. latest is a moving pointer. If production pulls latest and you push a new one, production gets the new image without warning, and there is no easy way to roll back.

A tagging scheme that works:

TagMeaning
myapp:1.2.3A specific release. Immutable. The thing you deploy.
myapp:1.2Points to the latest patch of the 1.2 minor version. 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. You can't roll back latest, because it already points to the new image.


13. Diagnostics

Three commands to know:

bash
docker ps                                        # running containers
docker logs <container>                          # the output (stdout and 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". It is useful for questions like "is the env var really set?" or "can this container reach the database?". Distroless images have no shell on purpose, so debug them from a sidecar container instead.


Common Mistakes

1. Secrets in the Dockerfile.

dockerfile
ENV STRIPE_KEY=sk_live_AbCdEf...                 # saved in the image PERMANENTLY

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. There is no rollback. The deploy that ran fine yesterday may be a different image 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. The fix is two lines.

4. COPY . . before installing deps. Every code change invalidates the pip-install cache, so builds become much slower. 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. Without it, your .git directory, .venv and .env end up in the image. Five minutes spent on a .dockerignore file prevents this.

7. One giant image with everything. Build tools, test tools, app code and production code, all in one image. Use multi-stage and ship only the runtime layer.

8. No HEALTHCHECK. The orchestrator can't tell a stuck container from a healthy one. Restarts don't happen when they should.


🎯 Your Turn: A Production-Ready 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, with 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, then pip install them
# 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 the versions again, and you lose the exact versions you locked.
Hint 2: gunicorn invocation CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "src.app:app"]. This 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 dependencies 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 does well:

  • Multi-stage: poetry never ships in the runtime image.
  • Cached layers: pyproject.toml/poetry.lock change rarely, so 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: it uses Python's stdlib so the slim image doesn't need curl. (Adding curl would add about 8 MB and more possible security vulnerabilities, with no benefit.)
  • PYTHONUNBUFFERED=1: stdout is written immediately, so docker logs shows output live.
  • COPY --chown: files are owned by appuser from the start, so there is no separate chown layer.

Final image size: ~150-180 MB depending on your dependencies. That is the size to aim for.


What You Learned

  • Dockerfile → image → container. Recipe, fixed artefact, running instance. Keep the three terms clear.
  • python:3.13-slim is the right default base. Avoid :3.13 (too big), :alpine (musl compatibility problems) and :3 (unpinned).
  • Order Dockerfile instructions so slow-changing inputs (requirements) come before fast-changing ones (source code). With layer caching, rebuilds then take seconds, not minutes.
  • .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. It is a two-line fix and one of the biggest security improvements.
  • HEALTHCHECK lets the orchestrator distinguish "process alive" from "app responding."
  • docker compose for multi-service local dev (Postgres, Redis, your app). Mount your source code as a volume so the app reloads when you save.
  • Tag carefully. 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.

Found this useful? Share it with a friend who’s learning Python.