← Back to blog
Python

Nuitka + Docker: Building Minimal, Compiled Python Containers

Nuitka + Docker: Building Minimal, Compiled Python Containers

A standard Python container for a FastAPI service typically runs 150 MB to 1 GB depending on base image and dependencies. Compile the same app with Nuitka first and containerize the binary instead of the source, and that number drops under 40 MB — with faster cold starts and no interpreter left inside the container for an attacker to abuse post-exploitation.

The pattern is a standard multi-stage Docker build: compile in one stage, ship only the resulting binary in the next.

Why compile before you containerize

The normal Python container workflow — copy source, install a full CPython runtime and every dependency — creates problems that compound at scale: bloated images slow down Kubernetes pod scheduling, anyone with image-pull access can read your application logic, and a live interpreter in the runtime container is a genuinely useful tool for an attacker who's already gotten in. A Nuitka-compiled container has none of that. The compiled binary is the application — no interpreter, no source, no pip.

Multi-stage build: FastAPI

# Stage 1: Compile
FROM python:3.12-slim AS builder
WORKDIR /build

RUN apt-get update && apt-get install -y \
    gcc patchelf ccache libffi-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

RUN python -m nuitka \
    --standalone --onefile \
    --include-package=fastapi \
    --include-package=uvicorn \
    --include-package=pydantic \
    --include-package=starlette \
    --output-filename=app \
    main.py

# Stage 2: Runtime — nothing but the binary
FROM debian:bookworm-slim AS runtime
WORKDIR /app
COPY --from=builder /build/app .

RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser

EXPOSE 8000
CMD ["/app/app"]

As with any Nuitka web app, main.py has to launch Uvicorn programmatically — a CLI-style uvicorn main:app module reference can't be statically traced:

# main.py
import uvicorn
from api import app

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000, workers=1)

workers=1 is required, not optional — Nuitka compiled binaries and Uvicorn's multi-worker process spawning don't get along. See our deeper walkthrough of the Flask/FastAPI/Django compilation trade-offs for why.

Multi-stage build: Flask + Waitress

Flask compiles more predictably because Waitress uses threads rather than spawning processes, sidestepping the Uvicorn conflict entirely. The only extra step is telling Nuitka about your templates and static assets, which it won't discover on its own:

RUN python -m nuitka \
    --standalone --onefile \
    --include-data-dir=templates=templates \
    --include-data-dir=static=static \
    --include-package=waitress \
    --include-package=flask \
    --output-filename=app \
    main.py
# main.py
from waitress import serve
from myapp import create_app

if __name__ == "__main__":
    app = create_app()
    serve(app, host="0.0.0.0", port=8080, threads=8)

Going distroless

Swap the Debian runtime base for Google's distroless image to strip out the shell and package manager entirely:

FROM gcr.io/distroless/cc-debian12 AS runtime
WORKDIR /app
COPY --from=builder /build/app .
EXPOSE 8000
ENTRYPOINT ["/app/app"]

distroless/cc ships the C standard library a Nuitka binary needs, and nothing else — no shell, no package manager, no docker exec for debugging. That's the point: plan your observability strategy (structured JSON logs to stdout, distributed tracing) around not having a shell to fall back on.

ApproachBase imageApproximate size
CPython + sourcepython:3.12900 MB – 1.2 GB
CPython + sourcepython:3.12-slim150–300 MB
Nuitka compileddebian:bookworm-slim60–100 MB
Nuitka compileddistroless/cc35–60 MB

Caching the C compilation step

Nuitka's real cost is the C compile, not the Python analysis. Enable ccache in the builder stage and cache it across CI runs:

FROM python:3.12-slim AS builder
ENV CCACHE_DIR=/ccache
ENV PATH="/usr/lib/ccache:${PATH}"
RUN apt-get update && apt-get install -y gcc patchelf ccache

Paired with GitHub Actions' BuildKit layer caching (cache-from: type=gha, cache-to: type=gha,mode=max), a rebuild of an unchanged codebase drops from 5–10 minutes to 30–60 seconds.

Deploying to Kubernetes

The compiled container behaves like any other container — with a couple of settings worth tightening specifically because it's a compiled binary:

        resources:
          requests:
            memory: "64Mi"   # lower than the equivalent CPython container
            cpu: "100m"
          limits:
            memory: "128Mi"
            cpu: "500m"
        readinessProbe:
          httpGet: { path: /health, port: 8000 }
          initialDelaySeconds: 1   # vs 5–10s for a CPython container
          periodSeconds: 5
        securityContext:
          readOnlyRootFilesystem: true
          runAsNonRoot: true
          runAsUser: 1000
          allowPrivilegeEscalation: false
          capabilities: { drop: ["ALL"] }

The aggressive initialDelaySeconds: 1 isn't reckless — a compiled binary is genuinely ready in milliseconds, not the 5–10 seconds a CPython container needs to import its dependency tree.

Debugging without a shell

Distroless means no kubectl exec. The tools that still work:

# Pull the binary out of a running pod for offline inspection
kubectl cp api-pod-xxx:/app/app ./app-binary

# Logs are your primary real-time signal
kubectl logs -f deployment/api

# Ephemeral debug containers (Kubernetes 1.23+) attach a separate,
# shell-having container to the same pod
kubectl debug -it api-pod-xxx --image=busybox --target=api

The trade-off is honest: longer CI build times for a dramatically smaller, faster, and harder-to-exploit runtime image. With ccache and layer caching in place, that becomes a 30–60 second tax for shipping a 40 MB container with no exposed source and no interpreter to attack — against a 30-second build that ships 900 MB with your source code readable by anyone who can pull the image.

If you're moving a Python service to compiled containers, talk to us — we've built this exact CI/CD pipeline enough times to save you the trial and error.

Have a project in mind?

The first call is always free — tell us what you're building.

Start the conversation