<?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: Danila Vershinin</title>
    <description>The latest articles on DEV Community by Danila Vershinin (@dvershinin).</description>
    <link>https://dev.to/dvershinin</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%2Fuser%2Fprofile_image%2F397155%2F0f139455-f763-423c-abad-e534661546a1.jpg</url>
      <title>DEV Community: Danila Vershinin</title>
      <link>https://dev.to/dvershinin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dvershinin"/>
    <language>en</language>
    <item>
      <title>CVE-2026-80668 on CentOS 7: the stock kernel is affected and gets no fix</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Sat, 07 Nov 2026 16:32:18 +0000</pubDate>
      <link>https://dev.to/dvershinin/cve-2026-80668-on-centos-7-the-stock-kernel-is-affected-and-gets-no-fix-11fm</link>
      <guid>https://dev.to/dvershinin/cve-2026-80668-on-centos-7-the-stock-kernel-is-affected-and-gets-no-fix-11fm</guid>
      <description>&lt;p&gt;CVE-2026-80668 is a use-after-free in Linux netfilter connection tracking: an expectation created by a protocol helper (FTP, SIP, H.323) can outlive its master connection. The kernel.org CNA rates it CVSS 9.8, remote and unauthenticated. Red Hat rates it Moderate.&lt;/p&gt;

&lt;p&gt;On CentOS 7 it reaches the kernel most servers still run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stock 3.10:&lt;/strong&gt; affected, per Red Hat, and CentOS 7 gets no kernel updates. Automatic helper assignment is still on (&lt;code&gt;net.netfilter.nf_conntrack_helper = 1&lt;/code&gt;). In a test VM, enabling firewalld's &lt;code&gt;ftp&lt;/code&gt; service and sending one &lt;code&gt;PASV&lt;/code&gt; from an unauthenticated client created the object this bug frees too early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ELRepo &lt;code&gt;kernel-lt&lt;/code&gt; 5.4.278 and &lt;code&gt;kernel-ml&lt;/code&gt; 6.9.7:&lt;/strong&gt; outside this CVE's affected range, but both carry CVE-2026-99270, a remote TCP use-after-free, and get no more builds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Disclosure: I maintain this package. GetPageSpeed's &lt;code&gt;kernel-lt&lt;/code&gt; 6.18.62 uses the same package name, installs beside your current kernel, and is free for any use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;https://extras.getpagespeed.com/release-latest.rpm
yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;yum-utils
yum-config-manager &lt;span class="nt"&gt;--enable&lt;/span&gt; getpagespeed-extras-corecare
yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;kernel-lt
grub2-set-default 0
grubby &lt;span class="nt"&gt;--default-kernel&lt;/span&gt;
reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Already on our 6.18 kernel? &lt;code&gt;yum -y update 'kernel-lt*'&lt;/code&gt; is enough: 6.18.62-2 makes the new kernel the GRUB default itself, which earlier packages did not.&lt;/p&gt;

&lt;p&gt;I ran both paths on CentOS 7 VMs against the live repository. The exposure checks, a stopgap for servers you cannot reboot yet, and the rollback are in the &lt;a href="https://www.getpagespeed.com/server-setup/security/cve-2026-80668-centos-7" rel="noopener noreferrer"&gt;full write-up&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>centos</category>
      <category>devops</category>
    </item>
    <item>
      <title>Brotli CVE-2026-111497 on CentOS 7: ngx_brotli is fine, libbrotli is not</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Thu, 05 Nov 2026 05:16:52 +0000</pubDate>
      <link>https://dev.to/dvershinin/brotli-cve-2026-111497-on-centos-7-ngxbrotli-is-fine-libbrotli-is-not-3k05</link>
      <guid>https://dev.to/dvershinin/brotli-cve-2026-111497-on-centos-7-ngxbrotli-is-fine-libbrotli-is-not-3k05</guid>
      <description>&lt;p&gt;CVE-2026-111497, the Brotli bug that also goes by HTTP2Shell, Broccoli2Shell and Node2Shell, is an out-of-bounds write in the Brotli &lt;strong&gt;decoder&lt;/strong&gt; (&lt;code&gt;c/dec/decode.c&lt;/code&gt;). It affects Google Brotli 1.0.3 through 1.2.0, is fixed in 1.0.10, 1.1.1 and 1.2.1, and CISA lists it as exploited.&lt;/p&gt;

&lt;p&gt;Most of the coverage is about Node.js. If you run NGINX on an EL box, two questions matter: is the Brotli module affected, and where does your decoder come from?&lt;/p&gt;

