Dockerize Your Python App: A Step-by-Step, Tested Walkthrough

The Docker for beginners guide explains the concepts. This one is different: we take a real Python API and put it in a container, end to end, and I show you the actual terminal output at each step. If you learn by doing, start here.

1. The App We Will Containerize

A tiny FastAPI service. Save it as main.py:

from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
def health():
    return {"status": "healthy"}

@app.get("/items")
def items():
    return [{"id": 1, "name": "Widget"}, {"id": 2, "name": "Gadget"}]

And the dependency file requirements.txt:

fastapi==0.111.0
uvicorn==0.30.1

2. Writing the Dockerfile

The Dockerfile is the recipe for your image. This version uses layer caching deliberately — dependencies are installed before the code is copied, so changing your code does not trigger a reinstall:

Editor showing a Python Dockerfile with FROM, WORKDIR, COPY, RUN pip install, EXPOSE and CMD
The Dockerfile: install dependencies first, then copy code, for better layer caching.

Each instruction:

LinePurpose
FROM python:3.12-slimA small Debian-based Python image (~50 MB vs ~900 MB for full)
WORKDIR /appAll following commands run here
COPY requirements.txt .Copy just the deps file first
RUN pip install ...Install into the image layer
COPY . .Copy the rest of your code
EXPOSE 8000Documents the port (does not publish it)
CMD ["uvicorn", ...]Runs the server when the container starts

3. Add a .dockerignore

Without it, COPY . . pulls in .venv, __pycache__, and .git — bloating the build context. Create .dockerignore:

__pycache__
*.pyc
.venv
.git
.env
Dockerfile
.dockerignore

4. Build the Image

From the project folder, tag the image demo-api:

docker build -t demo-api .

The output shows each instruction running as a layer. On a second build, unchanged layers are reused instantly:

Terminal showing docker build output with layered steps
docker build output. Cached layers are reused on repeat builds.

5. Run the Container

Map host port 8000 to the container's 8000, and run in the background:

docker run -d --name demo -p 8000:8000 demo-api

The -p 8000:8000 part is what makes the API reachable from your browser. Verify it is up:

docker ps
curl -s localhost:8000/health
Terminal showing docker ps and a curl health check
docker ps confirms the container is running; curl confirms the API responds.

6. Passing Configuration

Real apps need environment variables. Pass them with -e:

docker run -d --name demo \
  -p 8000:8000 \
  -e ENVIRONMENT=production \
  -e LOG_LEVEL=warning \
  demo-api

Read them in Python with os.environ["ENVIRONMENT"]. Never bake secrets into the image — inject them at runtime.

7. Pitfalls I Have Actually Hit

  • Running as root. Add RUN useradd -m appuser && USER appuser for production. Containers running as root are a security risk.
  • Missing --no-cache-dir. Without it, pip caches wheels inside the image, adding megabytes. Keep it.
  • Port not published. If curl localhost:8000 hangs, you forgot -p. EXPOSE alone does not publish.
  • Bind mounting over your code. A volume mount like -v $PWD:/app overwrites the image's code with your local files — great for dev, confusing in prod.

8. When to Reach for Docker Compose

One container is fine. When you need the API plus a database plus a cache, a docker-compose.yml defines them as one unit. That is a topic for the beginners guide's Compose section — the patterns here (Dockerfile, build, run) are the foundation.

Frequently Asked Questions

Why python:3.12-slim and not just python:3.12?
The slim variant drops build tooling and docs, cutting the image from roughly 900 MB to ~50 MB. You rarely need the full image for a web service.

How do I rebuild after code changes?
docker build -t demo-api . again, then docker rm -f demo && docker run ... to replace the running container. With the layer cache, only the COPY . . layer rebuilds.

The container exits immediately after starting. Why?
Usually the CMD process ended (or crashed). Run without -d to see the logs, or docker logs demo to read them after the fact.

You now have a reproducible, portable Python service. Ship the image to any Docker host and it runs identically. That is the whole point of containers.