Last updated: March 22, 2026

How to Set Up Nginx Unit for App Deployment

Nginx Unit is an application server that handles multiple languages (Python, Node.js, Go, PHP, Ruby) through a single unified REST API. Unlike traditional Nginx, you reconfigure it with HTTP calls instead of editing files and reloading — which means zero-downtime deployments become a single curl command.

This guide covers installation, deploying Python and Node.js apps, TLS termination, routing multiple apps, Go deployment, process tuning, CI/CD integration via SSH tunnel, health checks, and logging.


Why Nginx Unit Instead of a Traditional App Server

The standard deployment stack for Python or Node.js usually involves Gunicorn or PM2 sitting behind Nginx with a proxy_pass configuration. That works, but it means maintaining two separate configuration systems (Nginx config files and the app server config), two separate reload mechanisms, and two separate places where deployments can break.

Nginx Unit collapses this into one system with one configuration API. Some specific advantages:

The tradeoff: Unit’s community is smaller than Gunicorn’s or PM2’s. When something breaks, the debugging path is less well-documented. This guide includes a troubleshooting section to cover the common cases.


Installation

# Ubuntu 22.04 / 24.04
curl --output /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/ubuntu/ $(lsb_release -cs) unit" \
  > /etc/apt/sources.list.d/unit.list

sudo apt update
sudo apt install unit unit-python3.12 unit-nodejs unit-go

sudo systemctl enable unit && sudo systemctl start unit

Verify Unit is running:

sudo curl --unix-socket /var/run/control.unit.sock http://localhost/
# Returns current config as JSON

The unit-python3.12, unit-nodejs, and unit-go packages install language-specific modules. Install only the modules you need — each adds a small binary that Unit loads dynamically.


Core Concepts

Unit uses a JSON config tree with three main sections:

All changes go through the control API socket. No files to edit, no service restarts.

The config is persistent — Unit stores its state and restores it on restart. You do not need to re-apply your config after a host reboot.


Deploying a Python (FastAPI) App

sudo mkdir -p /var/www/fastapi-app
sudo chown unit:unit /var/www/fastapi-app

python3.12 -m venv /var/www/fastapi-app/venv
/var/www/fastapi-app/venv/bin/pip install fastapi uvicorn[standard]

cat > /var/www/fastapi-app/main.py << 'EOF'
from fastapi import FastAPI
app = FastAPI()

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

@app.get("/")
def root():
    return {"message": "Hello from Unit"}
EOF

Push the Unit configuration:

curl -X PUT --unix-socket /var/run/control.unit.sock http://localhost/config \
  -H "Content-Type: application/json" \
  -d '{
    "listeners": {
      "*:8000": {
        "pass": "applications/fastapi"
      }
    },
    "applications": {
      "fastapi": {
        "type": "python 3.12",
        "path": "/var/www/fastapi-app",
        "home": "/var/www/fastapi-app/venv",
        "module": "main",
        "callable": "app",
        "processes": {
          "max": 8,
          "spare": 2,
          "idle_timeout": 20
        }
      }
    }
  }'

Test it:

curl http://localhost:8000/health
# {"status":"ok"}

The processes block sets dynamic scaling. Unit starts with spare processes and scales up to max under load, then scales back down after idle_timeout seconds.


Deploying a Node.js App

cat > /var/www/nodeapp/server.js << 'EOF'
const http = require("http");
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ status: "ok", pid: process.pid }));
});
module.exports = server;
EOF

curl -X PUT --unix-socket /var/run/control.unit.sock \
  http://localhost/config/applications/nodeapp \
  -H "Content-Type: application/json" \
  -d '{
    "type": "node",
    "executable": "/usr/bin/node",
    "main": "/var/www/nodeapp/server.js",
    "processes": 4,
    "user": "unit",
    "group": "unit"
  }'

curl -X PUT --unix-socket /var/run/control.unit.sock \
  'http://localhost/config/listeners/*:8001' \
  -H "Content-Type: application/json" \
  -d '{"pass": "applications/nodeapp"}'

For Express apps, the main file should export the Express app object rather than call app.listen(). Unit manages the listener — calling listen() inside the app causes a conflict:

// express-app.js
const express = require("express");
const app = express();

app.get("/health", (req, res) => res.json({ status: "ok" }));

module.exports = app; // export, do NOT call app.listen()

Deploying a Go App

Go apps compile to a static binary that Unit runs as an external application. No language module is required:

// main.go
package main

import (
    "encoding/json"
    "net/http"
)

func main() {
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })
    http.ListenAndServe(":0", nil)
}
go build -o /var/www/goapp/server /var/www/goapp/

curl -X PUT --unix-socket /var/run/control.unit.sock \
  http://localhost/config/applications/goapp \
  -H "Content-Type: application/json" \
  -d '{
    "type": "external",
    "executable": "/var/www/goapp/server",
    "processes": 2,
    "user": "unit",
    "group": "unit"
  }'

curl -X PUT --unix-socket /var/run/control.unit.sock \
  'http://localhost/config/listeners/*:8002' \
  -H "Content-Type: application/json" \
  -d '{"pass": "applications/goapp"}'

TLS Termination

cat /etc/letsencrypt/live/example.com/fullchain.pem \
    /etc/letsencrypt/live/example.com/privkey.pem > /tmp/bundle.pem

curl -X PUT --unix-socket /var/run/control.unit.sock \
  http://localhost/certificates/example-com \
  --data-binary @/tmp/bundle.pem

rm /tmp/bundle.pem

curl -X PUT --unix-socket /var/run/control.unit.sock \
  'http://localhost/config/listeners/*:443' \
  -H "Content-Type: application/json" \
  -d '{
    "pass": "applications/fastapi",
    "tls": {
      "certificate": "example-com",
      "protocols": ["TLSv1.2", "TLSv1.3"]
    }
  }'

Certificate rotation does not require a restart. Upload a new certificate bundle and update the listener — Unit swaps the certificate in-place:

curl -X PUT --unix-socket /var/run/control.unit.sock \
  http://localhost/certificates/example-com \
  --data-binary @/tmp/new-bundle.pem

Zero-Downtime Deployments

# Deploy new code to a new path
rsync -a --delete build/ /var/www/fastapi-app-v2/

# Atomic swap via Unit config — no requests dropped
curl -X PUT --unix-socket /var/run/control.unit.sock \
  http://localhost/config/applications/fastapi/path \
  -H "Content-Type: application/json" \
  -d '"/var/www/fastapi-app-v2"'

# Check application status
curl --unix-socket /var/run/control.unit.sock \
  http://localhost/status/applications/fastapi/

Unit processes in-flight requests with the old code, then starts new requests against the new path. There is no connection drop.

To roll back, swap the path back:

curl -X PUT --unix-socket /var/run/control.unit.sock \
  http://localhost/config/applications/fastapi/path \
  -H "Content-Type: application/json" \
  -d '"/var/www/fastapi-app"'

Routing Multiple Apps on One Port

curl -X PUT --unix-socket /var/run/control.unit.sock \
  http://localhost/config \
  -H "Content-Type: application/json" \
  -d '{
    "listeners": {
      "*:80": {
        "pass": "routes"
      }
    },
    "routes": [
      {
        "match": { "uri": "/api/*" },
        "action": { "pass": "applications/fastapi" }
      },
      {
        "match": { "uri": "/app/*" },
        "action": { "pass": "applications/nodeapp" }
      },
      {
        "action": { "share": "/var/www/static/$uri" }
      }
    ]
  }'

Routes can also match on request headers, HTTP method, or source IP. This replaces most Nginx location block logic without needing a separate proxy server.


Exposing the Control API for CI/CD

Never expose Unit’s control socket directly over the network — it has no authentication layer. Tunnel from the CI runner:

# Tunnel from CI runner to the Unix socket
ssh -L 9000:/var/run/control.unit.sock user@prod-host -N &

# Deploy from CI using the tunnel
curl -X PUT http://localhost:9000/config/applications/fastapi/path \
  -H "Content-Type: application/json" \
  -d '"/var/www/fastapi-app-v3"'

Full GitHub Actions deployment job:

deploy:
  runs-on: ubuntu-latest
  steps:
    - name: Deploy to Unit via SSH tunnel
      run: |
        mkdir -p ~/.ssh
        echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy_key
        chmod 600 ~/.ssh/deploy_key
        ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
          -L 9000:/var/run/control.unit.sock \
          deploy@prod-host -N &
        sleep 2
        rsync -az --delete dist/ deploy@prod-host:/var/www/fastapi-app-v${{ github.sha }}/
        curl -sf -X PUT http://localhost:9000/config/applications/fastapi/path \
          -H "Content-Type: application/json" \
          -d '"/var/www/fastapi-app-v${{ github.sha }}"'

Systemd Service

# /etc/systemd/system/unit.service
[Unit]
Description=NGINX Unit
After=network.target

[Service]
Type=forking
PIDFile=/var/run/unit/unit.pid
ExecStartPre=/usr/share/doc/unit/examples/check_config.sh
ExecStart=/usr/sbin/unitd --log /var/log/unit.log --pid /var/run/unit/unit.pid
ExecStop=/bin/kill -QUIT $MAINPID
KillMode=mixed
Restart=on-failure

[Install]
WantedBy=multi-user.target

Health Checks and Process Monitoring

Unit tracks application process state via the status endpoint. Poll it to confirm healthy startup before updating your load balancer:

# Wait for application to report running processes
check_unit_health() {
  local app="$1"
  local retries=10
  local delay=3

  for i in $(seq 1 $retries); do
    STATUS=$(curl -s --unix-socket /var/run/control.unit.sock \
      "http://localhost/status/applications/${app}/processes" 2>/dev/null)
    RUNNING=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('running', 0))")
    if [ "${RUNNING:-0}" -gt 0 ]; then
      echo "App $app: $RUNNING process(es) running"
      return 0
    fi
    echo "Waiting for $app... (attempt $i/$retries)"
    sleep "$delay"
  done
  echo "ERROR: $app failed to start"
  return 1
}

check_unit_health fastapi

Unit log messages go to /var/log/unit.log — tail it during deployments to catch startup errors before they reach users:

# Watch for errors during deployment
tail -f /var/log/unit.log | grep -E "(error|warning|NOTICE)"

# Common startup errors:
# "failed to apply new conf" — JSON syntax error in config
# "unable to open ... module" — language module not installed
# "system error: permission denied" — wrong user/group for app files

For automated rollback, capture the current config before deploying and restore if the health check fails:

# Save current config
PREV_CONFIG=$(curl -s --unix-socket /var/run/control.unit.sock \
  http://localhost/config/applications/fastapi)

# Deploy
curl -X PUT --unix-socket /var/run/control.unit.sock \
  http://localhost/config/applications/fastapi/path \
  -d '"/var/www/fastapi-app-v3"'

# Health check
if ! check_unit_health fastapi; then
  echo "Rollback triggered"
  echo "$PREV_CONFIG" | curl -X PUT --unix-socket /var/run/control.unit.sock \
    http://localhost/config/applications/fastapi -H "Content-Type: application/json" -d @-
fi

Go App Deployment

Unit supports Go apps compiled as shared libraries. Unlike Python or Node, Go apps need to be compiled with Unit’s Go module:

# Install Go module for Unit
go get unit.nginx.org/go

# main.go — wrap your handler with Unit's ListenAndServe
package main

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

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, `{"status":"ok","path":"%s"}`, r.URL.Path)
}

func main() {
    http.HandleFunc("/", handler)
    unit.ListenAndServe(":0", nil)
}
# Build as a shared library
go build -buildmode=c-shared -o /var/www/goapp/app.so

# Configure Unit
curl -X PUT --unix-socket /var/run/control.unit.sock \
  http://localhost/config/applications/goapp \
  -H "Content-Type: application/json" \
  -d '{
    "type": "go",
    "executable": "/var/www/goapp/app.so",
    "processes": 4,
    "user": "unit",
    "group": "unit"
  }'

Go apps in Unit run as native shared libraries — no interpreter overhead, no port conflicts between apps. Each app uses Unit’s shared process pool management regardless of language.


Built by theluckystrike — More at zovo.one