"It works on my machine" is almost always a dependency problem. Your laptop has Python 3.12 and a library pinned at 2.1. The server has Python 3.9 and whatever pip installed last Tuesday. The code is identical, the environment is not, and the code is what gets blamed.
Docker fixes that by shipping the environment with the code. You describe the interpreter version, the system packages, the Python dependencies, and the start command in one file, and Docker builds that description into an image. The image runs the same way everywhere, because it is the same thing everywhere.
This guide covers what the four Docker concepts actually mean, how to containerize a real Python API step by step, what each Dockerfile line does and why the order matters, how to run it with a database using Docker Compose, and the mistakes that turn a working image into a 1 GB security problem.
The Four Concepts You Need
Docker has a large vocabulary and you need very little of it to start.
Image
A read-only package containing a filesystem and the instructions to start a process. It is built once and does not change. Think of it as a class.
Container
A running instance of an image, with its own filesystem layer on top. It can be started, stopped, and thrown away. Think of it as an object.
Registry
A place images are stored and shared. Docker Hub is the public default, and every cloud provider offers a private one.
Dockerfile
The text file that describes how to build the image. This is the file you actually write, and the rest of this guide is mostly about getting it right.
Check that Docker is installed and running before going further:
docker --version
docker run hello-world
If the second command prints a welcome message, the daemon is running and you are ready.
Containerizing a Python Application
We will containerize a small FastAPI service, because an API is the case most backend developers actually need. The same Dockerfile shape works for a script, a worker, or a Flask app with one line changed.
The Application
Create a project folder with a file called main.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/items/{item_id}")
def read_item(item_id: int):
return {"item_id": item_id, "name": f"Item {item_id}"}
Pin the Dependencies
Create requirements.txt next to it, with exact versions rather than ranges:
fastapi==0.115.6
uvicorn==0.34.0
Pinning is what makes the build reproducible. fastapi>=0.115 builds a different image next month, which defeats the point of using Docker at all.
Exclude What Should Not Be Copied
Create a .dockerignore file. This one is skipped more often than any other file in this guide, and skipping it is why images end up enormous:
__pycache__/
*.pyc
.venv/
venv/
.git/
.gitignore
.env
tests/
*.md
Everything listed there is excluded from the build context, which means it is not sent to the Docker daemon and cannot end up inside the image. Note .env and .git specifically: without this file, your secrets and your entire commit history get baked into an image you might push to a public registry.
Write the Dockerfile
Create a file named Dockerfile with no extension:
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Every line in there is doing something specific.
FROM python:3.12-slim picks the base image. The slim variant drops build tools and documentation the runtime does not need, which cuts the image size substantially compared with the default python:3.12 tag. Use a specific minor version, never python:latest, or your image changes underneath you.
PYTHONDONTWRITEBYTECODE=1 stops Python writing .pyc files into the image, which are useless in a container that gets rebuilt. PYTHONUNBUFFERED=1 makes Python flush output immediately, so your logs actually appear in docker logs instead of sitting in a buffer.
WORKDIR /app sets the working directory for every command after it, so you do not need absolute paths.
The two-step copy is the most important thing on the page. COPY requirements.txt . followed by RUN pip install and only then COPY . . means Docker caches the installed dependencies as their own layer. Change a line of application code and Docker reuses the cached dependency layer, so the rebuild takes seconds. Copy everything first and every code change reinstalls every dependency from scratch.
--no-cache-dir tells pip not to keep its download cache, which would otherwise sit in the image doing nothing.
useradd and USER appuser stop the container running as root. Containers default to root, and a process that does not need root privileges should not have them.
EXPOSE 8000 documents the port. It does not publish it; the -p flag at run time does that.
CMD uses the list form rather than a string, so the process runs directly instead of under a shell, which means it receives stop signals correctly and shuts down cleanly.
Build and Run
docker build -t python-api:1.0 .
docker run -p 8000:8000 python-api:1.0
The -t flag tags the image with a name and version. The . at the end is the build context, meaning the current directory. -p 8000:8000 maps port 8000 on your machine to port 8000 in the container.
Visit http://localhost:8000/health and you should get {"status":"ok"}. Stop it with Ctrl+C.
To run it in the background and read its logs:
docker run -d --name api -p 8000:8000 python-api:1.0
docker logs -f api
docker stop api
Passing Configuration In
Never bake configuration into an image. The same image should run in staging and production with different settings, which means settings arrive at run time.
Read them in Python from the environment:
import os
DATABASE_URL = os.environ["DATABASE_URL"]
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
Using os.environ["..."] for required values is deliberate: the container fails immediately at startup if the variable is missing, rather than running in a broken state.
Then pass them at run time:
docker run -p 8000:8000 \
-e DATABASE_URL="postgresql://user:pass@db:5432/app" \
-e LOG_LEVEL="DEBUG" \
python-api:1.0
For anything more than two variables, use a file:
docker run -p 8000:8000 --env-file .env python-api:1.0
The .env file stays out of the image because .dockerignore excludes it, and out of version control because .gitignore excludes it.
Running a Database Alongside It
A Python API on its own is rarely the whole system. Docker Compose describes several containers and the network between them in one file.
Create compose.yaml:
services:
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://app:secret@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 5
volumes:
pgdata:
Start both with one command:
docker compose up --build
Three things to notice. The API reaches the database at the hostname db, which is the service name, because Compose puts both containers on the same network. The named volume pgdata means your data survives docker compose down. And condition: service_healthy combined with the healthcheck block stops the API starting before Postgres can accept connections, which is the fix for the "connection refused on first boot" problem that depends_on alone does not solve.
Developing Inside a Container
Rebuilding an image on every code change is too slow to work in. Mount your source directory instead so the container sees your edits immediately:
services:
api:
build: .
ports:
- "8000:8000"
volumes:
- .:/app
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
The --reload flag restarts the server when a file changes. Use this in a development override file only. Never mount source code or enable reload in production, because the image stops being the thing you tested.
Four Mistakes That Break Python Images
These four account for most of the problems people hit after their first working build.
1. Copying Everything Before Installing Dependencies
COPY . . placed above RUN pip install throws away Docker's layer cache on every single code change. A 4-second rebuild becomes a 90-second one, and you feel it 50 times a day. Copy requirements.txt first, install, then copy the rest.
2. Using the Full Base Image
python:3.12 carries a complete build toolchain your running app does not use. python:3.12-slim is the sensible default. If a dependency needs a C compiler to build, use a multi-stage build so the compiler exists during the build and is absent from the final image:
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
The first stage compiles, the second stage only receives the finished wheels. The compiler never ships.
3. Running as Root
The default user in a container is root. If an attacker gets code execution inside your API, root inside the container is a much better starting position than an unprivileged user. Two lines fix it, and they are in the Dockerfile above.
4. Binding to 127.0.0.1
This one produces a container that starts cleanly and refuses every connection. Inside a container, 127.0.0.1 means the container itself, so nothing from outside can reach it. Bind to 0.0.0.0 and let the -p flag control what is actually exposed.
The Commands Worth Memorizing
docker images # images on this machine
docker ps # running containers
docker ps -a # all containers, including stopped
docker logs -f <name> # follow a container's output
docker exec -it <name> bash # open a shell inside a running container
docker stop <name> # stop it, with a grace period
docker rm <name> # delete a stopped container
docker rmi <image> # delete an image
docker system prune # reclaim disk from unused objects
docker exec -it <name> bash is the one that saves the most time. When a container behaves differently from your laptop, open a shell inside it and look at what is actually there.
Where to Go Next
Docker is the same tool whatever language sits inside the image, so most of what you learned here transfers directly. Our definitive guide to Docker covers containerization from first principles including networking and volumes in more depth. For the same exercise in another stack, see dockerizing a JavaScript application and Spring Boot with Docker.
Once your app and database are running in containers, caching is usually the next service you add. Spring Boot Redis Cache: The Complete Guide With Docker works through adding Redis to a containerized stack, and the Compose patterns in it apply unchanged to a Python API.
Frequently Asked Questions
Which Python base image should I use?
python:3.12-slim, pinned to a specific minor version. It excludes build tooling the runtime does not need while keeping full pip compatibility. Use the full python:3.12 image only as the first stage of a multi-stage build, when a dependency has to be compiled.
Why is my Docker build so slow every time I change one line of code?
Your COPY . . line almost certainly sits above RUN pip install. Any change to any file invalidates that layer and every layer after it, so dependencies reinstall from scratch. Copy requirements.txt and install first, then copy the application code.
Why can I not reach my Python app in the browser even though the container is running?
Two usual causes. The app is bound to 127.0.0.1 instead of 0.0.0.0, so it only accepts connections from inside the container. Or you did not publish the port: EXPOSE in the Dockerfile is documentation, and -p 8000:8000 at run time is what actually maps it.
Do I still need a virtual environment inside Docker?
Not for isolation, since the container is already isolated. Installing into the system interpreter inside the image is normal and keeps the Dockerfile simpler. Multi-stage builds achieve the same separation more effectively.
How do I get my Python code changes without rebuilding the image?
Mount the source directory as a volume and run your server with a reload flag. Keep this in a development-only Compose file. In production the image must contain the code, or you are no longer running what you tested.
Should secrets go in the Dockerfile?
No. Anything written into a Dockerfile stays in the image layers and can be read back by anyone who pulls it, even if a later layer deletes it. Pass secrets at run time with -e, --env-file, or your orchestrator's secret store, and put .env in .dockerignore.
Summary
Docker solves the Python dependency problem by shipping the interpreter and the libraries with the code, so the thing you tested is the thing that runs. A correct Python Dockerfile is short: a slim pinned base image, two environment variables for sane logging, dependencies copied and installed before the application code so the cache works, a non-root user, and a CMD in list form binding to 0.0.0.0.
Add a .dockerignore before your first build, keep configuration in environment variables rather than the image, and use Compose with a health check when a database is involved. The four mistakes worth checking for are copying code before installing dependencies, using the full base image where slim would do, running as root, and binding to 127.0.0.1.



