LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

NGINX Unit: The Application Server Nobody Talks About

nginx-unitpythonwsgiasgifastapidjangophpnodejsgojavaapplication-serverdevopsdockerkubernetesreverse-proxywebassemblyinfrastructure
Contents

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.

1
2
# The entire Unit config, in one HTTP call
curl --unix-socket /var/run/control.unit.sock http://localhost/config | python3 -m json.tool

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:

  1. Parses and validates the JSON payload
  2. Diffs the new config against the current in-memory state
  3. Signals the router to start directing new requests to new-config workers
  4. Allows existing workers (old config) to drain their in-flight requests
  5. 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 1. Add the signing key
curl --output /usr/share/keyrings/nginx-keyring.gpg \
  https://unit.nginx.org/keys/nginx-keyring.gpg

# 2. Add the repository (Ubuntu 24.04 Noble example)
cat > /etc/apt/sources.list.d/unit.list <<EOF
deb [signed-by=/usr/share/keyrings/nginx-keyring.gpg] \
    https://packages.nginx.org/unit/ubuntu/ noble unit
deb-src [signed-by=/usr/share/keyrings/nginx-keyring.gpg] \
    https://packages.nginx.org/unit/ubuntu/ noble unit
EOF

# 3. Install the base daemon plus desired language modules
apt update
apt install unit
apt install unit-python3.12 unit-php unit-go unit-perl unit-ruby unit-wasm

# 4. Start and enable
systemctl enable --now unit

For Ubuntu 22.04 Jammy, replace noble with jammy. Debian 12 Bookworm uses bookworm.

RHEL / CentOS / Rocky Linux

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 1. Create the repo file
cat > /etc/yum.repos.d/unit.repo <<EOF
[unit]
name=unit repo
baseurl=https://packages.nginx.org/unit/rhel/\$releasever/\$basearch/
gpgkey=https://unit.nginx.org/keys/nginx-keyring.gpg
gpgcheck=1
enabled=1
EOF

# 2. Install
yum install unit
yum install unit-devel unit-go unit-php unit-python39 unit-wasm

systemctl enable --now unit

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:

1
2
apt install unit-dev          # or: yum install unit-devel
npm install -g --unsafe-perm unit-http

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Pull a specific language variant
docker pull unit:1.35.0-python3.12
docker pull unit:1.35.0-php8.3
docker pull unit:1.35.0-node20
docker pull unit:1.35.0-go1.22
docker pull unit:1.35.0-jsc17      # Java (Eclipse Temurin 17)
docker pull unit:1.35.0-ruby3.3
docker pull unit:1.35.0-wasm
docker pull unit:1.35.0-minimal    # No language modules; add your own

# From ECR Public
docker pull public.ecr.aws/nginx/unit:1.35.0-python3.12

The minimal variant gives you the Unit daemon only; install additional language modules on top to build a multi-language image.

Building from Source

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
git clone -b 1.35.0 https://github.com/nginx/unit
cd unit

# Configure with desired language modules
./configure --prefix=/opt/unit \
            --control=unix:/var/run/control.unit.sock \
            --modules=/opt/unit/modules \
            --log=/var/log/unit.log \
            --pid=/var/run/unit.pid

./configure python --config=python3-config
./configure php
./configure go
./configure nodejs

make
make install

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:

1
2
3
4
5
6
7
8
{
  "listeners":   { ... },
  "routes":      [ ... ],
  "applications":{ ... },
  "upstreams":   { ... },
  "settings":    { ... },
  "access_log":  { ... }
}

Reading Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
SOCK=/var/run/control.unit.sock

# Full config
curl -s --unix-socket $SOCK http://localhost/config | python3 -m json.tool

# Just the listeners
curl -s --unix-socket $SOCK http://localhost/config/listeners

# A specific application
curl -s --unix-socket $SOCK http://localhost/config/applications/myapp

# Build info and version
curl -s --unix-socket $SOCK http://localhost/ | python3 -m json.tool

Writing Configuration — Atomic Full Replace

1
2
3
4
5
# Replace the entire config at once
curl -X PUT \
     --data-binary @/path/to/config.json \
     --unix-socket $SOCK \
     http://localhost/config

