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.
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.
# 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.
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)
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.
| Approach | Base image | Approximate size |
|---|---|---|
| CPython + source | python:3.12 | 900 MB – 1.2 GB |
| CPython + source | python:3.12-slim | 150–300 MB |
| Nuitka compiled | debian:bookworm-slim | 60–100 MB |
| Nuitka compiled | distroless/cc | 35–60 MB |
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.
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.
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.