Make Nginx Unit controllable from non-root user

Recently I heard about Nginx Unit, a piece of software which lets you run Python web application on production with less components. It's like you let Nginx run your Python code, no longer Nginx - Gunicorn separation. So I installed it and learned how to use, but the default setup is not convenient: You have to switch to root user too often. So I share extra steps of setup to save you from it.

Nginx Unit is not configured via file like Nginx. It is done via HTTP API, which is nice because Nginx Unit can get update with new configuration without restarting, although I don't know what is the benefit over using SIGHUP signal to trigger rereading. The API communication is done via Unix domain socket, leveraging Linux authentication for restricting access, which is very reasonable because we don't have to remember port, username and password. But the issue is that, the default setup makes the socket writable by root only.

Unit control socket

The annoyance come from the combination of these factors:

  • Very few CLI HTTP clients support Unix domain socket: I only know cURL and HTTPie + plugin. I don't like cURL's verbose syntax, I pick HTTPie for its neat syntax and colored JSON display.

  • The HTTPie distributed by Ubuntu repo does not include the plugin for Unix socket. So I have to install via pipx.

  • When invoking HTTPie with sudo, sudo doesn't find HTTPie because the later is installed to my user home folder (~/.local/bin) by pipx.

If you have the same taste as me, we will do extra setup steps to make the socket writable for our user. Concretely, we will make the socket file is under adm group and add ourselves to adm group. This adm group pre-exists in Debian/Ubuntu system. I'm not sure about other distro.

To add ourselves to adm group:

$ sudo adduser $USER adm

We will need to log out and login again to make the command about effective.

Now the socket. Let's check the systemd unit file for launching Nginx Unit (/usr/lib/systemd/system/unit.service):

[Unit]
Description=NGINX Unit
Wants=network-online.target
After=network-online.target

[Service]
Type=forking
PIDFile=/var/run/unit.pid
EnvironmentFile=-/etc/default/unit
ExecStart=/usr/sbin/unitd $DAEMON_ARGS
ExecReload=
[Install]
WantedBy=multi-user.target

We can see that, this file tells systemd to read the $DAEMON_ARGS variable from the /etc/default/unit and use it as arguments for the unitd command. Now, check what unitd can accept:

$ unitd --help

unit options:

  --version            print unit version and configure options

  --no-daemon          run unit in non-daemon mode

  --control ADDRESS    set address of control API socket
                       default: "unix:/var/run/control.unit.sock"

  --control-mode MODE  set mode of the control API socket
                       default: 0600

  --control-user USER    set the owner of the control API socket

  --control-group GROUP  set the group of the control API socket

  --pid FILE           set pid filename
                       default: "/var/run/unit.pid"

  --log FILE           set log filename
                       default: "/var/log/unit.log"

  --modulesdir DIR     set modules directory name
                       default: "/usr/lib/unit/modules"

  --statedir DIR       set state directory name
                       default: "/var/lib/unit"

  --tmpdir DIR         set tmp directory name
                       default: "/var/tmp"

  --user USER          set non-privileged processes to run as specified user
                       default: "unit"

  --group GROUP        set non-privileged processes to run as specified group
                       default: "unit"

Ok, the --control-mode and --control-group options are what we want. We will create the /etc/default/unit file with this content:

DAEMON_ARGS=--control-mode 660 --control-group adm

Restart the systemd service and check the file permission:

$ sudo systemctl restart unit.service

Socket file permission

Try calling the API:

❯ https $'http+unix://('/run/control.unit.sock' | url encode -a)/status'

Output

HTTP/1.1 200 OK
Connection: close
Content-Length: 274
Content-Type: application/json
Date: Fri, 09 May 2025 23:31:45 GMT
Server: Unit/1.34.2

{
    "applications": {},
    "connections": {
        "accepted": 0,
        "active": 0,
        "closed": 0,
        "idle": 0
    },
    "modules": {
        "python": {
            "lib": "/usr/lib/unit/modules/python3.12.unit.so",
            "version": "3.12.3"
        }
    },
    "requests": {
        "total": 0
    }
}

It works!

You may notice that the command line above is unusual. It is the Nushell syntax. I switched to Nushell to easily convert the socket file path to the URL form that HTTPie's plugin requires (percent encoding). And because there is a built-in command http in Nushell, I use https to make Nushell invoke HTTPie instead of its built-in command.