Success response:

1
2
3
{
    "success": "Reconfiguration done."
}

Incremental Updates — Surgical PUT

The most powerful workflow: update a single section without touching anything else:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Add or replace a single application definition
curl -X PUT \
     --data-binary @app-config.json \
     --unix-socket $SOCK \
     http://localhost/config/applications/myapp

# Update only the listener for port 80
curl -X PUT \
     -d '{"pass": "routes/main"}' \
     --unix-socket $SOCK \
     http://localhost/config/listeners/*:80

# Update just the process count of a running app
curl -X PUT \
     -d '{"max": 16, "spare": 4, "idle_timeout": 60}' \
     --unix-socket $SOCK \
     http://localhost/config/applications/myapp/processes

Appending to Arrays (POST)

1
2
3
4
5
# Add a new route step to an existing route array
curl -X POST \
     -d '{"match": {"uri": "/health"}, "action": {"return": 200}}' \
     --unix-socket $SOCK \
     http://localhost/config/routes/main

Deleting Configuration

1
2
3
4
5
6
7
8
9
# Remove a listener
curl -X DELETE \
     --unix-socket $SOCK \
     http://localhost/config/listeners/*:8080

# Remove an entire application
curl -X DELETE \
     --unix-socket $SOCK \
     http://localhost/config/applications/oldapp

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Show running Unit instances
unitctl instances

# List all configured listeners
unitctl listeners

# Restart a specific application (drain in-flight, spawn fresh workers)
unitctl apps restart myapp

# Open current config in $EDITOR for interactive editing
unitctl edit

# Export full config as a tarball
unitctl export config.tar.gz

# Import config from a directory of JSON files
unitctl import /path/to/config-dir/

Part 5: Application Configuration — Language by Language

Python (WSGI)

The classic Flask/Django/any-WSGI-app setup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "listeners": {
    "*:8000": {
      "pass": "applications/flask_app"
    }
  },
  "applications": {
    "flask_app": {
      "type": "python 3.12",
      "working_directory": "/var/www/myapp/",
      "path": "/var/www/myapp/",
      "home": "/var/www/myapp/.venv/",
      "module": "wsgi",
      "callable": "application",
      "processes": {
        "max": 8,
        "spare": 2,
        "idle_timeout": 30
      },
      "environment": {
        "FLASK_ENV": "production",
        "DATABASE_URL": "postgresql://user:pass@db:5432/mydb",
        "SECRET_KEY": "your-secret-here"
      }
    }
  }
}

Key fields:

  • type"python" uses the system default; "python 3.12" locks to a specific minor version
  • module — 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, no source activate needed
  • path — Prepended to sys.path; can be a list for multiple lookup paths

Django WSGI:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "applications": {
    "django": {
      "type": "python 3.12",
      "path": "/var/www/myproject/",
      "home": "/var/www/myproject/.venv/",
      "module": "myproject.wsgi",
      "callable": "application",
      "environment": {
        "DJANGO_SETTINGS_MODULE": "myproject.settings.production"
      }
    }
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "listeners": {
    "*:8000": {
      "pass": "applications/fastapi_app"
    }
  },
  "applications": {
    "fastapi_app": {
      "type": "python 3.12",
      "protocol": "asgi",
      "working_directory": "/var/www/api/",
      "path": "/var/www/api/",
      "home": "/var/www/api/.venv/",
      "module": "main",
      "callable": "app",
      "threads": 4,
      "processes": {
        "max": 4,
        "spare": 1,
        "idle_timeout": 60
      },
      "environment": {
        "APP_ENV": "production"
      }
    }
  }
}

The threads field controls async workers per process — useful for I/O-bound ASGI apps.

Django ASGI (Django 3.0+):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "applications": {
    "django_asgi": {
      "type": "python 3.12",
      "protocol": "asgi",
      "path": "/var/www/myproject/",
      "home": "/var/www/myproject/.venv/",
      "module": "myproject.asgi",
      "callable": "application",
      "environment": {
        "DJANGO_SETTINGS_MODULE": "myproject.settings"
      }
    }
  }
}

Python — Multiple Targets (Monorepo Pattern)

