What you'll learn
- Explain what a container is and why production Python runs in one
- Write a small, correct Dockerfile for a Python project: pinned base image, locked dependencies, non-root user
- Run the container as a service with a health check, environment configuration, and logs to stdout
- Understand what changes between your laptop and production, and how the container removes most of it
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.

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.

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])
200 {"status": "ok", "database": "postgres"}
3 log lines to stdout; True | shut down cleanlyThe 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()))
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: TrueBuild, 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
FROM python:latest. The interpreter changes under you; pin the full version, and the-slimvariant unless you need build tools.- Copying the source before installing dependencies. Every code change then reinstalls everything; copy the lock first.
- Running as root, logging to files, or baking secrets into the image. Add a user, log to stdout, and pass secrets through the environment at run time.
What's next
- A container packages code, interpreter, and locked dependencies into an image that runs identically everywhere; configuration and secrets come from outside.
- The Dockerfile recipe: pinned slim base, dependency layer before code, non-root user, health check, exec-form
CMD, stdout logging. - Compose describes the service with its database for local runs that mirror production.
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.