<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: GetPageSpeed</title>
    <description>The latest articles on DEV Community by GetPageSpeed (getpagespeed).</description>
    <link>https://dev.to/getpagespeed</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14560%2F830ad193-324f-4e09-82b5-5187c8ec6874.png</url>
      <title>DEV Community: GetPageSpeed</title>
      <link>https://dev.to/getpagespeed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/getpagespeed"/>
    <language>en</language>
    <item>
      <title>The nginx misconfigurations that fail silently</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Sun, 30 Aug 2026 00:39:45 +0000</pubDate>
      <link>https://dev.to/getpagespeed/the-nginx-misconfigurations-that-fail-silently-22i2</link>
      <guid>https://dev.to/getpagespeed/the-nginx-misconfigurations-that-fail-silently-22i2</guid>
      <description>&lt;p&gt;Most nginx misconfigurations announce themselves. You typo a directive, &lt;code&gt;nginx -t&lt;/code&gt; fails, you fix it. That feedback loop is fast and it works.&lt;/p&gt;

&lt;p&gt;The dangerous ones are different. The config is valid. &lt;code&gt;nginx -t&lt;/code&gt; passes. The server starts, serves traffic, logs nothing unusual. And the thing you configured is quietly not happening.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/dvershinin/gixy" rel="noopener noreferrer"&gt;gixy-ng&lt;/a&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. OCSP stapling that staples nothing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/ssl/example.com.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/example.com.key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_stapling&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_stapling_verify&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks right. It does nothing.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;No &lt;code&gt;resolver&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;resolver&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="s"&gt;.0.1&lt;/span&gt; &lt;span class="s"&gt;valid=300s&lt;/span&gt; &lt;span class="s"&gt;ipv6=off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;resolver_timeout&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a local resolver or your cloud provider's internal DNS. Pointing this at &lt;code&gt;8.8.8.8&lt;/code&gt; sends every internal lookup off your network in cleartext, which is its own problem.&lt;/p&gt;

&lt;p&gt;Check it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; example.com:443 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-servername&lt;/span&gt; example.com &lt;span class="nt"&gt;-status&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 17 &lt;span class="s1"&gt;'OCSP response'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Working stapling prints &lt;code&gt;OCSP Response Status: successful&lt;/code&gt;. Broken stapling prints &lt;code&gt;no response sent&lt;/code&gt;. Run it twice, since the first handshake after a reload usually goes out unstapled while the fetch happens in the background.&lt;/p&gt;

&lt;p&gt;One caveat that catches people right now: Let's Encrypt &lt;a href="https://letsencrypt.org/2024/12/05/ending-ocsp/" rel="noopener noreferrer"&gt;stopped serving OCSP in August 2025&lt;/a&gt;. If your cert is from them, the fix is to remove &lt;code&gt;ssl_stapling&lt;/code&gt;, not to add a resolver.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. An allow list that allows everyone
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/admin/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="s"&gt;.0.0/8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;192.168&lt;/span&gt;&lt;span class="s"&gt;.1.0/24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://admin_backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ngx_http_access_module&lt;/code&gt; checks rules in order and stops at the first match. If nothing matches, access is &lt;strong&gt;granted&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;deny all;&lt;/code&gt; after the allows and it works.&lt;/p&gt;

&lt;p&gt;The subtler version bites harder. Access rules are inherited from an outer context only when the inner context defines &lt;em&gt;none&lt;/em&gt; of its own. So a single &lt;code&gt;allow&lt;/code&gt; inside a location discards the entire server-level rule set for that location, including its &lt;code&gt;deny all&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="s"&gt;.0.0/8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;# server-wide restriction&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/metrics/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;172.16&lt;/span&gt;&lt;span class="s"&gt;.0.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# replaces the whole set above&lt;/span&gt;
        &lt;span class="kn"&gt;stub_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;# /metrics/ is now public&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You added a rule to tighten access and made the endpoint public.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. return answering before your access rules run
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/health&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="s"&gt;.0.0/8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everyone on the internet gets &lt;code&gt;200 ok&lt;/code&gt;. The access list is correct, complete, and never consulted.&lt;/p&gt;

&lt;p&gt;nginx processes requests in ordered phases. &lt;code&gt;return&lt;/code&gt; lives in the rewrite phase. &lt;code&gt;allow&lt;/code&gt; and &lt;code&gt;deny&lt;/code&gt; live in the access phase. Rewrite runs first, &lt;code&gt;return&lt;/code&gt; terminates the request immediately, and the access phase never happens.&lt;/p&gt;

&lt;p&gt;Position in the file is irrelevant. Moving &lt;code&gt;return&lt;/code&gt; below &lt;code&gt;deny all&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The fix is to reach the canned response through an internal redirect, so the access phase gets a chance to run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;open_file_cache&lt;/span&gt; &lt;span class="s"&gt;max=10000&lt;/span&gt; &lt;span class="s"&gt;inactive=60s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;open_file_cache_errors&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/health&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="s"&gt;.0.0/8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="n"&gt;/nonexistent&lt;/span&gt; &lt;span class="s"&gt;@health&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="s"&gt;@health&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;try_files&lt;/code&gt; runs in the content phase, after access has been evaluated, so a refused client gets its 403 before the internal redirect is considered. The &lt;code&gt;open_file_cache&lt;/code&gt; lines are there because a bare &lt;code&gt;try_files&lt;/code&gt; pays a filesystem lookup per candidate on every request, which gixy also flags. Fixing one finding by creating another is not a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. QUIC connections dying on every reload
&lt;/h2&gt;

&lt;p&gt;This one is my favourite, in the way that a really good bug is a favourite.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;quic_bpf&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;worker_processes&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;quic&lt;/span&gt; &lt;span class="s"&gt;reuseport&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three ingredients: &lt;code&gt;quic_bpf on&lt;/code&gt;, &lt;code&gt;reuseport&lt;/code&gt; on a QUIC listener, and more than one worker. Any one alone is fine. All three together, and after every &lt;code&gt;nginx -s reload&lt;/code&gt; roughly half your QUIC connections are silently dropped.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;quic_bpf&lt;/code&gt; 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 &lt;code&gt;reuseport&lt;/code&gt; socket group that reads the connection ID and routes each packet to the right worker.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 (&lt;a href="https://github.com/nginx/nginx/issues/425" rel="noopener noreferrer"&gt;nginx/nginx#425&lt;/a&gt;) and unfixed in mainline.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;quic_bpf off;&lt;/code&gt;. You lose optimal connection-migration routing and you stop losing connections. For nearly every deployment that is the right trade.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;gixy-ng
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scan the config as nginx actually assembles it, not the file you happen to be editing. &lt;code&gt;nginx -T&lt;/code&gt; dumps the whole thing with every include resolved, which catches problems that only exist once the pieces are combined:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nginx &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; nginx-dump.conf
gixy nginx-dump.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gixy &lt;span class="nt"&gt;-l&lt;/span&gt; 2 nginx-dump.conf              &lt;span class="c"&gt;# MEDIUM and above&lt;/span&gt;
gixy &lt;span class="nt"&gt;-f&lt;/span&gt; json nginx-dump.conf           &lt;span class="c"&gt;# machine readable, for CI&lt;/span&gt;
gixy &lt;span class="nt"&gt;--nginx-version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.29.8 conf       &lt;span class="c"&gt;# also check your version against known CVEs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why static analysis for this
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The full check reference, with what each finding means and how to fix it, is at &lt;a href="https://gixy.org/checks/" rel="noopener noreferrer"&gt;gixy.org/checks&lt;/a&gt;. Source and issues on &lt;a href="https://github.com/dvershinin/gixy" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