Unit supports multiple WSGI/ASGI entry points from one application definition using targets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
  "applications": {
    "monorepo": {
      "type": "python 3.12",
      "working_directory": "/var/www/project/",
      "home": "/var/www/project/.venv/",
      "targets": {
        "api": {
          "module": "api.wsgi",
          "callable": "application"
        },
        "admin": {
          "module": "admin.wsgi",
          "callable": "application"
        }
      }
    }
  },
  "routes": [
    {
      "match": {"uri": "/api/*"},
      "action": {"pass": "applications/monorepo/api"}
    },
    {
      "match": {"uri": "/admin/*"},
      "action": {"pass": "applications/monorepo/admin"}
    }
  ]
}

PHP

PHP apps run directly — no PHP-FPM socket, no fastcgi_pass, no separate process manager. Unit embeds the PHP runtime:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
{
  "listeners": {
    "*:80": {
      "pass": "routes"
    }
  },
  "routes": [
    {
      "match": {
        "uri": "!/index.php"
      },
      "action": {
        "share": "/var/www/app/public$uri",
        "fallback": {
          "pass": "applications/laravel"
        }
      }
    }
  ],
  "applications": {
    "laravel": {
      "type": "php 8.3",
      "root": "/var/www/app/public/",
      "script": "index.php",
      "processes": {
        "max": 20,
        "spare": 5,
        "idle_timeout": 60
      },
      "options": {
        "file": "/etc/php/8.3/cli/php.ini",
        "admin": {
          "memory_limit": "256M",
          "max_execution_time": "30",
          "expose_php": "0"
        },
        "user": {
          "display_errors": "0"
        }
      },
      "environment": {
        "APP_ENV": "production",
        "APP_KEY": "base64:your-key-here"
      }
    }
  }
}

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 override
  • options.user — PHP ini directives that can be overridden per-script

WordPress (no script field, URI-mapped files):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "applications": {
    "wordpress": {
      "type": "php",
      "root": "/var/www/wordpress/",
      "index": "index.php",
      "processes": 10
    }
  }
}

Node.js

Node.js apps use the "external" type and require the unit-http npm package, which intercepts the built-in http module:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "listeners": {
    "*:3000": {
      "pass": "applications/express_app"
    }
  },
  "applications": {
    "express_app": {
      "type": "external",
      "working_directory": "/var/www/nodeapp/",
      "executable": "/usr/bin/env",
      "arguments": [
        "node",
        "--loader",
        "unit-http/loader.mjs",
        "--require",
        "unit-http/loader",
        "app.js"
      ],
      "processes": 4,
      "environment": {
        "NODE_ENV": "production",
        "PORT": "3000"
      }
    }
  }
}

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:

1
2
3
4
{
  "executable": "app.js",
  "working_directory": "/var/www/nodeapp/"
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
    "fmt"
    "net/http"
    "unit.nginx.org/go"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello from Unit + Go!")
}

func main() {
    http.HandleFunc("/", handler)
    // Replace http.ListenAndServe with unit.ListenAndServe
    unit.ListenAndServe(":8080", nil)
}

Build:

1
2
go get unit.nginx.org/go
go build -o /var/www/goapp/bin/server ./...

Configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "listeners": {
    "*:8080": {
      "pass": "applications/go_app"
    }
  },
  "applications": {
    "go_app": {
      "type": "external",
      "working_directory": "/var/www/goapp/",
      "executable": "bin/server",
      "arguments": ["--config", "/etc/goapp/config.yaml"],
      "processes": 2,
      "environment": {
        "GO_ENV": "production"
      }
    }
  }
}

Java (Spring Boot / WAR files)

Java support targets standard WAR-packaged applications running under Eclipse Jetty (embedded):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
  "listeners": {
    "*:8080": {
      "pass": "applications/springboot"
    }
  },
  "applications": {
    "springboot": {
      "type": "java 17",
      "working_directory": "/var/www/java/",
      "webapp": "/var/www/java/target/demo-0.0.1-SNAPSHOT.war",
      "classpath": [
        "/var/www/java/lib/extra.jar"
      ],
      "options": [
        "-Dspring.profiles.active=production",
        "-Xmx512m",
        "-Xms128m",
        "-Dfile.encoding=UTF-8"
      ],
      "threads": 16,
      "processes": 2
    }
  }
}

