Expert · Lesson 87 of 94

Deploying Python with Docker

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

"It works on my machine" ends where the machine stops being yours. A container packages your code, the exact Python, every dependency at its locked version, and the operating system libraries they need into one image that runs identically on a laptop, a CI runner, and a production server. Docker builds that image from a Dockerfile, a script of steps, and runs it as an isolated process with its own filesystem. For Python, this is the standard way code reaches production, whether on a single server, a container platform, or Kubernetes.

Deploying Python with Docker: source + pyproject.toml + uv.lock → image: OS libs + Python 3.14.7 + deps + your code → container: one isolated process, same everywhere → env vars, secrets, ports → logs collected by the platform
Deploying Python with Docker · Figure 1 · Open for full size

A good Python Dockerfile follows a fixed recipe. Start from a pinned official image, python:3.14.7-slim, small and exact. Copy the dependency files first and install them, so that layer is cached and rebuilding after a code change takes seconds. Then copy the source and install the project itself. Create a non-root user and switch to it, because a process that does not need root should not have it. Set the environment variables Python needs in containers, PYTHONUNBUFFERED=1 so logs appear immediately and PYTHONDONTWRITEBYTECODE=1 to skip cache files. Finish with the command that runs the service.

Dockerfile line Why
FROM python:3.14.7-slim exact interpreter, small image
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 logs stream immediately; no .pyc clutter
COPY pyproject.toml uv.lock ./ then install dependency layer cached until the lock changes
COPY src ./src then pip install . code layer rebuilt on every change, cheaply
RUN useradd -m app && USER app never run as root
EXPOSE 8000 documents the port the service listens on
HEALTHCHECK CMD python -c "..." the platform can tell healthy from stuck
CMD ["python", "server.py"] the one process the container exists to run

Configuration crosses into the container exactly as the configuration lesson taught: environment variables passed at docker run -e or from the platform, never baked into the image. Secrets likewise. Logs go to stdout and stderr, which the container runtime collects; a service in a container never writes log files. A health check, a command Docker runs periodically that exits zero when the service is fine, lets the platform restart a stuck container and route traffic only to healthy ones. docker compose describes a service plus its dependencies, such as a database, in one file for local development that mirrors production.

What changes between laptop and production is, after containers, mostly outside the process: the environment variables, the network the service can reach, the volume where data persists, and the number of replicas. Everything inside the image is identical, which is the point. The habits that make a container behave well are the ones this level has built: configuration from the environment, logging to stdout, a locked dependency set, a health endpoint, and a process that starts fast and shuts down cleanly on SIGTERM.

Deploying Python with Docker: laptop: docker run -e DATABASE_URL=sqlite:///dev.db -p 8000:8000 expenses → same image → production: platform sets DATABASE_URL, secrets, replicas=3, health checks → identical code, Python, and dependencies
Deploying Python with Docker · Figure 2 · Open for full size

Code

A minimal HTTP service for the expense library with a health endpoint, reading its configuration from the environment and logging to stdout. This is what the container will run; the second block starts it as a child process, checks /health, and stops it with SIGTERM the way a container platform would.

# server.py
import json
import logging
import os
import signal
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout)
log = logging.getLogger("expenses")
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///expenses.db")


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        log.info("%s %s", self.command, self.path)

    def do_GET(self):
        if self.path == "/health":
            body = json.dumps({"status": "ok", "database": DATABASE_URL.split(":")[0]}).encode()
            self.send_response(200)
        else:
            body = json.dumps({"error": "not found"}).encode()
            self.send_response(404)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


def main():
    port = int(os.environ.get("PORT", "8000"))
    server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
    # shutdown() must be called from another thread, or serve_forever deadlocks waiting for itself
    signal.signal(signal.SIGTERM, lambda *_: threading.Thread(target=server.shutdown).start())
    log.info("listening on :%d with %s", port, DATABASE_URL.split(":")[0])
    server.serve_forever()
    log.info("shut down cleanly")


if __name__ == "__main__":
    main()
# check_server.py
import os
import subprocess
import sys
import time
import urllib.request

import socket

with socket.socket() as s:                      # ask the OS for a free port so the demo never collides
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]

