Guides · Deployment

Python Dockerfile: production images with pip or uv

Three tested Dockerfiles for Python web apps, from a single stage with pip to a locked uv install, and the mistakes that make them slow or fragile.

By Jonathan Pitter ·

The short answer

For a Python web app with pure-Python dependencies (or ones that ship wheels), one stage is enough:

# syntax=docker/dockerfile:1
FROM python:3.14-slim

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

WORKDIR /app

# Dependencies first, so this layer stays cached until requirements.txt changes
COPY requirements.txt .
RUN pip install -r requirements.txt

COPY . .

RUN useradd --system --create-home app
USER app

ENV PORT=8000
EXPOSE 8000
CMD ["sh", "-c", "exec gunicorn app:app --bind 0.0.0.0:$PORT --workers 2"]

We built and ran it with a Flask app as the non-root app user, with gunicorn as PID 1 so it receives the stop signal. The sections below explain the choices, a two-stage version for packages that compile, and a version that installs from a uv.lock.

What each line does

python:3.14-slim is Debian with Python and little else. Prefer it to the full python image (which includes compilers you don't need at runtime) and to Alpine, where packages with C extensions often have no prebuilt wheels and compile from source, slowly, against musl.

PYTHONDONTWRITEBYTECODE=1 stops Python writing .pyc files into the image at runtime. PYTHONUNBUFFERED=1 sends output straight to the container's log instead of buffering it, so a crash doesn't lose its last lines. PIP_NO_CACHE_DIR=1 keeps pip's download cache out of the layer.

COPY requirements.txt . before COPY . . is the most important line for build speed. Docker caches each layer until its inputs change, so with dependencies installed first, editing your code reuses the installed packages.

useradd --system --create-home app and USER app run the app without root. The home directory matters: gunicorn and several libraries write small files to $HOME, and a user without one logs permission errors.

sh -c "exec gunicorn ... $PORT" expands PORT at start (exec-form CMD doesn't expand variables), and exec replaces the shell with gunicorn. Without it, sh stays as PID 1, never passes SIGTERM on, and every stop waits out the full grace period before the container is killed.

Two stages for packages that compile

psycopg[c], mysqlclient and some scientific packages compile C code during install, which needs a compiler and headers you don't want in the final image. Build into a virtual environment, then copy only the environment:

# syntax=docker/dockerfile:1

# 1. Build wheels with the compilers and headers
FROM python:3.14-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev \
    && rm -rf /var/lib/apt/lists/*
RUN python -m venv /venv
ENV PATH=/venv/bin:$PATH
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 2. Run with only the shared libraries
FROM python:3.14-slim
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/*
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/venv/bin:$PATH
COPY --from=build /venv /venv
WORKDIR /app
COPY . .
RUN useradd --system --create-home app
USER app
ENV PORT=8000
EXPOSE 8000
CMD ["sh", "-c", "exec gunicorn app:app --bind 0.0.0.0:$PORT --workers 2"]

The runtime stage keeps libpq5, the shared library psycopg links against, and drops the compiler. We checked it by connecting to PostgreSQL from the built image. If all your dependencies have wheels (psycopg[binary] does), the single-stage version is simpler.

With uv

If the project uses uv, install from the lockfile so the image gets exactly the versions you tested:

# syntax=docker/dockerfile:1
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:0.13 /uv /bin/uv

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Dependencies only, from the lockfile, cached until it changes
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project

COPY . .
RUN uv sync --locked --no-dev

RUN useradd --system --create-home app
USER app

ENV PATH="/app/.venv/bin:$PATH" PORT=8000
EXPOSE 8000
CMD ["sh", "-c", "exec gunicorn app:app --bind 0.0.0.0:$PORT"]

--locked fails the build if uv.lock is out of date with pyproject.toml, instead of quietly resolving new versions. The first uv sync installs only the dependencies, so the layer survives code changes, the same trick as copying requirements.txt first.

The server: gunicorn or uvicorn

Never run flask run or manage.py runserver in production; both are development servers. Use:

  • gunicorn for WSGI apps (Flask, Django): gunicorn app:app or gunicorn mysite.wsgi:application.
  • uvicorn (or fastapi run) for ASGI apps (FastAPI, Starlette): uvicorn main:app --host 0.0.0.0. See the FastAPI Dockerfile guide.

gunicorn reads WEB_CONCURRENCY for its worker count, so you can tune it per environment without rebuilding. Two to four workers per CPU is the usual starting point.

Add a .dockerignore

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

Excluding .venv matters most: a local virtual environment copied into the image is large, built for your laptop's platform, and shadows the packages you installed in the image.

Common problems

Nothing answers on the port

The server is bound to 127.0.0.1. gunicorn binds to 127.0.0.1:8000 by default (to 0.0.0.0:$PORT when PORT is set), and uvicorn to 127.0.0.1:8000. Pass the address explicitly: --bind 0.0.0.0:$PORT for gunicorn, --host 0.0.0.0 --port $PORT for uvicorn.

Builds reinstall every package on each change

COPY . . comes before the install. Copy the requirements file (or pyproject.toml and uv.lock) first and install, then copy the code.

error: command 'gcc' failed

A package is compiling from source. Use the two-stage Dockerfile, or a package variant that ships wheels (psycopg[binary]).

Stopping the container takes 10 seconds or more

The shell is PID 1 and swallows SIGTERM. Start the server with exec inside sh -c, or use the exec form without a shell.

Python on Staxa

Staxa generates the Dockerfile when the repository doesn't have one. It detects Django from manage.py, and Flask and FastAPI from your requirements, Pipfile or pyproject.toml. This is the Flask one, on port 5000 with Python 3.14 selected:

FROM python:3.14-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt* Pipfile* pyproject.toml* ./
RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; \
    elif [ -f Pipfile ]; then pip install pipenv && pipenv install --system --deploy; \
    elif [ -f pyproject.toml ]; then pip install --no-cache-dir .; fi
RUN pip show gunicorn >/dev/null 2>&1 || pip install --no-cache-dir gunicorn
COPY . .
RUN useradd --system --create-home app
USER app
ENV PORT=5000
EXPOSE 5000
CMD ["sh", "-c", "exec gunicorn app:app --bind 0.0.0.0:$PORT"]

It installs gunicorn if your requirements don't list it, and starts app:app, so the Flask object should be app in app.py. A project with only pyproject.toml and uv.lock installs through pip, which reads pyproject.toml but not the lockfile: commit a requirements.txt (uv export --no-hashes -o requirements.txt) or the uv Dockerfile above if the exact versions matter. Pick the Python version your project requires in the service settings. Django and FastAPI have their own guides: Django hosting and FastAPI Dockerfile.

Frequently asked questions

Alpine or slim?

Slim. Alpine images are smaller, but most binary wheels on PyPI are built for glibc, so on Alpine many packages compile from source: the build gets slower, needs compilers, and the image is often not much smaller in the end.

Should I use a virtual environment inside the container?

In a single stage it isn't needed; the container is already isolated. In a multi-stage build it's the easiest way to copy exactly the installed packages from one stage to the next.

Where do environment variables go?

Runtime configuration (database URLs, secret keys) goes in the container's environment when it starts, not in the Dockerfile. ENV lines are baked into the image and visible to anyone who can pull it.

Give every customer their own stack

Staxa provisions an isolated app, database, subdomain and TLS per customer from one API call. The Free plan includes 2 tenants.