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.
Top comments (4)
The config swap gets the attention but the GET worries me more. PATCH as you used it only re-reads what is already on disk, so an attacker still needs a second foothold to change the file. GET /1/control/config hands the whole file to anyone who can reach the port, and nginx configs are where people paste upstream auth headers and internal hostnames. Reading is the half that needs nothing else.
Yeah, that's a better way to rank it than how I wrote it. The "swapped the running config" bit in the intro needed me to docker cp a new file in first, so it really needed two footholds, and the post kind of buries that halfway down. GET needs nothing.
And it's probably worse than one file. I only tested a single nginx.conf, but from what I've read the GET returns every file the master has loaded, includes too, so a conf.d full of proxy_set_header Authorization lines and upstream hostnames would come back in one request. Haven't verified the includes part myself though, I should.
The one thing I'd add on the PATCH side is it's not totally harmless on its own either. Anyone on the port can trigger a reload whenever they want, so if someone is halfway through editing a file on disk, a stranger gets to decide when that half-edited version goes live, as long as it parses.
Either way it ends up at the same place, unix socket or nothing. I'll fix the intro so it doesn't oversell the swap and add the GET leak up top where it belongs.
Half-edited file going live because a stranger hit PATCH is a nasty little image. Temp file plus rename fixes that one for good, since the rename is atomic and the reload sees old or new, never the middle. Did the includes come back in the GET?
Haven't checked the includes yet, honestly. Docker isn't running on the machine I'm on right now, so it's still on the list, I'll post here when I do.
Temp file plus rename is the right fix, with two catches I'd add. It only covers one file, so if a change touches nginx.conf and something in conf.d, a PATCH can still land between the two renames. And the funny one, rename is actually what bit me in the post. I was bind mounting nginx.conf as a single file and editing it with sed on the host, and sed -i writes a new file and renames it over the old one. A single-file bind mount is pinned to the original inode, so the container just kept seeing the old file. I blamed my sandboxed Docker setup in the post, but I'm pretty sure that's the real reason, and it'd happen on a normal Docker host too. Mounting the directory instead of the file avoids it.