Most Python Dockerfiles work and are still wrong. They build, they run, and they quietly cost you an image several times larger than it needs to be, a full dependency reinstall every time you change one line, and a container running as root.
All three come from the same few lines, and all three are fixable in one pass. This guide walks through building a Python image properly: the ordering that makes the cache work, the multi-stage build that strips the compiler out of your final image, and the settings that stop Python misbehaving inside a container.
If you have not built a container before, start with our guide to Docker with Python, which covers what images and containers are and how to run one. This article assumes you can already run docker build and picks up at the point where the result is bigger and slower than it should be.
The Dockerfile Most Projects Start With
Create a file called Dockerfile in your project root:
FROM python:3.13
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
This is correct in the sense that it produces a working container. It also has four problems, and each one has a specific fix.
python:3.13is the full image, which carries a complete build toolchain you do not need at runtime.COPY . .beforepip installmeans any source change invalidates the dependency layer, so every build reinstalls every package.COPY . .also copies your .git directory, your virtual environment, and your .env file into the image.- Nothing sets a user, so the container runs as root.
Fix 1: Pick the Right Base Image
Python publishes several base images and the size difference between them is large. The default tag is the full Debian image. The -slim variant drops the build tools and documentation, and it is the right default for an application image.
Change the first line of Dockerfile:
FROM python:3.13-slim
Pin the minor version rather than using python:3 or python:latest. An unpinned base means a rebuild six months from now silently moves you to a new Python release, and the failure shows up in production rather than in your editor.
There is also an Alpine variant, and it is tempting because it is smaller still. It is usually the wrong choice for Python. Alpine uses musl instead of glibc, so packages with compiled extensions cannot use the prebuilt wheels on PyPI and must compile from source. That turns a 30-second install into a multi-minute one and pulls the compiler back into your build. Use slim unless you have measured a specific reason not to.
Fix 2: Order Your Layers So the Cache Works
Docker caches each instruction as a layer and reuses it if nothing that instruction depends on has changed. Once one layer is invalidated, every layer after it rebuilds too.
Your dependencies change rarely. Your source changes constantly. So copy the dependency file on its own, install, and only then copy the source.
Update Dockerfile:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Now editing a source file invalidates only the final COPY. The install layer is reused, and your rebuild drops from minutes to seconds.
--no-cache-dir tells pip not to keep its download cache, which is dead weight inside an image that will never install anything again.
Fix 3: Add a .dockerignore
This is the single most commonly missing file in a Python project, and it is why so many images are large for no reason. Without it, COPY . . copies everything, including your local virtual environment and your secrets.
Create .dockerignore next to your Dockerfile:
.git
.gitignore
__pycache__/
*.pyc
*.pyo
.venv/
venv/
env/
.env
.env.*
.pytest_cache/
.mypy_cache/
.coverage
htmlcov/
*.sqlite3
node_modules/
README.md
tests/
The .env entries matter most. An image is a set of layers, and a file copied in one layer and deleted in a later one is still present in the image history. Anyone who pulls the image can read it. Keeping secrets out of the build context in the first place is the only reliable fix.
Fix 4: Set the Python Environment Variables
Two settings change how Python behaves inside a container, and both are worth setting every time.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE stops Python writing .pyc files, which are useless in a container that runs the same code every time.
PYTHONUNBUFFERED is the important one. By default Python buffers stdout when it is not attached to a terminal, so your log lines sit in a buffer instead of reaching docker logs. The usual symptom is a container that appears to produce no output until it crashes, at which point everything arrives at once. If you have ever debugged a silent container, this is why.
Fix 5: Stop Running as Root
By default the process inside your container runs as root. If an attacker finds a way to execute code through your application, they have root inside the container, which is a much better starting position than an unprivileged account.
Add a user and switch to it before the final command:
RUN useradd --create-home --shell /bin/bash appuser
USER appuser
Put this after your installs, because pip install at the system level needs the privileges, and before CMD, so the running process drops them.
Fix 6: Use a Multi-Stage Build
Everything above still leaves pip and its machinery inside the shipped image. A multi-stage build compiles and installs in one stage, then copies only the finished packages into a clean final stage.
Replace Dockerfile with this:
FROM python:3.13-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/usr/local/bin:$PATH"
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
RUN useradd --create-home --shell /bin/bash appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["python", "app.py"]
The builder stage gets build-essential, which any package with C extensions needs to compile. The final stage never installs it, so the compiler is not in your shipped image. Only the installed packages cross the boundary, through COPY --from=builder.
Build it and check the result:
docker build -t my-python-app .
docker images my-python-app
Compare that figure against the image the first Dockerfile in this article produces. The gap is usually several hundred megabytes, and most of it is the toolchain you were shipping by accident.
Running a Web Application, Not a Script
CMD ["python", "app.py"] runs a development server. Flask and Django both print a warning about this, and FastAPI's development runner is no different. Development servers are single-threaded, do not restart dead workers, and are not written to face the internet.
For a WSGI application such as Flask or Django, use Gunicorn:
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "app:app"]
For an ASGI application such as FastAPI, use Uvicorn workers:
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
Bind to 0.0.0.0, not 127.0.0.1. Inside a container, 127.0.0.1 means the container's own loopback interface, so a server bound there is unreachable from outside no matter how you map the port. This is the most common reason a container starts cleanly and then refuses every connection.
Adding a Database With Compose
A Python service usually needs a database next to it, and Compose is how you run both without installing Postgres on your machine.
Create compose.yaml in your project root:
services:
web:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://app:secret@db:5432/appdb
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Start it:
docker compose up --build
Two details do the real work here. The host in DATABASE_URL is db, the service name, because Compose gives each service a DNS name on a shared network. And depends_on with condition: service_healthy waits for Postgres to actually accept connections rather than merely to start, which is the difference between a clean boot and an application that crashes on its first query.
The pgdata volume keeps your data across restarts. Without it, docker compose down discards the database.
Building on a Machine With Limited Resources
If you are building on a laptop with 8 GB of RAM, on a metered connection, or on power that is not guaranteed for the next hour, the layer ordering above stops being a nicety. A correctly cached build re-downloads nothing when you change a source file, which is the difference between a 10-second iteration and re-pulling every wheel in your requirements file.
Three things worth doing when bandwidth or uptime is the constraint:
- Pin every dependency with exact versions in requirements.txt. An unpinned file resolves differently on every machine and defeats the cache for everyone else on the team.
- Build the base layers once and leave them. Docker keeps the
python:3.13-slimlayer locally, so the expensive pull happens on the first build only. - Use
docker build --progress=plainwhen a build fails, so you can see which layer died rather than reading a collapsed summary.
Free tiers on most container registries are enough to push and pull a slim Python image, and every tool in this article is free and runs locally. Nothing here needs a card.
A Checklist Before You Push
- Base image is
-slimand pinned to a minor version. - requirements.txt is copied and installed before the source.
- .dockerignore exists and excludes .git, .env, and your virtual environment.
PYTHONUNBUFFERED=1is set, so logs reachdocker logs.- A non-root
USERis set beforeCMD. - The final stage does not contain
build-essential. - The command runs a production server, not a development one.
- The server binds to
0.0.0.0. - No secret is baked into any layer.
Where to Go Next
Containerising an application you already understand is straightforward. Containerising a service with a database, background jobs, and a third-party dependency that occasionally times out is where the real decisions are, and those are easier to learn by building than by reading.
Our backend projects are specified around that kind of constraint rather than a feature list. If Java is your other language, the same build discipline applies there and we cover it in dockerizing Spring Boot apps.
Python remains the most common language among people learning to code, at 71.8% against 57.9% of all respondents in the Stack Overflow 2025 Developer Survey, so a Python service is what most new backend engineers will be containerising first. Source: Stack Overflow 2025 Developer Survey, Technology, 2025.
Frequently Asked Questions
How Do You Write a Dockerfile for a Python Application?
Start from python:3.13-slim, set a working directory, copy requirements.txt and install it, then copy your source, add a non-root user, and end with a production server command. Copying the requirements file before the source is what makes rebuilds fast, because the install layer stays cached when only your code changes.
Why Is My Python Docker Image So Large?
Usually three causes together: the full python base image instead of -slim, a missing .dockerignore that copies your virtual environment and .git directory in, and build tools installed in the same stage you ship. A multi-stage build with a slim base and a proper ignore file typically cuts the image by several hundred megabytes.
Should You Use Alpine for Python Images?
Usually not. Alpine uses musl rather than glibc, so Python packages with compiled extensions cannot use the prebuilt wheels on PyPI and have to build from source. That makes installs slower, builds larger, and some packages fail outright. The -slim Debian variant is the better default.
How Do You Build a Docker Image From a Python App?
Run docker build -t my-python-app . from the directory containing your Dockerfile. The -t flag names the image and the trailing dot sets the build context, which is the set of files Docker can copy from. Anything listed in .dockerignore is excluded from that context.
Why Does My Container Start but the App Is Unreachable?
Almost always because the server is bound to 127.0.0.1. Inside a container that address refers to the container's own loopback interface, so nothing outside can reach it regardless of your port mapping. Bind to 0.0.0.0 instead.
Why Do No Logs Appear in docker logs?
Python buffers stdout when it is not attached to a terminal, so output sits in a buffer rather than reaching Docker. Set PYTHONUNBUFFERED=1 in your Dockerfile and the logs appear as they are written.
Can You Put a .env File in a Docker Image?
You can, and you should not. Image layers are additive, so a secret copied in one layer and deleted in a later one is still readable in the image history. Add .env to .dockerignore and pass configuration in as environment variables at run time instead.
Summary
A good Python Dockerfile does four things the default one does not: it starts from a slim pinned base, it installs dependencies before copying source so the cache survives, it excludes the build context you do not want shipped, and it drops privileges before running.
Add a multi-stage build and the compiler stops travelling with your application. Add PYTHONUNBUFFERED and you can see what your container is doing. Bind to 0.0.0.0 and you can reach it.
Work through the checklist above once on your current project. Most of it is a five-line change, and the result is a smaller image, a faster rebuild, and a container that is not running as root.



