Most nginx misconfigurations announce themselves. You typo a directive, nginx -t fails, you fix it. That feedback loop is fast and it works.
The dangerous ones are different. The config is valid. nginx -t passes. The server starts, serves traffic, logs nothing unusual. And the thing you configured is quietly not happening.
I maintain gixy-ng, a static analyzer for nginx configs. A growing share of its checks exist for exactly this category, because it turns out static analysis is the only practical way to catch a failure that produces no signal at runtime. Here are four worth knowing about.
1. OCSP stapling that staples nothing
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/example.com.pem;
ssl_certificate_key /etc/ssl/example.com.key;
ssl_stapling on;
ssl_stapling_verify on;
}
Looks right. It does nothing.
OCSP stapling means nginx fetches the certificate's revocation status from the CA itself and attaches it to the handshake, so the client does not have to. To do that, nginx has to make an outbound request to a hostname. nginx does not use the system resolver for runtime lookups. It has its own, and it only exists if you configure it.
No resolver in scope means the hostname never resolves, the fetch never happens, and stapling is silently skipped. Your config test passes. Your clients go do their own OCSP lookups, which is the exact thing you turned stapling on to avoid.
resolver 127.0.0.1 valid=300s ipv6=off;
resolver_timeout 5s;
Use a local resolver or your cloud provider's internal DNS. Pointing this at 8.8.8.8 sends every internal lookup off your network in cleartext, which is its own problem.
Check it with:
echo | openssl s_client -connect example.com:443 \
-servername example.com -status 2>/dev/null \
| grep -A 17 'OCSP response'
Working stapling prints OCSP Response Status: successful. Broken stapling prints no response sent. Run it twice, since the first handshake after a reload usually goes out unstapled while the fetch happens in the background.
One caveat that catches people right now: Let's Encrypt stopped serving OCSP in August 2025. If your cert is from them, the fix is to remove ssl_stapling, not to add a resolver.
2. An allow list that allows everyone
location /admin/ {
allow 10.0.0.0/8;
allow 192.168.1.0/24;
proxy_pass http://admin_backend;
}
ngx_http_access_module checks rules in order and stops at the first match. If nothing matches, access is granted.
So a request from an arbitrary internet address matches neither rule, falls off the end of the list, and gets served. The intent is obvious to a human reading it and completely invisible to nginx. Add deny all; after the allows and it works.
The subtler version bites harder. Access rules are inherited from an outer context only when the inner context defines none of its own. So a single allow inside a location discards the entire server-level rule set for that location, including its deny all:
server {
allow 10.0.0.0/8;
deny all; # server-wide restriction
location /metrics/ {
allow 172.16.0.1; # replaces the whole set above
stub_status; # /metrics/ is now public
}
}
You added a rule to tighten access and made the endpoint public.
3. return answering before your access rules run
location /health {
allow 10.0.0.0/8;
deny all;
return 200 "ok";
}
Everyone on the internet gets 200 ok. The access list is correct, complete, and never consulted.
nginx processes requests in ordered phases. return lives in the rewrite phase. allow and deny live in the access phase. Rewrite runs first, return terminates the request immediately, and the access phase never happens.
Position in the file is irrelevant. Moving return below deny all changes nothing, because nginx is not reading your block top to bottom at request time. This is the same root cause as "if is evil": directives from different modules run in different phases, in an order that has nothing to do with how you wrote the file.
The fix is to reach the canned response through an internal redirect, so the access phase gets a chance to run:
http {
open_file_cache max=10000 inactive=60s;
open_file_cache_errors on;
server {
location /health {
allow 10.0.0.0/8;
deny all;
try_files /nonexistent @health;
}
location @health {
return 200 "ok";
}
}
}
try_files runs in the content phase, after access has been evaluated, so a refused client gets its 403 before the internal redirect is considered. The open_file_cache lines are there because a bare try_files pays a filesystem lookup per candidate on every request, which gixy also flags. Fixing one finding by creating another is not a fix.
4. QUIC connections dying on every reload
This one is my favourite, in the way that a really good bug is a favourite.
quic_bpf on;
worker_processes auto;
http {
server {
listen 443 quic reuseport;
listen 443 ssl;
}
}
Three ingredients: quic_bpf on, reuseport on a QUIC listener, and more than one worker. Any one alone is fine. All three together, and after every nginx -s reload roughly half your QUIC connections are silently dropped.
quic_bpf exists for a good reason. QUIC connections survive an IP or port change by connection ID rather than by 4-tuple, so a migrated packet can land on the wrong worker. nginx attaches an eBPF program to the reuseport socket group that reads the connection ID and routes each packet to the right worker.
On reload, nginx starts fresh workers and retires the old ones, but the BPF socket map still holds entries pointing at workers that are shutting down. Packets for live connections get steered at sockets nobody is servicing. Those connections die. Clients time out and quietly fall back to HTTP/2 over TCP.
Nothing is logged. No error, no warning, no counter. Your HTTP/3 traffic share just sags after each reload. It is a known upstream issue (nginx/nginx#425) and unfixed in mainline.
Set quic_bpf off;. You lose optimal connection-migration routing and you stop losing connections. For nearly every deployment that is the right trade.
And if you reload to pick up renewed certificates, which with 90-day certs is most people, you are hitting this every couple of months at minimum.
Running it
pip install gixy-ng
Scan the config as nginx actually assembles it, not the file you happen to be editing. nginx -T dumps the whole thing with every include resolved, which catches problems that only exist once the pieces are combined:
nginx -T > nginx-dump.conf
gixy nginx-dump.conf
Useful flags:
gixy -l 2 nginx-dump.conf # MEDIUM and above
gixy -f json nginx-dump.conf # machine readable, for CI
gixy --nginx-version=1.29.8 conf # also check your version against known CVEs
That last one is worth calling out. gixy is config-static and has no view of your binary, so the CVE check stays silent unless you tell it which version you are running. Given a version, it reports the CVEs that apply, and for config-triggered ones it only fires when the offending directives are actually present. A CVE in the mp4 module is not your problem if you never compiled it in.
In CI, the JSON output plus a non-zero exit on findings above your threshold is usually all you need to stop a regression reaching production.
Why static analysis for this
Every failure above shares a shape: valid syntax, successful start, no runtime signal. Testing does not catch them because there is nothing to observe. Monitoring does not catch them because the metric you would need is the absence of a thing you assumed was happening.
The only place the information exists is the configuration itself, sitting there, being read by something that knows what these directives are supposed to do together.
The full check reference, with what each finding means and how to fix it, is at gixy.org/checks. Source and issues on GitHub.
This article was originally published by DEV Community and written by Danila Vershinin.
Read original article on DEV Community