Key Java fields:

  • webapp — Path to the WAR file
  • options — 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "routes": {
    "main": [
      {
        "match": {
          "uri": "/api/*"
        },
        "action": {
          "pass": "applications/api_app"
        }
      },
      {
        "match": {
          "uri": "/static/*"
        },
        "action": {
          "share": "/var/www/static$uri"
        }
      },
      {
        "action": {
          "pass": "applications/web_app"
        }
      }
    ]
  }
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  "match": {
    "method": ["GET", "HEAD"],
    "host": "*.example.com",
    "uri": ["/api/*", "!/api/public/*"],
    "scheme": "https",
    "headers": [
      {"Authorization": "*Bearer*"},
      {"X-API-Version": "2"}
    ],
    "cookies": {
      "session": "*valid*"
    },
    "arguments": {
      "format": "json"
    },
    "source": ["192.168.1.0/24", "10.0.0.0/8"],
    "destination": "*:443"
  }
}

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:

1
{"action": {"pass": "applications/myapp"}}

Return HTTP status:

1
{"action": {"return": 403}}

Redirect:

1
2
3
4
5
6
{
  "action": {
    "return": 301,
    "location": "https://$host$request_uri"
  }
}

Serve static files:

1
2
3
4
5
6
7
8
9
{
  "action": {
    "share": "/var/www/static$uri",
    "index": "index.html",
    "fallback": {
      "pass": "applications/spa"
    }
  }
}

Proxy to upstream:

1
{"action": {"proxy": "http://127.0.0.1:9200"}}

URI rewrite + pass:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "action": {
    "rewrite": "/app$uri",
    "pass": "applications/backend",
    "response_headers": {
      "X-Served-By": "unit",
      "Cache-Control": "max-age=3600"
    }
  }
}

Advanced Routing: Conditional if (1.33.0+)

1
2
3
4
5
6
7
8
9
{
  "match": {
    "uri": "/admin/*",
    "if": "$cookie_session != ''"
  },
  "action": {
    "pass": "applications/admin"
  }
}

HTTP-to-HTTPS Redirect Route

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "listeners": {
    "*:80": {"pass": "routes/http_redirect"},
    "*:443": {
      "pass": "routes/main",
      "tls": {"certificate": "my_cert"}
    }
  },
  "routes": {
    "http_redirect": [
      {
        "action": {
          "return": 301,
          "location": "https://$host$request_uri"
        }
      }
    ]
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Combine Let's Encrypt files into one bundle
cat /etc/letsencrypt/live/example.com/fullchain.pem \
    /etc/letsencrypt/live/example.com/privkey.pem \
    > /tmp/unit-bundle.pem

# Upload it with a name
curl -X PUT \
     --data-binary @/tmp/unit-bundle.pem \
     --unix-socket /var/run/control.unit.sock \
     http://localhost/certificates/example_com

# Response: {"success": "Certificate chain uploaded."}

The bundle name (example_com) is arbitrary — you reference it by this name in listener config.

Viewing Uploaded Certificates

1
2
3
4
5
6
7
# List all bundles (shows validity dates, subject, issuer)
curl -s --unix-socket /var/run/control.unit.sock \
     http://localhost/certificates | python3 -m json.tool

# Details on a specific bundle
curl -s --unix-socket /var/run/control.unit.sock \
     http://localhost/certificates/example_com

Configuring a TLS Listener

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "listeners": {
    "*:443": {
      "pass": "routes/main",
      "tls": {
        "certificate": "example_com"
      }
    }
  }
}

SNI — Multiple Certificates on One Listener

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "listeners": {
    "*:443": {
      "pass": "routes/main",
      "tls": {
        "certificate": ["example_com", "api_example_com", "cdn_example_com"]
      }
    }
  }
}

Unit selects the appropriate certificate based on the SNI hostname from the TLS ClientHello.

