A Python API that works on one laptop can still be painful to deploy if the image is bloated, runs as root, or rebuilds dependencies on every copy change. A clear multi-stage Dockerfile, plus a slightly different compose setup for local work, gives you the same service in development and in production without copying build tools into the runtime image.
This guide is a practical pattern for FastAPI and other Python APIs. It is not a full Kubernetes guide. The aim is a small, non-root image, fast rebuilds, and a local workflow that does not pretend to be production.
A production multi-stage Dockerfile
The builder stage installs compilers and Poetry, then creates a virtualenv. The runtime stage copies only that virtualenv and the application code, and runs as a non-root user.
# syntax=docker/dockerfile:1
FROM python:3.11-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.in-project true \
&& poetry install --only main --no-interaction --no-ansi --no-root
FROM python:3.11-slim AS runtime
WORKDIR /app
RUN groupadd --system --gid 999 appuser \
&& useradd --system --uid 999 --gid appuser --no-create-home appuser
COPY --from=builder /app/.venv /app/.venv
COPY src /app/src
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
USER appuser
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Install Poetry with pip in the builder, not by piping an installer script from the internet at build time. Copy pyproject.toml and poetry.lock before the application source so dependency layers stay cached when you only change code. The runtime image should not include build-essential or Poetry.
Add a .dockerignore that excludes .venv, .git, tests you do not need in the image, and local env files. Accidental copies are a common way to bloat an image or leak a secret.
Why these choices matter
- Multi-stage builds. Compilers and packaging tools stay in the builder. The runtime image is smaller and has a smaller attack surface.
- A non-root user. The process does not need to run as root to bind to port 8000 inside the container. If the app is compromised, a root process gives the attacker more room.
- Layer caching. Code changes should not reinstall every dependency. Lock files should change only when dependencies change.
- Unbuffered output.
PYTHONUNBUFFERED=1makes logs show up promptly in container platforms.
Do not treat a typical image size as a guarantee. The point is to keep build tools and virtualenv sources out of the final image, and to measure the result in your own CI.
Local development should not be a fake production
Locally, you want a fast feedback loop: bind-mount the source, reload the app, and talk to dependent services such as Postgres or Redis. In production, you want the built image, no bind mounts of application code, and no reload. Those are different compose files, or at least different service definitions, on purpose.
# docker-compose.yml (local)
services:
api:
build:
context: .
target: builder
command: poetry run uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
working_dir: /app
volumes:
- .:/app
ports:
- "8000:8000"
environment:
- PYTHONUNBUFFERED=1
depends_on:
- redis
The local service above is a sketch: it uses the builder context so Poetry is available, and it mounts the project. Your exact local target may differ. What should not differ is the rule: --reload and live source mounts stay in development. Production should run the runtime image, with a pinned tag, and a process manager or platform restart policy around it.
For production you may also want more than one Uvicorn worker, or a process manager in front of Uvicorn. Choose that based on the service, and keep reload disabled. A health endpoint that does not call a third-party API is useful for both compose and a later orchestrator.
# a simple health route in the app
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
Production habits
- Pin base image versions.
python:3.11-slimshould be a digest or a known tag in CI, not a moving target you never review. - Pass secrets as environment variables or mounted secret files, never as layers copied into the image.
- Run the same image you tested, not a rebuild on the server with different dependencies.
- Set resource limits in compose or in the platform so one process cannot eat the host.
- Consider a healthcheck that hits
/healthz, but keep it cheap.
If the API also needs a worker, give it the same runtime image and a different command. Do not invent a second, looser Dockerfile for the worker. Shared images keep local and production closer together.
Common mistakes
- A single-stage image that installs build tools and then never removes them. If you must stay on one stage, at least uninstall compilers in the same layer you used them. Multi-stage is cleaner.
- Copying the whole repo, including
.envand local virtualenvs. A tight.dockerignoreis part of the Dockerfile, not an afterthought. - Running Uvicorn with
--reloadin production. Reload is a development convenience. It adds a watcher and is not how you want a public process to start. - Building on the server from whatever is on the branch. Build in CI, scan or at least inspect the image, and deploy a tag you already ran tests against.
- Forgetting the lock file. A build that reads only
pyproject.tomlcan resolve a different set of packages tomorrow.
If you use pip instead of Poetry, the same structure applies: a builder installs into a virtualenv from a pinned requirements file, and the runtime copies that virtualenv. Tools such as uv can make the install faster, but they do not change the layout. Keep the runtime slim, non-root, and reproducible.
For a service that also serves static files or needs system libraries, add those packages in the runtime stage on purpose, not by reusing the builder image. Every package in the final image should have a reason to be there.
Summary
Dockerizing a Python API well is mostly discipline: multi-stage builds, a non-root runtime user, dependency layer caching, and a local compose setup that does not copy production habits such as bind mounts and reload. Do that, and the same service can move from a laptop to a production host without a one-off image for each environment.
If you want help containerizing a FastAPI or Python API for local work and production, get in touch.