NGINX Unit: The Application Server Nobody Talks About
The standard Python web stack tutorial has not changed much in fifteen years: stand up NGINX, write a uwsgi.ini or Gunicorn systemd unit, configure an upstream block, reload the proxy. Something breaks, you SIGHUP NGINX, SIGHUP uWSGI, wonder which process manager restarted which worker, and eventually the site comes back up. Then you do it all again for the Node.js service, and again for the PHP legacy app.
NGINX Unit was designed to make that entire ceremony disappear. It is a single binary that directly executes Python, Node.js, PHP, Go, Java, Ruby, Perl, and WebAssembly applications — no uWSGI, no Gunicorn, no pm2, no PHP-FPM needed. Its entire configuration lives in a JSON object you read and write over a Unix socket REST API. You PUT a new config and your running applications reconfigure themselves in place, zero restarts, zero downtime, no SIGHUP.
There is a critical caveat that must lead any honest 2025/2026 discussion: NGINX Unit was archived by F5/NGINX on October 8, 2025. The repository is read-only, marked unsupported, and will likely receive no further security patches. The final release was 1.35.0 on September 3, 2025. If you are starting a new production service today, you should consider that information carefully before committing. For existing deployments, for internal tooling, for homelab use, and as an architectural reference for what a REST-API-native application server looks like — Unit remains genuinely interesting and still runs perfectly well.
This guide covers everything: the architecture, the full configuration API, per-language setup, TLS, routing, Docker, Kubernetes, and a direct comparison with the alternatives.
Part 1: What NGINX Unit Actually Is
Unit is not a reverse proxy. It is an application server — the piece of the stack that actually runs your code. Where NGINX (the proxy) accepts HTTP connections and forwards them upstream, Unit accepts HTTP connections and hands them directly to your application process. The distinction matters: Unit occupies the same conceptual slot as uWSGI, Gunicorn, PHP-FPM, Node.js’s built-in server, or the JVM’s servlet container, not the slot occupied by NGINX itself.
The key differentiator — the thing that made Unit genuinely novel when Igor Sysoev and company shipped version 1.0 in 2018 — is the configuration model. There are no config files on disk that you edit and reload. There is no nginx.conf, no uwsgi.ini, no gunicorn.conf.py. Instead, Unit holds its entire configuration as a JSON document in memory, exposes that document over a local Unix socket at /var/run/control.unit.sock, and accepts GET/PUT/POST/DELETE HTTP requests against it. You curl a JSON body to an endpoint, Unit validates it, applies it atomically, and the new configuration is live. In-flight requests complete on the old worker processes; new requests go to the reconfigured workers. Nothing restarts.
|
|
That is the entire mental model. One binary, one JSON document, REST for everything.
Supported Languages (at EOL)
| Language | Module Package | Notes |
|---|---|---|
| Python 2.7, 3.x | unit-python3.12 |
WSGI and ASGI (FastAPI, Django Channels) |
| PHP 7.x, 8.x | unit-php |
Full FPM replacement, ini overrides |
| Node.js 14-20 | (via unit-http npm) |
Express, Koa, etc. via loader shim |
| Go | unit-go |
Requires linking against unit.nginx.org/go |
| Java 8-21 | unit-jsc11, unit-jsc17, unit-jsc21 |
WAR files via Eclipse Jetty |
| Ruby | unit-ruby |
Rack interface |
| Perl | unit-perl |
PSGI interface |
| WebAssembly | unit-wasm |
WASI-HTTP component model (Wasmtime) |
Part 2: Architecture
Understanding how Unit is structured explains why live reloads work the way they do.
Process Model
Unit runs three categories of processes:
Main (controller) process — The root daemon (unitd). Owns the control API socket, holds the authoritative configuration in memory, and orchestrates all other processes. It does not handle application requests.
Router process — A single-threaded (configurable as of 1.33.0) event-driven process that accepts all incoming HTTP connections, performs TLS termination, evaluates routes, and dispatches requests to application workers via Unix socket pairs and shared memory. The router is the only process that touches the network.
Application worker processes — One process group per configured application, potentially one per language runtime. Each language module runs in its own process group. A Python app and a PHP app never share a process. Workers receive request data from the router, execute application code, and return response data.
┌──────────────────────────────┐
curl/API ──────▶│ Main Process (unitd) │
│ /var/run/control.unit.sock │
└──────────┬───────────────────┘
│ config changes
┌──────────▼───────────────────┐
HTTP/HTTPS ────▶│ Router Process │
│ event loop + TLS termination│
└────┬──────────┬──────────────┘
│ │
┌──────────▼──┐ ┌────▼─────────┐
│ Python App │ │ PHP App │
│ Workers (N) │ │ Workers (M) │
└─────────────┘ └─────────────┘
How Configuration Changes Work
When you PUT /config, the main process:
- Parses and validates the JSON payload
- Diffs the new config against the current in-memory state
- Signals the router to start directing new requests to new-config workers
- Allows existing workers (old config) to drain their in-flight requests
- Terminates drained old workers
The result is a rolling transition. No connections are dropped. No restart occurs. This is the feature that makes Unit genuinely useful in environments where you want to change application configuration without touching a load balancer or process manager.
The Control Socket
The default socket path varies by install method:
| Platform | Socket Path |
|---|---|
| Debian/Ubuntu | /var/run/control.unit.sock |
| RHEL/CentOS | /var/run/unit/control.sock |
| macOS (Homebrew) | /usr/local/var/run/unit/control.sock |
| Docker containers | /var/run/control.unit.sock |
All curl examples in this guide use /var/run/control.unit.sock. Adjust the path for your platform.
Part 3: Installation
Debian / Ubuntu
|
|
For Ubuntu 22.04 Jammy, replace noble with jammy. Debian 12 Bookworm uses bookworm.
RHEL / CentOS / Rocky Linux
|
|
Node.js Module (npm)
Node.js support is delivered as an npm package, not a system package. Requires unit-dev (Debian) or unit-devel (RHEL) to be installed first:
|
|
Docker Images
The official Docker images are hosted on Docker Hub and Amazon ECR Public. Each image is a language-specific variant of the 1.35.0 base (the final release):
|
|
The minimal variant gives you the Unit daemon only; install additional language modules on top to build a multi-language image.
Building from Source
|
|
Part 4: The Configuration API
The single most important thing to understand about Unit is this: there are no config files. Everything goes through the REST API at the control socket.
The Root Object Structure
GET / → Unit build and status info
GET /config → Full application configuration
GET /certificates → Uploaded TLS certificate bundles
GET /control → Application control actions
GET /status → Request statistics
The /config object has these top-level keys:
|
|
Reading Configuration
|
|
Writing Configuration — Atomic Full Replace
|
|
Success response:
|
|
Incremental Updates — Surgical PUT
The most powerful workflow: update a single section without touching anything else:
|
|
Appending to Arrays (POST)
|
|
Deleting Configuration
|
|
Note: Unit prevents deletion of objects that are still referenced. Deleting an application that is still referenced by a listener or route returns an error.
The unitctl CLI (Technical Preview)
As of 1.33.0, Unit ships unitctl, a Rust-based CLI that wraps the API:
|
|
Part 5: Application Configuration — Language by Language
Python (WSGI)
The classic Flask/Django/any-WSGI-app setup:
|
|
Key fields:
type—"python"uses the system default;"python 3.12"locks to a specific minor versionmodule— The Python module name (not filename) containing the callable. For a file/var/www/myapp/wsgi.py, the module is"wsgi"callable— The WSGI application object inside the module (default:"application")home— Path to a virtualenv; Unit sources it automatically, nosource activateneededpath— Prepended tosys.path; can be a list for multiple lookup paths
Django WSGI:
|
|
Python (ASGI — FastAPI, Django ASGI, Starlette)
ASGI apps — FastAPI, Starlette, Django 3.0+ in async mode, Litestar — work with "protocol": "asgi". Unit handles the ASGI lifespan protocol (startup/shutdown events) as of 1.31.0:
|
|
The threads field controls async workers per process — useful for I/O-bound ASGI apps.
Django ASGI (Django 3.0+):
|
|
Python — Multiple Targets (Monorepo Pattern)
Unit supports multiple WSGI/ASGI entry points from one application definition using targets:
|
|
PHP
PHP apps run directly — no PHP-FPM socket, no fastcgi_pass, no separate process manager. Unit embeds the PHP runtime:
|
|
Key PHP fields:
root— Document root. Required.script— A fixed entry-point file for all requests (Laravel, Symfony, WordPress). Omit for traditional PHP where the URI maps to files.index— Filename for directory requests (default:index.php)options.admin— PHP ini directives the user cannot overrideoptions.user— PHP ini directives that can be overridden per-script
WordPress (no script field, URI-mapped files):
|
|
Node.js
Node.js apps use the "external" type and require the unit-http npm package, which intercepts the built-in http module:
|
|
The --loader unit-http/loader.mjs shim patches require('http') and require('https') at runtime so your Express/Koa/Fastify app works without code changes. The app does not need to call .listen() — Unit manages socket binding.
If your app has a shebang (#!/usr/bin/env node), you can simplify:
|
|
Go
Go apps require compilation against the Unit Go library (unit.nginx.org/go), which replaces the standard net/http listener. The compiled binary is then referenced in config:
Go source change:
|
|
Build:
|
|
Configuration:
|
|
Java (Spring Boot / WAR files)
Java support targets standard WAR-packaged applications running under Eclipse Jetty (embedded):
|
|
Key Java fields:
webapp— Path to the WAR fileoptions— JVM arguments (heap, system properties, GC tuning)threads— Worker threads per process (default: 1; increase for concurrent request handling)classpath— Additional JAR files on the classpath
Part 6: Routing
Unit’s routing layer is evaluated before requests reach applications. Routes are arrays of steps; the first matching step wins. If no step matches, Unit returns 404.
Basic Route Structure
|
|
The last step has no match — it is the catch-all default.
Match Conditions
match objects support the following fields, all of which can be single strings, arrays (OR logic), or negated with ! (AND NOT logic):
|
|
Pattern syntax:
- Exact:
"example.com"— exact match - Wildcard:
"*.example.com"—*matches any sequence including slashes - Regex:
"~^/api/v[0-9]+/"— PCRE, prefix with~ - Negated:
"!/admin/public"— matches everything except this
Action Types
Pass to application:
|
|
Return HTTP status:
|
|
Redirect:
|
|
Serve static files:
|
|
Proxy to upstream:
|
|
URI rewrite + pass:
|
|
Advanced Routing: Conditional if (1.33.0+)
|
|
HTTP-to-HTTPS Redirect Route
|
|
Part 7: TLS / SSL
Unit manages TLS certificates through the /certificates API endpoint entirely independently from the application config.
Uploading a Certificate Bundle
Unit expects a single PEM file containing the full certificate chain followed by the private key:
|
|
The bundle name (example_com) is arbitrary — you reference it by this name in listener config.
Viewing Uploaded Certificates
|
|
Configuring a TLS Listener
|
|
SNI — Multiple Certificates on One Listener
|
|
Unit selects the appropriate certificate based on the SNI hostname from the TLS ClientHello.
Advanced TLS Settings
|
|
conf_commands maps directly to OpenSSL configuration directives, giving you fine-grained TLS control.
Zero-Downtime Certificate Renewal
The renewal workflow does not require any server restart or downtime:
|
|
Part 8: Static File Serving
The share action serves files directly from disk without involving any application process:
|
|
try_files Equivalent (Fallback)
The fallback field in a share action implements the try_files pattern — try to serve the file, fall through to the application if not found:
|
|
This is the standard single-page app (SPA) pattern: serve static files first; if the URI doesn’t match a file, send to the app for client-side routing.
MIME Type Filtering
|
|
Patterns not matched by types return 403 unless a fallback is configured.
Path Confinement (chroot)
Requires Linux kernel 5.6+:
|
|
With chroot set, symbolic links outside the chroot boundary are rejected, preventing directory traversal attacks.
Part 9: Process Management and Scaling
Unit manages application process lifecycles without any external supervisor (no systemd service per-app, no supervisord, no pm2).
Fixed Process Count
|
|
Unit spawns exactly 8 workers at startup and maintains exactly 8 throughout. If a worker crashes, Unit replaces it.
Dynamic Process Scaling
|
|
spare— Minimum idle workers to keep warm (ready to accept requests immediately)max— Hard ceiling on worker countidle_timeout— Seconds an idle worker beyondsparecount waits before being terminated
With this config, Unit starts spare (4) workers immediately. As load increases, it spawns more up to max (32). As load drops, workers idle beyond spare are terminated after idle_timeout seconds. Memory footprint tracks actual traffic.
Request Limits
|
|
timeout— Seconds before Unit cancels a hung request (returns 503 to the client)requests— Worker is gracefully replaced after handling this many requests (useful for memory leak mitigation in PHP/Ruby apps)
What Happens During Config Changes
When you update the processes field or change other application parameters via the API:
- New workers are spawned with the updated configuration
- The router directs new incoming requests to the new workers
- Old workers continue handling their in-flight requests
- Old workers exit cleanly after their last request completes
No request is dropped. No connection is reset. This is the core value proposition.
Restarting Application Workers
To force a full worker restart (drain in-flight requests, spawn fresh pool):
|
|
Part 10: Load Balancing with Upstreams
Unit supports round-robin load balancing to pools of backend servers via the upstreams section:
|
|
Weights control request distribution. A server with weight 0 is disabled but remains in the pool. A server with weight 2 receives twice as many requests as weight-1 servers.
This is primarily useful when using Unit as a proxy tier (forwarding to external services), not for scaling Unit’s own application workers — for that, use the processes field.
Part 11: Access Logging and OpenTelemetry
Access Log (JSON format, 1.34.0+)
|
|
Omit format for the default Combined Log Format string. When format is a JSON object, Unit writes JSON lines.
Conditional Logging (1.32.0+)
|
|
This logs only non-200 responses that are not health checks.
OpenTelemetry (1.34.0+, build-time flag)
Unit 1.34.0 added OTEL tracing support, disabled by default and requiring --otel at build time:
|
|
Part 12: WebAssembly
As of 1.32.0, Unit supports WebAssembly via the WASI-HTTP component model using the Wasmtime runtime. This is distinct from the earlier (pre-1.32) Technology Preview that used a custom C/Rust SDK.
WASI-HTTP Component Configuration
|
|
The WASM component must be compiled targeting the wasi:http/proxy world interface. This allows language-agnostic compilation: Rust, Go (with TinyGo), C, and others can produce components that Unit runs identically.
Part 13: Process Isolation
Unit supports Linux namespace isolation per application — useful for multi-tenant deployments or defense-in-depth:
|
|
credential: true— User namespace isolation; app runs as a different UID insidemount: true— Mount namespace; the app gets its own mount pointspid: true— PID namespace; app processes see only their own process treerootfs— Chroot the application to this directorycgroup.path— Assign app workers to a specific cgroup for resource accounting
Part 14: Docker
Official Image Architecture
Unit’s Docker images use /docker-entrypoint.d/ for initialization. On first startup (when Unit’s state directory is empty), it loads:
*.pemfiles as TLS certificate bundles*.jsonfiles as configuration snippets*.shfiles as shell scripts
Subsequent starts skip this initialization (state is already populated), which means docker restart preserves your configuration.
Python/Flask Docker Example
Dockerfile:
|
|
unit-config.json:
|
|
app/wsgi.py:
|
|
Docker Compose Example
|
|
Updating the running config after startup (without rebuilding):
|
|
Multi-Language Docker Image
If you need to run multiple language runtimes from one container (unusual but valid for local dev or small services):
|
|
Part 15: Kubernetes
Unit fits Kubernetes as a standalone pod (no separate nginx ingress needed for small services) or as a per-pod application container behind an ingress.
Standalone Deployment with ConfigMap
|
|
Notes on the Kubernetes Pattern
The standard Kubernetes approach for Unit:
- Bake your application code into the Docker image (don’t use bind mounts in production)
- Ship the initial
config.jsoneither baked into the image (as/docker-entrypoint.d/config.json) or via ConfigMap - Mount the Unit state directory on an
emptyDiror a PVC if you want config changes to survive pod restarts - Expose the control socket via an
emptyDirshared between containers if you need a sidecar to push config updates
For live config updates in Kubernetes, the typical pattern is to push a new ConfigMap and trigger a rolling restart (the same as any other config-file-based server) — Unit’s live-reload advantage shines more in VM/bare-metal deployments where you can hit the socket directly.
Part 16: vs. The Alternatives — Honest Assessment
What Unit Eliminates vs. nginx + uWSGI/Gunicorn
| Task | nginx + uWSGI/Gunicorn | NGINX Unit |
|---|---|---|
| Process supervision | systemd unit, supervisord, or PM2 per app | Built into Unit daemon |
| Config reload | Edit file + nginx -s reload + kill -HUP $uwsgi_pid |
curl -X PUT config.json |
| Add new application | Edit nginx.conf + uwsgi.ini + systemd file + reload all | curl -X PUT /config/applications/newapp |
| Python venv activation | ExecStart with virtualenv path or wrapper script |
home field in config |
| PHP-FPM management | Separate php-fpm daemon + socket config + nginx fastcgi_pass |
type: php — done |
| Graceful worker restart | kill -USR1 $gunicorn_master or uwsgi --reload |
GET /control/applications/myapp/restart |
| TLS cert rotation | Reload nginx (brief pause or restart) | PUT new cert bundle, update listener atomically |
| Config state | Multiple files across filesystem | Single JSON document, queryable at any time |
Performance: Honest Numbers
NGINX Unit is not faster than a tuned nginx + Gunicorn stack. Independent benchmarks (2024) show Unit with WSGI approximately 0.7% slower than nginx + Gunicorn at equivalent concurrency, and Unit with ASGI approximately 7% slower. At typical application concurrency levels, this is irrelevant — application code dominates latency, not the server framework.
Where Unit’s performance story is genuinely good: startup time for dynamic scaling. Unit spawns spare processes proactively, meaning fresh workers are ready before a traffic spike hits. There is no cold-start penalty for the first request to an idle worker.
What Unit Lacks vs. NGINX (the proxy)
| Feature | NGINX (proxy) | NGINX Unit |
|---|---|---|
| HTTP response caching | Full (proxy_cache, fastcgi_cache) | None |
| Rate limiting | Advanced (limit_req, limit_conn) | None built-in |
| GeoIP routing | Yes (module) | No |
| njs scripting | Yes (mature) | Limited (njs support added 1.29.0 but less mature) |
| HTTP/3 / QUIC | Yes (since 1.25.0) | No |
| Compression | Yes (gzip, brotli) | HTTP compression added 1.35.0 |
| Load balancing algorithms | Least connections, IP hash, random | Round-robin with weights only |
| Ecosystem / modules | Massive | Minimal |
| Production track record | 15+ years, every scale | ~7 years, smaller adoption, now archived |
| Active development | Yes | No — archived October 2025 |
Comparison Table: Full Stack
| NGINX + uWSGI | NGINX + Gunicorn | NGINX Unit | Caddy | Standalone Gunicorn | |
|---|---|---|---|---|---|
| Language support | Python only | Python only | 8 languages | Reverse proxy only | Python only |
| Config method | Files + reload | Files + reload | REST API | Files + reload | CLI flags + files |
| Live reload | Yes (SIGHUP) | Yes (SIGHUP) | Yes (REST, zero-downtime) | Yes (file watch) | Signal-based |
| TLS management | Manual or certbot | Manual or certbot | Manual (cert API) | Automatic (Let’s Encrypt built-in) | No TLS |
| HTTP caching | Yes (nginx) | Yes (nginx) | No | Limited | No |
| Process supervision | External required | External required | Built-in | Built-in | External required |
| Active development | Yes (NGINX) | Yes | No (archived 2025) | Yes | Yes |
| Maturity | Very high | Very high | Medium (archived) | High | High |
| Best for | Complex, high-traffic sites | Python API services | Multi-language apps, live config | HTTPS-first sites, Go services | Simple Python deployments |
When to Use NGINX Unit (in 2026)
Despite being archived, Unit still makes sense in these scenarios:
- Existing deployments that are working and do not need new features or security patches for the remaining CVE surface
- Internal tools and home lab where the threat model does not require active CVE patching
- Multi-language services on a single host where you want one process manager for Python, PHP, and Node.js simultaneously
- Learning / architecture reference — the REST-API config model is genuinely elegant and worth understanding even if you then implement it with a different tool
- Short-lived environments (CI, dev, ephemeral VMs) where the EOL status has no practical impact
When to Use Something Else
- New production services exposed to the internet — you need active security maintenance. Use nginx + Gunicorn/uWSGI, Caddy, or Traefik.
- Python ASGI-heavy workloads — consider running Uvicorn directly behind nginx (standard approach for FastAPI/Starlette in 2026) or using Granian (a newer Rust-based ASGI/WSGI server with active development)
- Kubernetes-native environments — use the standard ingress + deployment pattern; Unit’s live-config-reload value proposition is largely moot at the pod level
- Sites needing HTTP caching, advanced rate limiting, or HTTP/3 — NGINX Unit has none of these
Part 17: Complete Working Example — Multi-App Server
Here is a complete configuration that runs a FastAPI application, a PHP Laravel app, and serves a static SPA, all from one Unit instance with TLS:
|
|
The Archived Status — What It Actually Means for You
To be direct about the October 2025 archival: F5/NGINX killed a product that was technically excellent but commercially niche. The REST-API config model was ahead of its time, the multi-language support was genuinely useful, and the zero-downtime reload story was cleaner than any SIGHUP-based alternative. But the adoption never reached the scale needed to justify continued investment when NGINX the proxy, NGINX Ingress, and NGINX Gateway Fabric were all competing for the same engineering resources.
The practical implications:
Security: The repository notice explicitly states “security vulnerabilities may be unaddressed.” For internet-facing services, this is a real concern. CVEs will accumulate. Plan accordingly.
Longevity: Version 1.35.0 runs today and will continue running. The binary does not expire. The API will not change. For internal tooling and closed-network deployments, this is a non-issue.
Community: The GitHub repo is archived (read-only), so forks are possible. A community fork may emerge for users who need continued maintenance; it had not materialized as of the archival date, but the codebase is well-structured and forkable under its Apache 2.0 license.
The ideas live on: The REST-API-first configuration model pioneered by Unit has influenced other projects. Understanding it is valuable regardless of whether you run Unit in production.
Quick Reference
|
|
Sources:
- NGINX Unit GitHub Repository (archived)
- NGINX Unit Configuration Documentation
- NGINX Unit Control API Documentation
- NGINX Unit Installation Guide
- NGINX Unit Changelog
- unitctl CLI Documentation
- Unit 1.34.0 Release Notes (OpenTelemetry)
- Unit 1.34.1 Release Notes
- Server-Side WebAssembly with NGINX Unit
- NGINX Unit Docker How-To
- DjangoTricks: Django Project on NGINX Unit
Comments