Advanced TLS Settings

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "listeners": {
    "*:443": {
      "pass": "routes/main",
      "tls": {
        "certificate": "example_com",
        "session": {
          "cache_size": 10240,
          "timeout": 300,
          "tickets": true
        },
        "conf_commands": {
          "MinProtocol": "TLSv1.2",
          "CipherString": "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384"
        }
      }
    }
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 1. Renew the certificate externally (certbot, acme.sh, etc.)
certbot renew --quiet

# 2. Rebuild the bundle with the renewed cert
cat /etc/letsencrypt/live/example.com/fullchain.pem \
    /etc/letsencrypt/live/example.com/privkey.pem > /tmp/new-bundle.pem

# 3. Upload as a new bundle name
curl -X PUT \
     --data-binary @/tmp/new-bundle.pem \
     --unix-socket /var/run/control.unit.sock \
     http://localhost/certificates/example_com_v2

# 4. Update the listener to reference the new bundle (live, zero-downtime)
curl -X PUT \
     -d '"example_com_v2"' \
     --unix-socket /var/run/control.unit.sock \
     http://localhost/config/listeners/*:443/tls/certificate

# 5. Delete the old bundle
curl -X DELETE \
     --unix-socket /var/run/control.unit.sock \
     http://localhost/certificates/example_com

Part 8: Static File Serving

The share action serves files directly from disk without involving any application process:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "routes": [
    {
      "match": {
        "uri": ["/css/*", "/js/*", "/img/*", "*.ico", "*.svg"]
      },
      "action": {
        "share": "/var/www/app/public$uri"
      }
    },
    {
      "action": {
        "pass": "applications/webapp"
      }
    }
  ]
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "action": {
    "share": [
      "/var/www/app/public$uri",
      "/var/www/app/public${uri}index.html"
    ],
    "fallback": {
      "pass": "applications/spa"
    }
  }
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "action": {
    "share": "/var/www/public$uri",
    "types": [
      "image/*",
      "text/css",
      "application/javascript",
      "!application/x-httpd-php"
    ]
  }
}

Patterns not matched by types return 403 unless a fallback is configured.

Path Confinement (chroot)

Requires Linux kernel 5.6+:

1
2
3
4
5
6
7
8
{
  "action": {
    "share": "/data/www$uri",
    "chroot": "/data/www/",
    "follow_symlinks": false,
    "traverse_mounts": false
  }
}

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

1
2
3
{
  "processes": 8
}

Unit spawns exactly 8 workers at startup and maintains exactly 8 throughout. If a worker crashes, Unit replaces it.

Dynamic Process Scaling

1
2
3
4
5
6
7
{
  "processes": {
    "max": 32,
    "spare": 4,
    "idle_timeout": 30
  }
}
  • spare — Minimum idle workers to keep warm (ready to accept requests immediately)
  • max — Hard ceiling on worker count
  • idle_timeout — Seconds an idle worker beyond spare count 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

1
2
3
4
5
6
{
  "limits": {
    "timeout": 30,
    "requests": 5000
  }
}
  • 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:

  1. New workers are spawned with the updated configuration
  2. The router directs new incoming requests to the new workers
  3. Old workers continue handling their in-flight requests
  4. 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):

1
2
3
curl -X GET \
     --unix-socket /var/run/control.unit.sock \
     http://localhost/control/applications/myapp/restart

Part 10: Load Balancing with Upstreams

Unit supports round-robin load balancing to pools of backend servers via the upstreams section:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "upstreams": {
    "backend_pool": {
      "servers": {
        "192.168.1.10:8080": {"weight": 2},
        "192.168.1.11:8080": {"weight": 1},
        "192.168.1.12:8080": {"weight": 1}
      }
    }
  },
  "listeners": {
    "*:80": {
      "pass": "upstreams/backend_pool"
    }
  }
}

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+)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "access_log": {
    "path": "/var/log/unit/access.log",
    "format": {
      "time": "$time_local",
      "method": "$method",
      "uri": "$request_uri",
      "status": "$status",
      "bytes": "$body_bytes_sent",
      "duration_ms": "$request_time",
      "client": "$remote_addr",
      "host": "$host",
      "request_id": "$request_id"
    }
  }
}

Omit format for the default Combined Log Format string. When format is a JSON object, Unit writes JSON lines.

Conditional Logging (1.32.0+)