&lt;h2&gt;
  
  
  ngx_brotli: not affected
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ngx_brotli&lt;/code&gt; compresses responses and serves &lt;code&gt;.br&lt;/code&gt; files. It never decompresses, so it never runs the vulnerable code. F5 says the same about the NGINX Plus Brotli module (&lt;a href="https://my.f5.com/manage/s/article/K000163902" rel="noopener noreferrer"&gt;K000163902&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ldd&lt;/code&gt; on CentOS 7 confirms it: the filter module links only &lt;code&gt;libbrotlienc&lt;/code&gt; and &lt;code&gt;libbrotlicommon&lt;/code&gt;, and the static module links no Brotli library at all.&lt;/p&gt;

&lt;p&gt;The one NGINX module that does decode is &lt;code&gt;unbrotli&lt;/code&gt;, which decompresses &lt;code&gt;Content-Encoding: br&lt;/code&gt; responses from upstreams. A hostile or compromised backend feeds it attacker-controlled data, so it is exposed through the system library.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually decodes Brotli on your server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rpm &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--whatrequires&lt;/span&gt; &lt;span class="s1"&gt;'libbrotlidec.so.1()(64bit)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a CentOS 7 test box that printed &lt;code&gt;brotli&lt;/code&gt;, &lt;code&gt;openssl35-libs&lt;/code&gt;, &lt;code&gt;libcurl-latest&lt;/code&gt;, &lt;code&gt;curl-latest&lt;/code&gt; and &lt;code&gt;nginx-module-unbrotli&lt;/code&gt;. Note that &lt;code&gt;rpm -q --whatrequires libbrotli&lt;/code&gt; prints nothing useful: packages depend on the soname, not the package name.&lt;/p&gt;

&lt;p&gt;All of them link the system library, so one package update fixes all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  CentOS 7: EPEL stopped at a vulnerable build
&lt;/h2&gt;

&lt;p&gt;RHEL, Rocky Linux and AlmaLinux 8 to 10 shipped fixed &lt;code&gt;brotli&lt;/code&gt;/&lt;code&gt;libbrotli&lt;/code&gt; within days. CentOS 7 got its Brotli from EPEL 7, which is archived. Its last build, &lt;code&gt;libbrotli-1.0.9-10.el7&lt;/code&gt;, sits inside the affected range and will never be updated. Amazon Linux 2 servers that took Brotli from EPEL are in the same spot.&lt;/p&gt;

&lt;p&gt;GetPageSpeed ships &lt;code&gt;libbrotli&lt;/code&gt; 1.2.1 for CentOS 7 and Amazon Linux 2, x86_64 and aarch64, with the same package split as EPEL, so it upgrades EPEL's build in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;https://extras.getpagespeed.com/release-latest.rpm
yum &lt;span class="nt"&gt;-y&lt;/span&gt; update libbrotli brotli
rpm &lt;span class="nt"&gt;-q&lt;/span&gt; libbrotli          &lt;span class="c"&gt;# expect libbrotli-1.2.1-5.el7&lt;/span&gt;
systemctl restart nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Free to install; commercial production use requires a subscription. Disclosure: I maintain this package.&lt;/p&gt;

&lt;p&gt;Restart matters: a running process keeps the old library mapped. Find the stragglers 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;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s1"&gt;'libbrotlidec.*(deleted)'&lt;/span&gt; /proc/[0-9]&lt;span class="k"&gt;*&lt;/span&gt;/maps 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Things the library update does not cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python bindings&lt;/strong&gt; compile Brotli into their own extension. Old &lt;code&gt;python-brotli&lt;/code&gt; and &lt;code&gt;python34-brotli&lt;/code&gt; 1.0.7 builds need replacing by &lt;code&gt;python2-brotli&lt;/code&gt; / &lt;code&gt;python36-brotli&lt;/code&gt; 1.2.1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundled copies&lt;/strong&gt;: Node.js, Firefox and anything else that vendors Brotli need their own update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Block &lt;code&gt;Content-Encoding: br&lt;/code&gt;"&lt;/strong&gt; only helps where the server decompresses request bodies. It does nothing for unbrotli, curl or OpenSSL, which receive Brotli from the other side of the connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full write-up, with the RHEL/Alma/Rocky and Debian/Ubuntu fixed versions and how each command was tested in clean containers, is on &lt;a href="https://www.getpagespeed.com/server-setup/security/brotli-cve-2026-111497" rel="noopener noreferrer"&gt;GetPageSpeed&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>linux</category>
      <category>nginx</category>
      <category>centos</category>
    </item>
    <item>
      <title>CVE-2026-98432 on CentOS 7: the stock kernel is fine, ELRepo kernels are not</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Sun, 01 Nov 2026 19:36:27 +0000</pubDate>
      <link>https://dev.to/dvershinin/cve-2026-98432-on-centos-7-the-stock-kernel-is-fine-elrepo-kernels-are-not-1615</link>
      <guid>https://dev.to/dvershinin/cve-2026-98432-on-centos-7-the-stock-kernel-is-fine-elrepo-kernels-are-not-1615</guid>
      <description>&lt;p&gt;CVE-2026-98432 is a remote use-after-free in Linux IPv4 TCP connection setup, rated CVSS 9.8. Anthropic showed an exploit, written by Claude after initial prompting, that gets root on servers with a multipath TCP listener: 48 minutes to leak a kernel pointer, five more to reach a shell.&lt;/p&gt;

&lt;p&gt;On CentOS 7 the answer depends on which kernel you boot.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stock 3.10:&lt;/strong&gt; not affected, per Red Hat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ELRepo &lt;code&gt;kernel-ml&lt;/code&gt; 6.9.7:&lt;/strong&gt; the bug is present, and MPTCP is on by default. Go 1.24 and later open MPTCP listeners by default, and &lt;code&gt;ss -lnt&lt;/code&gt; shows them as ordinary TCP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ELRepo &lt;code&gt;kernel-lt&lt;/code&gt; 5.4.278:&lt;/strong&gt; the bug is present. There is no MPTCP, so the demonstrated chain does not apply, but the bug sits in the regular TCP path. Amazon patched its own 5.4 kernel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those two ELRepo versions are the final EL7 builds, from June 2024, so &lt;code&gt;yum update&lt;/code&gt; has nothing to offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Disclosure: I maintain this package. GetPageSpeed's &lt;code&gt;kernel-lt&lt;/code&gt; 6.18.60 uses the same package name, installs beside your current kernel, and is free for any use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;https://extras.getpagespeed.com/release-latest.rpm
yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;yum-utils
yum-config-manager &lt;span class="nt"&gt;--enable&lt;/span&gt; getpagespeed-extras-corecare
yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;kernel-lt
grub2-set-default 0
grubby &lt;span class="nt"&gt;--default-kernel&lt;/span&gt;
reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not skip &lt;code&gt;grub2-set-default 0&lt;/code&gt;. On a server pinned to an ELRepo kernel, the new kernel installs but the old one stays the default.&lt;/p&gt;

&lt;p&gt;I ran this on CentOS 7 VMs starting from both ELRepo kernels. The stopgaps for servers you cannot reboot yet, the rollback and the test results are in the &lt;a href="https://www.getpagespeed.com/server-setup/security/cve-2026-98432-centos-7" rel="noopener noreferrer"&gt;full write-up&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>centos</category>
      <category>devops</category>
    </item>
    <item>
      <title>Eleven PHP versions, two package shapes, one CentOS 7: shipping the PHP2Shell fix everywhere</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Fri, 23 Oct 2026 20:02:13 +0000</pubDate>
      <link>https://dev.to/getpagespeed/eleven-php-versions-two-package-shapes-one-centos-7-shipping-the-php2shell-fix-everywhere-4dm1</link>
      <guid>https://dev.to/getpagespeed/eleven-php-versions-two-package-shapes-one-centos-7-shipping-the-php2shell-fix-everywhere-4dm1</guid>
      <description>&lt;p&gt;The day after PHP2Shell (CVE-2026-109618 and CVE-2026-109619) went public, we shipped patched PHP 5.6, 7.0, 7.3 and 8.0 for CentOS 7. I wrote up &lt;a href="https://dev.to/getpagespeed/backporting-the-php2shell-fix-to-php-56-and-70-on-centos-7-4b17"&gt;how those first backports were ported and proven&lt;/a&gt;. This is the follow-up: getting from "the versions we had time for" to every PHP a CentOS 7 box can run, 5.6 through 8.4, in both of Remi's package shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem was the matrix, not the patch
&lt;/h2&gt;

&lt;p&gt;Once the fix was ported to 5.6 and 7.0, the hard engineering was done. What was left was combinatorics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;11 PHP versions (5.6, 7.0 to 7.4, 8.0 to 8.4)&lt;/li&gt;
&lt;li&gt;2 package shapes per version: Remi's base-name &lt;code&gt;php-*&lt;/code&gt; (replaces system PHP, from &lt;code&gt;remi-phpNN&lt;/code&gt;) and the &lt;code&gt;phpNN-php-*&lt;/code&gt; software collection (side by side, from &lt;code&gt;remi-safe&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;a different starting point per version: a frozen EL7 build, a maintained EL8+ source, or plain upstream&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Doing that as 11 separate packaging repos meant 11 copies of the same build glue drifting apart. So the per-version repos were folded into one matrix repo: a &lt;code&gt;matrix.yml&lt;/code&gt; declares each version's source, collection name, channel and twin mode, and an &lt;code&gt;assemble.sh&lt;/code&gt; renders each CI unit's build directory from it. The SCL twin of a version is generated from the base spec, not committed by hand. Adding 7.1 and 7.2 became a &lt;code&gt;matrix.yml&lt;/code&gt; row plus that version's patches.&lt;/p&gt;

&lt;p&gt;Two versions (5.6 and 8.0) keep two committed specs, because Remi's base and SCL specs for them differ by hundreds of hunks (the base builds ZTS, the collection does not). Merging them would either drop ZTS from the base shape or produce an unmaintainable spec. Two small deltas over Remi beat one big one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the packages live
&lt;/h2&gt;

&lt;p&gt;The channel layout ended up mirroring Remi's own, which keeps the install story uniform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Base-name&lt;/strong&gt; &lt;code&gt;php-*&lt;/code&gt; for version NN lives in an opt-in &lt;code&gt;getpagespeed-extras-phpNN&lt;/code&gt; channel, enabled per transaction: &lt;code&gt;yum --enablerepo=getpagespeed-extras-php74 upgrade 'php-*'&lt;/code&gt;. It has to be opt-in, because a base-name &lt;code&gt;php&lt;/code&gt; from another version would out-version whatever the host runs on an unscoped &lt;code&gt;yum update&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collections&lt;/strong&gt; (&lt;code&gt;php56-php-*&lt;/code&gt; to &lt;code&gt;php84-php-*&lt;/code&gt;) live in the always-enabled main repository, like &lt;code&gt;remi-safe&lt;/code&gt;. Their names are version-prefixed, so they can never collide with anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PHP 8.4 was the odd one out: Remi never built it for EL7, so there was no collection to be compatible with. It became the first collection under a GetPageSpeed prefix, &lt;code&gt;/opt/getpagespeed/php84&lt;/code&gt; instead of &lt;code&gt;/opt/remi/php84&lt;/code&gt;. That is why every example uses &lt;code&gt;scl enable php84 -- php -v&lt;/code&gt; instead of a hardcoded path: &lt;code&gt;scl&lt;/code&gt; resolves the prefix, so the commands look the same for every version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime libraries: no Remi repository required
&lt;/h2&gt;

&lt;p&gt;The Remi EL7 builds link libraries only Remi ever shipped (gd3php, oniguruma5php, libicu69/73, the &lt;code&gt;phpNN-runtime&lt;/code&gt; metapackages). Leaving those on Remi's frozen mirror would make every fresh install depend on it. They are rebuilt from Remi's own SRPMs with identical names, SONAMEs and file lists, one release higher, so a Remi host upgrades in place and a fresh host needs only the GetPageSpeed release package.&lt;/p&gt;

&lt;p&gt;PHP 8.4 goes further and links maintained libraries instead: OpenSSL 3.5 LTS (namespaced, never replacing the system 1.0.2), ICU 74, libzip 1.11, libpq 17 and libxml2 2.13.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing that it serves pages, not just that it builds
&lt;/h2&gt;

&lt;p&gt;Package-level tests (phpt suite, &lt;code&gt;php -m&lt;/code&gt; parity, the PHP2Shell probes before and after, &lt;code&gt;yum downgrade&lt;/code&gt; back to Remi) all run on the CLI. None of them prove a channel can actually serve a request through Apache.&lt;/p&gt;

&lt;p&gt;So there is a separate runtime suite. For every channel it starts a clean CentOS 7 container and runs four scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;fresh&lt;/code&gt;: install from the repository on an empty host&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;upgrade&lt;/code&gt;: start from a Remi host with PECL extensions and ionCube loaded, run the documented upgrade command, fail if any extension is lost&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scl-fresh&lt;/code&gt; and &lt;code&gt;scl-upgrade&lt;/code&gt;: the same for the collection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each scenario serves a probe page through mod_php &lt;strong&gt;and&lt;/strong&gt; through PHP-FPM behind &lt;code&gt;proxy_fcgi&lt;/code&gt;, and asserts the SAPI, the version, the build provider, sessions, gd, intl, mbstring and opcache, that the CLI and web SAPIs load the same extensions, and that the logs are clean. It runs on every change and nightly against the published repository, and a red job there is treated as a customer-visible break. On October 24 it ran 45 of 45 green.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;https://extras.getpagespeed.com/release-latest.rpm
&lt;span class="c"&gt;# base-name, replaces system PHP:&lt;/span&gt;
yum &lt;span class="nt"&gt;--enablerepo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;getpagespeed-extras-php74 upgrade &lt;span class="s1"&gt;'php-*'&lt;/span&gt;
&lt;span class="c"&gt;# or the collection, side by side:&lt;/span&gt;
yum upgrade &lt;span class="s1"&gt;'php74-php*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same two commands work for every version from 5.6 to 8.3, and for 8.4 once its stack or collection is installed. Version-by-version details, the full fix lists and the rollback notes are in the &lt;a href="https://www.getpagespeed.com/server-setup/php/php2shell-on-centos-7" rel="noopener noreferrer"&gt;CentOS 7 PHP2Shell guide&lt;/a&gt;. If you are on Ubuntu 20.04 instead, the same problem and a free fix are covered in &lt;a href="https://www.getpagespeed.com/server-setup/php/php2shell-on-ubuntu-debian" rel="noopener noreferrer"&gt;PHP2Shell on Ubuntu 20.04&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The packages are free to install. If your CentOS 7 servers run a business, &lt;a href="https://www.getpagespeed.com/repo-subscribe#ultra-annual" rel="noopener noreferrer"&gt;GetPageSpeed Ultra&lt;/a&gt; is what funds the next round of these backports.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>linux</category>
      <category>centos</category>
    </item>
    <item>
      <title>PHP2Shell: why php -v can't tell you if you're patched</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:50:48 +0000</pubDate>
      <link>https://dev.to/dvershinin/php2shell-why-php-v-cant-tell-you-if-youre-patched-3gc3</link>
      <guid>https://dev.to/dvershinin/php2shell-why-php-v-cant-tell-you-if-youre-patched-3gc3</guid>
      <description>&lt;p&gt;PHP2Shell is two critical PHP bugs disclosed on October 21, 2026: &lt;strong&gt;CVE-2026-109618&lt;/strong&gt; (remote code execution via out-of-memory corruption) and &lt;strong&gt;CVE-2026-109619&lt;/strong&gt; (remote code execution via stale global state). Both are CVSS 4.0 9.2 and need no authentication. Attacks are already happening.&lt;/p&gt;

&lt;p&gt;Upstream fixed only PHP &lt;strong&gt;8.5.12, 8.4.27, 8.3.36 and 8.2.35&lt;/strong&gt;. Everything else depends on your vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version-number trap
&lt;/h2&gt;

&lt;p&gt;Most people check with &lt;code&gt;php -v&lt;/code&gt;. On a patched Ubuntu 24.04 that still prints &lt;code&gt;PHP 8.3.6&lt;/code&gt;, because Ubuntu backported the fix into its own package (&lt;code&gt;8.3.6-0ubuntu0.24.04.13&lt;/code&gt;). Debian, RHEL, AlmaLinux, Rocky, Remi and others do the same in different ways. A version scanner that only reads the upstream number flags patched servers as vulnerable, and can wave through an unpatched EOL build.&lt;/p&gt;

&lt;p&gt;The package release is what matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rpm &lt;span class="nt"&gt;-qa&lt;/span&gt; &lt;span class="s1"&gt;'php*'&lt;/span&gt;        &lt;span class="c"&gt;# RHEL, CentOS, Alma, Rocky&lt;/span&gt;
dpkg &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s1"&gt;'php*'&lt;/span&gt;        &lt;span class="c"&gt;# Debian, Ubuntu&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I built a &lt;a href="https://www.getpagespeed.com/php2shell#check" rel="noopener noreferrer"&gt;free PHP2Shell checker&lt;/a&gt; that reads that output in your browser and knows each vendor's fixed builds. Nothing you paste leaves the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NGINX can and can't do
&lt;/h2&gt;

&lt;p&gt;We tested edge mitigations on Rocky Linux 10 with NGINX 1.30 and PHP-FPM 8.3. Three results surprised me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stock NGINX already blunts one attack path.&lt;/strong&gt; CVE-2026-109618 can use a multipart body "without a usable declared content length". With the default &lt;code&gt;fastcgi_request_buffering on&lt;/code&gt;, NGINX de-chunks the body and hands PHP-FPM the real &lt;code&gt;CONTENT_LENGTH&lt;/code&gt;. Keep buffering on for PHP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;client_max_body_size&lt;/code&gt; is the best single lever.&lt;/strong&gt; NGINX rejects an oversized chunked body with &lt;code&gt;413&lt;/code&gt; before PHP parses a byte.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The popular &lt;code&gt;PHP_VALUE&lt;/code&gt; trick does not work.&lt;/strong&gt; &lt;code&gt;fastcgi_param PHP_VALUE "session.upload_progress.enabled=0"&lt;/code&gt; left upload progress enabled in our test. Set it in the FPM pool instead:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="na"&gt;php_admin_flag&lt;/span&gt;&lt;span class="nn"&gt;[session.upload_progress.enabled]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;off&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The advisory also warns that per-request INI values from the frontend which raise a warning are themselves a trigger for CVE-2026-109619, so audit what you send:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'PHP_VALUE\|PHP_ADMIN_VALUE'&lt;/span&gt; /etc/nginx /etc/php-fpm.d /etc/php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of this replaces updating PHP. Edge rules stop the known request shapes, and the advisories document paths that need no special upload at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still on CentOS 7? Or frozen on Ubuntu 20.04?
&lt;/h2&gt;

&lt;p&gt;Remi stopped building for CentOS 7 in June 2024, and php.net shipped nothing for PHP 7.x there. GetPageSpeed's Everpatch publishes PHP2Shell-fixed builds of PHP 5.6 to 8.4 for CentOS 7, in both Remi package shapes, free to install. Personal, development and evaluation use is free. Commercial production requires &lt;a href="https://www.getpagespeed.com/repo-subscribe#ultra-annual" rel="noopener noreferrer"&gt;Ultra&lt;/a&gt; for up to 10 servers running your own organization's services, or &lt;a href="https://www.getpagespeed.com/fleet-subscribe" rel="noopener noreferrer"&gt;Fleet&lt;/a&gt; for client servers you administer or larger fleets, under the &lt;a href="https://www.getpagespeed.com/terms#free-channels" rel="noopener noreferrer"&gt;repository terms&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;https://extras.getpagespeed.com/release-latest.rpm
yum &lt;span class="nt"&gt;--enablerepo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;getpagespeed-extras-php74 upgrade &lt;span class="s1"&gt;'php-*'&lt;/span&gt;
systemctl restart php-fpm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everpatch also ships patched DEB packages for the suites the Surý PPA dropped: PHP 7.4 and 8.0-8.4 for Ubuntu 20.04, PHP 7.2 for Ubuntu 18.04 and PHP 7.3 for Debian 10, all on amd64. These keep the existing package names and configuration layout, so you can update your current PHP branch while planning the OS migration. The &lt;a href="https://www.getpagespeed.com/server-setup/php/php2shell-on-ubuntu-debian" rel="noopener noreferrer"&gt;Ubuntu and Debian guide&lt;/a&gt; lists package versions and upgrade commands. These are PHP packages; they do not provide complete Ubuntu OS maintenance.&lt;/p&gt;

&lt;p&gt;Then restart every PHP process, whatever your platform. Running workers keep the old code in memory.&lt;/p&gt;

&lt;p&gt;The full write-up has fixed versions per distro, the ModSecurity rules we tested, detection commands and a timeline: &lt;a href="https://www.getpagespeed.com/php2shell" rel="noopener noreferrer"&gt;PHP2Shell: check, fix and mitigate&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>nginx</category>
      <category>linux</category>
    </item>
    <item>
      <title>Backporting the PHP2Shell fix to PHP 5.6 and 7.0 on CentOS 7</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Thu, 22 Oct 2026 06:15:28 +0000</pubDate>
      <link>https://dev.to/getpagespeed/backporting-the-php2shell-fix-to-php-56-and-70-on-centos-7-4b17</link>
      <guid>https://dev.to/getpagespeed/backporting-the-php2shell-fix-to-php-56-and-70-on-centos-7-4b17</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update, October 24:&lt;/strong&gt; the fix now covers every PHP from 5.6 to 8.4 on CentOS 7, in both Remi package shapes. How we got there: &lt;a href="https://dev.to/getpagespeed/eleven-php-versions-two-package-shapes-one-centos-7-shipping-the-php2shell-fix-everywhere-4dm1"&gt;Eleven PHP versions, two package shapes, one CentOS 7&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;PHP2Shell (CVE-2026-109618 and CVE-2026-109619) is a pair of unauthenticated RCEs in PHP, rated CVSS v4.0 9.2. The PHP project fixed 8.2 and newer, published source-only backports for 7.4, 8.0 and 8.1, and nothing for 7.3 and older. CentOS 7 boxes still running Remi's PHP 5.6 or 7.0 had no binary fix anywhere. Here is how we ported it and how we convinced ourselves the port is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the upstream fix actually does
&lt;/h2&gt;

&lt;p&gt;Both bugs share a root cause: PHP's out-of-memory handling is a &lt;code&gt;longjmp&lt;/code&gt; (&lt;code&gt;zend_bailout()&lt;/code&gt;). When that fires in the middle of request startup, say while &lt;code&gt;rfc1867.c&lt;/code&gt; is parsing a multipart body, request state is left half built. Cleanup then trusts it (CVE-2026-109618), or a freed pointer survives into the next request on the same worker (CVE-2026-109619).&lt;/p&gt;

&lt;p&gt;The security team's minimal backport for EOL branches (&lt;code&gt;php-src&lt;/code&gt; branch &lt;code&gt;PHP-7.4-CVE-2026-109618-109619&lt;/code&gt;, commit 712e60f9) does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stop unwinding on allocator failure.&lt;/strong&gt; A new &lt;code&gt;zend_mm_safe_error_ex()&lt;/code&gt; prints the error and calls &lt;code&gt;_exit(1)&lt;/code&gt; instead of bailing out. Every overflow site in &lt;code&gt;zend_alloc&lt;/code&gt;, &lt;code&gt;zend_hash&lt;/code&gt;, the string-concat VM handlers and friends routes through it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reset request globals on shutdown.&lt;/strong&gt; NULL out freed request strings in &lt;code&gt;SAPI.c&lt;/code&gt;, clear &lt;code&gt;http_globals&lt;/code&gt;, reset user error and exception handlers, the handler stacks and the object store at the end of &lt;code&gt;shutdown_executor()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close the known entry points.&lt;/strong&gt; Header whitelisting and de-duplication in &lt;code&gt;rfc1867.c&lt;/code&gt;, and the session upload-progress hook disabled outright.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Porting to 7.3 and 7.0
&lt;/h2&gt;

&lt;p&gt;7.3 is close enough to 7.4 that most hunks applied cleanly. The interesting differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;7.3 has no pre-free &lt;code&gt;!fast_shutdown&lt;/code&gt; block in &lt;code&gt;zend_execute_API.c&lt;/code&gt;, so the handler reset has to go where 7.4 runs it on the fast-shutdown path, before the object store is freed.&lt;/li&gt;
&lt;li&gt;7.3's in-place &lt;code&gt;CONCAT&lt;/code&gt; extend path had no overflow check at all. We added the upstream check, then regenerated &lt;code&gt;zend_vm_execute.h&lt;/code&gt; with &lt;code&gt;zend_vm_gen.php&lt;/code&gt; (first proving the generator is byte-identical on the unpatched tree). The result has 34 safe-exit sites, the same as upstream 7.4.&lt;/li&gt;
&lt;li&gt;Remi's base already carries the CVE-2023-0662 &lt;code&gt;max_multipart_body_parts&lt;/code&gt; patch, so those &lt;code&gt;rfc1867.c&lt;/code&gt; hunks were dropped instead of duplicated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;7.0 needed real adaptation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;7.0 has no &lt;code&gt;_zend_new_array_0()&lt;/code&gt;, so &lt;code&gt;array_init_singleton()&lt;/code&gt; became &lt;code&gt;array_init()&lt;/code&gt; plus &lt;code&gt;nTableSize = 1&lt;/code&gt;, the same table shape upstream creates.&lt;/li&gt;
&lt;li&gt;7.0 has no fast-shutdown branch and destroys the handler stacks inside a &lt;code&gt;zend_try&lt;/code&gt;. The reset runs once at the very end of &lt;code&gt;shutdown_executor()&lt;/code&gt;, after every destroy.&lt;/li&gt;
&lt;li&gt;7.0's &lt;code&gt;estrdup&lt;/code&gt; overflow sites passed an &lt;code&gt;int&lt;/code&gt; to &lt;code&gt;%zu&lt;/code&gt;; we used upstream's format.&lt;/li&gt;
&lt;li&gt;7.0 is C89, so the session hook is disabled with &lt;code&gt;|| 1&lt;/code&gt; rather than &lt;code&gt;true&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  PHP 5.6: start from a vendor port, then fix it
&lt;/h2&gt;

&lt;p&gt;For 5.6 we started from a commercial vendor's published 5.6 adaptation and audited it hunk by hunk against upstream 7.4 and our own 7.0 port. It was right about the allocator, &lt;code&gt;rfc1867.c&lt;/code&gt;, &lt;code&gt;SAPI.c&lt;/code&gt; and the session hook. It missed part of the shutdown reset: it cleared &lt;code&gt;error_handling&lt;/code&gt; and &lt;code&gt;exception&lt;/code&gt;, but not the user error and exception handlers, the three handler stacks, or the object store. In 5.6, &lt;code&gt;zend_ptr_stack_destroy()&lt;/code&gt; leaves &lt;code&gt;elements&lt;/code&gt; dangling, which is exactly the stale-state shape CVE-2026-109619 exploits. We added the missing resets.&lt;/p&gt;

&lt;p&gt;A public community patch set for 5.6 touches only the allocator, session and &lt;code&gt;rfc1867.c&lt;/code&gt;, and omits the shutdown resets entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it
&lt;/h2&gt;

&lt;p&gt;Each build runs through the same harness in a pristine CentOS 7 container:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the exact Remi NEVRAs the host has today.&lt;/li&gt;
&lt;li&gt;Run both PHP2Shell probes and require them to reproduce. The out-of-memory probe exits 255 and the upload-progress hook runs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;yum upgrade&lt;/code&gt; to our build. Require both probes to fail: the OOM probe exits 1 and the hook never runs.&lt;/li&gt;
&lt;li&gt;Run the full phpt suite against the baseline. The only accepted differences are nine tests whose behaviour changes by design (eight &lt;code&gt;rfc1867&lt;/code&gt; tests and &lt;code&gt;bug45392&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Diff &lt;code&gt;php -m&lt;/code&gt; (including PECL and ionCube), check configuration is preserved and PHP-FPM serves after an OOM request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;yum downgrade&lt;/code&gt; back to Remi and confirm it works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Builds that pass went to production the next day on PrestaShop 1.6 and Magento 1 servers with nothing more than a PHP-FPM restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off
&lt;/h2&gt;

&lt;p&gt;With the fix, hitting &lt;code&gt;memory_limit&lt;/code&gt; kills the worker immediately: no shutdown functions, no flushed output. Upstream made the same call for its EOL backports. If your code relies on a shutdown handler to log memory exhaustion, it will go quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;The packages are free in the GetPageSpeed repository and replace Remi's in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yum &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;https://extras.getpagespeed.com/release-latest.rpm
yum &lt;span class="nt"&gt;--enablerepo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;getpagespeed-extras-php70 upgrade &lt;span class="s1"&gt;'php-*'&lt;/span&gt;   &lt;span class="c"&gt;# or php56, php80&lt;/span&gt;
systemctl restart php-fpm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP 7.3 (Remi SCL), PHP 8.4.27, and the full per-version details are in the &lt;a href="https://www.getpagespeed.com/server-setup/php/php2shell-on-centos-7" rel="noopener noreferrer"&gt;PHP2Shell on CentOS 7 guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>linux</category>
      <category>centos</category>
    </item>
    <item>
      <title>Find Linux Packages Across Repositories with pkgdex</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Sun, 18 Oct 2026 17:44:27 +0000</pubDate>
      <link>https://dev.to/getpagespeed/find-linux-packages-across-repositories-with-pkgdex-3eok</link>
      <guid>https://dev.to/getpagespeed/find-linux-packages-across-repositories-with-pkgdex-3eok</guid>
      <description>&lt;p&gt;Installing a package is usually easy once you know its name and which repository provides it. Finding that combination can take longer, especially when you work across several Linux distributions.&lt;/p&gt;

&lt;p&gt;Does the package exist for your distribution release? Is there an ARM build? Which package contains a particular executable? Is the result from the distribution itself or a third-party publisher?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pkgdex.org/" rel="noopener noreferrer"&gt;pkgdex&lt;/a&gt; puts those questions into a searchable package index. I built pkgdex and run &lt;a href="https://www.getpagespeed.com/" rel="noopener noreferrer"&gt;GetPageSpeed&lt;/a&gt;, whose repositories are included alongside distribution repositories and other publishers. Here is a practical way to use it, starting with NGINX.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a package name
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://pkgdex.org/search/nginx" rel="noopener noreferrer"&gt;search results for nginx&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The results are grouped by distribution and release. Each row identifies the repository, architecture, package filename, and description. That makes it possible to see where a package comes from before opening its details.&lt;/p&gt;

&lt;p&gt;A name search can also return related packages. For example, the NGINX results include modules and configuration packages as well as the server itself. Read the package name and description rather than assuming every match installs the same thing.&lt;/p&gt;

&lt;p&gt;Start with the distribution release you actually run, then check the architecture. An &lt;code&gt;x86_64&lt;/code&gt; result and an &lt;code&gt;aarch64&lt;/code&gt; result are different builds. A familiar package name does not make a build for another distribution interchangeable with yours.&lt;/p&gt;

&lt;p&gt;If you prefer browsing first, the &lt;a href="https://pkgdex.org/" rel="noopener noreferrer"&gt;home page&lt;/a&gt; lists distributions and repositories. The wider index also includes BSD, macOS, and Flatpak sources, but this walkthrough stays with Linux packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect the package before installing
&lt;/h2&gt;

&lt;p&gt;Follow a package filename to its detail page. For a concrete example, this &lt;a href="https://pkgdex.org/opensuse/leap%2016.0/x86_64/oss/nginx-1.27.2-160000.2.2.rpm.html" rel="noopener noreferrer"&gt;NGINX package for openSUSE Leap 16.0&lt;/a&gt; shows its version, architecture, repository, license, dependencies, provided capabilities, and file list.&lt;/p&gt;

&lt;p&gt;The file list answers questions that a short package description cannot: where is the executable, which configuration paths are included, and does this build ship the module you need?&lt;/p&gt;

&lt;p&gt;The page also provides installation guidance and a download link. Treat it as information about that particular build. For an actual installation, use your distribution's package manager with the appropriate repository configured, so it can resolve dependencies and apply updates.&lt;/p&gt;

&lt;p&gt;Finding an older version in an index is useful for investigation. It does not mean that version is the best choice for a new server, or that it is the version your configured repositories will currently install.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search by a file you recognize
&lt;/h2&gt;

&lt;p&gt;Sometimes you know the executable path but not the package name. Switch to the &lt;strong&gt;Files&lt;/strong&gt; search scope instead of guessing.&lt;/p&gt;

&lt;p&gt;Try &lt;a href="https://pkgdex.org/search/files/%2Fusr%2Fbin%2Fhtop" rel="noopener noreferrer"&gt;finding packages that contain /usr/bin/htop&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That search returns packages whose indexed file metadata matches the path. You can then inspect the result for your distribution and architecture.&lt;/p&gt;

&lt;p&gt;Using the full path helps narrow the question. Searching for just &lt;code&gt;htop&lt;/code&gt; in file metadata can also match completion scripts or other filenames containing that text. Neither result tells you what is installed on your own machine; pkgdex searches repository metadata.&lt;/p&gt;

&lt;p&gt;The search tabs also expose &lt;strong&gt;Summary&lt;/strong&gt;, &lt;strong&gt;Provides&lt;/strong&gt;, and &lt;strong&gt;Requires&lt;/strong&gt; scopes. Those are useful when your starting point is a description or a dependency capability rather than a package name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limit discovery to one publisher
&lt;/h2&gt;

&lt;p&gt;If you already know which publisher you want, a global search adds unnecessary results.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://pkgdex.org/getpagespeed" rel="noopener noreferrer"&gt;GetPageSpeed publisher page&lt;/a&gt; groups our indexed repositories. For a specific example, open the &lt;a href="https://pkgdex.org/getpagespeed/search/nginx-module-brotli" rel="noopener noreferrer"&gt;GetPageSpeed search for nginx-module-brotli&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This keeps the search within GetPageSpeed while showing builds across distribution releases, architectures, and repository variants. It is a convenient starting point for checking whether we package a module for a particular target.&lt;/p&gt;

&lt;p&gt;Before installing an NGINX dynamic module, check that its build matches your NGINX packaging and version. A search match alone does not establish compatibility. Access and subscription terms belong to the source repository; being searchable in pkgdex does not change them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the results as JSON
&lt;/h2&gt;

&lt;p&gt;For a script or a non-commercial project, the public API offers the same kind of package discovery without scraping HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--fail&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--show-error&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 20 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'https://pkgdex.org/api/search/nginx'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is a JSON array. Returned fields include &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;arch&lt;/code&gt;, &lt;code&gt;repo_name&lt;/code&gt;, &lt;code&gt;distro&lt;/code&gt;, and &lt;code&gt;repo_version&lt;/code&gt;, plus an &lt;code&gt;id&lt;/code&gt; for looking up package details. Do not assume a single response is a complete inventory of every match.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://pkgdex.org/api-docs" rel="noopener noreferrer"&gt;API documentation&lt;/a&gt; describes scoped searches, package detail lookup, and MCP tools for assistants. It currently specifies free use for non-commercial projects, no authentication, and a maximum of 10 requests per second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try your next package lookup
&lt;/h2&gt;

&lt;p&gt;Start with a package name, a missing file, or a publisher you already use. Narrow the result to your distribution release and architecture, inspect the package metadata, and then return to your package manager for installation.&lt;/p&gt;

&lt;p&gt;Try it at &lt;a href="https://pkgdex.org/" rel="noopener noreferrer"&gt;pkgdex.org&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>opensource</category>
      <category>tools</category>
    </item>
    <item>
      <title>How We Made NGINX Vary Cache Purges 1000x Faster</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:47:30 +0000</pubDate>
      <link>https://dev.to/getpagespeed/how-we-made-nginx-vary-cache-purges-1000x-faster-528l</link>
      <guid>https://dev.to/getpagespeed/how-we-made-nginx-vary-cache-purges-1000x-faster-528l</guid>
      <description>&lt;p&gt;If you purge NGINX cache entries selectively, there is a trap hiding behind one innocent response header: &lt;code&gt;Vary&lt;/code&gt;. The moment your upstream says &lt;code&gt;Vary: Accept-Encoding&lt;/code&gt; (or you enable &lt;a href="https://nginx.org/r/gzip_vary" rel="noopener noreferrer"&gt;&lt;code&gt;gzip_vary&lt;/code&gt;&lt;/a&gt;), NGINX stores one cache file per distinct request header value under the same cache key. An NGINX Vary cache purge therefore has to remove &lt;em&gt;all&lt;/em&gt; of those files, not just one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nginx-extras.getpagespeed.com/modules/cache-purge/" rel="noopener noreferrer"&gt;ngx_cache_purge&lt;/a&gt; 2.6.2 fixed the correctness side of this. An exact purge now deletes every stored variant of the key, not just the variant matching the purge request's own headers. Release 2.7.0, out now, fixes the cost side. Here is the story, with benchmarks.&lt;/p&gt;

&lt;h2&gt;
  
  
  A purge, end to end
&lt;/h2&gt;

&lt;p&gt;A minimal setup with the module loaded. The cached location itself accepts &lt;code&gt;PURGE&lt;/code&gt; requests from localhost:&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;proxy_cache_path&lt;/span&gt; &lt;span class="n"&gt;/var/cache/nginx/site&lt;/span&gt; &lt;span class="s"&gt;keys_zone=site:10m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&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;80&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;/&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://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache&lt;/span&gt;       &lt;span class="s"&gt;site&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache_key&lt;/span&gt;   &lt;span class="nv"&gt;$uri$is_args$args&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache_valid&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="mi"&gt;10m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# same-location purging: PURGE method on the same URL&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache_purge&lt;/span&gt; &lt;span class="s"&gt;PURGE&lt;/span&gt; &lt;span class="s"&gt;from&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="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;Now two different clients request the same URL. The backend answers with &lt;code&gt;Vary: Accept-Encoding&lt;/code&gt;, so NGINX stores two cache files for the one key &lt;code&gt;/style.css&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Accept-Encoding: gzip'&lt;/span&gt; http://example.com/style.css   &lt;span class="c"&gt;# variant 1&lt;/span&gt;
curl http://example.com/style.css                              &lt;span class="c"&gt;# variant 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A purge is the same URL with the &lt;code&gt;PURGE&lt;/code&gt; method. One request removes the key and every variant, and the &lt;code&gt;Cache-Purge-Result&lt;/code&gt; header tells you exactly how many files went away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PURGE &lt;span class="nt"&gt;-i&lt;/span&gt; http://127.0.0.1/style.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;text/html&lt;/span&gt;
&lt;span class="na"&gt;Cache-Purge-Result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;exact; count=2&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Successful purge&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;&lt;/span&gt;Key : /style.css&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;count=2&lt;/code&gt; is both variants. Before 2.6.2 the same request would have removed at most one file - whichever variant matched the purge request's own &lt;code&gt;Accept-Encoding&lt;/code&gt; - and typically answered &lt;code&gt;412 Precondition Failed&lt;/code&gt; while stale variants stayed servable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Vary variants are invisible to a purge
&lt;/h2&gt;

&lt;p&gt;NGINX names a variant's cache file by hashing the cache key together with the &lt;em&gt;requesting client's&lt;/em&gt; header values. The gzip client above produces one file name; the no-encoding client produces another. The purge request knows the cache key. It cannot know every &lt;code&gt;Accept-Encoding&lt;/code&gt; string your clients have ever sent, so it cannot compute the variant file names. NGINX keeps no on-disk index of variants either. (Our &lt;a href="https://www.getpagespeed.com/server-setup/nginx/nginx-proxy-cache-microcaching" rel="noopener noreferrer"&gt;proxy cache and microcaching guide&lt;/a&gt; covers how these cache files are laid out.)&lt;/p&gt;

&lt;p&gt;That leaves exactly one correct way to find the variants: walk the whole cache zone and check each file's embedded &lt;code&gt;KEY:&lt;/code&gt; line. This is what 2.6.2 does. It is the same cost class as a wildcard purge: O(every file in the zone) for a single exact purge.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much does the scan cost?
&lt;/h2&gt;

&lt;p&gt;We measured an NGINX Vary cache purge against zones of 10k, 100k and 1M small cache files. Setup: single worker, Linux ext4 in a container on Apple M2; the benchmark script (&lt;code&gt;bench/variant_scan_bench.sh&lt;/code&gt;) ships in the module source, so the numbers are reproducible. "Warm" means the page cache already holds the zone; "cold" is the first purge after the page cache was dropped:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Files in zone&lt;/th&gt;
&lt;th&gt;Cold purge&lt;/th&gt;
&lt;th&gt;Warm purge&lt;/th&gt;
&lt;th&gt;Non-Vary purge (control)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;0.5 s&lt;/td&gt;
&lt;td&gt;0.03 s&lt;/td&gt;
&lt;td&gt;0.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;4.5 s&lt;/td&gt;
&lt;td&gt;0.2 s&lt;/td&gt;
&lt;td&gt;0.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;227 s&lt;/td&gt;
&lt;td&gt;~1 s&lt;/td&gt;
&lt;td&gt;0.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each warm cell is the median of three trials that agreed within a few percent. The non-Vary control purge stayed at a fraction of a millisecond across every zone size and run. That control is the measurement noise floor, and the effects above sit two to six orders of magnitude over it. These are order-of-magnitude numbers from a laptop-hosted VM, not lab-grade latencies, but the shape is unambiguous.&lt;/p&gt;

&lt;p&gt;The scary part is not the number itself. It is that &lt;strong&gt;the NGINX worker is blocked for the whole scan&lt;/strong&gt;. A purge that takes 4.5 seconds means one worker serving nothing for 4.5 seconds. On a CDN-sized zone with a million files, a cold purge parks a worker for minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a variant index that never guesses
&lt;/h2&gt;

&lt;p&gt;Version 2.7.0 introduces a shared-memory &lt;strong&gt;variant index&lt;/strong&gt;: a map from each cache key to the variant hashes that were stored or served for it. When an exact purge finds the key in the index, it unlinks exactly those files. That is O(number of variants) instead of O(zone).&lt;/p&gt;

&lt;p&gt;The interesting part is how it stays correct. An index that &lt;em&gt;might&lt;/em&gt; be missing a variant is worse than no index. Trusting it would leave stale content servable after a "successful" purge, which is precisely the bug 2.6.2 eliminated. So the index follows one rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An index entry is created only at the moment an exact purge has just removed every file for the key. That is the one state in which "no variants exist" is provably true.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From then on, a header filter records each variant NGINX stores or serves into that entry. Anything that casts doubt drops the entry: an NGINX restart, the entry being evicted under index memory pressure, or more than 64 variants on one key. The next purge then simply falls back to the full scan, which re-seeds the index on completion. Stale entries pointing at files the cache manager already evicted are skipped harmlessly. Correctness is never traded for speed; the worst case is always exactly the 2.6.2 behavior.&lt;/p&gt;

&lt;p&gt;In practice that means: the &lt;em&gt;first&lt;/em&gt; exact purge of a Vary'd key in an NGINX instance's lifetime scans. Every purge after that is indexed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which purges pay what
&lt;/h2&gt;

&lt;p&gt;Not every purge is affected, and the expensive case is narrower than it may sound:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purge request&lt;/th&gt;
&lt;th&gt;Cached response&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Typical latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PURGE /style.css&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no &lt;code&gt;Vary&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;direct key lookup, one file unlinked&lt;/td&gt;
&lt;td&gt;sub-millisecond, any zone size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PURGE /style.css&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;has &lt;code&gt;Vary&lt;/code&gt;, first purge of this key&lt;/td&gt;
&lt;td&gt;full zone scan, index seeded&lt;/td&gt;
&lt;td&gt;seconds to minutes, grows with zone size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PURGE /style.css&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;has &lt;code&gt;Vary&lt;/code&gt;, repeat purge&lt;/td&gt;
&lt;td&gt;index lookup, variants unlinked directly&lt;/td&gt;
&lt;td&gt;milliseconds, any zone size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PURGE /css/*&lt;/code&gt; (wildcard), &lt;code&gt;purge_all&lt;/code&gt;, cache tags&lt;/td&gt;
&lt;td&gt;any&lt;/td&gt;
&lt;td&gt;zone walk by design - candidates cannot be enumerated&lt;/td&gt;
&lt;td&gt;grows with zone size&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two practical notes. You can tell which path a purge took from the error log at &lt;code&gt;info&lt;/code&gt; level: the scan logs &lt;code&gt;exact purge removed N vary variant(s)&lt;/code&gt; and the indexed path appends &lt;code&gt;via variant index&lt;/code&gt;. And if a response should never have had variants in the first place, &lt;a href="https://nginx.org/r/proxy_ignore_headers" rel="noopener noreferrer"&gt;&lt;code&gt;proxy_ignore_headers Vary&lt;/code&gt;&lt;/a&gt; in the cached location avoids the whole topic - at the price of serving one representation to all clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Same benchmark, index enabled (the default):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Files in zone&lt;/th&gt;
&lt;th&gt;First purge (scan, seeds index)&lt;/th&gt;
&lt;th&gt;Repeat purge (indexed)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;17.7 s (cold)&lt;/td&gt;
&lt;td&gt;0.2-0.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;1-5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Repeat purges land in the &lt;strong&gt;0.2-2 ms&lt;/strong&gt; range regardless of zone size. At a million files that is roughly a 1000× improvement over the warm scan, and five orders of magnitude over the cold one. Purge results are identical either way: &lt;code&gt;Cache-Purge-Result: exact; count=N&lt;/code&gt; reports the same counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;Nothing to do. The index is on by default with an 8 MB shared memory zone, which tracks tens of thousands of purged keys. One &lt;code&gt;http&lt;/code&gt;-level directive controls it:&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="c1"&gt;# default; size it up for very large purge-heavy deployments&lt;/span&gt;
&lt;span class="k"&gt;cache_purge_variant_index&lt;/span&gt; &lt;span class="mi"&gt;8m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;# or always use the full scan&lt;/span&gt;
&lt;span class="k"&gt;cache_purge_variant_index&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Non-Vary purges never scan and never touch the index. If your upstreams send no &lt;code&gt;Vary&lt;/code&gt; header, none of this affects you: purges were and remain sub-millisecond.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ngx_cache_purge&lt;/code&gt; 2.7.0 is available from the &lt;a href="https://nginx-extras.getpagespeed.com/modules/cache-purge/" rel="noopener noreferrer"&gt;GetPageSpeed repositories&lt;/a&gt; for all supported RPM and DEB platforms:&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="c"&gt;# RHEL-likes&lt;/span&gt;
dnf &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;https://extras.getpagespeed.com/release-latest.rpm
dnf &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;install &lt;/span&gt;nginx-module-cache-purge

&lt;span class="c"&gt;# Debian/Ubuntu: see the repo setup instructions, then&lt;/span&gt;
apt &lt;span class="nb"&gt;install &lt;/span&gt;nginx-module-cache-purge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then load the module and reload NGINX:&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;load_module&lt;/span&gt; &lt;span class="nc"&gt;modules/ngx&lt;/span&gt;&lt;span class="s"&gt;_http_cache_purge_module.so&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full changelog, directive reference and the correctness model live on the &lt;a href="https://nginx-extras.getpagespeed.com/modules/cache-purge/" rel="noopener noreferrer"&gt;cache-purge module page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run WordPress behind NGINX, the module pairs well with our &lt;a href="https://www.getpagespeed.com/server-setup/nginx/supercharging-wordpress-with-nginx-cache-purge-say-goodbye-to-mounts-and-permissions-hassles" rel="noopener noreferrer"&gt;WordPress cache purge setup&lt;/a&gt;. And if you came here comparing purging options, &lt;a href="https://www.getpagespeed.com/server-setup/ngx_cache_purge-closing-the-gap-with-varnish" rel="noopener noreferrer"&gt;ngx_cache_purge vs Varnish&lt;/a&gt; covers the feature gap this module keeps closing.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>caching</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Gixy v0.2.55: release notes</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Sat, 03 Oct 2026 17:25:48 +0000</pubDate>
      <link>https://dev.to/dvershinin/gixy-v0255-release-notes-5b2n</link>
      <guid>https://dev.to/dvershinin/gixy-v0255-release-notes-5b2n</guid>
      <description>&lt;p&gt;Gixy v0.2.55 is available. Gixy analyzes NGINX configurations for security issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Release notes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fixed
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Crash on directives nginx itself would reject&lt;/strong&gt;: A directive with an argument count nginx refuses to load - such as the bare &lt;code&gt;proxy_pass;&lt;/code&gt; that NixOS's &lt;code&gt;nixosTests.nginx&lt;/code&gt; deliberately generates through &lt;code&gt;writeNginxConfig&lt;/code&gt; - crashed gixy with &lt;code&gt;IndexError&lt;/code&gt; in the &lt;code&gt;ssrf&lt;/code&gt;/&lt;code&gt;proxy_pass_normalized&lt;/code&gt; plugins (typed directives like &lt;code&gt;add_header;&lt;/code&gt; failed earlier, at parse time). The parser now validates argument counts against nginx's own arity rules (via the bundled crossplane analyzer) and skips such directives with a warning: nginx would never load that config, so the directive cannot influence runtime behavior. This unblocks the nixpkgs &lt;code&gt;gixy&lt;/code&gt; 0.2.x upgrade reverted in &lt;a href="https://github.com/NixOS/nixpkgs/pull/568041" rel="noopener noreferrer"&gt;NixOS/nixpkgs#568041&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/dvershinin/gixy/releases/tag/v0.2.55" rel="noopener noreferrer"&gt;Release and downloads&lt;/a&gt; | &lt;a href="https://github.com/dvershinin/gixy" rel="noopener noreferrer"&gt;Source repository&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <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>
    <item>
      <title>The official Amplify app is now on iPhone</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Wed, 22 Jul 2026 13:51:22 +0000</pubDate>
      <link>https://dev.to/dvershinin/the-official-amplify-app-is-now-on-iphone-5b4g</link>
      <guid>https://dev.to/dvershinin/the-official-amplify-app-is-now-on-iphone-5b4g</guid>
      <description>&lt;p&gt;F5's hosted NGINX Amplify reached end of life on January 31, 2026. GetPageSpeed Amplify keeps the familiar agent and compatible API running, with monitoring for NGINX, Varnish, PHP-FPM, MySQL and websites.&lt;/p&gt;

&lt;p&gt;Now it has an official iPhone app.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apps.apple.com/us/app/uptime-by-getpagespeed/id6780385411" rel="noopener noreferrer"&gt;Uptime by GetPageSpeed&lt;/a&gt; uses the same Amplify account and data already collected by your agents. There is nothing new to install on your servers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwv5dxolhhmn02u280gpk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwv5dxolhhmn02u280gpk.png" alt="Uptime by GetPageSpeed Servers screen showing NGINX, Varnish and Caddy hosts" width="800" height="1738"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The native app lets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Browse every server and its current health&lt;/li&gt;
&lt;li&gt;Open live NGINX and system metric graphs&lt;/li&gt;
&lt;li&gt;Manage alert rules and webhooks&lt;/li&gt;
&lt;li&gt;Receive real-time push notifications&lt;/li&gt;
&lt;li&gt;Follow host and metric incidents with Live Activities&lt;/li&gt;
&lt;li&gt;Monitor website uptime, response time and SSL certificates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuzdpf9go4gccdw78h5g3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuzdpf9go4gccdw78h5g3.png" alt="Uptime by GetPageSpeed showing live NGINX and system metric graphs" width="800" height="1738"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It also includes home-screen widgets, passkeys and two-factor security settings. Existing Amplify users can install it and sign in immediately.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.getpagespeed.com/server-setup/official-amplify-app-iphone" rel="noopener noreferrer"&gt;Read the full announcement&lt;/a&gt; or &lt;a href="https://apps.apple.com/us/app/uptime-by-getpagespeed/id6780385411" rel="noopener noreferrer"&gt;download Uptime on the App Store&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>ios</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>NGINX Abuse Guard Module: Auto-Ban Scanners and Bots</title>
      <dc:creator>Danila Vershinin</dc:creator>
      <pubDate>Sun, 19 Jul 2026 17:06:05 +0000</pubDate>
      <link>https://dev.to/dvershinin/nginx-abuse-guard-module-auto-ban-scanners-and-bots-3a93</link>
      <guid>https://dev.to/dvershinin/nginx-abuse-guard-module-auto-ban-scanners-and-bots-3a93</guid>
      <description>&lt;p&gt;Open your NGINX error log on any public server and you will see the same thing: a steady drip of &lt;code&gt;404&lt;/code&gt;s probing for &lt;code&gt;/wp-login.php&lt;/code&gt;, &lt;code&gt;/.env&lt;/code&gt;, &lt;code&gt;/phpmyadmin&lt;/code&gt;, and a thousand other paths that do not exist on your site. A vulnerability scanner walking your tree is a wall of &lt;code&gt;404&lt;/code&gt;s. A bot rattling locked admin endpoints is a wall of &lt;code&gt;403&lt;/code&gt;s. A credential-stuffing run is a wall of failed logins. Legitimate visitors almost never generate a burst of errors, but abusers generate little else. That asymmetry is the entire signal the NGINX abuse guard module acts on, and most servers throw it away.&lt;/p&gt;

&lt;p&gt;The usual tools do not act on it cleanly. A rate limiter such as &lt;code&gt;limit_req&lt;/code&gt; shapes load by request volume, so it slows everyone during a flood and lets the offender back in the instant the rate eases. Tools like &lt;code&gt;fail2ban&lt;/code&gt; read the error pattern correctly, but they live outside NGINX: they tail a log file, parse it on a delay, and shell out to the firewall, so the ban lands seconds or minutes after the damage. What you actually want is to read the status codes your server is already returning and evict the clients whose traffic is mostly failure, immediately, inside the worker.&lt;/p&gt;

&lt;p&gt;That is exactly what the &lt;strong&gt;NGINX abuse guard module&lt;/strong&gt; does. It watches the final response status of every request, keeps a small per-client score in shared memory, and the moment a client crosses a threshold of errors it earns a timed ban enforced at the NGINX preaccess phase, before any handler, file lookup, or upstream runs. There is no sidecar, no log shipper, and no scripting layer. The decision happens in compiled C in a few microseconds. This guide shows you how it works, how to install and configure it, and how to roll it out safely against live traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the NGINX Abuse Guard Module Works
&lt;/h2&gt;

&lt;p&gt;The module reduces abuse detection to three moving parts, all of which live inside the NGINX worker. Understanding them makes every configuration choice below obvious.&lt;/p&gt;

&lt;h3&gt;
  
  
  A leaky, decaying score per client
&lt;/h3&gt;

&lt;p&gt;Every client identity carries a single small number in a shared-memory zone. Each matching error response adds to that score, and the score continuously bleeds away at a rate of &lt;code&gt;threshold ÷ interval&lt;/code&gt; per second. A short, sharp burst of errors pushes the score over the line and trips a ban; a slow trickle of the occasional &lt;code&gt;404&lt;/code&gt; spread across hours never accumulates. Crucially, that score is one fixed-size record no matter how high you set the threshold, so a single modest zone comfortably tracks the tens of thousands of distinct source addresses a botnet throws at you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Counting at the header filter, deciding at preaccess
&lt;/h3&gt;

&lt;p&gt;The module hooks two points in the request lifecycle. A header filter inspects the &lt;em&gt;final&lt;/em&gt; response status of each request and updates the offending client's score. Separately, the preaccess phase checks whether the current client is already banned. Because preaccess runs before routing, static-file handling, and upstream proxying, a banned client is turned away at the cheapest possible point: NGINX never spends a cycle serving the request it is about to reject.&lt;/p&gt;

&lt;h3&gt;
  
  
  A privacy-correct refusal
&lt;/h3&gt;

&lt;p&gt;When a banned client returns, it receives &lt;code&gt;429 Too Many Requests&lt;/code&gt; by default (you choose the code). The response carries a &lt;code&gt;Retry-After&lt;/code&gt; header telling honest clients when to come back, and a &lt;code&gt;Cache-Control: private, no-store&lt;/code&gt; header so that no shared cache or CDN can ever store one client's ban page and serve it to another. Identities are folded into a fixed-size digest before they are stored, so keying on something large like &lt;code&gt;$request_uri&lt;/code&gt; or a header costs exactly as much memory as keying on a plain IP address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Abuse Guard ships as a precompiled, signed dynamic module from the GetPageSpeed repository, so there is no build toolchain to install.&lt;/p&gt;

&lt;h3&gt;
  
  
  RHEL, CentOS, AlmaLinux, Rocky Linux, Amazon Linux
&lt;/h3&gt;

&lt;p&gt;Install the NGINX abuse guard module from the &lt;a href="https://nginx-extras.getpagespeed.com/modules/abuse-guard/" rel="noopener noreferrer"&gt;GetPageSpeed RPM repository&lt;/a&gt;:&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;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;https://extras.getpagespeed.com/release-latest.rpm
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;nginx-module-abuse-guard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then load the module by adding this line at the top of &lt;code&gt;/etc/nginx/nginx.conf&lt;/code&gt;, in the main context before any &lt;code&gt;http {}&lt;/code&gt; block:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;load_module modules/ngx_http_abuse_guard_module.so;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h3&gt;
  
  
  Debian and Ubuntu
&lt;/h3&gt;

&lt;p&gt;First, &lt;a href="https://apt-nginx-extras.getpagespeed.com/apt-setup/" rel="noopener noreferrer"&gt;set up the GetPageSpeed APT repository&lt;/a&gt;, then install from the &lt;a href="https://apt-nginx-extras.getpagespeed.com/modules/abuse-guard/" rel="noopener noreferrer"&gt;APT module page&lt;/a&gt;:&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;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install &lt;/span&gt;nginx-module-abuse-guard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;On Debian and Ubuntu, the package handles module loading automatically. No &lt;code&gt;load_module&lt;/code&gt; directive is needed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On either platform, confirm the configuration is valid before reloading:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo nginx -t
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;

&lt;p&gt;A working policy is two directives. Declare one shared-memory zone in the &lt;code&gt;http&lt;/code&gt; context, then switch enforcement on wherever abuse arrives:&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;load_module&lt;/span&gt; &lt;span class="nc"&gt;modules/ngx&lt;/span&gt;&lt;span class="s"&gt;_http_abuse_guard_module.so&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;abuse_guard_zone&lt;/span&gt; &lt;span class="s"&gt;zone=clients:10m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;# one shared-memory zone&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;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;abuse_guard&lt;/span&gt; &lt;span class="s"&gt;zone=clients&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# enforce here&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo nginx -t &amp;amp;&amp;amp; sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;With nothing but a zone name and size, those defaults ban any single IP that returns &lt;strong&gt;100 &lt;code&gt;403&lt;/code&gt; or &lt;code&gt;404&lt;/code&gt; responses inside a 5-minute window, and the ban holds for one hour&lt;/strong&gt;. Every one of those numbers is tunable, as the reference below shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Directive Reference
&lt;/h2&gt;

&lt;p&gt;The module is four directives: one declares a policy, the rest apply it, exempt trusted clients, and optionally share bans across machines. Everything documented here is verified against the module source, so you can rely on the contexts and defaults exactly as listed.&lt;/p&gt;

&lt;h3&gt;
  
  
  abuse_guard_zone
&lt;/h3&gt;

&lt;p&gt;Context: &lt;code&gt;http&lt;/code&gt;. Carves out one shared-memory zone and sets the policy that governs it. The zone name and size are the only required parameters; sensible defaults fill in the rest.&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;abuse_guard_zone&lt;/span&gt; &lt;span class="s"&gt;zone=clients:10m&lt;/span&gt;
                 &lt;span class="s"&gt;key=&lt;/span&gt;&lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt;
                 &lt;span class="s"&gt;statuses=403,404&lt;/span&gt;
                 &lt;span class="s"&gt;interval=300s&lt;/span&gt;
                 &lt;span class="s"&gt;threshold=100&lt;/span&gt;
                 &lt;span class="s"&gt;block=60m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zone&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(required)&lt;/td&gt;
&lt;td&gt;The policy name (referenced by &lt;code&gt;abuse_guard&lt;/code&gt;) and the shared-memory size, e.g. &lt;code&gt;clients:10m&lt;/code&gt;. About 10 MB tracks on the order of a hundred thousand live clients.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$binary_remote_addr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The expression that defines one client. Any NGINX variable. A request whose key resolves to an empty string is skipped entirely.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;statuses&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;403,404&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which response codes count as errors. Individual codes, ranges, or a mix, e.g. &lt;code&gt;statuses=401,403,404,500-599&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;300s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The window the score decays over. A burst inside it trips a ban; a slow trickle never accumulates.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;threshold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many errors within the window cross the line, up to 1024.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;block&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;60m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long a tripped client stays locked out.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inactive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;max(1h, interval, block)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long a dormant client lingers in memory before reclamation. Any explicit value must be at least as large as both &lt;code&gt;interval&lt;/code&gt; and &lt;code&gt;block&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;on&lt;/code&gt; to replicate this zone's bans across a fleet (see below).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;persist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(none)&lt;/td&gt;
&lt;td&gt;A file path to snapshot bans into so they survive a restart.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;persist_interval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How often the snapshot is rewritten.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;persist_secret&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(none)&lt;/td&gt;
&lt;td&gt;A hex key that signs the snapshot with HMAC-SHA256, so a tampered file is rejected rather than loaded.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;5xx&lt;/code&gt; is left out by default:&lt;/strong&gt; a server error is usually your side's doing, and counting it would let one flaky backend get innocent visitors banned. Add &lt;code&gt;statuses=403,404,500-599&lt;/code&gt; only when you deliberately want to act on clients that trigger server errors.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  abuse_guard
&lt;/h3&gt;

&lt;p&gt;Context: &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;server&lt;/code&gt;, &lt;code&gt;location&lt;/code&gt;. Applies a declared policy. Name the zone to switch enforcement on; write &lt;code&gt;abuse_guard off;&lt;/code&gt; in a nested scope to switch it back off for that scope.&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;location&lt;/span&gt; &lt;span class="n"&gt;/wp-login.php&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;abuse_guard&lt;/span&gt; &lt;span class="s"&gt;zone=clients&lt;/span&gt; &lt;span class="s"&gt;status=429&lt;/span&gt; &lt;span class="s"&gt;log_level=warn&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zone&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(required)&lt;/td&gt;
&lt;td&gt;The zone (declared with &lt;code&gt;abuse_guard_zone&lt;/code&gt;) whose policy applies here.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;429&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The code a banned client receives, anywhere in &lt;code&gt;400&lt;/code&gt; to &lt;code&gt;599&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dry_run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;on&lt;/code&gt; to observe without enforcing: the verdict is logged but no ban is written.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;log_level&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How loudly to log each decision: &lt;code&gt;info&lt;/code&gt;, &lt;code&gt;notice&lt;/code&gt;, &lt;code&gt;warn&lt;/code&gt;, or &lt;code&gt;error&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  abuse_guard_allow
&lt;/h3&gt;

&lt;p&gt;Context: &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;server&lt;/code&gt;, &lt;code&gt;location&lt;/code&gt;. Repeatable and inherited downward. Listed clients are never counted and never banned. Matching is on the true connection address, so it cooperates with &lt;code&gt;realip&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;abuse_guard_allow&lt;/span&gt; &lt;span class="mf"&gt;127.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="k"&gt;abuse_guard_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="mf"&gt;192.168&lt;/span&gt;&lt;span class="s"&gt;.0.0/16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  abuse_guard_redis
&lt;/h3&gt;

&lt;p&gt;Context: &lt;code&gt;http&lt;/code&gt;. Points the server at one Redis or Valkey instance for fleet-wide ban replication. Pair it with &lt;code&gt;redis=on&lt;/code&gt; on the zone you want shared.&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;abuse_guard_redis&lt;/span&gt; &lt;span class="s"&gt;host=10.0.0.5&lt;/span&gt; &lt;span class="s"&gt;password=secret&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# tls://host for TLS&lt;/span&gt;
&lt;span class="k"&gt;abuse_guard_zone&lt;/span&gt;  &lt;span class="s"&gt;zone=clients:10m&lt;/span&gt; &lt;span class="s"&gt;redis=on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(required)&lt;/td&gt;
&lt;td&gt;The Redis or Valkey host. Prefix with &lt;code&gt;tls://&lt;/code&gt; to connect over TLS.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;password&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(none)&lt;/td&gt;
&lt;td&gt;Authentication password, if the server requires one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;port&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;6379&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The server port.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The logical database number.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prefix&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ag_&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key and channel prefix, so several services can share one Redis safely.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;100ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Connection and command timeout for the out-of-band replication path.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Variables
&lt;/h2&gt;

&lt;p&gt;The module exposes exactly three variables, which you can write to your access log or use in a &lt;code&gt;map&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$abuse_guard_status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The verdict for this request: &lt;code&gt;BYPASSED&lt;/code&gt;, &lt;code&gt;PASSED&lt;/code&gt;, &lt;code&gt;COUNTED&lt;/code&gt;, &lt;code&gt;BLOCKED&lt;/code&gt;, or &lt;code&gt;DRY_RUN&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$abuse_guard_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The error count currently attributed to this client.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$abuse_guard_blocked_until&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The Unix timestamp when the ban lifts, or &lt;code&gt;0&lt;/code&gt; if the client is not banned.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A custom log format makes the module's decisions visible while you tune it:&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;log_format&lt;/span&gt; &lt;span class="s"&gt;guard&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="nv"&gt;$remote_addr&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                 &lt;span class="s"&gt;'guard=&lt;/span&gt;&lt;span class="nv"&gt;$abuse_guard_status&lt;/span&gt; &lt;span class="s"&gt;count=&lt;/span&gt;&lt;span class="nv"&gt;$abuse_guard_count&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;access_log&lt;/span&gt; &lt;span class="n"&gt;/var/log/nginx/access.log&lt;/span&gt; &lt;span class="s"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security Hardening Use Cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Blocking vulnerability scanners and 404 floods
&lt;/h3&gt;

&lt;p&gt;This is the case the defaults are built for. An automated scanner requests hundreds of nonexistent paths in quick succession, and every miss is a &lt;code&gt;404&lt;/code&gt;. Enforce the default policy site-wide and the scanner bans itself within seconds of starting, while a real visitor who fat-fingers one URL never comes close to the threshold:&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;abuse_guard_zone&lt;/span&gt; &lt;span class="s"&gt;zone=scanners:10m&lt;/span&gt; &lt;span class="s"&gt;statuses=404&lt;/span&gt; &lt;span class="s"&gt;threshold=40&lt;/span&gt; &lt;span class="s"&gt;interval=60s&lt;/span&gt; &lt;span class="s"&gt;block=30m&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;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;abuse_guard&lt;/span&gt; &lt;span class="s"&gt;zone=scanners&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;h3&gt;
  
  
  Brute-force protection on login and admin endpoints
&lt;/h3&gt;

&lt;p&gt;Credential-stuffing and admin-probing bots produce &lt;code&gt;403&lt;/code&gt;s and failed logins. Scope a stricter policy to the endpoints they target, so a handful of failures is tolerated but a sustained run is locked out, without affecting the rest of the site:&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;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/wp-login.php&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;abuse_guard&lt;/span&gt; &lt;span class="s"&gt;zone=clients&lt;/span&gt; &lt;span class="s"&gt;status=429&lt;/span&gt; &lt;span class="s"&gt;log_level=warn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# ... your usual fastcgi_pass / proxy_pass ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Exempting authenticated users with a map
&lt;/h3&gt;

&lt;p&gt;Because a request whose &lt;code&gt;key&lt;/code&gt; resolves to an empty string is skipped, a &lt;code&gt;map&lt;/code&gt; lets you track anonymous visitors by IP while leaving logged-in users untouched. Here, requests that carry a session cookie produce an empty key and are never scored:&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;map&lt;/span&gt; &lt;span class="nv"&gt;$cookie_sessionid&lt;/span&gt; &lt;span class="nv"&gt;$abuse_key&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;""&lt;/span&gt;      &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# anonymous: key on IP&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;# logged in: skip entirely&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;abuse_guard_zone&lt;/span&gt; &lt;span class="s"&gt;zone=clients:10m&lt;/span&gt; &lt;span class="s"&gt;key=&lt;/span&gt;&lt;span class="nv"&gt;$abuse_key&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;h3&gt;
  
  
  Protecting verified search crawlers
&lt;/h3&gt;

&lt;p&gt;A search-engine crawler grinding through stale URLs can rack up &lt;code&gt;404&lt;/code&gt;s through no fault of yours. Allowlist the published crawler ranges so they are never counted. Because matching is on the real connection address, this also cooperates with &lt;code&gt;realip&lt;/code&gt; when you run behind a CDN:&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;abuse_guard_allow&lt;/span&gt; &lt;span class="mf"&gt;66.249&lt;/span&gt;&lt;span class="s"&gt;.64.0/19&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# example Googlebot range&lt;/span&gt;
&lt;span class="k"&gt;abuse_guard_allow&lt;/span&gt; &lt;span class="mf"&gt;157.55&lt;/span&gt;&lt;span class="s"&gt;.0.0/16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;# example Bingbot range&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rolling out safely with dry_run
&lt;/h3&gt;

&lt;p&gt;Never flip a new ban policy straight to enforcing on production traffic. Run it in observation mode first: &lt;code&gt;dry_run=on&lt;/code&gt; records every ban it &lt;em&gt;would&lt;/em&gt; issue, in the log, without writing any state. You can even run a dry-run location next to an enforcing one on the same zone, calibrate the threshold against real traffic, then flip it live once the numbers look right:&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;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;abuse_guard&lt;/span&gt; &lt;span class="s"&gt;zone=clients&lt;/span&gt; &lt;span class="s"&gt;dry_run=on&lt;/span&gt; &lt;span class="s"&gt;log_level=warn&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;Watch the log for the bans it reports, confirm no legitimate client is caught, then remove &lt;code&gt;dry_run=on&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fleet-Wide Bans with Redis or Valkey
&lt;/h2&gt;

&lt;p&gt;Behind a load balancer, a per-server ban is theatre: the attacker simply lands on a different node. Abuse Guard closes that gap without ever putting Redis in the request path. Point every node at one Redis or Valkey instance and set &lt;code&gt;redis=on&lt;/code&gt; on the zone:&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;abuse_guard_redis&lt;/span&gt; &lt;span class="s"&gt;host=10.0.0.5&lt;/span&gt; &lt;span class="s"&gt;password=secret&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;abuse_guard_zone&lt;/span&gt;  &lt;span class="s"&gt;zone=clients:10m&lt;/span&gt; &lt;span class="s"&gt;redis=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;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;abuse_guard&lt;/span&gt; &lt;span class="s"&gt;zone=clients&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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SELinux:&lt;/strong&gt; on enforcing systems (RHEL, Rocky Linux, AlmaLinux) the kernel blocks NGINX from opening the connection to Redis until you permit it once with &lt;code&gt;setsebool -P httpd_can_network_connect 1&lt;/code&gt;. Skip this and replication silently does nothing while local enforcement carries on as normal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each node still decides locally and counts locally. The instant a node issues a ban, it broadcasts that one fact to the cluster and records a durable copy; every other node imports it within milliseconds, and a node that was offline reconciles the moment it reconnects. Because enforcement is always served from each node's own in-memory state, a visitor's request never waits on a network round-trip. Redis here is a one-way alarm bell, not a shared ledger consulted per request, so a slow or missing Redis can never add latency to your traffic. Run it on a private network and treat write access as privileged: anything that can write to it can issue bans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bans That Survive a Restart
&lt;/h2&gt;

&lt;p&gt;Point a zone at a file and active bans are snapshotted on an interval and restored at startup, so a reload or a reboot does not hand every attacker a fresh slate:&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;abuse_guard_zone&lt;/span&gt; &lt;span class="s"&gt;zone=clients:10m&lt;/span&gt;
                 &lt;span class="s"&gt;persist=/var/lib/nginx/abuse_guard/clients.state&lt;/span&gt;
                 &lt;span class="s"&gt;persist_secret=00112233445566778899aabbccddeeff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The snapshot is integrity-checked and written so that a crash can never leave a torn file. With &lt;code&gt;persist_secret&lt;/code&gt; set, it is also signed with HMAC-SHA256, so a tampered file is rejected rather than trusted. Keep the directory readable only by the NGINX worker user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Abuse Guard vs limit_req and fail2ban
&lt;/h2&gt;

&lt;p&gt;NGINX already ships rate limiting, and most servers already run &lt;code&gt;fail2ban&lt;/code&gt;, so it is worth being precise about where Abuse Guard fits rather than presenting it as a replacement for either.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;limit_req&lt;/th&gt;
&lt;th&gt;fail2ban&lt;/th&gt;
&lt;th&gt;Abuse Guard&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Signal&lt;/td&gt;
&lt;td&gt;Request rate&lt;/td&gt;
&lt;td&gt;Log patterns&lt;/td&gt;
&lt;td&gt;Response error rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action&lt;/td&gt;
&lt;td&gt;Throttle / delay&lt;/td&gt;
&lt;td&gt;Firewall ban&lt;/td&gt;
&lt;td&gt;Timed in-worker ban&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where it runs&lt;/td&gt;
&lt;td&gt;In NGINX&lt;/td&gt;
&lt;td&gt;Out of process&lt;/td&gt;
&lt;td&gt;In NGINX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reaction time&lt;/td&gt;
&lt;td&gt;Immediate&lt;/td&gt;
&lt;td&gt;Log-tail delay&lt;/td&gt;
&lt;td&gt;Immediate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best at&lt;/td&gt;
&lt;td&gt;Shaping load&lt;/td&gt;
&lt;td&gt;Host-wide bans across services&lt;/td&gt;
&lt;td&gt;Evicting error-heavy clients&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use a rate limiter to shape load and protect capacity. Use &lt;code&gt;fail2ban&lt;/code&gt; for host-wide bans that span SSH, mail, and other services. Use Abuse Guard for the specific job neither does well: reading the errors NGINX is already returning and evicting the clients defined by them, in the same worker, on the request itself. The three layer cleanly. A common production stack runs &lt;code&gt;limit_req&lt;/code&gt; for burst control and Abuse Guard for eviction in the same &lt;code&gt;location&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Considerations
&lt;/h2&gt;

&lt;p&gt;Abuse Guard is engineered to make abuse cheap to reject and good traffic free to serve. Three properties matter in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rejection is the cheapest outcome.&lt;/strong&gt; A banned client is turned away at the preaccess phase, before routing, file lookups, or upstream connections, so the more an attacker hammers you the less each request costs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory is fixed per client.&lt;/strong&gt; Each tracked identity is one small, constant-size record regardless of threshold, so a single 10 MB zone tracks roughly a hundred thousand clients. That is what lets one zone absorb a botnet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional dependencies fail open.&lt;/strong&gt; Clustering and signed snapshots are best-effort by design. If Redis or the disk misbehaves, enforcement quietly carries on from local memory and your traffic is never held hostage to a dependency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause and fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;nginx -t&lt;/code&gt; reports "unknown directive abuse_guard_zone"&lt;/td&gt;
&lt;td&gt;The module is not loaded. Confirm &lt;code&gt;load_module modules/ngx_http_abuse_guard_module.so;&lt;/code&gt; is in the main context on RHEL-based systems, and that the package is installed: &lt;code&gt;ls /usr/lib64/nginx/modules/ngx_http_abuse_guard_module.so&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bans never trip&lt;/td&gt;
&lt;td&gt;The score decays at &lt;code&gt;threshold ÷ interval&lt;/code&gt; per second. If errors arrive slower than that, the score never builds. Lower &lt;code&gt;threshold&lt;/code&gt; or shorten &lt;code&gt;interval&lt;/code&gt; for the burst you want to catch.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A client is never counted&lt;/td&gt;
&lt;td&gt;Its &lt;code&gt;key&lt;/code&gt; may resolve to an empty string (skipped by design), or it may match an &lt;code&gt;abuse_guard_allow&lt;/code&gt; range. Check both.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong client is banned behind a CDN&lt;/td&gt;
&lt;td&gt;You are keying on the proxy address. Resolve the real client with &lt;code&gt;realip&lt;/code&gt; first, then key on &lt;code&gt;$binary_remote_addr&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dry_run&lt;/code&gt; bans nothing&lt;/td&gt;
&lt;td&gt;That is correct: &lt;code&gt;dry_run=on&lt;/code&gt; logs the verdict but never writes a ban. Check the log for the bans it reports, then remove &lt;code&gt;dry_run=on&lt;/code&gt; to enforce.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis replication does nothing on RHEL or Rocky Linux&lt;/td&gt;
&lt;td&gt;SELinux is blocking the outbound connection. Allow it once with &lt;code&gt;setsebool -P httpd_can_network_connect 1&lt;/code&gt;, then reload NGINX. Local enforcement is unaffected either way.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;code&gt;return&lt;/code&gt; in the same location appears to bypass the guard&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;return&lt;/code&gt; directive can short-circuit request processing. Place enforcement where it runs before any short-circuit, or in a parent scope.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When you run behind a CDN or reverse proxy, never trust a raw &lt;code&gt;X-Forwarded-For&lt;/code&gt;. Let &lt;code&gt;realip&lt;/code&gt; resolve the true client first:&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;set_real_ip_from&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="k"&gt;real_ip_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;real_ip_recursive&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A tuned Abuse Guard policy only stays correct until someone touches the config.&lt;/strong&gt; NGINX upgrades, module updates, and routine edits quietly reintroduce the very gaps you just closed, and a ban rule that silently stops matching is worse than none. &lt;a href="https://amplify.getpagespeed.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;GetPageSpeed Amplify&lt;/strong&gt;&lt;/a&gt; runs scheduled gixy scans across every host and ties findings to live NGINX runtime metrics. Drop-in compatible with the deprecated &lt;code&gt;nginx-amplify-agent&lt;/code&gt; (EOL January 2026).&lt;/p&gt;

&lt;p&gt;The NGINX abuse guard module turns the errors your server already returns into an automatic, self-calibrating defense. It reads the one signal abusers cannot hide, decides in microseconds inside the worker, and evicts offenders with a real timed ban instead of a throttle that lets them straight back in. With &lt;code&gt;dry_run&lt;/code&gt; for safe rollout, a &lt;code&gt;map&lt;/code&gt;-driven key for surgical scoping, optional fleet-wide replication, and bans that survive a restart, it covers the timed-ban niche that neither &lt;code&gt;limit_req&lt;/code&gt; nor &lt;code&gt;fail2ban&lt;/code&gt; fills. Pair it with the &lt;a href="https://www.getpagespeed.com/server-setup/nginx/nginx-delay-module" rel="noopener noreferrer"&gt;NGINX delay module&lt;/a&gt; to tarpit slow probers, the &lt;a href="https://www.getpagespeed.com/server-setup/nginx/nginx-waf-module" rel="noopener noreferrer"&gt;NGINX WAF module&lt;/a&gt; for payload inspection, or &lt;a href="https://www.getpagespeed.com/server-setup/nginx/install-modsecurity-nginx-linux" rel="noopener noreferrer"&gt;ModSecurity for NGINX&lt;/a&gt; for full rule-based filtering.&lt;/p&gt;

&lt;p&gt;Abuse Guard is available as a pre-built package from the &lt;a href="https://nginx-extras.getpagespeed.com/modules/abuse-guard/" rel="noopener noreferrer"&gt;GetPageSpeed RPM repository&lt;/a&gt; and the &lt;a href="https://apt-nginx-extras.getpagespeed.com/modules/abuse-guard/" rel="noopener noreferrer"&gt;GetPageSpeed APT repository&lt;/a&gt;. For licensing, volume deployments, or a hand getting set up, &lt;a href="https://www.getpagespeed.com/contact-us" rel="noopener noreferrer"&gt;contact us&lt;/a&gt;.&lt;/p&gt;

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