Nuitka + Docker: Building Minimal, Compiled Python Containers
A standard Python Docker image for a FastAPI application typically ranges from 150 MB to 1 GB, depending on the base image and dependencies. A Nuitka-compiled equivalent can drop to under 40 MB using a distroless base, with faster startup and no Python interpreter exposed in the container.
This guide covers the multi-stage build pattern that makes this work reliably.
Why Compile Before Containerizing?
The standard Python container workflow copies source files into an image with a full Python runtime. This creates several problems at scale:
- Large image size: CPython + pip + dependencies + source = bloat
- Slow cold starts: Kubernetes pod scheduling with large images increases time-to-ready
- Exposed source: anyone with image pull access can read your application logic
- Runtime attack surface: a full Python interpreter in the container is a useful tool for an attacker post-exploitation
Nuitka-compiled containers eliminate all of these. The compiled binary is the entire application; no interpreter, no source, no pip.
Prerequisites
You need the following on your build machine (or CI runner):
# Install Nuitka
pip install nuitka
# Required C compiler (Linux)
apt-get install -y gcc patchelf ccache
# Required C compiler (macOS)
xcode-select --install
The final Docker image does not need Python or gcc — compilation happens in the build stage.
Multi-Stage Dockerfile: FastAPI Application
# ── Stage 1: Compile ────────────────────────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build
# Install C compiler and Nuitka dependencies
RUN apt-get update && apt-get install -y \
gcc \
patchelf \
ccache \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application source
COPY . .
# Compile with Nuitka
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 ─────────────────────────────────────────────────────────
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# Copy only the compiled binary
COPY --from=builder /build/app .
# Non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
EXPOSE 8000
CMD ["/app/app"]
The runtime stage contains nothing but the compiled binary and a minimal Debian base. No Python, no pip, no source files.
The Application Entry Point
Your main.py must launch Uvicorn programmatically, not via the CLI. Nuitka cannot resolve CLI-style uvicorn main:app module references:
# main.py
import uvicorn
from api import app # your FastAPI application
if __name__ == "__main__":
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
workers=1, # single worker — Nuitka + Uvicorn multiprocessing is unstable
access_log=True,
)
Multi-Stage Dockerfile: Flask + Waitress
Flask with the Waitress WSGI server is simpler because Waitress uses threads instead of processes, which compiles more cleanly:
# ── Stage 1: Compile ────────────────────────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y gcc patchelf ccache \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Include template and static directories
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
# ── Stage 2: Runtime ─────────────────────────────────────────────────────────
FROM debian:bookworm-slim AS runtime
WORKDIR /app
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --from=builder /build/app .
USER appuser
EXPOSE 8080
CMD ["/app/app"]
Flask entry point:
# 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)
Using Distroless for Minimal Attack Surface
Replace the debian:bookworm-slim runtime base with Google’s distroless image to remove all shells and package managers:
FROM gcr.io/distroless/cc-debian12 AS runtime
WORKDIR /app
COPY --from=builder /build/app .
EXPOSE 8000
ENTRYPOINT ["/app/app"]
distroless/cc includes the C standard library (libc) required by Nuitka-compiled binaries. It has no shell, no package manager, and no Python — which means docker exec into the container for debugging is not available. Plan your observability strategy accordingly (structured logging to stdout, distributed tracing).
Image Size Comparison
| 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 |
The range depends on the number of dependencies your application compiles in. Pure-Python dependencies become compiled C code; native C extensions are included as-is.
GitHub Actions CI/CD Pipeline
Complete pipeline that builds the Docker image using Nuitka compilation:
# .github/workflows/build.yml
name: Build and Push
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=ref,event=branch
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# BuildKit cache speeds up repeated Nuitka C compilation
build-args: |
BUILDKIT_INLINE_CACHE=1
Caching Nuitka’s C Compilation
Nuitka’s primary cost is the C compilation step. Enable ccache in your Dockerfile builder stage to cache this across builds:
FROM python:3.12-slim AS builder
# Set ccache directory
ENV CCACHE_DIR=/ccache
ENV PATH="/usr/lib/ccache:${PATH}"
RUN apt-get update && apt-get install -y gcc patchelf ccache
# In GitHub Actions, cache the ccache directory
# Add to your workflow:
# - uses: actions/cache@v4
# with:
# path: /tmp/.buildx-cache
# key: nuitka-ccache-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
With ccache enabled and BuildKit layer caching, subsequent builds of an unchanged codebase take 30–60 seconds instead of 5–10 minutes.
Kubernetes Deployment
The compiled container works identically to any other container in Kubernetes. The main considerations:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: ghcr.io/yourorg/api:latest
ports:
- containerPort: 8000
resources:
requests:
memory: "64Mi" # lower than equivalent CPython container
cpu: "100m"
limits:
memory: "128Mi"
cpu: "500m"
# Compiled binary starts fast; readiness probe can be aggressive
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 1 # vs 5-10s for Python containers
periodSeconds: 5
# Security context: no shell available anyway, enforce it
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
The initialDelaySeconds: 1 on the readiness probe reflects Nuitka’s fast startup. CPython containers typically need 5–10 seconds before the application is ready to serve traffic.
Debugging Without a Shell
Since distroless containers have no shell, debugging requires alternative approaches:
# Copy files out of a running container for inspection
kubectl cp api-pod-xxx:/app/app ./app-binary
# Stream logs (your only real-time insight)
kubectl logs -f deployment/api
# Use ephemeral debug containers (Kubernetes 1.23+)
kubectl debug -it api-pod-xxx --image=busybox --target=api
Structure your application to log to stdout in JSON format so log aggregators (Datadog, Loki, CloudWatch) can provide searchable observability without requiring shell access.
Summary
Nuitka + Docker multi-stage builds produce containers that are:
- 60–80% smaller than CPython equivalents
- Faster to start (measured in milliseconds, not seconds)
- Safer: no interpreter, no shell in distroless runtime
- IP-protected: machine code, not decompilable bytecode
The only cost is longer CI build times. With ccache and BuildKit layer caching, this overhead is manageable — and a 5-minute build that produces a 40 MB secure container is a worthwhile trade against a 30-second build that ships a 900 MB image with exposed source code.
Related: Nuitka Packaging for Web Frameworks: FastAPI, Flask, and Django | Nuitka vs PyInstaller: Python Packaging Compared