I bound nginx's new control socket to a plain TCP port, sent it a bare curl with no headers and no credentials from outside the container, and it handed back the live PIDs of both worker processes. A second curl, still with nothing attached, swapped the running configuration for a different one.
That's the control API, a feature nginx 1.31.5 added to the open-source build on 2 September 2026. It lets you reload nginx over HTTP instead of a kill -HUP or nginx -s reload, and get a structured answer back instead of a line in the error log. I tested it on nginx 1.31.8, the current mainline, which carries the same feature forward unchanged.
Finding it
You turn it on with a new command-line flag, not a config directive:
nginx -g 'daemon off;' -l unix:/tmp/nginx_control.sock
The official nginx:1.31.8 Docker image already ships the binary with --with-control-api compiled in, so no custom build was needed. Querying it from inside the container found the endpoint tree by walking it one GET at a time:
$ curl -s --unix-socket /tmp/nginx_control.sock http://localhost/
["1"]
$ curl -s --unix-socket /tmp/nginx_control.sock http://localhost/1/
["control","nginx"]
$ curl -s --unix-socket /tmp/nginx_control.sock http://localhost/1/control
["processes","config"]
GET /1/control/config returns the live config file as JSON, name and full content. PATCH /1/control/config with no body re-reads the file from disk and reloads, which is the one I cared about.
The headline: a reload failure you can parse
I broke the config on purpose β a typo, retrun instead of return β and reloaded it both ways.
$ curl -s -i --unix-socket /tmp/nginx_control.sock -X PATCH http://localhost/1/control/config
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{"logs":["2026/10/23 05:11:41 [emerg] 1#1: unknown directive \"retrun\" in /etc/nginx/nginx.conf:6\n"]}
$ nginx -s reload
2026/10/23 05:11:41 [emerg] 131#131: unknown directive "retrun" in /etc/nginx/nginx.conf:6
nginx: [emerg] unknown directive "retrun" in /etc/nginx/nginx.conf:6
Both refuse the bad config and keep the old one running β nginx has always done that part safely, and I confirmed the previous config was still being served after each attempt. The difference is what a calling script gets back. The signal route gives you a process exit code and a line that only exists in the error log, which means a deploy script either parses log text or just trusts the exit code and hopes. The control API gives you a 422 and the exact compiler message as a JSON array, in the response body, with nothing to tail.
The opposite reading: it isn't faster
Before I found the error-reporting difference I assumed the API route would at least be quicker, since it skips sending a signal and waiting for the master to notice it. Five reloads each, same container, alternating between two valid configs so every run was a genuine reload rather than a no-op:
| Method | Trial 1 | Trial 2 | Trial 3 | Trial 4 | Trial 5 |
|---|---|---|---|---|---|
PATCH /1/control/config |
102ms | 91ms | 90ms | 101ms | 102ms |
nginx -s reload |
83ms | 78ms | 85ms | 96ms | 100ms |
Those numbers include docker exec's own overhead, so treat them as a comparison between the two methods rather than nginx's true reload latency. By that comparison, the signal route was if anything a touch quicker, never slower. The control API's benefit is entirely in what it tells you, not in how fast it gets there.
Under concurrency: in-flight requests didn't care either way
I proxied a request to a backend that sleeps three seconds before answering, started the request, and reloaded nginx one second in β once with the API, once with the signal, three trials each:
| Trigger | Trial 1 | Trial 2 | Trial 3 |
|---|---|---|---|
| API reload at 1s | 3.005s, HTTP 200 | 3.004s, HTTP 200 | 3.004s, HTTP 200 |
| Signal reload at 1s | 3.003s, HTTP 200 | 3.004s, HTTP 200 | 3.003s, HTTP 200 |
No dropped connections, no difference from a reload-free baseline of the same request (3.012s). I was suspicious of this result at first, because "no difference" is the answer a broken test gives you by default. So I checked the obvious failure: did the reload actually happen during the request, not before or after it? The worker PIDs confirmed it β the old workers showed "exiting":true in /1/control/processes while the slow request was still in flight, and new PIDs appeared, so the drain was genuinely overlapping the request. nginx's graceful worker shutdown applies the same way regardless of which door you reload through, which is reassuring rather than interesting, but it's the kind of thing worth checking rather than assuming.
What it actually exposes
nginx's own release notes call binding this to a network port "strongly discouraged" and say the API carries no authentication of its own. I wanted the exact shape of that risk, not just the warning.
$ docker run -d -p 19000:9000 nginx:1.31.8 nginx -g 'daemon off;' -l 0.0.0.0:9000
$ curl -s http://127.0.0.1:19000/1/control/processes
[{"name":"worker process","pid":30,"exiting":false},{"name":"worker process","pid":31,"exiting":false}]
$ curl -s -X PATCH http://127.0.0.1:19000/1/control/config
{"logs":[]}
Both requests ran from the host, outside the container, with no Authorization header, no API key, nothing but a bare URL. The second one actually replaced the running configuration β I'd already copied a different config file into the container first, and the content being served changed immediately after. There's no login, no token, no IP allowlist built into the feature itself. Anyone who can reach that port can read live process state and push a complete config swap.
The documented mitigation is to bind the control socket to the filesystem instead and rely on normal file permissions. I checked that it actually works rather than taking it on faith. The default unix socket came up as srw-------, owner and group both root, world nothing:
$ ls -la /tmp/nginx_control.sock
srw------- 1 root root 0 Oct 23 05:11 /tmp/nginx_control.sock
$ docker exec --user nobody mycontainer curl -s --unix-socket /tmp/nginx_control.sock http://localhost/1/control/processes
(exit code 7 β couldn't connect)
As nobody, the connection was refused before it ever reached nginx. So the workaround is real: a unix socket with tight permissions genuinely blocks an unprivileged user on the same host, which is more than the API does for itself over a network interface, where it blocks nothing.
What it costs to leave open
I compared the master process's resident memory with and without the -l flag, same image, same otherwise-empty config:
| Configuration | Master process VmRSS |
|---|---|
| No control socket | 9,164 KB |
| Control socket open | 9,468 KB |
About 304KB, roughly 3.3%, just for keeping the listener alive with nothing connected to it. Small, and nobody's capacity planning is going to notice it, but it's a real, non-zero cost for a feature you might enable once and forget about.
What I got wrong on the way
My first attempt at testing the reload used a host-side bind mount for the config file, editing it with sed on the host and expecting the running container to see the change immediately, the way bind mounts normally work. It didn't: the container kept serving the old content no matter what I wrote to the host file, and I spent a while assuming the control API itself was silently failing to apply changes. It wasn't. The sandboxed Docker setup I was running in didn't propagate host-side edits to an already-mounted file the way a normal Docker host does. Switching to docker cp to push the new config file directly into the container, then triggering the reload, fixed it immediately and every measurement above uses that approach.
Run it yourself
docker pull nginx:1.31.8
docker run -d --name ngxtest -p 18080:8080 \
nginx:1.31.8 nginx -g 'daemon off;' -l unix:/tmp/nginx_control.sock
# confirm the socket and walk the endpoint tree
docker exec ngxtest curl -s --unix-socket /tmp/nginx_control.sock http://localhost/1/control
# push a new config and reload through the API
cat > new.conf <<'EOF'
events {}
http {
server {
listen 8080;
location / { return 200 "v2\n"; }
}
}
EOF
docker cp new.conf ngxtest:/etc/nginx/nginx.conf
docker exec ngxtest curl -s -i --unix-socket /tmp/nginx_control.sock \
-X PATCH http://localhost/1/control/config
curl -s http://127.0.0.1:18080/ # should print v2
I ran that exact sequence again right before publishing this, on a fresh container, and it produced v2.
What to do with this
If you already run nginx -s reload from a deploy script and have ever had it fail silently in a way you only noticed later, the control API is worth wiring in for the structured error alone β a 422 with the compiler's own message beats grepping an error log you may not even be shipping anywhere. Don't bind it to anything but a unix socket, and don't treat the socket's default permissions as optional β they're the only access control this feature has. If you're auditing a host that already runs nginx 1.31.5 or newer, check now whether anyone turned this on with -l pointed at a network address, because nothing in the feature itself will stop a request from working.