env = {**os.environ, "PORT": str(port), "DATABASE_URL": "postgres://db/expenses", "PYTHONUNBUFFERED": "1"}
proc = subprocess.Popen([sys.executable, "server.py"], env=env, stdout=subprocess.PIPE, text=True)
try:
    time.sleep(0.5)
    with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as r:
        print(r.status, r.read().decode())
    proc.terminate()                            # SIGTERM, exactly what `docker stop` sends
    stdout, _ = proc.communicate(timeout=5)
finally:
    if proc.poll() is None:
        proc.kill()
lines = stdout.splitlines()
print(len(lines), "log lines to stdout;", "listening" in lines[0], "|", lines[-1].split(" INFO ")[-1])
Output
200 {"status": "ok", "database": "postgres"}
3 log lines to stdout; True | shut down cleanly

The Dockerfile, following the recipe: pinned base, cached dependency layer, non-root user, health check, one command. Shown and validated, not built, because Docker is not part of this course's toolchain.

# Dockerfile
FROM python:3.14.7-slim

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

# dependency layer: only rebuilt when the lock changes
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv==0.8.13 && uv export --no-dev --frozen -o requirements.txt \
    && pip install --no-cache-dir -r requirements.txt

# code layer: rebuilt on every change, cheaply
COPY src ./src
COPY server.py ./
RUN pip install --no-cache-dir --no-deps .

RUN useradd --create-home app
USER app

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
    CMD python -c "import urllib.request, sys; \
    sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status == 200 else 1)"

CMD ["python", "server.py"]
# .dockerignore
.git
.venv
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
tests
.env

A lint of the Dockerfile against the recipe, so the rules are checked mechanically even without a Docker daemon.

# lint_dockerfile.py
import re
from pathlib import Path

text = Path("Dockerfile").read_text()
checks = {
    "pinned base image (no :latest)": re.search(r"^FROM python:\d+\.\d+\.\d+-slim$", text, re.M) is not None,
    "unbuffered output": "PYTHONUNBUFFERED=1" in text,
    "dependency files copied before source": text.index("COPY pyproject.toml uv.lock") < text.index("COPY src"),
    "non-root user": "USER app" in text and text.index("USER app") < text.index("CMD ["),
    "health check present": "HEALTHCHECK" in text,
    "exec-form CMD": re.search(r'^CMD \["', text, re.M) is not None,
    ".env excluded": ".env" in Path(".dockerignore").read_text().split(),
}
for name, ok in checks.items():
    print(f"{'ok ' if ok else 'BAD'} {name}")
print("all rules satisfied:", all(checks.values()))
Output
ok  pinned base image (no :latest)
ok  unbuffered output
ok  dependency files copied before source
ok  non-root user
ok  health check present
ok  exec-form CMD
ok  .env excluded
all rules satisfied: True

Build, run, and inspect commands, with a compose.yaml that adds a database and wires configuration. These are the commands you run once Docker is installed.

# docker_commands.sh
docker build -t expenses:0.2.0 .
docker run --rm -p 8000:8000 -e DATABASE_URL=sqlite:///data/expenses.db -e LOG_LEVEL=INFO expenses:0.2.0
curl -s http://127.0.0.1:8000/health
docker ps --format '{{.Names}} {{.Status}}'      # shows "(healthy)" once the HEALTHCHECK passes
docker logs <container>                          # the stdout/stderr your logging wrote
docker compose up --build                        # the service plus its database, from compose.yaml
# compose.yaml
services:
  api:
    build: .
    ports: ["8000:8000"]
    environment:
      DATABASE_URL: postgres://expenses:${DB_PASSWORD}@db:5432/expenses
      LOG_LEVEL: INFO
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:17.5
    environment:
      POSTGRES_USER: expenses
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes: ["pgdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U expenses"]
      interval: 10s
volumes:
  pgdata:

Try it yourself

Install Docker Desktop, then containerize the expense project: add server.py with a /health route and a /report route that returns the JSON report for a CSV path given by EXPENSES_FILE, write the Dockerfile and .dockerignore, build it, and run it with -e variables and a mounted volume for the CSV. Confirm docker ps shows (healthy), that docker logs shows your logging, and that stopping the container with docker stop produces the "shut down cleanly" line.

Common mistakes

What's next

Your project can ship. Next you will make sure it ships safely: the security mistakes Python code actually makes, and how to avoid each one.

Course overview