1
2
3
4
5
6
{
  "access_log": {
    "path": "/var/log/unit/access.log",
    "if": "$status != '200' && $uri != '/healthz'"
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "settings": {
    "telemetry": {
      "endpoint": "http://otel-collector:4318/v1/traces",
      "protocol": "http",
      "batch_size": 512,
      "sampling_ratio": 1.0
    }
  }
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "applications": {
    "wasm_app": {
      "type": "wasm-wasi-component",
      "component": "/var/www/wasm/app.wasm",
      "processes": {
        "max": 4,
        "spare": 1,
        "idle_timeout": 30
      },
      "access": {
        "filesystem": ["/tmp", "/var/wasm-data"]
      }
    }
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
  "applications": {
    "isolated_app": {
      "type": "python 3.12",
      "module": "wsgi",
      "isolation": {
        "namespaces": {
          "credential": true,
          "mount": true,
          "network": false,
          "pid": true,
          "uname": true
        },
        "rootfs": "/var/chroot/myapp/",
        "uidmap": [
          {
            "host": 1000,
            "container": 0,
            "size": 1000
          }
        ],
        "cgroup": {
          "path": "unit/myapp"
        }
      }
    }
  }
}
  • credential: true — User namespace isolation; app runs as a different UID inside
  • mount: true — Mount namespace; the app gets its own mount points
  • pid: true — PID namespace; app processes see only their own process tree
  • rootfs — Chroot the application to this directory
  • cgroup.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:

  • *.pem files as TLS certificate bundles
  • *.json files as configuration snippets
  • *.sh files as shell scripts

Subsequent starts skip this initialization (state is already populated), which means docker restart preserves your configuration.

Python/Flask Docker Example

Dockerfile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
FROM unit:1.35.0-python3.12

WORKDIR /www

# Install dependencies
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt

# Copy application
COPY app/ /www/app/

# Copy Unit config (loaded on first startup)
COPY unit-config.json /docker-entrypoint.d/config.json

EXPOSE 8000

unit-config.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  "listeners": {
    "*:8000": {
      "pass": "applications/flask"
    }
  },
  "applications": {
    "flask": {
      "type": "python 3.12",
      "path": "/www/",
      "module": "app.wsgi",
      "callable": "application",
      "processes": {
        "max": 4,
        "spare": 1,
        "idle_timeout": 30
      }
    }
  }
}

app/wsgi.py:

1
2
3
4
5
6
7
8
9
from flask import Flask

app = Flask(__name__)

@app.route("/")
def index():
    return "Hello from Unit + Flask!"

application = app  # Unit looks for this name

Docker Compose Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
version: "3.9"

services:

  unit:
    image: unit:1.35.0-python3.12
    container_name: unit_app
    ports:
      - "8000:8000"
      - "8443:8443"
    volumes:
      # Persist Unit state (config survives container restarts)
      - unit_state:/var/lib/unit
      # Access logs
      - ./logs/:/var/log/unit/
      # Application code (dev: bind mount; prod: bake into image)
      - ./app/:/www/app/:ro
      # Initial config loaded on first startup
      - ./config/unit.json:/docker-entrypoint.d/config.json:ro
    environment:
      - DATABASE_URL=postgresql://user:pass@postgres:5432/mydb
      - APP_ENV=production
    depends_on:
      - postgres
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
    volumes:
      - pg_data:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  unit_state:
  pg_data:

Updating the running config after startup (without rebuilding):

1
2
3
4
5
# Update application config live, no restart
docker exec unit_app curl -X PUT \
  --data-binary @/docker-entrypoint.d/config.json \
  --unix-socket /var/run/control.unit.sock \
  http://localhost/config

Multi-Language Docker Image

If you need to run multiple language runtimes from one container (unusual but valid for local dev or small services):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
FROM unit:1.35.0-minimal

RUN apt-get update && apt-get install -y \
    curl gnupg2 apt-transport-https lsb-release \
  && curl -o /usr/share/keyrings/nginx-keyring.gpg \
       https://unit.nginx.org/keys/nginx-keyring.gpg \
  && echo "deb [signed-by=/usr/share/keyrings/nginx-keyring.gpg] \
       https://packages.nginx.org/unit/debian/ $(lsb_release -cs) unit" \
       > /etc/apt/sources.list.d/unit.list \
  && apt-get update && apt-get install -y \
       unit-python3.12 \
       unit-php \
  && apt-get remove -y curl gnupg2 apt-transport-https lsb-release \
  && apt-get autoremove --purge -y \
  && rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/*.list

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

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: unit-config
  namespace: default
data:
  config.json: |
    {
      "listeners": {
        "*:8000": {
          "pass": "applications/api"
        }
      },
      "applications": {
        "api": {
          "type": "python 3.12",
          "path": "/app/",
          "module": "wsgi",
          "callable": "application",
          "processes": {
            "max": 4,
            "spare": 1,
            "idle_timeout": 30
          }
        }
      }
    }

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: unit-app
  namespace: default
spec:
  replicas: 2
  selector:
    matchLabels:
      app: unit-app
  template:
    metadata:
      labels:
        app: unit-app
    spec:
      initContainers:
        # Load config on first start via init container
        - name: unit-config-loader
          image: unit:1.35.0-python3.12
          command: ["sh", "-c"]
          args:
            - |
              # Wait for unit to start
              until curl -sf --unix-socket /var/run/control.unit.sock \
                         http://localhost/ >/dev/null 2>&1; do
                sleep 0.5
              done
              curl -X PUT \
                --data-binary @/etc/unit/config.json \
                --unix-socket /var/run/control.unit.sock \
                http://localhost/config
          volumeMounts:
            - name: unit-config
              mountPath: /etc/unit/
            - name: unit-socket
              mountPath: /var/run/

      containers:
        - name: unit
          image: unit:1.35.0-python3.12
          ports:
            - containerPort: 8000
              name: http
          volumeMounts:
            - name: app-code
              mountPath: /app/
            - name: unit-state
              mountPath: /var/lib/unit/
            - name: unit-socket
              mountPath: /var/run/
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 1000m
              memory: 512Mi
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8000
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8000
            initialDelaySeconds: 3
            periodSeconds: 5

      volumes:
        - name: unit-config
          configMap:
            name: unit-config
        - name: app-code
          # In production: use an initContainer to pull from git or a PVC
          emptyDir: {}
        - name: unit-state
          emptyDir: {}
        - name: unit-socket
          emptyDir: {}

---
apiVersion: v1
kind: Service
metadata:
  name: unit-app
  namespace: default
spec:
  selector:
    app: unit-app
  ports:
    - port: 80
      targetPort: 8000
  type: ClusterIP

Notes on the Kubernetes Pattern

The standard Kubernetes approach for Unit:

  1. Bake your application code into the Docker image (don’t use bind mounts in production)
  2. Ship the initial config.json either baked into the image (as /docker-entrypoint.d/config.json) or via ConfigMap
  3. Mount the Unit state directory on an emptyDir or a PVC if you want config changes to survive pod restarts
  4. Expose the control socket via an emptyDir shared 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:

  1. Existing deployments that are working and do not need new features or security patches for the remaining CVE surface
  2. Internal tools and home lab where the threat model does not require active CVE patching
  3. Multi-language services on a single host where you want one process manager for Python, PHP, and Node.js simultaneously
  4. Learning / architecture reference — the REST-API config model is genuinely elegant and worth understanding even if you then implement it with a different tool
  5. Short-lived environments (CI, dev, ephemeral VMs) where the EOL status has no practical impact

When to Use Something Else

  1. New production services exposed to the internet — you need active security maintenance. Use nginx + Gunicorn/uWSGI, Caddy, or Traefik.
  2. 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)
  3. Kubernetes-native environments — use the standard ingress + deployment pattern; Unit’s live-config-reload value proposition is largely moot at the pod level
  4. 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:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
#!/bin/bash
# Full setup script for a multi-app Unit server

SOCK=/var/run/control.unit.sock

# 1. Upload TLS certificate
cat /etc/letsencrypt/live/example.com/fullchain.pem \
    /etc/letsencrypt/live/example.com/privkey.pem \
    > /tmp/cert-bundle.pem

curl -X PUT \
     --data-binary @/tmp/cert-bundle.pem \
     --unix-socket $SOCK \
     http://localhost/certificates/example_com

# 2. Push the full configuration
curl -X PUT \
     --data-binary @/dev/stdin \
     --unix-socket $SOCK \
     http://localhost/config << 'CONFIG'
{
  "listeners": {
    "*:80": {
      "pass": "routes/http_to_https"
    },
    "*:443": {
      "pass": "routes/main",
      "tls": {
        "certificate": "example_com",
        "session": {
          "cache_size": 8192,
          "timeout": 300,
          "tickets": true
        }
      }
    }
  },
  "routes": {
    "http_to_https": [
      {
        "action": {
          "return": 301,
          "location": "https://$host$request_uri"
        }
      }
    ],
    "main": [
      {
        "match": {
          "host": "api.example.com"
        },
        "action": {
          "pass": "applications/fastapi"
        }
      },
      {
        "match": {
          "host": "app.example.com"
        },
        "action": {
          "pass": "routes/laravel"
        }
      },
      {
        "match": {
          "host": "static.example.com"
        },
        "action": {
          "share": "/var/www/spa/dist$uri",
          "index": "index.html",
          "fallback": {
            "share": "/var/www/spa/dist/index.html"
          }
        }
      }
    ],
    "laravel": [
      {
        "match": {
          "uri": "!/index.php"
        },
        "action": {
          "share": "/var/www/laravel/public$uri",
          "fallback": {
            "pass": "applications/laravel"
          }
        }
      }
    ]
  },
  "applications": {
    "fastapi": {
      "type": "python 3.12",
      "protocol": "asgi",
      "working_directory": "/var/www/api/",
      "path": "/var/www/api/",
      "home": "/var/www/api/.venv/",
      "module": "main",
      "callable": "app",
      "threads": 4,
      "processes": {
        "max": 8,
        "spare": 2,
        "idle_timeout": 30
      },
      "environment": {
        "APP_ENV": "production",
        "DATABASE_URL": "postgresql://user:pass@localhost:5432/apidb"
      }
    },
    "laravel": {
      "type": "php 8.3",
      "root": "/var/www/laravel/public/",
      "script": "index.php",
      "processes": {
        "max": 20,
        "spare": 4,
        "idle_timeout": 60
      },
      "options": {
        "admin": {
          "memory_limit": "256M",
          "expose_php": "0"
        }
      },
      "environment": {
        "APP_ENV": "production",
        "APP_KEY": "base64:your-laravel-key-here"
      }
    }
  },
  "access_log": {
    "path": "/var/log/unit/access.log",
    "format": {
      "time": "$time_local",
      "host": "$host",
      "method": "$method",
      "uri": "$request_uri",
      "status": "$status",
      "bytes": "$body_bytes_sent",
      "ms": "$request_time",
      "client": "$remote_addr",
      "id": "$request_id"
    }
  }
}
CONFIG

echo "Done. Verify with:"
echo "  curl -s --unix-socket $SOCK http://localhost/config | python3 -m json.tool"

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Socket path (Debian/Ubuntu)
SOCK=/var/run/control.unit.sock

# Get full config
curl -s --unix-socket $SOCK http://localhost/config | python3 -m json.tool

# Get version/build info
curl -s --unix-socket $SOCK http://localhost/

# Get request statistics
curl -s --unix-socket $SOCK http://localhost/status

# Replace full config
curl -X PUT --data-binary @config.json --unix-socket $SOCK http://localhost/config

# Update a single application
curl -X PUT --data-binary @app.json --unix-socket $SOCK http://localhost/config/applications/myapp

# Update process count live
curl -X PUT -d '{"max":16,"spare":4,"idle_timeout":60}' \
     --unix-socket $SOCK http://localhost/config/applications/myapp/processes

# Delete a listener
curl -X DELETE --unix-socket $SOCK http://localhost/config/listeners/*:8080

# Upload TLS certificate
curl -X PUT --data-binary @bundle.pem --unix-socket $SOCK http://localhost/certificates/mycert

# Restart application workers (graceful drain)
curl -X GET --unix-socket $SOCK http://localhost/control/applications/myapp/restart

# List uploaded certificates
curl -s --unix-socket $SOCK http://localhost/certificates | python3 -m json.tool

Sources:

Comments