← Back to blog
Python

Compiling Python Web Apps with Nuitka: Flask, FastAPI, and Django

Compiling Python Web Apps with Nuitka

Nuitka compiles Python into a standalone native executable — no interpreter to install, no source code exposed, and often a real performance gain since it's translating to optimized C rather than just bundling the interpreter. For a CLI tool or a desktop app, that's a straightforward win. For a web application, it's more interesting: which framework you picked determines whether compilation is a non-event or a multi-day debugging exercise.

We ran all three of the major Python web frameworks through Nuitka to find out. The short version: Flask compiles cleanly, FastAPI compiles with one hard constraint, and Django isn't worth attempting. Here's why, and how to actually do it.

Why framework choice matters more than Nuitka flags

Nuitka works by statically tracing your code's import graph at compile time. That works beautifully for code that says what it imports. It breaks down for code that decides what to import at runtime based on a string, an environment variable, or a directory scan — which is exactly how Django is built.

This isn't a Nuitka limitation so much as a preview of a deeper trade-off: frameworks that favor explicit declarations (route decorators, dependency injection) are inherently more compileable than frameworks that favor "convention over configuration" (autodiscovered management commands, settings modules resolved from an env var). Compiling with Nuitka is, in effect, a static-analysis audit of how explicit your architecture actually is.

Flask: the reliable choice

Flask's only real friction point is static assets. Nuitka only traces Python imports by default — it has no idea your app needs its templates/ and static/ directories until it fails at runtime with jinja2.exceptions.TemplateNotFound. The fix is one flag:

nuitka --standalone \
  --include-data-dir=templates=templates \
  --include-data-dir=static=static \
  main.py

The other requirement: don't use Flask's development server. Launch a real WSGI server programmatically so Nuitka has an explicit entry point to trace:

# main.py
from flask import Flask, render_template
from waitress import serve

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

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

Waitress is the right server here specifically because of how it handles concurrency — more on that below. With those two things sorted (data dirs included, server started programmatically), Flask compiles and runs with no further surprises.

FastAPI: fast, with one non-negotiable constraint

FastAPI's issue isn't packaging — it's Uvicorn's multi-worker model. The standard uvicorn main:app invocation relies on a string-based dynamic import that Nuitka's static analyzer can't resolve, so it has to be replaced with a programmatic launch:

# main.py
from fastapi import FastAPI
import uvicorn

app = FastAPI()

@app.get("/")
def read_root():
    return {"status": "ok"}

if __name__ == "__main__":
    uvicorn.run(
        app,
        host="0.0.0.0",
        port=8000,
        workers=1,  # non-negotiable in a compiled binary — see below
    )

That workers=1 isn't a style preference. Running uvicorn.run() with workers > 1 inside a compiled binary spawns child processes that try to re-import the application module — something a self-contained executable has no mechanism for. In practice this shows up as child processes dying immediately or multiprocessing/resource_tracker.py warnings on startup. There's no reliable fix; the constraint is structural, not a bug to work around.

This means a compiled FastAPI service has to get its concurrency entirely from the asyncio event loop in a single process — which is exactly what FastAPI's async model is built for on the I/O side. The gap is CPU-bound work, which would otherwise block that one event loop. Offload it to a process pool instead of trying to scale via Uvicorn workers:

import asyncio
from concurrent.futures import ProcessPoolExecutor
from multiprocessing import freeze_support
from fastapi import FastAPI
import uvicorn

def heavy_computation(x: int, y: int) -> int:
    # CPU-bound work goes here
    return x * y

app = FastAPI()
process_pool = ProcessPoolExecutor()

@app.get("/compute")
async def compute(x: int, y: int):
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(process_pool, heavy_computation, x, y)
    return {"result": result}

if __name__ == "__main__":
    freeze_support()  # required for multiprocessing in a frozen/compiled binary
    uvicorn.run(app, host="0.0.0.0", port=8000, workers=1)

freeze_support() is mandatory on Windows and macOS whenever a compiled binary uses multiprocessing — omit it and child processes fail to initialize or spawn indefinitely. Nuitka's own multiprocessing support is solid; the constraint is specifically Uvicorn's worker-spawning model, not Nuitka's.

Django: don't

Django's entire design leans on the dynamic behavior Nuitka can't follow — settings resolved from an environment variable, management commands autodiscovered by scanning directories, apps registered by convention rather than explicit import. The result is compilation failures where Nuitka can't determine a settings module path, or runtime ModuleNotFoundErrors for code that was never traced.

Workarounds exist (force-including entire packages with --include-package, patching Django's internals) but they're brittle, and Nuitka's own maintainers have said supporting Django properly isn't a priority absent dedicated funding. If your app is built on Django, Nuitka compilation isn't a realistic path — containerize it instead.

Getting the build right

A few flags matter regardless of framework:

  • --standalone — produces a distribution folder with the executable and all dependencies. This is the right mode for a server app; --onefile adds startup latency from self-extraction and makes missing-file issues harder to diagnose.
  • --enable-plugin=upx — compresses the output 50–70% via UPX, at no runtime cost.
  • --enable-plugin=anti-bloat — strips unused code paths from common libraries (test utilities inside requests, for instance).
  • --lto=yes — whole-program optimization at link time. Real performance gain, real increase in build time and linker memory — worth it for a release build, not for your inner dev loop.

Install ccache (Linux/macOS) or clcache (Windows/MSVC) before you start iterating — Nuitka picks them up automatically and it's the difference between a 3-minute and a 30-second rebuild once the C++ compilation cache is warm.

Automating cross-platform releases

A GitHub Actions matrix build handles Windows, Linux, and macOS from one workflow, triggered on a version tag:

name: Build and Release
on:
  push:
    tags: ['v*']

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: $
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install -r requirements.txt
      - name: Build with Nuitka
        uses: Nuitka/Nuitka-Action@main
        with:
          script-name: main.py
          standalone: true
      - uses: actions/upload-artifact@v4
        with:
          name: myapp-$
          path: main.dist/

For Linux, FPM turns the standalone dist folder into .deb and .rpm packages with a single command each (fpm -s dir -t deb -n myapp -v 1.0.0 --prefix /opt/myapp -C ./myapp.dist .), run inside a Docker container matching the target distro so the dependency metadata is correct. For macOS/Linux, a Homebrew tap formula pointing at the release's .tar.gz with a matching SHA256 gets you a one-line install for users who already have Homebrew.

The verdict

For general-purpose web apps where compilation is a goal, Flask is the safer default — its only challenge is a compile-time flag, and its recommended server (Waitress) sidesteps the multiprocessing conflict entirely. Reach for FastAPI when you specifically need its async performance and are willing to accept single-process deployment plus a process pool for CPU-bound work. Don't attempt Django — containerize it and save the debugging time for something that pays off.

Treat the compile step as free integration testing, not a final packaging chore: a clean Nuitka build is proof that every code and data dependency your app needs is statically traceable from its entry point. That's a useful property to have regardless of whether you ship a compiled binary or a container.


If you're evaluating Nuitka for a production Python service, or building the CI/CD pipeline to ship compiled binaries across platforms, let's talk — we've done this build-and-release pipeline enough times to skip the trial and error.

Have a project in mind?

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

Start the conversation