<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	xmlns:media="http://search.yahoo.com/mrss/" >

<channel>
	<title>deb.myguard.nl</title>
	<atom:link href="https://deb.myguard.nl/feed/" rel="self" type="application/rss+xml" />
	<link>https://deb.myguard.nl</link>
	<description>Building packages, building the web</description>
	<lastBuildDate>Sat, 20 Jun 2026 21:33:12 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	
<image>
	<url>https://deb.myguard.nl/wp-content/uploads/2026/05/deb-myguard-shield-favicon-150x150.png</url>
	<title>deb.myguard.nl</title>
	<link>https://deb.myguard.nl</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>nginx-autocert-module: Automatic TLS Certs, No Certbot</title>
		<link>https://deb.myguard.nl/2026/06/nginx-autocert-module/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Thu, 18 Jun 2026 21:37:47 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[acme]]></category>
		<category><![CDATA[angie]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[letsencrypt]]></category>
		<category><![CDATA[nginx-module]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[tls]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6477</guid>

					<description><![CDATA[An open-source NGINX module that bakes a full ACME client into the server itself. Write autocert on; and NGINX gets, serves and renews its own Let's Encrypt certificates — no certbot, no cron, no reload.]]></description>
										<content:encoded><![CDATA[<p>Let&#8217;s be honest about how most of us do TLS certificates on NGINX. You install certbot. You wire up a cron job — or a systemd timer, if you&#8217;re feeling fancy. You pick a challenge plugin, point it at a webroot, cross your fingers, and bolt on a deploy hook that runs <code>nginx -s reload</code> afterwards. Then you spend the next two years quietly hoping none of those four moving parts ever drifts out of alignment, because when a renewal silently fails you usually find out from a customer, at 2 a.m., via a browser screaming about an expired certificate.</p>
<p><strong>nginx-autocert-module</strong> deletes that entire apparatus. It&#8217;s an open-source dynamic module — one we wrote ourselves — that builds a complete ACME client <em>into NGINX itself</em>. You write one directive, <code>autocert on;</code>, and the server obtains, serves, and renews its own Let&#8217;s Encrypt certificates. No certbot. No cron. No reload. This is the complete guide to what it is, how it pulls that off, every directive it understands, and why a handful of its design choices are genuinely interesting rather than merely convenient.</p>
<p>It&#8217;s a long read, because this is the page we want to be <em>the</em> reference for the module. Grab a coffee. We&#8217;ll start from absolute zero and end somewhere only the people who hold the private keys usually get to stand.</p>
<h2 style="color:#f59e0b">What is ACME, and what is nginx-autocert-module?</h2>
<p>Quick refresher, because we promised to start from nothing. <strong>ACME</strong> — Automatic Certificate Management Environment — is the protocol Let&#8217;s Encrypt uses to hand out TLS certificates. Instead of filling in a form and paying a certificate authority, your server <em>proves</em> it controls a domain by answering a challenge, and the CA signs a certificate in return. That&#8217;s the whole idea: control the domain, get the cert.</p>
<p>Certbot is just <em>one</em> program that speaks ACME. It&#8217;s not the protocol — it&#8217;s a client. A perfectly good one, but a separate, standalone thing that lives next to your web server and has to be told, repeatedly, what your web server is doing.</p>
<p>nginx-autocert-module is a <em>different</em> ACME client — one that happens to live <em>inside</em> your web server. It ships as two small <code>.so</code> files you load with <code>load_module</code>. Once loaded, it reads the <code>server_name</code>s you already declared in your vhosts, asks Let&#8217;s Encrypt for a certificate for each, serves them, and renews them on a schedule it runs internally. You don&#8217;t maintain a list of domains anywhere. Your existing config <em>is</em> the list. Add a vhost, get a certificate. Delete it, stop renewing. The config is the single source of truth, the way it always should have been.</p>
<p>If you&#8217;ve read our <a href="https://deb.myguard.nl/angie-web-server-complete-guide/">guide to the Angie web server</a>, you&#8217;ll know Angie — and commercial NGINX Plus — already ship a native <code>acme</code> directive. Open-source mainline NGINX has nothing of the sort. This module fills exactly that gap: it gives plain, free, upstream NGINX the one feature people most often reach for a paid fork or a sidecar tool to get.</p>
<h2 style="color:#f59e0b">The one line that does everything</h2>
<p>Here&#8217;s a complete, working TLS server. Read it twice, because the thing that&#8217;s <em>missing</em> is the entire point:</p>
<pre><code>http {
    resolver 1.1.1.1;                 # the ACME engine needs DNS to reach the CA
    autocert on <span style="display:inline;" class="">adm&#105;n&#64;e&#120;&#97;m&#112;le&#46;&#99;o&#109;</span>;    # global ACME contact

    server {
        listen 443 ssl;
        server_name example.com www.example.com;
        autocert on;                  # both names get a certificate
    }
}</code></pre>
<p>Notice there is no <code>ssl_certificate</code> line. None. And here&#8217;s the thing every NGINX admin&#8217;s brain just flagged: normally NGINX flatly <em>refuses</em> to start a <code>listen ssl;</code> server without one. It&#8217;s a hard, boot-stopping error. So how does this even come up?</p>
<p>The module seeds the server with a throwaway <strong>self-signed bootstrap certificate</strong> the instant NGINX starts. The listener comes up immediately, handshakes succeed (your browser will grumble about the self-signed cert for a few seconds, that&#8217;s expected), and the very first time a real certificate lands on disk it gets swapped in transparently. Your server is never down waiting for issuance. And if you <em>do</em> already have an <code>ssl_certificate</code> line — say you&#8217;re migrating from certbot — the module keeps it as the pre-issuance fallback and overrides it per-connection once the real Let&#8217;s Encrypt cert arrives. Zero-downtime by construction.</p>
<h2 style="color:#f59e0b">Why put the ACME client inside the server at all?</h2>
<p>Reasonable question. Certbot works. Millions of servers run it. Why reinvent the wheel and stuff it inside NGINX?</p>
<p>Three reasons, and they compound.</p>
<p><strong>One: the config is already the source of truth.</strong> An external client has to be told your domains — a CLI flag, a config file, an Ansible template, <em>something</em> that has to be kept in sync with your actual vhosts by hand. Every &#8220;I forgot to add the cert for the new subdomain&#8221; outage traces back to that gap. When the client lives in the server, the gap can&#8217;t exist. The names it provisions are literally the names the server is configured to answer for.</p>
<p><strong>Two: no reload on renewal.</strong> We&#8217;ll dedicate a whole section to this below, because it&#8217;s the headline trick — but the short version is that an in-server client can swap a renewed certificate into a live listener without an <code>nginx -s reload</code>. An external client fundamentally cannot; it can only rewrite a file and ask NGINX to re-read it.</p>
<p><strong>Three: the ACME client lives inside NGINX, not bolted on beside it.</strong> Certbot runs as root (or close to it), writes keys to disk, and trusts a deploy hook to do the reload. The module instead runs the whole ACME engine — network chatter with the CA, the renewal clock, the key handling — on a single NGINX worker, the same way <a href="https://angie.software/" target="_blank" rel="noopener">Angie</a>&#8216;s built-in <code>acme</code> and the official Rust <code>nginx-acme</code> do. No separate daemon, no cron, no deploy hook. More on exactly how next.</p>
<h2 style="color:#f59e0b">How it actually works under the hood</h2>
<p>This is where the module gets opinionated in a good way. All the ACME machinery — talking to Let&#8217;s Encrypt, the renewal clock, building CSRs — runs on <strong>one NGINX worker</strong> (worker&nbsp;0), driven by a timer on NGINX&#8217;s own event loop. Exactly one worker ever runs it: an <code>flock</code> on a lock file in the certificate store hands the role cleanly from one process to the next across a reload or upgrade, so two copies never race the CA or fight over the account. This mirrors how Angie&#8217;s native <code>acme</code> and the official Rust <code>nginx-acme</code> work — and it&#8217;s a deliberate change from an earlier design that used a separate privileged helper process (which turned out to be the odd one out and the source of a nasty cold-start edge case).</p>
<p>The keys live in one place: the account key and every certificate&#8217;s private key are generated and held by that single ACME worker, and on disk they sit in a store directory owned by the worker user, mode <code>0700</code>, with each private key at <code>0600</code>. NGINX reads a certificate back at handshake time; nothing outside that worker user can read the key material. It&#8217;s the same model the rest of the in-server ACME ecosystem (Angie, nginx-acme) settled on — run the client where NGINX already runs, keep the store locked down to the service user, and don&#8217;t spread key material across every process on the box.</p>
<p>And it isn&#8217;t a hacked-up background thread or a forked shell script. The engine is just a timer on a normal NGINX worker&#8217;s event loop, so it answers the same QUIT / TERMINATE / REOPEN signals as every other worker, survives reloads, and if that worker ever crashes the master respawns it and the ACME role re-arms automatically on the new one. It behaves like a first-class part of NGINX because, structurally, it is one. (Getting the single-runner hand-off right across reloads — so exactly one worker drives ACME at any instant, with no gap and no overlap — was the fiddly part; the gory details are in the project&#8217;s commit history if you enjoy that sort of pain.)</p>
<p>When a name needs a certificate, the engine walks the full <a href="https://datatracker.ietf.org/doc/html/rfc8555" target="_blank" rel="noopener">RFC 8555</a> dance, end to end:</p>
<ol>
<li>Register (or reuse) an ACME account, keyed by an ECDSA account key.</li>
<li>Open an <em>order</em> listing the domains it wants certified.</li>
<li>Fetch the authorization for each domain and answer its domain-control challenge.</li>
<li>Poll the CA until validation flips to <code>valid</code>.</li>
<li>Send a certificate signing request (CSR) built fresh for that domain.</li>
<li>Poll the order until it&#8217;s <code>valid</code>, then download the signed chain.</li>
</ol>
<p>It writes the result atomically into a per-domain directory: private key at <code>0600</code>, full chain at <code>0644</code>, all under a <code>0700</code> store owned by the worker user. Workers read only the certificate and chain, only at handshake time, never the account key. Atomic write means a renewal half-finishing — power loss mid-download — never leaves a worker reading a truncated certificate; it sees the old complete one until the new complete one is fully in place.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/nginx-autocert-module-architecture-helper-process.webp" alt="nginx-autocert-module architecture: the ACME order flow runs on one NGINX worker that holds the private keys, while all workers serve certificates per-SNI at the TLS handshake" /></figure>
<h2 style="color:#f59e0b">The headline trick: no reload on renewal</h2>
<p>Here&#8217;s the feature that makes this more than a certbot reskin, and the reason an in-server client is worth the trouble.</p>
<p>With the traditional stack, renewing a certificate means rewriting a file <em>and then</em> telling NGINX to reload so it re-reads that file. At one or two domains, who cares. At a few hundred, that reload becomes a recurring, slightly racy event you&#8217;d really rather not fire off a timer: every reload spins up a fresh set of workers, drains the old ones, and for a beat your connection-handling capacity wobbles. Do it for a cert renewal and you&#8217;re paying a whole-server hiccup to update one file.</p>
<p>nginx-autocert-module doesn&#8217;t reload at all. Certificates are loaded <strong>per-SNI, at the TLS handshake</strong>, straight from disk — and a worker only re-reads a certificate file when its modification time changes. So when the helper renews a certificate, it just writes the new file. The next client to connect for that hostname triggers a fresh read; everyone already connected keeps using the cached one. The renewal takes effect on the next handshake, with zero reloads, zero dropped connections, and zero capacity wobble.</p>
<p>The clock that drives all this runs on that same ACME worker. It sweeps periodically, checks each certificate&#8217;s expiry, marks one &#8220;due&#8221; once it enters the <code>autocert_renew_before</code> window — 7 days by default — and reissues it. One order at a time, so a server with hundreds of domains doesn&#8217;t open hundreds of simultaneous ACME orders and trip a rate limit. Quiet, paced, and invisible until the day you notice you haven&#8217;t thought about certificate expiry in months.</p>
<h2 style="color:#f59e0b">Two ways to prove you own the domain</h2>
<p>ACME needs you to demonstrate control of each domain before it&#8217;ll sign anything. The module supports two challenge types, and you pick with a single directive.</p>
<h3>HTTP-01 — the default</h3>
<p><strong>HTTP-01</strong> serves a magic token at <code>/.well-known/acme-challenge/&lt;token&gt;</code> on port 80. The CA fetches that URL; if it gets the right token back, you&#8217;ve proven control. The module answers it with a built-in handler — no <code>location</code> block, no webroot directory, no separate listener to babysit. The token lives in shared memory and vanishes the instant validation passes. It&#8217;s the path of least resistance and it works for the overwhelming majority of setups.</p>
<h3>TLS-ALPN-01 — for the port-80 refuseniks</h3>
<p><strong>TLS-ALPN-01</strong> (<a href="https://datatracker.ietf.org/doc/html/rfc8737" target="_blank" rel="noopener">RFC 8737</a>), set with <code>autocert_challenge tls-alpn-01;</code>, is the cooler one. It validates <em>entirely inside the TLS handshake</em>. When Let&#8217;s Encrypt connects negotiating the special <code>acme-tls/1</code> ALPN protocol, the module presents a one-off certificate carrying a magic extension, and <em>that</em> is the proof — no HTTP request, no port 80 involved at all.</p>
<p>The payoff: <strong>you never have to open port 80.</strong> For anyone running a locked-down, HTTPS-only edge — where 80 is firewalled shut on principle — that&#8217;s a genuine win, not a party trick. It was also surprisingly fiddly to implement correctly, because NGINX doesn&#8217;t give you a polite way to chain its ALPN selection callback; you have to thread your handshake-time certificate in without breaking the normal path. Worth it, though.</p>
<h2 style="color:#f59e0b">ECDSA only — and the CA is treated as the enemy</h2>
<p>Two security decisions worth calling out, because they&#8217;re the kind of thing that separates &#8220;a tool that works&#8221; from &#8220;a tool you&#8217;d trust with your private keys.&#8221;</p>
<p><strong>First: there is no RSA anywhere.</strong> Account keys and certificate keys are ECDSA P-384 by default (P-256 if you ask via <code>autocert_key_type secp256r1;</code>). Smaller keys, faster signatures, less work on the handshake hot path — and it&#8217;s squarely where the CAs and the wider ecosystem are heading. If you care about modern crypto on your edge, you&#8217;ll also enjoy our write-ups on <a href="https://deb.myguard.nl/tls-configuration-ssllabs-a-plus/">getting an A+ on SSL Labs</a> and <a href="https://deb.myguard.nl/post-quantum-cryptography-nginx-angie-ml-kem-hybrid-tls/">post-quantum TLS on NGINX and Angie</a>.</p>
<p><strong>Second — and this is the part security folks will appreciate — the module treats everything the CA says as hostile network input.</strong> That sounds paranoid until you remember the CA&#8217;s responses arrive over the network, and anything that arrives over the network can be tampered with, malformed, or hostile if the connection is ever compromised. So the module ships its own depth-bounded JSON parser and a strict base64url decoder that <em>rejects</em> garbage rather than silently truncating it — which is exactly what NGINX&#8217;s permissive built-in decoder would happily do, and exactly the kind of silent-truncation behaviour that turns into a security bug three years later. Every single ACME request verifies the CA&#8217;s certificate chain <em>and</em> hostname; there is no &#8220;skip verification&#8221; escape hatch, not even a hidden one for testing. A full security proofread of roughly ten thousand lines of C turned up zero memory-safety, parser, or TLS-verification bugs. For a thing that parses attacker-reachable input and holds your private keys, that paranoia is precisely the right amount.</p>
<h2 style="color:#f59e0b">The directive reference</h2>
<p>Everything you can configure, in one place. Most people will only ever touch the first one.</p>
<table>
<thead>
<tr>
<th style="background:#f59e0b;color:#0b1220;padding:6px 10px">Directive</th>
<th style="background:#f59e0b;color:#0b1220;padding:6px 10px">Context</th>
<th style="background:#f59e0b;color:#0b1220;padding:6px 10px">What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>autocert on|off [email];</code></td>
<td>http, server</td>
<td>Master switch. Global form sets the ACME contact email; per-server form opts that vhost into issuance for its <code>server_name</code>s.</td>
</tr>
<tr>
<td><code>autocert_challenge http-01|tls-alpn-01;</code></td>
<td>http, server</td>
<td>Domain-control method. Default <code>http-01</code>. Use <code>tls-alpn-01</code> to avoid opening port 80.</td>
</tr>
<tr>
<td><code>autocert_key_type secp384r1|secp256r1;</code></td>
<td>http, server</td>
<td>Certificate key curve. Default <code>secp384r1</code> (P-384). These are the OpenSSL curve names — not <code>p384</code>/<code>p256</code>.</td>
</tr>
<tr>
<td><code>autocert_renew_before <em>time</em>;</code></td>
<td>http, server</td>
<td>How long before expiry to renew. Default 7 days.</td>
</tr>
<tr>
<td><code>autocert_path <em>dir</em>;</code></td>
<td>http</td>
<td>Root of the certificate store. Per-domain dirs live underneath, owned by the worker user (<code>0700</code>).</td>
</tr>
<tr>
<td><code>autocert_resolver <em>addr</em>;</code></td>
<td>http</td>
<td>DNS resolver the ACME engine uses to reach the CA. Falls back to the <code>http{}</code> <code>resolver</code> if unset.</td>
</tr>
<tr>
<td><code>autocert_resolver_timeout <em>time</em>;</code></td>
<td>http</td>
<td>Timeout for those DNS lookups.</td>
</tr>
<tr>
<td><code>autocert_ca_certificate <em>file</em>;</code></td>
<td>http</td>
<td>Trust anchor for verifying the CA endpoint. For pointing at a staging or private ACME server.</td>
</tr>
</tbody>
</table>
<p>The canonical, always-current reference lives in the project&#8217;s README — see the link at the end — but the table above covers everything in normal use.</p>
<h2 style="color:#f59e0b">How it compares: certbot, Angie, and this module</h2>
<p>Not a fight — a &#8220;pick the right tool&#8221; table. All three speak ACME; they differ in <em>where the client lives</em> and what that buys you.</p>
<table>
<thead>
<tr>
<th style="background:#f59e0b;color:#0b1220;padding:6px 10px"></th>
<th style="background:#f59e0b;color:#0b1220;padding:6px 10px">certbot + cron</th>
<th style="background:#f59e0b;color:#0b1220;padding:6px 10px">Angie native <code>acme</code></th>
<th style="background:#f59e0b;color:#0b1220;padding:6px 10px">nginx-autocert-module</th>
</tr>
</thead>
<tbody>
<tr>
<td>Where the client runs</td>
<td>External process</td>
<td>Inside the server</td>
<td>Inside the server (on worker 0)</td>
</tr>
<tr>
<td>Reload on renewal</td>
<td>Yes (deploy hook)</td>
<td>No</td>
<td>No</td>
</tr>
<tr>
<td>Domain list source</td>
<td>Maintained by hand</td>
<td>Server config</td>
<td>Server config (<code>server_name</code>)</td>
</tr>
<tr>
<td>Works on mainline NGINX</td>
<td>Yes</td>
<td>n/a (it&#8217;s a fork)</td>
<td>Yes</td>
</tr>
<tr>
<td>Where keys live</td>
<td>On disk (root)</td>
<td>In the server</td>
<td>One worker, <code>0700</code> store</td>
</tr>
<tr>
<td>RSA option</td>
<td>Yes</td>
<td>Yes</td>
<td>No — ECDSA only</td>
</tr>
<tr>
<td>Wildcards / DNS-01</td>
<td>Yes</td>
<td>Yes</td>
<td>Not yet</td>
</tr>
</tbody>
</table>
<p>The honest summary: if you&#8217;re on Angie, use Angie&#8217;s built-in <code>acme</code> — it&#8217;s excellent and this module runs and is tested on Angie too, but Angie&#8217;s own is the natural choice there. If you need wildcards or DNS-01 today, certbot is still your friend. If you&#8217;re on mainline NGINX and you want the in-server, no-reload experience without switching forks, this module is the one that didn&#8217;t exist until we wrote it.</p>
<h2 style="color:#f59e0b">What it doesn&#8217;t do (yet)</h2>
<p>Honesty section, because cornerstone pages that only list strengths age badly. Today the module does full issuance and renewal on mainline NGINX, both challenge types, ECDSA keys, and robust rate-limit handling — it even honours Let&#8217;s Encrypt&#8217;s <code>Retry-After</code> header and backs off per-domain so it never hammers the CA. What it doesn&#8217;t do yet:</p>
<ul>
<li><strong>Wildcards and DNS-01.</strong> Not supported. The &#8220;use the names already in your config&#8221; model doesn&#8217;t cover <code>*.example.com</code>, which fundamentally needs DNS-based validation — a different challenge type that proves control of the whole zone, not one hostname.</li>
<li><strong>Multiple CAs / EAB.</strong> One CA, one account at a time. No External Account Binding for commercial ACME CAs yet.</li>
<li><strong>Angie.</strong> The module builds and runs on Angie, and the full end-to-end test suite runs against Angie on every change — but since Angie ships its own native <code>acme</code>, that&#8217;s the natural choice there. The module coexists with it if you want it.</li>
</ul>
<p>It builds as a clean dynamic module against current mainline NGINX, and it&#8217;s part of the same family as our other modules over on the <a href="https://deb.myguard.nl/nginx-modules/">NGINX modules repository</a>.</p>
<h2 style="color:#f59e0b">Frequently asked questions</h2>
<h3>Do I still need certbot or a cron job?</h3>
<p>No. nginx-autocert-module <em>is</em> the ACME client, and it runs its own renewal timer inside NGINX. There&#8217;s nothing external to install or schedule — no certbot package, no cron line, no systemd timer.</p>
<h3>Does NGINX reload when a certificate renews?</h3>
<p>No. Renewed certificates are written to disk atomically and picked up at the next TLS handshake via a modification-time check. No <code>nginx -s reload</code>, no dropped connections, no capacity wobble.</p>
<h3>Which certificates does it request — do I list domains somewhere?</h3>
<p>There&#8217;s no separate list. It provisions the concrete <code>server_name</code>s already declared in any vhost that has <code>autocert on;</code>. Regex and wildcard server names are skipped, because they don&#8217;t name a concrete host to certify.</p>
<h3>Can I get certificates without opening port 80?</h3>
<p>Yes — set <code>autocert_challenge tls-alpn-01;</code>. Validation then happens entirely inside the TLS handshake (RFC 8737), so port 80 can stay firewalled shut. Otherwise HTTP-01 serves the challenge on port 80.</p>
<h3>Can I get RSA certificates?</h3>
<p>No. Keys are ECDSA P-384 by default, or P-256 if you set <code>autocert_key_type secp256r1;</code>. Note those are the OpenSSL curve names, not <code>p384</code>/<code>p256</code>. RSA is intentionally not an option.</p>
<h3>Where are the private keys, and who can read them?</h3>
<p>One NGINX worker (worker&nbsp;0) runs the ACME engine and generates the account key and every certificate key. On disk they live under <code>&lt;autocert_path&gt;/</code> at <code>0600</code>, inside a <code>0700</code> store owned by the worker user — so make sure that directory is writable by the user NGINX drops to (the <code>user</code> directive). If you&#8217;re upgrading from an older build whose store was created by root, <code>chown</code> it to the worker user once before restarting.</p>
<h3>Will NGINX start if I haven&#8217;t gotten a certificate yet?</h3>
<p>Yes. A <code>listen ssl; autocert on;</code> server with no <code>ssl_certificate</code> starts immediately behind a self-signed bootstrap certificate, then swaps in the real one per-SNI the moment issuance completes.</p>
<h3>What happens if Let&#8217;s Encrypt rate-limits me?</h3>
<p>The scheduler backs off per-domain — exponential, from 60 seconds up to an hour — and honours a CA <code>Retry-After</code> header on HTTP 429, waiting the later of the two deadlines before retrying. It won&#8217;t get your account throttled.</p>
<h3>Does it work with HTTP/3 and the rest of a modern NGINX stack?</h3>
<p>Yes. It&#8217;s a standard dynamic module and doesn&#8217;t care what else you&#8217;ve loaded. Pair it with our <a href="https://deb.myguard.nl/http3-quic-nginx-setup-tuning-gotchas-2026/">HTTP/3 and QUIC setup guide</a> and the certificates it issues are served over whichever protocols your listeners speak.</p>
<h2 style="color:#f59e0b">Where to get it</h2>
<p>The module is open source and lives on GitHub: <a href="https://github.com/eilandert/nginx-autocert-module" target="_blank" rel="noopener">github.com/eilandert/nginx-autocert-module</a>. The README there has the full directive reference, build instructions, and the CI matrix we run on every change. If you run our packaged NGINX builds, keep an eye on the <a href="https://deb.myguard.nl/nginx-modules/">modules repository page</a> — that&#8217;s where it&#8217;ll show up as a drop-in package you can <code>apt install</code> instead of compiling.</p>
<h3>Related reading</h3>
<ul>
<li><a href="https://deb.myguard.nl/angie-web-server-complete-guide/">Angie Web Server: The Complete Guide</a> — review, native ACME, migration, API and HTTP/3.</li>
<li><a href="https://deb.myguard.nl/tls-configuration-ssllabs-a-plus/">TLS Configuration for NGINX and Angie</a> — the complete guide to an A+ on SSL Labs.</li>
<li><a href="https://deb.myguard.nl/post-quantum-cryptography-nginx-angie-ml-kem-hybrid-tls/">Post-Quantum Cryptography with NGINX and Angie</a> — ML-KEM and hybrid TLS.</li>
<li><a href="https://deb.myguard.nl/http3-quic-nginx-setup-tuning-gotchas-2026/">HTTP/3 and QUIC on NGINX</a> — real-world setup, tuning and gotchas.</li>
<li><a href="https://deb.myguard.nl/nginx-modules/">NGINX APT Repository for Debian &amp; Ubuntu</a> — 100+ modules, no compiling.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Olefy and rspamd: scan Office macro malware in your mail</title>
		<link>https://deb.myguard.nl/2026/06/olefy-rspamd-office-macro-scanning/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 10:03:36 +0000</pubDate>
				<category><![CDATA[Antispam]]></category>
		<category><![CDATA[Mail]]></category>
		<category><![CDATA[147]]></category>
		<category><![CDATA[157]]></category>
		<category><![CDATA[197]]></category>
		<category><![CDATA[198]]></category>
		<category><![CDATA[199]]></category>
		<category><![CDATA[200]]></category>
		<category><![CDATA[216]]></category>
		<category><![CDATA[234]]></category>
		<category><![CDATA[235]]></category>
		<category><![CDATA[237]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6453</guid>

					<description><![CDATA[Olefy lets rspamd run oletools against Office attachments to catch VBA-macro malware. Here is how it works, where stock olefy falls over under load, and how olefied makes it survive a real mail stream.]]></description>
										<content:encoded><![CDATA[<p>In February 2022, Microsoft started blocking VBA macros in Office files that arrive from the internet. The malware crews did not pack up and go home. They moved to ISO images, LNK shortcuts, OneNote blobs, and a long tail of booby-trapped <code>.doc</code> and <code>.xlsm</code> files aimed at every mail server still running a 2019 config. The macros that still reach an inbox are the ones nobody bothered to open and inspect. Inspecting them is the job we are wiring up today.</p>

<p>If you run a mail server, you know the feeling. A user forwards you a &#8220;weird invoice&#8221;, you open it in a sandbox six hours too late, and there it is: an auto-exec macro that would have phoned home the moment someone clicked <em>Enable Content</em>. The fix is not hope. The fix is to crack every Office attachment open <strong>at the gateway</strong> and read its macros before your users ever see them. <a href="https://rspamd.com" target="_blank" rel="noopener">Rspamd</a> can do exactly that, with a little help from a tool called olefy and the Python library underneath it.</p>

<p>This post walks the whole pipeline: what olefy is, how rspamd talks to it, where the stock setup quietly falls apart under load, and the wrapper we built — <a href="https://github.com/eilandert/rspamd-olefy" target="_blank" rel="noopener">olefied</a> — to make it survive a real mail stream. Want the wider spam-filtering picture first? Our <a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">rspamd explainer</a> covers Bayes, neural nets, RBLs and the rest. This one zooms all the way in on a single module.</p>

<h2 style="color:#f59e0b">Why Office macros still get people killed (figuratively)</h2>

<p>A VBA macro is just code. Word and Excel will happily run it, and for thirty years that has been a feature, not a bug. Finance teams automate spreadsheets with it; attackers automate your ruin with it.</p>

<p>The classic attack chain is depressingly short. Spam lands. The victim opens the attachment. The document looks blank or &#8220;broken&#8221;, so the victim clicks the yellow <em>Enable Content</em> bar to fix it. The macro fires, pulls a second-stage payload off some compromised WordPress site, and now you are hosting someone else&#8217;s botnet.</p>

<p>The macro itself almost never carries the real weapon — it carries a <em>downloader</em>, usually obfuscated to hell so a plain string search finds nothing. Think Base64 inside a string-reverse inside a split-and-concatenate, eventually calling <code>WScript.Shell</code> or spawning PowerShell with <code>-enc</code>. You cannot catch that by grepping the file for &#8220;powershell&#8221;. You have to actually parse the OLE structure, pull the macro streams out, and look at what the code <em>does</em>.</p>

<p>That parsing is exactly what <a href="https://github.com/decalage2/oletools" target="_blank" rel="noopener">oletools</a> does. It is a Python toolkit by Philippe Lagadec that has been the reference for Office-document forensics for over a decade. The piece we care about is <code>olevba</code>: hand it a file and it digs out every macro, decodes the obvious obfuscation, and flags the suspicious calls — auto-exec triggers, shell-outs, suspicious URLs, the works. It speaks fluent malware-analyst. The catch: it is a command-line Python program, and your mail filter is a long-running C daemon scanning thousands of messages a minute. It is not about to fork a Python interpreter for every attachment.</p>

<h2 style="color:#f59e0b">How rspamd hands a file to olevba</h2>

<p>Rspamd does not call <code>olevba</code> directly — spinning up Python per message would melt your box by lunchtime. Instead it has an <strong>external-services</strong> mechanism (the <code>oletools</code> module, part of the <code>external_services</code> family) that talks to a small network service over a TCP socket. You point rspamd at a host and port, it streams the attachment bytes across, and it gets back a verdict it can turn into a symbol and a score.</p>

<p>That little network service is olefy. Carsten Rosenberg and Dennis Kalbhen at Heinlein Support wrote it precisely to bridge the gap: a long-lived socket daemon that pays the expensive Python startup <em>once</em> and then runs <code>olevba</code> on demand. Rspamd connects, sends a tiny header plus the raw document, and olefy shells out to <code>olevba</code>, scrapes its JSON, and ships it back.</p>

<p>The wire protocol is deliberately dumb, which is a compliment. A request looks like this:</p>

<pre><code>OLEFY/1.0
Method: oletools
Rspamd-ID: a1b2c3

&lt;raw bytes of the .docm here&gt;</code></pre>

<p>Header lines, a blank line, then the file. The client half-closes the connection to say &#8220;that&#8217;s all the bytes&#8221;, olefy runs the scan, writes back a JSON array, and closes. There is also a health check: send <code>PING\n\n</code> and a healthy olefy answers <code>PONG</code>. That is the entire contract — you can test it with <code>nc</code> and a sacrificial document, which is the kind of debuggability you learn to treasure after your third opaque enterprise appliance.</p>

<p>Wiring it into rspamd is four lines:</p>

<pre><code># external_services.conf  (or oletools.conf)
oletools {
  servers  = "olefy:10050";
  timeout  = 15s;
  max_size = 5M;
}</code></pre>

<p>Set the server, give it a timeout so a slow scan cannot wedge the whole message, cap the size so nobody feeds you a 2 GB &#8220;spreadsheet&#8221;, reload rspamd, done. Macros in attachments now get parsed and scored. For about a week, you feel clever.</p>

<h2 style="color:#f59e0b">Where stock olefy quietly falls over</h2>

<p>Here is the part nobody puts in the README. Olefy is a single-threaded asyncio server, and when a request comes in it runs <code>olevba</code> as a blocking subprocess <em>on the event loop</em>. Read that again. While one document is being scanned, the entire daemon is frozen and every other connection waits. On a quiet personal server you will never notice. On a mail relay during a Monday-morning malware blast, you have just turned your parallel filter into a single-file queue — and <code>olevba</code> on a nasty document can take a second or three.</p>

<p>It gets worse. <strong>There is no scan timeout. None.</strong> <code>olevba</code> is a big pile of parsing code chewing on hostile input, and hostile input is the entire point of the exercise. Feed it a malformed or maliciously crafted document and it can hang — and stock olefy will wait for that subprocess forever. The upstream answer is a systemd unit that restarts the service every four hours, which is the software equivalent of rebooting the router when the wifi gets weird. It works, in the sense that a brick works as a paperweight.</p>

<p>And the input buffer is unbounded. A client that opens a connection and dribbles bytes without ever closing will grow that buffer until the kernel gets unhappy, the OOM killer wakes up, looks around, and shoots your scanner in the head. Then your <code>external_services</code> calls start timing out, rspamd logs a wall of errors, and you are reading this at 3 a.m. wondering why &#8220;the spam filter&#8221; is down — when the spam filter is fine, and the macro scanner is the corpse.</p>

<p>None of this is a knock on olefy. It does one job, the code is clean, and it was written for a workload of &#8220;one mail server, reasonable volume&#8221; — for which it is perfect. The trouble starts when you put it in front of real throughput and expect it to behave like a service instead of a script. So we wrapped it.</p>

<h2 style="color:#f59e0b">olefied: the same olefy, built to take a beating</h2>

<p>We kept Heinlein&#8217;s <code>olefy.py</code> exactly as it is — not a line changed. <a href="https://github.com/eilandert/rspamd-olefy" target="_blank" rel="noopener">olefied</a> is a thin front-end that sits in front of one or more unmodified olefy processes and fixes the operational problems without touching the thing that already works. The whole front-end is one file, <code>olefyd.py</code>, and the design is the boring-on-purpose kind that survives contact with production.</p>

<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/olefied-architecture-diagram.webp" alt="olefied architecture diagram: rspamd streams an Office attachment to the olefied dispatcher on port 10050, which load-balances across a pool of unmodified olefy workers running olevba, with an optional redis result cache keyed by document hash" width="1200" height="640" loading="lazy"/></figure>

<p><strong>Concurrency.</strong> <code>olevba</code> is CPU-bound, so the right model is a pool, not a thread pile. olefied launches a set of olefy worker processes (one per CPU by default), each on loopback with its own private scratch directory, and load-balances scans across them from an idle queue. One in-flight scan per worker: a request grabs a free worker, runs, hands it back. When every worker is busy, the next request waits a short, bounded time and then gets a clean &#8220;busy&#8221; answer instead of piling onto an ever-growing backlog. That is backpressure — the difference between a service that degrades and one that face-plants.</p>

<p><strong>The scan timeout, the big one.</strong> Every scan is wrapped in a hard deadline. Blow the deadline and olefied assumes that worker is wedged on a poison document, kills it, and spawns a fresh one. The bad message gets an error verdict, the worker is back in under a second, and the other workers never even noticed. No four-hour restart cron. No frozen daemon. The thing that pages you at 3 a.m. simply stops being a thing.</p>

<p><strong>Limits and self-healing.</strong> Uploads are capped, so the dribble-forever trick hits a wall instead of your RAM. Connections are capped too, which bounds how much memory all those in-flight uploads can hold at once. And a small supervisor loop watches the pool: any worker that has died — or come back but gone mute — gets recycled. The pool heals itself while you sleep, which is the only time pools ever break.</p>

<p>Through all of this the wire protocol is untouched. <code>PING</code> still returns <code>PONG</code>; an <code>OLEFY/1.0</code> request still gets the same JSON back. Your existing rspamd config does not change one character — you point it at olefied instead of olefy, and nothing downstream can tell the difference, except that it stops falling over.</p>

<h2 style="color:#f59e0b">The cheapest scan is the one you don&#8217;t run</h2>

<p>Here is the trick that buys the most headroom, and it is almost embarrassing: <strong>the same attachments show up over and over.</strong> A malware campaign sends the identical <code>.xlsm</code> to ten thousand mailboxes. A mailing list staples the same footer document to every digest. People forward the same quarterly report around the company until the heat death of the universe. Scanning that file a thousand times to get the same answer a thousand times is CPU you are paying for and throwing away.</p>

<p>So olefied has an optional result cache, backed by redis. Point it at a redis URL and successful scans get cached, keyed by a SHA-256 of the document body. The next time that exact document arrives, the answer comes straight out of redis and no worker is touched at all.</p>

<p>The key hashes the <strong>document only</strong>, not the per-message <code>Rspamd-ID</code> header, so the same attachment in different mails is one cache entry, not ten thousand. The oletools version is baked into the key too, so the day you upgrade oletools, the old cache transparently stops matching — you are never served stale verdicts from a parser that has since learned new tricks.</p>

<p>Two things matter here, and we got both right:</p>

<ul>
<li><strong>Only successful scans are cached.</strong> Errors, timeouts and busy responses are never stored, because caching a transient failure is how you turn a blip into an outage that outlives its cause.</li>
<li><strong>The cache can never take the scanner down.</strong> If redis is slow or dead, the lookup is treated as a miss and the scan just runs. The cache is an accelerator, not a dependency.</li>
</ul>

<p>On real mail you will see hit rates in the 30–70% range, and every hit is a scan you did not pay for. Run several copies of olefied behind a load balancer and they all share one redis, so a document scanned by one replica is free for all the others.</p>

<h2 style="color:#f59e0b">How fast is it, really</h2>

<p>Throughput here is not a vibe, it is arithmetic. <code>olevba</code> is CPU-bound and a worker scans one document at a time, so per container the ceiling is roughly:</p>

<pre><code>sustainable msg/s  =  workers / average_scan_seconds</code></pre>

<p>A small attachment scans in something like 50–200 milliseconds, so a single worker handles maybe 5–20 documents a second. Give the container more cores and the worker count rises with them. Need more than one box can do? Run more replicas behind your TCP load balancer — they are stateless, so total throughput is just the sum, climbing in a straight line until you run out of CPU. The cache bends that line further in your favour by removing scans entirely.</p>

<p>Do not take my word for it, and do not take a vendor&#8217;s either — measure it on your own hardware with your own documents, because your attachment mix is not mine. olefied ships a small benchmark script: paste a representative file into <code>sample.bin</code>, run it with a concurrency and a request count, and it prints messages per second plus p50 and p95 latency. Warm the cache first if you want the hit-path numbers. Benchmark one replica in isolation, then multiply. The honest number you get beats the optimistic number somebody put on a slide.</p>

<h2 style="color:#f59e0b">Running it without making yourself a target</h2>

<p>You are about to run a fully untrusted-input parser as a network service. Treat the container as hostile territory, because its entire job is to eat hostile files. The image runs as a non-root user, ships as a multi-stage build with no compiler or build tools in the final layer, and binds the workers to loopback only so the sole exposed thing is the dispatcher. It runs happily read-only with a <code>tmpfs</code> for scratch. Here is the hardened invocation:</p>

<pre><code>docker run -d --name olefied --init \
  --read-only --tmpfs /tmp:rw,mode=1777,size=512m \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --memory 1g --cpus 4 \
  -e OLEFIED_WORKERS=4 \
  -p 10050:10050 \
  eilandert/rspamd-olefy</code></pre>

<p>Drop all capabilities, forbid privilege escalation, set memory and CPU limits so a runaway scan cannot starve its neighbours, and in a real mail stack keep it on an internal network with no published port. If any of that container hardening is new to you, our <a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker hardening guide</a> walks through every flag and why it earns its place. The <code>--init</code> flag adds a PID-1 reaper as belt-and-suspenders; olefied already waits on the workers it spawns, but reaping is cheap insurance.</p>

<p>One detail I am quietly proud of: the image pulls <code>olefy.py</code> and its requirements <strong>fresh from Heinlein&#8217;s upstream at build time</strong> instead of carrying a vendored copy, so every rebuild tracks their latest. It is built daily alongside the rest of our <a href="https://deb.myguard.nl/nginx-dockerized/">dockerized images</a>, so you are never more than a day behind upstream oletools and its parser fixes. Prefer to pin a known revision for a reproducible build? Pass a build argument and you get exactly that. The image is on <a href="https://hub.docker.com/r/eilandert/rspamd-olefy" target="_blank" rel="noopener">Docker Hub</a>; the source, the dispatcher, the tests and the benchmark live on <a href="https://github.com/eilandert/rspamd-olefy" target="_blank" rel="noopener">GitHub</a>.</p>

<h2 style="color:#f59e0b">Frequently asked questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I still need this if Microsoft blocks macros by default now?</h3>
<div class="rank-math-answer ">

<p>Yes. The default-block only applies to files marked as coming from the internet, and only in recent Office versions with the policy intact. Plenty of documents arrive without that mark, plenty of installs have the policy disabled by some helpful admin, and attackers actively work around it with container formats. Scanning at the mail gateway catches what the endpoint policy misses, and it catches it before a human is in the loop.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">What is the difference between olefy and olefied?</h3>
<div class="rank-math-answer ">

<p>olefy is Heinlein Support&#8217;s original socket daemon that bridges rspamd to oletools&#8217; olevba. olefied is our wrapper around it: it runs a pool of unmodified olefy workers and adds a scan timeout, backpressure, an input cap, self-healing and an optional redis cache. Same wire protocol, same olefy underneath, built to handle real throughput.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Will olefied break my existing rspamd configuration?</h3>
<div class="rank-math-answer ">

<p>No. The wire protocol is identical, so rspamd&#8217;s oletools external-service config does not change. You point the servers line at olefied instead of olefy and reload. PING still returns PONG and scan requests get the same JSON back.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Is the redis cache safe? What if redis goes down?</h3>
<div class="rank-math-answer ">

<p>It is non-fatal by design. If redis is slow or unreachable the lookup is treated as a cache miss and the scan runs normally, so the cache can never take the scanner offline. Only successful scans are cached, never errors or timeouts, and keys include the oletools version so an upgrade invalidates old entries automatically.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">How many messages per second can it actually handle?</h3>
<div class="rank-math-answer ">

<p>Per container, roughly workers divided by the average scan time. A worker does one scan at a time and a typical scan is 50 to 200 ms, so figure 5 to 20 messages per second per worker, times your core count. Scale out with stateless replicas behind a load balancer, and let the redis cache remove repeat scans entirely. Benchmark it on your own documents with the included script rather than trusting any single number.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Does olevba run the macros?</h3>
<div class="rank-math-answer ">

<p>No, and this is the whole point. olevba statically parses the document, extracts the macro source, decodes common obfuscation and flags suspicious behaviour. It never executes the code. You get the analysis without detonating the payload, which is exactly what you want at a mail gateway.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Where to go next</h2>

<ul>
<li><a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd explained</a> — the full spam-filtering picture: Bayes, neural nets, RBLs, Pyzor, Razor and where olefy fits in.</li>
<li><a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker hardening for self-hosters</a> — every flag in that hardened run command, explained.</li>
<li><a href="https://deb.myguard.nl/nginx-dockerized/">Our dockerized images</a> — how the daily-rebuilt image factory works, olefied included.</li>
<li><a href="https://deb.myguard.nl/where-to-find-us/">Where to find us</a> — every repo, Docker image and GitHub project in one place.</li>
<li><a href="https://github.com/eilandert/rspamd-olefy" target="_blank" rel="noopener">olefied on GitHub</a> and on <a href="https://hub.docker.com/r/eilandert/rspamd-olefy" target="_blank" rel="noopener">Docker Hub</a>, built on Heinlein&#8217;s <a href="https://github.com/HeinleinSupport/olefy" target="_blank" rel="noopener">olefy</a>.</li>
</ul>

<p>So: the next &#8220;weird invoice&#8221; that lands in your queue gets its macros read by a machine that never clicks Enable Content, never gets tired, and never wonders if this one is fine. Go point rspamd at it — then back up your config before you touch the timeout.</p>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>YARA malware scanning in rspamd: building the missing module</title>
		<link>https://deb.myguard.nl/2026/06/yara-malware-scanning-rspamd-yarad/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 09:49:59 +0000</pubDate>
				<category><![CDATA[Antispam]]></category>
		<category><![CDATA[Mail]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[mail]]></category>
		<category><![CDATA[malware]]></category>
		<category><![CDATA[rspamd]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[selfhosted]]></category>
		<category><![CDATA[yara]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6447</guid>

					<description><![CDATA[Rspamd has no YARA module. Here is how to add real YARA malware scanning to rspamd with a tiny out-of-process Go scanner, public rulesets, and a cache that holds up under serious mail volume.]]></description>
										<content:encoded><![CDATA[<p>The feature request to add YARA malware scanning to rspamd has sat open on GitHub since 2021. So if a colleague tells you &#8220;oh yeah, rspamd does YARA,&#8221; they&#8217;re wrong — there&#8217;s no module, there never was. YARA scanning in rspamd is something you build, not a checkbox you tick. Here&#8217;s how, and what I learned building it.</p>

<p>If you&#8217;ve never met YARA: it&#8217;s the pattern-matching engine malware analysts use to describe and detect families of nasty files. Think of it as grep that understands &#8220;this looks like a malicious PDF dropper&#8221; instead of &#8220;this line contains the word cat.&#8221; VirusTotal runs it. Every threat-intel team writes rules in it. And it&#8217;s genuinely useful bolted onto a mail filter, because email is still how most malware introduces itself to your users. Yet rspamd, the best open-source spam filter going, has no native YARA module. Let&#8217;s talk about why, and what to do about it.</p>

<h2 style="color:#f59e0b">The module that doesn&#8217;t exist</h2>

<p>Here&#8217;s how the conversation usually goes. Someone wires up rspamd, reads that it does Bayes, neural nets, RBLs, fuzzy hashing, DCC, Razor, Pyzor, the works, and assumes YARA is in the pile somewhere. It feels like it should be. YARA is the obvious tool for &#8220;scan this attachment against a pile of malware signatures.&#8221; So they go looking for <code>local.d/yara.conf</code> and come up empty.</p>

<p>I did the responsible thing and checked the running container before trusting my own memory. Grepped the whole config tree for &#8220;yara.&#8221; Nothing. Checked whether the rspamd binary was even linked against libyara. It wasn&#8217;t. Looked for the plugin Lua file that every native module ships. Not there either.</p>

<pre class="wp-block-code"><code>$ docker exec rspamd sh -c 'grep -rli yara /etc/rspamd/; rspamadm configdump | grep -ci yara'
0</code></pre>

<p>Zero. The upstream answer is the same: YARA scanning is a feature request, discussion #3511, still open, still unimplemented as of rspamd 4.1. The closest thing rspamd will tell you is &#8220;use ClamAV, it can load YARA rules.&#8221; Which is true, and also a trap, and we&#8217;ll get to why.</p>

<p>None of this makes rspamd bad — it&#8217;s superb at what it does (the full tour: <a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">rspamd, Bayes, neural nets and RBLs</a>). YARA just isn&#8217;t in the toolkit yet. So how do you add it without making a mess?</p>

<h2 style="color:#f59e0b">Your three options, ranked by how much they&#8217;ll hurt</h2>

<p>When a tool you love is missing a feature, you&#8217;ve got the usual choices, and they&#8217;re rarely equal.</p>

<p><strong>Option one: shove YARA into ClamAV.</strong> Clamd loads <code>.yar</code> files and rspamd already talks to ClamAV. On paper, done. In practice your rules become hostages of clamd&#8217;s reload cycle, you can&#8217;t score individual rule hits (everything is one generic CLAM_VIRUS verdict), and debugging which rule fired means spelunking clamd logs. A precise instrument turned blunt. It works. It&#8217;s grim.</p>

<p><strong>Option two: write a pure-Lua YARA plugin.</strong> Rspamd&#8217;s plugins are Lua, so surely you just call libyara from Lua and you&#8217;re done? No. libyara is a C library with a CGO-shaped hole where its bindings should be, and rspamd&#8217;s Lua runtime has no YARA bindings at all. Even if it did, running a full YARA scan inside the rspamd worker means doing heavy CPU work on the event loop. That loop is the thing keeping your mail flowing. Block it and you don&#8217;t have a spam filter, you have a very expensive way to make Postfix queue up.</p>

<p><strong>Option three: run YARA out of process, over HTTP.</strong> The worker fires an async request, a separate service scans, the answer comes back, the worker never blocks. More code up front, but the only option that doesn&#8217;t make you hate your life in six months. So that&#8217;s the one.</p>

<p>That pattern is familiar: rspamd already does it for the collaborative networks. DCC, Razor and Pyzor are CLI tools behind a small HTTP backend I&#8217;d built in Go, <a href="https://github.com/eilandert/gozer" target="_blank" rel="noopener">gozer</a> — and the YARA scanner is its sibling. Same shape, different payload.</p>

<figure class="wp-block-image size-large"><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/yarad-rspamd-architecture-diagram.webp" alt="Architecture diagram showing Postfix to rspamd to yarad to YARA rules and Valkey, with yarad scanning out of process over HTTP"/><figcaption>The whole shape: rspamd&#8217;s Lua plugin asks yarad over HTTP, yarad scans against the rules and caches the verdict, and the worker never blocks.</figcaption></figure>

<h2 style="color:#f59e0b">What YARA actually does, for the uninitiated</h2>

<p>Before the plumbing, the engine. A YARA rule is a tiny declarative program: &#8220;a file matches if it contains these patterns under these conditions.&#8221; Here&#8217;s the canonical harmless one, matching the EICAR test string every antivirus recognises:</p>

<pre class="wp-block-code"><code>rule EICAR_Test_File : test
{
    meta:
        description = "EICAR antivirus test pattern"
    strings:
        $e = "$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!"
    condition:
        $e
}</code></pre>

<p>Three parts. <code>meta</code> is free-form notes (author, what it catches, severity). <code>strings</code> declares patterns — plain text, hex, or regex. <code>condition</code> is the logic: match if this string appears, or three of five do, or the file starts with a PE header and holds that suspicious import. A good rule keys off structural traits the author can&#8217;t change without breaking their own payload.</p>

<p>That last bit matters. A signature on a literal string is trivial to evade: change the string. A rule that matches the <em>shape</em> of a packer stub, or the specific sequence of API calls a dropper makes, survives the malware author tweaking cosmetics. This is why threat-intel teams ship YARA rules and not just hash lists. Hashes catch yesterday&#8217;s exact file. Rules catch tomorrow&#8217;s variant.</p>

<p>And here&#8217;s the thing people miss: YARA is <em>much</em> more than a box of regexes. The engine ships file-format <strong>modules</strong> that crack a file open before you match anything. The <code>pe</code> module parses a Windows executable, so a rule can say &#8220;imports the function used to inject code into another process, and its last section is high-entropy&#8221; — entropy being shorthand for &#8220;looks encrypted or packed, the way malware hides.&#8221; There are siblings for <code>elf</code>, <code>macho</code> and .NET, a <code>hash</code> module for fingerprinting, and a <code>math</code> module for the entropy sums. None of that is expressible as a regex: a regex sees a flat stream of characters; these modules see a <em>structured file</em> and let a rule reason about its anatomy. That&#8217;s why a real scanner uses libyara, not grep. yarad compiles all of it.</p>

<p>For email you point these rules at two things: the whole raw message, and each attachment on its own. The per-attachment scan is where the real malware-hunting happens — that&#8217;s where the malicious PDF or macro-laden spreadsheet lives — and, as we&#8217;ll see, where you have to do some unpacking before the rules can even see the nasty bit.</p>

<h2 style="color:#f59e0b">The scanner: a small Go daemon called yarad</h2>

<p>So I wrote <a href="https://github.com/eilandert/rspamd-yarad" target="_blank" rel="noopener">yarad</a> — deliberately boring, the highest compliment for mail infrastructure. It compiles a rule set at startup, listens on HTTP, and answers one question: &#8220;here are some bytes, which rules match?&#8221; The rspamd plugin POSTs a message or attachment, yarad scans and returns the matched rule names as JSON.</p>

<pre class="wp-block-code"><code>$ printf '%s' "$EICAR_STRING" | curl -s -H "X-YARAD-Token: secret" \
    --data-binary @- http://yarad:8079/scan
{"matches":[{"rule":"EICAR_Test_File","tags":["test"],"meta":{"description":"EICAR antivirus test pattern"}}]}</code></pre>

<p>Under the hood it&#8217;s Go calling libyara through CGO (<code>go-yara</code>). The compiled rule set is immutable, so a reload compiles a fresh set and swaps a pointer atomically — in-flight scans finish on the old rules, new ones pick up the new, no long-held lock. A SIGHUP recompiles in place; a broken rule edit fails the reload and keeps the previous rules live, because the one thing worse than stale rules is a scanner that disarmed itself over a fat-fingered brace.</p>

<p>The build is the fiddly part. Go with CGO against a static libyara, on a distroless final image, is a minefield of glibc version skew — build on a newer Debian than the runtime base and you get:</p>

<pre class="wp-block-code"><code>/usr/local/bin/yarad: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found</code></pre>

<p>The build image&#8217;s glibc must be no newer than the runtime base&#8217;s — match the Debian releases. And don&#8217;t fully static-link glibc (it breaks <code>dlopen</code>/<code>getaddrinfo</code>): link libyara statically, leave glibc dynamic, ship on a minimal Debian-based distroless image. The result is a ~89 MB container with no shell, no package manager, running non-root — see my longer take on <a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">hardening Docker the rootless, read-only, distroless way</a>.</p>

<p>Worth knowing where that 89 MB actually goes, because it&#8217;s mostly not code. About half of it — ~37 MB — is the compiled rule bundle (some 11,000 rules baked into one <code>.yac</code>). The distroless base and its system libraries (glibc, libssl, tzdata) are another ~25 MB. The yarad binary itself, Go statically linked against libyara and stripped, is barely ~8 MB. So the &#8220;scanner&#8221; is the small part; the rules are the weight, and they grow a little every day as the rebuild pulls the latest.</p>

<h2 style="color:#f59e0b">Rulesets: where the good rules actually live</h2>

<p>A YARA scanner with no rules is a very fast way to do nothing. The whole value is in the rules, and writing your own from scratch for general malware is a fool&#8217;s errand when teams who do this full time publish theirs for free.</p>

<p>yarad bakes five public sources straight into the image at build time. I want to be loud about this part: <strong>these rules are other people&#8217;s work, given away for free, and they are the entire reason any of this catches anything.</strong> yarad just packages and runs them. So, with full credit and their licenses spelled out:</p>

<ul>
<li><strong><a href="https://github.com/YARAHQ/yara-forge" target="_blank" rel="noopener">YARA-Forge</a></strong> (the &#8220;core&#8221; bundle) is a clever project by YARAHQ: it ingests dozens of public rule repositories, deduplicates them, strips the broken and the dangerous, and ships a single curated bundle in quality tiers. You pull &#8220;core&#8221; and you&#8217;ve got vetted rules without wrangling forty git repos by hand. <em>License: it&#8217;s an aggregator, so every bundled rule keeps its original author&#8217;s license; the build tooling itself is GPL-3.0.</em></li>
<li><strong><a href="https://github.com/Neo23x0/signature-base" target="_blank" rel="noopener">Neo23x0&#8217;s signature-base</a></strong> is Florian Roth&#8217;s long-running collection that powers a huge amount of the YARA scanning in the wild (it&#8217;s the engine behind the THOR and Loki scanners). <em>License: Detection Rule License (DRL) 1.1 — a permissive, MIT-style licence written specifically for detection content.</em></li>
<li><strong><a href="https://github.com/anyrun/YARA" target="_blank" rel="noopener">ANY.RUN&#8217;s</a></strong> rules round it out with actively maintained malware-family and phishing signatures from a well-known sandbox vendor. <em>License: published as open detection rules (no separate licence file in the repo).</em></li>
<li><strong><a href="https://github.com/DidierStevens/DidierStevensSuite" target="_blank" rel="noopener">Didier Stevens&#8217; suite</a></strong> brings the document specialists — the <code>vba.yara</code>, <code>rtf.yara</code> and <code>maldoc.yara</code> rules that hunt malicious Office macros and RTF exploits, which matter enormously for mail. <em>License: public domain (&#8220;no copyright, use at your own risk&#8221;).</em></li>
<li><strong><a href="https://github.com/bartblaze/Yara-rules" target="_blank" rel="noopener">bartblaze&#8217;s Yara-rules</a></strong> adds maldoc, RTF and phishing-document rules that aren&#8217;t aggregated into YARA-Forge. <em>License: MIT.</em></li>
</ul>

<p>Together that&#8217;s around 10,000 compiled rules covering malware, webshells, suspicious documents and phishing kits — and the licences are all permissive (DRL, MIT, public domain). I deliberately turned down richer but restrictive sets (Elastic&#8217;s own licence, the GPL-only Yara-Rules project): baking a copyleft or &#8220;no managed service&#8221; clause into a public MIT image drags it onto everyone who pulls the image.</p>

<p>Compiling ten thousand rules isn&#8217;t free, so yarad doesn&#8217;t do it at startup. The rules are precompiled into a single binary bundle at image-build time, and the running container just loads that ready set instead of building one. Startup is instant, and the loaded set looks like this:</p>

<pre class="wp-block-code"><code>compile-rules: bundled 745 files (13 skipped) -> /rules/compiled.yac
[yarad] loaded 10385 YARA rules from /rules/compiled.yac</code></pre>

<p>Those 13 skipped files are the other lesson: public rulesets are messy. Some import modules I didn&#8217;t compile in, others use THOR/Loki external variables that only make sense scanning files on disk. So each rule file is test-compiled on its own first and a single rotten one is logged and skipped, fatal only if <em>nothing</em> compiles. Test rule loading against the real ruleset, not a toy rule, or you meet your first broken file in production with zero rules loaded.</p>

<p>Baking the rules into the image (a daily rebuild re-pulls the latest) means they track a dated, rollback-able artifact, not a mutable folder nobody remembers editing — same logic as pinning a package version. One caveat: running rules you didn&#8217;t write is running internet logic against your mail. YARA can&#8217;t execute code, so the blast radius is a noisy false-positive, not a shell — but treat a rule source like any dependency: know the maintainer, pin it, watch what it flags first.</p>

<h2 style="color:#f59e0b">Documents are where the malware hides: OLE, RTF and macros</h2>

<p>Here&#8217;s a problem that breaks naive scanners, and it took me a beat to appreciate how badly. You bake in ten thousand rules, including a pile that specifically hunt malicious Office macros, and then you scan a real malicious spreadsheet… and nothing fires. The rules are right there. The macro is right there. They just never meet. Why?</p>

<p>Because a modern Office document is a Russian doll. A <code>.docm</code>/<code>.xlsm</code> is secretly a ZIP holding a <code>vbaProject.bin</code> — itself an older OLE2 container (same as legacy <code>.doc</code>/<code>.xls</code>) — and inside <em>that</em> the macro code is stored <strong>compressed</strong> (Microsoft&#8217;s MS-OVBA). So a rule hunting telltale words like <code>AutoOpen</code> or <code>Shell</code> stares at a compressed blob: the words are there, squished into gibberish, and the rule sees nothing.</p>

<h3>Step one: unwrap and decompress</h3>

<p>So before yarad matches a single rule, it cracks the doll open. It sniffs the first few bytes of every attachment to recognise the two container shapes (OLE2 and ZIP), unzips the modern ones in memory, finds the embedded macro project, and <strong>decompresses the VBA back into the plain source the attacker actually wrote.</strong> Then it scans <em>both</em> things: the raw bytes (for rules that hunt file-format exploits) and the decompressed macro source (for rules that hunt suspicious keywords). The matches get merged together. Suddenly all those macro rules can see the macro, and they fire exactly when they should.</p>

<p>The nice part: this is done entirely in Go, with a pure-Go library called <a href="https://github.com/Velocidex/oleparse" target="_blank" rel="noopener">oleparse</a>, so there&#8217;s no extra C dependency and no Python in the hot path. And there&#8217;s a little flourish — while scanning the decompressed source, yarad flips an internal switch (a YARA &#8220;external variable&#8221; called <code>VBA</code>) to true, so Didier Stevens&#8217; macro-keyword rules fire on the cleartext macro but stay quiet on raw bytes, where the same keywords would just be noise.</p>

<p>RTF files — the other classic malware vehicle, home of the famous Equation Editor exploit — work differently and need no unpacking: they smuggle their payload as hexadecimal text right there in the raw file, so the RTF exploit rules match the raw bytes directly. Different shape, same outcome: the rule meets the malware.</p>

<h3>Step two: un-disguise the URLs</h3>

<p>Malware authors know macros get scanned, so they disguise the addresses their code phones home to: <code>hxxp://evil[.]example/payload</code> — &#8220;defanged&#8221; so a dumb scanner doesn&#8217;t recognise it. yarad un-disguises these on every buffer, rewriting <code>hxxp</code>→<code>http</code>, <code>[.]</code>/<code>(dot)</code>→a dot, putting the threat back in plain sight.</p>

<p>Why bother? Because of the next trick: yarad can check every URL it finds — in the message and in those decompressed macros — against <strong><a href="https://urlhaus.abuse.ch/" target="_blank" rel="noopener">URLhaus</a></strong>, abuse.ch&#8217;s free feed of known malware-distribution links. With an abuse.ch key it pulls the feed into memory periodically, so every lookup is an instant local check, never a per-message API call. A macro reaching a URL <em>known</em> to serve malware is about as close to a smoking gun as mail scanning gets — and one that only matched after un-disguising is flagged as more suspicious still. A dead feed just means a miss; it never holds up mail.</p>

<h3>How this compares to Python&#8217;s oletools</h3>

<p>If you&#8217;ve done maldoc analysis you know <a href="https://github.com/decalage2/oletools" target="_blank" rel="noopener">oletools</a> — Philippe Lagadec&#8217;s Python suite (<code>olevba</code>, <code>mraptor</code>, <code>oleid</code>, <code>rtfobj</code>), the reference toolkit for pulling documents apart. I run it elsewhere in this stack, wrapped as <a href="https://deb.myguard.nl/2026/06/olefy-rspamd-office-macro-scanning/">olefy</a>. So why duplicate it in yarad?</p>

<p>Because with the unpacking step above, yarad covers <strong>roughly 80% of what oletools does for mail — in-process, in Go, no Python, no second service.</strong> It extracts and decompresses the VBA (the heart of <code>olevba</code>), detects suspicious-keyword and auto-run patterns (<code>olevba</code> indicators, <code>mraptor</code>&#8216;s heuristic), spots encrypted or macro-bearing documents (<code>oleid</code>&#8216;s job), catches RTF and embedded-object exploits, and goes <em>beyond</em> oletools by reputation-checking the extracted URLs against URLhaus. For the overwhelming majority of malicious mail, that&#8217;s enough.</p>

<p>The remaining ~20% is the deep tail: <code>olevba</code> doesn&#8217;t just notice Base64 or string-reversal, it <em>decodes</em> the obfuscation chain to reveal the hidden command; it emulates Excel 4.0 &#8220;XLM&#8221; macros; yarad matches the <em>patterns</em> of obfuscation, it doesn&#8217;t fully unwind it — which is why olefy keeps running in parallel as a second opinion. But most maldoc detection no longer needs to leave the Go process, and that&#8217;s a big win at a thousand messages a second.</p>

<h2 style="color:#f59e0b">Caching, or how to keep a busy mail server alive</h2>

<p>YARA scanning is CPU work, and a busy mail server can&#8217;t scan every byte of every message from cold. The saving grace is that mail is gloriously repetitive: bulk campaigns, one body fanned out to a 500-person list, MTA retries of the identical message. Scanning all of those independently sets CPU on fire computing the same answer over and over.</p>

<p>So yarad caches verdicts keyed by a SHA-256 of the bytes. Scan a message once, remember the result, and every identical message after that is a microsecond map lookup instead of a scan. The cache is an in-process LRU with a TTL, always on, bounded so it can&#8217;t eat all your RAM.</p>

<p>The cleverer trick is coalescing. When a 500-recipient blast lands, all 500 copies arrive with the same hash before any has finished scanning. Naively that&#8217;s 500 scans; yarad&#8217;s singleflight group makes the first scan and the other 499 block on its result. One scan, not five hundred — the difference between a blip and a load spike.</p>

<pre class="wp-block-code"><code>yarad_scans_total 1
yarad_matches_total 1
yarad_cache_hits_total 1
yarad_cache_coalesced_total 0</code></pre>

<p>For one box the in-process cache is plenty. Run several yarad replicas and you&#8217;ll want them sharing a verdict cache: point <code>YARAD_REDIS_URL</code> at a Redis or <a href="https://deb.myguard.nl/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/">Valkey</a> instance. A dead or slow Redis fails open (yarad just scans, 200 ms budget), so a cache outage means &#8220;a bit more CPU&#8221;, not &#8220;mail stops.&#8221;</p>

<p>That&#8217;s the rule for every external dependency on a mail path, and yarad takes it all the way. A scan error, a timeout, a panic, a libyara hiccup, all of it gets reported as &#8220;no match&#8221; and logged, never as an error that blocks the message. Spam filtering is allowed to miss. It is never allowed to eat your mail. A scanner that drops legitimate email because it crashed is infinitely worse than one that occasionally lets a sample through.</p>

<h2 style="color:#f59e0b">Wiring it into rspamd without blocking real mail</h2>

<p>The rspamd side is a Lua plugin. It grabs the message content and each attachment, fires async HTTP requests at yarad, and attaches whatever matched to the message. Each matched rule comes back with the rule name and, crucially, the <em>source file</em> it came from, so the history shows <code>SUSP_Just_EICAR (sigbase-gen_suspicious_strings.yar)</code> rather than a bare rule name you then have to go hunting for. URLhaus hits show the actual offending URL. The plugin stays fully async, so the worker is never sitting around waiting on a scan.</p>

<p>One small but important piece of hygiene: public rulesets ship the occasional demo or teaching rule that is useless in production. Didier Stevens&#8217; set, for instance, includes a rule literally named <code>http</code> whose entire logic is &#8220;the text contains the string http&#8221; — which is to say, it matches essentially every email ever sent. So the plugin keeps a small denylist (just <code>http</code> by default) of rule names to ignore, and you can add to it without rebuilding anything. Knowing which file a rule came from, from the line above, is exactly what lets you spot a noisy one and silence it.</p>

<p>One deployment gotcha: if your rspamd uses a locked-down resolver that can&#8217;t resolve Docker service names, point the plugin at yarad by container IP, not name.</p>

<p>Then there&#8217;s scoring, which is where careers quietly end. Public YARA rules are written to catch malware across the entire internet, not against your specific mail, and some will false-positive on something perfectly legitimate that one of your users sends every Tuesday. The naive approach is one symbol, one weight, for any rule hit — but that treats &#8220;this is the Emotet trojan&#8221; and &#8220;this looks vaguely suspicious&#8221; as equally damning, which is nonsense. So the plugin doesn&#8217;t do that.</p>

<p>Instead it <strong>classifies</strong> each matched rule — from its name, source file and tags — into a tier, and each tier scores differently. A confirmed malware family hits hard; a broad heuristic barely nudges. The tiers all live in one rspamd group (called <code>YARA</code>) and stack, capped by the group&#8217;s <code>max_score</code>:</p>

<pre class="wp-block-code"><code>group "YARA" {
  max_score = 15;
  symbols {
    "YARA_MALWARE"        { weight = 8.0; }  # malware family / webshell / RAT / APT
    "YARA_EXPLOIT"        { weight = 7.0; }  # exploit / CVE / maldoc exploit
    "YARA_PHISHING"       { weight = 5.0; }  # phishing kit or document
    "YARA"                { weight = 4.0; }  # matched, but uncategorized
    "YARA_SUSPICIOUS"     { weight = 2.0; }  # heuristic / suspicious (FP-prone)
    "URLHAUS_MALWARE_URL" { weight = 8.0; }  # URL in the mail is a known malware link
  }
}</code></pre>

<p>So a real malware sample lands around 8 and a fuzzy heuristic adds a gentle 2, and they stack if both fire. The classifier is just a heuristic in the plugin, so retuning the buckets or the weights is a config edit and an rspamd reload — no rebuilding the scanner.</p>

<p>Whatever weights you pick, do the boring part first: run the tiers at weight 0.0 against live traffic, watch the rspamd history for a week or two, see what fires, and confirm the false positives are gone. Weight zero means the symbol still shows up in the history — you see exactly what&#8217;s matching, in which tier — and it adds nothing to the score. It blocks nothing. Detection before enforcement, every single time. It&#8217;s the same discipline as rolling out any rule engine: when I wrote up <a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">installing ModSecurity and the OWASP Core Rule Set on NGINX</a>, the entire first phase was &#8220;run it in detection-only mode and read the logs.&#8221;</p>


<h2 style="color:#f59e0b">Where this leaves you</h2>

<p>Three moving parts: a Go daemon that scans bytes against rules and caches the answers, a pile of public rules baked in and refreshed daily, and a Lua plugin that lets rspamd ask without blocking. None of it exotic — the same out-of-process pattern rspamd already uses for the collaborative networks, applied to a tool it forgot to include.</p>

<p>The code is open. <a href="https://github.com/eilandert/rspamd-yarad" target="_blank" rel="noopener">yarad lives on GitHub</a> — see its <a href="https://github.com/eilandert/rspamd-yarad#status--roadmap" target="_blank" rel="noopener">status &amp; roadmap</a> for what&#8217;s implemented and what&#8217;s next. Its collaborative-filtering sibling <a href="https://github.com/eilandert/gozer" target="_blank" rel="noopener">gozer</a> is right next door, and the <a href="https://github.com/eilandert/rspamd-dcc-razor-pyzor" target="_blank" rel="noopener">rspamd DCC/Razor/Pyzor backend</a> shows the same pattern in a fuller deployment. Take them, break them, send patches.</p>


<h2 style="color:#f59e0b">Frequently asked questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Does rspamd have a built-in YARA module?</h3>
<div class="rank-math-answer ">

<p>No. As of rspamd 4.1 there is no native YARA module, no libyara linkage in the binary, and no YARA plugin. The feature has been an open request upstream since 2021. To scan mail with YARA you either route it through ClamAV&#8217;s YARA support or run a separate scanner like yarad that rspamd queries over HTTP.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I just use ClamAV for YARA rules instead?</h3>
<div class="rank-math-answer ">

<p>You can. Clamd loads .yar files alongside its own signatures and rspamd already talks to ClamAV through the antivirus module. The downsides: your rules are tied to clamd&#8217;s reload cycle, you cannot score individual rule hits because everything returns as one generic virus verdict, and figuring out which rule fired means digging through clamd logs. It works for a quick win but you lose per-rule visibility and scoring.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Will YARA scanning slow down my mail server?</h3>
<div class="rank-math-answer ">

<p>Only if you do it naively. YARA scanning is CPU work, but mail is highly duplicated (bulk campaigns, multi-recipient messages, MTA retries), so a verdict cache keyed by message hash turns most scans into a microsecond lookup. A singleflight mechanism collapses a burst of identical messages into a single scan. Run the scanner out of process so it never blocks the rspamd event loop, and the overhead stays small.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Which YARA rulesets should I use for email?</h3>
<div class="rank-math-answer ">

<p>Start with curated public sources rather than writing your own. YARA-Forge ingests and deduplicates dozens of public repositories into a single vetted bundle in quality tiers. Neo23x0&#8217;s signature-base is a long-running, widely-used collection, and ANY.RUN publishes actively maintained malware-family and phishing rules. Together they give you around ten thousand compiled rules. Pin the versions and refresh on a schedule so you know exactly what was live when a rule fired.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Is it safe to run YARA rules you downloaded from the internet?</h3>
<div class="rank-math-answer ">

<p>Reasonably. YARA is a matching engine, not an interpreter, so a rule cannot execute code on your server. The realistic risk is a noisy rule that false-positives on legitimate mail, not one that compromises the host. Treat rule sources like any dependency: know the maintainer, pin the version, and run new rules in log-only mode (weight 0) before letting them affect scoring.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">What happens to my mail if the YARA scanner crashes?</h3>
<div class="rank-math-answer ">

<p>With a sane design, nothing bad. yarad fails open: any scan error, timeout, panic or backend outage is reported as &#8216;no match&#8217; and logged, never as an error that blocks the message. Spam filtering is allowed to miss the occasional sample; it is never allowed to drop legitimate mail. A scanner that blocks email because it fell over is far worse than one that quietly lets a sample through.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">Can YARA detect malicious Office macros in email attachments?</h3>
<div class="rank-math-answer ">

<p>Yes, but only if you unpack the document first. A .docm or .xlsm is a ZIP holding an OLE2 container, and the VBA macro code inside it is MS-OVBA-compressed, so keyword rules scanning the raw bytes never match. yarad decompresses the macro back to source before scanning, then runs the macro-keyword rules (AutoOpen, Shell and friends) on the cleartext. With that step it covers roughly 80% of what Python&#8217;s oletools does for mail, in-process and with no Python: VBA extraction and decompression, suspicious-keyword and auto-run detection, encryption and macro indicators, and RTF exploit matching.</p>

</div>
</div>
<div id="rm-faq-8" class="rank-math-list-item">
<h3 class="rank-math-question ">How does yarad compare to oletools or olevba?</h3>
<div class="rank-math-answer ">

<p>oletools (olevba, mraptor, oleid, rtfobj) is the reference Python toolkit for analysing malicious documents. yarad replicates about 80% of its mail-relevant work directly in Go: it decompresses VBA macros, detects suspicious keywords and auto-execution, identifies encrypted or macro-bearing documents, catches RTF exploits, and additionally reputation-checks extracted URLs against the URLhaus malware feed. The remaining 20% is the deep tail oletools still owns: actually decoding Base64/hex/StrReverse obfuscation chains, emulating Excel 4.0 XLM macros, and carving embedded objects from malformed RTF. For that, a parallel oletools-based scanner (olefy) still runs as a second opinion.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related reading</h2>

<ul>
<li><a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd explained: how modern spam filtering actually works</a>. Bayes, neural nets, RBLs, and the collaborative networks YARA sits alongside.</li>
<li><a href="https://deb.myguard.nl/2026/06/olefy-rspamd-office-macro-scanning/">Olefy and rspamd: scan Office macro malware in your mail</a>. The same out-of-process pattern, pointed at VBA macros in attachments.</li>
<li><a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to install ModSecurity and OWASP CRS on NGINX</a>. The same detection-before-enforcement discipline, applied to a web application firewall.</li>
<li><a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker hardening for self-hosters</a>. Rootless, read-only, cap-drop and distroless, which is how yarad ships.</li>
<li><a href="https://deb.myguard.nl/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/">Valkey explained: the Redis fork that actually won</a>. The shared cache backend yarad uses across replicas.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>DCC, Razor and Pyzor for Rspamd: One Docker Backend</title>
		<link>https://deb.myguard.nl/2026/06/rspamd-dcc-razor-pyzor-docker-backend/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sun, 14 Jun 2026 19:56:10 +0000</pubDate>
				<category><![CDATA[Antispam]]></category>
		<category><![CDATA[Mail]]></category>
		<category><![CDATA[dcc]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[dovecot]]></category>
		<category><![CDATA[mail]]></category>
		<category><![CDATA[pyzor]]></category>
		<category><![CDATA[razor]]></category>
		<category><![CDATA[rspamd]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[selfhosted]]></category>
		<category><![CDATA[spam-filtering]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6423</guid>

					<description><![CDATA[Run DCC, Razor and Pyzor for rspamd from one token-authed Docker backend that never blocks the scanner and never writes your mail to disk. Here is how the shim works and why it looks the way it does.]]></description>
										<content:encoded><![CDATA[<p>The spam world&#8217;s three best crowd-sourced filters &mdash; DCC, Razor and Pyzor &mdash; all ship as command-line tools written in C, Perl and Python, and every one of them <em>blocks</em> while it talks to a server in another country. A single <code>razor-check</code> costs you about 367&nbsp;milliseconds of dead air. rspamd, meanwhile, is asynchronous to its bones. Shell out to a blocking CLI from inside its single-threaded worker and you&#8217;ve bolted a ball and chain to your fast scanner: one <code>razor-check</code>, and the thousand messages queued behind it all wait for the privilege.</p>

<p>So we did the thing that actually fixes it: <strong>we rewrote all three clients from scratch in Go</strong> and linked them into one static binary. No <code>fork()</code>, no interpreter start per message, no Perl or Python or set-UID C binary anywhere near your mail. Just Go code that speaks each wire protocol byte-for-byte identically to the original, answers rspamd over a single non-blocking HTTP call, and ships as a <strong>~6&nbsp;MB distroless image instead of a 268&nbsp;MB one</strong>. This is the story of that port, and why it looks the way it does.</p>

<p>The thing is <a href="https://github.com/eilandert/rspamd-dcc-razor-pyzor" rel="noopener" target="_blank">rspamd-dcc-razor-pyzor</a>: a standalone Docker backend that runs all three networks in-process in one Go binary &mdash; <strong>gozer</strong> &mdash; and answers rspamd over one HTTP endpoint, plus the Lua plugin that talks to it. The image runs no rspamd of its own. Yours stays exactly where it is.</p>

<h2 style="color:#f59e0b">Why rspamd needs a sidecar for Razor and Pyzor</h2>

<p>rspamd has a built-in DCC module. Good. It has nothing for Razor, and nothing for Pyzor. That&#8217;s the gap, and the obvious way to close it is a trap: write a tiny Lua rule that shells out to <code>razor-check</code>, watch it work in testing, ship it. Then traffic ramps and rspamd&#8217;s worker &mdash; the one event loop doing all the scanning &mdash; parks itself on a blocking <code>read()</code> waiting for a Razor server abroad to answer. While it waits it does nothing else. Not your message, not anyone else&#8217;s.</p>

<p>rspamd&#8217;s whole design is the opposite of that: it fires DNS, RBL and module checks concurrently and stitches the answers back as they land. One synchronous shell-out punches a hole straight through that model. The fix is old and boring &mdash; don&#8217;t do slow work in the hot path. Move the clients out of the worker, put an HTTP boundary in front of them, and let the plugin make a normal non-blocking request like every other rspamd module. One request out, three networks queried behind it, and the event loop never stops turning. rspamd logs it as a &ldquo;slow asynchronous rule,&rdquo; which sounds alarming and is actually the system telling you it&#8217;s working: slow, yes, but <em>asynchronous</em>, so nothing waits on it.</p>

<h2 style="color:#f59e0b">DCC, Razor and Pyzor, and what each one actually knows</h2>

<p>If you&#8217;ve never met these three, here&#8217;s the short version. They&#8217;re collaborative-filtering networks, and they all answer a different flavour of the same question: &ldquo;have a lot of other people seen this exact message too?&rdquo; Spam is bulk by nature &mdash; the thing that makes it profitable, blasting one message to ten million inboxes, is also the thing that gives it away.</p>

<p><strong>DCC</strong> (Distributed Checksum Clearinghouse) counts. It hashes the fuzzy &ldquo;bulk&rdquo; body and asks the network how many times that checksum has been reported. A personal email scores one; a newsletter to a million people scores in the millions. DCC doesn&#8217;t say &ldquo;spam,&rdquo; it says &ldquo;bulk&rdquo; &mdash; and it&#8217;s the fastest of the three, a single lean UDP round-trip.</p>

<p><strong>Razor</strong> (Vipul&#8217;s Razor) is signatures. It computes a fuzzy fingerprint and checks it against a collaboratively maintained catalogue of known spam. It&#8217;s the slowest of the trio, because a check is a multi-step conversation &mdash; discovery, greeting, server state, then the lookup.</p>

<p><strong>Pyzor</strong> is the same idea in different clothes: a digest of the message against a public server that counts sightings and whitelist hits &mdash; and in practice the quickest to answer.</p>

<p>None of these replaces your Bayes classifier or your RBLs. They&#8217;re a separate layer of evidence, and they catch the one thing statistical filters are weakest on: brand-new spam that&#8217;s textually clean but going out in enormous volume. For the full map of where these sit in a modern stack, we wrote it up in <a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd Explained</a>. This post is about wiring three of those layers in without setting your scanner on fire.</p>

<h2 style="color:#f59e0b">One Go binary, three protocols, zero forks</h2>

<figure class="wp-block-image size-large"><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/rspamd-dcc-razor-pyzor-gozer-architecture.webp" alt="Architecture diagram: the rspamd dcc_razor_pyzor.lua plugin sends one non-blocking POST /check to the gozer backend, which queries DCC, Razor and Pyzor in-process via gdcc, gazor and gyzor" /><figcaption>The plugin makes one non-blocking request; <strong>gozer</strong> queries all three networks in parallel, in-process, and the raw message never touches disk.</figcaption></figure>

<p>The first version was a Python shim, <code>spamcheck_shim.py</code>, that forked the perl <code>razor-check</code>, the python <code>pyzor</code> and the C <code>dccproc</code> once per message. It worked, but it dragged a perl&nbsp;+&nbsp;python&nbsp;+&nbsp;dcc toolchain and an s6 supervisor into the image, and every check paid an interpreter start (plus a set-UID <code>dccproc</code>). So all three clients were rewritten from scratch in Go and linked straight into the backend:</p>

<ul>
<li><a href="https://github.com/eilandert/gdcc" target="_blank" rel="noopener"><strong>gdcc</strong></a> &mdash; a clean-room Go <a href="https://www.dcc-servers.net/dcc/" target="_blank" rel="noopener">DCC</a> client; computes the message checksums byte-for-byte identically to <code>dccproc</code>.</li>
<li><a href="https://github.com/eilandert/gazor" target="_blank" rel="noopener"><strong>gazor</strong></a> &mdash; a Go <a href="https://en.wikipedia.org/wiki/Vipul%27s_Razor" target="_blank" rel="noopener">Razor2</a> client; speaks the discovery/signature protocol of the perl <code>razor-agents</code>.</li>
<li><a href="https://github.com/eilandert/gyzor" target="_blank" rel="noopener"><strong>gyzor</strong></a> &mdash; a Go <a href="https://github.com/SpamExperts/pyzor" target="_blank" rel="noopener">Pyzor</a> client; reproduces the SHA1 digest of the python client.</li>
</ul>

<p>Each speaks its wire protocol byte-for-byte compatibly with the reference perl/python/C client, and each is gated by parity tests against the real razor, pyzor and <code>dccproc</code> in its own CI &mdash; so the servers see identical fingerprints and the switch is invisible on the wire. <a href="https://github.com/eilandert/gozer" target="_blank" rel="noopener"><strong>gozer</strong></a> imports all three (it lives in its own repo, pulled into the image build as a submodule), listens on <code>:8077</code> as a non-root user, and on each <code>/check</code> runs the three networks concurrently <em>in-process</em>: no subprocess, no fork, no stdin pipe. A single <code>/check</code> still costs you roughly the slowest backend, not the sum &mdash; Razor sets the pace; DCC and Pyzor finish in its shadow.</p>

<p>What disappeared is the entire scaffolding: no s6 supervisor, no shell, no Perl or Python runtime, no <code>dcc</code> package and no set-UID <code>dccproc</code>, no <code>dccifd</code> daemon (gozer&#8217;s DCC client talks to the servers directly). The image is <code>FROM</code> a <code>distroless/static</code> base with gozer as the entrypoint, and it dropped from about <strong>268&nbsp;MB to roughly 6&nbsp;MB</strong> &mdash; with no interpreter left in the message-parsing path, and nothing running as root over attacker-controlled bytes. That&#8217;s the real payoff of the Go port: not just speed, but an image with almost nothing left in it to attack.</p>

<p>The design rule we care most about is best-effort. Every backend can fail on its own without taking the others, or the container, down. If Razor&#8217;s servers fall off the internet at 3&nbsp;a.m., gozer shrugs, returns whatever DCC and Pyzor had, and the healthcheck stays green because it only depends on gozer&#8217;s own <code>/health</code> (probed by <code>gozer&nbsp;health</code>, since the distroless image ships no shell or curl). A dead network degrades your scoring; it doesn&#8217;t degrade your availability.</p>

<h2 style="color:#f59e0b">The message never touches disk</h2>

<p>This is the part we&#8217;d argue with you about. gozer keeps the message in memory and computes every checksum in-process. Nothing gets written to a temp file &mdash; not in <code>/tmp</code>, not in a tmpfs, not anywhere. Every spool file is a tiny liability: a window where the plaintext of someone&#8217;s email sits on a filesystem, waiting for a backup job to copy it, a log to mention it, or a forensic tool to find it after you thought it was gone. The cleanest way to never leak a temp file is to never write one.</p>

<p>The cache follows the same rule: it stores <code>sha256(body) &rarr; verdict</code> and nothing else. The hash, not the body. Point it at Redis instead of memory and the property holds &mdash; the shared cache stores hashes too, never content. And no, a tmpfs overlay would do nothing for speed here: there is no per-message disk write to accelerate, because there isn&#8217;t one. The latency is network round-trips to DCC, Razor and Pyzor, full stop.</p>

<p>The only thing that ever leaves the container is what collaborative filtering fundamentally needs: content fingerprints &mdash; DCC checksums, Razor signatures, Pyzor digests. Not the message. The networks get a hash of what your mail looks like, never the mail itself (and on a spam report, a submission, which is the entire point of reporting).</p>

<h2 style="color:#f59e0b">Hardening, because the defaults will get you fired</h2>

<p>Every default in container-land is tuned for &ldquo;does the demo work,&rdquo; not &ldquo;will this survive a hostile internet.&rdquo; So the bundled compose ships the opposite. gozer runs non-root with bounded concurrency (<code>GOZER_MAX_CONCURRENT</code> defaults to 8), and every POST is token-authenticated: send the secret as <code>Authorization: Bearer &lt;token&gt;</code> or <code>X-DRP-Token: &lt;token&gt;</code>, get a <code>401</code> if it&#8217;s wrong. The detail we&#8217;re proud of: if you forget to set a token at all, gozer doesn&#8217;t fail open &mdash; it returns <code>503</code> to every POST and refuses to do anything. A spam backend that accepts unauthenticated &ldquo;report this as spam&rdquo; calls from anywhere on your network is a poisoning vector with a bow on it.</p>

<p>The compose file also runs the container read-only, with <code>cap_drop: ALL</code>, <code>no-new-privileges</code>, and <strong>no published host port</strong>. Read that last one twice: the backend is reachable only by containers on the same Docker network, by service name. It is not one <code>iptables</code> mistake away from the public internet. If you&#8217;ve ever found a Redis open to the world because someone published a port &ldquo;just to test,&rdquo; you know why that matters &mdash; the test port is forever. None of this is exotic; it&#8217;s the baseline every container that touches untrusted input should run with, and turning the safety on costs you four lines of YAML.</p>

<h2 style="color:#f59e0b">Performance and the cache that earns its keep</h2>

<p>Numbers, measured from the build host against the public servers (anonymous; your mileage varies with distance): Pyzor about 50&nbsp;ms, DCC about 170&nbsp;ms, Razor about one second &mdash; Razor&#8217;s multi-step discovery handshake dominates. gozer queries all three concurrently, in-process, so a cold <code>/check</code> costs you roughly the Razor figure, and as an asynchronous rule that latency never blocks the scanner.</p>

<p>Here&#8217;s where it gets good, precisely because spam is bulk: the same campaign hits your server over and over, same body, a thousand recipients in an hour. gozer caches verdicts keyed on <code>sha256(body)</code>, with a 300-second TTL (<code>GOZER_CACHE_TTL</code>) and a 4096-entry LRU (<code>GOZER_CACHE_SIZE</code>) by default. The first copy of a bulk body pays the full cold cost; every copy after it, within the TTL, comes back in <strong>well under a millisecond</strong> &mdash; the in-process cache hit is about 55&nbsp;nanoseconds with zero allocations, flagged with <code>X-DRP-Cache: hit</code> in the response. No fork, no CLI, no round-trip, no concurrency slot consumed.</p>

<p>That&#8217;s the whole game on bulk traffic. In a synthetic end-to-end benchmark driving the full request path (auth, the concurrency gate, the cache, single-flight de-duplication and dispatch), gozer sustains roughly <strong>30,000 checks per second</strong> at a 90% cache-hit ratio &mdash; where the backends see only about one message in nine &mdash; versus about 12,000 per second when every check misses. The property that makes the spammer&#8217;s life cheap makes your scanner&#8217;s life cheap too. Running more than one scanner? Point <code>GOZER_REDIS_URL</code> at a shared Redis or Valkey and every scanner shares one cache; the first box to see a campaign warms it for all of them. <code>/report</code> and <code>/revoke</code> are never cached, because reporting is a side effect you don&#8217;t want to silently swallow.</p>

<h2 style="color:#f59e0b">Wiring it up: backend, plugin, and Dovecot feedback</h2>

<p>Three pieces. The backend container, the rspamd plugin, and the optional Dovecot reporting glue. Take them in order.</p>

<h3>The backend</h3>

<p>gozer refuses every POST until it has a token, and it isn&#8217;t published to the host, so the only sane way to run it is with the bundled compose:</p>

<pre><code>cd docker
mkdir -p secrets &amp;&amp; openssl rand -hex 32 &gt; secrets/drp_token.txt
docker compose up -d</code></pre>

<p>Containers on the same Docker network now reach it at <code>http://rspamd-drp:8077</code>. The image is on Docker Hub as <a href="https://hub.docker.com/r/eilandert/rspamd-dcc-razor-pyzor" rel="noopener" target="_blank">eilandert/rspamd-dcc-razor-pyzor</a> if you&#8217;d rather pull than build. Test it by POSTing a raw message (<code>--data-binary</code> keeps the bytes intact, since the fingerprints are computed over them):</p>

<pre><code>TOKEN=$(cat docker/secrets/drp_token.txt)

# scan a message
curl -s --data-binary @message.eml \
  -H "Authorization: Bearer $TOKEN" http://rspamd-drp:8077/check
# {"dcc":{"action":"unknown","bulk":null},"razor":{"hit":false},"pyzor":{"count":42,"wl":0}}

# user feedback &mdash; X-DRP-Token works in place of the Bearer header
curl -s --data-binary @spam.eml -H "X-DRP-Token: $TOKEN" http://rspamd-drp:8077/report
curl -s --data-binary @ham.eml  -H "X-DRP-Token: $TOKEN" http://rspamd-drp:8077/revoke</code></pre>

<h3>The plugin</h3>

<p>The Lua plugin is not baked into the backend image, on purpose: it belongs in <em>your</em> rspamd, not in this one. Drop it in and tell it where the backend lives:</p>

<pre><code>cp rspamd/plugins/dcc_razor_pyzor.lua  /etc/rspamd/plugins/
cp rspamd/local.d/dcc_razor_pyzor.conf /etc/rspamd/local.d/
cp rspamd/local.d/groups.conf          /etc/rspamd/local.d/
echo 'dofile("/etc/rspamd/plugins/dcc_razor_pyzor.lua")' &gt;&gt; /etc/rspamd/rspamd.local.lua</code></pre>

<p>Then set the backend URL and the <strong>same token</strong> in <code>local.d/dcc_razor_pyzor.conf</code>:</p>

<pre><code>url   = "http://rspamd-drp:8077/check";
token = "the-shared-secret";</code></pre>

<p>Restart rspamd and you get three new symbols, scored in <code>groups.conf</code>: <code>DRP_DCC_BULK</code> when DCC says the body is bulk, <code>DRP_RAZOR</code> on a Razor signature match, and <code>DRP_PYZOR</code> when Pyzor sightings clear the threshold.</p>

<p>One gotcha that will eat your afternoon: rspamd resolves URLs through its <em>own</em> configured resolver, not the system one. If you&#8217;ve pointed it at an RBL-only unbound that can&#8217;t see Docker service names, <code>rspamd-drp</code> won&#8217;t resolve and you&#8217;ll get a confusing silence rather than a clean error. The fix is to put the backend&#8217;s IP in <code>url</code> instead of the service name. It&#8217;s always DNS. It&#8217;s genuinely always DNS.</p>

<h3>Dovecot feedback</h3>

<p><code>/check</code> is for scanning. <code>/report</code> and <code>/revoke</code> are for human feedback: someone drags a message into Junk (spam, report it) or rescues one back out (ham, revoke it). Real human corrections are the best signal you&#8217;ll ever get. Sieve can&#8217;t speak HTTP, so a little wrapper called <code>drp-report</code> bridges the gap, triggered by imapsieve; the <a href="https://github.com/eilandert/dockerized" rel="noopener" target="_blank">eilandert/dovecot</a> image already bakes it in. On any other Dovecot host you copy three files, compile two sieve scripts, and drop the URL and token into an env file (sieve scrubs the environment, so it can&#8217;t come from a shell variable). Move into Junk fires <code>POST /report</code>; move out fires <code>POST /revoke</code>. And <code>drp-report</code> always exits 0, on purpose, so a reporting hiccup never bounces a message or blocks the IMAP move &mdash; the worst case is one un-reported spam, not a stuck mailbox.</p>

<h2 style="color:#f59e0b">Where this fits with the rest of the spam stack</h2>

<p>Collaborative filtering is one layer, not the layer. It answers &ldquo;is this bulk&rdquo; and &ldquo;has the crowd flagged this,&rdquo; and it&#8217;s brilliant at catching high-volume campaigns the instant they start &mdash; and nearly useless against a targeted message sent to you and only you, because there&#8217;s no crowd to have seen it yet. That&#8217;s why it pairs so well with rule-based scoring, which catches the textual tells a single message gives off regardless of volume. The other half of our setup is <a href="https://github.com/eilandert/rspamd-kam-rules" rel="noopener" target="_blank">rspamd-kam-rules</a>, a native-Lua converter that brings 3,200-odd SpamAssassin KAM.cf rules into rspamd without the Perl <code>spamassassin</code> module; we wrote that up in <a href="https://deb.myguard.nl/2026/06/kam-cf-rspamd-lua-converter/">KAM.cf in Rspamd</a>. Rules catch the content signature, DCC/Razor/Pyzor catch the volume, Bayes catches the statistical drift, and between them very little gets through.</p>

<p>Everything&#8217;s open: <a href="https://github.com/eilandert/gozer" rel="noopener" target="_blank">gozer</a> and the <a href="https://github.com/eilandert/rspamd-dcc-razor-pyzor" rel="noopener" target="_blank">deployment repo</a> on GitHub, the <a href="https://github.com/eilandert/gdcc" target="_blank" rel="noopener">gdcc</a>&nbsp;/&nbsp;<a href="https://github.com/eilandert/gazor" target="_blank" rel="noopener">gazor</a>&nbsp;/&nbsp;<a href="https://github.com/eilandert/gyzor" target="_blank" rel="noopener">gyzor</a> Go clients each in their own repo, the <a href="https://hub.docker.com/r/eilandert/rspamd-dcc-razor-pyzor" rel="noopener" target="_blank">image on Docker Hub</a>, and the lot tracked in the <a href="https://github.com/eilandert/dockerized" rel="noopener" target="_blank">dockerized monorepo</a>. The full list of repos and images lives on the <a href="https://deb.myguard.nl/where-to-find-us/">where to find us</a> page.</p>

<h2 style="color:#f59e0b">Frequently asked questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I need to run rspamd inside this container?</h3>
<div class="rank-math-answer ">

<p>No, and that&#8217;s the whole point. The image runs no rspamd of its own. Your rspamd stays in its own container or on its own host; you drop the shipped Lua plugin into it and point the plugin&#8217;s <code>url</code> at this backend over HTTP. The backend is a sidecar that does one job, scoring mail against DCC, Razor and Pyzor, and answers your existing scanner over port 8077.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Why rewrite DCC, Razor and Pyzor in Go instead of calling the CLIs?</h3>
<div class="rank-math-answer ">

<p>The reference clients are C, Perl and Python tools that block while they talk to a remote server, and shelling out to them from rspamd&#8217;s single-threaded worker stalls the whole event loop. Rewriting them in Go (gdcc, gazor, gyzor) lets gozer query all three in-process, with no fork and no interpreter start per message, and collapses the image from about 268 MB of perl/python/dcc/s6 to a roughly 6 MB distroless static binary. Each Go client is gated by parity tests against the real client, so the servers see identical fingerprints.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Will this slow down my mail scanning?</h3>
<div class="rank-math-answer ">

<p>Not in a way that blocks anything. The plugin makes a non-blocking asynchronous request, so the rspamd event loop never waits on it. A cold check costs roughly the slowest backend (Razor, about a second) because all three run concurrently in-process, but that latency runs alongside the rest of the scan. And because bulk mail repeats, the verdict cache turns most checks into an about 0.4 ms lookup with an <code>X-DRP-Cache: hit</code> header.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Is my email content sent to these networks or written to disk?</h3>
<div class="rank-math-answer ">

<p>Neither. The raw message never leaves the container and never touches disk. gozer holds the message in memory and computes every checksum in-process (Go, no subprocess), so there is no temp file. The only data that leaves are content fingerprints: DCC checksums, Razor signatures and Pyzor digests, which is exactly what collaborative filtering needs. The cache stores only a sha256 hash of the body, never the body, whether in memory or Redis.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">What happens if DCC, Razor or Pyzor is down?</h3>
<div class="rank-math-answer ">

<p>Each backend is best-effort and independent. If one network is unreachable it simply doesn&#8217;t contribute a score, and the other two still answer. The container&#8217;s healthcheck depends only on gozer&#8217;s own /health endpoint, so a dead upstream degrades your scoring without affecting availability. Your mail keeps flowing; you just lose one source of evidence until the network comes back.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">How do I report spam and ham back from my users?</h3>
<div class="rank-math-answer ">

<p>Use the /report and /revoke endpoints, wired through Dovecot with imapsieve. When a user moves a message into Junk, a sieve script calls POST /report to flag it across all three networks; moving it back out calls POST /revoke to mark it ham (Razor and Pyzor support un-reporting; DCC does not). The eilandert/dovecot image bakes the glue in, and the drp-report wrapper always exits 0 so a failed report never blocks a mailbox move.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">Can multiple rspamd instances share one backend?</h3>
<div class="rank-math-answer ">

<p>Yes. Point each scanner&#8217;s plugin at the same backend URL, and set <code>GOZER_REDIS_URL</code> on the backend to a shared Redis or Valkey so all of them share one verdict cache. The first instance to see a campaign warms the cache for the rest, which matters a lot when you&#8217;re scanning the same bulk run across several nodes.</p>

</div>
</div>
</div>
</div>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Coraza WAF on NGINX: The Go-Powered ModSecurity Replacement</title>
		<link>https://deb.myguard.nl/2026/06/coraza-waf-nginx-modsecurity-replacement/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sun, 14 Jun 2026 00:16:00 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[angie]]></category>
		<category><![CDATA[coraza]]></category>
		<category><![CDATA[crs]]></category>
		<category><![CDATA[golang]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[modsecurity]]></category>
		<category><![CDATA[owasp]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[waf]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6395</guid>

					<description><![CDATA[Coraza is the memory-safe, Go-written WAF that speaks ModSecurity's language and runs the OWASP CRS unchanged. Here is what libcoraza and the nginx-coraza module are, why we package them, and the fork-deadlock gotcha nobody warns you about.]]></description>
										<content:encoded><![CDATA[<p>In July 2024, Trustwave handed ModSecurity to OWASP and called time on the engine that had guarded web apps since 2002. The reference WAF of the entire industry, the thing the <a href="https://coreruleset.org/" rel="noopener" target="_blank">OWASP Core Rule Set</a> was written against, went into maintenance-only mode written in aging C++.</p>

<p><a href="https://coraza.io/" rel="noopener" target="_blank">Coraza WAF</a> is the answer to the obvious next question: what runs the CRS when ModSecurity finally stops? It&#8217;s a clean-room rewrite of the whole engine in Go, it reads your existing <code>SecRule</code> files without translation, and we package it for Debian and Ubuntu as a pair of <code>.deb</code>s plus an NGINX dynamic module. This is the tour: what Coraza WAF is, why a Go firewall needs a C library bolted to its side, and when to pick it over the engine it replaces.</p>

<h2 style="color:#f59e0b">What Coraza WAF actually is</h2>

<p>A web application firewall sits in front of your app and reads every request before your code does. SQL injection in a query string, a path-traversal <code>../../etc/passwd</code> in a URL, a known exploit payload aimed at some WordPress plugin: the WAF matches it against a ruleset and blocks, logs, or scores it before your PHP ever wakes up. ModSecurity invented this pattern for the open web. The OWASP Core Rule Set (CRS) is the big free ruleset everyone uses on top of it.</p>

<p>Coraza is a from-scratch reimplementation of that engine in Go. Not a wrapper, not a port: a new codebase that happens to speak the same dialect. It implements the SecLang rule language, the same one you&#8217;ve been writing as <code>SecRule REQUEST_URI "@rx evil" "id:1,phase:1,deny"</code> for twenty years. It runs the OWASP CRS unmodified. If you already have a ModSecurity config, most of it drops straight in.</p>

<p>The reason it exists in Go and not C++ is the reason half of you already guessed. Memory safety. ModSecurity is a C++ codebase parsing hostile, attacker-controlled input on the hottest path in your stack, which is exactly the place you least want a buffer overflow.</p>

<p>A WAF with a memory-corruption bug is worse than no WAF: now the thing meant to stop the attacker is the attacker&#8217;s way in. Go won&#8217;t hand you a use-after-free in the request parser. That single property is most of the pitch.</p>

<p>Here&#8217;s the catch, and it&#8217;s the whole reason this article isn&#8217;t two sentences long. Go is a garbage-collected language with its own runtime and its own scheduler. NGINX is C. You cannot simply call Go from C the way you call a normal library. Bridging those two worlds is what the C library in the next section exists to do.</p>

<h2 style="color:#f59e0b">libcoraza: why a Go firewall needs a C library</h2>

<p>NGINX speaks C. The Coraza engine is Go. Something has to translate, and that something is <strong>libcoraza</strong>: a thin C-bindings layer that compiles the Go engine into a C-callable shared object using cgo and Go&#8217;s <code>c-shared</code> build mode. The output is a real <code>libcoraza.so</code> with a header full of honest C functions: <code>coraza_new_transaction</code>, <code>coraza_process_request_headers</code>, <code>coraza_process_response_body</code>, and the rest. Your C code calls those. Inside, the Go runtime does the actual work.</p>

<p>We package libcoraza on its own, exactly like we package <code>libmodsecurity3</code> for the old engine. Two binary packages come out of it:</p>

<ul>
<li><code>libcoraza1</code>: the runtime shared library, <code>libcoraza.so.1</code>, what your server actually loads.</li>
<li><code>libcoraza-dev</code>: the header (<code>coraza.h</code>) and the unversioned <code>libcoraza.so</code> symlink, needed only at build time to compile something against it.</li>
</ul>

<p>Building it is its own small adventure. The library needs the Go toolchain (currently Go 1.25), autotools, libtool, and a C compiler, because it&#8217;s a cgo build wrapped in an autoconf shell. That Go version requirement matters more than it looks, and I&#8217;ll come back to it when we talk about which distros get the module at all.</p>

<p>libcoraza also ships a SWIG interface file, which means the same C-callable engine can generate bindings for Python, Ruby, Java, PHP, and Perl. We don&#8217;t ship those. But it&#8217;s a nice reminder that this isn&#8217;t an NGINX-only project. libcoraza is the front door for embedding Coraza in any C-speaking application, and NGINX is just our first guest.</p>

<h2 style="color:#f59e0b">The nginx-coraza connector and its four directives</h2>

<p>On top of libcoraza sits the actual NGINX module, <code>ngx_http_coraza_module</code>, built from the <code>coraza-nginx</code> connector. It&#8217;s a fork of the old ModSecurity-nginx connector, which is why the wiring feels familiar if you&#8217;ve ever run ModSec on NGINX. The module is the plumbing between NGINX&#8217;s request lifecycle and libcoraza&#8217;s transaction API: it hands each request&#8217;s headers, body, and response over to the engine at the right phase and acts on the verdict.</p>

<p>It adds four config directives, and that&#8217;s the whole surface area:</p>

<ul>
<li><code>coraza on|off;</code>: the master switch. Works in <code>http</code>, <code>server</code>, and <code>location</code> blocks, defaults to off. Turn it on where you want inspection, leave it off for your static asset locations so you&#8217;re not running the CRS against every favicon request.</li>
<li><code>coraza_rules_file /path/to/rules.conf;</code>: point it at a rules file. This is where you load the CRS.</li>
<li><code>coraza_rules 'SecRuleEngine On ...';</code>: inline rules straight in the NGINX config, handy for one or two per-location tweaks without a whole file.</li>
<li><code>coraza_transaction_id $some_var;</code>: feed it NGINX&#8217;s own request ID so your WAF logs and your access logs share a key. When you&#8217;re correlating &#8220;what did the WAF do to request X&#8221; against your access log at 3 a.m., this is the line that saves you.</li>
</ul>

<p>A minimal server looks like this:</p>

<pre><code>server {
    coraza on;
    coraza_rules_file /etc/nginx/coraza/coraza.conf;

    location / {
        root /var/www/html;
    }
}</code></pre>

<p>The inheritance is the bit worth memorising. Rules merge parent-then-child: a <code>coraza_rules_file</code> in the <code>server</code> block applies everywhere, and a <code>location</code> block with its own rules gets the parent&#8217;s rules <em>prepended</em> to its own. A location with no rules of its own just shares the parent set. So you set the CRS once at the server level and only add per-location overrides where a specific app genuinely needs them. Predictable, top-down, no surprises. The way config inheritance should work and frequently doesn&#8217;t.</p>

<h2 style="color:#f59e0b">Coraza WAF vs ModSecurity: which one do you actually want</h2>

<p>We package both. <code>libmodsecurity3</code> with its NGINX connector, and Coraza with this one. They run the same OWASP CRS. So which?</p>

<p>ModSecurity v3 (libmodsecurity) is the battle-tested incumbent. It has been in production on more sites than anyone can count, the CRS is tuned against it first, and every Stack Overflow answer about a weird false positive assumes it. It&#8217;s C++, which means it&#8217;s fast and it means a parser bug is a memory-safety bug. It&#8217;s also in maintenance mode now that ModSecurity proper has been handed off, so the long arc bends away from it.</p>

<p>Coraza is the future-facing pick. Memory-safe by construction, actively developed, designed from day one to be embeddable beyond NGINX. The tradeoff is the cgo/Go-runtime bridge: a slightly heavier per-worker footprint because every worker carries its own copy of the Go runtime. For most sites that cost is invisible. If you&#8217;re running a thousand workers on a memory-starved box, measure it.</p>

<p>Here&#8217;s my actual opinion, and you can disagree. For a new deployment in 2026, start with Coraza. The memory-safety argument on the single most attacker-exposed component in your stack is the one that wins, and &#8220;the CRS is tuned against ModSec first&#8221; matters far less than it used to because the CRS team treats Coraza as a first-class target now. Keep ModSecurity if you have years of finely-tuned exclusions you don&#8217;t want to revalidate. That tuning is real work and throwing it away to chase a newer engine is its own kind of foolish.</p>

<p>Either way, the WAF is one layer. It is not your security plan. A WAF in front of an unpatched app buys you time, not safety, and anyone who tells you otherwise is selling something.</p>

<h2 style="color:#f59e0b">How we package it, and why only some distros get it</h2>

<p>Our build produces a clean dependency chain. <code>libcoraza</code> builds first and publishes to the apt repo: <code>libcoraza1</code> and <code>libcoraza-dev</code>. Then NGINX builds, with <code>libcoraza-dev</code> as a build dependency (it needs <code>coraza.h</code> to compile the connector) and <code>libcoraza1</code> as the runtime dependency of the resulting <code>libnginx-mod-http-coraza</code> package. Order matters: if libcoraza isn&#8217;t in the repo yet, the NGINX build aborts at dependency resolution before it even starts. Build the library, publish it, then build NGINX. The same applies to our Angie packages, which carry the module as <code>angie-module-http-coraza</code>.</p>

<p>There&#8217;s a deliberate limit on which distributions get the module: trixie and resolute only. No bullseye, no bookworm, no jammy, no noble. The reason is that Go 1.25 requirement from earlier. libcoraza needs <code>golang-1.25</code> to build, and only Debian trixie (with backports, which is what resolute tracks) ships it. Older releases simply don&#8217;t have a new enough Go, so the library can&#8217;t be built there, so the module can&#8217;t exist there. We gate it with Debian build profiles rather than architecture restrictions, because this is a &#8220;which release&#8221; decision, not a &#8220;which CPU&#8221; one. On amd64 and arm64 alike, trixie and resolute get Coraza; everything older gets the ModSecurity package and a shrug.</p>

<h2 style="color:#f59e0b">Turning it on with the OWASP CRS</h2>

<p>A real config loads the recommended Coraza base config and then the CRS on top. The base config sets the engine on and configures body inspection limits; the CRS brings the actual attack rules. Roughly:</p>

<pre><code>http {
    # one master switch, inherited by every server below
    coraza on;
    coraza_rules_file /etc/nginx/coraza/coraza.conf;
    coraza_rules_file /etc/nginx/coraza/coreruleset/crs-setup.conf;
    coraza_rules_file /etc/nginx/coraza/coreruleset/rules/*.conf;

    server {
        listen 443 ssl;
        server_name example.com;

        location /assets/ {
            coraza off;   # don't run the CRS against static files
        }
    }
}</code></pre>

<p>Start in <strong>detection-only</strong> mode. The CRS ships with <code>SecRuleEngine DetectionOnly</code> for a reason: turn it loose in blocking mode on a real app on day one and you will block your own admins, your own API, and probably your own healthcheck inside the hour. Run it in detection mode, watch the logs for a week, build your exclusions for the false positives, <em>then</em> flip to <code>SecRuleEngine On</code>. The trailing slash on those location paths matters; yes, it always does; no, nobody is happy about it.</p>

<p>When something does get blocked and you need to know which rule did it, this is where that <code>coraza_transaction_id</code> directive earns its keep. libcoraza 1.6 added the ability to surface the matched rule&#8217;s ID through the API, so your logs can tell you &#8220;rule 942100 fired&#8221; instead of &#8220;something somewhere said no&#8221;. Wire the transaction ID to NGINX&#8217;s <code>$request_id</code> and you can pivot from an access-log line straight to the WAF decision for that exact request. Future you, mid-incident, will be grateful.</p>

<p>If you&#8217;ve set up ModSecurity and the CRS on NGINX before, none of this will feel foreign, and our <a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">step-by-step ModSecurity and OWASP CRS guide</a> maps almost directly onto Coraza. Swap the engine, keep the rules.</p>

<h2 style="color:#f59e0b">Migrating from ModSecurity to Coraza, step by step</h2>

<p>So you already run ModSecurity v3 with the CRS on NGINX, and you want to move to Coraza without taking your site down or throwing away years of tuning. Good news: because both engines speak SecLang and run the same OWASP CRS, this is a config-swap, not a rewrite. The rules come with you. Only the wiring changes. Here is the order I&#8217;d do it in, the boring careful way, because the exciting careless way ends with you blocking your own login page at 2 a.m.</p>

<h3>Step 0 — Inventory what you already have</h3>

<p>Before you touch anything, write down what ModSecurity is currently loading. The three things that matter: your base config (the <code>modsecurity.conf</code> with <code>SecRuleEngine</code>, body limits, audit log settings), your CRS install (which version, the <code>crs-setup.conf</code>, the <code>rules/*.conf</code>), and — the valuable part — your <strong>exclusions</strong>. Those are the <code>SecRuleRemoveById</code>, <code>ctl:ruleRemoveTargetById</code>, and custom allow rules you bled for over months of false positives. That tuning is the whole reason you&#8217;re nervous about migrating. It&#8217;s also the part that ports across completely unchanged.</p>

<pre><code># find what your current modsec setup pulls in
grep -rhE 'Include|modsecurity_rules_file' /etc/nginx/ /etc/modsecurity/ 2>/dev/null
ls -la /etc/modsecurity/ /etc/nginx/modsec*/ 2>/dev/null</code></pre>

<h3>Step 1 — Install the Coraza packages alongside, not instead</h3>

<p>You do not remove ModSecurity yet. Both modules can be installed at once; you just don&#8217;t load both for the same traffic. On trixie or resolute:</p>

<pre><code>apt update
apt install libnginx-mod-http-coraza   # pulls in libcoraza1 automatically
# Angie users:
apt install angie-module-http-coraza</code></pre>

<p>If apt can&#8217;t find it, you&#8217;re on the wrong release — Coraza is trixie/resolute only, for the Go 1.25 reason covered above. Older boxes stay on ModSecurity; there is no migration target for them yet.</p>

<h3>Step 2 — Lay down the Coraza base config and bring your CRS over</h3>

<p>Coraza ships a recommended base config (<code>coraza.conf-recommended</code>) that mirrors ModSecurity&#8217;s <code>modsecurity.conf-recommended</code>. Copy it, then drop your existing CRS directory in next to it. You do not need to re-download the CRS — the exact files you feed ModSecurity work as-is:</p>

<pre><code>mkdir -p /etc/nginx/coraza
cp /etc/nginx/coraza/coraza.conf-recommended /etc/nginx/coraza/coraza.conf

# reuse the CRS you already have — copy or symlink it in
cp -r /etc/modsecurity/coreruleset /etc/nginx/coraza/coreruleset
cp /etc/nginx/coraza/coreruleset/crs-setup.conf.example \
   /etc/nginx/coraza/coreruleset/crs-setup.conf   # if not already done</code></pre>

<p>This is the part people don&#8217;t believe until they see it: your <code>crs-setup.conf</code>, your paranoia-level setting, your anomaly thresholds, all of it carries over byte-for-byte. The CRS doesn&#8217;t know or care which engine is reading it.</p>

<h3>Step 3 — Port your exclusions (the bit that actually matters)</h3>

<p>Your hand-tuned exclusions are SecLang too, so they move across verbatim. If you kept them in a <code>REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf</code> / <code>RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf</code> pair (the CRS-recommended layout), just copy those files in alongside the CRS and load them in the same before/after order. If instead you&#8217;d been using our CRS plugins — <a href="https://github.com/eilandert/wordpress-hardening-plugin" rel="noopener" target="_blank">wordpress-hardening-plugin</a>, <a href="https://github.com/eilandert/vaultwarden-crs-plugin" rel="noopener" target="_blank">vaultwarden-crs-plugin</a>, <a href="https://github.com/eilandert/vimbadmin-crs-plugin" rel="noopener" target="_blank">vimbadmin-crs-plugin</a> — even better: they&#8217;re engine-agnostic, so you copy the same plugin directory and load it the same way. No re-tuning, no revalidation. That&#8217;s the entire payoff of staying inside the CRS ecosystem instead of inventing your own rules.</p>

<h3>Step 4 — Swap the NGINX wiring</h3>

<p>This is the only genuinely new part. ModSecurity&#8217;s connector used <code>modsecurity on;</code> and <code>modsecurity_rules_file</code>; Coraza uses <code>coraza on;</code> and <code>coraza_rules_file</code>. Same shape, different prefix. Comment out the old directives, add the new ones. Load order is base config → CRS setup → CRS rules → your exclusions, exactly as before:</p>

<pre><code>load_module modules/ngx_http_coraza_module.so;   # in the main context

http {
    # --- old, leave commented until cutover is proven ---
    # modsecurity on;
    # modsecurity_rules_file /etc/nginx/modsec/main.conf;

    # --- new ---
    coraza on;
    coraza_rules_file /etc/nginx/coraza/coraza.conf;
    coraza_rules_file /etc/nginx/coraza/coreruleset/crs-setup.conf;
    coraza_rules_file /etc/nginx/coraza/coreruleset/rules/*.conf;
    coraza_transaction_id $request_id;   # so WAF logs join your access logs
}</code></pre>

<h3>Step 5 — Detection-only, for a week, no exceptions</h3>

<p>Set <code>SecRuleEngine DetectionOnly</code> in your Coraza base config and reload. Yes, even though you already tuned this exact ruleset under ModSecurity. The engines are not bit-identical: Coraza&#8217;s parser and a few operators behave subtly differently on edge cases, so a rule that never fired under ModSec might find something new, and vice versa. Run both engines&#8217; logs side by side for a week and diff the verdicts. If Coraza blocks something ModSecurity let through (or the reverse), that&#8217;s your short list to investigate before cutover.</p>

<pre><code>nginx -t && systemctl reload nginx
# watch what Coraza would have done
tail -f /var/log/nginx/error.log | grep -i coraza</code></pre>

<h3>Step 6 — Cut over, and keep the rollback one line away</h3>

<p>Once the detection-mode logs are clean and match your expectations, flip Coraza to <code>SecRuleEngine On</code>, reload, and watch live for a few hours. The beauty of leaving the old ModSecurity directives commented rather than deleted is your rollback is uncommenting four lines and a reload — under a minute, no reinstall. Once Coraza has run clean in blocking mode for a week or two, then you remove the ModSecurity module and config for good. Not before. There is no prize for deleting the safety net early.</p>

<h2 style="color:#f59e0b">Frequently asked questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Is Coraza a drop-in replacement for ModSecurity?</h3>
<div class="rank-math-answer ">

<p>Close, but read the fine print. Coraza implements the SecLang rule language and runs the OWASP Core Rule Set unmodified, so your existing SecRule files and CRS setup port over with little or no change. What differs is the engine internals and the integration layer: on NGINX you use the coraza-nginx connector and its four directives instead of the ModSecurity connector. The rules are portable; the wiring is new. Test in detection-only mode before you trust the port.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Why is Coraza written in Go instead of C++?</h3>
<div class="rank-math-answer ">

<p>Memory safety on the most attacker-exposed component in your stack. A WAF parses hostile, attacker-controlled input on the hot path, which is the worst possible place for a buffer overflow or use-after-free. ModSecurity is C++, so a parser bug can be a memory-corruption bug. Go&#8217;s garbage collector and bounds checking remove that entire class of vulnerability. The cost is a heavier runtime and the cgo bridge needed to call Go from NGINX&#8217;s C.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">What is libcoraza and why do I need it separately?</h3>
<div class="rank-math-answer ">

<p>libcoraza is the C-bindings layer that compiles the Go Coraza engine into a C-callable shared library (libcoraza.so.1) using cgo&#8217;s c-shared mode. NGINX is C and cannot call Go directly, so libcoraza is the translator. We ship it as two packages: libcoraza1 (the runtime library your server loads) and libcoraza-dev (the header and symlink needed only to build modules against it).</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Should I run Coraza in blocking mode right away?</h3>
<div class="rank-math-answer ">

<p>No. Start in detection-only mode (SecRuleEngine DetectionOnly), which is how the CRS ships by default. Run it for at least a week against real traffic, watch your logs for false positives, and build exclusion rules for the legitimate requests it flags. Only after you have tuned out the false positives should you switch to SecRuleEngine On. Going straight to blocking on a live app is the fastest way to block your own admins and API.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Our OWASP CRS plugins</h2>

<p>The CRS supports plugins: drop-in rule bundles that tune the base rules for one specific app, killing its false positives and adding app-aware hardening without forking the rule set. They work the same whether the engine underneath is Coraza or ModSecurity. We maintain three:</p>

<ul>
<li><a href="https://github.com/eilandert/wordpress-hardening-plugin" rel="noopener" target="_blank">wordpress-hardening-plugin</a>: locks down the usual WordPress attack surface (wp-admin, xmlrpc, REST, login) and clears the CRS false positives the block editor and plugin updates would otherwise trip.</li>
<li><a href="https://github.com/eilandert/vaultwarden-crs-plugin" rel="noopener" target="_blank">vaultwarden-crs-plugin</a>: exclusions for the Vaultwarden / Bitwarden API so the CRS stops flagging legitimate encrypted vault traffic as an attack.</li>
<li><a href="https://github.com/eilandert/vimbadmin-crs-plugin" rel="noopener" target="_blank">vimbadmin-crs-plugin</a>: exclusions for ViMbAdmin, the mail-domain admin panel, so its forms survive a paranoid CRS in blocking mode.</li>
</ul>

<h2 style="color:#f59e0b">Related reading</h2>

<ul>
<li><a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: the older engine, same rule set, step by step. The closest map to a Coraza setup.</li>
<li><a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">What Is the BREACH Attack?</a>: a compression side-channel a WAF won&#8217;t save you from, and what actually does.</li>
<li><a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker Hardening for Self-Hosters</a>: defence in depth below the WAF: rootless, read-only, cap-drop.</li>
</ul>

<p>Anyway. Before you flip <code>SecRuleEngine On</code> in production, mirror your traffic to a detection-only box for a week. The CRS will block something you depend on. It always does.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>JA3/JA4 TLS Fingerprinting: How It Works and Is It Safe to Block?</title>
		<link>https://deb.myguard.nl/2026/06/ja3-ja4-tls-fingerprinting-nginx/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Fri, 12 Jun 2026 16:07:36 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[bot-detection]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[ja4]]></category>
		<category><![CDATA[openssl]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[tls]]></category>
		<category><![CDATA[tls-fingerprint]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6345</guid>

					<description><![CDATA[JA3 and JA4 TLS fingerprinting read the bytes of the ClientHello to spot the software behind a connection, even when it lies about its User-Agent. Here is how it works on nginx with ngx_ssl_fingerprint_module, and why blocking on a fingerprint is riskier than it looks.]]></description>
										<content:encoded><![CDATA[<p>Your browser introduces itself before it says a single word. Long before the HTTP request, before the first <code>GET /</code>, before any cookie or User-Agent header, the very first packet of a TLS handshake (the ClientHello) carries a list of preferences so specific that it acts like a signature. Salesforce noticed this back in 2017, hashed those preferences into a 32-character string, and called it JA3. In 2023 John Althouse, one of the original JA3 authors, threw the whole thing out and built JA4 to fix what JA3 got wrong. Both do the same trick: TLS fingerprinting lets your server recognise the <em>software</em> talking to it, even when that software is lying about who it is.</p>

<p>That&#8217;s the pitch for TLS fingerprinting, and it&#8217;s a good one. A Python <code>requests</code> script can set <code>User-Agent: Mozilla/5.0 ... Chrome/120</code> all day long. It cannot easily forge the byte-level shape of a real Chrome handshake, because that shape comes from the TLS library underneath, not the string you type into a header. So a bot pretending to be a browser usually fails the handshake-shape test even while it passes the header test. That&#8217;s why fingerprinting works, and that&#8217;s why people want to block on it. Whether you <em>should</em> block on it is the part nobody warns the juniors about, and it&#8217;s where this post spends most of its time.</p>

<p>We&#8217;ll use Hanada Lee&#8217;s <a href="https://github.com/HanadaLee/ngx_ssl_fingerprint_module" target="_blank" rel="noopener">ngx_ssl_fingerprint_module</a> as the concrete example, because it&#8217;s the cleanest current implementation for nginx and it exposes JA3, JA3 hash, and the full JA4 family as plain nginx variables you can log, match, and (if you&#8217;re brave) reject on.</p>

<h2 style="color:#f59e0b">What a TLS fingerprint actually is</h2>

<p>Here&#8217;s what&#8217;s happening on the wire. When a client opens a TLS connection, the first thing it sends is the ClientHello. It&#8217;s a structured blob that says, roughly: &#8220;I speak TLS version X, here are the cipher suites I support in <em>this order</em>, here are the extensions I&#8217;m sending in <em>this order</em>, here are the elliptic curves I like, and here are the signature algorithms I&#8217;ll accept.&#8221; None of that is secret. All of it is necessary for the handshake to work. And all of it is wildly inconsistent between different TLS stacks.</p>

<p>Chrome&#8217;s BoringSSL sends a different cipher list, in a different order, with a different extension layout than OpenSSL, than Go&#8217;s <code>crypto/tls</code>, than Python&#8217;s <code>ssl</code> module, than curl, than Java. The ordering matters as much as the contents. Two libraries can support the exact same 15 cipher suites and still be told apart instantly because one lists them oldest-first and the other newest-first. That ordering is baked into the library at compile time. The script kiddie spoofing a User-Agent string never touches it.</p>

<p>A fingerprint is just a deterministic recipe for turning that ClientHello into a short, comparable string. Feed the same client through the recipe twice, you get the same string. Feed a different stack through it, you get a different string. The recipe is public. The discriminating power comes from the fact that the inputs are stable per-software and varied across-software.</p>

<h2 style="color:#f59e0b">JA3: the original, and where it bleeds</h2>

<p>JA3 was the first widely-adopted version, and the recipe is simple enough to do on a napkin. You take five fields from the ClientHello: the TLS version, the list of cipher suites, the list of extensions, the list of elliptic curves, and the list of EC point formats. You concatenate their decimal values with commas, glue the five groups together with dashes, and you get a string like this:</p>

<pre><code>769,4865-4866-4867-49195-49199-...,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-21,29-23-24,0</code></pre>

<p>Then you MD5-hash that string down to something like <code>e7d705a3286e19ea42f587b344ee6865</code>. That hash is the JA3. Short, loggable, easy to compare against a blocklist of known-bad bots. Salesforce shipped it, threat-intel feeds adopted it, and for a few years it was the bot-detection darling.</p>

<p>And then it started bleeding, for two reasons that will page you at 3 a.m. if you trusted it too hard.</p>

<p>First: <strong>MD5</strong>. Not for the cryptographic reasons (collisions don&#8217;t matter here, nobody&#8217;s attacking the hash) but because once you&#8217;ve hashed the string you&#8217;ve thrown away all the structure. You can&#8217;t look at <code>e7d705a3...</code> and reason about <em>why</em> two clients differ. It&#8217;s opaque. You either have it in your blocklist or you don&#8217;t.</p>

<p>Second, and worse: <strong>GREASE</strong>. Chrome and other modern browsers deliberately inject random junk values into their cipher and extension lists. RFC 8701, &#8220;Generate Random Extensions And Sustained Extensibility&#8221;, exists so that middleboxes don&#8217;t ossify the protocol by assuming the list of valid values never grows. The browser tosses in a reserved GREASE value (0x0a0a, 0x1a1a, and friends) that means nothing, and a correct server ignores it. But naive JA3 includes those random values in the hash. So the same Chrome install produces a <em>different</em> JA3 on every single connection, because the GREASE value rotates. Your blocklist of &#8220;known bad JA3 hashes&#8221; is now playing whack-a-mole against noise. Implementations learned to strip GREASE before hashing, but the spec never said they had to, so you get inconsistent JA3 values depending on whose code computed them. Great.</p>

<h2 style="color:#f59e0b">JA4: same idea, fewer foot-guns</h2>

<p>John Althouse went back to the drawing board and released the JA4+ family in 2023 under a more permissive license than the original drama around JA3 allowed. JA4 keeps the core insight and fixes the operational mess.</p>

<p>The big changes. JA4 is <em>human-readable</em> by design. Instead of one opaque MD5, a JA4 fingerprint has visible structure: a prefix that tells you the transport (TCP or QUIC), the TLS version, whether SNI was present, the cipher and extension counts, and the ALPN value, followed by a truncated SHA-256 of the sorted cipher list and another of the sorted extension list. So a JA4 looks like <code>t13d1516h2_8daaf6152771_b186095e22b6</code> and you can actually read the front of it: <code>t</code> = TCP, <code>13</code> = TLS 1.3, <code>d</code> = SNI present, <code>1516</code> = 15 ciphers / 16 extensions, <code>h2</code> = ALPN says HTTP/2.</p>

<p>And the killer fix: <strong>JA4 sorts the cipher and extension lists before hashing</strong>. GREASE values get stripped, the rest get sorted, so the random junk and the connection-to-connection ordering jitter stop mattering. One Chrome build now produces one stable JA4, the way you always wanted JA3 to behave. This is the single most important reason to prefer JA4 over JA3 if you&#8217;re starting fresh. The sorted, GREASE-stripped fingerprint is the one that survives contact with reality.</p>

<p>JA4 also comes with a whole family. JA4 fingerprints the client. <strong>JA4S</strong> fingerprints the <em>server&#8217;s</em> ServerHello response, which is handy for fingerprinting what a backend is running. The original JA3 had a server-side sibling too, <strong>JA3S</strong>, built the same way from the ServerHello. The module we&#8217;re about to compile exposes both raw and hashed JA4 variants so you can pick readability or compactness depending on whether a human or a regex is reading the value.</p>

<h2 style="color:#f59e0b">Getting it into nginx: the patched-stack tax</h2>

<p>Now the part that makes sysadmins sigh. You cannot just <code>--add-module</code> this thing onto a stock nginx and call it a day. TLS fingerprinting needs the raw ClientHello bytes, and stock OpenSSL doesn&#8217;t hand them to nginx. The handshake parsing happens deep inside OpenSSL, the interesting bytes get consumed, and by the time nginx&#8217;s code runs they&#8217;re gone. So ngx_ssl_fingerprint_module ships <strong>two patches</strong>: one for OpenSSL (to preserve the ClientHello during negotiation and expose it) and one for nginx (to store the computed fingerprints on the connection where the variables can reach them).</p>

<p>That means rebuilding nginx against a patched, statically-linked OpenSSL. You&#8217;re not using the distro&#8217;s <code>libssl</code> any more. The current module targets OpenSSL 3.5.4+ and nginx 1.29.3+, and the build looks like this:</p>

<pre><code>git clone -b release-1.29.3 --depth=1 https://github.com/nginx/nginx
cd nginx
git clone -b openssl-3.5.5 --depth=1 https://github.com/openssl/openssl
git clone -b ja4_fingerprint https://git.hanada.info/hanada/ngx_ssl_fingerprint_module

patch -p1 -d openssl < ngx_ssl_fingerprint_module/patches/openssl.openssl-3.5.5+.patch
patch -p1 < ngx_ssl_fingerprint_module/patches/nginx-1.29.3+.patch

./auto/configure \
  --with-openssl=./openssl \
  --with-stream_ssl_module \
  --add-module=./ngx_ssl_fingerprint_module \
  --with-http_v2_module \
  --with-stream
make -j</code></pre>

<p>Two things bite here. One: this is a <em>static</em> OpenSSL build, so every time OpenSSL ships a CVE fix (and OpenSSL ships CVE fixes the way the rest of us ship typos) you rebuild nginx, you don't just <code>apt upgrade libssl3</code> and reload. You own the patch cadence now. Two: the patch is version-pinned. The OpenSSL patch is written against a specific OpenSSL source tree, and if you bump to a version it wasn't written for, it'll fail to apply or, worse, apply with fuzz and miscompile. Pin your versions, test the build in a throwaway environment, and don't do it for the first time on the box that's serving traffic.</p>

<p>If the idea of maintaining your own OpenSSL build makes you twitch, that's the correct instinct, and it's exactly why we ship a <a href="https://deb.myguard.nl/2026/05/openssl-nginx-the-dedicated-openssl-built-just-for-nginx-and-angie/">dedicated openssl-nginx package built just for nginx and Angie</a>. The fingerprinting patch lives in the same world as every other "nginx needs a custom crypto stack" feature: HTTP/3, post-quantum key exchange, kTLS. Once you've accepted one, the rest are just more patches on the pile.</p>

<h2 style="color:#f59e0b">The variables, and a config that does something</h2>

<p>Once it's built, you flip it on with one directive and you get a fistful of nginx variables. The directive is <code>ssl_fingerprint on;</code> and it works in both the <code>http</code> and <code>stream</code> contexts, which means you can fingerprint plain TCP/TLS too, not only HTTP. Off by default, because computing fingerprints on every handshake isn't free and most vhosts don't need it.</p>

<p>Here's the variable set:</p>

<pre><code>$ssl_greased            # 1 if the client sent GREASE values (a real-browser tell)
$ssl_fingerprint_ja3    # raw JA3 string
$ssl_fingerprint_ja3_hash   # MD5 of the JA3
$ssl_fingerprint_ja4_r  # JA4 raw (full, unhashed)
$ssl_fingerprint_ja4    # JA4 standard (the readable+hashed form)
$ssl_fingerprint_ja4_ro # JA4 "original" raw (the pre-sort ordering variant)
$ssl_fingerprint_ja4_o  # JA4 "original" hashed</code></pre>

<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" width="1000" height="560" src="https://deb.myguard.nl/wp-content/uploads/2026/06/ja3-ja4-nginx-fingerprint-variables.webp" alt="ngx_ssl_fingerprint_module nginx variables: JA3, JA4 and ssl_greased reference" class="wp-image-6347" srcset="https://deb.myguard.nl/wp-content/uploads/2026/06/ja3-ja4-nginx-fingerprint-variables.webp 1000w, https://deb.myguard.nl/wp-content/uploads/2026/06/ja3-ja4-nginx-fingerprint-variables-300x168.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/06/ja3-ja4-nginx-fingerprint-variables-768x430.webp 768w" sizes="(max-width: 1000px) 100vw, 1000px" /><figcaption class="wp-element-caption">The variables ngx_ssl_fingerprint_module exposes once you flip on ssl_fingerprint.</figcaption></figure>

<p>The <code>_r</code> suffix means raw (unhashed, debuggable). The <code>_o</code> suffix is the "original" ordering variant: JA4 normally sorts the lists, but JA4_O preserves the original on-wire order, which keeps a little more discriminating power at the cost of the GREASE-stability that sorting buys you. Use the plain <code>$ssl_fingerprint_ja4</code> for blocklists and the <code>_r</code> / <code>_ro</code> raw forms when you're staring at a log trying to work out why a legit client got nuked.</p>

<p>A minimal config that just shows you your own fingerprint:</p>

<pre><code>http {
    ssl_fingerprint on;
    server {
        listen 127.0.0.1:4433 ssl;
        ssl_certificate     cert.pem;
        ssl_certificate_key priv.key;
        return 200 "ja4: $ssl_fingerprint_ja4\nja3: $ssl_fingerprint_ja3\ngreased: $ssl_greased\n";
    }
}</code></pre>

<p>The first thing any sane person does is <strong>log the fingerprint, don't block on it</strong>. Add <code>$ssl_fingerprint_ja4</code> to your <code>log_format</code>, let it run for a week, and go look at what your actual traffic looks like before you reject a single byte. You'll learn more from one week of logs than from any threat-intel blog post. A custom log line:</p>

<pre><code>log_format fp '$remote_addr "$http_user_agent" '
              'ja4=$ssl_fingerprint_ja4 greased=$ssl_greased';
access_log /var/log/nginx/fp.log fp;</code></pre>

<p>From there, the obvious move is to feed the fingerprint into the same machinery you already use to deal with abusive clients. Pair it with <a href="https://deb.myguard.nl/2026/06/auto-ban-abusive-clients-in-nginx-with-the-error-abuse-module/">the error-abuse module that auto-bans clients</a>, or use it as one more signal in the broader fight to <a href="https://deb.myguard.nl/2026/06/defend-webserver-vibe-coded-ai-exploit-scanners-bots/">defend your webserver against vibe-coded AI exploit scanners and bots</a>. The fingerprint is a feature, not a verdict. Treat it like one.</p>

<h2 style="color:#f59e0b">Why TLS fingerprinting works (and why GREASE is your friend)</h2>

<p>The reason this whole approach has teeth is the gap between two layers. The application layer (headers, cookies, the User-Agent) is trivially forgeable because it's just strings the client chooses to send. The transport layer (the ClientHello shape) is much harder to forge because it's emitted by the TLS library, and the TLS library is compiled C that the average scraper author has no idea how to bend. A Go program using <code>net/http</code> emits Go's handshake. A Python <code>requests</code> call emits OpenSSL's handshake via CPython. Neither looks remotely like Chrome, no matter what User-Agent they paste on top.</p>

<p>So the highest-value check is dead simple and almost free: does the User-Agent <em>claim</em> to be Chrome while the JA4 fingerprint says "this is Python"? That mismatch is a screaming red flag. The header says one thing, the handshake says another, and the handshake is the one that's expensive to fake. That single contradiction catches a depressing amount of low-effort bot traffic.</p>

<p>And <code>$ssl_greased</code> is the quiet hero here. Real modern browsers send GREASE. Most scripting libraries don't bother. So a connection that claims to be a current browser but has <code>greased=0</code> is suspicious before you even look at the full fingerprint. It's not proof (some libraries have started adding GREASE to blend in), but as a cheap first-pass tell it earns its keep. Remember that callback when we get to evasion: the moment a signal becomes valuable, the bots start mimicking it.</p>

<h2 style="color:#f59e0b">Is it safe to block on? The honest answer</h2>

<p>No. Not on its own. Here's the part the breathless "block all bad JA3" blog posts skip, and it's the part that'll cost you real customers if you ignore it.</p>

<p><strong>Fingerprints are shared by millions of people.</strong> A JA3 or JA4 fingerprint identifies a <em>TLS stack</em>, not a person and not a bot. Every Chrome 120 user on the same OS produces a near-identical fingerprint. That's hundreds of millions of humans behind a handful of hashes. So when you blocklist a fingerprint because some bot used it, you may be blocking that bot and also every legitimate Chrome user who happens to share its TLS library. The fingerprint has zero ability to tell those two apart, because at the TLS layer they <em>are</em> identical. This is the single biggest reason fingerprint blocklists backfire.</p>

<p><strong>TLS libraries churn.</strong> Chrome updates roughly every four weeks, and a fair number of those updates touch the TLS stack: a new cipher, a reordered extension, a tweaked GREASE behaviour. Each change shifts the fingerprint. Your carefully-curated allowlist of "good browser fingerprints" goes stale on Chrome's release schedule, not yours. Pin too hard and you'll start blocking the newest Chrome the day it ships, which is exactly the users you least want to lose. I've watched a fingerprint allowlist turn into an outage because nobody updated it through three Chrome releases. It's always the allowlist.</p>

<p><strong>Collisions cut both ways.</strong> Different browsers sometimes converge on the same fingerprint, and the same browser behind different middleboxes (corporate TLS-inspection proxies, some VPNs, certain antivirus products that MITM your HTTPS) produces a <em>different</em> fingerprint than the bare browser would. So your corporate users behind a Zscaler proxy don't look like Chrome any more, they look like Zscaler. Block "non-browser fingerprints" and you've just locked out every employee at a security-conscious company.</p>

<p><strong>And spoofing is a solved problem for motivated attackers.</strong> Tools like utls (Go), curl-impersonate, and a pile of others exist specifically to emit a byte-perfect Chrome ClientHello from a non-Chrome client. The serious bots already use them. So fingerprinting filters out the lazy 80% (the raw <code>python-requests</code> and <code>Go-http-client</code> traffic) and does nothing to the motivated 20% who copied a real Chrome fingerprint on purpose. That's still a useful 80%. Just don't kid yourself that you've stopped the people actually worth worrying about.</p>

<p>So what <em>is</em> safe? Use the fingerprint as one weighted signal among several, never as a sole verdict. Log first, always. Score, don't ban: a UA/JA4 mismatch plus <code>greased=0</code> plus a hammering request rate is a confident bot; any one of those alone is a coin flip. Rate-limit or challenge the suspicious buckets rather than hard-blocking them, so a false positive degrades to a CAPTCHA instead of a white page. And keep a human-readable record (the <code>_r</code> raw variants in your logs) so when a customer emails "your site won't load", you can actually see what their handshake looked like instead of guessing. Fingerprinting is a fantastic <em>detector</em> and a terrible <em>judge</em>. Wire it up accordingly.</p>

<h2 style="color:#f59e0b">Where this fits in a real defence</h2>

<p>Think of TLS fingerprinting as the bouncer who clocks that your ID photo doesn't match your face. It's a fast, cheap first impression that flags the obvious fakes. It is not the metal detector, the guest list, or the security camera, and a club that ran on nothing but the bouncer's gut would get robbed weekly. Layer it. The fingerprint feeds a score; the score feeds rate-limiting and challenges; persistent abusers get auto-banned; and your <a href="https://deb.myguard.nl/2026/05/tls-configuration-for-nginx-and-angie-the-complete-guide-to-getting-a-on-ssl-labs/">underlying TLS configuration</a> is already hardened so the handshake you're fingerprinting is a modern one in the first place.</p>

<p>The ngx_ssl_fingerprint_module gives you the raw material cleanly: JA3 for compatibility with old threat feeds, JA4 for the stuff that actually survives a Chrome update, JA3S/JA4S if you're fingerprinting servers, and <code>$ssl_greased</code> as the cheapest browser-tell you'll ever get. What it doesn't give you, and what no module can, is the judgement to know when a fingerprint is a clue and when it's a trap. That part's still your job.</p>

<p>Anyway. Log it for a week before you block anything, and back up your nginx config before you go anywhere near a custom OpenSSL build.</p>

<h2 style="color:#f59e0b">Frequently asked questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">What is the difference between JA3 and JA4?</h3>
<div class="rank-math-answer ">

<p>JA3 is the original TLS client fingerprint from 2017: it concatenates five ClientHello fields and MD5-hashes them into one opaque string. JA4, released in 2023, keeps the same idea but makes the fingerprint human-readable, includes ALPN and SNI info in a visible prefix, and (crucially) sorts the cipher and extension lists and strips GREASE before hashing. That sorting makes JA4 stable across connections where JA3 would jitter. If you are starting fresh, use JA4.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Can a TLS fingerprint be spoofed?</h3>
<div class="rank-math-answer ">

<p>Yes, by motivated attackers. Tools like utls, curl-impersonate and similar libraries can emit a byte-perfect copy of a real Chrome ClientHello from a non-Chrome client, producing a matching JA3/JA4. Fingerprinting reliably filters out lazy bots that use default Python or Go HTTP stacks, but it does little against attackers who deliberately impersonate a browser fingerprint. Treat it as one signal, not proof.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Is it safe to block traffic based on JA3 or JA4 fingerprints?</h3>
<div class="rank-math-answer ">

<p>Not on its own. A fingerprint identifies a TLS library, not a person, so a single hash can be shared by hundreds of millions of legitimate browser users. Blocklisting a fingerprint risks blocking every real user on that browser version. Fingerprints also change with every Chrome TLS update and shift behind corporate TLS-inspection proxies. Safe use means scoring and rate-limiting on the fingerprint as one weighted signal, logging first, and never hard-blocking on the fingerprint alone.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Why does ngx_ssl_fingerprint_module need a patched OpenSSL and nginx?</h3>
<div class="rank-math-answer ">

<p>The raw ClientHello bytes needed to compute a fingerprint are consumed deep inside OpenSSL during the handshake and are gone by the time nginx runs. The module ships one patch for OpenSSL (to preserve and expose the ClientHello) and one for nginx (to store the computed fingerprints on the connection where its variables can read them). That means rebuilding nginx against a statically-linked, patched OpenSSL rather than using the distro libssl, so you own the OpenSSL CVE-patch cadence yourself.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">What does the $ssl_greased variable tell me?</h3>
<div class="rank-math-answer ">

<p>GREASE (RFC 8701) is random reserved junk that modern browsers deliberately inject into their cipher and extension lists to keep the protocol extensible. $ssl_greased is 1 when the client sent GREASE values and 0 when it did not. Real current browsers send GREASE; many scripting libraries do not, so a client claiming to be a browser while reporting greased=0 is a cheap early bot tell. It is a hint, not proof, since some bot tooling now adds GREASE to blend in.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">What are JA3S and JA4S?</h3>
<div class="rank-math-answer ">

<p>JA3 and JA4 fingerprint the client's ClientHello. JA3S and JA4S fingerprint the server's ServerHello response instead, built the same way from the server-side handshake fields. They are useful for identifying what software a backend or remote server is running, for example in threat hunting or asset inventory, rather than for filtering incoming client traffic.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related reading</h2>

<ul>
<li><a href="https://deb.myguard.nl/2026/06/defend-webserver-vibe-coded-ai-exploit-scanners-bots/">How to defend your webserver against vibe-coded AI exploit scanners and bots</a>: where fingerprinting fits in the wider bot-defence toolkit.</li>
<li><a href="https://deb.myguard.nl/2026/06/auto-ban-abusive-clients-in-nginx-with-the-error-abuse-module/">Auto-ban abusive clients in NGINX with the error-abuse module</a>: the enforcement layer your fingerprint score should feed into.</li>
<li><a href="https://deb.myguard.nl/2026/05/openssl-nginx-the-dedicated-openssl-built-just-for-nginx-and-angie/">openssl-nginx: the dedicated OpenSSL built just for NGINX and Angie</a>: the custom crypto stack that makes patched-OpenSSL features sane to maintain.</li>
<li><a href="https://deb.myguard.nl/2026/05/tls-configuration-for-nginx-and-angie-the-complete-guide-to-getting-a-on-ssl-labs/">TLS configuration for NGINX and Angie: getting an A+ on SSL Labs</a>: harden the handshake you are fingerprinting.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>How to cache pages in nginx with cache-turbo (no Varnish)</title>
		<link>https://deb.myguard.nl/2026/06/nginx-cache-turbo-built-in-page-cache/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Thu, 11 Jun 2026 10:53:46 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[caching]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[memcached]]></category>
		<category><![CDATA[nginx-module]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[php-fpm]]></category>
		<category><![CDATA[redis]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6319</guid>

					<description><![CDATA[A page cache that lives inside nginx: no Varnish, no Lua, no second daemon. How cache-turbo uses stale-while-revalidate, L1/L2 tiers and single-flight refresh to keep your backend asleep under load.]]></description>
										<content:encoded><![CDATA[<p>Ten thousand people hit the same page the exact millisecond your cache expires, and a naive cache forwards all ten thousand to your backend at once. cache-turbo forwards one. The other 9,999 get served a slightly stale copy from RAM while that single request quietly fetches a fresh one. Nobody waits. Your database never finds out it was supposed to panic.</p>

<p>This is a build guide. By the end you&#8217;ll have an nginx page cache running inside the web server itself: no Varnish daemon, no Lua, no second port to babysit. We&#8217;ll go from a stock nginx to a tuned, fleet-shared, self-monitoring cache in nine steps, and I&#8217;ll tell you which knob pages you at 3 a.m. if you get it wrong.</p>

<p>First, the thirty-second version of <em>why</em>. Your backend is slow. I don&#8217;t care what it&#8217;s written in. PHP, Node, Python, that Rust service you&#8217;re very proud of: the moment it has to talk to a database, render a template, and stitch together a page, you&#8217;re looking at tens to hundreds of milliseconds per request. A WordPress homepage that wakes PHP-FPM, runs forty plugins, and fires a dozen MySQL queries can take 600 to 900 ms to build. nginx can hand you a saved copy out of memory in about 0.4 ms. That&#8217;s not a typo. It&#8217;s roughly a thousand to one. So you build the page once, keep it, and serve everyone else the copy. That&#8217;s a page cache, and cache-turbo is one that lives in the worker processes you already have.</p>

<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/nginx-cache-turbo-built-in-page-cache.webp" alt="nginx cache-turbo built-in page cache: L1 shared memory, optional L2 Redis, and stale-while-revalidate" width="1200" height="630" loading="lazy"/><figcaption>The shape of the thing: L1 in local RAM, an optional Redis L2 for the fleet, and stale-while-revalidate keeping the origin asleep.</figcaption></figure>

<div id="rank-math-howto" class="rank-math-block" >
<div class="rank-math-howto-description">

<p>Add an in-process page cache to nginx with cache-turbo: build the module, declare a shared-memory zone, turn caching on with stale-while-revalidate, tune the already-normalized cache key, pick a preset (including 1s microcaching), optionally add a Redis or memcached L2 tier, lock down the admin endpoint, scrape it with Prometheus, and verify the X-Cache header.</p>

</div>
<p class="rank-math-howto-duration"><strong>Time to a working cache</strong> <span>15 minutes</span></p>
<div class="rank-math-steps ">
<div id="step-build" class="rank-math-step">
<h3 class="rank-math-step-title ">Build the module</h3>
<div class="rank-math-step-content "><p>Compile cache-turbo as a dynamic module against your nginx (or Angie) source: ./configure &#8211;with-compat &#8211;add-dynamic-module=/path/to/nginx-cache-turbo-module &amp;&amp; make modules. On Debian or Ubuntu via deb.myguard.nl it ships prebuilt as libnginx-mod-http-cache-turbo, so you skip the compiler.</p>
</div>
</div>
<div id="step-zone" class="rank-math-step">
<h3 class="rank-math-step-title ">Declare a shared-memory zone</h3>
<div class="rank-math-step-content "><p>In the http block, carve out RAM for the cache: cache_turbo_zone name=ct 256m. This is your L1, shared across worker processes.</p>
</div>
</div>
<div id="step-enable" class="rank-math-step">
<h3 class="rank-math-step-title ">Turn caching on</h3>
<div class="rank-math-step-content "><p>In a location, bind the zone and set a freshness TTL: cache_turbo ct; cache_turbo_valid 60s; proxy_pass http://backend. Past the TTL, stale-while-revalidate serves the old copy while one background request refreshes it.</p>
</div>
</div>
<div id="step-key" class="rank-math-step">
<h3 class="rank-math-step-title ">Tune the cache key</h3>
<div class="rank-math-step-content "><p>The default key is already normalized: it drops tracking params (utm_, fbclid, gclid, sid, sessionid, tmp_) and sorts args out of the box. Use cache_turbo_normalize_vary, or cache_turbo_auto_vary, to split genuinely different variants like gzip vs brotli or mobile vs desktop.</p>
</div>
</div>
<div id="step-preset" class="rank-math-step">
<h3 class="rank-math-step-title ">Pick a preset (or autotune)</h3>
<div class="rank-math-step-content "><p>Set cache_turbo_preset micro|conservative|balanced|aggressive to configure four knobs at once (micro = 1s microcaching), or turn on cache_turbo_autotune to adapt refresh eagerness, stale window and lock to measured backend load.</p>
</div>
</div>
<div id="step-redis" class="rank-math-step">
<h3 class="rank-math-step-title ">Add an L2 for a fleet</h3>
<div class="rank-math-step-content "><p>cache_turbo_redis redis://host:6379/0 (or rediss:// for TLS), or cache_turbo_memcached host:11211, gives every nginx box one shared cache: write-through on store, one GET on an L1 miss, never on a hit. Redis is required for tag-based purging.</p>
</div>
</div>
<div id="step-admin" class="rank-math-step">
<h3 class="rank-math-step-title ">Wire up the admin endpoint and lock it down</h3>
<div class="rank-math-step-content "><p>Point a location at the zone with cache_turbo_admin for stats, purge and warm, then gate it with allow 127.0.0.1; deny all. An open admin endpoint is a DoS button and an SSRF primitive.</p>
</div>
</div>
<div id="step-monitor" class="rank-math-step">
<h3 class="rank-math-step-title ">Scrape it with Prometheus</h3>
<div class="rank-math-step-content "><p>GET /_cache?format=prometheus emits hit, miss, stale-serve, refresh, eviction, L2 and lock counters labelled by zone. Import the bundled Grafana dashboard, graph the hit ratio and watch evictions for an undersized zone.</p>
</div>
</div>
<div id="step-verify" class="rank-math-step">
<h3 class="rank-math-step-title ">Verify it works</h3>
<div class="rank-math-step-content "><p>Curl the URL twice and grep for X-Cache. First request: no header (a miss). Second: X-Cache: HIT, served from RAM. STALE means an old copy while a refresh runs.</p>
</div>
</div>
</div>
</div>





<h2 style="color:#f59e0b">Step 1: build the module</h2>

<p>cache-turbo is a normal nginx dynamic module. Nothing exotic, no external libraries to chase:</p>

<pre><code>$ ./configure --with-compat --add-dynamic-module=/path/to/nginx-cache-turbo-module
$ make modules</code></pre>

<p>That drops <code>ngx_http_cache_turbo_module.so</code> into <code>objs/</code>. It compiles against both <a href="https://deb.myguard.nl/nginx-modules/">nginx</a> and <a href="https://deb.myguard.nl/angie-modules-optimized-extended/">Angie</a>. You end up with a <code>.so</code> and one line at the top of your config:</p>

<pre><code>load_module modules/ngx_http_cache_turbo_module.so;</code></pre>

<p>One detail the README is quiet about and I&#8217;m not: the Redis client (Step 6) is hand-rolled on nginx&#8217;s own event loop. No hiredis, no blocking socket calls parked in the middle of your event-driven server choking every other connection on the worker. That&#8217;s why there&#8217;s nothing to <code>apt install</code> alongside it. Bolting a synchronous client into an async server is how you turn a fast server into a slow one with extra steps, and somebody always tries it.</p>

<h2 style="color:#f59e0b">Step 2: declare a shared-memory zone</h2>

<p>The cache needs a slab of RAM to live in. You declare it once, in the <code>http</code> block, and name it:</p>

<pre><code>http {
    cache_turbo_zone name=ct 256m;
    ...
}</code></pre>

<p>This is your L1, and it&#8217;s the whole game on a single server. It&#8217;s an <code>mmap</code>&#8216;d region the worker processes share, holding an rbtree keyed on a hash of each request, with LRU eviction once it fills. A hit here never leaves the worker. It&#8217;s per-box: every nginx server has its own L1, and they don&#8217;t know about each other until you add Redis.</p>

<p>Size it for your hot set, not your whole site. 256 MB holds a lot of HTML. If you&#8217;re caching a million long-tail URLs that each get one hit a week, you&#8217;ve misunderstood the tool: that&#8217;s a job for disk, and we&#8217;ll stack the two in a moment. Watch the eviction counter (Step 8) to know if you guessed too small.</p>

<h2 style="color:#f59e0b">Step 3: turn caching on, and meet stale-while-revalidate</h2>

<p>Now bind the zone inside a location and give it a freshness window:</p>

<pre><code>server {
    listen 80;
    location / {
        cache_turbo       ct;
        cache_turbo_valid 60s;
        proxy_pass http://127.0.0.1:8080;
    }
}</code></pre>

<p>That&#8217;s a working cache. But the interesting behaviour is what happens when a copy gets old, and this is the part worth tattooing somewhere.</p>

<p>A cached copy has three life stages. While it&#8217;s young it&#8217;s <strong>fresh</strong>: served instantly, backend stays asleep. Once it passes the TTL it goes <strong>stale</strong>, and here&#8217;s the move: cache-turbo keeps serving the old copy <em>immediately</em> while it sends exactly one request off in the background to fetch a new one. Nobody in the queue waits. When the copy gets truly ancient it&#8217;s <strong>expired</strong>, and only then does cache-turbo treat it as a miss and make someone wait.</p>

<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/nginx-cache-turbo-stale-while-revalidate.webp" alt="cache-turbo stale-while-revalidate: the fresh, stale and expired life stages, plus the dice, beta and single-flight lock" width="1200" height="560" loading="lazy"/><figcaption>The three life stages of a cached page, and the dice that decide when one stale copy gets refreshed.</figcaption></figure>

<p>That middle stage is stale-while-revalidate, SWR for short. The naive alternative, the one every junior writes first, is &#8220;when the cache expires, the next request rebuilds it.&#8221; Sounds reasonable. It&#8217;s a trap. Picture your most popular page expiring at noon. At noon plus one millisecond, a thousand requests arrive, all see an empty cache, and all charge at your backend simultaneously to rebuild the same page. That&#8217;s a <strong>thundering herd</strong>, also lovingly known as a cache stampede or the dogpile. I&#8217;ve watched one take down a database that was, on paper, massively over-provisioned. The post-mortem was short. &#8220;It&#8217;s always the cache.&#8221; Right next to &#8220;it&#8217;s always DNS.&#8221; SWR kills the herd because nobody ever stares at an empty slot. (If you want the formal version, serve-stale is codified in <a href="https://www.rfc-editor.org/rfc/rfc9111#name-serving-stale-responses" rel="noopener" target="_blank">RFC 9111</a>, the HTTP caching spec.)</p>

<h3>The dice and the beta knob</h3>

<p>When exactly does cache-turbo refresh a stale copy? Not the instant it goes stale (a page that gets one hit an hour shouldn&#8217;t refresh the microsecond it expires). It rolls dice, and the dice get loaded the longer the copy has been stale. The model is a linear ramp across the stale window: probability zero the moment it goes stale, effectively one by the time it&#8217;s about to expire. Every reader rolls independently, so a barely-stale page almost always gets served as-is and a nearly-expired one almost certainly triggers a refresh.</p>

<p>The <code>cache_turbo_beta</code> directive scales how eager that ramp is. It&#8217;s fixed-point integer math (<code>beta</code> times 1000, so no floats in the hot path; the people who&#8217;ve profiled nginx workers know why that matters). At <code>beta=1000</code> the probability tracks the elapsed fraction directly. Crank it to <code>2000</code> and pages refresh earlier and more often. Drop it to <code>500</code> and they coast deeper into staleness first. Higher beta means fresher pages and more backend load; lower beta means staler pages and a backend that gets to nap. That&#8217;s the entire tradeoff.</p>

<h3>Single-flight: who actually does the work</h3>

<p>The dice decide <em>whether</em> a refresh should start. They don&#8217;t decide <em>who</em>, because under a real burst several readers win the dice in the same instant, and if all of them refreshed you&#8217;d have reinvented the stampede. So there&#8217;s a hard lock. The first reader to claim the refresh takes a single-flight lock (<code>cache_turbo_lock_ttl</code> sets how long it&#8217;s held), and everyone else who rolled a winner just serves stale. One refresh per cycle, full stop. If the refreshing request dies or the backend hangs, the lock expires on its own and the next reader tries. No deadlock, no stuck entry haunting you for a week.</p>

<p>The same lock guards the <em>cold</em> miss too, not just refreshes. With <code>cache_turbo_lock on</code> (the default) the first request for an uncached key goes to the origin and the rest <strong>wait</strong> for it to fill the slot, instead of all stampeding a page that simply isn&#8217;t there yet; <code>cache_turbo_lock_timeout</code> caps how long a waiter waits before giving up and going itself. So both ends of a page&#8217;s life, the cold birth and every stale refresh, collapse to roughly one origin request.</p>

<p>The SWR math here was lifted, deliberately, from the same algorithm our <a href="https://deb.myguard.nl/2026/05/database-boost-free-wordpress-database-optimization-plugin/">WordPress object-cache work</a> uses. Same constants, same dice. Edge cache and object cache speak one language and tune the same way. That was not an accident.</p>

<h3>Redirects, negative caching, and &#8220;cache forever&#8221;</h3>

<p>By default cache-turbo stores a <code>200 OK</code> to a <code>GET</code> (never a <code>HEAD</code>, which would store an empty body, and never a mutating <code>POST</code>/<code>PUT</code>/<code>DELETE</code>). But you can give other status codes a TTL of their own and cache those too:</p>

<pre><code>cache_turbo_valid 30s;              # the default / 200 TTL
cache_turbo_valid 301 302 308 1h;   # cache redirects
cache_turbo_valid 404 410 1m;       # negative caching
cache_turbo_valid 0;                # "cache forever" until purged</code></pre>

<p>Negative caching matters more than it sounds: a flood of requests for a missing URL can hammer your backend just as hard as a popular one, and a 1-minute cache on a <code>404</code> turns that into one origin hit a minute. A <code>TIME</code> of <code>0</code> means the copy stays fresh indefinitely and is only updated when you purge it. One sharp edge: <code>cache_turbo_valid</code> <em>replaces</em>, it doesn&#8217;t merge, so if a nested <code>location</code> sets any <code>cache_turbo_valid</code> of its own it discards the whole inherited set, redirect and 404 lines included. Re-state every status line you still want in the child block.</p>

<p>There&#8217;s also a freebie you never configure: conditional requests. If the origin gave the cached <code>200</code> an <code>ETag</code> or <code>Last-Modified</code>, cache-turbo answers an <code>If-None-Match</code> or <code>If-Modified-Since</code> with a bodyless <code>304 Not Modified</code> straight from cache, no origin round trip, when the client&#8217;s copy is still current. It only does this from a <em>fresh</em> entry, never a stale one, because a stale copy hasn&#8217;t been revalidated and can&#8217;t honestly claim &#8220;still current.&#8221;</p>

<h3>And when php-fpm falls over: stale-if-error</h3>

<p>Here&#8217;s the failure mode that earns this cache its keep. Your PHP-FPM pool maxes out, or a deploy briefly returns <code>502</code>, or the database has a bad thirty seconds. What do your visitors see? With a plain cache: the error, in full colour. With cache-turbo: nothing, if it can help it.</p>

<p>The everyday case is already covered for free by the stale-while-revalidate you set up in Step 3. When a page is past its TTL but still inside its stale window, the <em>visitor</em> is served the old copy instantly while one background request goes to refresh it. If that background request comes back <code>500</code>/<code>502</code>/<code>503</code>/<code>504</code> or simply times out, cache-turbo shrugs and leaves the good copy exactly where it was — the failed refresh never overwrites it, and the visitor never sees the error. The backend gets to fall over in private. That&#8217;s <strong>stale-if-error</strong>, and you don&#8217;t type anything to get it.</p>

<p>For outages longer than the stale window, let the origin ask for more grace. If your app (or the nginx in front of it) sends <code>Cache-Control: stale-if-error=600</code> on a page, cache-turbo will keep serving that stale copy through up to ten minutes of origin 5xxs, marking the served response <code>X-Cache: STALE-IF-ERROR</code> so you can see exactly when it kicked in (it shows up as <code>STALE</code> in <code>$cache_turbo_status</code> too). A ten-minute backend outage becomes a non-event for every page that was warm when it started.</p>

<p>One honest limit, because a cache is not a magician: it can only shield pages it already has a copy of. A page nobody had requested yet, hit for the first time during the outage, has nothing cached to fall back to, so that visitor still gets the error. Caching can&#8217;t invent a page it never saw. Which is the best argument there is for cache warming (Step 7&#8217;s <code>?url=</code> verb): a warm cache is also your outage insurance.</p>

<h2 style="color:#f59e0b">Step 4: the cache key is already clean (here&#8217;s how to tune it)</h2>

<p>A cache key is the string that decides whether two requests are &#8220;the same page.&#8221; Get it wrong in one direction and you cache too little (every URL looks unique, hit rate is garbage). Wrong the other way and two different pages collide, and people get served each other&#8217;s content. This is the part people historically got wrong, so cache-turbo now ships a sane key by default: <code>$host$uri$cache_turbo_normalized_args</code>. Host plus path plus <em>normalized</em> args. You get the right behaviour without typing anything.</p>

<p>What &#8220;normalized&#8221; buys you for free: it sorts the args (so <code>?b=2&a=1</code> and <code>?a=1&b=2</code> hit one slot) and drops a built-in denylist of tracking junk: <code>utm_*</code>, <code>fbclid</code>, <code>gclid</code>, <code>msclkid</code>, <code>mc_eid</code>, <code>_ga</code>, <code>ref</code>, plus <code>sid</code>, <code>sessionid</code> and <code>tmp_*</code>. Marketing slaps <code>?utm_source=twitter</code> on every link, and a dumb cache would treat <code>/post-42?utm_source=twitter</code> and <code>/post-42?utm_source=facebook</code> as two different pages that render identically, caching the same HTML a dozen times while your hit rate quietly bleeds out. cache-turbo collapses them onto one slot out of the box. Add your own params to drop, or nuke them all:</p>

<pre><code>cache_turbo_normalize_strip     sid sessionid "tmp_*";   # extra args to drop (trailing * = prefix)
cache_turbo_normalize_strip     *;                       # or: a bare * matches every arg = drop all</code></pre>

<p>Then the opposite problem: variants that genuinely differ and must <em>not</em> share a slot. The cache keys on the request, not on the response&#8217;s <code>Vary</code> header, so if your page differs by gzip-vs-brotli or mobile-vs-desktop and you don&#8217;t say so, the first variant stored wins for everyone. You have two ways to fix it. If you know the axes up front, declare them with a vary bucket:</p>

<pre><code>cache_turbo_normalize_vary encoding device;   # keep gzip ≠ brotli, mobile ≠ desktop</code></pre>

<p>The <code>encoding</code> bucket splits by Accept-Encoding class and ranks zstd above brotli (we ship the <a href="https://deb.myguard.nl/2026/05/what-is-zstd-nginx-angie-browser-support/">zstd module</a>, so a zstd-capable client gets its own slot); <code>device</code> splits mobile from desktop by sniffing the User-Agent. Or, if you&#8217;d rather learn the axes from the response, turn on auto-Vary:</p>

<pre><code>cache_turbo_auto_vary on;   # read the response's own Vary and split automatically</code></pre>

<p>That reads the response&#8217;s own <code>Vary</code> header and splits the cache by the named request header, honouring a safe whitelist (<code>Accept-Encoding</code>, <code>User-Agent</code> device class, <code>Accept-Language</code>, <code>Origin</code>). Anything it can&#8217;t safely key on (<code>Vary: *</code>, <code>Cookie</code>, <code>Authorization</code>, or any header off the whitelist) is treated as uncacheable rather than stored under a key that ignores the varied axis. Pick one mechanism per axis: declaring an axis with <code>normalize_vary</code> <em>and</em> letting <code>auto_vary</code> learn it splits the cache on it twice for no benefit. Add only the axes your page actually varies on; an extra one halves your hit rate for nothing.</p>

<p>One safety move for origins you don&#8217;t fully trust: if the normalizer might merge two genuinely-private URLs that differ only by a <code>sessionid</code> the origin forgot to mark <code>private</code>, set an explicit raw key and skip the stripping and sorting entirely — <code>cache_turbo_key $scheme$host$request_uri;</code>. Every distinct query then gets its own slot. Belt-and-braces for an upstream that&#8217;s careless about per-user marking; the normalized default is fine if your origin is well-behaved.</p>

<h2 style="color:#f59e0b">Step 5: pick a preset, or let it tune itself</h2>

<p>Four knobs (valid, beta, lock_ttl, stale window) is three more than most people want to think about on a Tuesday. So pick a vibe:</p>

<pre><code>cache_turbo        ct;
cache_turbo_preset aggressive;</code></pre>

<table>
<thead><tr><th style="background:#f59e0b;color:#0b1220;padding:6px 10px">Knob</th><th style="background:#f59e0b;color:#0b1220;padding:6px 10px">micro</th><th style="background:#f59e0b;color:#0b1220;padding:6px 10px">conservative</th><th style="background:#f59e0b;color:#0b1220;padding:6px 10px">balanced (default)</th><th style="background:#f59e0b;color:#0b1220;padding:6px 10px">aggressive</th></tr></thead>
<tbody>
<tr><td>fresh TTL</td><td>1s</td><td>30s</td><td>60s</td><td>300s</td></tr>
<tr><td>beta (refresh eagerness)</td><td>1000</td><td>500</td><td>1000</td><td>3000</td></tr>
<tr><td>lock_ttl</td><td>1s</td><td>10s</td><td>5s</td><td>3s</td></tr>
<tr><td>stale-window multiplier</td><td>×2</td><td>×2</td><td>×4</td><td>×8</td></tr>
</tbody>
</table>

<p>The stale window works out to <code>valid × (multiplier − 1)</code>. Balanced plus a 60-second valid means fresh for 60s, served stale for another 180s, then expired. Any explicit knob still beats the preset, so <code>cache_turbo_preset balanced</code> followed by <code>cache_turbo_valid 120s</code> does exactly what you&#8217;d hope.</p>

<h3>Microcaching: the micro preset for APIs and PHP-FPM</h3>

<p>That first column, <code>micro</code>, is the one people sleep on, and it&#8217;s quietly the best trick in the box. Microcaching means a deliberately tiny TTL, about a second, on endpoints you&#8217;d normally call &#8220;too dynamic to cache.&#8221; The page is fresh for only one second, so the data is near-real-time, but during that second a burst of N requests is served from RAM and the backend is hit <em>once</em>. On a hammered <code>/api</code> or a PHP app, backend load drops from &#8220;every request&#8221; to &#8220;roughly one per endpoint per second,&#8221; and the content is at most a second or two stale. Nobody notices the staleness; everybody notices the server not falling over.</p>

<p>Because cache-turbo runs in the ACCESS phase and captures the body in a filter, it&#8217;s upstream-agnostic: the exact same directives microcache a <code>proxy_pass</code> JSON API and a <code>fastcgi_pass</code> PHP-FPM app. And since only <code>GET</code> is cached, mutations sail straight through untouched. For a CMS there&#8217;s a shortcut that auto-skips the admin, login and logged-in-cookie surfaces for you:</p>

<pre><code>location ~ \.php$ {
    cache_turbo               ct;
    cache_turbo_preset        micro;          # valid 1s + lock_ttl 1s + ×2 stale, in one word
    cache_turbo_lock          on;             # collapse the per-second burst to ONE origin hit
    cache_turbo_backend       wordpress;      # auto-skip wp-admin, login + logged-in cookies
    cache_turbo_cache_control respect;        # pin the 1s TTL regardless of app headers
    cache_turbo_no_store      $cookie_PHPSESSID;

    include      fastcgi_params;
    fastcgi_pass unix:/run/php/php-fpm.sock;
}</code></pre>

<p>The <code>cache_turbo_backend</code> directive takes <code>generic</code> (a.k.a. <code>auto</code>), <code>wordpress</code>, <code>woocommerce</code> or <code>joomla</code>, and a request that looks like a session (login cookie, admin URI, dynamic arg) skips the cache entirely. One catch worth knowing: enabling any CMS preset defaults <code>cache_turbo_cache_control</code> to <code>honor</code>, so an app emitting <code>Cache-Control: max-age=600</code> would override your <code>1s</code> unless you pin it back with <code>cache_turbo_cache_control respect</code>, as above. For the per-user safety net, <code>cache_turbo_bypass</code> and <code>cache_turbo_no_store</code> let you name a session cookie so a logged-in <code>GET</code> is never collapsed onto the anonymous slot.</p>

<h3>Autotune: let it read the load</h3>

<p>If you genuinely can&#8217;t be bothered to pick numbers, turn on autotune:</p>

<pre><code>cache_turbo_autotune on;</code></pre>

<p>This one grew up since the early builds. It measures how long your backend actually takes to regenerate a page, and when the origin is genuinely under load it dials <em>three</em> things: it picks <code>beta</code> from the measured cost (clamped to your preset&#8217;s band), it widens the serve-stale window so a stale entry stays serveable longer before becoming a hard miss, and it widens the single-flight <code>lock_ttl</code> so a slow regen isn&#8217;t re-claimed mid-flight. All three are bounded at four times their configured value, and the load factor it&#8217;s using is published per-zone as <code>cache_turbo_autotuned_load</code> (1000 = baseline, up to 4000). The instant your backend is no longer struggling, it snaps everything back. So a traffic spike transparently buys the cache more headroom and tighter dogpile control, and it all reverts on its own once the spike passes.</p>

<p>The one thing autotune will <strong>never</strong> touch is the <em>fresh</em> TTL. A client is never told &#8220;fresh&#8221; about content older than your <code>cache_turbo_valid</code> contract; only the best-effort stale grace and the dogpile window stretch. It recomputes every 30s off the live shared-memory stats. Watch it work by exposing <code>$cache_turbo_beta</code> as a header and watching the number drift as your backend&#8217;s mood changes. Closest this module gets to a party trick.</p>

<h3>What an nginx page cache should and shouldn&#8217;t store</h3>

<p>Before you go to production, know the safety rails, because a cache that serves the wrong person&#8217;s page is not a performance feature, it&#8217;s a data breach with good latency. cache-turbo only stores a <code>200 OK</code> to a <code>GET</code>, and it flatly refuses anything that looks per-user: a request carrying an <code>Authorization</code> header (which is also never <em>served</em> a cached copy, so an anonymously-primed page never leaks to a credentialed caller), a response that sets a cookie (<code>Set-Cookie</code>), or a response marked <code>Cache-Control: private</code>, <code>no-store</code>, <code>no-cache</code>, <code>max-age=0</code>, or <code>s-maxage=0</code>. It also honours request <code>Cache-Control</code>: <code>no-cache</code> forces a revalidation, <code>no-store</code> runs the request without storing, and <code>only-if-cached</code> answers <code>504</code> rather than touch the origin. Those signals mean a page belongs to one specific human. Someone caches a page with a logged-in username in the corner, and suddenly every visitor is &#8220;Hi, Dave.&#8221; The defaults exist so you have to go out of your way to make that mistake. Don&#8217;t go out of your way.</p>

<h2 style="color:#f59e0b">Step 6: add a shared L2 for a fleet</h2>

<p>One nginx box is happy on L1 alone. The moment you have several, you want them to share a cache so one box warming a page warms everybody, and a rebooted box refills from the shared tier instead of stampeding the origin like a cold herd. That&#8217;s L2. One line:</p>

<pre><code># plain, same box
cache_turbo_redis redis://127.0.0.1:6379/0;

# ACL user, password, db 2, remote
cache_turbo_redis redis://cache:s3cret@10.0.0.5:6379/2;

# TLS, verifying the server cert against the system CA by default
cache_turbo_redis rediss://redis.internal:6380/0;

# TLS with a private CA and an overridden verified name
cache_turbo_redis rediss://10.0.0.5:6380/0 tls_ca=/etc/ssl/redis-ca.pem tls_name=redis.internal;</code></pre>

<p>The contract that keeps it fast: write-through on store, one <code>GET</code> on an L1 miss, and it <em>never</em> touches Redis on an L1 hit. The hot path stays in local RAM; Redis only catches the misses. <code>rediss://</code> (two s&#8217;s) means TLS, verification is on by default (the correct default, leave it alone unless you can say out loud why you&#8217;re turning it off), and the password sits in your nginx config so <code>chmod 600</code> it and keep it out of git, same as every secret you&#8217;ve been tempted to commit at 2 a.m. and regretted. There&#8217;s a <code>keepalive=N</code> option to pool idle connections per worker if you want to skip the per-op reconnect. If you want a hardened Redis to point it at, our <a href="https://deb.myguard.nl/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/">Valkey package</a> is the obvious backend. (And if you&#8217;re wondering why compressing secret-adjacent responses is its own footgun, the <a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">BREACH attack</a> is the cautionary tale.)</p>

<p>Already running memcached instead of Redis? Point the L2 there with <code>cache_turbo_memcached 127.0.0.1:11211 prefix=mc:;</code> — same write-through-on-store, sync-GET-on-miss model, native client, no <code>libmemcached</code>. It&#8217;s the deliberately lean option: memcached has no sorted sets, no <code>SCAN</code> and no atomic <code>SET-NX</code>, so it can&#8217;t do tag purges, whole-keyspace purges or the cross-node single-flight lock (per-box single-flight still works), and values over its 1 MiB ceiling stay L1-only. Use Redis if you need tags or cluster-wide dogpile protection; reach for memcached if you already run one and just want a simple shared object tier. The two are mutually exclusive in the same block.</p>

<h2 style="color:#f59e0b">Step 7: wire up the admin endpoint, and lock it down</h2>

<p>You get a built-in control panel: stats, purging, and cache warming. Point a location at the zone, and optionally let it answer the <code>PURGE</code> method too:</p>

<pre><code>location = /_cache {
    cache_turbo_admin ct;
    cache_turbo_purge on;        # also accept PURGE &lt;uri&gt;
    allow 127.0.0.1;
    deny  all;
}</code></pre>

<p>Curl it for JSON stats, and purge in three flavours: one page, everything sharing a tag, or the whole zone. With <code>cache_turbo_purge on</code> you can also drop a single URL with a <code>PURGE</code> request to it directly, which beats reconstructing the cache key by hand:</p>

<pre><code>$ curl localhost/_cache
{"hits":1240,"misses":83,"stale_serves":12,"refreshes":11,"evictions":0,"l2_hits":61,"l2_misses":22,"cost_ms":34,"autotuned_beta":1700,"autotuned_load":1000}

$ curl -X POST  'localhost/_cache?key=example.com/blog/post-42'  # one entry (verbatim key)
$ curl -X PURGE 'localhost/blog/post-42'                         # ...or just PURGE the URL
$ curl -X POST  'localhost/_cache?tag=post-42'                   # everything tagged
$ curl -X POST  'localhost/_cache?all=1'                         # the nuclear option
$ curl -X POST  'localhost/_cache?url=/,/blog/,/about'           # warm cold pages</code></pre>

<p>Tag purging is the good stuff. Set <code>cache_turbo_tag</code> from a response header (your backend emits something like <code>X-Cache-Tags: post-42 author-dave category-nginx</code>) and you can invalidate every page touched by one author or one category in a single call. It needs Redis on, because the tag index lives in its sorted sets. This is the difference between &#8220;I edited a post&#8221; and &#8220;I have to flush everything and re-warm forty thousand pages.&#8221; One footnote on <code>?key=</code>: it hashes the string verbatim, so it has to equal the entry&#8217;s full cache-key value (for the default key that&#8217;s <code>&lt;host&gt;&lt;uri&gt;&lt;normalized-args&gt;</code>, e.g. <code>example.com/blog/post-42</code>), not just the path. The <code>PURGE</code> method exists precisely so you don&#8217;t have to think about that.</p>

<p>Now the part where I get loud, and it&#8217;s the one line of this whole guide I&#8217;ll state without a hedge. <strong>That <code>allow</code>/<code>deny</code> is not optional.</strong> The endpoint purges your cache and fires server-side fetches to local paths. Left public, <code>?all=1</code> is a denial-of-service button with a friendly URL, and <code>?url=</code> pointed at the wrong place is a server-side request forgery primitive someone finds with a scanner inside a week. An admin endpoint with no gate isn&#8217;t a convenience, it&#8217;s an incident waiting for a CVE number.</p>

<h2 style="color:#f59e0b">Step 8: scrape it with Prometheus</h2>

<p>The same endpoint speaks Prometheus. Add <code>?format=prometheus</code> and point a scrape at it, every sample labelled by <code>zone</code> so one job watches many zones:</p>

<pre><code>$ curl 'localhost/_cache?format=prometheus'
cache_turbo_hits_total{zone="ct"} 1240
cache_turbo_misses_total{zone="ct"} 83
cache_turbo_stale_serves_total{zone="ct"} 12
cache_turbo_refreshes_total{zone="ct"} 11
cache_turbo_evictions_total{zone="ct"} 0
cache_turbo_l2_hits_total{zone="ct"} 61
cache_turbo_l2_misses_total{zone="ct"} 22
cache_turbo_lock_waits_total{zone="ct"} 9
cache_turbo_min_uses_skips_total{zone="ct"} 4
cache_turbo_bypasses_total{zone="ct"} 5
cache_turbo_regen_cost_ms{zone="ct"} 34
cache_turbo_autotuned_beta{zone="ct"} 1700
cache_turbo_autotuned_load{zone="ct"} 1000</code></pre>

<p>The number you&#8217;ll stare at is hit ratio: <code>rate(cache_turbo_hits_total[5m]) / (rate(cache_turbo_hits_total[5m]) + rate(cache_turbo_misses_total[5m]))</code>. At 0.98 your backend is asleep and you&#8217;re winning. At 0.40 something&#8217;s wrong with your key, probably a Vary axis you forgot to split back in Step 4. Watch <code>cache_turbo_evictions_total</code> too: if it&#8217;s climbing, the zone from Step 2 is too small and the LRU is throwing out pages you wanted. The <code>l2_hits</code>/<code>l2_misses</code> pair tells you how much work Redis is saving the origin, <code>bypasses_total</code> isolates the requests you skipped to origin on purpose (a <code>cache_turbo_bypass</code> predicate or a CMS preset) from genuine misses, and <code>autotuned_load</code> climbing toward 4000 is your live &#8220;the backend is under pressure right now&#8221; gauge. There&#8217;s a ready-made <strong>Grafana dashboard</strong> shipped in <code>tools/grafana-dashboard.json</code>: import it, pick your Prometheus datasource, and you get hit ratios, L1/L2 request rates, regen cost and autotuned beta with a per-zone template variable. And gate the scrape behind the same <code>allow</code>/<code>deny</code>; your metrics are nobody else&#8217;s business.</p>

<h2 style="color:#f59e0b">Step 9: verify it actually works</h2>

<p>Reload nginx (<code>nginx -t</code> first, always, because the missing semicolon you can&#8217;t see will take the site down on reload), then curl the URL twice and grep for the header:</p>

<pre><code>$ curl -sI localhost/ | grep -i x-cache      # 1st: nothing, it was a miss
$ curl -sI localhost/ | grep -i x-cache
X-Cache: HIT                                  # 2nd: served from RAM</code></pre>

<p>That header is your whole debugging story. <code>HIT</code> means fresh from cache. <code>STALE</code> means an old copy while a refresh runs in the background. No header at all means it went to the backend. When someone swears the cache &#8220;isn&#8217;t working,&#8221; this settles the argument in one curl. (If you&#8217;d rather not advertise cache state to the public, strip the <code>X-Cache</code> header downstream with the standard nginx header tooling; the RFC-meaningful <code>Age</code> still goes out.) For logs rather than curl, drop <code>$cache_turbo_status</code> into a <code>log_format</code> — it records <code>HIT</code>, <code>STALE</code>, <code>MISS</code>, <code>BYPASS</code> (a <code>cache_turbo_bypass</code> or CMS-preset skip) or <code>EXPIRED</code> per request, so you can chart cache outcomes straight from the access log.</p>

<h3>Optional: stack it over nginx&#8217;s disk cache</h3>

<p>Remember L1 holds your hot set, not the whole site? Here&#8217;s where that resolves. You can run cache-turbo <em>and</em> nginx&#8217;s built-in <a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_cache" rel="noopener" target="_blank"><code>proxy_cache</code></a> together, because they sit at different layers. cache-turbo runs in the ACCESS phase in shared memory; <code>proxy_cache</code> runs later, in the content phase, on disk. On a cache-turbo hit the request finalizes before it ever reaches <code>proxy_pass</code>, so the disk cache never runs. On a miss it flows through <code>proxy_cache</code> as usual and cache-turbo captures whatever comes back into shm. So cache-turbo becomes an L0 over the disk L1:</p>

<pre><code>location / {
    cache_turbo       ct;
    cache_turbo_valid 30s;
    proxy_cache       disk;
    proxy_cache_valid 200 10m;
    proxy_pass        http://app;
}</code></pre>

<p>Two things bite if you stack carelessly. The caches store and purge independently (same page can live in shm and on disk, and purging one ignores the other), so keep the disk TTL at or above the shm TTL. And cache-turbo strips the disk cache&#8217;s <code>Age</code>, <code>X-Cache</code> and <code>X-Cache-Status</code> before storing, so an L1 hit never replays a frozen age; cache-turbo&#8217;s own <code>X-Cache</code> is the source of truth, read <code>$upstream_cache_status</code> for the disk layer&#8217;s opinion. Rule of thumb: don&#8217;t double-cache the same content. shm for hot HTML that benefits from SWR, Redis and tag purge; disk for a huge corpus that won&#8217;t fit in RAM.</p>

<h2 style="color:#f59e0b">So how fast is it, really?</h2>

<p>Numbers, because &#8220;it&#8217;s fast, trust me&#8221; is what every README says. The repo ships a benchmark harness (<code>tools/bench.sh</code>) that stands up one nginx with four edges sharing the same origin and payloads — no cache at all, nginx&#8217;s own <code>proxy_cache</code>, cache-turbo in RAM, and cache-turbo with a Redis tier — primes each so it&#8217;s actually serving from cache, then hammers it with <code>wrk</code> and checks the hit ratio is genuinely 100% before believing a single number. Here&#8217;s a stock-nginx build serving cached pages, requests per second (higher is better):</p>

<pre><code>payload     no cache     proxy_cache   cache-turbo (RAM)
tiny 200B   23,000       493,000       605,000   (+23% over proxy_cache)
med  200KB  14,700        41,400        56,800   (+37%)
large 4MB    1,000         2,510         2,750   (+10%)</code></pre>

<p>Two separate wins hide in that table. First, <strong>caching at all</strong> is the giant one: on a small page, 23k requests/sec without a cache becomes 600k with one, because a hit skips the entire backend round-trip. That&#8217;s the 20-to-25× that makes this whole exercise worth it, and you get it from <code>proxy_cache</code> too. Second, cache-turbo beats nginx&#8217;s own disk cache by <strong>23–37%</strong> on small and medium pages, with lower median <em>and</em> tail latency, because a shared-memory hit never touches the disk. The edge shrinks to ~10% on a 4MB body — at that size the wall-clock is dominated by copying four megabytes out the socket, which every contender pays equally, so the cache machinery stops being the bottleneck. The Redis tier serves at the same speed as plain RAM, by design: an L1 hit never calls Redis, so the L2 only earns its keep when a <em>different</em> box needs the page.</p>

<p>Fair-play caveat, because benchmarks lie by omission: these are loopback, single-box, best-case (100% hit, one hot key) numbers. Treat the <em>gaps between the columns</em> as the real signal, not the absolute rps — your network, TLS and traffic mix will move every number, but the shape holds. Run <code>tools/bench.sh</code> on your own box if you want your own truth; that&#8217;s what it&#8217;s there for.</p>

<h3>cache-turbo vs proxy_cache: when to pick which</h3>

<p>Speed isn&#8217;t the whole story, and nginx&#8217;s built-in <code>proxy_cache</code> is a fine, battle-tested cache. Here&#8217;s the honest split.</p>

<p><strong>Where cache-turbo wins:</strong></p>
<ul>
<li><strong>It&#8217;s faster</strong>, and it serves in the access phase from RAM, so it can sit as an L0 <em>in front of</em> <code>proxy_cache</code> rather than replace it.</li>
<li><strong>Stale-while-revalidate and stale-if-error are built in and on by default</strong> — the resilience from the last two sections. <code>proxy_cache</code> can serve stale, but only after you wrestle <code>proxy_cache_use_stale</code> and friends into shape.</li>
<li><strong>Dogpile protection that crosses machines.</strong> <code>proxy_cache_lock</code> collapses a stampede per box; cache-turbo&#8217;s single-flight also coordinates a whole fleet through the Redis lock.</li>
<li><strong>A shared L2.</strong> A cluster of nginx boxes share one Redis/memcached cache, so one box warming a page warms them all. <code>proxy_cache</code> is per-box disk, every node cold on its own.</li>
<li><strong>One cache for every upstream.</strong> The same directives microcache a <code>fastcgi_pass</code> PHP-FPM app <em>and</em> a <code>proxy_pass</code> API — stock nginx makes you run <code>fastcgi_cache</code> and <code>proxy_cache</code> as two separate systems with two separate configs. (Caching dynamic and PHP-FPM responses isn&#8217;t the win here — nginx does that fine — the unified config and the single-flight that makes a 1-second TTL safe is.)</li>
<li><strong>Operational extras:</strong> tag-based purging, auto-Vary, CMS auto-classify, and a JSON + Prometheus admin endpoint baked in.</li>
</ul>

<p><strong>Where proxy_cache still wins:</strong></p>
<ul>
<li><strong>It&#8217;s already in nginx.</strong> Nothing to compile or package; cache-turbo is a third-party module you build or install (prebuilt here, but still a moving part).</li>
<li><strong>Its cache is on disk</strong>, so it survives a reload or restart and holds far more than fits in RAM. cache-turbo&#8217;s L1 is shared memory, cleared on every reload — the Redis L2 softens that, but it&#8217;s another service to run.</li>
<li><strong>A huge cold corpus</strong> — big media, a long-tail archive — belongs on disk. Don&#8217;t pin gigabytes of rarely-touched files in shared memory.</li>
<li><strong>Maturity.</strong> <code>proxy_cache</code> has a decade of every weird edge case shaken out of it.</li>
</ul>

<p>Short version: hot HTML and dynamic apps lean cache-turbo; a giant on-disk archive leans <code>proxy_cache</code>; and when in doubt, stack them — cache-turbo for the hot set in RAM, <code>proxy_cache</code> as the deep disk tier behind it.</p>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I still need Varnish if I run cache-turbo?</h3>
<div class="rank-math-answer ">

<p>For most sites, no. Varnish is a separate daemon on a separate port with its own config language (VCL) and its own process to monitor and restart. cache-turbo gives you the same in-memory page cache plus stale-while-revalidate and a Redis or memcached tier, inside nginx itself. Reach for Varnish only if you need VCL&#8217;s full request-mangling power or an edge tier fully decoupled from your web server.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">What is stale-while-revalidate, in one sentence?</h3>
<div class="rank-math-answer ">

<p>When a cached page passes its freshness TTL, cache-turbo keeps serving the old copy immediately while exactly one background request fetches a fresh one, so visitors never wait on a refresh and the backend never gets stampeded by a thundering herd.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">What is microcaching and when should I use it?</h3>
<div class="rank-math-answer ">

<p>Microcaching is a deliberately tiny cache TTL, about one second, on otherwise-dynamic endpoints like a JSON API or a PHP app. The data stays near-real-time, but a burst of requests inside that second is served from RAM and the backend is hit roughly once. cache-turbo ships it as the &#8216;micro&#8217; preset (valid 1s, lock_ttl 1s, ×2 stale window); it drops backend load from &#8216;every request&#8217; to &#8216;about one per endpoint per second&#8217; with content at most a second or two stale.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Will it ever cache a logged-in user&#8217;s page and serve it to someone else?</h3>
<div class="rank-math-answer ">

<p>Not by default, and you have to work to break that. cache-turbo only caches a 200 OK to a GET, and refuses any response to a request that carried an Authorization header (such a request is never served a cached copy either), any response that sets a cookie, and any response marked Cache-Control: private, no-store, no-cache, max-age=0, or s-maxage=0. For cookie-session apps add cache_turbo_no_store on the session cookie. The danger only appears if you override those defaults or use a cache key that ignores a real Vary axis.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I have to run Redis to use it?</h3>
<div class="rank-math-answer ">

<p>No. L1, the per-box shared-memory cache, is always on and needs nothing extra. Redis (or memcached) is an optional L2 tier that lets a fleet of nginx boxes share one cache and survive reboots without stampeding the origin. Redis is also required for tag-based purging and the cross-node lock; memcached is the leaner option without those. A single server is perfectly happy on L1 alone.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">How is this different from nginx&#8217;s built-in proxy_cache?</h3>
<div class="rank-math-answer ">

<p>proxy_cache is a disk cache in the content phase. cache-turbo is a shared-memory cache in the access phase, with stale-while-revalidate, single-flight refresh, an optional Redis or memcached tier, auto-Vary and tag-based purging. They stack: cache-turbo becomes an L0 in front of proxy_cache&#8217;s on-disk L1. Use cache-turbo for hot HTML that benefits from RAM speed and SWR; use proxy_cache for a large on-disk corpus that won&#8217;t fit in memory.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">Why must I lock down the admin endpoint?</h3>
<div class="rank-math-answer ">

<p>Because it purges your cache and fires server-side fetches to local paths. Left public, POST /_cache?all=1 is a one-line denial-of-service against your own cache, and the ?url= warming verb is a server-side request forgery primitive. Always gate the admin (and Prometheus) location with allow/deny or authentication. Never expose it to the internet.</p>

</div>
</div>
<div id="rm-faq-8" class="rank-math-list-item">
<h3 class="rank-math-question ">Is cache-turbo faster than nginx&#8217;s built-in proxy_cache?</h3>
<div class="rank-math-answer ">

<p>On the repo&#8217;s loopback benchmark, yes: about 23% more requests per second on small pages and 37% on medium ones, with lower median and tail latency, because a shared-memory hit never touches disk. The gap narrows to roughly 10% on multi-megabyte bodies, where the time goes to copying the bytes out the socket rather than to cache lookup. Both are vastly faster than running no cache at all (20 to 25 times). The numbers are best-case single-box figures; run the bundled tools/bench.sh to measure on your own hardware.</p>

</div>
</div>
<div id="rm-faq-9" class="rank-math-list-item">
<h3 class="rank-math-question ">What happens to cached pages if PHP-FPM or my backend returns a 5xx?</h3>
<div class="rank-math-answer ">

<p>For any page cache-turbo already holds, the visitor is shielded. Stale-while-revalidate means a page past its TTL is served from the old copy while one background request refreshes it; if that refresh returns 500/502/503/504 or times out, the failed response never overwrites the good copy and the visitor never sees the error. This stale-if-error behaviour is automatic. To extend it beyond the normal stale window, have the origin send Cache-Control: stale-if-error=600 and cache-turbo will keep serving the stale copy through up to ten minutes of backend 5xxs, marked X-Cache: STALE-IF-ERROR. The one thing it can&#8217;t do is shield a page it has never cached, so warm critical URLs ahead of time.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related reading</h2>

<ul>
<li><a href="https://deb.myguard.nl/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/">Valkey explained: the Redis fork that actually won</a>: the L2 tier&#8217;s natural backend, and why we package it hardened.</li>
<li><a href="https://deb.myguard.nl/2026/05/what-is-zstd-nginx-angie-browser-support/">What is zstd? nginx, Angie, history and browser support</a>: the encoding the Step 4 vary bucket ranks above brotli.</li>
<li><a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">What is the BREACH attack?</a>: why compressing secret-adjacent responses is its own footgun.</li>
<li><a href="https://deb.myguard.nl/2026/05/database-boost-free-wordpress-database-optimization-plugin/">Database Boost</a>: the other end of the slow-backend problem, on the database side.</li>
</ul>

<p>One last thing, because it&#8217;s the mistake everyone makes once. Before you ship <code>cache_turbo_preset aggressive</code> to production, set a short <code>cache_turbo_valid</code> and watch your <code>X-Cache</code> headers and eviction counter for an afternoon. The cache that serves stale content for five minutes because you fat-fingered a TTL is the cache that gets blamed for a bug that doesn&#8217;t exist. Ask me how I know.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Auto-Ban Abusive Clients in NGINX with the error-abuse module</title>
		<link>https://deb.myguard.nl/2026/06/auto-ban-abusive-clients-in-nginx-with-the-error-abuse-module/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Mon, 08 Jun 2026 22:48:29 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[bot-detection]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[nginx-module]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[rate-limiting]]></category>
		<category><![CDATA[redis]]></category>
		<category><![CDATA[security]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6299</guid>

					<description><![CDATA[A single misbehaving scraper can fire 40,000 requests an hour at a 404 it will never stop hitting, and your access log&#8230;]]></description>
										<content:encoded><![CDATA[<p>A single misbehaving scraper can fire 40,000 requests an hour at a 404 it will never stop hitting, and your access log will dutifully record every one. Most of us notice somewhere around the third pager alert, usually at 3 a.m., usually after the bot has already walked your entire <code>/wp-login.php</code> guessing routine. The annoying part isn&#8217;t the traffic. It&#8217;s that the server <em>knew</em> every one of those requests was garbage the instant it returned the error code, and did absolutely nothing with that knowledge.</p>

<p>That gap is exactly what the <strong>nginx-error-abuse-module</strong> closes. It&#8217;s a dynamic NGINX module, written in C, that watches the status codes your server hands back and temporarily blocks any client generating too many errors. A hundred errors in five minutes? Gone for an hour. Think fail2ban, except it lives inside the worker process instead of tailing a log file from the outside, and it makes its decision before the next request ever reaches your application.</p>

<p>Source is on GitHub: <a href="https://github.com/eilandert/nginx-error-abuse-module" target="_blank" rel="noopener">github.com/eilandert/nginx-error-abuse-module</a>. No Lua, no njs, no sidecar daemon. Let&#8217;s get into what it does, why you&#8217;d want it, and the three or four places it&#8217;ll bite you if you&#8217;re careless.</p>

<h2 style="color:#f59e0b">What the error-abuse module actually does</h2>

<p>Here&#8217;s the whole idea in one sentence: you tell it which status codes count as abuse, how many a client gets in a time window, and how long to lock them out when they cross the line.</p>

<p>A client trips the threshold, and from that point every request gets a <code>429 Too Many Requests</code> (or whatever status you pick) until the block expires. The client&#8217;s own blocked requests don&#8217;t count against them, so a banned bot hammering your door doesn&#8217;t extend its own sentence. That detail matters more than it sounds. Get it wrong and an aggressive scraper renews its ban forever, which is either a feature or a footgun depending on how much you enjoy clients that can never recover.</p>

<p>The codes you watch are yours to choose. A list, a range, or both: <code>403,404,429,500-599</code> is a perfectly normal config. Watching the 5xx range is the interesting one, because a flood of 500s usually means somebody found the one URL that makes your backend cry, and you&#8217;d rather not let them keep poking it while you figure out why.</p>

<p>Counting happens in an NGINX shared-memory zone, so every worker sees the same tally. The bot doesn&#8217;t get its allowance <em>per worker</em>. It gets one budget, total, across the whole server. And because the module sits in the response phase, the cost for normal traffic is close to nothing. A 200 response barely touches it. It only does real work when something actually errored.</p>

<h2 style="color:#f59e0b">Why not just fail2ban or limit_req?</h2>

<p>Fair question. You&#8217;ve probably got both already. Here&#8217;s the honest comparison, scars included.</p>

<p>Fail2ban works by reading your logs. Something has to write the log line, fail2ban has to read it, parse it with a regex, decide, then shell out to <code>iptables</code> or <code>nftables</code> to install a rule. That whole pipeline has latency measured in seconds, and it depends on a second process staying alive, your log format not drifting, and the regex still matching after the next NGINX upgrade quietly renames a field. I&#8217;ve watched fail2ban silently stop banning anything for three weeks because a log format tweak broke one capture group. Nobody noticed until the bill from the bandwidth showed up.</p>

<p>The error-abuse module makes the decision in-process, in microseconds, with no log round-trip and no firewall syscall. The state lives in shared memory the worker already has mapped. There&#8217;s no second daemon to babysit.</p>

<p>And <code>limit_req</code>? Great tool, wrong job. It throttles request <em>rate</em>, full stop. It doesn&#8217;t know or care whether those requests succeeded. A client politely fetching your sitemap at 10 req/s looks identical, to <code>limit_req</code>, to a client brute-forcing logins at 10 req/s. The error-abuse module only cares about the ones that failed. That&#8217;s the distinction: <code>limit_req</code> rations everyone, error-abuse punishes the clients that are obviously up to no good and leaves your legitimate fast traffic alone.</p>

<p>Use all three. They don&#8217;t overlap. <code>limit_req</code> caps the firehose, the error-abuse module catches the error-storming scanners, and fail2ban can still mop up at the network layer for the stuff that never reaches NGINX at all. If you&#8217;re already running a WAF, this pairs nicely with it too. See our guide on <a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">installing ModSecurity and the OWASP CRS on NGINX</a> for the layer that inspects request <em>content</em> rather than counting failures.</p>

<h2 style="color:#f59e0b">The five-minute config</h2>

<p>Load the module, declare a Redis endpoint if you want one (optional, skip it for now), declare a zone, then switch it on in a location. Here&#8217;s a working setup with every knob labelled.</p>

<pre><code>load_module modules/ngx_http_error_abuse_module.so;

http {
    error_abuse_zone zone=client_errors:10m
                     key=$binary_remote_addr
                     statuses=403,404,500-599
                     interval=300s
                     threshold=100
                     block=60m
                     inactive=1h;

    log_format main '$remote_addr "$request" $status '
                    'error_abuse=$error_abuse_status '
                    'count=$error_abuse_count';

    server {
        location / {
            error_abuse zone=client_errors status=429;
        }
    }
}</code></pre>

<p>Read it out loud and it explains itself. A client identified by IP (<code>$binary_remote_addr</code>) gets <code>threshold=100</code> matching errors inside a rolling <code>interval=300s</code> (five minutes) before it earns a <code>block=60m</code> timeout. The <code>10m</code> after the zone name is the shared-memory size, not a duration, which trips up everyone exactly once. The <code>inactive=1h</code> tells the module to forget about a key after an hour of silence so the zone doesn&#8217;t fill up with one-time visitors.</p>

<p><code>nginx -t</code> before you reload. Always. The one time you skip it is the time you fat-fingered the zone size and took the vhost down on a Friday afternoon.</p>

<h2 style="color:#f59e0b">The sliding window, and why it matters</h2>

<p>Most naive rate limiters use a fixed window. Reset the counter every ten seconds on the clock, count until it resets. The problem is the boundary. A client can fire five requests in the last half-second of one window and five more in the first half-second of the next, and slip ten requests through a &#8220;five per ten seconds&#8221; rule without ever tripping it. Attackers know this. It has a name, the fixed-window boundary problem, and it&#8217;s exactly the kind of thing a determined scanner probes for.</p>

<p>The error-abuse module uses an <em>exact</em> sliding window instead. The interval moves with the client. Every request looks back across the real previous <code>interval</code> seconds, not at an arbitrary clock-aligned bucket. There&#8217;s no seam to exploit. It costs a little more bookkeeping per key, which is why <code>threshold</code> is capped at 1024: the per-key memory stays bounded no matter how clever you get with the numbers.</p>

<p>One thing to internalise. The window, the threshold, and the block are independent. <code>interval</code> is how far back it looks. <code>threshold</code> is how many errors fit in that look-back before the hammer drops. <code>block</code> is how long the hammer stays down, and it has nothing to do with the interval. You can watch a tight 10-second window but block for a full day. Tune them separately to taste.</p>

<h2 style="color:#f59e0b">Picking the key: IP, account, or something smarter</h2>

<p>By default you key on the client IP, and for most setups that&#8217;s the right answer. But the key is just an NGINX variable, which means it can be anything the request gives you. An authenticated API? Key on the account ID and a single abusive user can&#8217;t dodge the ban by bouncing across a /16 of cloud IPs. A tenant identifier, a session token, a hash of something. Your call.</p>

<p>Now the part that pages people. <strong>If you run behind a CDN or a load balancer, every request arrives wearing the proxy&#8217;s IP, not the client&#8217;s.</strong> Key on <code>$binary_remote_addr</code> naively in that setup and you&#8217;ll either ban your own load balancer (taking out everyone at once) or never ban anyone, because all the traffic shares one address. Neither is the outcome you wanted.</p>

<p>The fix is the standard <code>ngx_http_realip_module</code>, configured to trust <em>only</em> your actual proxies:</p>

<pre><code>set_real_ip_from 10.0.0.0/8;
set_real_ip_from 2001:db8::/32;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

error_abuse_zone zone=client:10m
                 key=$binary_remote_addr
                 statuses=404 interval=30s threshold=10 block=15m;</code></pre>

<p>With realip set up correctly, <code>$binary_remote_addr</code> resolves to the genuine client again and everything works. What you must never do is reach for the raw <code>X-Forwarded-For</code> header as your key without realip in front of it. That header is client-supplied. An attacker just sticks a fresh fake IP in it on every request and your ban list fills with ghosts while the real bot strolls through. Trust the header only from sources you control.</p>

<p>For allowlists, there&#8217;s an elegant trick: the module ignores empty keys entirely. So a <code>map</code> that returns an empty string for the addresses you trust skips them with zero overhead, no special directive needed.</p>

<pre><code>map $remote_addr $error_abuse_key {
    127.0.0.1   "";
    10.0.0.0/8  "";
    default     $binary_remote_addr;
}

error_abuse_zone zone=external:10m
                 key=$error_abuse_key
                 statuses=403,404 interval=30s threshold=10 block=15m;</code></pre>

<p>Localhost and your internal range now sail past untouched. Health checks from your monitoring stop accidentally banning themselves. Everyone else gets counted.</p>

<h2 style="color:#f59e0b">Surviving a restart: disk persistence</h2>

<p>Out of the box, counters live in shared memory and survive a graceful reload (<code>nginx -s reload</code>) automatically. The module hands state across to the new workers. Good.</p>

<p>What a graceful reload does <em>not</em> cover is a full stop and start, or a host reboot, or the OOM killer waking up and shooting a worker in the head. Shared memory evaporates, and every ban resets to zero. The bot you locked out two minutes ago gets a clean slate the moment systemd restarts the service after your kernel upgrade.</p>

<p>Add <code>persist=</code> and the module snapshots state to disk:</p>

<pre><code>error_abuse_zone zone=client_errors:10m
                 key=$binary_remote_addr
                 statuses=403,404,500-599
                 interval=300s threshold=100 block=60m
                 persist=/var/lib/nginx/error-abuse-client_errors.state
                 persist_interval=5s;</code></pre>

<p>The state directory has to exist and be writable by the worker user before NGINX starts. The module won&#8217;t create it for you, and it won&#8217;t be subtle about complaining. Each persistent zone needs its own file. The snapshot writes atomically every <code>persist_interval</code> (5 seconds by default), so a crash loses at most a few seconds of counting. Graceful shutdown writes one final snapshot on the way out.</p>

<p>Every snapshot carries a CRC32 checksum. If the file is corrupt, half-written from a power cut, or scribbled on by a bad disk, the module spots the bad checksum, deletes the file, logs the error, and starts clean rather than loading garbage state. The format is versioned, binary, and deliberately local. It is not a sync mechanism between hosts, just one server&#8217;s memory of who it was annoyed at. For sharing bans across machines, you want the next section.</p>

<h2 style="color:#f59e0b">Redis: one ban list for your whole fleet</h2>

<p>Run more than one NGINX box behind a balancer and you&#8217;ve got a coordination problem. A scanner trips the threshold on node A, gets banned there, and the load balancer cheerfully sends its next request to node B, which has never heard of it and starts counting from zero. The attacker effectively multiplies their allowance by your number of front-ends. Embarrassing.</p>

<p>Point the module at a shared Redis and the ban becomes fleet-wide:</p>

<pre><code>http {
    error_abuse_redis host=127.0.0.1 port=6379
                      prefix=ea_ timeout=100ms;

    error_abuse_zone zone=client_errors:10m
                     key=$binary_remote_addr
                     statuses=403,404,500-599
                     interval=300s threshold=100 block=60m
                     redis=on;
}</code></pre>

<p>Every host using the same prefix and the same zone settings now shares one counter. Ban on node A, banned everywhere, instantly. The I/O is asynchronous: block lookups pause that one request&#8217;s processing without blocking the worker, and matching responses queue an atomic sliding-window update. Each worker holds a single Redis connection, not one per request. It speaks plain RESP, so a <a href="https://deb.myguard.nl/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/">Valkey</a> server works exactly the same as Redis. The default key prefix is <code>ea_</code>, and every host in a zone has to agree on it.</p>

<h3>Locking down the Redis link: TLS, AUTH, and a separate DB</h3>

<p>That shared Redis is also a shared liability, which is the next section&#8217;s whole point, so the module gives you the three knobs you actually need to harden the link. You don&#8217;t have to settle for a plaintext connection to an open port and hope nobody&#8217;s listening.</p>

<pre><code>error_abuse_redis host=tls://redis.internal port=6380
                  user=erroruser password=secret
                  db=3 prefix=ea_ timeout=100ms;</code></pre>

<p>Three things changed there, each optional. The <code>tls://</code> prefix on the host (or <code>rediss://</code>, same thing) wraps the connection in TLS, with the server certificate verified against the system CA store at <code>/etc/ssl/certs</code> and checked against the hostname. That needs <code>libhiredis_ssl</code> at build and run time, and note one current limitation: a self-signed server cert won&#8217;t pass, because there&#8217;s no <code>cacert=</code> override yet. The <code>user=</code> and <code>password=</code> pair does Redis 6+ ACL authentication (a bare <code>password=</code> on its own sends a legacy <code>AUTH</code>); leave both off and it connects unauthenticated, same as before. And <code>db=3</code> issues a <code>SELECT</code> so the module&#8217;s keys live in their own logical database instead of squatting in DB 0 next to whatever else you cache there.</p>

<p>The critical design choice here is what happens when Redis falls over. Because Redis always falls over eventually, usually during the incident you can least afford it. The module is <strong>fail-open</strong>. Redis unreachable means the local shared-memory zone keeps right on enforcing its own counters. You lose the cross-host coordination, you don&#8217;t lose protection, and you definitely don&#8217;t take an outage on your web tier because a cache node hiccuped.</p>

<p>It goes one better with a circuit breaker. After five consecutive Redis failures the module stops trying for thirty seconds, then tests the water again. Without that, every single request would keep flogging a dead Redis, filling your error log with thousands of identical timeout lines and burning syscalls on a connection that isn&#8217;t coming back. The breaker means one terse log line instead of a denial-of-service against your own logging. Anyone who&#8217;s watched a log partition fill to 100% during an outage, then watched the outage get worse <em>because</em> the disk filled, knows precisely why this matters.</p>

<p>One honest caveat, and the README is upfront about it: when <code>redis=on</code>, your Redis server becomes a trust boundary. A compromised Redis could inject fake bans or fiddle the counters. So keep it on a trusted network, firewall it, turn on the <code>tls://</code> transport and the <code>user=</code>/<code>password=</code> ACL from the section above, and don&#8217;t expose it to the same internet you&#8217;re trying to defend against. If you&#8217;re hardening the box it runs on, our <a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker hardening guide</a> covers locking down the surrounding container.</p>

<h2 style="color:#f59e0b">Operating it without breaking prod</h2>

<p>Never deploy a blocking rule straight to live traffic. You will get the threshold wrong on the first try, lock out something legitimate, and learn about it from an angry customer instead of a graph. So the module ships a dry-run mode.</p>

<pre><code>location / {
    error_abuse zone=client_errors dry_run=on log_level=warn;
}</code></pre>

<p>In dry-run the module does all its counting and decision-making and logs exactly who it <em>would</em> have blocked, while letting every request through untouched. Run it for a day. Read the logs. Find out that your threshold of 5 would have banned the Googlebot crawling a section of stale 404s, raise it, and only then flip dry-run off. This is the difference between a tool that protects you and a tool that becomes the outage.</p>

<p>It exposes three log variables so you can actually see what it&#8217;s doing:</p>

<ul>
<li><code>$error_abuse_status</code> resolves to <code>BYPASSED</code>, <code>PASSED</code>, <code>COUNTED</code>, <code>BLOCKED</code>, or <code>DRY_RUN</code>. One word that tells you the decision for this request.</li>
<li><code>$error_abuse_count</code> is how many matching errors are currently sitting in this client&#8217;s sliding window. Watch it climb.</li>
<li><code>$error_abuse_blocked_until</code> is the Unix timestamp the block lifts, or <code>0</code> if the client is clean.</li>
</ul>

<p>Put them in your <code>log_format</code> and your access log becomes a live feed of the module&#8217;s reasoning. Grep for <code>BLOCKED</code> to see who&#8217;s currently in the penalty box. Grep for <code>DRY_RUN</code> during a trial run to size your threshold before it&#8217;s load-bearing.</p>

<figure class="wp-block-image size-large"><img decoding="async" width="1000" height="560" src="https://deb.myguard.nl/wp-content/uploads/2026/06/nginx-error-abuse-decision-flow.webp" alt="How the nginx error-abuse module judges a request: PASSED, COUNTED, BLOCKED, BYPASSED, DRY_RUN" class="wp-image-6303" srcset="https://deb.myguard.nl/wp-content/uploads/2026/06/nginx-error-abuse-decision-flow.webp 1000w, https://deb.myguard.nl/wp-content/uploads/2026/06/nginx-error-abuse-decision-flow-300x168.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/06/nginx-error-abuse-decision-flow-768x430.webp 768w" sizes="(max-width: 1000px) 100vw, 1000px" /><figcaption class="wp-element-caption">Every response lands in one of five states, and $error_abuse_status tells you which.</figcaption></figure>

<p>A couple of operational truths worth tattooing somewhere. Shared-memory exhaustion is fail-open: if the zone fills, the request is served and an error logged, never dropped. A zone&#8217;s <code>key</code> and <code>threshold</code> can&#8217;t change across a graceful reload, so when you need to change either, declare a new zone name instead of editing the old one in place. And subrequests don&#8217;t count, nor do responses the module itself generates, which is what stops a banned client from extending its own ban into eternity.</p>

<h2 style="color:#f59e0b">Building and installing it</h2>

<p>It&#8217;s a standard dynamic module. You need <code>libhiredis</code> for the Redis support, even if you never turn Redis on, because it links at build time regardless.</p>

<pre><code>apt-get install libhiredis-dev

./configure --with-compat \
    --add-dynamic-module=/path/to/nginx-error-abuse-module
make modules</code></pre>

<p>The <code>--with-compat</code> flag is what lets the resulting <code>.so</code> load into a binary-compatible NGINX you didn&#8217;t compile yourself, which is almost certainly the one your distro shipped. Drop <code>objs/ngx_http_error_abuse_module.so</code> into your modules directory, add the <code>load_module</code> line, and you&#8217;re off.</p>

<p>If compiling modules by hand isn&#8217;t your idea of a good evening, that&#8217;s rather the point of this whole site. The module is part of our prebuilt NGINX packages and Docker images, so you can <code>apt install</code> it alongside the rest of the stack instead of wrestling a build tree. Browse the full set on the <a href="https://deb.myguard.nl/nginx-modules/">NGINX modules page</a>. The repo also carries a GitHub Actions matrix that runs the test suite under Valgrind and CodeQL on every push, because a security module that leaks memory or carries its own vulnerabilities is just a different shape of the problem you were trying to solve.</p>

<h2 style="color:#f59e0b">Frequently asked questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-fail2ban" class="rank-math-list-item">
<h3 class="rank-math-question ">Does the error-abuse module replace fail2ban?</h3>
<div class="rank-math-answer ">

<p>No, and you should not make it. They work at different layers. The module decides in-process, in microseconds, from HTTP status codes, and only protects traffic that reaches NGINX. fail2ban works at the firewall from log data and can block traffic before it touches your web tier, including non-HTTP services like SSH. Run both: the module is faster and more precise for HTTP error storms, fail2ban is broader and lives at the network edge.</p>

</div>
</div>
<div id="rm-faq-legit" class="rank-math-list-item">
<h3 class="rank-math-question ">Will it block legitimate users who hit a few 404s?</h3>
<div class="rank-math-answer ">

<p>Only if you set the threshold too low. A real user clicking a couple of dead links will not trip a sensible <code>threshold=10, interval=30s</code> rule. The safe way to find your numbers is <code>dry_run=on</code>: run it for a day, read the logs, see who would have been blocked, then enforce. Allowlist your monitoring and internal ranges with an empty-key <code>map</code> so health checks never count against themselves.</p>

</div>
</div>
<div id="rm-faq-redis-down" class="rank-math-list-item">
<h3 class="rank-math-question ">What happens to my site if Redis goes down?</h3>
<div class="rank-math-answer ">

<p>Nothing bad. The module is fail-open. If Redis is unreachable, each NGINX host falls back to its own local shared-memory counters and keeps enforcing bans, just without cross-host coordination. A circuit breaker suspends Redis attempts after five consecutive failures so a dead cache does not flood your logs or waste syscalls. Your site stays up and you temporarily lose only the fleet-wide sharing.</p>

</div>
</div>
<div id="rm-faq-restart" class="rank-math-list-item">
<h3 class="rank-math-question ">Do the bans survive an NGINX restart or reboot?</h3>
<div class="rank-math-answer ">

<p>A graceful reload preserves state automatically. A full stop/start or reboot wipes shared memory unless you configure <code>persist=</code>, which snapshots state to a CRC32-checksummed file every few seconds and reloads it on startup. With <code>redis=on</code>, active bans live in Redis and survive a restart of any single node regardless of local persistence.</p>

</div>
</div>
<div id="rm-faq-key" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I use something other than IP address as the key?</h3>
<div class="rank-math-answer ">

<p>Yes. The key is any NGINX variable, so you can key on an account ID, a session token, a tenant identifier, or anything else in the request. Behind a CDN or load balancer, configure <code>ngx_http_realip_module</code> to trust only your proxies and keep <code>$binary_remote_addr</code> as the key, so it resolves to the real client. Never key on a raw, untrusted <code>X-Forwarded-For</code> header: it is client-supplied and trivially spoofed.</p>

</div>
</div>
<div id="rm-faq-overhead" class="rank-math-list-item">
<h3 class="rank-math-question ">Does it slow down normal traffic?</h3>
<div class="rank-math-answer ">

<p>Barely measurably. The module is optimised for the common case: a successful, non-matching response does almost no work, because the expensive bookkeeping only runs when a request returns one of your watched error codes. The counters live in shared memory the worker already has mapped, with no log round-trip and no firewall syscall on the hot path.</p>

</div>
</div>
<div id="rm-faq-secure-redis" class="rank-math-list-item">
<h3 class="rank-math-question ">How do I secure the Redis connection?</h3>
<div class="rank-math-answer ">

<p>Three optional knobs on <code>error_abuse_redis</code>. Prefix the host with <code>tls://</code> (or <code>rediss://</code>) to wrap the link in TLS, verified against the system CA store and hostname (needs <code>libhiredis_ssl</code>; self-signed certs are not supported yet). Add <code>user=</code> and <code>password=</code> for Redis 6+ ACL authentication, or just <code>password=</code> for a legacy AUTH. Use <code>db=N</code> to isolate the module keys in their own logical database. A Valkey server works identically since it speaks the same RESP protocol.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related reading</h2>
<ul>
<li><a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a> — the content-inspecting WAF layer that pairs with error-counting.</li>
<li><a href="https://deb.myguard.nl/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/">Valkey Explained: The Redis Fork That Actually Won</a> — the cache engine behind cluster-wide bans.</li>
<li><a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">What Is the BREACH Attack?</a> — another HTTPS-layer threat and how to shut it down.</li>
<li><a href="https://deb.myguard.nl/nginx-modules/">NGINX Modules, optimized &amp; extended</a> — the full set of prebuilt modules in our APT repo.</li>
</ul>

<p>Anyway. Set <code>dry_run=on</code> first, watch the logs for a day, and only then let it start swinging. The bot that&#8217;s been hammering your 404s since last Tuesday isn&#8217;t going anywhere.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>KAM.cf in Rspamd: 3,200 SpamAssassin Rules, Native Lua, No Perl</title>
		<link>https://deb.myguard.nl/2026/06/kam-cf-rspamd-lua-converter/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Mon, 08 Jun 2026 21:28:43 +0000</pubDate>
				<category><![CDATA[Antispam]]></category>
		<category><![CDATA[Mail]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[mail]]></category>
		<category><![CDATA[open-source]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[rspamd]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[spamassassin]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6293</guid>

					<description><![CDATA[KAM.cf is 3,200+ SpamAssassin rules. Loading it through Rspamd compat mode drags the whole Perl engine along. Here is the converter that transpiles it to native Rspamd Lua instead.]]></description>
										<content:encoded><![CDATA[<p>KAM.cf is roughly 6,500 lines of SpamAssassin rules, and on a busy mail server SpamAssassin will happily burn 2 to 4 CPU cores chewing through them in Perl while a message sits in the queue going nowhere. Rspamd does the same work in C and Lua and barely notices. So the obvious move is to run KAM.cf on Rspamd. The catch nobody tells the juniors: the obvious way to do that is also the slow, leaky way.</p>

<p>This is the story of <a href="https://github.com/eilandert/rspamd-kam-rules" target="_blank" rel="noopener">rspamd-kam-rules</a>, a small converter that takes Kevin McGrail&#8217;s famous SpamAssassin ruleset and turns it into a single native Rspamd Lua plugin. No Perl. No compatibility shim parsing 6,500 lines on every reload. Just 3,248 rules that actually resolve against the symbols your Rspamd already has, with the dead weight stripped out before it ever hits production.</p>

<h2 style="color:#f59e0b">Two spam fighters, one ruleset</h2>

<p>SpamAssassin is the elder, and KAM.cf is one of the best community rulesets ever written for it: 3,000-plus patterns hunting phishing, malware droppers, and the endless tide of &#8220;RE: your invoice&#8221; that turns out to be an XLSM with a macro that wants to be friends with your domain controller. It&#8217;s maintained by Kevin A. McGrail with help from Joe Quinn, Karsten Bräckelmann, Bill Cole, and Giovanni Bechis. It&#8217;s genuinely good. It&#8217;s also written in SpamAssassin&#8217;s dialect, which means it expects SpamAssassin to run it.</p>

<p>Rspamd is the younger, faster animal. Event-driven C core, Lua for the logic, and a regexp engine that compiles every pattern once at startup and scans each message in a single pass with Hyperscan. A SpamAssassin box doing KAM.cf might average 800 ms per message under load; the same logic on Rspamd lands closer to 40 ms. That&#8217;s the whole reason people want KAM.cf on Rspamd. If you want the full tour of how Rspamd decides what&#8217;s spam, I covered that in <a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">how modern spam filtering works</a>. This piece is about feeding it KAM.cf without doing something you&#8217;ll regret.</p>

<h2 style="color:#f59e0b">The trap: just point Rspamd at the .cf file</h2>

<p>Rspamd ships a <code>spamassassin</code> module. Drop your <code>.cf</code> into the config, point the module at it, reload. And it works. Sort of. Here&#8217;s the part the tutorials skip.</p>

<p>It parses the entire ruleset on every config load, all 6,500 lines, including the hundreds of rules it can&#8217;t run: <code>eval:</code> calls into SpamAssassin plugins Rspamd never implemented, <code>askdns</code> lookups it handles its own way, metas referencing symbols from plugins you don&#8217;t have. But the one that pages you later is subtler: symbol names don&#8217;t match. SpamAssassin calls a passing SPF check <code>SPF_PASS</code>; Rspamd calls it <code>R_SPF_ALLOW</code>. SpamAssassin says <code>DKIM_VALID</code>; Rspamd says <code>R_DKIM_ALLOW</code>. A KAM.cf meta like <code>(FREEMAIL_FROM &amp;&amp; SPF_PASS &amp;&amp; BITCOIN_ADDR)</code> references a symbol your Rspamd never raises under that name, so the meta silently never fires. It compiles fine. It costs you CPU at startup. It catches nothing. No error. Just a rule that looks active and is functionally dead, which is the worst kind, because you&#8217;ll trust it.</p>

<p>You find out the hard way, of course. A spam wave gets through, you grep the logs, and the rule you were counting on never raised a single hit in three months.</p>

<h2 style="color:#f59e0b">The fix: transpile, don&#8217;t interpret</h2>

<p>rspamd-kam-rules takes the other road. It reads KAM.cf with a proper parser, works out which rules can genuinely run on your Rspamd, rewrites the SA symbol names to their Rspamd equivalents, throws away everything that can never fire, and emits one self-contained <code>kam.lua</code>. That file is the only thing that touches your Rspamd. It carries a SHA-256 of the exact KAM.cf it came from, and it registers its regexes straight into Rspamd&#8217;s native regexp cache.</p>

<p>The current run converts 3,248 rules: 1,179 body, 1,116 header, 690 meta, 156 URI, 67 rawbody, 38 MIME header, and 2 full-message. Another 179 meta rules get dropped on the floor, on purpose, because they depend on symbols that don&#8217;t exist on the target. More on that fight in a minute. It&#8217;s the most interesting part.</p>

<figure class="wp-block-image size-large"><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/kam-cf-rspamd-pipeline-diagram.webp" alt="Conversion pipeline from KAM.cf through the converter to native Rspamd kam.lua"/><figcaption>KAM.cf goes in, the converter maps symbols and prunes dead metas, and one native kam.lua comes out.</figcaption></figure>

<p>Parsing KAM.cf properly means handling the bits that bite. Conditional blocks: it tracks the <code>ifplugin</code> / <code>if !plugin(...)</code> / <code>else</code> / <code>endif</code> stack and only emits rules whose entire chain is satisfiable, while an unbalanced <code>endif</code> raises an error instead of silently corrupting the set. Regex extraction: SpamAssassin patterns come as <code>/pattern/flags</code> or <code>m{pattern}flags</code> with arbitrary delimiters, so the extractor walks the string tracking escapes rather than naively splitting on a slash that a character class would laugh at. And <code>replace_tag</code> / <code>replace_rules</code>: KAM.cf writes <code>/foo&lt;LETTER&gt;/</code> and defines <code>&lt;LETTER&gt;</code> once, so the converter resolves those tags to a fixpoint and expands them inline. It also preserves <code>tflags multiple maxhits=N</code>, so a rule that hits five times scores five times, exactly like the original.</p>

<h2 style="color:#f59e0b">The symbol map: speaking Rspamd&#8217;s language</h2>

<p>Here&#8217;s where the converter earns its keep. It carries a translation table from SpamAssassin symbol names to Rspamd native ones: <code>SPF_PASS</code> to <code>R_SPF_ALLOW</code>, <code>DKIM_VALID</code> to <code>R_DKIM_ALLOW</code>, the whole <code>URIBL_*</code> family onto Rspamd&#8217;s SURBL and DBL symbols. A meta rule is only as good as the symbols it references. Map <code>SPF_FAIL</code> to <code>R_SPF_FAIL</code> and the meta fires; leave it as <code>SPF_FAIL</code> and it sits there forever, compiled and useless. The map is what turns a pile of inert metas into rules that actually vote.</p>

<p>At runtime the generated Lua keeps that table too. When a meta references a symbol that isn&#8217;t a KAM rule itself, the plugin calls <code>task:has_symbol()</code> on the mapped Rspamd name, reading the result your existing modules already computed. The converter isn&#8217;t reimplementing SPF. It&#8217;s wiring KAM&#8217;s logic into the checks Rspamd already runs.</p>

<h2 style="color:#f59e0b">Pruning the dead: meta resolution as a fixpoint</h2>

<p>This is the bit I&#8217;d put on the whiteboard. A meta depends on other symbols, which might be regex rules, external Rspamd symbols, or other metas that depend on yet more symbols. You can&#8217;t judge a meta in isolation. You have to know whether everything it transitively depends on is reachable.</p>

<p>So the converter iterates to a fixpoint. Start with the known-good set: every regex rule that parsed, plus the external Rspamd symbols you fed it. Walk every meta; if all its dependencies are in the good set, mark it good and add it. Repeat until a pass adds nothing. Anything still unresolved depends on a symbol nobody can provide, and out it goes, with its missing dependencies recorded in the report so you can see exactly why. That&#8217;s the 179 dropped metas: not bugs, just rules referencing symbols this Rspamd doesn&#8217;t have. Shipping them would mean compiling expression trees that can never be true.</p>

<p>You feed the target&#8217;s symbol set in via two text files. <code>external-symbols.txt</code> is dumped straight from your production Rspamd&#8217;s <code>/symbols</code> endpoint, the real list of everything your instance can raise. <code>unavailable-symbols.txt</code> is the explicit blocklist of KAM symbols you know aren&#8217;t registered on your stack. Change stacks, regenerate the dump, rebuild. The output adapts to the box it&#8217;s actually going to run on, which is more than the SA module ever does for you.</p>

<h2 style="color:#f59e0b">Why the Lua is fast: register once, scan once</h2>

<p>At config load, the plugin registers each pattern with <code>rspamd_config:register_regexp</code>, tagged by type: <code>sabody</code> for body, <code>sarawbody</code> for rawbody, <code>message</code> for full, <code>url</code> for URI, and the header variants for the rest. That tag tells Rspamd which slice of the message the pattern wants to see. Then the regexp cache does its thing: every pattern of a given type gets compiled into one combined Hyperscan database, and a message is scanned once to find every match in a single pass. Compare that to SpamAssassin&#8217;s per-rule, per-message Perl loop that pins your cores. Same patterns, completely different cost model. That&#8217;s why the converter bothers to register properly instead of calling <code>string.match</code> in a loop like a tutorial would.</p>

<p>Metas compile through <code>rspamd_expression.create</code> into proper expression trees that short-circuit. Results are cached per-task, so a symbol referenced by ten metas is evaluated once. There&#8217;s even a recursion guard: the cache entry is set to zero before evaluation starts, so a pathological meta cycle returns zero instead of blowing the Lua stack. Small detail, big difference between a bad rule scoring nothing and a bad rule taking down your scanner.</p>

<h2 style="color:#f59e0b">Installing it, and the auto-update that won&#8217;t wake you up</h2>

<p>The build runs in GitHub Actions daily at 3 a.m. UTC. It checks the <code>Last-Modified</code> header on KAM.cf and does nothing if upstream hasn&#8217;t changed: no rebuild, no commit, no churn. When it has changed, it runs the tests, regenerates <code>kam.lua</code>, and commits the new file with its fresh SHA-256. Deploying is three boring lines:</p>

<pre><code>sudo wget -O /etc/rspamd/plugins.d/kam.lua \
  https://raw.githubusercontent.com/eilandert/rspamd-kam-rules/main/dist/kam.lua

rspamadm configtest &amp;&amp; systemctl restart rspamd</code></pre>

<p>Run <code>rspamadm configtest</code> first. Always. I&#8217;ll say it twice because someone reading this is going to skip it and restart straight into a syntax error at 2 a.m. with the queue backing up. The generated Lua compiles cleanly, but you still validate before you reload, because the one time you don&#8217;t is the one time the download truncated on a full disk.</p>

<p>One honest caveat. The big &#8220;800 ms versus 40 ms&#8221; number compares SpamAssassin-the-Perl-daemon against Rspamd. It is not a benchmark of Rspamd&#8217;s own SA compatibility module against this converter; both run inside the same regexp cache, so they&#8217;re in the same ballpark on raw scan speed. What the converter buys you over the SA module is correctness and hygiene: symbols that resolve, dead metas pruned, one reviewable file pinned to a known KAM.cf hash, and no 6,500-line parse on every reload. Anyone selling you a 20x speedup from the converter specifically is selling you the wrong number, and you should always check who&#8217;s holding the stopwatch.</p>

<p>The converter is MIT-licensed. The generated <code>kam.lua</code> is a derivative work of KAM.cf and inherits its Apache-2.0 license and authorship, so the credit stays with McGrail and the SpamAssassin crew where it belongs.</p>

<h2 style="color:#f59e0b">Frequently asked questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">What is KAM.cf?</h3>
<div class="rank-math-answer ">

<p>KAM.cf is a large community SpamAssassin ruleset maintained by Kevin A. McGrail, with contributions from Joe Quinn, Karsten Brackelmann, Bill Cole, and Giovanni Bechis. It contains over 3,000 patterns for catching phishing, malware, and scam email, and is distributed under Apache-2.0 from mcgrail.com. It is one of the most widely used add-on rulesets for SpamAssassin.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Why not just load KAM.cf with Rspamd&#8217;s spamassassin module?</h3>
<div class="rank-math-answer ">

<p>You can, and it works, but Rspamd parses the whole 6,500-line file on every config load, carries hundreds of rules it cannot run, and never remaps SpamAssassin symbol names like SPF_PASS to Rspamd&#8217;s R_SPF_ALLOW. Meta rules that reference unmapped symbols compile but never fire. The converter pre-resolves all of that, maps symbols, drops dead rules, and emits one tight Lua file instead.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">How many rules does the converter actually produce?</h3>
<div class="rank-math-answer ">

<p>The current run converts 3,248 rules out of roughly 6,500 lines: 1,179 body, 1,116 header, 690 meta, 156 URI, 67 rawbody, 38 MIME header, and 2 full-message rules. A further 179 meta rules are deliberately dropped because they depend on symbols the target Rspamd does not provide.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">How do I install the generated kam.lua?</h3>
<div class="rank-math-answer ">

<p>Download dist/kam.lua from the GitHub repo into /etc/rspamd/plugins.d/kam.lua, run rspamadm configtest to validate, then restart Rspamd with systemctl restart rspamd. Always run configtest before reloading a production mail filter.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Does it update automatically when KAM.cf changes?</h3>
<div class="rank-math-answer ">

<p>Yes. A GitHub Actions workflow runs daily at 3am UTC, checks the Last-Modified header on upstream KAM.cf, and only rebuilds and commits a new kam.lua when the source has actually changed. Each generated file carries the SHA-256 of the exact KAM.cf it was built from.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Is the converter faster than running SpamAssassin?</h3>
<div class="rank-math-answer ">

<p>Running KAM.cf inside Rspamd is far faster than running SpamAssassin&#8217;s Perl daemon, often roughly 40ms per message versus several hundred. The converter&#8217;s specific advantage over Rspamd&#8217;s built-in SA compatibility module is correctness and hygiene rather than raw speed: mapped symbols, pruned dead metas, and a single auditable, version-pinned plugin.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related reading</h2>

<ul>
<li><a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd Explained: How Modern Spam Filtering Actually Works</a>. Bayes, neural nets, RBLs, greylisting and the rest of the engine KAM.cf plugs into.</li>
<li><a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>. The same defense-in-depth idea, applied to your web stack instead of your mail.</li>
<li><a href="https://deb.myguard.nl/where-to-find-us/">Where To Find Us</a>. All our repos, Docker images, and GitHub projects in one place.</li>
</ul>

<p>Anyway. Go dump your Rspamd&#8217;s <code>/symbols</code> endpoint into <code>external-symbols.txt</code> before you build anything, because a converter that doesn&#8217;t know what your server can do will cheerfully drop half of KAM.cf and never tell you it mattered.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>How to defend your webserver against vibe-coded AI exploit scanners and bots</title>
		<link>https://deb.myguard.nl/2026/06/defend-webserver-vibe-coded-ai-exploit-scanners-bots/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sat, 06 Jun 2026 21:26:56 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[ai]]></category>
		<category><![CDATA[bot-detection]]></category>
		<category><![CDATA[crs]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[modsecurity]]></category>
		<category><![CDATA[php-fpm]]></category>
		<category><![CDATA[rate-limiting]]></category>
		<category><![CDATA[security]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6256</guid>

					<description><![CDATA[Half of all web traffic is bots, and a growing slice are vibe-coded AI scanners written by a chatbot prompt. Here is the five-layer defense in depth that stops them: rate limiting, WAF, TLS hardening, request validation, access control, PHP and Docker hardening, plus the patching that does the most work.]]></description>
										<content:encoded><![CDATA[<p>Bots made up 49.6% of all internet traffic in 2024, and a third of everything that hit your server was a <em>bad</em> bot, according to Imperva&#8217;s 2024 Bad Bot Report. Sit with that number for a second. Half of the noise pounding your access log isn&#8217;t a human, isn&#8217;t Google, isn&#8217;t even an honest crawler. It&#8217;s a script. And lately a growing slice of those scripts were written by someone who has never read an RFC in their life, who asked a chatbot for &#8220;a Python tool that finds vulnerable WordPress sites,&#8221; pasted the answer, and pointed it at the entire IPv4 space before lunch.</p>

<p>Welcome to the vibe-coded exploit era. The barrier to writing an attack tool used to be skill. Now it&#8217;s a prompt. That sounds terrifying until you realise the same thing that makes these tools easy to make also makes them loud, sloppy, and gloriously easy to catch. This is the part nobody tells the juniors: you don&#8217;t beat volume with cleverness. You beat it with layers, each one cheap, each one boring, each one catching the trash the layer above let through.</p>

<p>Let me walk you through the whole stack, from the firewall rule that drops a scan before it costs you a CPU cycle, all the way down to the PHP setting that saves your bacon when everything above it fails. Because it will. Something always gets through. The only question is what&#8217;s waiting for it when it does.</p>

<h2 style="color:#f59e0b">What a vibe-coded attack actually looks like on the wire</h2>

<p>Here&#8217;s the thing about a tool an LLM wrote in thirty seconds: it has tells. Lots of them. Real attackers spend effort hiding. Vibe-coders spend effort shipping, and the model optimises for &#8220;works on the happy path,&#8221; not &#8220;evades a WAF.&#8221; So you get this beautiful pattern of self-incrimination.</p>

<p>The user-agent is usually a dead giveaway. <code>python-requests/2.31.0</code>. <code>Go-http-client/1.1</code>. <code>curl/8.5.0</code> firing 400 requests a second. Nobody browsing your blog ships a default requests UA. The wordlists are recycled, the same <code>/wp-login.php</code>, <code>/.env</code>, <code>/.git/config</code>, <code>/phpmyadmin</code> sweep that every GitHub &#8220;scanner&#8221; repo has copied from the last one since 2019. The error handling is non-existent, so the tool keeps hammering a path that&#8217;s already returned 403 forty times, because the model never wrote a backoff. And the TLS handshake is naked: a stock Go or Python client has a fingerprint (its JA4 hash) as recognisable as a fingerprint at a crime scene, because the author never thought to randomise the cipher order. They didn&#8217;t know it was a thing.</p>

<p>That&#8217;s your edge. Every one of those tells is something you can match on, cheaply, before the request ever reaches your application. The defense isn&#8217;t one magic rule. It&#8217;s a sieve with six meshes, and the trash gets caught somewhere on the way down.</p>

<h2 style="color:#f59e0b">The whole picture: defense in depth, six layers</h2>

<p>Before we go layer by layer, look at the shape of the thing. A request comes in at the top. Each layer drops what it can and passes the rest down. Whatever survives all six gets logged, studied, and fed back to the top so the next one dies sooner. That feedback loop on the right is the part most people skip, and it&#8217;s the part that turns a static wall into something that learns.</p>

<div style="max-width:760px;margin:2.5rem auto;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;">
  <div style="text-align:center;color:#94a3b8;font-size:0.95rem;letter-spacing:0.02em;margin-bottom:0.4rem;">Exploit tool / scanner</div>
  <div style="text-align:center;color:#64748b;font-size:1.3rem;line-height:1;margin-bottom:0.6rem;">&#8595;</div>
  <div style="position:relative;">
    <div style="border-radius:12px;background:#8b2020;color:#fff;padding:1rem 1.2rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,0.3);">
      <div style="font-size:1.12rem;font-weight:600;margin-bottom:0.25rem;">Layer 1 &mdash; Rate limiting &amp; WAF</div>
      <div style="font-size:0.92rem;opacity:0.92;">req/s limits &middot; ModSecurity CRS &middot; geo-blocking &middot; IP reputation<br>&rarr; Kills mass scans, fuzzing and brute force automatically</div>
    </div>
    <div style="text-align:center;color:#64748b;font-size:0.85rem;padding:0.35rem 0;">&#8595; passes through</div>
    <div style="border-radius:12px;background:#8a5a08;color:#fff;padding:1rem 1.2rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,0.3);">
      <div style="font-size:1.12rem;font-weight:600;margin-bottom:0.25rem;">Layer 2 &mdash; TLS hardening &amp; security headers</div>
      <div style="font-size:0.92rem;opacity:0.92;">TLSv1.3 only &middot; HSTS &middot; CSP &middot; X-Frame-Options &middot; server token off<br>&rarr; Shrinks fingerprint surface, blocks browser-side injection</div>
    </div>
    <div style="text-align:center;color:#64748b;font-size:0.85rem;padding:0.35rem 0;">&#8595; passes through</div>
    <div style="border-radius:12px;background:#156152;color:#fff;padding:1rem 1.2rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,0.3);">
      <div style="font-size:1.12rem;font-weight:600;margin-bottom:0.25rem;">Layer 3 &mdash; Request validation</div>
      <div style="font-size:0.92rem;opacity:0.92;">max body size &middot; method whitelist &middot; path normalisation &middot; null-byte blocking<br>&rarr; Catches the badly crafted payloads LLM tools generate</div>
    </div>
    <div style="text-align:center;color:#64748b;font-size:0.85rem;padding:0.35rem 0;">&#8595; passes through</div>
    <div style="border-radius:12px;background:#1d4e7a;color:#fff;padding:1rem 1.2rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,0.3);">
      <div style="font-size:1.12rem;font-weight:600;margin-bottom:0.25rem;">Layer 4 &mdash; Authentication &amp; access control</div>
      <div style="font-size:0.92rem;opacity:0.92;">mTLS &middot; fail2ban coupling &middot; JWT validation &middot; admin paths off the internet<br>&rarr; Raises the exploit bar hard for script kiddies</div>
    </div>
    <div style="text-align:center;color:#64748b;font-size:0.85rem;padding:0.35rem 0;">&#8595; passes through</div>
    <div style="border-radius:12px;background:#7a2348;color:#fff;padding:1rem 1.2rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,0.3);">
      <div style="font-size:1.12rem;font-weight:600;margin-bottom:0.25rem;">Layer 5 &mdash; PHP layer hardening</div>
      <div style="font-size:0.92rem;opacity:0.92;">disable_functions &middot; open_basedir &middot; Snuffleupagus &middot; expose_php off<br>&rarr; Turns a webshell upload into an expensive way to print &quot;hello&quot;</div>
    </div>
    <div style="text-align:center;color:#64748b;font-size:0.85rem;padding:0.35rem 0;">&#8595; passes through</div>
    <div style="border-radius:12px;background:#4b3c9e;color:#fff;padding:1rem 1.2rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,0.3);">
      <div style="font-size:1.12rem;font-weight:600;margin-bottom:0.25rem;">Layer 6 &mdash; Observability &amp; detection</div>
      <div style="font-size:0.92rem;opacity:0.92;">structured logging &middot; 4xx-ratio alerting &middot; user-agent analysis &middot; honeypots<br>&rarr; Sees what got through, feeds the signal back to Layer 1</div>
    </div>
    <div style="position:absolute;top:1.5rem;bottom:1.5rem;right:-22px;width:18px;border:2px dashed #475569;border-left:none;border-radius:0 8px 8px 0;"></div>
    <div style="position:absolute;top:50%;right:-46px;transform:translateY(-50%) rotate(90deg);color:#64748b;font-size:0.8rem;letter-spacing:0.05em;">feedback</div>
  </div>
  <div style="text-align:center;color:#64748b;font-size:0.85rem;margin-top:1.2rem;">Each layer is independent. Defense in depth means no single bypass owns the box.</div>
</div>

<p>The golden rule of this diagram: every layer assumes the one above it failed. That&#8217;s not pessimism. That&#8217;s how you sleep at night. Now let&#8217;s build it.</p>

<h2 style="color:#f59e0b">Layer 1: rate limiting and the WAF</h2>

<p>This is your front door and it does the most work for the least money. A mass scanner&#8217;s entire business model is volume. Take the volume away and most of them just fall over, because the author never wrote retry logic.</p>

<p>Start with rate limiting in nginx or Angie. Two zones: one for the general site, one tight zone for the login and API paths that bots love.</p>

<pre><code>limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=3r/m;
limit_conn_zone $binary_remote_addr zone=conns:10m;

server {
    limit_conn conns 20;

    location / {
        limit_req zone=general burst=40 nodelay;
    }

    location = /wp-login.php {
        limit_req zone=login burst=2 nodelay;
        limit_req_status 429;
    }
}</code></pre>

<p>That <code>rate=3r/m</code> on the login path is not a typo. Three requests a minute. No human logs in faster than that, and a brute-force tool that expected to try ten thousand passwords now needs two days per IP. Most give up. The ones that don&#8217;t, Layer 4 handles.</p>

<p>On top of rate limiting, run a real WAF. ModSecurity with the OWASP Core Rule Set is the standard, and we have a <a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">full step-by-step guide to installing ModSecurity and the OWASP CRS on nginx</a> if you&#8217;re starting from zero. The CRS is a giant pile of regexes that catch SQL injection, path traversal, command injection, the lot. Vibe-coded payloads get caught by it constantly, because the model generated a textbook <code>' OR 1=1--</code> that the CRS has matched since the Obama administration. Running WordPress? Our <a href="https://github.com/eilandert/wordpress-hardening-plugin" target="_blank" rel="noopener">WordPress Hardening Plugin</a> ships CRS-aware rules that block the common attacks without you editing a line of PHP, and the <a href="https://deb.myguard.nl/2026/06/wordpress-hardening-plugin-modsecurity-crs-block-attacks/">full writeup</a> explains what it catches.</p>

<p>Start the CRS in <code>DetectionOnly</code> mode. I mean it. The day I flipped a fresh CRS install straight to blocking on a client site, paranoia level 2, I took down their checkout because a legitimate product description contained the word &#8220;select&#8221; near a quote. Six hours of my Saturday, gone, chasing a false positive that a week in detection mode would have shown me on day one.</p>

<pre><code>SecRuleEngine DetectionOnly
SecDefaultAction "phase:2,log,auditlog,pass"
# After a week of clean logs, flip to:
# SecRuleEngine On
# SecDefaultAction "phase:2,log,auditlog,deny,status:403"</code></pre>

<p>Then layer in cheap reputation filtering. You don&#8217;t need a paid threat feed to start. A <code>map</code> that drops the default scanner user-agents costs nothing and catches an embarrassing amount of vibe-coded traffic:</p>

<pre><code>map $http_user_agent $bad_ua {
    default            0;
    ~*python-requests  1;
    ~*Go-http-client   1;
    ~*\bzgrab\b        1;
    ~*\bnuclei\b       1;
    ~*masscan          1;
    ""                 1;   # empty UA is almost always a bot
}

server {
    if ($bad_ua) { return 444; }   # 444 = drop the connection, no response
}</code></pre>

<p>Return 444, not 403. A 403 is a polite &#8220;no&#8221; that tells the tool the path exists and the server is alive. 444 closes the socket with nothing. The scanner&#8217;s bad error handling does the rest: it logs a connection reset, shrugs, and moves on, having learned exactly nothing about you.</p>

<p>One war story for the road. Geo-blocking is tempting and mostly fine, but don&#8217;t blanket-ban entire countries without thinking about who lives there. I once watched a team block all of &#8220;Asia&#8221; with a GeoIP rule and then spend a frantic afternoon wondering why their own CDN&#8217;s Singapore PoP started failing health checks. Block what you must, allowlist what you need, and write a comment in the config saying <em>why</em>, because future-you will not remember.</p>

<h2 style="color:#f59e0b">Layer 2: TLS hardening and security headers</h2>

<p>Layer 1 dropped the loud ones. Layer 2 shrinks how much you tell the rest. Every byte of metadata you leak is a byte a tool can match on, and a thinner fingerprint means more of those automated tools simply don&#8217;t know what they&#8217;re looking at.</p>

<p>Kill the old TLS. On a modern stack there is no reason to speak anything below TLSv1.2, and TLSv1.3 should be your floor wherever your client base allows it.</p>

<pre><code>ssl_protocols TLSv1.3 TLSv1.2;
ssl_prefer_server_ciphers off;
ssl_conf_command Ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;</code></pre>

<p>Then stop introducing yourself to strangers. <code>server_tokens off</code> hides your exact nginx version, so a scanner can&#8217;t map you straight to a CVE list. On Angie there&#8217;s a bit more you can do, and if you want the gory details of running a hardened build, the whole point of our <a href="https://deb.myguard.nl/angie-modules-optimized-extended/">extended Angie package</a> is that the security knobs are already turned the right way.</p>

<p>Now the headers. These are mostly about the second class of attack: not the scanner hitting your server, but the payload that tries to run in your visitor&#8217;s browser. Set them once, in a snippet you include everywhere.</p>

<pre><code>add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; frame-ancestors 'self'; base-uri 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;</code></pre>

<p>That <code>always</code> keyword matters more than it looks. Without it, nginx skips the header on error responses, which means your 404 and 500 pages ship naked. Guess which pages an attacker is most likely to be staring at? Right. Add <code>always</code> or the header is theatre.</p>

<p>A word on Content-Security-Policy, since it&#8217;s the one that pages you at 3 a.m. A tight CSP is the single most effective control against cross-site scripting, and it&#8217;s also the one most likely to break your own site the moment someone adds an inline script. Roll it out in <code>Content-Security-Policy-Report-Only</code> first, watch the violation reports, then enforce. The compression side-channel angle matters here too: if you&#8217;re serving secrets over a compressed, dynamic response, read up on the <a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">BREACH attack</a> before you assume HTTPS alone has you covered. It doesn&#8217;t.</p>

<h2 style="color:#f59e0b">Layer 3: request validation at the edge</h2>

<p>This layer is where vibe-coded tools really show their underwear. A human-written exploit is crafted to look plausible. An LLM-generated one is often syntactically valid garbage: a 40 MB request body to a login form, a <code>PUT</code> where a <code>POST</code> belongs, a path with a null byte in it because the model copied a 2008 Stack Overflow answer about bypassing file extension checks.</p>

<p>Cap the body size. Tightly. WordPress media uploads aside, most of your endpoints have no business accepting megabytes.</p>

<pre><code>client_max_body_size 2m;

location = /xmlrpc.php { deny all; }   # nobody misses it

location = /wp-login.php {
    client_max_body_size 64k;
}</code></pre>

<p>Whitelist methods. If an endpoint only ever sees GET and POST, say so, and watch the <code>OPTIONS</code> and <code>TRACE</code> probes bounce.</p>

<pre><code>location /api/ {
    limit_except GET POST {
        deny all;
    }
}</code></pre>

<p>nginx normalises paths before matching, which already eats a lot of the classic <code>../../../etc/passwd</code> traversal noise, but don&#8217;t lean on that alone. The CRS from Layer 1 has dedicated traversal and null-byte rules, and they fire hard on exactly the kind of malformed input these tools spray. The combination is the point: nginx normalises, the CRS inspects, and the request that&#8217;s still standing is one a careful human wrote, which is a much smaller pile to worry about.</p>

<p>Here&#8217;s the trap nobody warns you about. <code>merge_slashes</code> is on by default, which collapses <code>//</code> into <code>/</code>. Sounds helpful. It also means a poorly written upstream auth check that keys on the literal path string can be bypassed with a doubled slash. The default is right for most people. Just know it&#8217;s there before someone files a bug claiming your access rules &#8220;randomly&#8221; don&#8217;t apply.</p>

<h2 style="color:#f59e0b">Layer 4: authentication and access control</h2>

<p>Everything above is about cutting noise. This layer is about making sure the few requests that reach something sensitive have actually earned it. The single most effective thing you can do here costs nothing: take your admin surface off the public internet.</p>

<p>Your <code>/wp-admin</code>, your phpMyAdmin, your Grafana, your staging site. None of it needs to face the whole planet. Bind it to a VPN range or allowlist your own IPs and the entire category of &#8220;scanner finds login page, scanner brute-forces login page&#8221; evaporates.</p>

<pre><code>location ^~ /wp-admin/ {
    allow 10.8.0.0/24;     # your WireGuard subnet
    allow 203.0.113.7;     # office static IP
    deny all;
}

# keep admin-ajax.php public, the front-end needs it
location = /wp-admin/admin-ajax.php { allow all; }</code></pre>

<p>That admin-ajax carve-out is the gotcha. Block all of <code>/wp-admin/</code> without it and half your plugins&#8217; front-end features stop working, and you&#8217;ll get a &#8220;the site is broken&#8221; ticket before the page cache even expires. Ask me how I know.</p>

<p>Couple your WAF to fail2ban so repeated offenders get banned at the firewall, where blocking costs a single iptables/nftables rule instead of a full request cycle through nginx and ModSecurity.</p>

<pre><code># /etc/fail2ban/jail.local
[nginx-limit-req]
enabled  = true
filter   = nginx-limit-req
logpath  = /var/log/nginx/error.log
maxretry = 10
findtime = 60
bantime  = 3600

[nginx-badbots]
enabled  = true
port     = http,https
filter   = nginx-badbots
logpath  = /var/log/nginx/access.log
maxretry = 2
bantime  = 86400</code></pre>

<p>For anything you actually expose, like an API, validate the credential properly. If you&#8217;re handing out JWTs, check the signature <em>and</em> the algorithm, because the classic <code>alg: none</code> bypass is exactly the kind of thing a vibe-coded tool will try, having read about it in the same blog post you did. Mutual TLS (mTLS) is the heavier hammer for machine-to-machine traffic: if the client can&#8217;t present a cert you issued, the handshake never completes and your application code never runs. No request, no attack surface.</p>

<h2 style="color:#f59e0b">Layer 5: the PHP layer, assume the WAF missed one</h2>

<p>Every layer so far lived in front of your application. But the whole premise of defense in depth is that one day a request gets all the way through, and on a WordPress or PHP site, that request lands in the PHP interpreter. So you harden the interpreter too, on the assumption that it&#8217;s the last thing standing.</p>

<p>Start in <code>php.ini</code>. Stop announcing your version, and lock down the file system the interpreter can see.</p>

<pre><code>expose_php = Off
display_errors = Off
log_errors = On
allow_url_fopen = Off
allow_url_include = Off
open_basedir = /var/www/html:/tmp
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec</code></pre>

<p>That <code>disable_functions</code> line is the one that turns a successful file-upload exploit into a dead end. The whole point of dropping a PHP webshell is to call <code>system()</code> and run commands. Take those functions away and the shell the attacker just uploaded is an expensive way to print &#8220;hello.&#8221; It won&#8217;t stop every exploit. It absolutely wrecks the most common one.</p>

<p>The caveat: some plugins legitimately call <code>exec()</code>, and the day you blanket-ban it you might break a backup plugin that shells out to <code>mysqldump</code>. Test in staging. Read the error log. This is the recurring theme of the whole job, isn&#8217;t it. Every good hardening control breaks something the first time, and the difference between a senior and a junior is that the senior expected it.</p>

<p>For real teeth, run <a href="https://deb.myguard.nl/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/">Snuffleupagus</a>. It&#8217;s a PHP module that does virtual-patching and runtime hardening, the spiritual successor to the old Suhosin patch, and it can bind dangerous functions to specific call sites and kill the rest. We package it, and the ready-made <code>myguard.rules</code> ship inside our <a href="https://github.com/eilandert/dockerized" target="_blank" rel="noopener">hardened Docker images on GitHub</a>, because the default PHP posture is too trusting. Our <a href="https://deb.myguard.nl/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/">step-by-step Snuffleupagus tutorial</a> walks through writing those rules from scratch if you&#8217;d rather understand them than copy them. And keep an object cache like Redis or Valkey in front of your database so the brute-force login attempts that <em>do</em> slip through aren&#8217;t also hammering MySQL into the ground. If you&#8217;re weighing the options there, our writeup on <a href="https://deb.myguard.nl/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/">Valkey, the Redis fork that actually won</a> covers why we default to it now.</p>

<h2 style="color:#f59e0b">Layer 6: observability, or how you find out what got through</h2>

<p>Here&#8217;s the uncomfortable truth. Something will get through. A WAF is a probabilistic filter, not a wall, and anyone who tells you their setup is unbreakable is selling something or hasn&#8217;t been breached <em>yet</em>. Layer 6 is the difference between learning about a compromise from your own dashboard and learning about it from a stranger on Twitter.</p>

<p>Log in a structured format you can actually query. JSON access logs turn &#8220;grep and pray&#8221; into real analysis.</p>

<pre><code>log_format json_combined escape=json '{'
  '"time":"$time_iso8601",'
  '"ip":"$remote_addr",'
  '"status":$status,'
  '"method":"$request_method",'
  '"uri":"$request_uri",'
  '"ua":"$http_user_agent",'
  '"ja4":"$ssl_ja4"'
'}';
access_log /var/log/nginx/access.json json_combined;</code></pre>

<p>That <code>$ssl_ja4</code> field is the quiet hero. JA4 is a TLS client fingerprint (see the <a href="https://deb.myguard.nl/2026/06/ja3-ja4-tls-fingerprinting-nginx/">full write-up on how JA3/JA4 fingerprinting works</a>), and because our <a href="https://deb.myguard.nl/nginx-modules/">Angie and nginx builds ship the JA4 module</a>, you get the attacker&#8217;s handshake signature on every line. When a vibe-coded tool sweeps you, every request shares one JA4 hash, because the author never randomised the TLS stack. One hash, ten thousand requests, four hundred distinct paths, ninety percent 404s. That&#8217;s not a user. That&#8217;s a banner that says &#8220;ban me.&#8221;</p>

<p>Alert on the 4xx ratio, not just on errors. A sudden spike of 404s from a single IP or JA4 is a scan in progress. Pipe the logs into anything (Loki, an ELK stack, a cron job and a shell script if you&#8217;re scrappy) and trip an alert when one client crosses, say, fifty 404s in a minute.</p>

<p>And plant a honeypot. A path that no human or legitimate crawler should ever request, like <code>/wp-admin-secret-backup/</code>, with one job: any IP that touches it gets instantly added to your deny list. Real users never find it. Scanners, working from their recycled wordlists, find it constantly. It&#8217;s the cheapest, highest-signal trap you can set, and it feeds straight back to Layer 1. That&#8217;s the loop closing. The thing that got caught at the bottom teaches the top to catch it sooner next time.</p>

<h2 style="color:#f59e0b">Docker isolation: shrink the blast radius</h2>

<p>Say the worst happens. The WAF missed it, the PHP hardening didn&#8217;t catch it, and an attacker has code execution inside your web container. Defense in depth says: fine, now what can they reach? If the answer is &#8220;the whole host as root,&#8221; you built a wall with a door behind it. If the answer is &#8220;a read-only filesystem, no capabilities, no other containers,&#8221; you&#8217;ve turned a breach into an inconvenience.</p>

<p>Run the container as a non-root user, read-only, with every Linux capability dropped and no ability to gain new ones.</p>

<pre><code>services:
  web:
    image: angie:hardened
    read_only: true
    user: "1000:1000"
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp
      - /run
    networks: [frontend]</code></pre>

<p>A read-only root filesystem alone neutralises a huge class of exploits, because the attacker can&#8217;t write their toolkit anywhere persistent. <code>cap_drop: ALL</code> means even if they&#8217;re &#8220;root&#8221; inside the container, that root can&#8217;t load kernel modules, can&#8217;t mess with the network stack, can&#8217;t do most of what root implies. And network segmentation means the compromised web container can&#8217;t pivot to your database container, because they don&#8217;t share a network. This is the whole game: containment. We went deep on every one of these flags in the <a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker hardening guide for self-hosters</a>, so I won&#8217;t repeat the lot here. And it&#8217;s not theory: the same flags lock down our own <a href="https://deb.myguard.nl/2026/06/hardened-roundcube-docker-image/">hardened Roundcube webmail image</a>, which runs as nobody and can chown nothing.</p>

<h2 style="color:#f59e0b">Patching: the boring layer that does the most</h2>

<p>I saved the least glamorous one for last, because it&#8217;s the one that actually matters most, and the one juniors skip because it&#8217;s boring. Here&#8217;s the flat truth, no hedging: the overwhelming majority of &#8220;hacks&#8221; are not clever zero-days. They are a known CVE, with a patch available for months, against software nobody updated. The vibe-coded tool sweeping you isn&#8217;t smart. It&#8217;s just checking whether you did your homework.</p>

<p>So do your homework automatically. On Debian and Ubuntu, turn on unattended security upgrades and stop thinking about it.</p>

<pre><code>apt install unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades</code></pre>

<p>Keep your container images rebuilt on a schedule, not whenever you happen to remember. A &#8220;latest&#8221; tag you pulled eight months ago is not latest, it&#8217;s a museum piece full of CVEs. Our <a href="https://deb.myguard.nl/nginx-dockerized/">Angie and nginx Docker images rebuild daily</a> for exactly this reason, so the base layer you pull from <a href="https://hub.docker.com/r/eilandert/angie" target="_blank" rel="noopener">Docker Hub</a> was patched this morning, not last winter. The reason this works: the gap between &#8220;CVE published&#8221; and &#8220;exploit tool ships it&#8221; is now days, sometimes hours, because the tool-maker can ask a model to write the exploit from the CVE description. The patch window has collapsed. Automated patching is no longer optional hygiene, it&#8217;s the only way to keep pace with automated attacks.</p>

<p>There&#8217;s a grim symmetry to it. The same AI that lets a kid write a scanner in one prompt also lets defenders find bugs faster, as the curl project discovered when AI tooling dredged up a record pile of vulnerabilities (and a flood of garbage reports alongside the real ones). If you run WordPress, we wrote up <a href="https://deb.myguard.nl/2026/06/wordpress-hardening-plugin-modsecurity-crs-block-attacks/">blocking these AI-driven attacks at the WAF layer</a> separately. The arms race is real, both sides got a force multiplier, and the only people who lose are the ones running unpatched software and hoping.</p>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">What is a vibe-coded exploit tool?</h3>
<div class="rank-math-answer ">

<p>It&#8217;s an attack script written largely by an AI model rather than a skilled human. Someone asks a chatbot for something like a vulnerability scanner, pastes the output, and runs it. The barrier used to be skill; now it&#8217;s a prompt. The upside for defenders is that these tools are loud and sloppy: default user-agents, recycled wordlists, no retry logic, and unrandomised TLS fingerprints, all of which make them easy to catch at the edge.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Can a WAF alone stop AI-generated attacks?</h3>
<div class="rank-math-answer ">

<p>No, and anyone claiming otherwise is overselling. A WAF like ModSecurity with the OWASP CRS catches a large share of textbook payloads, but it&#8217;s a probabilistic filter, not a wall. That&#8217;s why defense in depth matters: rate limiting in front, request validation and access control behind it, PHP and container hardening below that, and observability to catch whatever slips through. No single layer owns the box.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Why return 444 instead of 403 to bad bots?</h3>
<div class="rank-math-answer ">

<p>A 403 is a valid HTTP response that confirms your server is alive and the path exists, which is information a scanner logs and uses. nginx&#8217;s non-standard 444 closes the connection with no response at all. The tool records a connection reset and moves on, having learned nothing. Against tools with poor error handling, which describes most vibe-coded ones, 444 is strictly better.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">What is JA4 and how does it help against scanners?</h3>
<div class="rank-math-answer ">

<p>JA4 is a fingerprint of a client&#8217;s TLS handshake, the cipher order, extensions, and version it offers. A stock Python or Go HTTP client has a fixed, recognisable JA4 hash because its author never randomised the TLS stack. Log the JA4 of every request and a mass scan shows up as thousands of requests sharing one hash, which is a clean signal to rate-limit or ban. The myguard Angie and nginx builds ship the JA4 module.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Is automatic patching safe for a production server?</h3>
<div class="rank-math-answer ">

<p>For security updates on Debian and Ubuntu, unattended-upgrades is well-tested and the risk of skipping patches now far outweighs the small risk of a bad update, because the window between a CVE going public and an exploit tool shipping it has collapsed to days. Pin it to the security pocket only, keep backups, and rebuild container images on a schedule so your base layer isn&#8217;t a museum piece full of known holes.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Does taking wp-admin off the public internet break the site?</h3>
<div class="rank-math-answer ">

<p>Not if you do it right. Allowlist your VPN range and static IPs for /wp-admin/, but keep /wp-admin/admin-ajax.php public, because front-end plugin features depend on it. Block that one file by accident and you&#8217;ll get a &#8216;site is broken&#8217; ticket fast. With the carve-out in place, the entire &#8216;scanner finds login, scanner brute-forces login&#8217; attack category disappears.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Where to start tomorrow morning</h2>

<p>You don&#8217;t build all six layers in one afternoon, and you shouldn&#8217;t try. Pick the cheapest, highest-impact wins first. Turn on rate limiting and the bad-UA map today, they take ten minutes and cut the noise immediately. Put the WAF in detection mode this week and read its logs before you flip it to blocking. Take your admin surface off the public internet this month. Turn on unattended-upgrades right now, before you close this tab, because that&#8217;s the one that quietly handles the attacks you&#8217;ll never even see.</p>

<p>The attackers got a force multiplier. So did you. The tools to build every layer above are free, open source, and mostly a few lines of config. The vibe-coders are betting you didn&#8217;t bother. Prove them wrong.</p>

<p>Anyway. Go check whether your last backup actually restores before you touch any of this. You did test it, right?</p>]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>WordPress Hardening Plugin for ModSecurity CRS: Block Attacks Without Touching Your PHP</title>
		<link>https://deb.myguard.nl/2026/06/wordpress-hardening-plugin-modsecurity-crs-block-attacks/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Fri, 05 Jun 2026 22:15:00 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[brute-force]]></category>
		<category><![CDATA[crs]]></category>
		<category><![CDATA[geoip]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[ip-reputation]]></category>
		<category><![CDATA[modsecurity]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[waf]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5534</guid>

					<description><![CDATA[WordPress XSS and SQL injection CVEs are exploding because AI now finds them faster than you can patch. This ModSecurity CRS plugin is the last wall: 40+ rules, typed-parameter SQLi blocking, rate limiting and GeoIP — before PHP ever boots.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Last year the world logged north of 8,000 new WordPress vulnerabilities, and almost all of them were the same two ancient sins: cross-site scripting and SQL injection. Sit with that number for a second. Eight thousand. The bugs didn&#8217;t get cleverer. We just pointed AI at plugin source code, and the machine doesn&#8217;t get bored, doesn&#8217;t take lunch, doesn&#8217;t quit at 5. It reads every <code>$_GET</code> in a 40,000-line plugin at 3 a.m. and finds the <code>orderby=</code> hole you&#8217;ve been shipping since 2019 and kept meaning to refactor. The fix isn&#8217;t another PHP plugin, it&#8217;s a WordPress hardening plugin for ModSecurity that blocks the attack at the WAF, before PHP ever runs.</p>



<p class="wp-block-paragraph">Right. Gather round, you lot. First week on the job, and I&#8217;m going to tell you the thing nobody told me: you cannot patch faster than a machine can find holes. You just can&#8217;t. So you do the next best thing. You put a wall in front of the hole and you wait for the patch to land. That wall is the <strong>wordpress-hardening-plugin</strong>: an open-source ModSecurity Core Rule Set plugin that bolts 40-odd WordPress-specific rules onto your web server&#8217;s edge. No PHP changes. No extra WordPress plugin to babysit. Nothing phoning home. Grab a coffee. We&#8217;re doing all of it.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">So what even is a ModSecurity CRS plugin?</h2>



<p class="wp-block-paragraph">Quick map of the territory, because half of you are nodding along pretending you know. That&#8217;s fine. We all did.</p>



<p class="wp-block-paragraph"><strong>ModSecurity</strong> is a Web Application Firewall (a WAF) that lives <em>inside</em> your web server (NGINX, Angie, Apache) and reads every HTTP request before your app ever sees it. On its own it knows nothing. It&#8217;s an empty engine with no brain. The <strong>OWASP Core Rule Set</strong> is the brain you bolt on: a curated, battle-tested library of rules for SQL injection, XSS, path traversal, the whole rogues&#8217; gallery.</p>



<p class="wp-block-paragraph">And since CRS 4.0 there&#8217;s a plugin system. Drop a couple of specially-named files into one directory, CRS loads them automatically, layered on top. No patching core. No forking. No merge hell when CRS updates next month. The wordpress-hardening-plugin is exactly that kind of drop-in.</p>



<p class="wp-block-paragraph">Here&#8217;s what I mean. The stock CRS is a very good bouncer who knows every general troublemaker in the city. Problem is, your venue is a WordPress site, and WordPress has its own private list of regulars who cause grief: <code>xmlrpc.php</code>, <code>?author=1</code>, <code>wp-cron.php</code>, the theme editor. This plugin hands the bouncer a laminated cheat-sheet of every WordPress-specific trick in the book. Same bouncer. Sharper instincts. And here&#8217;s the bit that matters: it stops them at the door, <strong>before PHP even wakes up</strong>.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The AI vulnerability wave, and why a WAF is suddenly your seatbelt</h2>



<p class="wp-block-paragraph">I&#8217;m going to say something the vendor brochures won&#8217;t. A WAF does not fix your vulnerabilities. It hides them. That used to be a slightly embarrassing thing to admit at conferences. Now? It&#8217;s the whole point. Let me explain, because this is the single most important thing you&#8217;ll learn this week.</p>



<p class="wp-block-paragraph">The economics of finding bugs just flipped. It used to take a skilled human days to audit a plugin and dig out one exploitable <code>orderby</code> injection. Now an LLM does it across a thousand plugins over a weekend, and the same trick runs on both sides of the fence. The good guys disclose responsibly (the same models are already being turned loose as <a href="/2026/06/defend-webserver-vibe-coded-ai-exploit-scanners-bots/">vibe-coded exploit scanners hitting your webserver</a>). The bad guys don&#8217;t. The gap between &#8220;a bug exists in the plugin you installed and forgot about&#8221; and &#8220;a bot is firing the exploit at your site&#8221; has collapsed from months to hours.</p>



<p class="wp-block-paragraph">But the defender&#8217;s loop hasn&#8217;t sped up to match. Think about it. The plugin author still has to read the report, write the fix, test it, ship it. You still have to notice the update and apply it. And across that gap, those hours or days where the bug is public and your site isn&#8217;t patched yet, something has to stand in the doorway and refuse to let the exploit through. That something is a WAF that actually understands WordPress.</p>



<p class="wp-block-paragraph">So get this straight now and save yourself a bad night later: this plugin is a <strong>last line of defense</strong> and a time-buyer. Not a cure. It&#8217;s your seatbelt. Seatbelts are brilliant. They are also not a reason to drive into walls. The stuff at the end of this guide (update everything, lock down PHP, harden the box and the container) is the brakes and the steering. You want all of it. Honestly.</p>



<figure class="wp-block-image size-large"><img decoding="async" width="1200" height="675" src="https://deb.myguard.nl/wp-content/uploads/2026/06/wordpress-hardening-plugin-defense-in-depth.webp" alt="Defense-in-depth diagram: the wordpress-hardening-plugin WAF blocks most XSS and SQL injection attacks at the edge before PHP loads, backed by Snuffleupagus, a hardened Docker container and patching" class="wp-image-6098" srcset="https://deb.myguard.nl/wp-content/uploads/2026/06/wordpress-hardening-plugin-defense-in-depth.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/06/wordpress-hardening-plugin-defense-in-depth-300x169.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/06/wordpress-hardening-plugin-defense-in-depth-1024x576.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/06/wordpress-hardening-plugin-defense-in-depth-768x432.webp 768w" sizes="(max-width: 1200px) 100vw, 1200px" /><figcaption class="wp-element-caption">The plugin is the first wall an AI-found exploit hits, and the cheapest place to stop it, before PHP ever boots.</figcaption></figure>



<h2 class="wp-block-heading" style="color:#f59e0b">Why a WordPress hardening plugin for ModSecurity beats a PHP security plugin</h2>



<p class="wp-block-paragraph">Someone always asks this. Fair question. Wordfence, Sucuri, the rest: they exist, they&#8217;re not useless. But for a self-hosted site where you actually care about resources, doing your perimeter blocking at the WAF layer wins. Four reasons, and I want you to remember the first one especially.</p>



<ul class="wp-block-list">
<li><strong>PHP never loads for a blocked request.</strong> When ModSecurity rejects something at the NGINX layer, WordPress doesn&#8217;t boot, PHP-FPM doesn&#8217;t fork, MariaDB never gets touched. A botnet hammering your login page burns almost none of your CPU. A PHP-based security plugin? By definition it has to boot PHP and WordPress before it can decide to block you, which is the exact resource fire you were trying to put out. You know that special hell where the site falls over <em>because</em> of the thing meant to protect it? The firewall cheerfully DDoSing you on the attacker&#8217;s behalf? Yeah. Avoid that.</li>
<li><strong>The guard isn&#8217;t standing inside the building it&#8217;s guarding.</strong> A PHP security plugin runs in the same process, with the same database access, as the code it protects. A WordPress-core zero-day or a bug in some <em>other</em> plugin can take it down with everything else. A rule in ModSecurity has zero exposure to WordPress-level bugs. Different process. Often a different privilege domain entirely.</li>
<li><strong>One file protects every site.</strong> Running twenty WordPress sites behind one NGINX? One plugin directory hardens all of them, the same way, every time.</li>
<li><strong>One less thing to trust.</strong> Every WordPress plugin you bolt on is another supply-chain dependency and another row in next year&#8217;s CVE firehose. This one lives outside WordPress completely.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">Every rule, explained: the full inventory</h2>



<p class="wp-block-paragraph">No hand-waving. No &#8220;enterprise-grade protection&#8221; word salad (if anyone ever sells you that phrase, walk away). Here&#8217;s every protection the plugin ships, grouped by what it actually defends, with the real reasoning. Nearly all of it is a one-line toggle in <code>plugins/wordpress-hardening-config.conf</code>; I&#8217;ll call out the defaults as we go. Anything tagged PL2 only fires if you run CRS at paranoia level 2 or higher.</p>



<h3 class="wp-block-heading">Access control &amp; identity leaks</h3>



<ul class="wp-block-list">
<li><strong>Block <code>xmlrpc.php</code> (default: ON).</strong> A legacy RPC endpoint from the blogging-client era nobody remembers. Today it&#8217;s almost pure attack surface: <code>system.multicall</code> lets an attacker test thousands of passwords in one request, pingback enables DDoS amplification, and it exposes upload methods. Unless a specific mobile app needs it, it has no business being public. Localhost and private ranges stay whitelisted so your internal tooling lives.</li>
<li><strong>Block user enumeration (default: ON).</strong> WordPress hands out usernames via the <code>?author=1</code> redirect and the REST <code>/wp/v2/users</code> endpoint. No username, no targeted brute force. Blocks both. (The <code>?author=N</code> form is always blocked; the readable <code>/author/&lt;slug&gt;/</code> archive page is a separate toggle, below.)</li>
<li><strong>Block the literal username &#8220;admin&#8221; at login (default: ON).</strong> The single most-tried brute-force username on Earth is &#8220;admin&#8221;. If you don&#8217;t have a user named exactly that, and you shouldn&#8217;t, that&#8217;s day-one hygiene, this deflects a huge chunk of automated junk with basically zero false positives. It blocks the <em>username</em> &#8220;admin&#8221;, not your actual admins. Don&#8217;t panic.</li>
<li><strong>Block the <code>/wp-json</code> REST API (default: OFF).</strong> The REST API powers Gutenberg and half the plugin ecosystem, so blocking it wholesale breaks most sites. Hence off by default. For a headless or static setup where REST consumers live on an internal network, turning it on closes a real data-exposure surface.</li>
<li><strong>Block <code>wp-cron.php</code> (default: OFF).</strong> WordPress fakes cron by hitting <code>wp-cron.php</code> on visitor page loads. Inefficient, occasionally abusable. If you&#8217;ve wired up a real system cron (do it), public access is dead weight. Off by default so you don&#8217;t break sites leaning on the pseudo-cron; localhost stays whitelisted.</li>
<li><strong>Block <code>/author/&lt;slug&gt;/</code> archive pages (default: OFF).</strong> The slug-form author archive also leaks login names. Most blogs expose these on purpose, so it&#8217;s off by default; flip it on if author pages aren&#8217;t part of your public surface.</li>
</ul>



<h3 class="wp-block-heading">File, path &amp; secret leaks</h3>



<ul class="wp-block-list">
<li><strong>Block direct PHP execution and directory listing in <code>/wp-content/</code> and <code>/wp-includes/</code> (default: ON).</strong> Real WordPress runs through <code>index.php</code>. Direct PHP execution in these trees is either a misconfig or someone running a webshell. Directory listing turns a missing <code>index.html</code> into a free site map for the attacker.</li>
<li><strong>Block sensitive extensions: <code>.db .orig .sql .log .git</code> (default: ON).</strong> Developers leave things lying around. They just do. Database exports, editor backups, SQLite files, logs, exposed <code>.git/HEAD</code>. Scanners find these on production sites every single day.</li>
<li><strong>Block VCS and dotfile probes: <code>.env</code>, <code>.git/</code>, <code>.svn/</code>, <code>.hg/</code>, <code>.bzr/</code>, <code>.htpasswd</code>, <code>.DS_Store</code> (default: ON).</strong> The <code>.env</code> file is the modern crown jewel: database password, API keys, mail creds, all in one tidy file. Bots scan for it constantly. Constantly.</li>
<li><strong>Block <code>wp-config.php</code> backup variants (default: ON).</strong> <code>.save</code>, <code>.old</code>, <code>.new</code>, <code>.dist</code>, <code>.sample</code>, <code>.copy</code>, a trailing <code>~</code>, numeric <code>.1</code>/<code>.2</code>. Every editor and every tired admin generates one eventually, and any of them serves your database credentials in plaintext to whoever types the URL. Usually the bored teenager currently scanning your /24.</li>
<li><strong>Block backup directories &amp; archives (default: ON).</strong> UpdraftPlus, BackWPup and friends sometimes drop <code>.zip</code>/<code>.tar.gz</code>/<code>.tar.bz2</code> into web-reachable paths. That&#8217;s your whole site in one click.</li>
<li><strong>Block compressed database dumps: <code>.sql.gz</code>, <code>.sql.bz2</code>, <code>.sql.zip</code> (default: ON).</strong> A migration leftover that contains every password hash, every email address, every post you&#8217;ve ever written. Found more often than you&#8217;d believe.</li>
<li><strong>Hard-block info-leak paths in phase 1 (default: ON).</strong> <code>readme.html</code>, <code>license.txt</code>, <code>.user.ini</code>, <code>wp-admin/install.php</code>, <code>wp-admin/setup-config.php</code>, <code>wp-includes/wlwmanifest.xml</code>, <code>wp-content/debug.log</code>. This one fires in phase 1, before any upstream redirect can sneak ahead of ModSec. That <code>readme.html</code> version string is exactly how a scanner learns your WordPress version and which CVEs to throw.</li>
<li><strong>Block plugin/theme <code>readme.txt</code> version probes (default: OFF).</strong> Scanners read <code>/wp-content/plugins/&lt;slug&gt;/readme.txt</code> to map installed plugin to known CVE. Off by default because legit tooling (wp-cli) reads these too; turn it on if you accept that trade.</li>
</ul>



<h3 class="wp-block-heading">Upload abuse &amp; remote code execution</h3>



<ul class="wp-block-list">
<li><strong>Block nasty files in <code>/wp-content/uploads/</code> (default: ON).</strong> The uploads directory is world-writable by design. It&#8217;s where the cat photos go. It&#8217;s also the favourite landing zone for a webshell smuggled in through a vulnerable plugin. Requests for executable or dangerous extensions there get blocked.</li>
<li><strong>Block directory traversal in uploads (default: ON).</strong> <code>../</code> and its URL-encoded cousins inside <code>/wp-content/uploads/</code> requests: the trick that turns a sloppy file-serving function into &#8220;read me <code>/etc/passwd</code>, would you.&#8221;</li>
<li><strong>Block alternative interpreters: <code>.pl</code>, <code>.lua</code>, <code>.py</code>, <code>.sh</code> (PL2).</strong> If your server is ever misconfigured to run these over HTTP, a file-upload bug becomes full remote code execution. This slams that door.</li>
<li><strong>Block PHP stream wrappers in arguments (default: ON).</strong> <code>php://</code>, <code>data://</code>, <code>expect://</code>, <code>file://</code>, <code>phar://</code>, <code>glob://</code>, <code>zip://</code>, <code>compress.zlib://</code>, <code>compress.bzip2://</code> anywhere in a parameter or the URI. Classic local/remote file inclusion. <code>phar://</code> deserialization alone has powered a decade of WordPress RCE chains.</li>
<li><strong>Block null-byte injection (PL2, default: ON).</strong> <code>%00</code> in the URI or parameters truncates strings in C-based code, beats extension checks (<code>shell.php%00.jpg</code>), confuses parsers. No legitimate request has ever contained one. Not once.</li>
</ul>



<h3 class="wp-block-heading">Reconnaissance &amp; version disclosure</h3>



<ul class="wp-block-list">
<li><strong>Block the exact <code>/wp-json</code> path (default: ON).</strong> Even with full REST blocking off, the bare <code>/wp-json</code> root returns a manifest of every registered endpoint: a free map of your attack surface. This blocks the map while leaving the API itself working.</li>
<li><strong>Detect version-disclosure response headers (default: tag).</strong> <code>X-Pingback</code>, <code>X-Powered-By</code>, and the REST <code>Link: rel="https://api.w.org/"</code> header all whisper your software versions. ModSecurity v2 can&#8217;t strip response headers, so the plugin <em>flags</em> them and you do the actual removal at the proxy: <code>proxy_hide_header X-Pingback; proxy_hide_header X-Powered-By; more_clear_headers "Link";</code></li>
<li><strong>Block XDebug &amp; phpinfo probes (default: ON).</strong> <code>XDEBUG_SESSION</code> triggers and phpinfo probe parameters. A live phpinfo on production is a catastrophic leak, and this is exactly how bots go looking for it.</li>
<li><strong>BREACH compression side-channel tagging (default: tag).</strong> Requests to <code>/wp-admin/</code>, <code>/wp-login.php</code> and <code>/wp-json/*</code> get flagged so you can confirm your proxy strips <code>Accept-Encoding</code> on those paths. The real fix lives in the server. Our <a href="/2026/05/breach-attack-explained-prevention/">deep dive on the BREACH attack</a> explains why compressing secret-bearing responses leaks them.</li>
<li><strong>Block known security scanners (PL2, default: OFF).</strong> nikto, sqlmap, wpscan and pals by user-agent. Off by default (UA blocking is trivially bypassed and real pen-testers use these) but a handy noise-reducer on a heavily-scanned box. Heads up: the bundled list also catches SEO crawlers like Ahrefs and Semrush, so read the README before flipping it if you care about those.</li>
</ul>



<h3 class="wp-block-heading">Known CVEs &amp; HTTP hygiene</h3>



<ul class="wp-block-list">
<li><strong>Block actively-exploited plugin CVEs (default: ON).</strong> Signatures for SureTriggers / OttoKit (CVE-2025-3102, CVE-2025-27007, unauthenticated admin creation) and Bricks Builder (CVE-2024-25600, unauthenticated RCE). Turn it off only if you genuinely run those plugins and need their REST traffic.</li>
<li><strong>Block legacy CVE scanner probes (default: ON).</strong> revslider, timthumb, WP Symposium, MailPoet&#8217;s <code>wysija_captcha</code>, wp-file-manager&#8217;s <code>connector.minimal.php</code>, Duplicator installer leaks. Long dead. Still scanned hourly. Pure log-noise reduction; no legit request hits these.</li>
<li><strong>Block uncommon HTTP methods (default: ON).</strong> TRACE/TRACK/DEBUG/PROPFIND/MKCOL/COPY/MOVE/LOCK/UNLOCK/PUT/DELETE/PATCH on <code>/wp-admin/</code>, <code>/wp-login.php</code>, <code>/xmlrpc.php</code>, <code>/wp-cron.php</code>. <code>/wp-json</code> is deliberately exempt, because authenticated REST writes legitimately use PUT/DELETE/PATCH.</li>
<li><strong>Block CVE-2018-6389 DoS (default: ON).</strong> A long <code>?load=</code> list on <code>wp-admin/load-scripts.php</code> or <code>load-styles.php</code> makes WordPress concatenate dozens of files per request and cook your CPU. Anonymous, unauthenticated, still works on unpatched cores. Blocked.</li>
<li><strong>Block dangerous wp-admin endpoints (default: ON).</strong> <code>upgrade.php</code> and <code>wp-activate.php</code>, rarely reached on a settled site. (<code>install.php</code> and <code>setup-config.php</code> are already hard-blocked in phase 1 above.)</li>
<li><strong>Block code injection in login fields (default: ON).</strong> <code>&lt;script&gt;</code>, <code>eval(</code>, <code>base64_decode</code>, <code>onload=</code>, <code>onerror=</code> in the <code>log</code> and <code>pwd</code> POST parameters of <code>wp-login.php</code>. Those are the username and password boxes. Nobody&#8217;s real password is a <code>&lt;script&gt;</code> tag.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">The new front line: typed-parameter SQLi &amp; XSS rules</h2>



<p class="wp-block-paragraph">Okay. Pens down, eyes up. This is the part written specifically for the AI wave, and it earns its own section because it works differently from everything above. Rules 9522330 through 9522334. If you remember one thing from today, make it this.</p>



<p class="wp-block-paragraph">Here&#8217;s the thing nobody tells the new starters: the stock CRS <em>already</em> hunts for SQL injection and XSS in every parameter. It uses a library called libinjection, and at paranoia level 1 it runs on everything. So why add more? Because libinjection is a generalist pattern-matcher, and the single most common WordPress plugin SQL injection, the <code>ORDER BY</code> injection, is precisely the case it has documented bypasses for. Maddening, right? The most common bug is the one the generic tool is weakest at.</p>



<p class="wp-block-paragraph">Quick lesson, because this genuinely is the most important bug class in the current wave. SQL has a clause: <code>ORDER BY column ASC</code>. And you can&#8217;t parameterise a column name or a sort direction the way you can a value, so lazy plugin code just jams <code>$_GET['orderby']</code> and <code>$_GET['order']</code> straight into the query. An attacker sends <code>orderby=id-(case when (1=1) then 1 else 0 end)</code> and now your database is happily answering true/false questions about its own contents. No quotes. No <code>SELECT</code> keyword. Nothing that looks like the textbook injection libinjection was trained on. Slips right past.</p>



<p class="wp-block-paragraph">The plugin&#8217;s answer isn&#8217;t to play whack-a-mole with cleverer signatures. It&#8217;s to use <strong>types</strong>. A sort direction can only ever be <code>asc</code> or <code>desc</code>. A column name can only ever be letters, digits, underscores, dots. So:</p>



<ul class="wp-block-list">
<li><strong>9522330: <code>order=</code> must be <code>asc</code> or <code>desc</code> (default: ON).</strong> Anything else, blocked outright. There is no legitimate third value. The admin-ajax reorder vector that uses <code>order[]=</code> arrays is deliberately not matched, so your drag-to-reorder UIs keep working.</li>
<li><strong>9522331: <code>orderby=</code> may not contain SQL metacharacters (default: ON).</strong> Quotes, parens, comment markers, SQL keywords: rejected. Bare column names, including multi-column <code>title,date</code>, pass clean.</li>
<li><strong>9522332: strict <code>orderby=</code> allowlist (PL2).</strong> At paranoia level 2, <code>orderby</code> must match a plain column-name pattern and nothing else. Stricter, for the cautious among you.</li>
<li><strong>9522333: WordPress core numeric query-vars must be integers (default: ON, zero false positive).</strong> <code>p</code>, <code>page_id</code>, <code>attachment_id</code>, <code>m</code>, <code>w</code>, <code>year</code>, <code>monthnum</code>, <code>day</code>, <code>hour</code>, <code>minute</code>, <code>paged</code>, <code>cpage</code> are integers by definition in WordPress core. A <code>&lt;script&gt;</code> or a <code>UNION SELECT</code> in any of them is, with certainty, an attack. (<code>cat</code> and <code>author</code> are excluded; they legitimately take comma-separated and negative lists.)</li>
<li><strong>9522334: strict integer enforcement on generic id params (PL2, default: OFF).</strong> <code>id</code>, <code>post_id</code>, <code>user_id</code>, <code>term_id</code>, <code>parent_id</code>, <code>comment_id</code>. These names are generic enough that some plugins (mis)use them for non-integers, so it&#8217;s opt-in and only fires at PL2. But when your plugins behave, it&#8217;s a wall across the most-injected parameter names in the catalogue.</li>
</ul>



<p class="wp-block-paragraph">Why is this stronger than a signature? Think about it this way. An allowlist doesn&#8217;t care how clever the payload is. A blocklist asks &#8220;does this look like an attack I&#8217;ve seen before?&#8221;, and a motivated attacker just makes their attack look new. An allowlist asks &#8220;is this one of the handful of values that are actually legitimate here?&#8221; And for a sort direction, that&#8217;s a closed set of two. You cannot obfuscate your way into being the word <code>asc</code>. That&#8217;s the whole game. Allowlist the things you can; you&#8217;ll sleep better.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Three teeth that aren&#8217;t just pattern matching</h2>



<p class="wp-block-paragraph">Signatures can&#8217;t cover everything. Three features add stateful, identity-aware defense, and I&#8217;m going to be straight with you about their sharp edges, because the README is, and you deserve the same. A tool that lies about its weaknesses is worse than no tool.</p>



<h3 class="wp-block-heading">IP-based rate limiting for wp-login.php</h3>



<p class="wp-block-paragraph">A slow, distributed brute force (one guess every few seconds from a rotating botnet) sails past signature rules forever. Rate limiting closes that. The plugin counts POST requests to <code>/wp-login.php</code> per resolved client IP using ModSecurity&#8217;s persistent collections, and after the threshold it blocks everything further from that IP until the window expires, returning a proper <strong>HTTP 429 Too Many Requests</strong> (RFC 6585). Default: 5 attempts per 60 seconds, both tunable.</p>



<pre class="wp-block-code"><code># Tighten to 3 attempts; windows must be one of 30/60/120/300/600
SecAction "id:9522049,phase:1,nolog,pass,t:none,setvar:'tx.wphard.ratelimit_login_attempts=3'"
SecAction "id:9522050,phase:1,nolog,pass,t:none,setvar:'tx.wphard.ratelimit_login_window=30'"</code></pre>



<p class="wp-block-paragraph">And here&#8217;s the sharp edge, the one that&#8217;ll bite you if I don&#8217;t say it now. Persistent collections work reliably on <strong>Apache + mod_security2</strong>. On <strong>libmodsecurity3</strong>, the engine NGINX and Angie use, the collection implementation has long-standing gaps, and the counter often just never persists across requests. So if you&#8217;re on NGINX (most of you will be), do your login rate-limiting in the server instead with <code>limit_req zone=...</code>, and treat this feature as Apache-only. There&#8217;s also a collection-growth footgun: each unique IP creates an entry, so on a directly-exposed box set <code>SecCollectionTimeout 300</code> and turn on trusted-proxy pinning (below) before someone rotates IPv6 addresses to bloat your data dir. A firewall that fills your disk is just a slow, self-inflicted outage with your name in the post-mortem. Don&#8217;t be that ticket.</p>



<h3 class="wp-block-heading">GeoIP access control for wp-login.php</h3>



<p class="wp-block-paragraph">If your admins are all in the Netherlands and Germany, why is <code>wp-login.php</code> reachable from anywhere else on the planet? Maintain an allowlist of ISO country codes; everyone outside gets blocked before WordPress loads. The clever bit: no GeoIP database is needed on the WAF. Your upstream already knows the country. Cloudflare sets <code>CF-IPCountry</code>, NGINX with <code>ngx_http_geoip2_module</code> (in our <a href="/nginx-modules/">optimized NGINX builds</a>) sets <code>X-GeoIP-Country</code>, and ModSecurity just reads the header.</p>



<pre class="wp-block-code"><code># plugins/wordpress-hardening-login-countries.data  (lowercase, one per line!)
nl
de
gb</code></pre>



<p class="wp-block-paragraph">Off by default, fail-open (a missing header lets the request through, so a proxy misconfig can&#8217;t lock you out), and the lookup is case-sensitive. Write <code>NL</code> instead of <code>nl</code> and you&#8217;ll block every login including your own, then spend twenty minutes convinced the plugin is broken. Ask me how I know. Go on, check your data file. I&#8217;ll wait.</p>



<h3 class="wp-block-heading">IP reputation blocklist</h3>



<p class="wp-block-paragraph">The most aggressive option: block <em>all</em> requests from known-bad IPs, not just logins. A plain-text file of IPs and CIDRs, loaded with <code>@ipMatchFromFile</code> and checked on every request. The plugin ships the mechanism; you supply the data from a threat feed (Spamhaus DROP, Emerging Threats, Firehol Level 1) via a cron job that refreshes the file and reloads NGINX. Off by default. Loopback and private ranges are always immune, so you can&#8217;t blocklist your own infrastructure by accident. (You&#8217;d be amazed.)</p>



<h2 class="wp-block-heading" style="color:#f59e0b">IP whitelisting, so you don&#8217;t lock yourself out</h2>



<p class="wp-block-paragraph">Every IP-aware feature (the blockable endpoints, the rate-limit counter, the GeoIP gate, the reputation list) shares a single client-IP resolver and a single &#8220;is this a private IP?&#8221; decision, so the same identity is used everywhere. Out of the box it whitelists IPv4 loopback and RFC 1918, plus IPv6 <code>::1</code> and ULA <code>fc00::/7</code>. Your cron jobs, monitoring, and load-balancer health checks won&#8217;t trip a thing.</p>



<p class="wp-block-paragraph">The resolver defaults to <code>REMOTE_ADDR</code> and, if present, takes the leftmost hop of <code>X-Forwarded-For</code>. Which raises a footgun worth naming out loud: on a directly-exposed server, an attacker can forge <code>X-Forwarded-For</code> to fake a trusted private IP and skate past the whitelist, or rotate it to dodge the rate-limiter. The fix is <strong>trusted-proxy pinning</strong>: list your real upstream proxy CIDRs in a data file and enable it, and XFF is honoured only when the connecting peer is actually one of your proxies. It&#8217;s off by default for backward compatibility. If your box has a public IP, turn it on. Today.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">How to install it</h2>



<p class="wp-block-paragraph">Standard CRS plugin install. You need CRS 4.0+ and a ModSecurity-compatible WAF (ModSecurity v2/v3 or Coraza). No WAF yet? Start with our <a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">step-by-step guide to ModSecurity and the OWASP CRS on NGINX</a>, then come back and layer this on top.</p>



<pre class="wp-block-code"><code># 1. Go to your CRS plugins directory
cd /etc/nginx/modsecurity.d/owasp-crs/plugins/

# 2. Clone the plugin in place
git clone https://github.com/eilandert/wordpress-hardening-plugin .

# 3. CRS auto-loads plugins/*-config.conf, *-before.conf, *-after.conf

# 4. Test config and reload
nginx -t &amp;&amp; systemctl reload nginx</code></pre>



<p class="wp-block-paragraph">That&#8217;s it. Every sensible default is live immediately; open <code>plugins/wordpress-hardening-config.conf</code> to tune. I&#8217;d also strongly grab the companion <a href="https://github.com/coreruleset/wordpress-rule-exclusions-plugin" target="_blank" rel="noopener">wordpress-rule-exclusions-plugin</a>, which suppresses the CRS false positives WordPress naturally trips (Gutenberg&#8217;s POST bodies are absolutely feral). Add protection, not noise. That&#8217;s the whole job, really.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">A WAF is the last line, not the only one</h2>



<p class="wp-block-paragraph">And now the lecture you came here to skip and actually need to hear. So sit back down. This plugin buys you time. It does not buy you immortality. Install it, then stop patching, and you&#8217;ve built a lovely fence around a house with the doors hanging open. Defense in depth means layers, and the WAF is one of them. Here are the others, ranked by how often people skip them.</p>



<ul class="wp-block-list">
<li><strong>Update and upgrade. Religiously.</strong> WordPress core, every plugin, every theme, PHP itself, the OS packages. The whole point of the AI wave is that the gap between disclosure and exploitation is now hours. Auto-update what you safely can; for the rest, check weekly and treat a pending security update like a smoke alarm, not a notification you&#8217;ll get to eventually. The breach-disclosure email you send your users is always longer than the changelog you didn&#8217;t read. The WAF covers you <em>across</em> that gap. It is not permission to widen it.</li>
<li><strong>Lock down PHP with Snuffleupagus.</strong> Even if an exploit gets past the WAF and reaches PHP, you can stop it doing damage. <a href="/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/">php-snuffleupagus</a> is a PHP module that virtual-patches whole bug classes: disable <code>system()</code> calls that don&#8217;t match a whitelist, block <code>phar://</code> deserialization, neuter dangerous <code>ini_set</code> calls, cookie-encrypt sessions. It&#8217;s the difference between &#8220;the exploit ran&#8221; and &#8220;the exploit ran, then hit a wall inside the interpreter.&#8221;</li>
<li><strong>Harden the web server.</strong> TLS done right, security headers, sane timeouts, request-size limits, the WAF itself tuned properly. Our <a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">expert guide to NGINX and Angie performance and security</a> is the long version. Short version: defaults are for getting started, not for production.</li>
<li><strong>Harden the container.</strong> If WordPress runs in Docker (and it should), run it rootless, read-only, capabilities dropped, <code>no-new-privileges</code> set, so a compromise inside the container is a compromise of <em>nothing useful</em>. Our <a href="/2026/05/docker-hardening-rootless-readonly-distroless/">Docker hardening checklist</a> walks the whole thing: rootless, read-only root filesystem, cap-drop, distroless, network segmentation.</li>
</ul>



<p class="wp-block-paragraph">Stack those and an attacker has to beat your WAF, <em>and</em> find an unpatched bug, <em>and</em> get past Snuffleupagus, <em>and</em> break out of a locked container, instead of beating one sloppy PHP plugin. That&#8217;s defense in depth. The wordpress-hardening-plugin is the first wall they hit. It should never be the last.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Design philosophy &amp; how it&#8217;s tested</h2>



<p class="wp-block-paragraph">A few design choices worth understanding, because they change how you deploy it.</p>



<ul class="wp-block-list">
<li><strong>No PHP, no MySQL, no WordPress in the hot path.</strong> Every rule fires at the ModSecurity layer. Under a real attack, that&#8217;s the difference between staying up and falling over.</li>
<li><strong>One toggle per feature.</strong> Each blocking group is gated by a <code>TX:wphard.*</code> variable read in phase 1. Disabling anything is one commented line in the config. No editing rule bodies, no risk of snapping the chain.</li>
<li><strong>Paranoia-level aware.</strong> Rules are tagged PL1 or PL2. Run a strict PL2 setup and the extra rules engage automatically; stay on PL1 (right for most sites) and you get the core protection without the PL2 chatter.</li>
<li><strong>Honest about its own limits.</strong> The README documents the libmodsecurity3 rate-limit gap, the collection-growth DoS, and the known false-positive patterns, instead of hiding them. Trust is built on the weaknesses you admit, not the strengths you advertise.</li>
<li><strong>Tested on every commit.</strong> The repo carries a full <a href="https://github.com/coreruleset/go-ftw" target="_blank" rel="noopener">go-ftw</a> suite: positive regression tests for each rule, a false-positive corpus of legitimate WordPress traffic that must <em>never</em> trip a rule, and a bypass-evasion corpus of obfuscated attacks that must always be caught. It runs against real nginx+libmodsecurity3 <em>and</em> Apache+mod_security2 containers. Rules don&#8217;t ship unless all three corpora pass.</li>
</ul>



<p class="wp-block-paragraph">There&#8217;s even a self-healing touch I&#8217;m a bit fond of: a built-in rule (9522801) suppresses three noisy CRS data-leakage rules on front-end blog permalinks only, so a tutorial site that publishes <code>&lt;?php</code> snippets in its posts doesn&#8217;t get its own articles blocked by the outbound filter. Admin, login and API paths keep the full protection. That bug was found in production, on this very site, and fixed in the plugin. Dogfooding works. Sometimes it bites first.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Related reading</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: build the WAF foundation first, then add this plugin.</li>
<li><a href="/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/">PHP Snuffleupagus Tutorial: Harden PHP-FPM</a>: the in-interpreter layer that catches what gets past the WAF.</li>
<li><a href="/2026/05/docker-hardening-rootless-readonly-distroless/">Docker Hardening for Self-Hosters</a>: rootless, read-only, cap-drop, distroless. Contain the blast radius.</li>
<li><a href="/2026/06/defend-webserver-vibe-coded-ai-exploit-scanners-bots/">How to defend your webserver against vibe-coded AI exploit scanners and bots</a>: the wave this plugin defends against, in one case study.</li>
<li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">Nginx &amp; Angie: The Expert Guide to Maximum Performance and Security</a>: harden the server the WAF runs in.</li>
<li><a href="/nginx-modules/">NGINX Modules: Optimized &amp; Extended</a>: our builds ship the http-modsecurity and geoip2 modules precompiled.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">Frequently asked questions</h2>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Does a WAF stop the AI-discovered vulnerabilities by itself?</h3>
<div class="rank-math-answer ">

<p>It mitigates them and buys you time, but it is not a cure. A WAF blocks the exploit shapes it knows, and the typed-parameter rules in this plugin block whole classes of SQL injection by allowlist rather than signature, which is genuinely strong. But the only thing that removes a vulnerability is the patch. Treat the WAF as the layer that protects you in the hours between disclosure and you applying the update, and keep updating.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I still need to update WordPress and my plugins if I run this?</h3>
<div class="rank-math-answer ">

<p>Yes, absolutely, and more urgently than before. The plugin is a seatbelt, not a reason to stop steering. The AI-vulnerability wave has collapsed the time between a bug going public and bots exploiting it to hours. The WAF covers that gap; it does not close it. Update core, plugins, themes, PHP and the OS on a tight schedule.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Does this replace Wordfence or Sucuri?</h3>
<div class="rank-math-answer ">

<p>It replaces the perimeter part (request blocking, brute-force protection, IP blocking) and does it before PHP loads, which is far cheaper under attack. It does not do file-integrity monitoring, post-compromise forensics, or in-WordPress alerting. For a high-traffic self-hosted site, the WAF approach is a big resilience win; pair it with off-host monitoring rather than a second PHP plugin.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Will it break my site?</h3>
<div class="rank-math-answer ">

<p>The defaults are deliberately conservative. Anything that risks false positives on a normal WordPress install (REST API blocking, wp-cron blocking, scanner UA blocking) is off by default, and the numeric-parameter SQLi rule only rejects values that WordPress core defines as integers anyway. Start with defaults, watch your ModSecurity log for a few days, then tighten. Disabling any single rule is one commented line.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">How do the ORDER BY / orderby rules differ from what CRS already does?</h3>
<div class="rank-math-answer ">

<p>The stock CRS runs libinjection on every parameter, which is a signature-style generalist with documented bypasses for ORDER BY injection (the most common WordPress plugin SQLi). These rules use an allowlist instead: a sort direction can only be asc or desc, a column name can only be letters, digits, underscores and dots. An allowlist cannot be obfuscated past, because it asks what is legitimate rather than what looks malicious.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">I am on NGINX. Does the rate limiter work for me?</h3>
<div class="rank-math-answer ">

<p>Probably not reliably. The rate limiter relies on ModSecurity persistent collections, which work well on Apache + mod_security2 but have long-standing gaps on libmodsecurity3, the engine NGINX and Angie use, where the counter often never persists. On NGINX, do login rate-limiting with the server&#8217;s native limit_req instead, and use the rest of this plugin for everything else.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">Does it work on Apache, or only NGINX?</h3>
<div class="rank-math-answer ">

<p>Both. The rules are standard ModSecurity/CRS and run identically on Apache mod_security2, on NGINX/Angie with libmodsecurity3, and on Coraza. Only the install path differs. Ironically, the persistent-collection features (rate limiting) are more reliable on Apache.</p>

</div>
</div>
<div id="rm-faq-8" class="rank-math-list-item">
<h3 class="rank-math-question ">Where is the source, and can I contribute?</h3>
<div class="rank-math-answer ">

<p>It is open source at github.com/eilandert/wordpress-hardening-plugin. Issues and pull requests are welcome: new rules, test cases, documentation. If you report a false positive, include your CRS version, ModSecurity engine and version, and the audit-log excerpt that fired.</p>

</div>
</div>
</div>
</div>


<p class="wp-block-paragraph"><em>The wordpress-hardening-plugin is open source (Apache-2 licensed) and maintained at <a href="https://github.com/eilandert/wordpress-hardening-plugin" target="_blank" rel="noopener">github.com/eilandert/wordpress-hardening-plugin</a>. CRS 4.0+ required; works with ModSecurity v2, v3 and Coraza.</em></p>



<p class="wp-block-paragraph"><!-- seo-orphan-link --> Related: <a href="/2026/06/http2-bomb-cve-2026-49975-memory-dos/">HTTP/2 Bomb (CVE-2026-49975)</a>, another vulnerability an AI found, and how to defend it at the edge.</p>


]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Dovecot, Post-Quantum TLS and Sieve: The BOFH Guide to a Hardened IMAP Server</title>
		<link>https://deb.myguard.nl/2026/06/dovecot-post-quantum-tls-sieve-hardened-docker/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Wed, 03 Jun 2026 22:10:04 +0000</pubDate>
				<category><![CDATA[Mail]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6090</guid>

					<description><![CDATA[A cryptographically relevant quantum computer doesn&#8217;t exist yet, and a nation-state is almost certainly recording your IMAP session anyway. That&#8217;s not paranoia,&#8230;]]></description>
										<content:encoded><![CDATA[<p>A cryptographically relevant quantum computer doesn&#8217;t exist yet, and a nation-state is almost certainly recording your IMAP session anyway. That&#8217;s not paranoia, it&#8217;s procurement: encrypted traffic is cheap to store and patient adversaries are betting that the RSA key protecting your mailbox today will be trivially crackable in 2032. They&#8217;re probably right. The strategy even has a name in the literature, &#8220;harvest now, decrypt later&#8221;, and it&#8217;s the single best reason to turn on Dovecot post-quantum TLS <em>this year</em>, not when the headlines force you to. Dovecot post-quantum TLS is the whole point of this guide.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/dovecot-post-quantum-tls.webp" alt="Dovecot post-quantum TLS on a hardened IMAP server" width="1024" height="576" loading="lazy"/></figure>

<p>Which brings us to Dovecot, the quiet workhorse that&#8217;s been holding your email together while you weren&#8217;t looking. If you&#8217;ve ever read a mail header and felt a flicker of dread, this one&#8217;s for you. We&#8217;re going to take Dovecot apart, explain what cryptography actually does under the hood (no hand-waving), wire in genuine post-quantum key exchange, lock down every cipher that has any business being shot behind the barn, and bolt on Sieve so your inbox files itself. And we&#8217;ll do it the way the Bastard Operator From Hell would: with contempt for defaults, suspicion of every open port, and a container that trusts absolutely nobody.</p>

<h2 style="color:#f59e0b">What <a href="https://doc.dovecot.org/" rel="noopener" target="_blank">Dovecot</a> actually is (and why you already depend on it)</h2>

<p>Dovecot is an IMAP and POP3 server. That&#8217;s the boring one-liner. The useful version: it&#8217;s the piece of software that lets your phone, your laptop, and that one ancient Thunderbird install all see the same mailbox, in sync, over an encrypted connection. When Postfix or another MTA accepts a message from the outside world, it hands it off for <em>local delivery</em>, and Dovecot is what stores it, indexes it, and serves it back when a client connects on port 993 (IMAPS) or 995 (POP3S).</p>

<p>It&#8217;s the most popular IMAP server on the public internet by a wide margin, surveys have put it north of 70% of all IMAP-capable hosts for years. There&#8217;s a good reason for that dominance. Dovecot is fast (its index format is built for the access patterns mail clients actually use, not the ones a textbook assumes), it&#8217;s pedantically standards-compliant, and its security record is genuinely excellent for software this widely deployed. Timo Sirainen, the original author, was famously willing to pay out of his own pocket for any security hole found in the core. That kind of confidence is rare and earned.</p>

<p>Dovecot also does more than serve mail. It runs <strong>LMTP</strong> (the local delivery protocol that lets your MTA drop messages straight into Dovecot&#8217;s storage), <strong>ManageSieve</strong> (so users can edit their own mail-filtering rules), and a <strong>SASL auth</strong> service that Postfix leans on to decide who&#8217;s allowed to send mail. In a modern stack, Dovecot isn&#8217;t a leaf node, it&#8217;s load-bearing. If you&#8217;re running a mail server at all, you should treat its config like you treat your SSH config: paranoidly.</p>

<h2 style="color:#f59e0b">Crypto in ninety seconds, properly this time</h2>

<p>Before we talk post-quantum anything, let&#8217;s get the foundations right, because most &#8220;TLS hardening&#8221; guides skip the part that actually matters.</p>

<p>When your client connects to Dovecot over TLS, three different jobs happen, and people constantly conflate them:</p>

<h3>1. Key exchange (the asymmetric part)</h3>

<p>Your client and the server need to agree on a shared secret over a wire that&#8217;s being watched. They do this with <em>asymmetric</em> cryptography, maths where a public value can be shared openly but a private one stays secret. Modern TLS uses <strong>ECDHE</strong>: Elliptic Curve Diffie-Hellman, Ephemeral. &#8220;Ephemeral&#8221; is the important word. A fresh key is generated for every single session and thrown away after. This gives you <strong>forward secrecy</strong>: even if someone steals the server&#8217;s long-term private key next year, they <em>still</em> can&#8217;t decrypt the session they recorded today, because the ephemeral key that protected it never touched disk and no longer exists. Forward secrecy is non-negotiable. Any cipher that lacks it, anything starting with plain <code>RSA</code> key exchange, belongs in a museum.</p>

<h3>2. Authentication (the signature part)</h3>

<p>Key exchange stops eavesdropping, but it doesn&#8217;t stop someone <em>pretending</em> to be your server. That&#8217;s what the certificate is for: the server signs part of the handshake with the private key matching its public certificate, and a CA you trust vouches for that certificate. This is where the &#8220;is this really mail.example.com?&#8221; question gets answered.</p>

<h3>3. Bulk encryption (the symmetric part)</h3>

<p>Once both sides share a secret, they switch to fast <em>symmetric</em> crypto for the actual data, your IMAP commands, your messages, your folder list. This is <strong>AES-GCM</strong> or <strong>ChaCha20-Poly1305</strong>. Both are AEAD ciphers: Authenticated Encryption with Associated Data, meaning they encrypt <em>and</em> tamper-detect in one pass. If an attacker flips a bit, the connection dies instead of quietly delivering corrupted data. The bad old CBC-mode ciphers didn&#8217;t do this cleanly, which is exactly how attacks like <a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">BREACH and its CBC cousins</a> got their teeth. We turn CBC off entirely.</p>

<p>Here&#8217;s the thing that should make you sit up: a quantum computer threatens exactly <em>one</em> of these three. Symmetric crypto (AES, ChaCha20) is basically fine, Grover&#8217;s algorithm halves the effective key length, so AES-256 still gives you a comfortable 128 bits of quantum security. The thing that <em>shatters</em> is the asymmetric key exchange. Shor&#8217;s algorithm turns the hard maths behind RSA and elliptic curves into a tractable problem. The padlock holding your session secret is the part that breaks.</p>

<h2 style="color:#f59e0b">Dovecot post-quantum TLS and harvest-now-decrypt-later</h2>

<p>So we have a clear, bounded threat: when a cryptographically relevant quantum computer arrives, every ECDHE and RSA key exchange ever recorded becomes decryptable. Not <em>future</em> traffic, <em>recorded</em> traffic. Anything an adversary stored on tape today.</p>

<p>That&#8217;s the harvest-now-decrypt-later attack in full. It doesn&#8217;t require the quantum computer to exist yet. It only requires an adversary patient enough to keep your encrypted blobs until one does. For most web traffic, a cat photo, a session that expires in an hour, who cares. For mail? Mail is forever. The contents of your inbox in 2026 may be every bit as sensitive in 2034. Email is the canonical worst case for HNDL, which is precisely why <a href="https://deb.myguard.nl/2026/05/postfix-3-11-post-quantum-tls-tlsrpt-milters-and-the-modern-mta-stack/">Postfix 3.11 shipped post-quantum TLS</a> and why your IMAP server has no excuse to lag behind.</p>

<p>The fix is a new family of algorithms whose hardness doesn&#8217;t collapse under Shor&#8217;s algorithm. NIST ran a multi-year competition and standardised the winners in 2024. The key-exchange winner is <strong>ML-KEM</strong> (Module-Lattice Key Encapsulation Mechanism, FIPS 203, formerly known as Kyber). It&#8217;s a lattice-based KEM, and the lattice problems it relies on have no known efficient quantum attack.</p>

<p>But, and this is the part the marketing skips, nobody fully trusts a brand-new algorithm on its own yet. Lattice crypto is young. So the sane deployment is a <strong>hybrid</strong>: combine the classical, battle-tested X25519 elliptic-curve exchange <em>with</em> ML-KEM-768, and derive your session secret from both. An attacker now has to break <em>both</em> the lattice maths <em>and</em> elliptic curves to win. If ML-KEM turns out to have a flaw, you&#8217;re no worse off than classical TLS. If quantum computers arrive, X25519 falls but ML-KEM holds. You lose only if both break, and if that happens, we have bigger problems than your email. That hybrid group is called <code>X25519MLKEM768</code>, and it&#8217;s the de-facto default that browsers and OpenSSL already negotiate.</p>

<h2 style="color:#f59e0b">Wiring real PQC into our Dovecot</h2>

<p>Here&#8217;s where theory becomes config. Post-quantum key exchange in Dovecot isn&#8217;t a Dovecot feature at all, it&#8217;s an OpenSSL feature that Dovecot inherits, because Dovecot is built with <code>--with-ssl=openssl</code> and links the system library. Hybrid ML-KEM landed in <strong>OpenSSL 3.5</strong>. No OpenSSL 3.5, no post-quantum handshake, full stop.</p>

<p>That single fact drove a deliberate decision in our package: we build Dovecot <strong>only for Debian trixie and Ubuntu resolute</strong>, the two suites that ship OpenSSL 3.5+. Older distros (bookworm, jammy, noble, bullseye) carry OpenSSL 3.0 or 1.1 and physically cannot negotiate ML-KEM. Building for them would be shipping a security promise we couldn&#8217;t keep. So we don&#8217;t.</p>

<p>The TLS config itself lives in <code>conf.d/10-ssl.conf</code>, and it&#8217;s where the BOFH energy goes. The key-exchange group list puts the post-quantum hybrid first:</p>

<pre><code>ssl_curve_list = X25519MLKEM768:X25519:X448:secp384r1:secp256r1</code></pre>

<p>Read that as a preference order. A modern client that speaks ML-KEM gets the post-quantum hybrid. A client that doesn&#8217;t falls back gracefully to classical X25519, still forward-secret, still strong, just not quantum-resistant. Nobody gets locked out; everybody capable gets the best available. That&#8217;s the whole philosophy.</p>

<p>For the symmetric ciphers, we keep only AEAD suites and shoot everything else:</p>

<pre><code>ssl_cipher_suites = TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256
ssl_cipher_list   = ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:\
                    ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:\
                    ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
ssl_server_prefer_ciphers = yes
ssl_min_protocol = TLSv1.2</code></pre>

<p>What got the bullet: <code>kRSA</code> (no forward secrecy), all DH(E) and 3DES and DES, RC4, MD5, SHA1-MAC, every CBC suite, PSK, SRP, NULL, EXPORT. If a cipher was ever in a CVE title, it&#8217;s gone. We set <code>ssl_server_prefer_ciphers = yes</code> so the <em>server</em> dictates the ordering, we don&#8217;t let a client downgrade us into something weaker because it asked nicely. TLS 1.3 is negotiated whenever the client can; TLS 1.2 stays as a hardened fallback (forward-secret AEAD only) so we don&#8217;t break older-but-not-ancient clients. TLS 1.0 and 1.1, being thoroughly broken, are simply not on the menu.</p>

<figure class="wp-block-image size-large"><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/dovecot-tls-ciphers-keep-vs-shoot.webp" alt="Dovecot TLS hardening: post-quantum ciphers kept, RC4 3DES CBC MD5 SHA1 removed"/><figcaption>What we keep and what we shoot in the Dovecot TLS config.</figcaption></figure>

<h2 style="color:#f59e0b">The rest of the hardening: an IMAP server that trusts nobody</h2>

<p>TLS is the front door. The BOFH worries about the whole house. Our <a href="https://hub.docker.com/r/eilandert/dovecot" target="_blank" rel="noopener">dockerized Dovecot</a> is built to a single principle: assume the process will be compromised, and make that compromise worthless.</p>

<p><strong>Privilege separation, caged.</strong> Here&#8217;s where a lot of &#8220;hardened&#8221; images get it backwards. Dovecot is <em>built</em> around privilege separation: a small root master process binds the ports and loads the TLS key, then immediately hands every network-facing job to an unprivileged user. The processes that actually parse hostile input off the internet, the pre-auth IMAP/POP3 login workers, run as <code>dovenull</code>, a user that owns nothing. The processes that touch your mail run as <code>vmail</code> (uid 5000). Root never reads a byte from an attacker. Collapsing all that to a single uid (which a lot of &#8220;fully unprivileged&#8221; containers do) actually <em>weakens</em> you: a bug in the login parser would then already be running as the mailbox owner. So we keep Dovecot&#8217;s separation and cage the root master instead, <code>cap_drop: ALL</code>, then add back only the handful of capabilities it genuinely needs (drop privilege to dovenull/vmail, chroot the login workers, own its runtime sockets), plus <code>no-new-privileges</code>, AppArmor, and every setuid bit stripped out of the image. As a bonus we move every listener to a high port inside the container (via <code>conf.d/99-unprivileged-ports.conf</code>) and map the real public ports down to them host-side, so we don&#8217;t even need <code>CAP_NET_BIND_SERVICE</code>. Pop the login worker and you&#8217;re <code>dovenull</code> in a chroot with no capabilities and nowhere to go.</p>

<p><strong>Read-only rootfs.</strong> The filesystem is immutable. The only writable surfaces are the mail volume, your config mount, and a few small <code>tmpfs</code> mounts the daemon needs at runtime (<code>/run</code> for its sockets, <code>/var/lib/dovecot</code> for its instance registry, and a <code>noexec</code> <code>/tmp</code> that <code>$HOME</code> points at, pyzor and razor insist on writing a dotfile somewhere). Everything else is carved in stone. Drop a webshell and it&#8217;s got nowhere to live, and because <code>/tmp</code> is mounted <code>noexec</code>, nowhere to run even if it did.</p>

<p><strong>Architecture-independent malloc tuning.</strong> The image runs unchanged on amd64 and arm64; the LD_PRELOAD&#8217;d allocator (mimalloc or jemalloc) is selected at runtime by resolving the multiarch lib dir in <code>bootstrap.sh</code>, so there&#8217;s no per-arch image to maintain.</p>

<p>And the binaries themselves are hardened at compile time, not just at runtime. Our Dovecot package builds with <code>-D_FORTIFY_SOURCE=3</code> (tighter buffer checks than the usual level 2), <code>-fstack-clash-protection</code>, <code>-fcf-protection=full</code> for Intel CET branch-target enforcement on amd64, <code>-fno-plt</code> to shrink the PLT-overwrite attack surface, and full RELRO with bindnow. None of these break old hardware, they&#8217;re no-ops on silicon that lacks the feature, but they make memory-corruption exploitation genuinely harder on hardware that has it.</p>

<h2 style="color:#f59e0b">Sieve: your inbox filing itself</h2>

<p>Now the fun part, because security without convenience just trains users to disable it.</p>

<p><strong>Sieve</strong> (RFC 5228) is a small, deliberately limited scripting language for filtering mail <em>at delivery time</em>, on the server, before your client ever sees it. It&#8217;s implemented in Dovecot by the <strong>Pigeonhole</strong> plugin, which tracks Dovecot&#8217;s release cycle tag-for-tag (our build resolves both from matching version tags). The language is intentionally not Turing-complete, no loops, no shell-out, no way to wedge the delivery agent in an infinite loop. That constraint is a feature: a hostile or buggy Sieve script can misfile your mail, but it can&#8217;t take down the server.</p>

<p>A trivial example that files everything from your mailing list into a folder and drops obvious spam:</p>

<pre><code>require ["fileinto", "mailbox"];

if header :contains "List-Id" "debian-devel" {
    fileinto :create "Lists/Debian";
}
if header :contains "X-Spam-Flag" "YES" {
    fileinto "Junk";
    stop;
}</code></pre>

<p>The killer feature is that users can manage their own Sieve scripts over <strong>ManageSieve</strong> (port 4190) without touching the server filesystem, Thunderbird, Roundcube and friends all speak it. And Sieve&#8217;s <code>vnd.dovecot.execute</code> extension is what lets you wire spam-reporting into the delivery pipeline, which is exactly where the next section comes in.</p>

<h2 style="color:#f59e0b">Batteries included: rspamc, spamc, pyzor, razor and the vimbadmin scripts</h2>

<p>A bare IMAP server is half a mail stack. Our dockerized image ships the other half, the spam-fighting and administration tooling that a real deployment needs, so you&#8217;re not <code>apt install</code>-ing things into a &#8220;temporary&#8221; container that somehow becomes production.</p>

<p>Inside the image you get the full spam-reporting client set: <strong>rspamc</strong> and <strong>spamc</strong> (the command-line clients for <a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd and SpamAssassin respectively</a>), plus <strong>pyzor</strong> and <strong>razor</strong>, two collaborative spam-fingerprinting networks. These aren&#8217;t decoration. Wire them into a Sieve script and &#8220;report this as spam&#8221; becomes a one-line filter action: the message gets fingerprinted, the fingerprint is fed back to the network, and Rspamd&#8217;s Bayesian classifier learns from it. Move a message to Junk, and the whole feedback loop fires. That&#8217;s how a self-hosted server gets <em>smarter</em> over time instead of staying as dumb as the day you installed it.</p>

<p>The image also bundles the <strong>vimbadmin helper scripts</strong>, the Perl glue that lets <a href="https://deb.myguard.nl/2026/06/vimbadmin-postfix-dovecot-mailbox-admin-panel/">ViMbAdmin, the Postfix + Dovecot mailbox admin panel</a>, drive Dovecot for quota reporting and mailbox operations. ViMbAdmin gives you a proper web UI (with TOTP and brute-force protection) for managing virtual domains, mailboxes and aliases, instead of writing raw SQL into your mail database at two in the morning. The helper scripts are what connect that web panel to Dovecot&#8217;s actual mailbox state. And because the whole thing pulls its packages straight from the <a href="https://deb.myguard.nl/2026/05/deb-myguard-apt-repository-layout-per-package-trees/">deb.myguard.nl APT repository</a> baked into the base image, every component is the same hardened build, signed by the same key.</p>

<p>And when Sieve needs to send mail outward, a <code>redirect</code> that forwards a copy elsewhere, a <code>vacation</code> auto-reply, a <code>notify</code>, the container doesn&#8217;t run its own MTA at all. There&#8217;s no <code>sendmail</code> binary in the image, nothing to exploit. Instead Dovecot hands the outbound message over SMTP to the real <a href="https://deb.myguard.nl/2026/06/postfix-post-quantum-tls-tlsrpt-milters-modern-mta/">Postfix</a> container via <code>submission_host</code>. Mail <em>access</em> is Dovecot&#8217;s job; mail <em>transport</em> is Postfix&#8217;s, with all the queueing, retries, DKIM and bounce handling that implies. One egress point, one place to sign and rate-limit, and the smallest possible attack surface on the mailbox server itself.</p>

<h2 style="color:#f59e0b">Putting it together</h2>

<p>Pull the picture back and the design philosophy is consistent at every layer. The crypto is post-quantum where it counts and forward-secret everywhere. The cipher list is AEAD-only and the protocol floor is TLS 1.2. The root master is caged behind dropped capabilities and no-new-privileges, hands every hostile byte to an unprivileged separated user, and sits on a read-only filesystem. The binaries are fortified, CET-protected and RELRO-locked. The spam pipeline learns. The admin happens through a hardened web panel instead of raw SQL. And the whole thing is two distros wide on purpose, because we&#8217;d rather ship a smaller promise we can actually keep than a bigger one we can&#8217;t.</p>

<p>Your mailbox is going to outlive the cryptography currently protecting it. The only question is whether someone&#8217;s keeping a copy of the handshake. Build for the answer being yes.</p>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-cert" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I need a special certificate for post-quantum Dovecot?</h3>
<div class="rank-math-answer ">

<p>No. The post-quantum protection here is in the key exchange (ML-KEM hybrid), which is negotiated automatically by OpenSSL 3.5+ during the TLS handshake and needs no certificate change at all. Your existing RSA or ECDSA certificate works unchanged. Post-quantum signatures (ML-DSA certificates) are a separate, still-maturing topic and aren&#8217;t required to defend against harvest-now-decrypt-later, which targets key exchange, not the signature.</p>

</div>
</div>
<div id="rm-faq-distros" class="rank-math-list-item">
<h3 class="rank-math-question ">Why is the Dovecot package only built for trixie and resolute?</h3>
<div class="rank-math-answer ">

<p>Hybrid ML-KEM post-quantum key exchange requires OpenSSL 3.5 or newer. Only Debian trixie and Ubuntu resolute ship that version. Older distributions carry OpenSSL 3.0 or 1.1, which physically cannot negotiate the post-quantum handshake, so building for them would ship a security promise the library can&#8217;t keep.</p>

</div>
</div>
<div id="rm-faq-ciphers" class="rank-math-list-item">
<h3 class="rank-math-question ">Will disabling old ciphers break my mail clients?</h3>
<div class="rank-math-answer ">

<p>Almost certainly not. We keep TLS 1.2 as a fallback with a full set of forward-secret AEAD ciphers, which every mail client built in the last decade supports. Only genuinely broken protocols (TLS 1.0/1.1) and weak ciphers (RC4, 3DES, CBC, non-forward-secret RSA key exchange) are removed. A client old enough to need those has worse problems than cipher selection.</p>

</div>
</div>
<div id="rm-faq-sieve" class="rank-math-list-item">
<h3 class="rank-math-question ">What is Sieve and do my users need to know how to code?</h3>
<div class="rank-math-answer ">

<p>Sieve is a small, safe scripting language for filtering mail at delivery time, implemented in Dovecot via the Pigeonhole plugin. Users don&#8217;t need to write it by hand: mail clients like Thunderbird and Roundcube provide graphical filter editors that speak ManageSieve and generate the script for them. The language is deliberately not Turing-complete, so a bad script can misfile mail but can&#8217;t crash the server.</p>

</div>
</div>
<div id="rm-faq-uid" class="rank-math-list-item">
<h3 class="rank-math-question ">If there&#8217;s a root master process, is the container really hardened?</h3>
<div class="rank-math-answer ">

<p>Yes, and keeping the root master is the safer choice, not a compromise. Dovecot&#8217;s root master never parses network input: it binds ports, loads the TLS key, then hands every internet-facing job to unprivileged users (dovenull for the pre-auth login workers, vmail for mail). That privilege separation is Dovecot&#8217;s strongest mitigation, so collapsing everything to one uid would actually weaken it. The root is caged: cap_drop ALL with only a few capabilities added back, no-new-privileges, AppArmor, every setuid binary stripped, and a read-only rootfs. A compromise of a login worker lands an attacker as dovenull in a chroot with no capabilities and nothing to escalate through.</p>

</div>
</div>
<div id="rm-faq-spam" class="rank-math-list-item">
<h3 class="rank-math-question ">How do rspamc, pyzor and razor fit into Dovecot?</h3>
<div class="rank-math-answer ">

<p>They&#8217;re spam-reporting clients bundled in the image so they can be wired into Sieve delivery scripts. When a user reports spam or a message is filed to Junk, a Sieve action can fingerprint it with pyzor and razor, feed the fingerprint back to those collaborative networks, and tell Rspamd to learn from it. This feedback loop makes the spam filter progressively more accurate for your specific mail flow.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related reading</h2>

<ul>
<li><a href="https://deb.myguard.nl/2026/05/postfix-3-11-post-quantum-tls-tlsrpt-milters-and-the-modern-mta-stack/">Postfix 3.11: Post-Quantum TLS, TLSRPT, Milters and the Modern MTA Stack</a>: the MTA half of the stack, with the same post-quantum TLS story on the sending side.</li>
<li><a href="https://deb.myguard.nl/2026/06/vimbadmin-postfix-dovecot-mailbox-admin-panel/">ViMbAdmin: The Postfix + Dovecot Mailbox Admin Panel</a>: the web UI that manages the virtual domains and mailboxes Dovecot serves.</li>
<li><a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd Explained: How Modern Spam Filtering Actually Works</a>: the spam engine that rspamc, pyzor and razor feed into.</li>
<li><a href="https://deb.myguard.nl/2026/06/hardened-roundcube-docker-image/">Hardened Roundcube Docker: The Webmail Container That Trusts Nobody</a>: the same cap-dropped, read-only hardening philosophy, applied to webmail.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>HTTP/2 Bomb (CVE-2026-49975): The Memory DoS an AI Found</title>
		<link>https://deb.myguard.nl/2026/06/http2-bomb-cve-2026-49975-memory-dos/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Wed, 03 Jun 2026 16:47:16 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6070</guid>

					<description><![CDATA[An AI noticed two ten-year-old HTTP/2 tricks could be combined into one critical exploit. CVE-2026-49975, the HTTP/2 Bomb, drives a single server to 32 GB of memory in seconds. Here is how it works on nginx, Apache, IIS, Envoy and Pingora — and how to defend it.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">An AI model read the source code of every major web server, noticed that two ten-year-old attacks could be bolted together, and wrote the exploit nobody had written. The result, <a href="https://nvd.nist.gov/vuln/detail/CVE-2026-49975" rel="noopener" target="_blank"><strong>CVE-2026-49975</strong></a>, the <strong>HTTP/2 Bomb</strong>, pushes an Envoy server from idle to roughly <strong>32 GB of allocated memory in about ten seconds</strong> at an amplification ratio near <strong>5,700:1</strong>. Disclosed publicly on 2 June 2026. Both halves of the trick had been sitting in the open since 2016. It just took something with infinite patience and no ego to notice they rhymed. The HTTP/2 Bomb is the rare CVE where the writeup is as interesting as the patch.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/http2-bomb-cve.webp" alt="HTTP/2 Bomb CVE-2026-49975 memory exhaustion DoS attack" width="1024" height="576" loading="lazy"/></figure>



<p class="wp-block-paragraph">Pull up a chair. I&#8217;ve kept enough servers breathing through enough 3am pages to have firm opinions about HTTP/2, and this one&#8217;s a beauty, not because it&#8217;s clever in some galaxy-brained way, but because it&#8217;s <em>boring</em>. It&#8217;s two well-documented behaviours doing exactly what the spec says, multiplied together until your RAM evaporates. The CVSS score is 9.8. Critical. The kind of number that ruins a Tuesday.</p>



<p class="wp-block-paragraph">And before you ask: yes, this is a <em>different</em> attack from HTTP/2 Rapid Reset. Rapid Reset (CVE-2023-44487) burned CPU by opening and cancelling streams. This one barely touches your CPU. It goes after memory, and it does it by exploiting the gap between two limits your server thought were the same limit. Let me show you.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The HTTP/2 Bomb in one idea: two limits, not one</h2>



<p class="wp-block-paragraph">Every defended HTTP/2 server caps the <strong>maximum decoded header size</strong>. Send a megabyte of headers and the server says &#8220;no thanks&#8221; and drops you. This cap exists precisely because of the original 2016 HPACK Bomb (CVE-2016-6581): stuff one enormous value into the compression table, reference it a thousand times, and a tiny request balloons into gigabytes once decompressed. We learned that lesson. We capped total decoded size. Problem solved, we thought.</p>



<p class="wp-block-paragraph">Here&#8217;s the gap, and it&#8217;s the whole attack in one sentence: <strong>&#8220;maximum decoded header size&#8221; and &#8220;maximum header <em>count</em>&#8221; are two different limits, and most servers only enforced the first one.</strong> They counted the bytes. They didn&#8217;t count the <em>fields</em>. And it turns out the expensive part of a header isn&#8217;t the bytes, it&#8217;s the per-entry bookkeeping the server allocates around each field. Struct here, pointer there, a little metadata, an entry in a table. Do that ten thousand times with near-empty headers and you&#8217;ve spent almost no decoded bytes while forcing the server to allocate a mountain of accounting structures. The size cap never fires, because there&#8217;s almost no size. You walked straight under the bar.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Part one: the HPACK indexed-reference bomb (the cheap multiplier)</h2>



<p class="wp-block-paragraph">HTTP/2 compresses headers with HPACK, which keeps a <strong>dynamic table</strong>, a per-connection dictionary of headers you&#8217;ve already sent, so repeats cost one byte instead of the whole string. Lovely for efficiency. The attack seeds the table with one header, then emits <strong>thousands of one-byte indexed references</strong> to it. Each single wire byte expands into a full header field on the server side, and crucially, into the per-field bookkeeping that goes with it.</p>



<p class="wp-block-paragraph">How expensive is each byte? Depends on the server&#8217;s internals, and the spread is wild. The discoverer&#8217;s measured numbers: each wire byte translates to somewhere between <strong>70 and 4,000 bytes</strong> of server allocation. nginx, which is fairly lean, sits around 70:1. Apache, which rebuilds merged header strings repeatedly, hits roughly 4,000:1. Same protocol, same attack, two orders of magnitude difference purely from how each codebase chose to store a header. The spec was honest; the implementations each picked their own poison.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The Cookie crumb trick: how to dodge a header-count cap</h2>



<p class="wp-block-paragraph">&#8220;Fine,&#8221; says the server that <em>did</em> cap header count, smugly. &#8220;I limit you to 1,000 fields. Good luck.&#8221; And here&#8217;s where it gets gloriously petty. <strong>RFC 9113 §8.2.3 explicitly permits splitting the <code>Cookie</code> header into one field per crumb.</strong> That&#8217;s a real, deliberate feature, HTTP/2 lets a client send <code>cookie: a=1</code>, <code>cookie: b=2</code>, <code>cookie: c=3</code> as separate fields for better compression, and the server is required by the standard to stitch them back together.</p>



<p class="wp-block-paragraph">Most servers weren&#8217;t counting Cookie crumbs against their field limit, because spec-wise they&#8217;re &#8220;one logical header.&#8221; So the attacker pours thousands of empty cookie crumbs down the pipe. Each one is a field. Each field needs bookkeeping. And on Apache specifically, the server <strong>rebuilds the merged cookie string repeatedly</strong> as crumbs arrive, quadratic-ish string reconstruction over empty data, producing that ~4,000:1 amplification with cookies that contain literally nothing. You&#8217;re not even sending data. You&#8217;re sending the <em>idea</em> of data, and the server does all the work.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Part two: the window stall, pinning the memory so it never frees</h2>



<p class="wp-block-paragraph">A bomb that detonates and clears up isn&#8217;t much of a bomb. You&#8217;d allocate, respond, free, and recover. So the second half of the attack makes sure the server can <em>never let go</em> of what it allocated. This is a Slowloris-style hold dressed up in HTTP/2 flow control.</p>



<p class="wp-block-paragraph">HTTP/2 has per-stream flow control: the receiver advertises a <strong>window</strong> saying &#8220;I can accept this many bytes of your response right now.&#8221; The attacker advertises a <strong>zero-byte window</strong>. The server, obedient, prepares its response, holds it in memory, and waits to send, because the client has said it can&#8217;t receive a single byte yet. The response, and every allocation behind it, is now pinned. Then, to stop the server&#8217;s idle timeout from giving up and freeing everything, the attacker <strong>drips one-byte <code>WINDOW_UPDATE</code> frames</strong>, each one resetting the send timeout. Just enough life support to look alive, never enough to drain the buffer. Every allocation stays nailed in place. Multiply across thousands of streams across thousands of connections and you are out of memory in seconds.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="630" src="https://deb.myguard.nl/wp-content/uploads/2026/06/http2-bomb-cve-2026-49975-attack-chain.webp" alt="HTTP/2 Bomb CVE-2026-49975 attack chain: HPACK indexed-reference bomb plus Cookie crumb amplification plus zero-window flow-control stall pin memory until the server runs out of RAM" class="wp-image-6078" srcset="https://deb.myguard.nl/wp-content/uploads/2026/06/http2-bomb-cve-2026-49975-attack-chain.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/06/http2-bomb-cve-2026-49975-attack-chain-300x158.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/06/http2-bomb-cve-2026-49975-attack-chain-1024x538.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/06/http2-bomb-cve-2026-49975-attack-chain-768x403.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /><figcaption>The full chain: cheap indexed references and empty cookie crumbs inflate per-field bookkeeping, then a zero-byte flow-control window with drip-fed WINDOW_UPDATE frames pins every allocation so it never frees.</figcaption></figure>



<h2 class="wp-block-heading" style="color:#f59e0b">The numbers, because they&#8217;re genuinely frightening</h2>



<p class="wp-block-paragraph">The discoverer published demo figures, and they&#8217;re the kind of thing you read twice. These are single-attacker results, not botnets:</p>



<figure class="wp-block-table"><table><thead><tr><th>Server</th><th>Amplification</th><th>Demo result</th></tr></thead><tbody>
<tr><td>Envoy 1.37.2</td><td>~5,700:1</td><td>~32 GB in ~10 s</td></tr>
<tr><td>Apache httpd 2.4.67</td><td>~4,000:1</td><td>~32 GB in ~18 s</td></tr>
<tr><td>nginx 1.29.7</td><td>~70:1</td><td>~32 GB in ~45 s</td></tr>
<tr><td>Microsoft IIS</td><td>~68:1</td><td>~64 GB in ~45 s</td></tr>
</tbody></table></figure>



<p class="wp-block-paragraph">nginx&#8217;s relatively modest 70:1 is the upside of a lean codebase, it still falls over, just more slowly. Envoy&#8217;s 5,700:1 is what happens when rich internal data structures meet an attack that bills you per structure. Note there&#8217;s no botnet column. One machine. One attacker. Tens of gigabytes. That asymmetry is the entire point of a DoS, and this is one of the cleanest ratios ever published against mainstream software.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">How to defend it, patch first, then defence in depth</h2>



<p class="wp-block-paragraph">Good news, mostly: the responsible-disclosure process worked, and the fix is conceptually simple once you name the bug. The bug was &#8220;we counted bytes, not fields.&#8221; The fix is &#8220;count fields too.&#8221; Here&#8217;s the layered defence.</p>



<h3 class="wp-block-heading">nginx, upgrade to 1.29.8+ and meet <code>max_headers</code></h3>



<p class="wp-block-paragraph">nginx was told in April 2026 and shipped a fix <em>the next day</em>, a turnaround that deserves a quiet round of applause. Version <strong>1.29.8</strong> adds a new <code>max_headers</code> directive that caps the header <em>field count</em> independently of size, defaulting to <strong>1000</strong>. That default is sane for almost everyone; lower it if your app genuinely never sends a thousand headers (it doesn&#8217;t).</p>



<pre class="wp-block-preformatted"># nginx 1.29.8+
http2 on;
max_headers 1000;   # new directive: caps header FIELD count, default 1000</pre>



<p class="wp-block-paragraph">Can&#8217;t upgrade today? Turn HTTP/2 off until you can:</p>



<pre class="wp-block-preformatted">http2 off;</pre>



<h3 class="wp-block-heading">Apache httpd, mod_http2 v2.0.41</h3>



<p class="wp-block-paragraph">Disclosed to Apache on 27 May 2026; Stefan Eissing committed a fix the same day (these maintainers are not messing around). The fix lives in <strong>mod_http2 v2.0.41</strong>, available from the standalone mod_http2 releases and in httpd trunk, but at disclosure it was <strong>not yet in a 2.4.x release</strong>. The patch makes Cookie crumbs count against <code>LimitRequestFields</code>. Until you can deploy it, lowering <code>LimitRequestFieldSize</code> is a partial mitigation, or kill HTTP/2 outright:</p>



<pre class="wp-block-preformatted"># Apache: disable HTTP/2 until mod_http2 2.0.41 is deployed
Protocols http/1.1</pre>



<h3 class="wp-block-heading">IIS, Envoy, Cloudflare Pingora, no patch yet</h3>



<p class="wp-block-paragraph">At disclosure, Microsoft IIS, Envoy, and Cloudflare Pingora had <strong>no patch available</strong>. If you run these on a public HTTP/2 endpoint, your options are: disable HTTP/2 where you can, or front the server with something that enforces a hard cap on header-field count per request. A reverse proxy or WAF that rejects requests with absurd field counts turns the bomb into a dud before it reaches the vulnerable allocator. This is, incidentally, exactly the architecture we run, a hardened nginx/Angie front edge that can be told &#8220;no request needs 9,000 cookie crumbs, drop it.&#8221;</p>



<h3 class="wp-block-heading">The general principle</h3>



<p class="wp-block-paragraph">If you write protocol code: <strong>treat &#8220;maximum decoded size&#8221; and &#8220;maximum field count&#8221; as separate, independent limits.</strong> They are not proxies for each other. Bytes are cheap; bookkeeping is not. Anywhere a client can make you allocate a per-item structure, count the items and cap them, even if each item is empty, <em>especially</em> if each item is empty, because empty is exactly what an attacker will send.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The lesson, because there&#8217;s always a lesson</h2>



<p class="wp-block-paragraph">The two ingredients, HPACK reference amplification and flow-control stalling, were public for a decade. Anyone could have read them. The reason nobody combined them isn&#8217;t that it was hard; it&#8217;s that it lived in the seam between two specialisms, and humans are bad at the seams. We file HPACK under &#8220;compression,&#8221; flow control under &#8220;transport,&#8221; and never think to multiply them. An AI model with no such filing system read both codebases end to end and saw one attack where we saw two unrelated footnotes.</p>



<p class="wp-block-paragraph">That&#8217;s the real story here, and it&#8217;s coming for every protocol you run. The next decade of vulnerabilities won&#8217;t all be novel bugs. A lot of them will be old, documented, individually-harmless behaviours that nobody bothered to compose, dragged into the light by something that reads faster than you and forgets nothing. The defence is the same as it&#8217;s always been, count what costs you, cap what a stranger can make you do, and assume the seams between your subsystems are exactly where the next bomb is hiding. Go check your nginx version. I&#8217;ll wait.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Frequently asked questions</h2>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Is CVE-2026-49975 the same as HTTP/2 Rapid Reset?</h3>
<div class="rank-math-answer ">

<p>No. Rapid Reset (CVE-2023-44487) is a CPU-exhaustion attack that opens and instantly cancels streams. The HTTP/2 Bomb (CVE-2026-49975) is a memory-exhaustion attack: it inflates per-header-field bookkeeping with cheap HPACK indexed references and empty Cookie crumbs, then pins those allocations in memory with a zero-byte flow-control window. Different resource, different mechanism, both abuse HTTP/2&#8217;s design rather than a coding bug.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Am I vulnerable, and how do I check?</h3>
<div class="rank-math-answer ">

<p>If you serve HTTP/2 on nginx before 1.29.8, Apache httpd without mod_http2 2.0.41, or any version of Microsoft IIS, Envoy, or Cloudflare Pingora at disclosure, you are exposed in the default configuration. Check your nginx version with `nginx -v` and your Apache mod_http2 version. The vulnerability is in the default HTTP/2 config, so you do not need any special setup to be affected.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">What is the fastest way to mitigate if I cannot patch?</h3>
<div class="rank-math-answer ">

<p>Disable HTTP/2. On nginx use `http2 off;`, on Apache set `Protocols http/1.1`. Alternatively, front your server with a reverse proxy or WAF that enforces a hard cap on the number of header fields per request, that rejects the bomb before it reaches the vulnerable allocator. Disabling HTTP/2 costs you multiplexing performance but stops the attack dead.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Why do empty cookies cause so much damage?</h3>
<div class="rank-math-answer ">

<p>RFC 9113 §8.2.3 lets a client split the Cookie header into one field per crumb, and many servers did not count those crumbs against their header-field limit. Each crumb still forces a per-field allocation, and Apache rebuilds the merged cookie string repeatedly as crumbs arrive, producing roughly 4,000:1 amplification even when every cookie is empty. The damage comes from per-entry bookkeeping, not from the data, so empty crumbs are the most efficient payload.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">How was the HTTP/2 Bomb discovered?</h3>
<div class="rank-math-answer ">

<p>An AI model (Codex) analysed the source code of major web servers and recognised that two long-public techniques, the 2016 HPACK indexed-reference bomb and HTTP/2 flow-control stalling, could be composed into a single new attack. Both halves had been documented for about a decade; the contribution was reading the codebases, seeing that they combine, and building the combined exploit.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">How bad is it really?</h3>
<div class="rank-math-answer ">

<p>CVSS 9.8 (Critical). A single attacker, no botnet, drove demo servers to roughly 32 GB of memory in 10 to 45 seconds, with amplification ratios from about 70:1 on nginx up to about 5,700:1 on Envoy. Because it exhausts memory rather than CPU, it can take a server fully offline (OOM-killed) extremely quickly, which is why patching or disabling HTTP/2 is urgent.</p>

</div>
</div>
</div>
</div>


<h2 class="wp-block-heading" style="color:#f59e0b">Related reading</h2>



<ul class="wp-block-list">
<li><a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">What Is the BREACH Attack? How It Works and How to Stop It</a>: another attack that weaponises a normal HTTP feature (compression). Same &#8220;the spec was honest, the threat model wasn&#8217;t&#8221; flavour.</li>
<li><a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: put a real WAF in front of the endpoints that need a hard header-count cap.</li>
<li><a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker Hardening for Self-Hosters: Rootless, Read-Only, Cap-Drop, Distroless</a>: memory limits and cap-drop are exactly the blast-radius control that turns an OOM into a contained restart.</li>
<li><a href="https://deb.myguard.nl/nginx-modules/">NGINX Modules, Optimized &amp; Extended</a>: the hardened nginx builds, packaged for Debian and Ubuntu, where fixes like 1.29.8 land fast.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Hardened Roundcube Docker: The Webmail Container That Trusts Nobody</title>
		<link>https://deb.myguard.nl/2026/06/hardened-roundcube-docker-image/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Wed, 03 Jun 2026 15:48:58 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[php-fpm]]></category>
		<category><![CDATA[roundcube]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[selfhosted]]></category>
		<category><![CDATA[snuffleupagus]]></category>
		<category><![CDATA[webmail]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6065</guid>

					<description><![CDATA[Our hardened Roundcube Docker image runs as nobody, can chown nothing, and treats every request as hostile. Here is the full unprivileged + WAF security model — and why default webmail containers are a liability.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The average compromised webmail container gets popped not through some elegant zero-day, but because it ran as <code>root</code>, mounted its filesystem read-write, kept every Linux capability it was born with, and answered to a user-agent string of <code>sqlmap/1.8</code> without so much as a raised eyebrow. That&#8217;s not a hack. That&#8217;s an open door with a &#8220;Welcome&#8221; mat and the keys taped to the frame. This is how to build a hardened Roundcube Docker image instead: one that assumes every request, every plugin, and every logged-in user is hostile until proven otherwise.</p>



<p class="wp-block-paragraph">I&#8217;ve spent enough years watching default container images get owned to develop a particular kind of contempt for the phrase &#8220;it works out of the box.&#8221; Of course it works out of the box. So does a bank vault with the door left open. The question was never whether it <em>works</em>. The question is what happens when someone who isn&#8217;t you starts poking it. And the honest answer, for most webmail Docker images on the registry, is: nothing good, very quickly, and you&#8217;ll find out from your logs three weeks later, assuming you read your logs, which, let&#8217;s be honest, you don&#8217;t.</p>



<p class="wp-block-paragraph">So we built <a href="https://hub.docker.com/r/eilandert/roundcube" target="_blank" rel="noopener">eilandert/roundcube</a>: a <strong>hardened Roundcube Docker image</strong> that runs as a non-existent user, can&#8217;t change the ownership of a single file, treats its own root filesystem as carved in granite, and has a web application firewall bolted to the front that assumes, correctly, that the internet is full of people who want to ruin your day. This is the tour. Bring coffee.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What a hardened Roundcube Docker image actually is (and why you&#8217;d containerise it)</h2>



<p class="wp-block-paragraph">Roundcube is webmail. It&#8217;s the thing that turns a raw IMAP mailbox into a browser interface that normal humans can use without learning <code>mutt</code> keybindings or developing a thousand-yard stare. You point it at an IMAP server for reading mail and an SMTP server for sending it, give it a database to remember contacts and preferences, and it hands your users a clean, fast, plugin-extensible inbox in any browser. It&#8217;s PHP. It&#8217;s been around since 2008. It is, by webmail standards, genuinely lovely software.</p>



<p class="wp-block-paragraph">Here&#8217;s the thing the marketing pages won&#8217;t tell you: Roundcube is a <em>PHP application that sits directly between the public internet and your users&#8217; email</em>. Read that sentence again. Every credential, every private message, every password-reset link in someone&#8217;s inbox is on the other side of this login form. That makes it one of the highest-value targets you can self-host. A compromised blog is embarrassing. A compromised webmail is a credential-harvesting machine that hands an attacker the keys to every other account those users own, because where do you think password reset emails go?</p>



<p class="wp-block-paragraph">Containerizing it is the right call. You get a reproducible, version-pinned, throwaway runtime that you can rebuild from scratch in ninety seconds and that doesn&#8217;t leave PHP extensions rotting on your host for the next decade. But, and this is the entire point of this post, a container is not a security boundary by default. Out of the box, Docker gives a process inside the container <code>root</code>, a writable filesystem, and a frankly alarming pile of capabilities. If your &#8220;hardened&#8221; webmail image is really just upstream Roundcube with a <code>Dockerfile</code> that says <code>USER root</code> and calls it a day, you have containerized the blast radius, not contained it.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The Luser&#8217;s Container vs. Ours: Running as Nobody</h2>



<p class="wp-block-paragraph">Let&#8217;s start with the single most important word in this entire image: <strong>unprivileged</strong>. The container runs as UID 10001, a system user called <code>roundcube</code> that owns the application code and absolutely nothing else of value. PID 1 inside the container, the very first process, is <em>not</em> root. The Angie web server master, the PHP-FPM master, every worker they spawn, all of them are this nobody user.</p>



<p class="wp-block-paragraph">Why does this matter so much? Because the classic container escape story goes: attacker gets code execution inside the container (say, through a PHP bug), and then uses the fact that they&#8217;re <code>root</code> inside it to chew through a kernel vulnerability or a misconfigured mount and land as <code>root</code> on your <em>host</em>. If the process was never root to begin with, that entire escalation chain loses its first link. The attacker who pops our PHP worker finds themselves as UID 10001, who owns some read-only PHP files and has the system privileges of a particularly disappointing houseplant.</p>



<p class="wp-block-paragraph">To make this work, the whole identity model is collapsed to one user. There&#8217;s no root master &#8220;dropping&#8221; to a worker user via <code>setuid()</code>, because a non-root master <em>can&#8217;t</em> setuid, that needs <code>CAP_SETUID</code>, which (spoiler) we don&#8217;t have. Instead, both Angie and PHP-FPM inherit the container&#8217;s identity from the start. The Angie config has no <code>user</code> directive. The PHP-FPM pool has its <code>user</code> and <code>group</code> lines commented out. Set them, and a non-root master throws an error and refuses to boot. We learned that the polite way, by reading the error message, which is more than I can say for most people.</p>



<p class="wp-block-paragraph">And here&#8217;s the consequence that trips up everyone the first time: <strong>the container cannot <code>chown</code> anything.</strong> Not won&#8217;t, <em>can&#8217;t</em>. With all capabilities dropped, there&#8217;s no <code>CAP_CHOWN</code> to change file ownership. So every writable mount has to already be owned by UID 10001 on the host before the container starts. Named Docker volumes inherit this automatically from the image&#8217;s directory ownership. Bind mounts you pre-<code>chown</code> yourself: <code>sudo chown -R 10001:10001 ./roundcube/config</code>. Forget to, and the container fails to boot with a <code>Permission denied</code>, which is annoying for about thirty seconds and then deeply reassuring, because it means the container genuinely has no power to fix it on its own. That&#8217;s the whole idea.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Dropping Every Capability and Nailing the Filesystem Shut</h2>



<p class="wp-block-paragraph">Linux capabilities are the granular pieces that <code>root</code>&#8216;s god-mode used to be one indivisible blob. Bind to a low port? <code>CAP_NET_BIND_SERVICE</code>. Change file ownership? <code>CAP_CHOWN</code>. Override file permission checks entirely? <code>CAP_DAC_OVERRIDE</code>, the capability that says &#8220;permissions are a suggestion.&#8221; Default Docker hands a container a pile of these for no reason other than tradition. Our compose file says <code>cap_drop: [ALL]</code> and adds back exactly zero. The container runs with an empty capability set. <code>CapEff</code> is <code>0000000000000000</code>. It has nothing.</p>



<p class="wp-block-paragraph">&#8220;But how does it bind a port with no <code>CAP_NET_BIND_SERVICE</code>?&#8221; Excellent question, you&#8217;re paying attention. It doesn&#8217;t bind a privileged one. Angie listens on <strong>:8080</strong>, which is above 1024 and therefore bindable by any user without special powers. You terminate TLS at an upstream reverse proxy and point it at :8080. The container never needs to touch a privileged port, so it never needs the capability to, so we can take it away. This is the recurring theme: every capability we don&#8217;t need, we design out, then drop. You can read the full philosophy in our <a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker hardening guide for self-hosters</a>, which is the ten-flag checklist this image is built on.</p>



<p class="wp-block-paragraph">Then we glue the floor down. <code>read_only: true</code> mounts the entire root filesystem immutable. An attacker who gets a write primitive has nowhere to write a webshell, nowhere to drop a cron payload, nowhere to modify the application code that&#8217;s about to execute. The only writable surfaces are explicit, minimal, and isolated: a tmpfs at <code>/tmp</code> (sockets, the PID file, Angie&#8217;s scratch temp dirs, Roundcube&#8217;s attachment staging) owned by UID 10001, and a config volume holding exactly one generated file. That&#8217;s it. Everything else is granite.</p>



<p class="wp-block-paragraph">We finish the container layer with two more flags that cost nothing and matter enormously. <code>no-new-privileges: true</code> sets the kernel bit that makes <code>setuid</code> binaries and similar escalation tricks no-ops, even if some leftover setuid binary existed (we strip the bits at build time anyway, belt and braces), it could not be used to gain privilege. And <code>apparmor=docker-default</code> loads the host&#8217;s AppArmor profile, a mandatory-access-control layer that constrains what syscalls and paths the process can touch regardless of file permissions. AppArmor is enforced by the host kernel, not bundled in the image, so your Docker host needs it, most do by default on Debian and Ubuntu.</p>



<figure class="wp-block-image"><img loading="lazy" decoding="async" width="1200" height="630" src="https://deb.myguard.nl/wp-content/uploads/2026/06/hardened-roundcube-docker-security-layers.webp" alt="Hardened Roundcube Docker security layers: unprivileged UID 10001 PID 1, cap-drop ALL, read-only rootfs, Snuffleupagus, and the Angie WAF" class="wp-image-6067" srcset="https://deb.myguard.nl/wp-content/uploads/2026/06/hardened-roundcube-docker-security-layers.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/06/hardened-roundcube-docker-security-layers-300x158.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/06/hardened-roundcube-docker-security-layers-1024x538.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/06/hardened-roundcube-docker-security-layers-768x403.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /></figure>



<h2 class="wp-block-heading" style="color:#f59e0b">Snuffleupagus: The PHP Bodyguard That Doesn&#8217;t Trust PHP</h2>



<p class="wp-block-paragraph">PHP is a wonderful language to attack. Decades of footguns, a function for every dangerous thing you could possibly want to do, and a community-wide habit of <code>eval()</code>-ing things that should never be eval&#8217;d. So we don&#8217;t trust it. We run <a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">Snuffleupagus</a>, a PHP hardening extension, conceptually the spiritual successor to Suhosin, with a strict, source-audited rulebook tailored to Roundcube.</p>



<p class="wp-block-paragraph">Snuffleupagus operates at the engine level. It can virtually patch known-vulnerable function behaviors, kill dangerous functions entirely, enforce that uploaded files can never be executed, lock cookies to secure flags from inside the interpreter, and detect when a function is being called from a context it has no business being called from. The point is defense at a layer the application code can&#8217;t override. An <code>ini_set()</code> in some compromised plugin can&#8217;t undo a Snuffleupagus rule. It&#8217;s a bodyguard who reports to the building, not to the guest.</p>



<p class="wp-block-paragraph">Backing it up at the PHP-FPM pool level is a layer of <code>php_admin_value</code> hardening, the <code>admin</code> prefix meaning userland <code>ini_set()</code> cannot override these. <code>open_basedir</code> confines PHP&#8217;s filesystem access to the application directory plus its few writable runtime paths; PHP literally cannot <code>fopen()</code> outside that jail, so the classic &#8220;read <code>/etc/passwd</code> via path traversal&#8221; trick returns nothing. <code>disable_functions</code> nukes the obvious subprocess and reconnaissance surface, <code>exec</code>, <code>shell_exec</code>, <code>system</code>, <code>proc_open</code>, <code>passthru</code>, <code>pcntl_exec</code>, <code>php_uname</code>, the lot, so even a perfect remote-code-execution bug finds the gun cabinet welded shut.</p>



<p class="wp-block-paragraph">And the session cookies get the full treatment: <code>secure</code> (never sent over plaintext HTTP), <code>httponly</code> (invisible to JavaScript, so an XSS can&#8217;t steal the session), and <code>samesite=Strict</code> (not sent on cross-site requests, which guts CSRF). Session IDs are 64 characters of strict-mode entropy. None of this is exotic. All of it is the stuff that &#8220;works out of the box&#8221; images can&#8217;t be bothered to set, because the box ships with the defaults PHP picked in 2009.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The Angie WAF: Assuming Every Request Is an Attack</h2>



<p class="wp-block-paragraph">The web server is <a href="https://deb.myguard.nl/angie-modules-optimized-extended/">Angie</a> (the nginx fork with the saner config and the better defaults), and we&#8217;ve turned its front door into a checkpoint. Not a friendly one. The kind that assumes you&#8217;re guilty and makes you prove otherwise.</p>



<p class="wp-block-paragraph">First, <strong>gzip is off</strong>. Entirely. This is not a performance oversight, it&#8217;s deliberate, and it stops the <a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">BREACH attack</a>. BREACH is a compression side-channel: if you compress a response that contains both a secret (like a CSRF token) and attacker-influenced content, the size of the compressed output leaks information about the secret, one byte at a time. Roundcube&#8217;s HTML is stuffed with session-bound CSRF tokens. Compressing it dynamically would hand an attacker a BREACH oracle. So we don&#8217;t. The static assets are small and cacheable; the bandwidth cost of leaving gzip off is trivial; the security win is real.</p>



<p class="wp-block-paragraph">Second, the server actually knows who it&#8217;s talking to. Sitting behind a reverse proxy, a naive backend sees every request as coming from the proxy&#8217;s IP, which makes per-client rate limiting useless and turns your failed-login tracking into a single bucket for the entire internet. We configure <code>real_ip</code> to trust the private proxy ranges and read the true client address from <code>X-Forwarded-For</code>. Now the controls below can actually distinguish one attacker from ten thousand legitimate users behind the same proxy.</p>



<p class="wp-block-paragraph">Third, the scanner gate. Empty user-agent? <code>return 444</code>, Angie&#8217;s special &#8220;close the connection without so much as a response&#8221; code. User-agent matching <code>nikto</code>, <code>sqlmap</code>, <code>nmap</code>, <code>masscan</code>, <code>nuclei</code>, <code>wpscan</code>, and the rest of the script-kiddie starter pack? Same treatment. Yes, a competent attacker will change their user-agent. But the overwhelming majority of the traffic hammering any public endpoint is automated garbage that doesn&#8217;t bother, and 444-ing it costs us nothing while denying them the satisfaction of even a 403. It&#8217;s the digital equivalent of not buzzing the door.</p>



<p class="wp-block-paragraph">Fourth, login brute-force throttling. Roundcube funnels its login through <code>/index.php?_task=login&_action=login</code>, but every dynamic page hits <code>index.php</code> too, so a naive rate limit would throttle normal browsing into oblivion. The trick: a request map keys the rate-limit zone on the real client IP <em>only</em> for the login action, and on an empty string otherwise. An empty key isn&#8217;t counted. So the throttle, 12 requests per minute, with a small burst to absorb a human fat-fingering their password, applies to login attempts and login attempts alone. Brute-force a password here and you get a <code>429</code> after twelve tries a minute, keyed to your actual address, while the user next to you behind the same corporate proxy browses unbothered.</p>



<p class="wp-block-paragraph">And finally the response headers, the cheap stuff everyone forgets: a <strong>Content-Security-Policy</strong> that pins script, style, image, and connection sources to <code>self</code> (with the <code>unsafe-inline</code> concessions Roundcube&#8217;s skin genuinely requires, because it has no nonce support, we&#8217;re honest about that rather than shipping a CSP that breaks the UI and gets disabled), <code>frame-ancestors 'none'</code> to kill clickjacking, <code>object-src 'none'</code>, and a <strong>HSTS</strong> header with a two-year max-age, <code>includeSubDomains</code>, and <code>preload</code> eligibility, originating from the app so it survives the proxy. None of it is glamorous. All of it is the difference between &#8220;we thought about this&#8221; and &#8220;we shipped the defaults.&#8221;</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Defense in Depth, or: Why Bother With All Of It?</h2>



<p class="wp-block-paragraph">A reasonable person, not you, you&#8217;re reading a 3000-word post about webmail hardening, but a <em>reasonable</em> person, might ask why we need all of this. Surely one good layer is enough? Surely if the app is patched, the rest is paranoia?</p>



<p class="wp-block-paragraph">No. The entire premise of <strong>defense in depth</strong> is that any single layer <em>will</em> fail, because layers are written by humans and humans are a catastrophe. The WAF might miss a novel payload. Snuffleupagus might not have a rule for tomorrow&#8217;s CVE. The app might ship a bug between your last pull and your next one. So you stack independent controls such that the attacker has to defeat <em>all</em> of them, in sequence, to get anywhere, and each one buys you time, noise in the logs, and a decent chance they give up and go bother someone with a softer target.</p>



<p class="wp-block-paragraph">Walk the kill chain. An attacker finds a fresh Roundcube RCE. The WAF&#8217;s scanner gate already dropped their recon traffic, so they had to work harder to even map the surface. They land code execution, but they&#8217;re UID 10001, not root, so no privilege escalation freebie. They try to write a webshell, read-only filesystem, nowhere to put it. They try to spawn a reverse shell, <code>disable_functions</code> ate <code>exec</code> and friends, and Snuffleupagus is watching the rest. They try to read secrets off disk, <code>open_basedir</code> jails them to the app directory. They try to escalate through a kernel bug, no capabilities, <code>no-new-privileges</code> set, AppArmor constraining the syscall surface. At every single step, a layer they didn&#8217;t expect says no. That&#8217;s not paranoia. That&#8217;s just doing the job.</p>



<p class="wp-block-paragraph">It&#8217;s the same philosophy that runs through everything we self-host, from our <a href="https://deb.myguard.nl/2026/05/self-hosted-password-manager-with-vaultwarden/">self-hosted Vaultwarden setup</a> to the <a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">ModSecurity and OWASP CRS</a> stack we put in front of higher-risk apps. Layers. Always layers. The attacker only has to be right once; you have to be wrong everywhere, all at once, for it to matter, so make &#8220;everywhere, all at once&#8221; as expensive as you possibly can.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What&#8217;s In The Box: Plugins and Skins</h2>



<p class="wp-block-paragraph">Hardening is the point, but nobody wants webmail that&#8217;s a security exhibit and nothing else. So the image ships a curated pile of plugins and skins, pre-installed, loaded only when you ask for them via <code>ROUNDCUBEMAIL_PLUGINS</code>. Nothing runs that you didn&#8217;t list, minimal attack surface by default, opt in to what you need.</p>



<p class="wp-block-paragraph">The bundled <strong>plugins</strong>: <code>contextmenu</code> and <code>contextmenu_folder</code> (right-click menus on messages and folders), <code>swipe</code> (touch gestures), <code>show_folder_size</code>, <code>quota</code> (IMAP quota display), <code>persistent_login</code> (&#8220;keep me logged in&#8221;), <code>advanced_search</code>, <code>account_details</code>, <code>message_highlight</code> (colour rules), <code>authres</code> (shows SPF/DKIM/DMARC results so you can spot forgeries), <code>thunderbird_labels</code>, <code>responses</code> (canned replies), <code>easy_unsubscribe</code> (one-click List-Unsubscribe), <code>rcguard</code> (reCAPTCHA after failed logins, another brute-force layer), <code>kolab_2fa</code> (two-factor: TOTP, Yubikey, U2F), and <code>carddav</code> (address-book sync). The 2FA support libraries, <code>endroid/qr-code</code>, <code>spomky-labs/otphp</code>, <code>enygma/yubikey</code>, come along for the ride. Roundcube&#8217;s own core plugins (<code>archive</code>, <code>zipdownload</code>, <code>managesieve</code>, <code>password</code>, <code>newmail_notifier</code>, <code>new_user_dialog</code>) are enabled out of the gate.</p>



<p class="wp-block-paragraph">The bundled <strong>skins</strong> (pick one with <code>ROUNDCUBEMAIL_SKIN</code>, default <code>elastic</code>): <code>elastic</code> (the responsive default), <code>elastic4mobile</code> (mobile-tuned), <code>elastic-dark</code> (dark mode), <code>elastic2025</code> (a refreshed look), plus two we built ourselves, <code>gmail</code> and <code>outlook365</code>, look-alikes for the people migrating off Big Mail who want the muscle memory intact. And for the nostalgic: <code>larry</code> and <code>classic</code>, the old Roundcube skins, still here, still working.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Running It Yourself</h2>



<p class="wp-block-paragraph">Pull <code>eilandert/roundcube:latest</code>, give it an external MariaDB or PostgreSQL (there&#8217;s no SQLite fallback, on purpose, webmail is not a toy), point it at your IMAP and SMTP servers, and front it with a TLS-terminating reverse proxy targeting :8080. The entrypoint generates the Roundcube config from environment variables on every start, ensures the database schema, and starts the stack, all as UID 10001, chowning nothing, because it can&#8217;t.</p>



<p class="wp-block-paragraph">The one thing you cannot skip: pre-own the writable mounts. <code>sudo chown -R 10001:10001</code> on any bind mount, or use named volumes that inherit it automatically. If the container boots, you got it right. If it dies screaming <code>Permission denied</code>, you didn&#8217;t, and that&#8217;s the read-only, cap-dropped, unprivileged design working exactly as intended, refusing to paper over your mistake with privileges it doesn&#8217;t have. The full source, the Dockerfile, the Angie config, and the PHP-FPM pool all live at <a href="https://github.com/eilandert/dockerized" target="_blank" rel="noopener">github.com/eilandert/dockerized</a>. Read it. Audit it. That&#8217;s the point of shipping it in the open.</p>



<p class="wp-block-paragraph">Default webmail containers trust the user, trust the network, trust PHP, and trust that nobody will ever be rude enough to attack them. Ours trusts nobody, and sleeps fine.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Frequently Asked Questions</h2>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">What does &#8220;unprivileged&#8221; mean for a Docker container?</h3>
<div class="rank-math-answer ">

<p>It means the processes inside the container do not run as root. In our Roundcube image, PID 1 and every worker run as UID 10001 with all Linux capabilities dropped (cap_drop: ALL) and no-new-privileges set. If an attacker gets code execution inside the container, they land as a powerless system user instead of root, which removes the first link in most container-escape chains.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Why does the container fail to start with &#8220;Permission denied&#8221;?</h3>
<div class="rank-math-answer ">

<p>Because it runs unprivileged and cannot chown anything (no CAP_CHOWN). Every writable mount must already be owned by UID 10001 on the host. Named Docker volumes inherit this automatically; for bind mounts run &#8216;sudo chown -R 10001:10001 &#8216; first. The boot failure is the security model working as intended, the container has no power to fix ownership on its own.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Why is gzip turned off in the web server config?</h3>
<div class="rank-math-answer ">

<p>To prevent the BREACH attack. BREACH is a compression side-channel that can leak secrets like CSRF tokens from compressed HTTPS responses. Roundcube&#8217;s pages contain session-bound CSRF tokens, so compressing them dynamically would create a BREACH oracle. Static assets are small enough that leaving gzip off costs almost nothing while closing the side channel entirely.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">What is Snuffleupagus and why use it with Roundcube?</h3>
<div class="rank-math-answer ">

<p>Snuffleupagus is a PHP hardening extension that operates at the engine level, virtually patching vulnerable functions, killing dangerous ones, enforcing secure cookie flags, and preventing uploaded-file execution. Because it works below the application, userland code (including a compromised plugin) cannot override its rules. We ship a strict, source-audited rulebook tuned specifically for Roundcube.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Why does the container listen on port 8080 instead of 80?</h3>
<div class="rank-math-answer ">

<p>Binding a port below 1024 requires CAP_NET_BIND_SERVICE. Since we drop all capabilities, the container cannot bind a privileged port. Port 8080 is above 1024 and bindable by any user, so the container needs no special powers. You terminate TLS at an upstream reverse proxy and forward to :8080.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">How does login brute-force protection avoid throttling normal browsing?</h3>
<div class="rank-math-answer ">

<p>Roundcube routes both login and ordinary page loads through index.php, so a naive rate limit would throttle everything. We use a request map that keys the rate-limit zone on the real client IP only for the login action and on an empty string otherwise. Empty keys are not counted, so the 12-requests-per-minute throttle applies to login attempts alone. The real_ip configuration ensures it keys on the actual client, not the reverse proxy.</p>

</div>
</div>
</div>
</div>


<h2 class="wp-block-heading" style="color:#f59e0b">Related Reading</h2>



<ul class="wp-block-list">
<li><a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker Hardening for Self-Hosters</a>: the ten-flag checklist (rootless, read-only, cap-drop, distroless) this image is built on.</li>
<li><a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">What Is the BREACH Attack?</a>: why we turn gzip off on dynamic HTML, explained from first principles.</li>
<li><a href="https://deb.myguard.nl/2026/05/self-hosted-password-manager-with-vaultwarden/">Self-Hosted Vaultwarden</a>: the same unprivileged, minimal-container philosophy applied to your password vault.</li>
<li><a href="https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">Install ModSecurity and OWASP CRS on NGINX</a>: adding a full rule-based WAF in front of higher-risk apps.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>ViMbAdmin: The Postfix + Dovecot Mailbox Admin Panel (Modernised for PHP 8.5)</title>
		<link>https://deb.myguard.nl/2026/06/vimbadmin-postfix-dovecot-mailbox-admin-panel/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 02 Jun 2026 01:14:00 +0000</pubDate>
				<category><![CDATA[Mail]]></category>
		<category><![CDATA[authentication]]></category>
		<category><![CDATA[brute-force]]></category>
		<category><![CDATA[CSRF]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[dovecot]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[mail]]></category>
		<category><![CDATA[mariadb]]></category>
		<category><![CDATA[modsecurity]]></category>
		<category><![CDATA[mysql]]></category>
		<category><![CDATA[open-source]]></category>
		<category><![CDATA[owasp]]></category>
		<category><![CDATA[php]]></category>
		<category><![CDATA[postfix]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[selfhosted]]></category>
		<category><![CDATA[two-factor]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6048</guid>

					<description><![CDATA[Your mailbox table deserves better than raw SQL at 02:00. ViMbAdmin — modernised for PHP 8.5 — manages Postfix + Dovecot virtual domains, mailboxes and aliases via web UI or JSON-RPC API, with TOTP, brute-force protection and a hardened Docker image.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Your <code>mailbox</code> table has a row with a blank password field. You put it there at 23:45 last Thursday because you were &#8220;just testing&#8221; and you were going to fix it &#8220;in a minute.&#8221; It&#8217;s still there. I know because I&#8217;ve seen it on every mail server I&#8217;ve ever inherited, and I&#8217;ve inherited a lot of mail servers. ViMbAdmin is the web panel that finally broke me of that habit: a real interface for the Postfix and Dovecot virtual-mailbox tables, so you stop editing them by hand at quarter to midnight.</p>



<p class="wp-block-paragraph">This is what happens when your mail server user management is &#8220;open a MySQL client and hope.&#8221; <strong>ViMbAdmin</strong> is the alternative, a web panel that sits between your caffeine-addled hands and the database that runs your virtual mail. Our modernised fork brings it up to PHP 8.5, adds a full JSON-RPC API, TOTP, brute-force protection, and a security posture that assumes, correctly, that the entire internet is trying to get in. Fork is here: <a href="https://github.com/eilandert/ViMbAdmin" target="_blank" rel="noopener">github.com/eilandert/ViMbAdmin</a>.</p>



<p class="has-base-2-background-color has-background wp-block-paragraph"><strong>👉 Want to poke at it first?</strong> A live demo runs at <a href="https://vimbadmin.myguard.nl" target="_blank" rel="noopener">vimbadmin.myguard.nl</a> — the real panel, with password and 2FA changes locked and outgoing mail no-op&#8217;d for the demo account. Log in, click around, break nothing.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/06/vimbadmin-mailbox-admin-panel.webp" alt="ViMbAdmin Postfix Dovecot virtual mailbox administration panel dashboard"/><figcaption class="wp-block-image__caption">ViMbAdmin: because your users deserve better than watching you fat-finger a DELETE.</figcaption></figure>



<h2 class="wp-block-heading" style="color:#f59e0b">What ViMbAdmin actually is</h2>



<p class="wp-block-paragraph">Postfix and Dovecot do not care how the rows get into your database. They read <code>virtual_mailbox_maps</code>, they hash a password, they deliver mail, they go back to sleep. The database is the contract. <em>How you maintain that contract</em> is left, with magnificent Unix indifference, as an exercise for the reader.</p>



<p class="wp-block-paragraph">Most people solve this one of three ways:</p>



<ol class="wp-block-list">
<li>Raw SQL, forever, on three hours&#8217; sleep, one typo away from dropping the wrong domain.</li>
<li>A full mail appliance that installs 47 things you didn&#8217;t ask for, writes its own Postfix config, and breaks every time you touch anything.</li>
<li>ViMbAdmin.</li>
</ol>



<p class="wp-block-paragraph">ViMbAdmin is a CRUD app over three concepts: <strong>domains</strong> (<code>@example.com</code>, with quotas and mailbox limits), <strong>mailboxes</strong> (actual accounts with bcrypt-hashed passwords, maildirs, quotas), and <strong>aliases</strong> (the forwards, <code>sales@</code> → wherever, <code>postmaster@</code> → the person whose job it is to read bounces). It writes to the shared database. Postfix and Dovecot pick up the changes on their next lookup. That&#8217;s the whole transaction.</p>



<p class="wp-block-paragraph">It does not configure Postfix. It does not install Dovecot. It does not filter spam, for that you want <a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd</a>. It is a component, deliberately narrow, which is exactly why it can be audited and trusted. Tools that try to do everything are the tools that have a buffer overflow in the &#8220;we didn&#8217;t think anyone would use this&#8221; codepath.</p>



<p class="wp-block-paragraph">Mailbox passwords are hashed <strong>natively in PHP</strong> into exactly the format Dovecot stores — no <code>doveadm pw</code> binary, no shell-out, no network round-trip. It produces the crypt-family schemes PHP and Dovecot share, strongest first: <strong>ARGON2ID</strong> (memory-hard, Dovecot&#8217;s recommended modern scheme — set it if you hash out-of-band), then <strong>BLF-CRYPT</strong> (bcrypt <code>$2y$</code>, the recommended native default), then <code>SHA512-CRYPT</code>, then <code>SHA256-CRYPT</code>. If you&#8217;ve ever spent an evening debugging why your hand-rolled <code>{SHA512-CRYPT}</code> prefix was subtly wrong, you know what this is worth.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The history: perfectly good software, abandoned mid-sentence</h2>



<p class="wp-block-paragraph">ViMbAdmin was written by <a href="https://www.opensolutions.ie/" target="_blank" rel="noopener">Open Solutions</a>, an Irish shop, around 2010. Zend Framework 1, Doctrine ORM, Smarty, a perfectly sensible stack for the era. Then, as happens to a great deal of genuinely useful software, the commits slowed. Then stopped. The last meaningful upstream release predates several PHP versions that have since been born, matured, and declared end-of-life.</p>



<p class="wp-block-paragraph">Run the stock code on PHP 8.x and you get a wall of deprecation notices, a couple of fatals, and the special kind of silence where a form should have rendered. It didn&#8217;t die so much as sit down, mid-sentence, and stop talking.</p>



<p class="wp-block-paragraph">We needed it on PHP 8.5 on a hardened production stack. So we fixed it. Then, because leaving a five-year-dormant admin panel on the internet with no security review is how you become a cautionary tale, we kept going.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What we actually changed (the itemised version, not the press release)</h2>



<h3 class="wp-block-heading">PHP 8.5, Smarty 5, Doctrine ORM 3</h3>



<p class="wp-block-paragraph">The framework internals got a full transplant. <strong>Smarty 4 → 5</strong>: the templating layer changed under us in three distinct ways. It removed the public property API the old <code>OSS_View_Smarty</code> bridge poked at. It dropped bare PHP function calls inside <code>{if}</code> expressions. And, the landmine, its backward-compat plugin loader makes <code>getPluginsDir()</code> return an empty array, so the cloned view that Zend&#8217;s form-partial renderer uses silently lost every custom plugin. Forms rendered as blank space. No error, no warning, just nothing. We now track plugin directories manually and re-register them on clone.</p>



<p class="wp-block-paragraph"><strong>Doctrine ORM 2.8 → 3.x</strong> (currently orm 3.6 / dbal 4 / persistence 4) renamed half the query API along the way. CLI bootstrap, <code>fetchAll()</code> calls, the works. Every <code>function f(Type $x = null)</code> implicit-nullable across the entity and proxy trees got the <code>?Type</code> treatment, because Zend_Session promotes that particular deprecation to a fatal during session start, and nothing tanks user trust in a mail admin panel like a white screen at login. The jump to ORM 3 specifically needed native lazy-loading proxies (<code>enableNativeLazyObjects</code> — Symfony 8 retired the old var-exporter ghosts), PSR-6 caches in place of the deleted <code>DoctrineProvider</code>, an XSD-clean rewrite of the XML mappings, and a small <code>object</code>-type shim DBAL 4 dropped. <strong>Cache layer</strong>: <code>doctrine/cache</code> 2.x deleted its concrete providers, so the metadata/query cache now wraps a Symfony Cache PSR-6 pool. Without a persistent backend Doctrine re-parses entity mappings on every single request. With APCu it parses them once. The Docker image leaves <code>doctrine2cache.type = auto</code>, which picks APCu when the extension is present — for a single container that beats Redis, no socket, no hop. OPcache with <code>validate_timestamps=0</code> and a preload script round it out.</p>



<h3 class="wp-block-heading">Security: the actual list, not &#8220;we hardened it&#8221;</h3>



<p class="wp-block-paragraph">The stock app had no CSRF protection. None. Every form, every destructive link, wide open. We added a per-session token to the base form class, every form inherits it, Zend&#8217;s <code>isValid()</code> checks it for free, then guarded every destructive GET link (purge, delete, cancel, restore) with an explicit token check. Forge a request without the token: 403, redirect, no mailbox deleted.</p>



<p class="wp-block-paragraph">Smarty was running with output escaping <em>off</em>. Every <code>{$variable}</code> was a stored-XSS waiting room. We flipped <code>setEscapeHtml(true)</code> globally and marked the genuinely-HTML outputs as <code>nofilter</code>. A description field containing <code>&lt;script&gt;alert(1)&lt;/script&gt;</code> now renders as inert text. We tested that payload. It does nothing, which is the point.</p>



<p class="wp-block-paragraph">SQL injection: Doctrine ORM with parameterised queries throughout, plus we deleted four unreferenced &#8220;OSS API&#8221; integration classes that were carrying actual SQL concatenation (one with a live injection). ~1,600 lines removed. Dead code in an admin panel is attack surface.</p>



<p class="wp-block-paragraph">Command injection: every shell-out (<code>doveadm</code>, archive <code>tar</code>/<code>bzip2</code>/<code>du</code>) is <code>escapeshellarg()</code>&#8216;d. Deserialisation: <code>unserialize()</code> of archive blobs is restricted with <code>['allowed_classes' =&gt; false]</code>. Tokens and backup codes use <code>random_int()</code>, the old <code>str_shuffle</code>/<code>mt_rand</code> was replaced. CSRF: covered above. Every session ID is regenerated on login, and again after the 2FA step.</p>



<h3 class="wp-block-heading">TOTP two-factor auth</h3>



<p class="wp-block-paragraph">Opt-in per admin at <code>/admin/two-factor</code>. Scan QR, confirm a code, save the one-time backup codes (they&#8217;re shown once, they work once each, write them down, or find out the hard way). The TOTP secret is stored encrypted at rest with libsodium, keyed off <code>securitysalt</code>. A database read yields nothing usable.</p>



<p class="wp-block-paragraph">The lockout-yourself scenario: two escape hatches, no SQL required.</p>



<pre class="wp-block-code"><code># phone dead, authenticator gone — CLI reset:
./bin/vimbtool.php -a admin.cli-reset-totp --username=you@example.com
./bin/vimbtool.php -a admin.cli-reset-totp --all   # bad day

# or, applied at next login:
; in application.ini:
twofactor.force_disable = "you@example.com"   ; or "*"</code></pre>



<h3 class="wp-block-heading">Brute-force protection</h3>



<p class="wp-block-paragraph">Per-source-IP attempt counter, configurable lockout window. A fully successful login (password + 2FA, both) clears the counter. Configure in <code>application.ini</code>:</p>



<pre class="wp-block-code"><code>bruteforce.enabled      = 1
bruteforce.max_attempts = 5
bruteforce.window       = 900    ; seconds counter accumulates over
bruteforce.lockout      = 900    ; seconds locked out
bruteforce.whitelist[]  = "127.0.0.1"
bruteforce.whitelist[]  = "10.0.0.0/8"</code></pre>



<p class="wp-block-paragraph">If you&#8217;re behind a reverse proxy, see the <a href="#trusted-proxy">trusted-proxy section below</a>, the limiter needs the actual client IP, and there&#8217;s a right way and a very wrong way to get it.</p>



<h3 class="wp-block-heading">Defence in depth: Snuffleupagus, ModSecurity, hardened configs</h3>



<p class="wp-block-paragraph">Application-layer fixes are necessary. They&#8217;re not sufficient. The fork ships three more layers:</p>



<ul class="wp-block-list">
<li><strong>Snuffleupagus ruleset</strong> (<code>contrib/snuffleupagus/vimbadmin-strict.list</code>): code-derived, not copy-pasted from a blog post. Every ban is checked against a scan of what the app <em>and its vendor tree</em> actually call. It bans dangerous PHP functions the app never touches, allow-scopes the few it does, and blocks RFI/LFI wrappers, <code>eval</code>/<code>base64_decode</code> webshell pipes, mail-header injection, world-writable chmod, writing PHP-loadable files, and insecure cURL. The latest pass dropped roughly thirty more — a webshell&#8217;s favourite egress and RCE channels (<code>imap_open</code>, <code>ftp_connect</code>, the <code>ssh2_*</code> family, <code>pfsockopen</code>, <code>expect_*</code>), runtime code-redefinition (<code>runkit</code>/<code>uopz</code>), filesystem-ownership calls, and host-fingerprinting functions — each verified to have zero real call-sites first, so nothing legitimate breaks. Note: do not stack native <code>disable_functions</code> with Snuffleupagus, they conflict and the worker SIGSEGVs. Ask how we know.</li>
<li><strong>Hardened PHP-FPM pool</strong> (<code>contrib/php-fpm/vimbadmin.conf</code>): <code>open_basedir</code>, empty native <code>disable_functions</code> (SP owns policy), strict session-cookie flags, <code>security.limit_extensions=.php</code>, sane resource limits.</li>
<li><strong>Hardened Angie/nginx vhost</strong> (<code>contrib/angie/vimbadmin.conf</code>): positive security gate: only known HTTP methods, the exact route map (controllers + ZF1 param URLs), and the app&#8217;s known argument names reach PHP. Scanner traffic, empty user-agents, and the eternal <code>/.env</code>/<code>/wp-login.php</code> probe loop die at the edge. Plus strict CSP, security headers, BREACH mitigation (no compression on dynamic HTML), and a rate-limited login endpoint. Optional add-on: the <a href="https://github.com/eilandert/vimbadmin-crs-plugin" target="_blank" rel="noopener">ModSecurity CRS plugin</a> for payload-signature scanning on top.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">Quick start</h2>



<h3 class="wp-block-heading">Docker (recommended: fastest path to a running panel)</h3>



<p class="wp-block-paragraph">Bring a MariaDB/MySQL database. The image bundles the app, PHP-FPM, and the web server, pre-wired. First boot generates secrets and sets up the schema. Config lives in a mountable volume, edit <code>application.ini</code> without rebuilding, no files clobbered. The image is on Docker Hub at <a href="https://hub.docker.com/r/eilandert/vimbadmin" target="_blank" rel="noopener">hub.docker.com/r/eilandert/vimbadmin</a> (<code>eilandert/vimbadmin:latest</code>), built from the <a href="https://github.com/eilandert/dockerized/tree/master/src/vimbadmin" target="_blank" rel="noopener">dockerized</a> recipe; the app source is the <a href="https://github.com/eilandert/ViMbAdmin" target="_blank" rel="noopener">ViMbAdmin fork on GitHub</a>.</p>



<pre class="wp-block-code"><code>services:
  db:
    image: mariadb:lts
    environment:
      MARIADB_ROOT_PASSWORD: actually-change-this
      MARIADB_DATABASE: vimbadmin
      MARIADB_USER: vimbadmin
      MARIADB_PASSWORD: also-change-this

  vimbadmin:
    image: eilandert/vimbadmin:latest
    depends_on: [db]
    ports:
      - "8080:80"
    environment:
      TZ: Europe/Amsterdam</code></pre>



<pre class="wp-block-code"><code>docker compose up -d
# wait for MariaDB's first-boot, then browse to http://localhost:8080/</code></pre>



<p class="wp-block-paragraph">Put it behind TLS in production. Behind the hardened vhost from <code>contrib/</code> if you can. The point of shipping deployment configs is that you don&#8217;t have to invent them under pressure at 23:00.</p>



<h3 class="wp-block-heading">From source</h3>



<p class="wp-block-paragraph">PHP 8.4.1+ with <code>pdo_mysql</code>, <code>mbstring</code>, <code>intl</code>, <code>gettext</code>, <code>dom</code>, <code>ctype</code>, <code>iconv</code>, <code>sodium</code> (2FA encryption). <code>apcu</code> optional but don&#8217;t skip it.</p>



<pre class="wp-block-code"><code>git clone https://github.com/eilandert/ViMbAdmin.git
cd ViMbAdmin
composer install --no-dev
cp application/configs/application.ini.dist application/configs/application.ini
# edit application.ini: point resources.doctrine2.connection.options.* at your DB
./bin/doctrine-cli.php orm:schema-tool:create</code></pre>



<p class="wp-block-paragraph">That last command is the modernised CLI. The stock one called a Doctrine 2.8 API that no longer exists (the fork is on ORM 3 now). PHP 8.4.1 is the dependency-tree floor.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">First run: claim the throne before someone else does</h2>



<p class="wp-block-paragraph">On first launch ViMbAdmin detects no administrators exist and routes you to a setup page. This is the one window where the panel is briefly unauthenticated. Do it immediately, on a network you trust, then never think about it again.</p>



<p class="wp-block-paragraph">The setup page generates a security salt, use the one it gives you. Then it asks for your first super-admin&#8217;s credentials. <strong>The username is an email address.</strong> Not the word &#8220;admin.&#8221; Not &#8220;root.&#8221; An actual <code><span style="display:inline;" class="">y&#111;u&#64;y&#111;&#117;r&#100;&#111;mai&#110;&#46;c&#111;m</span></code>. The field is labelled &#8220;Email.&#8221; Read the label. This trips up more people than you&#8217;d believe, and none of them believe it could trip them up until it does.</p>



<p class="wp-block-paragraph">Pick a real password. It&#8217;s bcrypt-hashed and constant-time-compared on every login. The strength is entirely on you. The super-admin can see and touch everything. Treat the credentials accordingly.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Day-to-day: domain, mailbox, alias, in that order</h2>



<p class="wp-block-paragraph">The order matters because the foreign keys matter.</p>



<ol class="wp-block-list">
<li><strong>Domains → Add.</strong> Set the limits (max mailboxes, max aliases, default quota), decide backup MX status. ViMbAdmin writes the row. Postfix will now accept mail for the domain: <em>assuming</em> your <code>virtual_mailbox_domains</code> is actually pointed at this database. The panel maintains the data; it can&#8217;t make Postfix care about a table you never configured it to read. That part is on you.</li>
<li><strong>Mailboxes → Add.</strong> Local part, password, quota. ViMbAdmin hashes the password natively in your configured scheme (ARGON2ID / BLF-CRYPT / SHA512-CRYPT — no <code>doveadm pw</code> binary involved), computes maildir path, stores it. User can now authenticate against Dovecot. If you enabled the welcome-email feature, it tells them: which beats texting them a plaintext password, a practice that should be punishable by having to read your own sent folder.</li>
<li><strong>Aliases → Add.</strong> Address → comma-separated goto list. Build your <code>postmaster@</code> (RFC 5321 requires it, you will forget until a remote server complains), your role addresses, your distribution lists.</li>
</ol>



<p class="wp-block-paragraph">Every action is logged, validated, and CSRF-protected. The delete button on a mailbox carries a token; a malicious page can&#8217;t trick your browser into purging an account behind your back.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">No more scripts: archive, autoprune and quotas are all Dovecot-native</h2>



<p class="wp-block-paragraph">This is the part that changed the most, and it&#8217;s the part that makes the panel genuinely pleasant to run. The original ViMbAdmin leaned on a pile of helper scripts and cron jobs that had to live <em>on the mail host</em>, where the maildirs are: one to tar up an archive, one to scan every maildir for its size, one to actually delete files off disk. They needed shell access to the mail filesystem, they needed to be kept in sync with the panel, and if you forgot one, features silently didn&#8217;t work.</p>



<p class="wp-block-paragraph"><strong>All of that is gone.</strong> The panel now talks to Dovecot over its <strong>doveadm HTTP API</strong> — a REST endpoint — and never touches the mail filesystem itself. No tarballs, no <code>du</code>, no shared mount, no scripts to deploy on the mail host. The web request just writes a small job to a database queue, and a background runner carries it out against Dovecot. There is one moving part instead of five, and it&#8217;s a network call to an API, not a shell command on someone else&#8217;s server.</p>



<h3 class="wp-block-heading">Delete → archive → autoprune</h3>



<p class="wp-block-paragraph">Deleting a mailbox is no longer a destructive single click you regret at 02:00. When you delete (or explicitly archive) a mailbox, the runner asks Dovecot to <code>doveadm backup</code> the whole store into a <strong>zstd-compressed maildir</strong> under your backup path, and only then removes the live account. The backup shows up on the new <strong>Archives</strong> tab: when it was made, whether the account still exists, and its <em>autoprune</em> state. A deletion&#8217;s backup is flagged for autoprune and is cleaned up automatically after a configurable number of days (default 90; set it to 0 if you want delete to mean <em>delete now, no backup</em>). Changed your mind inside the window? <strong>Restore</strong> recreates the mailbox — original password hash and all — and <code>doveadm sync</code>s the mail back from the backup. Future-you, the one not reconstructing a fat-fingered deletion from a three-day-old dump, is grateful.</p>



<h3 class="wp-block-heading">The Maintenance tab</h3>



<p class="wp-block-paragraph">There&#8217;s a new <strong>Maintenance</strong> tab that surfaces all the housekeeping in one place instead of hiding it in cron files: app and schema version, the last queue-run and prune markers, inactive-domain stats, and one-click buttons to <em>update the database schema</em>, <em>run autoprune now</em> (clear expired backups), or <em>delete all autoprune backups</em>. The same actions are available headless for a cron (<code>maintenance.prune-expired</code>) if you&#8217;d rather automate them — but you no longer have to.</p>



<h3 class="wp-block-heading">Quotas update themselves, in real time</h3>



<p class="wp-block-paragraph">Two things people conflate: the <strong>limit</strong> (how big a mailbox is allowed to get) and the <strong>usage</strong> (how full it is right now). ViMbAdmin owns the limit — you set it in the GUI. Dovecot owns the usage, and it now reports it back <strong>live</strong> via its <code>quota-clone</code> plugin, which writes each mailbox&#8217;s current byte count straight into a table the panel reads. No nightly maildir-scan cron, no stale numbers — the usage bar you see is what Dovecot measured on the last delivery. The old size-scanning script that used to crawl every maildir overnight is simply retired.</p>



<h2 class="wp-block-heading" style="color:#f59e0b" id="mcp-adapter">MCP adapter: JSON-RPC API for when clicking is beneath you</h2>



<p class="wp-block-paragraph">Off by default. Enable with <code>mcp.enabled = 1</code> in <code>application.ini</code>. This is a <strong>JSON-RPC 2.0 API at <code>/mcp</code></strong> that lets an agent, a script, or a CI pipeline read and manage the mailbox database without a human in the loop. The full method set:</p>



<figure class="wp-block-table"><table><thead><tr><th style="color:#0b1220">Method</th><th style="color:#0b1220">Scope</th><th style="color:#0b1220">Does</th></tr></thead><tbody>
<tr><td><code>ping</code></td><td>read</td><td>Liveness check, <code>pong</code> + timestamp</td></tr>
<tr><td><code>domains.list</code></td><td>read</td><td>All domains with mailbox/alias counts and quotas</td></tr>
<tr><td><code>mailboxes.list</code></td><td>read</td><td>All mailboxes for a domain</td></tr>
<tr><td><code>aliases.list</code></td><td>read</td><td>All aliases for a domain</td></tr>
<tr><td><code>domain.create</code></td><td>write</td><td>Create a virtual domain</td></tr>
<tr><td><code>domain.delete</code></td><td>write</td><td>Delete a domain and everything in it</td></tr>
<tr><td><code>mailbox.create</code></td><td>write</td><td>Create a mailbox (hashes password, wires auto-alias)</td></tr>
<tr><td><code>mailbox.delete</code></td><td>write</td><td>Delete a mailbox permanently</td></tr>
<tr><td><code>alias.create</code></td><td>write</td><td>Create an alias (address → goto)</td></tr>
<tr><td><code>alias.delete</code></td><td>write</td><td>Delete an alias</td></tr>
<tr><td><code>mailbox.archive</code></td><td>write*</td><td>Queue mailbox for archive (purges live, schedules tar)</td></tr>
<tr><td><code>archive.restore</code></td><td>write*</td><td>Queue archive for restore</td></tr>
<tr><td><code>archive.delete</code></td><td>write*</td><td>Queue archive for deletion</td></tr>
</tbody></table><figcaption>* Destructive methods are additionally rate-limited per token (max + window configurable in <code>mcp.ratelimit.destructive</code>).</figcaption></figure>



<p class="wp-block-paragraph">Auth is bearer-only, no session, no cookie. Only the SHA-256 hash of each token is stored; a database read yields nothing usable. Tokens are scoped (<code>read</code> or <code>read write</code>), per-token IP/CIDR allowlisted, expirable, and revocable from the CLI without touching the web panel:</p>



<pre class="wp-block-code"><code># read-only token — printed once, store it now
./bin/vimbtool.php -a mcp.cli-token-generate --name=agent1 --scope="read"

# write-scoped, IP-locked, 90-day expiry
./bin/vimbtool.php -a mcp.cli-token-generate --name=provisioner \
    --scope="read write" --ip="10.0.0.5" --days=90

./bin/vimbtool.php -a mcp.cli-token-list
./bin/vimbtool.php -a mcp.cli-token-revoke --name=agent1</code></pre>



<p class="wp-block-paragraph">The vhost should enforce an IP allowlist in front of <code>/mcp</code> as primary network defence (the <code>contrib/angie/vimbadmin.conf</code> has the block). Bearer is the application layer on top. Revoked names can be reused, the CLI drops the old row rather than refusing, so token rotation doesn&#8217;t require inventing new names.</p>



<h2 class="wp-block-heading" style="color:#f59e0b" id="trusted-proxy">Real client IP behind a proxy: do it right or your brute-force protection is a lie</h2>



<p class="wp-block-paragraph">If your reverse proxy sits in front of ViMbAdmin and you haven&#8217;t configured trusted-proxy handling, your brute-force limiter sees the proxy&#8217;s IP address, not the attacker&#8217;s. It will either lock out nobody (if the proxy is whitelisted) or lock out your entire office (if the proxy isn&#8217;t). Both outcomes are bad. The MCP per-token IP allowlist has the same problem.</p>



<p class="wp-block-paragraph">Controlled by <code>trustedproxy.mode</code> in <code>application.ini</code>:</p>



<pre class="wp-block-code"><code>; auto (default) — trust X-Forwarded-For only when REMOTE_ADDR is private/loopback
trustedproxy.mode = "auto"

; on — trust X-Forwarded-For only from the listed proxy CIDRs
;trustedproxy.mode = "on"
;trustedproxy.proxies[] = "10.0.0.0/8"
;trustedproxy.proxies[] = "172.16.0.0/12"

; off — ignore X-Forwarded-For, always use raw REMOTE_ADDR
;trustedproxy.mode = "off"</code></pre>



<ul class="wp-block-list">
<li><strong><code>auto</code></strong> (default): trusts <code>X-Forwarded-For</code> only when <code>REMOTE_ADDR</code> is a private or loopback address. Covers the standard &#8220;proxy on the same host or LAN&#8221; setup with zero configuration. A public <code>REMOTE_ADDR</code> bypasses the header entirely.</li>
<li><strong><code>on</code></strong>: trust <code>X-Forwarded-For</code> from the CIDRs you list. Use when your proxy is on a public IP or a separate network segment.</li>
<li><strong><code>off</code></strong>: always use raw <code>REMOTE_ADDR</code>. Use when you handle IP rewriting at the web server layer instead (Angie/nginx <code>real_ip</code> module, there&#8217;s a commented example in <code>contrib/angie/vimbadmin.conf</code>).</li>
</ul>



<p class="wp-block-paragraph">The client is taken as the <em>right-most</em> address in the <code>X-Forwarded-For</code> chain that isn&#8217;t a trusted proxy. A client can&#8217;t spoof it by prepending extra IPs to the header, the leftmost entries are attacker-controlled, the rightmost is what your proxy saw.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Upgrading and schema migrations</h2>



<p class="wp-block-paragraph">Pulling a new version may add columns, indexes, or tables. Two paths:</p>



<pre class="wp-block-code"><code># Option A: Doctrine reconciles DB against entity mappings
./bin/doctrine-cli.php orm:schema-tool:update --dump-sql   # see what it wants to do
./bin/doctrine-cli.php orm:schema-tool:update --force      # do it

# Option B: targeted migration from contrib/migrations/
mysql -u&lt;user&gt; -p &lt;database&gt; &lt; contrib/migrations/2026-06-mailbox-username-unique.sql</code></pre>



<p class="wp-block-paragraph"><code>contrib/migrations/</code> holds idempotent SQL for changes that warrant a named file and a comment. Current one: <strong>UNIQUE index on <code>mailbox.username</code></strong>. Postfix and Dovecot query that column on every delivery and login. Without the index they full-scan the mailbox table every time. Fresh installs have it; DBs created from older dumps don&#8217;t. Before applying, check for duplicates (yes, they happen):</p>



<pre class="wp-block-code"><code>SELECT username, COUNT(*) c FROM mailbox GROUP BY username HAVING c &gt; 1;</code></pre>



<p class="wp-block-paragraph"><code>schema-tool:update</code> is additive (adds, doesn&#8217;t drop). The migration files do exactly what they say. Back up the database first regardless. This is not optional advice.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Where it fits in a real mail stack</h2>



<p class="wp-block-paragraph">The components and their responsibilities: Postfix handles SMTP. Dovecot handles IMAP/POP. Rspamd handles spam scoring before any of that. A web server fronts everything. ViMbAdmin keeps the shared user database coherent. That&#8217;s it. The deliberate narrowness is a feature, a tool with one job can be audited, hardened, and trusted in a way a sprawling appliance never can.</p>



<p class="wp-block-paragraph">One boundary to be clear about: ViMbAdmin only writes to the database. It never touches maildirs. The &#8220;archive&#8221; and &#8220;delete&#8221; buttons queue a row; a background runner does the real work against Dovecot over its HTTP API (<code>doveadm backup</code> to a zstd-compressed maildir, then <code>doveadm sync</code> to restore) — no tarballs, no shell tools, no shared filesystem with the mail host. In the Docker image that runner is the supervised <code>queue-runner</code> from earlier, ticking every five minutes with nothing to install. On bare metal you add a cron calling <code>queue.cli-run</code>; the fork ships examples in <code>contrib/cron/</code> with their requirements documented inline. Mailbox <em>usage</em> is fed live by Dovecot&#8217;s quota-clone plugin, so there&#8217;s no nightly maildir-scan job any more either.</p>



<p class="wp-block-paragraph">If you&#8217;re running this stack on Debian or Ubuntu, the rest of our work slots in beside it: hardened nginx/Angie packages with HTTP/3, daily-rebuilt <a href="https://deb.myguard.nl/nginx-dockerized/">Docker images</a>, and a general operating principle that default configs are a starting point for hardening, not a destination. ViMbAdmin, properly deployed, is the mail-admin-shaped piece of that.</p>



<p class="wp-block-paragraph">Stop editing your mailbox table by hand. Future you, the one not reconstructing a dropped domain at 02:00 from a backup that&#8217;s three days old, will not thank you, because future you will be asleep.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Frequently asked questions</h2>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-vimb-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Does ViMbAdmin configure Postfix and Dovecot for me?</h3>
<div class="rank-math-answer ">

<p>No. ViMbAdmin manages the SQL database of virtual domains, mailboxes and aliases. Postfix and Dovecot read that database independently, you still have to configure <code>virtual_mailbox_maps</code>, <code>virtual_mailbox_domains</code>, and the Dovecot <code>userdb</code>/<code>passdb</code> SQL queries yourself. ViMbAdmin maintains the data; the mail daemons consume it. It does not reload or signal them.</p>

</div>
</div>
<div id="rm-faq-vimb-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Why does &#8220;admin / admin&#8221; not work?</h3>
<div class="rank-math-answer ">

<p>Because the username is an email address, not the string &#8220;admin&#8221;. The login field is labelled &#8220;Email&#8221;. The setup page asked you to create a super-admin using a real address like <span style="display:inline;" class="">&#121;o&#117;&#64;y&#111;&#117;r&#100;o&#109;ai&#110;&#46;&#99;&#111;&#109;</span>. Use that address and the password you set. The form is telling you the truth.</p>

</div>
</div>
<div id="rm-faq-vimb-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Is the original ViMbAdmin still maintained?</h3>
<div class="rank-math-answer ">

<p>Upstream went quiet years ago and the stock code doesn&#8217;t run on modern PHP. Our fork at <a href="https://github.com/eilandert/ViMbAdmin" target="_blank" rel="noopener">github.com/eilandert/ViMbAdmin</a> brings it to PHP 8.5, Smarty 5, and Doctrine ORM 3, and adds CSRF, XSS auto-escaping, TOTP, brute-force protection, a Snuffleupagus ruleset, a ModSecurity CRS plugin, the MCP JSON-RPC API, and a self-supervising (s6) Docker image.</p>

</div>
</div>
<div id="rm-faq-vimb-4" class="rank-math-list-item">
<h3 class="rank-math-question ">What database do I need?</h3>
<div class="rank-math-answer ">

<p>MySQL or MariaDB, the same one your Postfix/Dovecot setup already uses. ViMbAdmin creates its schema with <code>./bin/doctrine-cli.php orm:schema-tool:create</code>. Point it at your existing mail database (or a dedicated one) and it manages the relevant tables.</p>

</div>
</div>
<div id="rm-faq-vimb-5" class="rank-math-list-item">
<h3 class="rank-math-question ">How are mailbox passwords hashed?</h3>
<div class="rank-math-answer ">

<p>Natively in PHP, into the exact format Dovecot stores — no <code>doveadm pw</code> binary or shell-out. You pick the scheme in <code>application.ini</code>; strongest first, that&#8217;s ARGON2ID (memory-hard, set it if you hash out-of-band), then BLF-CRYPT (bcrypt <code>$2y$</code>, the recommended native default), then SHA512-CRYPT and SHA256-CRYPT. Admin-account passwords use bcrypt, compared in constant time. CSPRNG for all tokens and backup codes.</p>

</div>
</div>
<div id="rm-faq-vimb-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I delegate management to domain owners?</h3>
<div class="rank-math-answer ">

<p>Yes. Per-domain admins can manage their own domain&#8217;s mailboxes and aliases without seeing other domains or system-level settings. Saves you being a human ticket queue.</p>

</div>
</div>
<div id="rm-faq-vimb-7" class="rank-math-list-item">
<h3 class="rank-math-question ">What is the MCP adapter?</h3>
<div class="rank-math-answer ">

<p>An optional JSON-RPC 2.0 API at <code>/mcp</code> for agents and scripts. Off by default (<code>mcp.enabled = 1</code> to enable). Bearer-token authenticated (SHA-256 hash stored, raw shown once), scoped read or read+write, per-token IP allowlist, expirable, revocable. Covers all 13 domain/mailbox/alias/archive operations. Tokens managed from the CLI, the web panel never touches them.</p>

</div>
</div>
<div id="rm-faq-vimb-8" class="rank-math-list-item">
<h3 class="rank-math-question ">How do I update the schema after pulling a new version?</h3>
<div class="rank-math-answer ">

<p>Run <code>./bin/doctrine-cli.php orm:schema-tool:update --dump-sql</code> to preview what Doctrine wants to add, then <code>--force</code> to apply. For specific migrations: hand-written, idempotent SQL in <code>contrib/migrations/</code>, each file documents why it exists and what to check first. Back up the DB before either.</p>

</div>
</div>
<div id="rm-faq-vimb-9" class="rank-math-list-item">
<h3 class="rank-math-question ">The brute-force limiter is blocking my whole office. What broke?</h3>
<div class="rank-math-answer ">

<p>Your reverse proxy. The limiter sees the proxy&#8217;s IP, not the real client&#8217;s, and locks the proxy out instead. Set <code>trustedproxy.mode = auto</code> in <code>application.ini</code> (default), it trusts <code>X-Forwarded-For</code> when the request comes from a private/loopback address. Add your office CIDR to <code>bruteforce.whitelist[]</code> as well.</p>

</div>
</div>
<div id="rm-faq-vimb-10" class="rank-math-list-item">
<h3 class="rank-math-question ">Where do I get the Docker image, and do I need a cron for the queue?</h3>
<div class="rank-math-answer ">

<p>The image is on Docker Hub at <a href="https://hub.docker.com/r/eilandert/vimbadmin" target="_blank" rel="noopener">hub.docker.com/r/eilandert/vimbadmin</a> as <code>eilandert/vimbadmin:latest</code>, built from the <a href="https://github.com/eilandert/dockerized/tree/master/src/vimbadmin" target="_blank" rel="noopener">dockerized</a> recipe. You do <em>not</em> need a cron with it: an s6-supervised queue-runner inside the container drains the queue every five minutes (and once on boot), and s6 also respawns PHP-FPM if it dies. A cron calling <code>queue.cli-run</code> is only needed for bare-metal or source installs.</p>

</div>
</div>
</div>
</div>


<h2 class="wp-block-heading" style="color:#f59e0b">Related reading</h2>



<ul class="wp-block-list">
<li><a href="https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd Explained: How Modern Spam Filtering Actually Works</a>: the spam-filtering piece that sits beside ViMbAdmin in a real Postfix/Dovecot stack.</li>
<li><a href="https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/">What Is the BREACH Attack?</a>: why we disable compression on dynamic HTML, from first principles.</li>
<li><a href="https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/">Docker Hardening for Self-Hosters</a>: the ten-flag checklist for running the ViMbAdmin container properly.</li>
<li><a href="https://deb.myguard.nl/nginx-dockerized/">Angie and NGINX Docker Images</a>: daily-rebuilt, fully-moduled web-server images to front the panel.</li>
<li><a href="https://hub.docker.com/r/eilandert/vimbadmin" target="_blank" rel="noopener">ViMbAdmin on Docker Hub</a>: pull <code>eilandert/vimbadmin:latest</code> — the self-supervising, read-only, unprivileged image this article describes.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Speed Up Debian Package Builds: eatmydata, mold, ccache, distcc, tmpfs — The Whole Shambam</title>
		<link>https://deb.myguard.nl/2026/05/speed-up-debian-package-builds-eatmydata-mold-ccache-distcc-tmpfs/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Fri, 29 May 2026 22:08:49 +0000</pubDate>
				<category><![CDATA[Packages]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[optimization]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6028</guid>

					<description><![CDATA[Five tools — eatmydata, mold, ccache, distcc, tmpfs — turn a 14-minute build into 90 seconds. Same compiler, same hardware. Any build system: make, cmake, autotools, ninja, Debian packaging. Here is how to wire them in, what each one breaks, and the order to enable them in.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A clean rebuild of NGINX with twenty-odd dynamic modules used to take this build host fourteen minutes. After turning on the five tools in this post, <strong>eatmydata, mold, ccache, distcc, and tmpfs</strong>, it takes ninety seconds. That is Debian package build optimization in one sentence. Same compiler. Same kernel. Same hardware. The difference is that the build stopped doing the dumb stuff: fsyncing every <code>dpkg</code> write, linking with a thirty-year-old linker, recompiling files that hadn&#8217;t changed, ignoring three idle machines on the LAN, and beating up an SSD for scratch files it would throw away in five seconds anyway. The <a href="https://www.debian.org/doc/manuals/maint-guide/build.en.html" rel="noopener" target="_blank">Debian maintainer build docs</a> cover the baseline workflow these tools accelerate.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/debian-package-build-speed.webp" alt="Debian package build optimization with eatmydata, mold, ccache, distcc and tmpfs" width="1024" height="576" loading="lazy"/></figure>



<p class="wp-block-paragraph">None of this is specific to Debian packaging, these five tools speed up any C/C++ build: a kernel compile, an autotools project, a CMake tree, a Yocto image, a CI pipeline. The examples below are generic shell. Wire them into whatever build system you already use.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="630" src="https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-circuit.webp" alt="Circuit board close-up, speed up Debian and Ubuntu package builds" class="wp-image-6034" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-circuit.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-circuit-300x158.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-circuit-1024x538.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-circuit-768x403.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /></figure>



<p class="wp-block-paragraph">This is the whole shambam. Five tools, what each one actually does, how to plug them into your workflow, what they cost, and what they break. By the end you&#8217;ll know which ones to switch on tomorrow, which one to think twice about, and the order to enable them in so you can measure the wins individually instead of crossing your fingers and hoping.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Why your build is slow: Debian package build optimization starts here</h2>



<p class="wp-block-paragraph">Open <code>htop</code> during a real package build. You&#8217;ll see <code>gcc</code> use one core, hit 100%, then drop to zero for two seconds while <code>dpkg</code> unpacks a build-dep. Then one core again. Then <code>ld</code> single-threaded for forty seconds at the link step. Then <code>make install</code> writing five thousand files to disk one fsync at a time. Then the whole thing again for the next binary package in the same source.</p>



<p class="wp-block-paragraph">The CPU isn&#8217;t the bottleneck. Almost nothing in a typical Debian build saturates a modern processor. The bottlenecks, in rough order of pain, are:</p>



<ul class="wp-block-list">
<li><strong>fsync storms</strong> from <code>dpkg</code>, <code>apt</code>, <code>ldconfig</code>, and friends installing build-deps and writing the final <code>.deb</code></li>
<li><strong>Single-threaded linking</strong> with <code>ld</code> or even <code>gold</code> on anything with a lot of static libraries (looking at you, NGINX with twenty modules)</li>
<li><strong>Pointless recompilation</strong> of files whose source bytes are identical to last time</li>
<li><strong>One-machine parallelism</strong>: your <code>-j$(nproc)</code> stops at the box you&#8217;re sitting at, ignoring every other Linux machine on your LAN</li>
<li><strong>SSD as scratch space</strong>: <code>build/</code>, <code>obj-*/</code>, <code>tmp/</code>, all writing temp files you&#8217;ll delete in ten seconds anyway, with the kernel journaling each write</li>
</ul>



<p class="wp-block-paragraph">Each tool below kills one of those. Stack them and the compounded win is not 5× or 10×, on a from-scratch rebuild it can hit 20×. On an incremental rebuild after a tiny patch, with ccache warm, you can be looking at a 100× speedup over a cold first build. The Debian project literally <a href="https://wiki.debian.org/eatmydata" target="_blank" rel="noopener">documents</a> a 30–70% wall-clock reduction from <code>eatmydata</code> alone on chroot-heavy workflows. We&#8217;ll get there.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1800" height="1140" src="https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-stack-v2.webp" alt="Debian build optimization stack diagram, eatmydata, tmpfs, ccache, distcc, mold" class="wp-image-6037" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-stack-v2.webp 1800w, https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-stack-v2-300x190.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-stack-v2-1024x649.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-stack-v2-768x486.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/speed-up-debian-package-builds-stack-v2-1536x973.webp 1536w" sizes="auto, (max-width: 1800px) 100vw, 1800px" /></figure>



<h2 class="wp-block-heading" style="color:#f59e0b">eatmydata: the world&#8217;s most aggressively named tool</h2>



<p class="wp-block-paragraph">The name is a warning and a promise. <code>eatmydata</code> is a small <code>LD_PRELOAD</code> shim that intercepts <code>fsync(2)</code>, <code>fdatasync(2)</code>, <code>sync(2)</code>, <code>msync(2)</code>, and the <code>O_SYNC</code> / <code>O_DSYNC</code> open flags, and turns every single one of them into a no-op. That&#8217;s it. That&#8217;s the tool.</p>



<p class="wp-block-paragraph">Why does that help so much? Because <code>dpkg</code> is paranoid for very good reasons. When it installs a package on your laptop, it fsyncs every single extracted file before it considers the install complete. If your battery dies mid-install, you want your system to come back up. That&#8217;s the right default for production.</p>



<p class="wp-block-paragraph">It is, however, the absurd default for a build chroot that lives for ninety seconds, gets discarded, and writes nothing you care about. You don&#8217;t need crash-safe writes for a tarball you&#8217;re going to <code>rm -rf</code> before lunch.</p>



<h3 class="wp-block-heading">How to use it</h3>



<pre class="wp-block-preformatted"><code>apt install eatmydata
eatmydata apt install build-essential ...
eatmydata dpkg -i some.deb
eatmydata make install      # works for anything that calls fsync()</code></pre>



<p class="wp-block-paragraph">Anything that calls <code>fsync</code> downstream of the wrapper gets the no-op treatment. Wrap your whole build invocation, your <code>apt</code>, your <code>make install</code>, your CI script, wherever the fsyncs are coming from. On a fresh chroot or a from-zero CI environment, the dep-install phase alone often shaves 30 to 70 percent off wall-clock time before <code>gcc</code> has even started.</p>



<h3 class="wp-block-heading">What it costs</h3>



<p class="wp-block-paragraph">Nothing, as long as you only use it where data loss is fine. Throwaway chroots, CI runs, test databases, scratch builds, local development trees: perfect. Anywhere you&#8217;d cry if a power failure corrupted the result: don&#8217;t. The name is the manual.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">mold: the linker that finally cares about you</h2>



<p class="wp-block-paragraph">For decades, linking was the unspoken final boss of every C and C++ project. You&#8217;d compile in parallel across all your cores in twenty seconds, then sit watching one CPU at 100% for forty seconds while <code>ld</code> stitched the object files together. Single-threaded. Memory-hungry. Embarrassing.</p>



<p class="wp-block-paragraph"><code>gold</code> (Google&#8217;s improved linker) helped a bit. <code>lld</code> (LLVM&#8217;s) helped more. Then <a href="https://github.com/rui314/mold" target="_blank" rel="noopener">Rui Ueyama</a>, who also wrote <code>lld</code>, looked at the problem fresh and decided to write a linker that was multi-threaded and cache-friendly from the start. The result is <code>mold</code>, and on a typical link step it&#8217;s somewhere between 2× and 20× faster than <code>ld.bfd</code>. NGINX with twenty static modules links in about three seconds with <code>mold</code> versus thirty with <code>ld</code>. That&#8217;s not a typo.</p>



<h3 class="wp-block-heading">How to use it</h3>



<pre class="wp-block-preformatted"><code>apt install mold

# One-off, for a single build of anything:
mold -run make
mold -run cmake --build .
mold -run ./configure && mold -run make -j$(nproc)

# Per-project, via the standard linker-selection flag:
export LDFLAGS="-fuse-ld=mold"

# System-wide, via update-alternatives (Debian trixie+, Ubuntu 24.04+):
update-alternatives --install /usr/bin/ld ld /usr/bin/mold 100</code></pre>



<p class="wp-block-paragraph">The <code>mold -run</code> form is the safest, because it intercepts <code>exec*()</code> calls and rewrites <code>/usr/bin/ld</code> to <code>mold</code> just for that command tree. No system-wide change, no surprises in unrelated builds.</p>



<h3 class="wp-block-heading">What it costs</h3>



<p class="wp-block-paragraph">Almost nothing, in 2026. Three or four years ago <code>mold</code> had rough edges with weird linker scripts (kernel modules, embedded firmware, custom <code>.lds</code> files). Today, on userspace Debian/Ubuntu packages, it&#8217;s a drop-in. The one thing to watch: a small handful of packages parse the output of <code>ld --version</code> and choke if they don&#8217;t see &#8220;GNU ld&#8221;, usually configurable, occasionally not. For ninety-nine percent of packages, you&#8217;ll never notice.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">ccache: don&#8217;t compile what you compiled yesterday</h2>



<p class="wp-block-paragraph">The premise of <code>ccache</code> is so obvious that the only mystery is why it isn&#8217;t on by default everywhere. It hashes your preprocessor output (or, in <code>direct mode</code>, the source file plus headers plus compiler flags). If it&#8217;s seen that exact hash before, it hands you back the cached <code>.o</code> file instead of running <code>gcc</code>. If it hasn&#8217;t, it runs the compiler and stores the result.</p>



<p class="wp-block-paragraph">On a from-scratch build, ccache costs you a few percent (the hashing overhead). On the second build, even after a <code>git pull</code> that touches three files, you&#8217;ll see hit rates of 90 to 99 percent and a build that finishes in the time it takes to <code>ld</code>. On our build host, ccache turns a fourteen-minute NGINX rebuild into a ninety-second one. The other minutes are linking, packaging, and signing.</p>



<h3 class="wp-block-heading">How to use it</h3>



<pre class="wp-block-preformatted"><code>apt install ccache

# Make gcc/g++ go through ccache:
export PATH="/usr/lib/ccache:$PATH"

# Or wire it in explicitly via your build's CC/CXX:
export CC="ccache gcc"
export CXX="ccache g++"

# Give it more space — default is 5GB, bump to 40 if you build a lot:
ccache --max-size=40G

# Check hit rate after a few builds:
ccache --show-stats</code></pre>



<p class="wp-block-paragraph">If your build runs inside a container, a chroot, or any ephemeral environment, mount the ccache directory in from the host so the cache survives across runs. In Docker that&#8217;s a <code>-v ~/.ccache:/root/.ccache</code>. In a chroot, a bind mount. In CI, a cache step that restores <code>~/.ccache</code> between jobs. The pattern is the same everywhere: the wrapper goes inside, the cache directory lives outside.</p>



<h3 class="wp-block-heading">What it costs</h3>



<p class="wp-block-paragraph">Disk space and one trap. The trap: ccache hashes the compiler binary path and a few sentinel flags. If you build the same source with two slightly different toolchains (say, gcc 14 on Debian trixie and gcc 13 on bookworm) you&#8217;ll get cache misses on every file. That&#8217;s the right behaviour. But if you accidentally include something volatile in the hash, like an absolute build path or <code>__DATE__</code>, your hit rate will tank. Set <code>CCACHE_BASEDIR</code> to normalize paths, and enable <code>hash_dir=false</code> if you&#8217;re sure builds are path-independent.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">distcc: turn the office into one big CPU</h2>



<p class="wp-block-paragraph">You have one workstation, one home server, an old laptop in the closet, and a Raspberry Pi running Pi-hole. Combined that&#8217;s maybe sixteen cores. <code>distcc</code> says: stop wasting them. Run a tiny daemon on the other machines, point your build at them, and your compile jobs farm out across the LAN.</p>



<p class="wp-block-paragraph">The trick is that <code>gcc</code> compiles each <code>.c</code> file into a <code>.o</code> file in isolation, no IPC, no shared state. That&#8217;s embarrassingly parallel, which is exactly the workload <code>distcc</code> exists to exploit. It runs the preprocessor locally (which needs your headers), ships the preprocessed source to a remote <code>distccd</code>, runs <code>gcc</code> there, ships the <code>.o</code> back.</p>



<h3 class="wp-block-heading">How to use it</h3>



<pre class="wp-block-preformatted"><code># On every helper machine:
apt install distcc
# Edit /etc/default/distcc — set ALLOWEDNETS=192.168.0.0/16 (or whatever)
systemctl enable --now distcc

# On the build host:
apt install distcc
export DISTCC_HOSTS="localhost/8 helper1.lan/8 helper2.lan/4"
export CC="distcc gcc"
export CXX="distcc g++"

make -j20   # parallelism = sum of all the /N slots above</code></pre>



<p class="wp-block-paragraph">Combine <code>distcc</code> with <code>ccache</code> by setting <code>CCACHE_PREFIX=distcc</code>, ccache handles the cache, distcc handles the actual compilation on misses. This is the classic stack and it works beautifully.</p>



<h3 class="wp-block-heading">What it costs</h3>



<p class="wp-block-paragraph">Setup time and trust. Every helper must run the exact same gcc version, the same glibc target, and ideally the same Debian release, or you&#8217;ll get binaries that link locally but crash on the build host. Use <code>distcc-pump</code> mode if you want to skip the local preprocessing step (faster, but requires identical header trees). And never expose <code>distccd</code> to the open internet: it&#8217;s a remote code execution waiting to happen. ALLOWEDNETS exists for a reason; respect it.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">tmpfs: put the whole build in RAM</h2>



<p class="wp-block-paragraph">Modern SSDs are fast. RAM is fifty times faster. A build that thrashes through ten thousand small files in a <code>build/</code> directory will, no matter how nice your NVMe is, spend most of its time bouncing through the kernel&#8217;s page cache, journaling, and waiting on serialised syscalls. Move that directory into <code>tmpfs</code> and the kernel never even touches the disk.</p>



<p class="wp-block-paragraph">This is the single highest-impact change for incremental rebuilds on a fast desktop. It also has the politest failure mode of every tool in this post: if you run out of RAM, the build fails loudly and obviously instead of producing a corrupted package. (You can also enable swap, but if you do that you&#8217;ve just moved the build back to disk, slower than where it started. Don&#8217;t.)</p>



<h3 class="wp-block-heading">How to use it</h3>



<pre class="wp-block-preformatted"><code># Permanent tmpfs scratch:
mkdir /build
echo "tmpfs /build tmpfs size=16G,mode=1777 0 0" >> /etc/fstab
mount /build

# Then point your build at it — examples:
make -C /build -f /path/to/project/Makefile
cmake -B /build/proj -S ~/project && cmake --build /build/proj
git clone ~/project /build/work && cd /build/work && ./configure && make</code></pre>



<p class="wp-block-paragraph">On a 32 GB workstation, a 16 GB tmpfs is comfortable for any single project build short of LibreOffice, Chromium, or the Linux kernel. For those, fall back to a fast SSD and pair it with <code>eatmydata</code>, you&#8217;ll get most of the win without the OOM risk.</p>



<h3 class="wp-block-heading">What it costs</h3>



<p class="wp-block-paragraph">RAM, and a small amount of caution. <code>tmpfs</code> doesn&#8217;t survive reboot, which is exactly what you want for scratch space and exactly what you don&#8217;t want for the finished <code>.deb</code>. Make sure your post-build hooks copy the artefacts to durable storage. And <em>don&#8217;t</em> point your CCACHE_DIR at tmpfs, that defeats the entire point of a cache.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Putting it all together</h2>



<p class="wp-block-paragraph">Here&#8217;s the whole stack as a single shell snippet you can paste in front of any build, make, cmake, autotools, ninja, kbuild, the lot. Each line is independent; comment one out to measure its contribution.</p>



<pre class="wp-block-preformatted"><code># 1. tmpfs scratch (one-time, in /etc/fstab):
#    tmpfs /build tmpfs size=16G,mode=1777 0 0
cd /build && git clone --depth=1 https://example.org/project src
cd src

# 2. ccache wrappers ahead of real gcc/g++
export PATH="/usr/lib/ccache:$PATH"
export CCACHE_DIR=/build/.ccache
export CCACHE_MAXSIZE=40G

# 3. distcc — farm compiles across the LAN
export CCACHE_PREFIX=distcc
export DISTCC_HOSTS="localhost/8 build2.lan/8 build3.lan/4"

# 4. mold — modern parallel linker
export LDFLAGS="-fuse-ld=mold ${LDFLAGS:-}"

# 5. eatmydata — kill fsync on the install step
./configure --prefix=/build/install
eatmydata make -j$(distcc -j) install</code></pre>



<p class="wp-block-paragraph">The order in that snippet is also the order to enable them in if you&#8217;re starting from scratch. Each one is independently measurable. Time the build after each <code>export</code> and you&#8217;ll see exactly where your wall-clock minutes are coming from.</p>



<p class="wp-block-paragraph">If you do build Debian packages, the same five exports drop into <code>~/.pbuilderrc</code>, <code>~/.sbuildrc</code>, a Dockerfile <code>ENV</code> block, or a GitHub Actions <code>env:</code> map with zero changes, the tools don&#8217;t care what&#8217;s calling them.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Things to watch out for</h2>



<p class="wp-block-paragraph">Three traps catch everyone at least once.</p>



<p class="wp-block-paragraph"><strong>Trap one: <code>eatmydata</code> inside a chroot that escapes to the host.</strong> The <code>LD_PRELOAD</code> is process-local, but if a build script does something weird like <code>nsenter</code> into a parent namespace and writes a file there, that write is unprotected by your &#8220;I don&#8217;t care about fsync&#8221; promise. In ninety-nine percent of pbuilder workflows you&#8217;re fine. Watch out in custom CI.</p>



<p class="wp-block-paragraph"><strong>Trap two: <code>ccache</code> caching the wrong thing.</strong> If your build embeds <code>__DATE__</code> or <code>__TIME__</code> in compiled output, every cache hit produces a binary with the wrong embedded timestamp. The Debian project (rightly) considers this a bug, reproducible builds depend on stripping that, but if you maintain an old codebase, audit for these macros first or you&#8217;ll ship stale-looking timestamps. Set <code>SOURCE_DATE_EPOCH</code> and call it done.</p>



<p class="wp-block-paragraph"><strong>Trap three: <code>mold</code> plus weird link scripts.</strong> Kernel modules, EFI binaries, certain GNU-isms in linker scripts, these are the small remaining edge cases where <code>mold</code> still trips. If your build mysteriously fails at the link step with cryptic relocation errors, <code>unset DEB_LDFLAGS_MAINT_APPEND</code> and try again with the system <code>ld</code>. If that fixes it, file a bug with upstream mold; they&#8217;re responsive.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What about Bazel, Nix, sccache, and the rest?</h2>



<p class="wp-block-paragraph">Fair question. <code>sccache</code> is Mozilla&#8217;s ccache rewrite in Rust, with optional S3 / Redis backends for sharing the cache across a CI fleet. If you&#8217;re running cloud CI it&#8217;s worth a look; on a single build host, ccache wins on simplicity. <code>Bazel</code> and <code>Nix</code> achieve similar incremental wins through content-addressed builds, but they require restructuring how you build, which is a much bigger ask than &#8220;stick a wrapper in front of gcc.&#8221;</p>



<p class="wp-block-paragraph">The five tools in this post share one virtue: they&#8217;re transparent. You don&#8217;t change a single line of upstream source. You don&#8217;t migrate your CI. You don&#8217;t learn a new build system. You install a deb, export a couple of variables, and watch your builds get faster. That&#8217;s why they&#8217;ve outlived every &#8220;next-gen build system&#8221; of the past fifteen years and will probably outlive the next ten.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Measuring the win</h2>



<p class="wp-block-paragraph">Don&#8217;t take anyone&#8217;s benchmarks at face value, including these. Time your own builds before and after, three runs each, throw out the slowest. Use <code>/usr/bin/time -v</code>, not the shell builtin, you want the resident-set-size numbers too so you know you haven&#8217;t blown your RAM budget.</p>



<pre class="wp-block-preformatted"><code>/usr/bin/time -v make clean && /usr/bin/time -v make -j$(nproc) 2>&1 | tee before.log
# enable one tool (export PATH=/usr/lib/ccache:$PATH, etc.)
/usr/bin/time -v make clean && /usr/bin/time -v make -j$(nproc) 2>&1 | tee after.log
diff &lt;(grep -E 'wall|user|sys|Max' before.log) \
     &lt;(grep -E 'wall|user|sys|Max' after.log)</code></pre>



<p class="wp-block-paragraph">The numbers should be unambiguous. If they&#8217;re not, you&#8217;ve misconfigured something, usually <code>PATH</code> ordering on ccache or a typo in <code>DISTCC_HOSTS</code>. The signal is loud when these tools work; it&#8217;s effectively zero when they don&#8217;t.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">FAQ</h2>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Is eatmydata safe to use in production?</h3>
<div class="rank-math-answer ">

<p>For one-shot throwaway chroots and CI runs that produce a final .deb you&#8217;ll copy off and verify, yes, totally safe. For installing packages on a server you actually care about, no. The point of fsync is to guarantee that your filesystem state survives a power cut. eatmydata removes that guarantee. Use it where you can afford to lose the work; never where you can&#8217;t.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Will mold break anything in a normal Debian package build?</h3>
<div class="rank-math-answer ">

<p>Almost never, in 2026. The handful of packages that still trip it are usually kernel modules with custom linker scripts, or firmware projects with hand-rolled .lds files. Mainstream userspace (NGINX, PHP, MariaDB, anything pulled from main) links cleanly. If you hit a snag, mold -run lets you test on one package without changing system defaults.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">How much disk should I give ccache?</h3>
<div class="rank-math-answer ">

<p>On a single-developer workstation, 10 to 20 GB is plenty. On a multi-distro build host that rebuilds dozens of source packages across bookworm, trixie, jammy, noble, and resolute, bump it to 40 to 80 GB. The hit rate plateaus around 90 to 95 percent once the cache is warm; beyond that you&#8217;re paying for diminishing returns.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I run distcc over the open internet?</h3>
<div class="rank-math-answer ">

<p>No. distccd has no authentication and no encryption, it&#8217;s designed for trusted LANs. If you need to use it across a hostile network, tunnel it through SSH (distcc supports an &#8211;ssh transport explicitly) or run it inside a WireGuard mesh. Exposing distccd port 3632 to the internet is a remote code execution waiting to happen.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">My build runs out of memory in tmpfs, what now?</h3>
<div class="rank-math-answer ">

<p>You&#8217;ve hit the one real downside of building in RAM. Options: (1) shrink the build (most packages don&#8217;t need a 16 GB scratch space), (2) move just the hottest directories to tmpfs and leave the rest on SSD, (3) accept the SSD-based build and lean harder on ccache + eatmydata, which together still produce most of the win without the RAM pressure. Kernel and LibreOffice builds usually want the third option.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Does any of this conflict with reproducible builds?</h3>
<div class="rank-math-answer ">

<p>Not if you&#8217;re careful. eatmydata, mold, tmpfs, and distcc are all reproducibility-neutral, they change wall-clock time, not output bytes. ccache only breaks reproducibility if your code embeds __DATE__ or __TIME__ at compile time; set SOURCE_DATE_EPOCH and the macros expand to a fixed timestamp, ccache hashes consistently, and your hashes stay stable. Debian&#8217;s reproducible-builds project recommends exactly this combination.</p>

</div>
</div>
</div>
</div>


<h2 class="wp-block-heading" style="color:#f59e0b">Related reading</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: once your NGINX builds are fast, here&#8217;s what to actually build into it</li>
<li><a href="/2026/05/what-is-zstd-nginx-angie-browser-support/">What Is Zstd? NGINX, Angie, History and Browser Support</a>: the compression format that the same techniques here let us ship as a dynamic module</li>
<li><a href="/2026/05/docker-hardening-rootless-readonly-distroless/">Docker Hardening for Self-Hosters</a>: once builds are fast you&#8217;ll start running more of them; here&#8217;s how to box them in</li>
<li><a href="/repository/">The deb.myguard.nl APT repository</a>: every package on this site is built with the stack in this post</li>
</ul>



<p class="wp-block-paragraph">Five tools. Five exports. About an hour to wire it all up. The next time you push a one-line patch and the rebuild finishes before your hand leaves the keyboard, remember: it isn&#8217;t magic. It&#8217;s just that the defaults were terrible, and someone finally fixed them.</p>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>The New deb.myguard.nl Repository Layout: Per-Package APT Trees Explained</title>
		<link>https://deb.myguard.nl/2026/05/deb-myguard-apt-repository-layout-per-package-trees/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Thu, 28 May 2026 19:54:47 +0000</pubDate>
				<category><![CDATA[Packages]]></category>
		<category><![CDATA[angie]]></category>
		<category><![CDATA[apt]]></category>
		<category><![CDATA[aptly]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[gpg]]></category>
		<category><![CDATA[nginx]]></category>
		<category><![CDATA[repository]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=6015</guid>

					<description><![CDATA[The deb.myguard.nl APT repository now publishes clean per-distribution and per-package trees under /apt/. Here is why we split the old mixed pool, how the new layout works, and how to add exactly the packages you want.]]></description>
										<content:encoded><![CDATA[
<p>For about eight years, every single package we built, nginx, Angie, Postfix, Dovecot, rspamd, MariaDB, all of it, got dumped into one giant shared folder per Debian release. One <code>pool/</code>. One index. Everything piled together like a teenager&#8217;s bedroom floor where the clean laundry and the dirty laundry have become philosophically indistinguishable. It worked. It also meant that if you only wanted our hardened nginx, your machine still had to download and parse an index listing twelve thousand other <code>.deb</code> files it would never install, and because everything shared one index, an <code>apt upgrade</code> could cheerfully hand you a brand-new Postfix or MariaDB you never asked for, just because it was sitting in the same pile. The new deb.myguard.nl APT repository layout fixes exactly that, one per-package tree, one index per package.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/deb-myguard-apt-repository-layout.webp" alt="deb.myguard.nl APT repository layout with per-package trees" width="1024" height="576" loading="lazy"/></figure>

<p>We just fixed that. The repository at <a href="https://deb.myguard.nl/apt">deb.myguard.nl/apt</a> now publishes itself in clean, separate trees: one per distribution, and one per package. Same signing key, same packages, same versions, just sorted. This is the story of why a package repository is shaped the way it is, what was wrong with the old shape, and how to use the new one. I&#8217;m going to explain it like you&#8217;re sixteen and have never set up an APT repo, because honestly most people who <em>run</em> them never had it explained either.</p>

<p>Let&#8217;s start with the thing nobody tells you.</p>



<h2 style="color:#f59e0b">What an APT repository actually is (and where the deb.myguard.nl APT repository layout fits)</h2>

<p>Here&#8217;s the big secret about APT repositories: they&#8217;re just <strong>a web server with files on it</strong>. That&#8217;s it. There&#8217;s no database, no API, no clever server-side magic. When you run <code>apt update</code>, your computer downloads a couple of plain text files over HTTP, reads them, and goes &#8220;ah, okay, here&#8217;s what&#8217;s available.&#8221; When you run <code>apt install nginx</code>, it downloads a <code>.deb</code> file, which is itself just a fancy <code>.tar</code> archive, and unpacks it.</p>

<p>If you can serve a folder of files over HTTP, you can run an APT repository. People act like it&#8217;s wizardry. It&#8217;s a glorified <code>python3 -m http.server</code> with some rules about folder names.</p>

<p>Those rules matter, though, so let&#8217;s look at them. A repository has two important parts:</p>

<ul>
<li><strong>The <code>pool/</code></strong>: this is where the actual <code>.deb</code> files live. The packages themselves. The cargo.</li>
<li><strong>The <code>dists/</code></strong>: this is the index. It&#8217;s a set of text files that say &#8220;for Debian bookworm, here are the packages we have, here are their versions, here are their SHA256 hashes, and here&#8217;s a cryptographic signature proving I really wrote this list.&#8221;</li>
</ul>

<p>When your machine runs <code>apt update</code>, it&#8217;s downloading the <code>dists/</code> index. It never downloads the whole <code>pool/</code>, that would be insane, it&#8217;s gigabytes. It downloads the catalogue, decides what it needs, and then fetches only those specific <code>.deb</code> files from the <code>pool/</code>. Think of <code>dists/</code> as the restaurant menu and <code>pool/</code> as the kitchen. You read the menu; you don&#8217;t walk into the kitchen and eat everything.</p>

<p>The line you put in <code>/etc/apt/sources.list.d/</code> is just an address telling your machine where the menu is:</p>

<pre><code>deb [signed-by=/etc/apt/keyrings/deb.myguard.nl.gpg] https://deb.myguard.nl/apt/dists/bookworm bookworm main</code></pre>

<p>That reads as: &#8220;Go to this URL, look in the <code>dists/bookworm</code> menu, the suite is called <code>bookworm</code>, and I want the <code>main</code> component.&#8221; Hold that thought. The shape of that URL is the entire point of this article.</p>



<h2 style="color:#f59e0b">The old layout, and why it was a teenager&#8217;s bedroom floor</h2>

<p>Back in 2022 we moved from a tool called <a href="/2026/05/what-is-zstd-nginx-angie-browser-support/">reprepro</a> to <a href="https://www.aptly.info/" rel="noopener" target="_blank">aptly</a> for managing the repo. (Different tools, same job: take a pile of <code>.deb</code> files, generate the signed index, publish it.) And the way it got set up, every Debian and Ubuntu release published straight into the web root. All of them. Into the same physical directory.</p>

<p>So on disk it looked like this, one <code>dists/</code> with every release crammed in side by side, and crucially, <strong>one shared <code>pool/</code></strong> holding the <code>.deb</code> files for everything:</p>

<pre><code>/                       &larr; the web root, served at deb.myguard.nl
├─ dists/
│   ├─ bookworm/      &larr; Debian 12 index
│   ├─ trixie/        &larr; Debian 13 index
│   ├─ jammy/         &larr; Ubuntu 22.04 index
│   ├─ noble/         &larr; Ubuntu 24.04 index
│   └─ ...
└─ pool/
    └─ main/          &larr; EVERY .deb for EVERY release, all together
</code></pre>

<p>It worked fine. APT is perfectly happy with this. But it had two real problems, and they got worse as we added more packages.</p>

<p><strong>Problem one: you got the whole buffet whether you wanted it or not.</strong> Say you run a mail server and you just want our Postfix and Dovecot and rspamd. You add the repo, and now <code>apt update</code> pulls down an index that also lists nginx, Angie, MariaDB, a hundred and eight nginx modules, OpenSSL builds, and a pile of shared libraries. Your <code>apt-cache search</code> results get polluted with stuff you&#8217;ll never touch. Your apt pinning has to be more careful. It&#8217;s noise. A 2 a.m. &#8220;why is <code>libnginx-mod-http-vod</code> showing up on my mailserver&#8221; kind of noise.</p>

<p><strong>Problem two, and this one actually scared me once, the shared <code>pool/</code> is a loaded footgun.</strong> When everything publishes to the same directory with the same prefix, the publishing tool treats them as one big shared object store. Drop or rebuild one release&#8217;s publication, and the tool happily wipes the shared <code>dists/</code> and <code>pool/</code> out from under <em>every other release at the same time</em>. I learned this the way everyone learns these things: by doing it once, watching twelve thousand <code>.deb</code> files vanish from the published tree, and feeling my soul briefly leave my body. (They were recoverable from the database. My blood pressure was not.)</p>

<p>So: one messy room, where touching one thing could knock over everything else, and where every guest had to look at all your junk. Time to get some shelves.</p>



<h2 style="color:#f59e0b">The new deb.myguard.nl APT repository layout: shelves, not a pile</h2>

<p>The fix is conceptually simple and it&#8217;s the same fix your mum suggested for your bedroom: <strong>put things in separate, labelled containers.</strong> Everything still lives under <code>/apt/</code>, but now it&#8217;s sorted two ways at once.</p>

<p>First, the <strong>full per-distribution trees</strong>. Each release gets its own isolated folder with its own <code>dists/</code> and its own <code>pool/</code>. Nothing shared. Nothing that can knock over a neighbour:</p>

<pre><code>/apt/dists/
├─ jammy/        &larr; Ubuntu 22.04 &mdash; its own dists/ + pool/
├─ noble/        &larr; Ubuntu 24.04
├─ bullseye/     &larr; Debian 11
├─ bookworm/     &larr; Debian 12
├─ trixie/       &larr; Debian 13
└─ resolute/     &larr; Ubuntu 26.04 LTS
</code></pre>

<p>This is the one most people want. It&#8217;s everything we build for your release, in one source line, completely separated from every other release. The URL is <code>/apt/dists/&lt;codename&gt;</code>, so for Debian 12 it&#8217;s <code>https://deb.myguard.nl/apt/dists/bookworm</code>.</p>

<p>Second, and this is the genuinely nice new bit, the <strong>per-package trees</strong>. Each package gets its own tree, sliced again by release:</p>

<pre><code>/apt/
├─ nginx/
│   ├─ bookworm/    &larr; just nginx + its deps, for Debian 12
│   ├─ trixie/
│   └─ ...
├─ angie/
├─ postfix/
├─ dovecot/
├─ rspamd/
├─ mariadb/
└─ openssh/
</code></pre>

<p>So if you want <em>only</em> our nginx and nothing else cluttering your apt index, you point at <code>/apt/nginx/&lt;codename&gt;</code> and that tree contains nginx, its modules, and the handful of libraries it actually depends on. Nothing else. A mail admin points at <code>/apt/postfix/bookworm</code> and <code>/apt/dovecot/bookworm</code> and never sees a single nginx module.</p>

<p>The old shared layout is still live at the site root, by the way. We didn&#8217;t rip it out. It&#8217;s marked &#8220;being retired&#8221; but it keeps working so nobody&#8217;s automation breaks the day they read this. You&#8217;ve got time to move over. We&#8217;re not monsters.</p>

<figure style="margin:2rem 0;text-align:center;"><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/apt-repository-old-vs-new-per-package-trees.webp" alt="Old shared APT pool versus new per-package and per-distribution trees on deb.myguard.nl" width="1100" height="620" loading="lazy" style="max-width:100%;height:auto;border-radius:12px;" /><figcaption style="color:#93a3b8;font-size:0.9rem;margin-top:0.5rem;">Left: the old shared pile, where one slip could knock over every release. Right: the new sorted trees under <code>/apt/</code>.</figcaption></figure>



<h2 style="color:#f59e0b">How aptly pulls this off without copying everything ten times</h2>

<p>Here&#8217;s a question that should bug you: if there&#8217;s a full <code>bookworm</code> tree <em>and</em> a separate <code>nginx/bookworm</code> tree, are we storing the nginx <code>.deb</code> twice? Disk is cheap but it&#8217;s not <em>free</em>, and copying the same 200 MB of nginx modules into six package trees across six releases adds up fast.</p>

<p>The answer is no, and the trick is <strong>hardlinks</strong>. When aptly publishes a tree, it doesn&#8217;t copy the <code>.deb</code> file into the <code>pool/</code>, it creates a hardlink. A hardlink is a second name for the exact same bytes on disk. Same inode, two directory entries. The file appears in both <code>/apt/dists/bookworm/pool/</code> and <code>/apt/nginx/bookworm/pool/</code>, costs the disk space of <em>one</em> copy, and if you edit one you edit both (you won&#8217;t, published <code>.deb</code> files are immutable). It&#8217;s the filesystem equivalent of one band member being in two bands. Same person, two posters.</p>

<p>The per-package trees are built with a technique aptly calls a <strong>snapshot pull</strong>. We take a snapshot of the whole release, then pull out just one source package and the things it actually depends on. The pull follows hard <code>Depends</code>, not the soft <code>Recommends</code> and <code>Suggests</code>, which is a detail that matters more than you&#8217;d think. The first time we built these, we left dependency-following turned all the way up, and Postfix&#8217;s &#8220;Suggests: a mail reader&#8221; dragged the entire Dovecot package set into the Postfix tree. Postfix politely <em>suggesting</em> you might also enjoy a different mail server is true, philosophically, and completely useless in a per-package repo. So: hard dependencies only. Each tree contains the package, its real libraries, and nothing it merely thinks you&#8217;d like.</p>

<p>One more detail, because someone always asks: not every package exists for every release. MariaDB only builds for <code>trixie</code> and <code>resolute</code>, because those are the only base systems shipping the MariaDB 11.x build dependencies we need. OpenSSH is wired up but not always populated. The build script just skips a release cleanly when a package isn&#8217;t there, instead of publishing an empty broken tree. No 404 surprises.</p>



<h2 style="color:#f59e0b">The signing key: why your computer trusts us at all</h2>

<p>Quick detour into the most important file you&#8217;ll never look at. Remember how the <code>dists/</code> index includes &#8220;a cryptographic signature proving I really wrote this list&#8221;? That signature is made with a <strong>GPG key</strong>, and your machine needs our <em>public</em> half of that key to check it. No key, no trust, and modern APT will flat-out refuse to install anything.</p>

<p>This is the bit that stops a random person on your coffee-shop WiFi from intercepting your <code>apt update</code> and feeding you a backdoored nginx. Even over plain HTTP, APT verifies every index and every package against the signature. The transport can be untrusted; the math can&#8217;t be faked. (This is also why our backup mirror on port 8888 can serve plain HTTP without it being a security problem, the signature check doesn&#8217;t care how the bytes arrived.)</p>

<p>Our key lives at one obvious address: <a href="https://deb.myguard.nl/deb.myguard.nl.gpg">https://deb.myguard.nl/deb.myguard.nl.gpg</a>. It&#8217;s an RSA 4096-bit key, born 2020-12-27, valid through May 2028. We serve it already in the binary format APT wants, so you don&#8217;t even need to run <code>gpg --dearmor</code> on it, just save it into the keyrings folder:</p>

<pre><code>sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://deb.myguard.nl/deb.myguard.nl.gpg \
  | sudo tee /etc/apt/keyrings/deb.myguard.nl.gpg >/dev/null</code></pre>

<p>Notice we put it in <code>/etc/apt/keyrings/</code> and reference it with <code>signed-by=</code> in the source line, rather than dumping it in the old <code>/etc/apt/trusted.gpg.d/</code> where it would be trusted for <em>every</em> repo on your system. Scoping the key to one repo is the modern, correct way to do this. It means our key can only ever vouch for our packages. If you&#8217;re the paranoid type, and around here that&#8217;s a compliment, verify the fingerprint after you save it:</p>

<pre><code>gpg --no-default-keyring --keyring /etc/apt/keyrings/deb.myguard.nl.gpg --fingerprint</code></pre>

<p>It must read <code>D18B 8E5A DF7D 55CE 2A00  D581 67F9 C3D8 456D 7F62</code>, character for character. If it doesn&#8217;t, stop, and don&#8217;t run <code>apt update</code>, something fetched you the wrong key.</p>



<h2 style="color:#f59e0b">Actually using it: three ways, pick your fighter</h2>

<p>Right, theory&#8217;s over. Here&#8217;s how you actually add the thing. All three methods use the same key from the step above and end up cryptographically identical, they differ only in how much of the repo you pull into your apt index.</p>

<h3>Option 1: the full release tree (recommended)</h3>

<p>You want everything we build for your release. This is the default and what most people should use.</p>

<pre><code>apt-get update
apt-get -y install lsb-release ca-certificates curl

CODENAME=$(lsb_release -cs)
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/deb.myguard.nl.gpg] https://deb.myguard.nl/apt/dists/$CODENAME $CODENAME main" \
  | sudo tee /etc/apt/sources.list.d/deb.myguard.nl.list

sudo apt-get update
sudo apt-get install nginx     # or: angie, postfix, rspamd, ...</code></pre>

<p>The <code>$(lsb_release -cs)</code> bit just prints your release codename (<code>bookworm</code>, <code>noble</code>, whatever) so the same commands work everywhere. No editing required.</p>

<h3>Option 2: one package only</h3>

<p>You want our nginx and literally nothing else in your apt index. Point at the package&#8217;s own tree:</p>

<pre><code>CODENAME=$(lsb_release -cs)
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/deb.myguard.nl.gpg] https://deb.myguard.nl/apt/nginx/$CODENAME $CODENAME main" \
  | sudo tee /etc/apt/sources.list.d/deb.myguard.nl-nginx.list

sudo apt-get update
sudo apt-get install nginx</code></pre>

<p>Swap <code>nginx</code> for <code>angie</code>, <code>postfix</code>, <code>dovecot</code>, <code>rspamd</code>, <code>mariadb</code> or <code>openssh</code>. You can stack several, give each its own file in <code>sources.list.d/</code> (like <code>deb.myguard.nl-postfix.list</code>) so they&#8217;re easy to manage and remove later. Want to see every tree that exists right now? Just open <a href="https://deb.myguard.nl/apt">deb.myguard.nl/apt</a> in a browser and click around. It&#8217;s a plain directory listing. The whole menu, visible.</p>

<h3>Option 3: the lazy way (the bootstrap package)</h3>

<p>If you trust us and just want it working <em>now</em>, we ship a tiny <code>.deb</code> that does the key install, the source line, and the apt pinning for you:</p>

<pre><code>apt-get update
apt-get -y install lsb-release ca-certificates curl

curl -fsSLO https://deb.myguard.nl/myguard.deb
sudo dpkg -i myguard.deb
sudo apt-get update</code></pre>

<p>Its install script detects your codename, writes the <code>signed-by</code> source pointing at the new <code>/apt/dists/&lt;codename&gt;</code> tree, drops in the signing key, and sets pinning so our builds win version ties. It&#8217;s a normal <code>.deb</code>, if you want to audit it, <code>dpkg-deb -R myguard.deb out/</code> and read the <code>postinst</code> yourself. We&#8217;d respect you more for it.</p>



<h2 style="color:#f59e0b">A word on pinning (the thing that decides who wins)</h2>

<p>Here&#8217;s a subtlety that trips people up. Say Debian ships nginx 1.22 and we ship a hardened nginx 1.31. You&#8217;ve added our repo. Which one does <code>apt install nginx</code> give you? By default APT picks the highest version number, but version comparison across repos gets weird fast, and you don&#8217;t want to leave it to chance.</p>

<p>That&#8217;s what <strong>pinning</strong> is for. A pin file in <code>/etc/apt/preferences.d/</code> says &#8220;packages from this origin get priority.&#8221; The bootstrap package sets this up automatically, pinning on the repository&#8217;s <code>Origin</code> field, which, across all our trees, is <code>deb.myguard.nl</code>. (Getting that <code>Origin</code> consistent across the new per-distro trees was its own small adventure; when you split a repo into pieces, every piece has to remember its own name, and the first build forgot. Fixed now.) If you set the repo up by hand and want our builds to take priority everywhere, the how-to page has the exact pin snippet.</p>

<p>If you only want, say, our nginx but Debian&#8217;s everything-else, you can pin more narrowly, or just use the per-package tree from Option 2, which sidesteps the whole question by only offering one package in the first place. Sometimes the cleanest pin is not needing one.</p>



<h2 style="color:#f59e0b">Why this matters beyond tidiness</h2>

<p>It would be easy to file this under &#8220;cosmetic spring cleaning,&#8221; but there&#8217;s real engineering payoff.</p>

<p><strong>Smaller, faster <code>apt update</code>.</strong> A per-package tree&#8217;s index lists a handful of packages, not twelve thousand. If you&#8217;re provisioning a fleet of mail servers and they each only need the Postfix tree, every <code>apt update</code> across that fleet parses a tiny index instead of the whole catalogue. Multiply by a few hundred machines and a few times a day and it stops being theoretical.</p>

<p><strong>Blast radius.</strong> Separate trees with separate pools mean a publishing mistake on one can&#8217;t nuke the others. The footgun from the old layout is structurally gone, not &#8220;we&#8217;ll be careful,&#8221; but &#8220;the tool physically can&#8217;t reach the neighbour&#8217;s files.&#8221; That&#8217;s the difference between a safety habit and a safety <em>property</em>, and the second one is the only kind you can trust at 2 a.m.</p>

<p><strong>Clarity.</strong> You can browse to <a href="https://deb.myguard.nl/apt">deb.myguard.nl/apt</a> and <em>see</em> the shape of what we publish. Which packages, which releases, what&#8217;s in each. A repo you can read with your eyes is a repo you can reason about. No guessing, no spelunking through one enormous flat <code>Packages</code> file.</p>

<p>If you want the practical, no-theory version of all this, just the commands to copy, the <a href="/how-to-use/">how-to-use page</a> is the cheat sheet. And if you came here because you&#8217;re setting up a hardened stack from scratch, the <a href="/nginx-modules/">nginx modules</a> we ship and the <a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">ModSecurity + OWASP CRS guide</a> are the obvious next stops. Mail people: the <a href="/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">rspamd explainer</a> pairs nicely with the Postfix and Dovecot trees.</p>

<p>A package repository is just a folder of files on a web server. But the <em>shape</em> of that folder is a promise about how much junk you have to accept to get the one thing you wanted. We just made that promise a lot smaller.</p>



<h2 style="color:#f59e0b">Frequently asked questions</h2>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">What is the difference between /apt/dists/&lt;codename&gt; and /apt/&lt;package&gt;/&lt;codename&gt;?</h3>
<div class="rank-math-answer ">

<p><code>/apt/dists/&lt;codename&gt;</code> is the full tree for one release, everything we build for, say, Debian bookworm, in one source line. <code>/apt/&lt;package&gt;/&lt;codename&gt;</code> is a single package&#8217;s tree (just nginx, or just rspamd) plus its real dependencies, and nothing else. Use the full tree if you want our whole stack; use a package tree if you want exactly one thing without cluttering your apt index. Both use the same signing key and the same codename.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I still have to use the old mixed repository at the site root?</h3>
<div class="rank-math-answer ">

<p>No. The old flat layout at the deb.myguard.nl root still works and is kept live so existing setups don&#8217;t break, but it is being retired. New installs should point at <code>/apt/dists/&lt;codename&gt;</code> (full release) or <code>/apt/&lt;package&gt;/&lt;codename&gt;</code> (single package). There&#8217;s no rush, migrate when convenient, but the new trees are the recommended path.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Why isn&#8217;t MariaDB (or OpenSSH) available for my release?</h3>
<div class="rank-math-answer ">

<p>Some packages are only built for the releases whose base system ships the build dependencies we need. MariaDB 11.x, for example, is published only for trixie and resolute. The build pipeline cleanly skips a release where a package can&#8217;t be built rather than publishing an empty or broken tree, so you&#8217;ll simply not see that package&#8217;s tree for unsupported releases. Browse <code>deb.myguard.nl/apt</code> to see exactly what exists.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Where do I get the signing key and where should it go?</h3>
<div class="rank-math-answer ">

<p>The key is at https://deb.myguard.nl/deb.myguard.nl.gpg, already in APT&#8217;s binary format (no <code>gpg --dearmor</code> needed). Save it to <code>/etc/apt/keyrings/deb.myguard.nl.gpg</code> and reference it with <code>signed-by=</code> in your source line. That scopes the key to our repository only, instead of trusting it system-wide. The fingerprint is D18B 8E5A DF7D 55CE 2A00 D581 67F9 C3D8 456D 7F62 (RSA 4096, valid through May 2028).</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Does splitting the repo into trees waste disk space by duplicating packages?</h3>
<div class="rank-math-answer ">

<p>No. aptly publishes with hardlinks, so a package that appears in both the full release tree and a per-package tree is the same bytes on disk under two names, it costs the space of one copy. Splitting the repo costs essentially no extra storage; it only changes how the indexes are organised.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I mix several single-package trees on one machine?</h3>
<div class="rank-math-answer ">

<p>Yes. Give each its own file in <code>/etc/apt/sources.list.d/</code> (for example <code>deb.myguard.nl-nginx.list</code> and <code>deb.myguard.nl-postfix.list</code>). They share the same keyring and don&#8217;t interfere. This is handy when you want, say, our nginx and our rspamd but Debian&#8217;s version of everything else.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">Is the plain-HTTP mirror on port 8888 safe to use?</h3>
<div class="rank-math-answer ">

<p>Yes. APT verifies every index and every package against the GPG signature regardless of how the bytes were transported, so an untrusted plain-HTTP connection can&#8217;t feed you a tampered package, the signature check would fail. The port 8888 mirror serves the identical signed trees as a fallback when the main HTTPS endpoint is unavailable.</p>

</div>
</div>
</div>
</div>


<h2 style="color:#f59e0b">Related reading</h2>
<ul>
<li><a href="/how-to-use/">How to Add the myguard APT Repository (Debian &amp; Ubuntu)</a>: the copy-paste cheat sheet for everything above.</li>
<li><a href="/nginx-modules/">NGINX Modules, optimized &amp; extended</a>: the full list of what&#8217;s in the nginx tree.</li>
<li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: turn your new nginx into a web application firewall.</li>
<li><a href="/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd Explained</a>: the brains behind the Postfix and Dovecot trees.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>njs + QuickJS-NG on NGINX: real JavaScript in your web server, finally</title>
		<link>https://deb.myguard.nl/2026/05/njs-quickjs-ng-nginx-real-javascript/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Wed, 27 May 2026 21:49:18 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[138]]></category>
		<category><![CDATA[141]]></category>
		<category><![CDATA[142]]></category>
		<category><![CDATA[143]]></category>
		<category><![CDATA[278]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5998</guid>

					<description><![CDATA[Stock njs is an ES5.1 subset with selected ES6 bits and a wall behind every modern feature. Rebuild it against QuickJS-NG and you get a real ES2023 runtime inside NGINX — async/await, BigInt, Proxy, dynamic import(), modern regex, Intl, the lot. Here is what changes, how the build wires it together, and copy-paste examples.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In 2017 the NGINX team shipped a JavaScript engine inside their web server and called it <strong>njs</strong>. Nine years later, late 2026, the year ES2024 is being finalised, the bundled njs interpreter is still a curated subset that lands somewhere between ES5.1 and &#8220;selected ES6 bits we felt like implementing.&#8221; Copy a five-line snippet off MDN and there&#8217;s an even chance it explodes on first parse. That&#8217;s not a bug. That was the design. njs is small on purpose so it can be embedded into a request hot path without dragging V8 along for the ride.</p>



<p class="wp-block-paragraph">The problem is that &#8220;small on purpose&#8221; has aged into &#8220;small in a way that hurts.&#8221; Want <code>async</code>/<code>await</code> with a real microtask queue? Want <code>BigInt</code> for a counter that exceeds 2<sup>53</sup>? Want to <code>import()</code> a helper module only when a specific path is hit? Want a regex with a lookbehind because you&#8217;re parsing a header field? On stock njs the answer is some variant of &#8220;no&#8221;, &#8220;almost&#8221;, or &#8220;yes but rewrite it like it&#8217;s 2013&#8221;. So we did the obvious thing: we rebuilt njs against <strong>QuickJS-NG</strong>, the maintained fork of Fabrice Bellard&#8217;s QuickJS engine, and now you get a proper ES2023 runtime in every <code>js_set</code>, <code>js_content</code>, <code>js_body_filter</code> and <code>js_periodic</code> on the box. This is the long version of why that matters, what it changes, and how to use it.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What njs actually is, for the people who skipped the docs</h2>



<p class="wp-block-paragraph">njs (short for &#8220;NGINX JavaScript&#8221;, and yes it should have been &#8220;ngs&#8221;) is a dynamic module for NGINX. You load it, you point it at a <code>.js</code> file, and from that moment on every request can pass through JavaScript on its way to and from the upstream. Two flavours: <strong>ngx_http_js_module</strong> for the HTTP layer, where most people use it, and <strong>ngx_stream_js_module</strong> for the L4 stream layer, where you can rewrite SNI, inspect PROXY protocol, do dynamic upstream selection, that kind of thing. Same engine, same scripts, different request lifecycle hooks.</p>



<p class="wp-block-paragraph">What does &#8220;passing through JavaScript&#8221; mean in concrete terms? You wire it up with one of half a dozen directives:</p>



<ul class="wp-block-list">
<li><code>js_set $variable function</code>: variable is set by running <code>function(r)</code> at the moment NGINX needs the value. Great for computed headers, signed URLs, request signatures, anything you&#8217;d otherwise reach for Lua for.</li>
<li><code>js_content function</code>: the whole response body is produced by JavaScript. Replaces the upstream entirely.</li>
<li><code>js_body_filter function</code>: runs as a streaming filter, sees the response in chunks, can transform on the fly without buffering the full document.</li>
<li><code>js_header_filter function</code>: same idea but for response headers.</li>
<li><code>js_periodic function interval=N</code>: run a function every N seconds, outside the request path. Great for refreshing caches, polling a control plane, sending stats to a downstream collector.</li>
<li><code>js_import name from path</code>: the module-loading directive. Everything above refers to functions exported from these imports.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve ever written OpenResty Lua, the mental model is the same: pluggable code at well-defined phases of the request, with a request object (<code>r</code> in njs, <code>ngx</code> in Lua) that gives you access to headers, args, body, variables, and subrequest helpers. njs is younger, smaller, and ships in the official NGINX repo. That&#8217;s its pitch. Its other pitch, until now, was a sharply restricted language.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The &#8220;njs doesn&#8217;t have that&#8221; wall, in detail</h2>



<p class="wp-block-paragraph">The stock njs interpreter is, per its own <a href="https://nginx.org/en/docs/njs/compatibility.html" target="_blank" rel="noopener">compatibility page</a>, an ES5.1 strict-mode subset plus a hand-picked grab bag of ES6+ features. The picks are reasonable on paper, let, const, arrow functions, template literals, basic Promises, the spread operator, but the omissions are where you lose your afternoon.</p>



<p class="wp-block-paragraph">Concrete things people walk into and bounce off:</p>



<ul class="wp-block-list">
<li><strong>Promises that interleave with the I/O loop.</strong> Stock njs has a Promise constructor and you can chain <code>.then()</code>, but the microtask semantics are bolted on rather than woven in. Real <code>async</code>/<code>await</code> with the natural &#8220;await my subrequest, then await this other subrequest, then return the combined header&#8221; pattern? Either contorted or unreliable depending on njs version.</li>
<li><strong>BigInt.</strong> Not there. Counters above 2<sup>53</sup>, certificate serials, monotonic timestamps in nanoseconds: you reach for it the second you need it, and it&#8217;s just gone.</li>
<li><strong>Proxy and Reflect.</strong> Useful for the kind of meta-programming where you&#8217;d wrap a config object so unknown keys throw with a helpful error. Not there.</li>
<li><strong>Dynamic <code>import()</code>.</strong> Stock njs has <code>js_import</code> at the config level, so you can preload modules at startup, but you can&#8217;t conditionally load a helper from inside a request handler when a specific route fires. That&#8217;s a real limitation when half your scripts are dead code for the other 90% of routes.</li>
<li><strong>Modern regex.</strong> No lookbehind <code>(?&lt;=...)</code>, named capture groups are spotty, Unicode property escapes <code>\p{L}</code> aren&#8217;t there. Try parsing a <code>Forwarded:</code> header with the elegant lookbehind regex from MDN and watch your config refuse to load.</li>
<li><strong>Intl.</strong> No locale-aware <code>toLocaleDateString</code>, no <code>NumberFormat</code>. Want a localised cache key based on Accept-Language? You&#8217;re writing a switch.</li>
<li><strong>ES modules with full semantics.</strong> Stock njs supports modules but the boundaries are blurry: top-level <code>await</code> doesn&#8217;t work, dynamic exports are limited, and you can&#8217;t really mix module styles cleanly.</li>
</ul>



<p class="wp-block-paragraph">None of this is njs being lazy. The original goal was a fast, tiny, embeddable engine for high-RPS web servers, and the team made deliberate cuts to keep the per-request cost low. The engineering trade is real. But for anyone copying patterns from Node, modern blog posts, or, let&#8217;s be honest, a chatbot&#8217;s autocomplete, the surface mismatch is constant friction.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Enter QuickJS-NG: ES2023, in a binary smaller than your average cat picture</h2>



<p class="wp-block-paragraph">QuickJS started as a side project from Fabrice Bellard, the same Fabrice Bellard who wrote FFmpeg, QEMU, the world&#8217;s smallest x86 emulator, and an arbitrary-precision arithmetic library because Tuesday. QuickJS shipped in 2019 as a tiny, complete, embeddable JS engine: under a megabyte stripped, passing nearly all of <strong>test262</strong> (the official ECMAScript conformance test suite), no JIT, no GC tuning headaches, just a bytecode interpreter with inline caches and a reference-counted heap.</p>



<p class="wp-block-paragraph">Bellard&#8217;s original repo went quiet after a year. The community forked it into <strong>QuickJS-NG</strong>, &#8220;next generation&#8221;, and that fork is what&#8217;s actually maintained now: regular releases, ES2023 feature work, security fixes, and a steady drip of performance improvements. As of 2026, QuickJS-NG passes the overwhelming majority of test262, including the corners njs doesn&#8217;t even try to reach. It&#8217;s the engine of choice when you want real JavaScript without dragging Chromium into the build.</p>



<p class="wp-block-paragraph">The relevant features it gives you, off the shelf:</p>



<ul class="wp-block-list">
<li>Proper <code>Promise</code> with woven microtask scheduling, real <code>async</code>/<code>await</code>, <code>Promise.allSettled</code>, <code>Promise.any</code>.</li>
<li><code>BigInt</code> with the full operator set and literal syntax (<code>123n</code>).</li>
<li><code>Symbol</code>, <code>WeakRef</code>, <code>FinalizationRegistry</code>.</li>
<li><code>Proxy</code> and <code>Reflect</code>.</li>
<li>Full ES modules including dynamic <code>import()</code>, top-level <code>await</code> in modules, re-exports.</li>
<li>Generators and async generators.</li>
<li>Modern regex: lookbehind, named groups, Unicode property escapes, the <code>d</code> flag for indices.</li>
<li>Tagged templates, optional chaining (<code>?.</code>), nullish coalescing (<code>??</code>), logical assignment (<code>??=</code>, <code>&amp;&amp;=</code>, <code>||=</code>).</li>
<li><code>Intl</code> for locale-aware formatting (when built with ICU; we ship it).</li>
<li><code>Array.prototype.at</code>, <code>findLast</code>, <code>group</code>, the whole class-fields-and-private-methods family.</li>
</ul>



<p class="wp-block-paragraph">That&#8217;s the language. The question is how to plug it into njs so the rest of your NGINX config doesn&#8217;t care which engine is underneath.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">How our build glues the two together</h2>



<p class="wp-block-paragraph">njs itself has a documented hook for &#8220;use an external QuickJS as the engine instead of the bundled one.&#8221; Its <code>nginx/config</code> probe runs <code>pkg-config quickjs-ng</code> at configure time, and if it finds a <code>quickjs-ng.pc</code> file with sane <code>--cflags</code> and <code>--libs</code>, the dynamic njs module is compiled against that QuickJS instead. If the probe fails, njs silently falls back to its own embedded interpreter, exactly the failure mode we don&#8217;t want, because the only thing worse than a missing feature is a runtime that <em>quietly</em> downgrades.</p>



<p class="wp-block-paragraph">So our <code>deb/nginx/debian/rules</code> does three things, in order, every build:</p>



<ol class="wp-block-list">
<li>Vendors QuickJS-NG as a git submodule under <code>modules/nginx/quickjs-ng/</code>, so the source is pinned and reproducible.</li>
<li>The <code>build_quickjs_ng</code> make target runs <code>cmake</code>, builds and installs QuickJS-NG into a per-build staging prefix (<code>debian/.quickjs-ng/</code>), and writes a hand-crafted <code>quickjs-ng.pc</code> pointing at that prefix.</li>
<li>The staging prefix&#8217;s <code>lib/pkgconfig</code> is prepended to <code>PKG_CONFIG_PATH</code> before NGINX&#8217;s <code>./configure</code> runs. njs&#8217;s probe sees it, compiles against it, links against it. Done.</li>
</ol>



<p class="wp-block-paragraph">One critical detail: the make target is <strong>fatal on any failure</strong>. If <code>cmake</code> isn&#8217;t in the pbuilder chroot, if the submodule didn&#8217;t unpack, if the <code>.pc</code> file fails to write, the build aborts loud and red. We deliberately do not let it silently fall back to the bundled interpreter. The whole point of shipping <code>libnginx-mod-http-njs</code> on this repo is that it&#8217;s the QuickJS-NG flavour. If it&#8217;s not, you&#8217;ve been mis-sold a package.</p>



<p class="wp-block-paragraph">The same vendoring lives in <code>deb/angie-nextgen/</code>, Angie is our other nginx flavour and ships the same module with the same backend. One source of truth, two consumers.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The language-completeness delta, with copy-pasteable examples</h2>



<p class="wp-block-paragraph">Let&#8217;s walk through the things that go from &#8220;awkward or impossible&#8221; on stock njs to &#8220;boring and obvious&#8221; on the QuickJS-NG build. Every snippet below is real njs you can drop into a <code>js_import</code>&#8216;d module.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="600" src="https://deb.myguard.nl/wp-content/uploads/2026/05/njs-quickjs-ng-nginx-real-javascript-async-example.webp" alt="njs async function with Promise.all subrequests running on QuickJS-NG" class="wp-image-6001" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/njs-quickjs-ng-nginx-real-javascript-async-example.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/njs-quickjs-ng-nginx-real-javascript-async-example-300x150.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/njs-quickjs-ng-nginx-real-javascript-async-example-1024x512.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/njs-quickjs-ng-nginx-real-javascript-async-example-768x384.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /><figcaption class="wp-element-caption">Real <code>async</code>/<code>await</code> with <code>Promise.all</code> across two subrequests, the QuickJS-NG build makes this boringly correct.</figcaption></figure>



<h3 class="wp-block-heading">Real async/await with subrequests</h3>



<pre class="wp-block-preformatted">async function combinedAuth(r) {
    const [user, perms] = await Promise.all([
        r.subrequest('/auth/whoami',  { method: 'GET' }),
        r.subrequest('/auth/perms',   { method: 'GET' }),
    ]);
    if (user.status !== 200 || perms.status !== 200) {
        r.return(401);
        return;
    }
    const u = JSON.parse(user.responseText);
    const p = JSON.parse(perms.responseText);
    r.headersOut['X-User']  = u.name;
    r.headersOut['X-Perms'] = p.scopes.join(',');
    r.return(204);
}
export default { combinedAuth };</pre>



<p class="wp-block-paragraph">On stock njs this exists in some form, but <code>Promise.all</code> with two subrequests has historically misordered microtasks in ways that hit you only under load. On the QuickJS-NG build it behaves like Node: the two subrequests start, the await yields, both complete, the rest of the function runs. Boringly correct.</p>



<h3 class="wp-block-heading">BigInt for a real counter</h3>



<pre class="wp-block-preformatted">let totalBytes = 0n;
function trackBytes(r) {
    totalBytes += BigInt(r.headersOut['Content-Length'] ?? '0');
    r.variables.total_bytes = totalBytes.toString();
}
export default { trackBytes };</pre>



<p class="wp-block-paragraph">That <code>0n</code> literal alone wouldn&#8217;t parse on stock njs. With QuickJS-NG it&#8217;s the natural way to keep a 64-bit running total of bytes served per worker without silent overflow at four petabytes. (Yes, you&#8217;ll probably restart the worker before then. That&#8217;s not the point.)</p>



<h3 class="wp-block-heading">Dynamic import() to lazy-load a helper</h3>



<pre class="wp-block-preformatted">async function maybeSlowHandler(r) {
    if (r.uri.startsWith('/admin/')) {
        const { expensiveAuth } = await import('./admin-auth.js');
        return expensiveAuth(r);
    }
    r.return(204);
}
export default { maybeSlowHandler };</pre>



<p class="wp-block-paragraph">Stock njs loads everything at config-parse time. With QuickJS-NG dynamic <code>import()</code> works, so admin-only modules are read off disk only when the admin path is hit. For modules that pull in a few hundred kilobytes of token-signing code, that&#8217;s a real win.</p>



<h3 class="wp-block-heading">Modern regex for a header you actually need to parse</h3>



<pre class="wp-block-preformatted">// Pull the client IP out of a Forwarded: header per RFC 7239.
const FORWARDED = /(?&lt;=for=)"?(?&lt;ip&gt;\[[\da-fA-F:]+\]|[\d.]+)"?/;
function clientIp(r) {
    const m = (r.headersIn['Forwarded'] || '').match(FORWARDED);
    return m?.groups?.ip ?? r.remoteAddress;
}
export default { clientIp };</pre>



<p class="wp-block-paragraph">Lookbehind, named groups, optional chaining, nullish coalescing, every feature in that one-liner is unavailable or partial on stock njs, and trivial on the QuickJS-NG build.</p>



<h3 class="wp-block-heading">Intl for a locale-aware cache key</h3>



<pre class="wp-block-preformatted">function priceCacheKey(r) {
    const locale = r.headersIn['Accept-Language']?.split(',')[0] || 'en-US';
    const today  = new Intl.DateTimeFormat(locale, { dateStyle: 'short' })
                       .format(new Date());
    return `${locale}|${today}`;
}
export default { priceCacheKey };</pre>



<p class="wp-block-paragraph">Real <code>Intl</code> with the underlying ICU data means the date string actually matches what the user&#8217;s browser would display. On stock njs you&#8217;d hand-roll a locale switch and feel bad about it.</p>



<h3 class="wp-block-heading">Proxy for a config object that yells at you</h3>



<pre class="wp-block-preformatted">const config = new Proxy({ ttl: 60, region: 'eu-west-1' }, {
    get(t, k) {
        if (!(k in t)) throw new Error(`config key '${String(k)}' not set`);
        return t[k];
    },
});
function withConfig(r) {
    r.headersOut['X-Cache-TTL'] = config.ttl;
    r.return(204);
}
export default { withConfig };</pre>



<p class="wp-block-paragraph">Typos in config keys become loud errors at request time instead of silent <code>undefined</code> values that propagate two functions deep. Stock njs: no Proxy, no luck.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">A worked example: JWT validation in 40 lines</h2>



<p class="wp-block-paragraph">The classic njs use case. You sit NGINX in front of an upstream that wants Bearer tokens, you don&#8217;t want the upstream to do the verification on every request, and you want NGINX to drop bad tokens at the edge. Stock njs makes you do it, but the code reads like 2014. Here&#8217;s what it looks like on the QuickJS-NG build, with real <code>async</code>/<code>await</code>, real <code>Uint8Array</code> crypto, real base64url, optional chaining the whole way down:</p>



<pre class="wp-block-preformatted">import crypto from 'crypto';

const SECRET = process.env.JWT_SECRET ?? 'replace-me-in-prod';

function b64urlDecode(s) {
    s = s.replace(/-/g, '+').replace(/_/g, '/');
    while (s.length % 4) s += '=';
    return Buffer.from(s, 'base64');
}

async function verifyJwt(r) {
    const auth = r.headersIn['Authorization'];
    const tok  = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
    if (!tok) return r.return(401, 'no token\n');

    const [hB64, pB64, sB64] = tok.split('.');
    if (!hB64 || !pB64 || !sB64) return r.return(401, 'malformed\n');

    const head = JSON.parse(b64urlDecode(hB64).toString('utf8'));
    if (head.alg !== 'HS256') return r.return(401, 'bad alg\n');

    const expected = crypto
        .createHmac('sha256', SECRET)
        .update(`${hB64}.${pB64}`)
        .digest('base64')
        .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

    if (expected !== sB64) return r.return(401, 'bad sig\n');

    const claims = JSON.parse(b64urlDecode(pB64).toString('utf8'));
    if (claims.exp &amp;&amp; claims.exp * 1000 &lt; Date.now())
        return r.return(401, 'expired\n');

    r.headersOut['X-JWT-Sub']    = claims.sub  ?? '';
    r.headersOut['X-JWT-Scopes'] = claims.scope ?? '';
    r.return(204);
}
export default { verifyJwt };</pre>



<p class="wp-block-paragraph">Wire it into <code>nginx.conf</code> as:</p>



<pre class="wp-block-preformatted">load_module modules/ngx_http_js_module.so;

http {
    js_import jwt from /etc/nginx/njs/jwt.js;

    server {
        listen 443 ssl;
        location /api/ {
            auth_request /_jwt_verify;
            proxy_pass http://upstream;
        }
        location = /_jwt_verify {
            internal;
            js_content jwt.verifyJwt;
        }
    }
}</pre>



<p class="wp-block-paragraph">That&#8217;s a complete, production-shaped JWT gate. Every modern-JS feature in it (optional chaining, nullish coalescing, template literals with backtick interpolation, <code>async</code>, <code>Buffer</code> arithmetic, the crypto module) is the QuickJS-NG side of the fence. None of it requires you to think about which engine is underneath, the script just runs, the way a Node developer would expect.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What it costs you</h2>



<p class="wp-block-paragraph">Nothing&#8217;s free. The honest trade-offs:</p>



<ul class="wp-block-list">
<li><strong>Memory.</strong> Each QuickJS-NG VM is a bit bigger than a stock njs interpreter. We&#8217;re talking single-digit megabytes per worker, not gigabytes. If you&#8217;re tuning a 64-worker box to within an inch of its life this matters; if you&#8217;re running four workers and 64 GB of RAM, you&#8217;ll never notice.</li>
<li><strong>Build dependency.</strong> The package build needs <code>cmake</code> and a C++ compiler in the chroot to compile QuickJS-NG. Our pbuilder configs already include them. If you&#8217;re rebuilding from source on a stripped-down environment, that&#8217;s one more apt-get.</li>
<li><strong>Cold start.</strong> The QuickJS-NG bytecode compiler is slightly slower than stock njs on first parse. NGINX caches compiled modules per worker, so this only matters at startup or after a reload. For most workloads, invisible.</li>
<li><strong>Steady-state throughput.</strong> Comparable on tiny scripts, often <em>faster</em> on compute-heavy ones thanks to QuickJS-NG&#8217;s inline caches. We&#8217;ve seen 10–30% gains on token-signing hot paths, partly because the modern syntax lets you write tighter code in the first place.</li>
<li><strong>Behavioural drift.</strong> A script that worked on stock njs by accident: for example by relying on a quirk of how its native Promise queued, might behave differently on the QuickJS-NG build. We&#8217;ve not seen this bite anyone in practice, but it&#8217;s worth knowing.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">Using it on our packages</h2>



<p class="wp-block-paragraph">If you&#8217;ve added <a href="/how-to-use/">the myguard apt repo</a> the install is one command:</p>



<pre class="wp-block-preformatted">apt install libnginx-mod-http-njs libnginx-mod-stream-njs</pre>



<p class="wp-block-paragraph">Or, on the Angie flavour:</p>



<pre class="wp-block-preformatted">apt install angie-module-njs</pre>



<p class="wp-block-paragraph">The Debian post-install drops a <code>/etc/nginx/modules-enabled/50-mod-http-njs.conf</code> snippet that loads the module, restart NGINX and the directives are available. Drop your <code>.js</code> files anywhere (we recommend <code>/etc/nginx/njs/</code>), <code>js_import</code> them, and you&#8217;re done. There&#8217;s nothing njs-specific about which scripts work, if it parses as ES2023 and only uses <code>r.</code> / <code>ngx.</code> APIs, it&#8217;ll run.</p>



<p class="wp-block-paragraph">The module&#8217;s section on the synopsis page, directives, syntax, examples, lives at <a href="https://deb.myguard.nl/nginx/modules-synopsis/#njs">/nginx/modules-synopsis/#njs</a>. The full module list with descriptions is at <a href="/nginx-modules/">/nginx-modules/</a> for NGINX and <a href="/angie-modules-optimized-extended/">/angie-modules-optimized-extended/</a> for Angie. Both pages call out QuickJS-NG explicitly in their bundled-libraries section, so when a future you wonders why your <code>BigInt</code> literal works, the answer is on the same page as the install command.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Frequently asked questions</h2>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-rewrite" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I have to rewrite my existing njs scripts?</h3>
<div class="rank-math-answer ">

<p>No. Anything that parsed on the bundled njs interpreter parses on the QuickJS-NG build, QuickJS-NG is a strict superset of the language features njs accepted. Your scripts keep working, you just get the option to use the modern syntax when it makes life better.</p>

</div>
</div>
<div id="rm-faq-perf" class="rank-math-list-item">
<h3 class="rank-math-question ">What about performance? Isn&#8217;t a full JS engine slower?</h3>
<div class="rank-math-answer ">

<p>In practice, no. QuickJS-NG is bytecode-interpreted with inline caches, similar in spirit to the stock njs interpreter. Cold start is marginally slower on first parse; steady-state throughput is comparable on small scripts and often faster on compute-heavy ones (10–30% in our token-signing benchmarks). Per-VM memory is a few MB higher. For the workloads people put on njs, this is a rounding error.</p>

</div>
</div>
<div id="rm-faq-npm" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I use npm packages?</h3>
<div class="rank-math-answer ">

<p>Pure-JavaScript packages with no Node-API dependencies often work as-is, JWT helpers, base64url libraries, small parsers, template engines. Anything that requires the Node runtime (filesystem access via the Node API, native modules, child_process, full HTTP client) will not, because njs doesn&#8217;t expose Node APIs even on QuickJS-NG. The crypto module njs ships is its own implementation; treat it as the API surface, not the npm crypto package.</p>

</div>
</div>
<div id="rm-faq-stock" class="rank-math-list-item">
<h3 class="rank-math-question ">Does this work on stock upstream nginx?</h3>
<div class="rank-math-answer ">

<p>Not out of the box. The official nginx.org repos ship njs with its bundled interpreter. To get the QuickJS-NG backend you either install from a repo that builds it that way (ours, for instance) or rebuild njs yourself with quickjs-ng staged so that njs&#8217;s nginx/config probe finds it via pkg-config. The mechanism is documented upstream, we just automate it in the Debian rules so it&#8217;s the default.</p>

</div>
</div>
<div id="rm-faq-fallback" class="rank-math-list-item">
<h3 class="rank-math-question ">What happens if the QuickJS-NG build fails, will it silently fall back to the bundled interpreter?</h3>
<div class="rank-math-answer ">

<p>No, and we made this a deliberate design call. Our deb/nginx/debian/rules fails the entire package build if QuickJS-NG can&#8217;t be staged. A silent fallback would mean shipping a package labelled &#8216;njs with QuickJS-NG&#8217; that wasn&#8217;t, which would set up the worst class of debugging problem: &#8216;this should work, why doesn&#8217;t it&#8217;. The hard failure forces the root cause to the surface.</p>

</div>
</div>
<div id="rm-faq-debug" class="rank-math-list-item">
<h3 class="rank-math-question ">How do I tell which engine is active?</h3>
<div class="rank-math-answer ">

<p>From inside a script, the cheap check is BigInt, try a literal like 1n, and if the config loads, you&#8217;re on QuickJS-NG. From the outside, ldd against the loaded module: &#8216;ldd /usr/lib/nginx/modules/ngx_http_js_module.so | grep quickjs&#8217; will show the libquickjs.ng linkage when it&#8217;s the QuickJS-NG build. If that line is absent, you&#8217;re on stock.</p>

</div>
</div>
<div id="rm-faq-angie" class="rank-math-list-item">
<h3 class="rank-math-question ">Does the Angie flavour ship the same backend?</h3>
<div class="rank-math-answer ">

<p>Yes. Angie is our other nginx flavour and uses the same modules/nginx/quickjs-ng vendored source, staged the same way via debian/rules. Same build_quickjs_ng target, same fatal-on-failure semantics, same module behaviour. One source of truth, two consumer packages.</p>

</div>
</div>
</div>
</div>


<h2 class="wp-block-heading" style="color:#f59e0b">Related reading</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/http3-quic-nginx-setup-tuning-gotchas-2026/">HTTP/3 and QUIC on NGINX: real-world setup, tuning, and gotchas</a>: the other big NGINX feature we ship enabled by default.</li>
<li><a href="/2026/05/zstd-nginx-module-what-it-does-bugs-fixed/">The zstd NGINX module: what it does and the bugs we fixed</a>: another module on this build that gets a heavier than usual amount of love.</li>
<li><a href="/2026/05/hardened-openssh-for-debian-and-ubuntu-pq-crypto-apparmor-three-sshd-flavours/">Hardened OpenSSH 10.3 for Debian and Ubuntu</a>: the same hardening philosophy applied to the SSH side of your stack.</li>
<li><a href="/2026/05/self-hosting-aptly-debian-apt-repository-nginx/">Self-hosting Aptly behind NGINX</a>: how this repository itself is served, in case you want to build your own.</li>
</ul>



<p class="wp-block-paragraph">Pick a function. Open the docs. Drop in a script. The walls aren&#8217;t there anymore.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Postfix 3.11: Post-Quantum TLS, TLSRPT, Milters and the Modern MTA Stack</title>
		<link>https://deb.myguard.nl/2026/05/postfix-3-11-post-quantum-tls-tlsrpt-milters-and-the-modern-mta-stack/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 26 May 2026 17:01:43 +0000</pubDate>
				<category><![CDATA[Mail]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[mail]]></category>
		<category><![CDATA[modsecurity]]></category>
		<category><![CDATA[postfix]]></category>
		<category><![CDATA[quic]]></category>
		<category><![CDATA[rspamd]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[tls]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5941</guid>

					<description><![CDATA[In May 1998, Wietse Venema released the first public alpha of a mailer he&#8217;d been writing inside IBM Research and originally called&#8230;]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In May 1998, Wietse Venema released the first public alpha of a mailer he&#8217;d been writing inside IBM Research and originally called <em>Vmailer</em>. Sendmail was the dominant MTA, its monolithic <code>sendmail.cf</code> looked like a regex got into a fight with a Lisp interpreter, and security advisories arrived faster than dependencies could be patched. Postfix was the response to that mess: a multi-process design where each daemon does one small job with the smallest possible privileges. Twenty-seven years later, it still runs on roughly a third of the public SMTP servers on the open internet, and the new 3.11 series shipped on 5 March 2026 with post-quantum TLS, structured TLS reporting, and a fresh round of crypto-knob spring-cleaning.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1600" height="1068" src="https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-3-11-post-quantum-tls-mta-modernization.webp" alt="Postfix 3.11 post-quantum TLS MTA modernization featured image" class="wp-image-5942" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-3-11-post-quantum-tls-mta-modernization.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-3-11-post-quantum-tls-mta-modernization-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-3-11-post-quantum-tls-mta-modernization-1024x684.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-3-11-post-quantum-tls-mta-modernization-768x513.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-3-11-post-quantum-tls-mta-modernization-1536x1025.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /></figure>



<p class="wp-block-paragraph">This is a tour of what&#8217;s actually new in Postfix 3.11, why post-quantum key exchange in SMTP is not just security theatre, and how the <a href="/">deb.myguard.nl</a> Postfix package and the matching <code>eilandert/postfix</code> container build it. There is config you can paste, history you can blame, and one moderately bad joke about Sendmail. Buckle in.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">A very short history of why Postfix exists</h2>



<p class="wp-block-paragraph">Sendmail, written by Eric Allman in 1981, was the canonical Unix MTA for fifteen years. It also had an exploit surface that read like a recipe book: setuid root, monolithic, with a configuration file generated from <code>m4</code> macros that no human could review by eye. Through the mid-1990s, CERT advisories naming Sendmail averaged one every few months. Wietse Venema, who had a track record of writing security-grade software (the original <code>tcp_wrappers</code> shipped with most Linux distributions), spent his sabbatical at IBM Research building an MTA that started from a different first principle: <em>nothing runs as root that doesn&#8217;t have to</em>.</p>



<p class="wp-block-paragraph">The Postfix architecture is a bunch of little daemons under the <code>master</code> process, each chrooted, each running as the unprivileged <code>postfix</code> user, each talking to its neighbours over a private socket. The SMTP server (<code>smtpd</code>) accepts mail. The cleanup daemon rewrites headers. The queue manager (<code>qmgr</code>) decides what gets delivered next. The SMTP client (<code>smtp</code>) ships mail outbound. If any one of them gets pwned, the blast radius is the privileges that one daemon had, which is almost nothing. Compare that to a monolithic MTA where a parser bug means root.</p>



<p class="wp-block-paragraph">The trade-off is that Postfix talks to itself a lot. A single inbound message touches at minimum five processes. On modern hardware this is invisible. On a Pentium II in 1999 it was a real concern, and the design choice paid off only because Venema is paranoid about <code>memcpy</code>. Performance matters, but not as much as not getting owned by a malformed SMTP envelope.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What is actually new in Postfix 3.11</h2>



<p class="wp-block-paragraph">Postfix&#8217;s release cadence is glacial by modern software standards, and that&#8217;s a feature. Wietse ships a new stable train roughly once a year. The 3.10 line landed in February 2025 and introduced TLSRPT (more on that below). 3.11.0 went out on 5 March 2026, with 3.11.1, 3.11.2 and 3.11.3 following over the next two months. The headline items:</p>



<ul class="wp-block-list">
<li><strong>Default <code>smtp_tls_security_level = may</code></strong>. When <code>compatibility_level</code> is 3.11 or higher and Postfix was built with TLS support, outbound mail attempts opportunistic TLS by default. For decades the default was &#8220;no TLS unless you set it&#8221;, which made sense in 2002 and was embarrassing by 2025. New compatibility level, new default.</li>
<li><strong>Deletion of <code>smtpd_tls_eecdh_grade</code> and friends</strong>. The old &#8220;strong/ultra&#8221; curve-grade settings were superseded by <code>tls_eecdh_auto_curves</code> in Postfix 3.6 and quietly ignored ever since. 3.11 deletes them outright. If you still have <code>smtpd_tls_eecdh_grade = ultra</code> in <code>main.cf</code>, <code>postconf</code> now warns and tells you to remove it.</li>
<li><strong>Post-quantum TLS via OpenSSL 3.5</strong>. The infrastructure landed in the 3.10 cycle, but 3.11 is the first stable release where it sees actual use. You list hybrid KEM groups in <code>tls_eecdh_auto_curves</code> and the SMTP TLS handshake negotiates X25519MLKEM768 when both ends support it.</li>
<li><strong>Berkeley DB phase-out</strong>. Debian and several other distributions are removing Berkeley DB. Postfix 3.11 ships migration helpers: a stand-alone reindexer service, automatic <code>$default_database_type</code> substitution in <code>alias_maps</code>, and a long <code>NON_BERKELEYDB_README</code> appendix on the migration. The package on deb.myguard.nl defaults to <code>cdb</code> for lookups and <code>lmdb</code> for caches, which dodges the issue entirely.</li>
<li><strong>SQLite quoting safety</strong>. The SQLite map client now logs a warning when a query uses double-quoted string literals instead of single quotes. Double-quoting in SQLite is a footgun that bypasses SQL injection protection; getting a loud warning beats a silent compromise.</li>
<li><strong>TLSRPT support hardened</strong>. The 3.10 line introduced it. 3.11 added routine logging of TLSRPT success and failure events, an option to suppress reports for reused TLS sessions, and a library API version check so a mismatched <code>libtlsrpt</code> won&#8217;t silently misbehave.</li>
<li><strong>Lots of small portability and bugfix work</strong>. Three Bugfix entries credit &#8220;Claude Opus 4.6&#8221; as the finder, which is the first time I&#8217;ve seen an LLM credited in upstream Postfix history. The robots are reading <code>proxymap.c</code>. Make of that what you will.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">Post-quantum TLS in SMTP: why now?</h2>



<p class="wp-block-paragraph">Here&#8217;s the threat model in one sentence. A sufficiently large quantum computer with enough qubits can run Shor&#8217;s algorithm against the discrete log problem that ECDHE and RSA-KEX rely on, and recover the symmetric session key from a recorded handshake. That computer does not yet exist. But state-level adversaries are recording encrypted traffic <em>today</em> on the bet that the computer will exist within fifteen years, and at that point everything captured today gets decrypted. The industry calls this &#8220;harvest now, decrypt later&#8221;, and it&#8217;s the reason every cryptography body on Earth started shipping post-quantum algorithms in late 2024.</p>



<p class="wp-block-paragraph">The standardised winner from NIST is ML-KEM (Module-Lattice-Based Key Encapsulation Mechanism), formerly known as Kyber, formally specified in <a href="https://csrc.nist.gov/pubs/fips/203/final" target="_blank" rel="noopener">FIPS 203</a> in August 2024. ML-KEM-768 (the medium security parameter set) is the version that ended up everywhere, Cloudflare, Google, Apple, OpenSSH 9.9, and OpenSSL 3.5. Crucially, no one runs ML-KEM <em>alone</em>. Lattice cryptography is young, and a bug in the lattice could break ML-KEM the way the lattice CRYSTALS-Dilithium signature scheme had a near-miss last year. So every deployment uses a hybrid: classical X25519 + ML-KEM-768 in parallel, and the session key is derived from <em>both</em>. The handshake breaks only if both schemes do.</p>



<p class="wp-block-paragraph">In TLS 1.3, the hybrid groups have IANA-registered names. The two that matter for Postfix are <code>X25519MLKEM768</code> (code point 0x11EC) and <code>SecP256r1MLKEM768</code> (0x11EB). Both ship in OpenSSL 3.5 (April 2025) and are picked up automatically by Postfix&#8217;s TLS layer once you list them in <code>tls_eecdh_auto_curves</code>. The classical curves go after them as a fallback, in case the remote MTA is still running an OpenSSL old enough to lack PQ support.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="800" src="https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-mta-relay-mail-server-architecture.webp" alt="Postfix MTA relay mail server architecture diagram" class="wp-image-5943" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-mta-relay-mail-server-architecture.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-mta-relay-mail-server-architecture-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-mta-relay-mail-server-architecture-1024x683.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-mta-relay-mail-server-architecture-768x512.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /></figure>



<p class="wp-block-paragraph">The Postfix configuration to enable it is two lines:</p>



<pre class="wp-block-preformatted">tls_eecdh_auto_curves = X25519MLKEM768 SecP256r1MLKEM768 X25519 prime256v1 secp384r1
tls_ffdhe_auto_groups = ffdhe3072 ffdhe4096</pre>



<p class="wp-block-paragraph">Test the handshake with a one-liner against your own MTA. <code>-groups X25519MLKEM768</code> forces the client to offer only that group. If the server picks it, you get <code>Negotiated TLS1.3 group: X25519MLKEM768</code> in the output. If the server does not support it, the handshake fails outright (which is the point of the test, you want a hard signal, not a silent downgrade):</p>



<pre class="wp-block-preformatted">openssl s_client -connect mail.example.com:25 -starttls smtp \
  -groups X25519MLKEM768 2&gt;&amp;1 | grep Negotiated</pre>



<p class="wp-block-paragraph">One important caveat. SMTP is opportunistic. Two MTAs that don&#8217;t share a single PQ group will still negotiate a classical curve and the mail still flows. That&#8217;s the right behaviour for an MTA, better to deliver mail on a classical curve than reject it because the recipient hasn&#8217;t upgraded yet. The PQ benefit is therefore <em>population-wide and gradual</em>: every server that adds the groups raises the percentage of traffic that&#8217;s recorded-but-uncrackable. We&#8217;re maybe 4% of inter-MTA traffic right now. By 2028, it&#8217;ll probably be most of it.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Anti-spam in Postfix: postscreen, access restrictions, and where rspamd takes over</h2>



<p class="wp-block-paragraph">Postfix has three layers of spam defence that run before the message even hits the queue. Each rejects a different category, and each is cheap because it happens before any disk write.</p>



<h3 class="wp-block-heading">Postscreen: the bouncer at the front door</h3>



<p class="wp-block-paragraph"><code>postscreen</code> is a separate daemon that listens on port 25 instead of <code>smtpd</code>, and its only job is to decide whether the connecting IP deserves to talk to a real SMTP server at all. It scores the connection against weighted DNSBLs, watches for protocol violations (pre-greet pipelining, bare newlines, malformed commands), and either drops the connection or hands it off to <code>smtpd</code>. The deciding rule is the <code>postscreen_dnsbl_threshold</code> score, anything at or above the threshold gets a 521 disconnect.</p>



<p class="wp-block-paragraph">The trick is choosing DNSBLs that are alive and not noisy. The mail filter landscape has thinned dramatically since 2020. SORBS shut down in June 2024 when Proofpoint pulled the funding. WPBL went away around 2021. The Lashback UBL is gone. Several SpamRats zones return wildcards now. If your <code>postscreen_dnsbl_sites</code> still lists any of those, you&#8217;re paying DNS lookup latency for zero benefit.</p>



<p class="wp-block-paragraph">A defensible 2026 postscreen list, scoped tight to &#8220;really worst offenders only&#8221;:</p>



<pre class="wp-block-preformatted">postscreen_dnsbl_threshold = 3
postscreen_dnsbl_sites =
  zen.spamhaus.org*3,
  dnsbl.dronebl.org*2,
  truncate.gbudb.net*2,
  z.mailspike.net=127.0.0.2*100,
  list.dnswl.org=127.0.[0..255].[1]*-4,
  list.dnswl.org=127.0.[0..255].[2]*-6,
  list.dnswl.org=127.0.[0..255].[3]*-9</pre>



<p class="wp-block-paragraph">Spamhaus Zen combines SBL (the manually curated naughty list, which includes the EDROP feed of hijacked netblocks), XBL (botnet C2), and PBL (dynamic IP ranges that should never be sending mail directly). Dronebl is botnet-only. Truncate.gbudb.net is extremely conservative, it lists only IPs with a substantial pattern of repeat abuse. Mailspike&#8217;s <code>z.</code> zone gets weight 100 because a single hit is dispositive. And the negative-weight dnswl entries can rescue a borderline-suspect-but-legitimate sender.</p>



<p class="wp-block-paragraph">If you already run a heavy content filter behind Postfix, and you should, you can drop Spamhaus from the postscreen list entirely. The rspamd RBL module queries Zen at content-scan time anyway. Letting rspamd handle reputation lookups centralises policy and avoids redundant DNS. See the <a href="/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">deep dive on rspamd</a> for what it can do that postscreen cannot.</p>



<h3 class="wp-block-heading">smtpd access restrictions: envelope sanity</h3>



<p class="wp-block-paragraph">Once a connection has cleared postscreen, <code>smtpd</code>&#8216;s access restrictions kick in. These are Postfix&#8217;s terminology for what the rest of the world sometimes calls &#8220;anti-spoofing&#8221; rules. They look at the SMTP envelope, the <code>HELO</code> argument, the <code>MAIL FROM</code>, the <code>RCPT TO</code>, and reject obvious forgeries before the <code>DATA</code> phase. The big ones:</p>



<ul class="wp-block-list">
<li><strong><code>reject_invalid_helo_hostname</code></strong> and <strong><code>reject_non_fqdn_helo_hostname</code></strong>: drops clients that say <code>HELO foo</code> instead of their actual FQDN. Real MTAs always send a proper FQDN.</li>
<li><strong><code>reject_unknown_sender_domain</code></strong>: looks up the sender domain in DNS. If it has no A, MX or AAAA record, the address can&#8217;t possibly receive a bounce. Drop the message.</li>
<li><strong><code>reject_non_fqdn_sender</code></strong> and <strong><code>reject_non_fqdn_recipient</code></strong>: refuses <code>MAIL FROM:&lt;bob@local&gt;</code> and similar bareword addresses.</li>
<li><strong><code>reject_unauth_destination</code></strong>: the rule that makes you not an open relay. If we are not the final destination for the recipient and the sender isn&#8217;t in <code>mynetworks</code> or SASL-authenticated, refuse to relay. This single line is the difference between &#8220;mail server&#8221; and &#8220;spammer&#8217;s tool&#8221;.</li>
<li><strong><code>reject_unauth_pipelining</code></strong>: clients that send multiple SMTP commands in one TCP packet before negotiating <code>PIPELINING</code> are almost certainly spam bots trying to dump payload faster than the server can refuse it.</li>
<li><strong><code>disable_vrfy_command</code></strong> and <strong><code>strict_rfc821_envelopes</code></strong>: close off the username-enumeration vector and reject envelopes that don&#8217;t use angle brackets properly.</li>
</ul>



<p class="wp-block-paragraph">Together these eight rules reject something like a fifth of all spam attempts before <code>DATA</code> ever runs, which is the cheapest spam to refuse. None of them involve content inspection. None require DKIM verification. They&#8217;re pure envelope sanity, and they&#8217;re free.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="800" src="https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-postscreen-tls-rpt-spam-shield.webp" alt="Postfix postscreen and TLSRPT spam shield illustration" class="wp-image-5944" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-postscreen-tls-rpt-spam-shield.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-postscreen-tls-rpt-spam-shield-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-postscreen-tls-rpt-spam-shield-1024x683.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-postscreen-tls-rpt-spam-shield-768x512.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /></figure>



<h2 class="wp-block-heading" style="color:#f59e0b">TLSRPT: SMTP finally gets a delivery receipt for crypto</h2>



<p class="wp-block-paragraph">TLSRPT (TLS Reporting, RFC 8460) is the answer to a question nobody could previously answer: <em>are remote MTAs actually using TLS when they deliver mail to me?</em> For two decades the answer was &#8220;look at the Received: headers and hope&#8221;. TLSRPT publishes a TXT record at <code>_smtp._tls.example.com</code> with an email or HTTPS endpoint, and remote MTAs send daily JSON reports listing every TLS handshake (and every failed one) for traffic destined to your domain. You get aggregated visibility into who&#8217;s downgrading, who&#8217;s tripping over expired certs, and who&#8217;s still on TLS 1.0.</p>



<p class="wp-block-paragraph">Postfix 3.10 was the first stable release with TLSRPT support, built against Wietse&#8217;s own <code>libtlsrpt</code>. 3.11 added the <code>smtp_tlsrpt_skip_reused_handshakes</code> knob (default <code>yes</code>) so you don&#8217;t drown in reports for connection-reused sessions that don&#8217;t have new TLS data to report. The library API version is now checked at startup, which catches the case where Postfix was built against one libtlsrpt and is running against a newer incompatible one.</p>



<p class="wp-block-paragraph">If you&#8217;re the recipient publishing the TXT record, the report ingestion problem is yours, not Postfix&#8217;s, Postfix is the <em>sending</em> side that generates reports. Tools like <a href="https://github.com/sys4/parsedmarc" target="_blank" rel="noopener">parsedmarc</a> handle the aggregation side. Worth it if you operate a domain that actually receives mail at volume; mostly noise if you&#8217;re a personal server.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Milters: the surgical content-filter API</h2>



<p class="wp-block-paragraph">Sendmail introduced milters (mail filters) in 2002 as a stable plugin ABI for content scanning. The protocol speaks over a Unix or TCP socket; the MTA streams envelope and headers and body to the milter, and the milter responds with verdicts (accept, reject, quarantine, modify headers, rewrite body). Postfix implemented the same protocol in version 2.3 (2006). The result is that anything that speaks milter, and a lot does, works with both MTAs.</p>



<p class="wp-block-paragraph">The big four in modern deployments:</p>



<ul class="wp-block-list">
<li><strong>OpenDKIM</strong>: signs outbound mail with your DKIM key, verifies inbound DKIM signatures. Postfix asks it for a verdict per message; OpenDKIM hands back a verified-or-not result that ends up in the <code>Authentication-Results:</code> header.</li>
<li><strong>OpenDMARC</strong>: applies the DMARC policy (your domain&#8217;s <code>_dmarc</code> TXT record) on inbound mail. Quarantine, reject, or pass depending on what the sender&#8217;s DNS says.</li>
<li><strong>rspamd</strong>: connects as a milter on port 11332 and runs the entire content-filter pipeline (Bayesian, neural net, RBL, URIBL, OLEFY, DCC, Razor, Pyzor, neural) before <code>smtpd</code> takes the <code>DATA</code> phase. See the <a href="/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">rspamd article</a> for what&#8217;s actually inside that pipeline.</li>
<li><strong>Amavis / ClamAV</strong>: virus and macro-payload scanning. Slower than rspamd, but it scans MIME attachments deeply and catches malware that pure-text reputation systems miss.</li>
</ul>



<p class="wp-block-paragraph">Postfix lets you chain milters in a comma-separated list, with order-of-execution semantics. Common practice: OpenDKIM and OpenDMARC first (they&#8217;re cheap header checks), rspamd second (the heavy lifter), Amavis last (slowest, and you don&#8217;t want to scan with ClamAV mail that rspamd already rejected). The configuration on a typical mailscreen:</p>



<pre class="wp-block-preformatted">smtpd_milters     = inet:127.0.0.1:8891, inet:127.0.0.1:8893, inet:rspamd:11332
non_smtpd_milters = $smtpd_milters
milter_default_action = tempfail
milter_protocol = 6</pre>



<p class="wp-block-paragraph"><code>milter_default_action = tempfail</code> is important. If a milter goes down, you want Postfix to issue a 4xx temporary failure so the remote MTA queues and retries, not a 5xx hard bounce. Setting it to <code>accept</code> means broken milters silently let everything through, which is exactly the security regression you&#8217;d expect.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">How the deb.myguard.nl Postfix package is built</h2>



<p class="wp-block-paragraph">The <code>postfix</code> binary on <a href="/">deb.myguard.nl</a> is built from upstream 3.11.3 source, on Debian trixie and Ubuntu resolute, with a hardened toolchain configuration that&#8217;s worth describing because it shows what &#8220;secure compilation&#8221; actually means in 2026:</p>



<pre class="wp-block-preformatted">DEB_BUILD_MAINT_OPTIONS = hardening=+all optimize=+lto future=+lfs
DEB_CPPFLAGS_MAINT_APPEND = -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3
DEB_CFLAGS_MAINT_APPEND   = -O3 -fno-plt -fstack-clash-protection -fcf-protection=full
DEB_LDFLAGS_MAINT_APPEND  = -Wl,-z,now -Wl,-z,relro -Wl,-z,noexecstack -Wl,-O1 -Wl,--as-needed</pre>



<p class="wp-block-paragraph">What that buys you, in plain terms:</p>



<ul class="wp-block-list">
<li><code>FORTIFY_SOURCE=3</code>: the compiler emits bounds-checking wrappers around <code>memcpy</code>, <code>strcpy</code>, <code>sprintf</code> and friends. Level 3 catches more than level 2 by using whole-program object-size analysis.</li>
<li><code>-fstack-clash-protection</code>: prevents stack-overflow attacks that jump past the guard page.</li>
<li><code>-fcf-protection=full</code>: Intel CET (Control-flow Enforcement Technology) and AMD SHSTK markers. Hardware-enforced shadow stack, where the CPU itself refuses returns to a hijacked address. Requires Tiger Lake / Zen 3 or newer to actually fire, but the binaries are tagged for it on older CPUs and ignored.</li>
<li><code>-z now -z relro -z noexecstack</code>: fully bind all dynamic symbols at load time, mark the GOT read-only after relocation, and forbid stack execution. The trifecta that turns most write-what-where bugs into mere crashes.</li>
<li><code>-O3 -fno-plt</code>: aggressive optimization plus no procedure-linkage-table indirection. Skip the PLT and let the linker resolve calls directly through the GOT; smaller dispatch path, marginal perf win.</li>
</ul>



<p class="wp-block-paragraph">Beyond compilation, the package ships a hardened default <code>main.cf</code> with the TLS settings from this article already in place (TLSv1.2+ floor, PFS-only cipher list, X25519MLKEM768 hybrid PQ, <code>NO_COMPRESSION</code> / <code>NO_RENEGOTIATION</code> / <code>NO_TICKET</code>, all the smtpd access restrictions). Install on Debian trixie or Ubuntu resolute with:</p>



<pre class="wp-block-preformatted">echo "deb [trusted=yes] https://deb.myguard.nl trixie main" \
  &gt; /etc/apt/sources.list.d/myguard.list
apt update
apt install postfix postfix-ldap postfix-mysql postfix-pcre postfix-pgsql postfix-sqlite</pre>



<h2 class="wp-block-heading" style="color:#f59e0b">The eilandert/postfix Docker image</h2>



<p class="wp-block-paragraph">For people who don&#8217;t want to install Postfix on the host, the <code>eilandert/postfix</code> image on Docker Hub (Debian and Ubuntu variants) wraps the same package in a container that boots in about 400 milliseconds and weighs 277 MB. The Dockerfile installs the myguard Postfix plus its map modules, populates <code>/etc/postfix.orig</code> with the hardened defaults, and runs a <code>bootstrap.sh</code> that copies them into the volume-mounted <code>/etc/postfix</code> on first run. After that, the container respects whatever you put in the volume.</p>



<p class="wp-block-paragraph">The compose file is essentially:</p>



<pre class="wp-block-preformatted">services:
  postfix:
    image: eilandert/postfix:debian
    container_name: postfix
    restart: always
    ports:
      - 25:25/tcp
      - 587:587/tcp
    volumes:
      - ./config/postfix:/etc/postfix:rw
      - ./data/postfix:/var/lib/postfix:rw
      - /etc/letsencrypt:/etc/letsencrypt:ro
    environment:
      - TZ=Europe/Amsterdam</pre>



<p class="wp-block-paragraph">A few container-specific touches. The image preloads <code>libmimalloc-secure.so</code> via <code>LD_PRELOAD</code> for the security-hardened allocator (you can switch to jemalloc or none via the <code>MALLOC</code> env var). It runs <code>postfix reload</code> in a daily loop in the background so renewed Let&#8217;s Encrypt certs get picked up automatically. And it forwards logs either to stdout (which Docker captures) or to a remote syslog if you set <code>SYSLOG_HOST</code>. The whole thing is on the dockerized monorepo at <a href="https://github.com/eilandert/dockerized" target="_blank" rel="noopener">github.com/eilandert/dockerized</a>.</p>



<p class="wp-block-paragraph">For the full mail-server stack, Postfix plus Dovecot for IMAP, rspamd for filtering, DKIM keys, DMARC reporting, see the <a href="/2026/05/postfix-dovecot-setup-debian/">Postfix + Dovecot setup guide</a>. It walks the whole pipeline end to end. This article was mostly about what&#8217;s new in 3.11 itself; that one is about how to make it actually deliver mail.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Frequently Asked Questions</h2>



<h3 class="wp-block-heading">Does Postfix 3.11 actually require OpenSSL 3.5 for post-quantum TLS?</h3>


<p class="wp-block-paragraph">For X25519MLKEM768 and SecP256r1MLKEM768, yes. OpenSSL 3.5 (April 2025) is the first stable release with hybrid ML-KEM groups in TLS 1.3. Postfix doesn&#8217;t carry its own crypto; it asks OpenSSL to negotiate. If the system OpenSSL is older, the PQ group names in <code>tls_eecdh_auto_curves</code> are silently ignored and the handshake falls through to whatever classical curves are left in the list. That&#8217;s why the recommended setting puts PQ groups first and classical X25519 / P-256 / P-384 second, older peers degrade gracefully.</p>



<h3 class="wp-block-heading">Is opportunistic TLS in SMTP secure against active attackers?</h3>


<p class="wp-block-paragraph">No. Opportunistic STARTTLS is downgrade-attackable by anyone who can inject TCP, they strip the <code>STARTTLS</code> response from the EHLO answer and the client falls back to plaintext. The fix is DANE (DNSSEC + TLSA records) or MTA-STS (a published HTTPS policy file). DANE is technically stronger because the TLSA record is signed; MTA-STS is easier to deploy because you don&#8217;t need DNSSEC. Postfix supports both. For a public-facing MTA, you want at least one of them active.</p>



<h3 class="wp-block-heading">What&#8217;s the difference between postscreen and smtpd access restrictions?</h3>


<p class="wp-block-paragraph">Postscreen is the front door, it decides whether the connecting IP gets to talk to a real SMTP server at all, based on DNSBL scoring and protocol-violation heuristics. It does all this in one daemon, before <code>smtpd</code> is even involved. Smtpd access restrictions run later, once the connection has cleared postscreen, and operate on the SMTP envelope itself (HELO name, MAIL FROM, RCPT TO). Postscreen is cheaper because it never spins up a per-connection smtpd process for connections it kills; smtpd restrictions can do things postscreen can&#8217;t, like reject based on the recipient address. Use both.</p>



<h3 class="wp-block-heading">Is the eilandert/postfix Docker image production-ready?</h3>


<p class="wp-block-paragraph">It&#8217;s what&#8217;s running the production mailscreen at myguard.nl, so by that definition yes. It&#8217;s a small wrapper around the deb.myguard.nl Postfix package with a bootstrap script that respects volume-mounted config. The image is rebuilt automatically when the underlying package version moves. The main things you bring to it are a real cert (mount <code>/etc/letsencrypt</code> read-only), a real <code>main.cf</code> (mount your config dir), and a milter setup for content filtering (rspamd typically). Don&#8217;t run it without those.</p>



<h3 class="wp-block-heading">Do I need to enable TLSRPT to use the new Postfix?</h3>


<p class="wp-block-paragraph">No. TLSRPT is opt-in for both the sender and the receiver. As a sending MTA, Postfix only emits reports when it sees a recipient domain that publishes a TLSRPT TXT record. As a receiver, you only see reports if you publish the record and run an ingestion pipeline. Most operators ignore TLSRPT entirely and the world keeps spinning. It&#8217;s most useful if you&#8217;re operating a domain that receives substantial mail volume and you want visibility into how remote MTAs are actually delivering it.</p>



<h3 class="wp-block-heading">Should I disable Berkeley DB on my Postfix install?</h3>


<p class="wp-block-paragraph">If you&#8217;re on Debian trixie or later, the answer is &#8220;Debian is going to do it for you&#8221;. The Postfix package on deb.myguard.nl already defaults to <code>cdb</code> for lookup tables and <code>lmdb</code> for caches, so the migration is essentially done by the time you install. If you have an existing install with <code>hash:</code> or <code>btree:</code> tables in your <code>main.cf</code>, the Postfix 3.11 <code>NON_BERKELEYDB_README</code> walks the conversion. The short version: run <code>postmap -F cdb:/etc/postfix/transport</code> to rebuild each table in the new format, then update the type prefix in <code>main.cf</code>.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Further reading</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/postfix-dovecot-setup-debian/">Postfix + Dovecot Mail Server Setup on Debian 12 and 13</a>: the full stack walkthrough, with DKIM, SPF, DMARC, virtual mailboxes and a 10/10 mail-tester score.</li>
<li><a href="/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/">Rspamd Explained: How Modern Spam Filtering Actually Works</a>: what the rspamd milter is doing under the hood (Bayesian, neural, RBLs, URIBLs, OLEFY).</li>
<li><a href="/2026/05/docker-hardening-rootless-readonly-distroless/">Docker Hardening: Rootless, Read-only, Distroless</a>: what to do to the eilandert/postfix container if you want to lock it down further.</li>
<li><a href="https://www.postfix.org/postconf.5.html" target="_blank" rel="noopener">postconf(5)</a>: the upstream reference for every parameter mentioned in this post.</li>
<li><a href="https://www.rfc-editor.org/rfc/rfc8460" target="_blank" rel="noopener">RFC 8460 (TLSRPT)</a> and <a href="https://csrc.nist.gov/pubs/fips/203/final" target="_blank" rel="noopener">FIPS 203 (ML-KEM)</a> if you want to read the standards instead of trusting my paraphrases.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Google Instant Indexing API for WordPress: end-to-end setup (service account, JWT, OAuth2)</title>
		<link>https://deb.myguard.nl/2026/05/google-instant-indexing-api-wordpress/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Mon, 25 May 2026 16:02:45 +0000</pubDate>
				<category><![CDATA[WordPress]]></category>
		<category><![CDATA[148]]></category>
		<category><![CDATA[207]]></category>
		<category><![CDATA[241]]></category>
		<category><![CDATA[261]]></category>
		<category><![CDATA[262]]></category>
		<category><![CDATA[263]]></category>
		<category><![CDATA[264]]></category>
		<category><![CDATA[265]]></category>
		<category><![CDATA[266]]></category>
		<category><![CDATA[267]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5924</guid>

					<description><![CDATA[Service account, JWT signing, OAuth2 dance, JSON key paste — the complete setup for Google's Instant Indexing API on WordPress, with verified quota via Cloud Monitoring and an honest take on what it actually does for non-JobPosting content.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Setting up the Google Instant Indexing API WordPress integration sounds like a fifteen-minute job, and it nearly is, once you know where the landmines are buried. Google&#8217;s Indexing API has a daily quota of <strong>200 publish requests per project</strong> by default, and the only two content types Google&#8217;s documentation officially supports for it are <code>JobPosting</code> and <code>BroadcastEvent</code>, livestream videos. Everything else returns HTTP 200 and gets quietly dropped on the server side. We&#8217;ll get to that. First, let&#8217;s build the thing that does the dropping, because despite all of that, a non-trivial number of WordPress folks running it report faster first-crawl times for normal posts, and the cost of being wrong is approximately zero. Worst case, you&#8217;ve practised your OAuth2 JWT-signing skills on a free quota.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="630" src="https://deb.myguard.nl/wp-content/uploads/2026/06/google-instant-indexing-api-wordpress-oauth2-jwt-flow.webp" alt="Google Instant Indexing API for WordPress OAuth2 service-account JWT flow from the WordPress plugin to the Google token endpoint and the Indexing API" class="wp-image-6172" srcset="https://deb.myguard.nl/wp-content/uploads/2026/06/google-instant-indexing-api-wordpress-oauth2-jwt-flow.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/06/google-instant-indexing-api-wordpress-oauth2-jwt-flow-300x158.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/06/google-instant-indexing-api-wordpress-oauth2-jwt-flow-1024x538.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/06/google-instant-indexing-api-wordpress-oauth2-jwt-flow-768x403.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /><figcaption class="wp-element-caption">How the Google Instant Indexing API for WordPress flow actually works: sign a JWT with the service-account key, swap it for an OAuth2 token, then call the Indexing API.</figcaption></figure>



<p class="wp-block-paragraph">This is the end-to-end setup guide for the Google Instant Indexing API on WordPress, using the MyGuard Pings module&#8217;s <em>Google Indexing</em> subtab. By the end you&#8217;ll have a service account, a downloaded JSON key, a verified Search Console ownership grant, and a single test ping returning a clean 200 from <code>indexing.googleapis.com</code>. If you&#8217;re here looking for the IndexNow alternative (which actually works for normal blog posts), skip to the FAQ at the bottom, short answer: we ship both, IndexNow is the recommended default, and you should probably enable IndexNow first.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The honest disclaimer up front</h2>



<p class="wp-block-paragraph">Read this once, then we never have to talk about it again.</p>



<p class="wp-block-paragraph">Google&#8217;s own <a href="https://developers.google.com/search/apis/indexing-api/v3/quickstart" target="_blank" rel="noopener">Indexing API quickstart</a> opens with a fence: <em>&#8220;Only use the Indexing API to notify Google of <code>JobPosting</code> or <code>BroadcastEvent</code> embedded in a <code>VideoObject</code>.&#8221;</em> That&#8217;s the entire supported surface. Job listings and livestream events. Nothing else. John Mueller from Google&#8217;s Search Relations team has restated this multiple times on Twitter and at office hours. If you submit a URL that isn&#8217;t one of those schema types, the API returns <code>200 OK</code> with a friendly <code>urlNotificationMetadata</code> object, and then nothing happens. The signal is discarded server-side. Google does not promise it will affect crawl scheduling for general content, and they explicitly say it doesn&#8217;t.</p>



<p class="wp-block-paragraph">So why does every SEO plugin ship this feature? Two reasons. One: between roughly 2019 and 2021, the API <em>did</em> have an observable effect on first-crawl latency for non-JobPosting content. That window has closed but the muscle memory hasn&#8217;t. Two: even today, a noisy minority of SEOs swear they still see faster discovery. There&#8217;s no controlled study, only forum threads. The honest engineer&#8217;s take is: it&#8217;s free, it&#8217;s harmless, the quota is small, the worst case is a no-op. If you have the patience to set it up, set it up. If you don&#8217;t, enable <a href="https://www.indexnow.org/" target="_blank" rel="noopener">IndexNow</a> instead, IndexNow is an open spec, multi-engine (Bing, Yandex, Naver, Seznam), and it actually works for arbitrary content. The MyGuard Pings module enables IndexNow by default; this article is about the Google-specific bit you have to opt into.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">What the Google Instant Indexing API WordPress setup actually builds</h2>



<p class="wp-block-paragraph">Before the click-through, it helps to know what we&#8217;re assembling. The Google Indexing API doesn&#8217;t take a username and password. It uses <strong>OAuth 2.0 with a service account</strong>, a non-human Google identity you create, give a private RSA key, and authorise to act on your domain. The flow looks like this:</p>



<ol class="wp-block-list">
<li>Your plugin builds a JSON Web Token (JWT): a short signed payload that says <em>&#8220;I am service account X, and I want to do indexing things for the next hour.&#8221;</em></li>
<li>It signs that JWT with the private key you downloaded (RS256, that&#8217;s RSA-SHA256).</li>
<li>It POSTs the signed JWT to Google&#8217;s OAuth token endpoint and gets back a short-lived <strong>access token</strong> (about an hour).</li>
<li>It uses that access token as a bearer credential to call <code>https://indexing.googleapis.com/v3/urlNotifications:publish</code> with the URL it wants Google to crawl.</li>
</ol>



<p class="wp-block-paragraph">The MyGuard plugin handles steps 1–4 for you. Your job is steps 0a through 0g: creating the service account, downloading the key, granting it ownership in Search Console, pasting the JSON into the plugin. All the cryptographic plumbing is on our side. You&#8217;re providing identity.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 1, Sign into the Google Cloud Console</h2>



<p class="wp-block-paragraph">Open <a href="https://console.cloud.google.com" target="_blank" rel="noopener">console.cloud.google.com</a>. Sign in with whichever Google account you want to own this service account, most sysadmins use their main Workspace identity here; some create a dedicated <code>ops@</code> alias so the indexing project survives staff turnover. Either is fine. The service account itself is what matters, not the human who created it; you can transfer projects between owners later.</p>



<p class="wp-block-paragraph">If this is your first time in Cloud Console, Google will ask you to accept the terms and pick a country. You&#8217;re not signing up for billing, the Indexing API has a free quota of 200 requests/day and doesn&#8217;t require a billing account to use at that tier.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 2, Create a project</h2>



<p class="wp-block-paragraph">Top bar, the project dropdown (left of the search box). <em>New Project</em>. Name it something boring and self-explanatory. I use <code>wordpress-indexing</code>; you&#8217;ll thank yourself in a year when you&#8217;ve forgotten what <code>untitled-project-7</code> is supposed to do. Leave the organisation field as-is (it&#8217;ll default to your personal account if you&#8217;re not in a Workspace org). Click <em>Create</em> and wait ten seconds for the spinner.</p>



<p class="wp-block-paragraph">Once it&#8217;s created, make sure the project dropdown is showing your new project. This is the single most common mistake in this entire procedure: people enable APIs and create service accounts in the wrong project, then wonder why nothing works. Check the top bar. <strong>The active project name appears there. It should say <code>wordpress-indexing</code> (or whatever you called it).</strong></p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 3, Enable the Indexing API</h2>



<p class="wp-block-paragraph">Hamburger menu (top-left) → <em>APIs &amp; Services</em> → <em>Library</em>. In the search box, type <code>indexing</code>. The first result will be <em>Web Search Indexing API</em> (or just <em>Indexing API</em>, the rename has been ongoing). Click it. Hit the big blue <em>Enable</em> button. It&#8217;ll think for five seconds and then drop you on the API&#8217;s dashboard.</p>



<p class="wp-block-paragraph">If you skip this step, every API call later returns the gloriously specific error <em>&#8220;Indexing API has not been used in project <code>wordpress-indexing</code> before or it is disabled. Enable it by visiting&#8230;&#8221;</em>, followed by a URL that takes you right back to this page. Google&#8217;s error messages here are unusually good. Trust them.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 4, Create the service account</h2>



<p class="wp-block-paragraph">Hamburger menu → <em>IAM &amp; Admin</em> → <em>Service Accounts</em>. Click <em>+ Create Service Account</em> at the top. You&#8217;ll see a three-step form.</p>



<ul class="wp-block-list">
<li><strong>Service account name:</strong> something descriptive. <code>wp-indexing-bot</code> works. This becomes the prefix of the auto-generated email: you&#8217;ll end up with something like <code><span style="display:inline;" class="">wp&#45;i&#110;&#100;&#101;x&#105;n&#103;&#45;b&#111;&#116;&#64;w&#111;&#114;d&#112;&#114;&#101;s&#115;&#45;&#105;&#110;d&#101;&#120;ing.&#105;&#97;&#109;.&#103;&#115;er&#118;ic&#101;acco&#117;&#110;&#116;&#46;c&#111;m</span></code>.</li>
<li><strong>Service account ID:</strong> Google fills it in from the name. Leave it.</li>
<li><strong>Description:</strong> optional. Write <em>&#8220;Pings Google Indexing API for new WordPress posts on deb.example.com&#8221;</em> or whatever helps future-you.</li>
</ul>



<p class="wp-block-paragraph">Click <em>Create and Continue</em>. The next step asks you to grant <em>this service account access to project</em>. <strong>Skip it.</strong> The Indexing API does not authorise via project-level IAM roles; it authorises via Search Console ownership (we&#8217;ll do that in step 6). Granting a project role here is harmless but unnecessary, and granting <code>Owner</code> on the project is actively dangerous, it gives the service account power over your entire Google Cloud project, not just indexing. Skip the role assignment, click <em>Continue</em>, then <em>Done</em>.</p>



<p class="wp-block-paragraph">You&#8217;ll land back on the Service Accounts list with one new entry. Copy the email address (the long <code>...gserviceaccount.com</code> string). You&#8217;ll paste it into Search Console in step 6. Keep it on the clipboard or jot it down.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 5, Generate and download the JSON key</h2>



<p class="wp-block-paragraph">Click the service account&#8217;s email in the list. You&#8217;ll land on its details page. Top tabs: <em>Details, Permissions, Keys, Metrics, Logs</em>. Open <em>Keys</em>.</p>



<p class="wp-block-paragraph"><em>Add Key</em> → <em>Create new key</em>. A modal pops up asking JSON or P12. <strong>JSON.</strong> Always JSON. P12 is a legacy format (PKCS#12, a 1996 vintage container originally for client TLS certs) that the modern Google SDKs still support but which nobody on the WordPress side wants to deal with. Click <em>Create</em>.</p>



<p class="wp-block-paragraph">Your browser will download a file. The filename is something hideous like <code>wordpress-indexing-a1b2c3d4e5f6.json</code>. Open it in a text editor. You&#8217;ll see this:</p>



<pre class="wp-block-code"><code>{
  "type": "service_account",
  "project_id": "wordpress-indexing",
  "private_key_id": "abc123...",
  "private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...lots of base64...\n-----END PRIVATE KEY-----\n",
  "client_email": "wp-indexing-bot@wordpress-indexing.iam.gserviceaccount.com",
  "client_id": "12345...",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/wp-indexing-bot%40wordpress-indexing.iam.gserviceaccount.com",
  "universe_domain": "googleapis.com"
}</code></pre>



<p class="wp-block-paragraph"><strong>This file is now a private RSA key in a JSON wrapper.</strong> Treat it like an SSH private key. Anyone holding this can ping the Google Indexing API as you, until you revoke it from the same Keys tab. Do not commit it to git. Do not paste it into a Slack channel. Do not email it to yourself unencrypted. If it leaks, go straight back to the Keys tab, delete that key entry, and generate a new one, there&#8217;s no PEM-passphrase concept, no rate-limit-the-blast-radius option, just &#8220;key exists&#8221; vs &#8220;key doesn&#8217;t exist&#8221;.</p>



<p class="wp-block-paragraph">If you&#8217;re the kind of paranoid that I am, save the key into something like Bitwarden / <a href="/2026/05/self-hosted-password-manager-with-vaultwarden/" target="_blank" rel="noopener">self-hosted Vaultwarden</a> as a &#8220;secure note&#8221; once it&#8217;s pasted into the plugin, then shred the local copy:</p>



<pre class="wp-block-code"><code>shred -u ~/Downloads/wordpress-indexing-a1b2c3d4e5f6.json</code></pre>



<p class="wp-block-paragraph">The key is now in WordPress&#8217;s <code>wp_options</code> table, which is approximately as safe as the rest of your WordPress install. That&#8217;s a real consideration, if a third party gains read access to your database (SQL injection, misconfigured backup, a stolen <code>wp-config.php</code>) they get this key. Plan accordingly. If you keep encrypted DB backups off-site, encrypt them. Don&#8217;t keep plain-text DB dumps lying around in <code>/tmp</code> after a debugging session.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 6, Grant Search Console ownership</h2>



<p class="wp-block-paragraph">This is the step nobody explains properly and where most people fail with a 403.</p>



<p class="wp-block-paragraph">The Indexing API doesn&#8217;t trust the service account&#8217;s existence alone. It checks, on every request, whether that service account is listed as an <em>Owner</em> of the target site in Google Search Console. <strong>Lesser roles (Full user, Restricted user) return 403.</strong> Must be Owner. This is unusual, Google services normally accept multiple permission levels, but the Indexing API was built when domain ownership was the only model that made sense for &#8220;may I tell you to re-crawl this&#8221;.</p>



<p class="wp-block-paragraph">Go to <a href="https://search.google.com/search-console" target="_blank" rel="noopener">search.google.com/search-console</a>. Pick the property for the domain you want to ping. If you don&#8217;t have one yet, add it now (the domain-property type is preferred; it covers all subdomains and protocols, but requires a DNS TXT record to verify). Take that detour, come back, then continue.</p>



<p class="wp-block-paragraph">With the property selected, click the gear icon (<em>Settings</em>) in the left sidebar → <em>Users and permissions</em>. You&#8217;ll see yourself listed as Owner. Click <em>Add user</em> in the top right.</p>



<ul class="wp-block-list">
<li><strong>Email address:</strong> paste the <code>client_email</code> from the JSON file. The full <code><span style="display:inline;" class="">&#119;p&#45;&#105;&#110;&#100;&#101;x&#105;&#110;g&#45;&#98;ot&#64;wor&#100;&#112;&#114;ess-in&#100;&#101;&#120;ing.&#105;&#97;&#109;.&#103;&#115;erv&#105;cea&#99;co&#117;nt.c&#111;m</span></code> address. Google will not complain that it&#8217;s not a human Gmail; it accepts service-account emails here.</li>
<li><strong>Permission:</strong> <strong>Owner.</strong> Not Full user. Not Restricted user. Owner.</li>
</ul>



<p class="wp-block-paragraph">Click <em>Add</em>. The service account now appears in the Users list with the Owner badge. From this moment, the API will accept publish requests for URLs on this domain from this service account.</p>



<p class="wp-block-paragraph">If you run multiple WordPress sites and want to ping all of them with one service account, repeat this Search Console step for each domain. The same service account can be Owner on as many properties as you want, there&#8217;s no per-domain key needed. The downside is that a leak gives the attacker indexing rights across all those domains, so if your blast radius matters, use one service account per property.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 7, Paste the JSON into MyGuard</h2>



<p class="wp-block-paragraph">In WordPress admin: <em>MyGuard → Pings → Google Indexing</em>. You&#8217;ll see two ways to provide the key:</p>



<ul class="wp-block-list">
<li><strong>Paste it into the big monospaced textarea.</strong> The whole JSON, opening brace to closing brace. The plugin&#8217;s textarea is wide enough to show the structure without horizontal scrolling.</li>
<li><strong>Or click <em>Browse</em> next to &#8220;Or upload the .json file&#8221; and pick the file Google downloaded.</strong> The browser reads the file in JS (it never leaves your machine until you hit Save) and pastes it into the textarea for you. This is usually the more reliable path because it avoids the next paragraph&#8217;s failure mode.</li>
</ul>



<p class="wp-block-paragraph"><strong>The number-one paste failure is mangled newlines in the <code>private_key</code> field.</strong> The JSON file uses <code>\n</code> as a literal two-character escape inside the string. If you copy the JSON out of a terminal that does line-wrapping, or paste through an editor that &#8220;helpfully&#8221; normalises whitespace, those escapes can turn into actual newlines, breaking the JSON parse. The file upload method bypasses this; if you must paste, paste from a real text editor (VS Code, gedit, Notepad++) rather than from a terminal that&#8217;s been word-wrapping.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 8, Save and verify</h2>



<p class="wp-block-paragraph">Click <em>Save</em>. The plugin runs a server-side <code>json_decode()</code> on the field, validates that <code>type == "service_account"</code> and that <code>client_email</code>, <code>private_key</code>, and <code>token_uri</code> are all present. If the parse fails, you&#8217;ll see a red admin notice <em>&#8220;Saved, but the pasted JSON failed to parse as a valid service-account key. The previous key (if any) was kept.&#8221;</em> The save handler deliberately keeps your old (working) key in that case, so a fat-finger paste doesn&#8217;t lock you out of your previously-working integration.</p>



<p class="wp-block-paragraph">If the parse succeeds, you&#8217;ll see a green notice showing the parsed <code>client_email</code> and <code>project_id</code>. That&#8217;s your confirmation that the plugin can actually use the key. From this point on, every ping the plugin sends will arrive at Google&#8217;s OAuth endpoint as a JWT signed with the <code>private_key</code> field of this JSON.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 9, Test now</h2>



<p class="wp-block-paragraph">Click <em>Test now (uses 1 quota)</em>. The plugin picks the most recent published post (of the post types you&#8217;ve selected, defaults to Posts), signs a JWT with your private key, exchanges it for an access token at Google&#8217;s OAuth endpoint, then POSTs a <code>URL_UPDATED</code> notification for that post&#8217;s permalink. The expected result is <strong>HTTP 200</strong> with a JSON body containing <code>urlNotificationMetadata</code>.</p>



<p class="wp-block-paragraph">If you want to verify what&#8217;s actually flying across the wire, the same call from the shell looks like this, useful for debugging, for understanding, or for satisfying the urge to see the bits move:</p>



<pre class="wp-block-code"><code># With $ACCESS_TOKEN obtained from the OAuth dance (the plugin does this for you):
curl -sS -X POST \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/your-post-slug/","type":"URL_UPDATED"}' \
  https://indexing.googleapis.com/v3/urlNotifications:publish</code></pre>



<p class="wp-block-paragraph">On success Google replies with the timestamp of your notification, proof that the request was accepted into their indexing queue. Whether they act on it for non-<code>JobPosting</code> content is the other question, but the API accepted you. That counts as setup-complete.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Step 10, Flip the switch</h2>



<p class="wp-block-paragraph">Toggle <em>Enable</em> on, hit Save once more. The plugin now adds a Google Indexing call to every drain tick: every time a queued post leaves the queue (whether queued by the auto-trigger on publish/update or shoved in manually from the <em>Manual</em> subtab), the plugin fires the Indexing API alongside whatever other endpoints you have enabled. One call per post. Subject to the 200/day quota, which the subtab shows you as a live meter.</p>



<p class="wp-block-paragraph">That meter resets at 00:00 UTC. Google&#8217;s quotas are wall-clock UTC, not your local time, not your WordPress timezone. If you publish a flurry of posts late on a UTC-day boundary, half might miss the day-N quota and end up firing on day-N+1, the queue tolerates that gracefully (it just respects the per-endpoint 1/hour rate limit, separately from the daily quota).</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">What 200 requests/day actually means</h2>



<p class="wp-block-paragraph">The default project quota is <strong>200 <code>urlNotifications:publish</code> requests per day</strong>. That sounds generous, and for most personal blogs it is. The math: you&#8217;d have to publish or substantively update a post every seven minutes for sixteen hours straight to exhaust it. If you do, congratulations, you&#8217;re a content farm and Google has other concerns about you.</p>



<p class="wp-block-paragraph">Google publishes a <a href="https://support.google.com/webmasters/contact/indexing_api_quota" target="_blank" rel="noopener">quota-increase request form</a>. It&#8217;s gated by use case, they want to see that you&#8217;re a legitimate JobPosting or BroadcastEvent publisher with a track record. Requesting more than 200/day for general blog content is almost never granted. If your real use case is a job board, request away; if not, treat 200 as a hard ceiling and design around it (which mostly means: don&#8217;t ping every trivial edit, only meaningful publishes, which the plugin already does by gating on <code>post_modified</code> advancing and on actual content changes).</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Optional but recommended: verified quota via Cloud Monitoring</h2>



<p class="wp-block-paragraph">The plugin tracks your daily usage in two ways. By default it just increments a local counter every time it gets a 2xx back from the Indexing API. That works but it can drift: a manual <em>Test now</em> retry, a quota you spent from another script outside WordPress, a server-side rate limit you weren&#8217;t tracking, the local number falls out of sync with what Google actually thinks. There&#8217;s a fix: ask Google.</p>



<p class="wp-block-paragraph">Google exposes per-project Indexing API usage via the <strong>Cloud Monitoring API</strong> as a <code>quota/allocation/usage</code> time-series metric. The plugin can query it directly using the same service-account JSON you already pasted in, it just needs a second OAuth scope (<code>monitoring.read</code>) and two extra setup steps in Cloud Console. Once it works, the subtab&#8217;s Daily quota row swaps from <em>&#8220;local counter only&#8221;</em> to <em>&#8220;✓ verified via Cloud Monitoring N minutes ago&#8221;</em> and the number you see is the real per-project total Google has registered for today.</p>



<p class="wp-block-paragraph">Two steps:</p>



<ol class="wp-block-list">
<li><strong>Enable the Cloud Monitoring API</strong> in the same project that owns the service account. Cloud Console → <em>APIs &amp; Services</em> → <em>Library</em> → search <code>monitoring</code> → pick <em>Cloud Monitoring API</em> → <em>Enable</em>. Same drill as step 3 above.</li>
<li><strong>Grant the service account the <code>roles/monitoring.viewer</code> role.</strong> Cloud Console → <em>IAM &amp; Admin</em> → <em>IAM</em> → find your service account&#8217;s row (it&#8217;ll show as <code><span style="display:inline;" class="">&#119;p-i&#110;&#100;ex&#105;&#110;g-b&#111;t&#64;ia&#109;.&#103;s&#101;&#114;&#118;i&#99;&#101;&#97;c&#99;o&#117;&#110;t.&#99;&#111;&#109;</span></code>: yes, the same one) → click the pencil → <em>Add another role</em> → <em>Monitoring Viewer</em> → <em>Save</em>. This is read-only access to your project&#8217;s monitoring data; it can&#8217;t write metrics or change anything.</li>
</ol>



<p class="wp-block-paragraph">Refresh the Google Indexing subtab. If both steps worked, the meter changes to the verified-count display with a green checkmark and the timestamp of the last fetch. If something&#8217;s off, the subtab shows the literal error message from Google&#8217;s Monitoring API inline, usually either <em>&#8220;Cloud Monitoring API has not been used in project X&#8221;</em> (step 1 missing) or <em>&#8220;Permission denied on resource&#8221;</em> (step 2 missing or applied to the wrong account). The plugin caches the verified count for 5 minutes so admin page loads stay snappy and you don&#8217;t burn Monitoring API quota; that&#8217;s also why the timestamp shows &#8220;N minutes ago&#8221; rather than &#8220;right now&#8221;.</p>



<p class="wp-block-paragraph">Setting this up is optional. The plugin works without it, the local counter is a reasonable proxy. But if you care about the difference between &#8220;200 calls I think I made&#8221; and &#8220;200 calls Google actually counted&#8221;, spend the five minutes.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Troubleshooting</h2>



<p class="wp-block-paragraph">Four failure modes account for almost every problem people have with this API.</p>



<ul class="wp-block-list">
<li><strong>HTTP 403 <code>Permission denied</code>.</strong> The service account isn&#8217;t an Owner of the target domain in Search Console. Re-do step 6. The error text usually includes the domain it&#8217;s checking against: sanity-check that it matches the URL you&#8217;re submitting (you can&#8217;t ping <code>example.com</code> if you&#8217;re Owner of <code>www.example.com</code> only, because Search Console URL-properties are exact).</li>
<li><strong>HTTP 401 / <code>invalid_grant</code>.</strong> The JSON paste corrupted the <code>private_key</code>. Almost always a newline issue: see step 7. Re-upload via the file input.</li>
<li><strong>HTTP 429 <code>Quota exceeded</code>.</strong> You&#8217;ve spent the 200 for today. The plugin&#8217;s quota meter on the subtab tells you exactly how many you&#8217;ve used. Wait until 00:00 UTC. Don&#8217;t disable the toggle in panic: the next reset will sort it.</li>
<li><strong><code>"Indexing API has not been used in project X before or it is disabled"</code>.</strong> You skipped step 3. Or you enabled it in a different project than the one your service account lives in. Cloud Console → APIs &amp; Services → Library, search <em>indexing</em>, click Enable. Make sure the project dropdown matches the service account&#8217;s project (the <code>project_id</code> field in your JSON).</li>
</ul>



<p class="wp-block-paragraph">For everything else, the plugin&#8217;s <em>History</em> subtab shows the full last-7-days response log including Google&#8217;s error message body, which tells you precisely what&#8217;s wrong. There&#8217;s a per-endpoint summary too, so if Google Indexing is consistently failing while every other endpoint is succeeding, the row colours make it obvious without scrolling.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">When you should use IndexNow instead (or alongside)</h2>



<p class="wp-block-paragraph">IndexNow is what you actually want for normal blog posts. It&#8217;s an open spec, Microsoft started it, Bing implemented it first, and now Yandex, Naver, and Seznam all participate. One ping fans out to all of them. It works for any content type, no JobPosting fence. The MyGuard plugin enables IndexNow by default with all four direct endpoints (Bing, Yandex, Naver, Seznam) plus the central <code>api.indexnow.org</code> fanout. The only configuration required is generating an IndexNow key, which the plugin does at the click of a button, and serving it from <code>/{key}.txt</code> at your site root, which the plugin also does automatically with an <code>init</code> hook.</p>



<p class="wp-block-paragraph">Google doesn&#8217;t participate in IndexNow. Officially they &#8220;evaluated&#8221; it and decided not to join. Unofficially, IndexNow telemetry suggests the participating engines hit roughly the search-volume sweet spot you actually care about outside the US, Naver dominates Korea, Yandex dominates Russia, Seznam is the default search engine for tens of millions of Czechs and Slovaks, and Bing&#8217;s slice of the global pie has crept past 5% in 2026 thanks to its tight ChatGPT integration. Pinging those four covers a real and growing fraction of the planet.</p>



<p class="wp-block-paragraph">The pragmatic stack: enable IndexNow for everything, enable Google Indexing as a best-effort opt-in (especially if you publish job listings or livestream content), and let Google&#8217;s normal crawler find your other posts via your sitemap. The plugin&#8217;s <em>Settings</em> subtab has IndexNow grouped at the top with its key field; the <em>Endpoints</em> subtab has the dozen-or-so still-living legacy XML-RPC blog ping services for the truly old-school. Use what helps; ignore what doesn&#8217;t.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Behind the curtain, what the JWT actually contains</h2>



<p class="wp-block-paragraph">For anyone curious, here&#8217;s the assertion the plugin builds and signs every hour to refresh its access token. The header is trivial, algorithm and type:</p>



<pre class="wp-block-code"><code>{"alg":"RS256","typ":"JWT"}</code></pre>



<p class="wp-block-paragraph">The claim is where the interesting bits live:</p>



<pre class="wp-block-code"><code>{
  "iss":   "wp-indexing-bot@wordpress-indexing.iam.gserviceaccount.com",
  "scope": "https://www.googleapis.com/auth/indexing",
  "aud":   "https://oauth2.googleapis.com/token",
  "iat":   1779713000,
  "exp":   1779716600
}</code></pre>



<p class="wp-block-paragraph"><code>iss</code> is the issuer, your service account email. <code>scope</code> is the OAuth scope being requested; the <code>indexing</code> scope is the minimum required, no broader Google API access is granted. <code>aud</code> is the audience, Google&#8217;s token endpoint, which is what verifies the signature. <code>iat</code> and <code>exp</code> are issued-at and expires-at Unix timestamps; Google accepts a maximum of one hour between them. The plugin caches the resulting access token for 50 minutes (a 10-minute clock-skew margin) so the OAuth round-trip only happens roughly once an hour per service account.</p>



<p class="wp-block-paragraph">The signature is RS256 over <code>base64url(header) + "." + base64url(claim)</code>, computed with the private key. PHP&#8217;s <code>openssl_sign($input, $sig, $private_key, OPENSSL_ALGO_SHA256)</code> does the cryptographic work in a single call. The whole signed JWT, header, claim, signature, dot-separated, base64url-encoded, gets POSTed as the <code>assertion</code> form parameter to <code>https://oauth2.googleapis.com/token</code> with grant type <code>urn:ietf:params:oauth:grant-type:jwt-bearer</code>. Google returns a normal OAuth response: <code>access_token</code>, <code>expires_in</code>, <code>token_type</code>. That access token then gets reused for every Indexing call until it expires.</p>



<p class="wp-block-paragraph">None of which you need to know to use the plugin. But if you&#8217;re the kind of person who reads a how-to all the way to the end, you&#8217;re the kind of person who wants to know what&#8217;s actually happening, and the kind of person who&#8217;d notice if I left it out.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">FAQ</h2>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-gi-normal" class="rank-math-list-item">
<h3 class="rank-math-question ">Does the Google Indexing API work for normal blog posts?</h3>
<div class="rank-math-answer ">

<p>Officially: no. Google&#8217;s documentation states the API only supports <code>JobPosting</code> and <code>BroadcastEvent</code> (livestream) schema. Pings for other content types return HTTP 200 but are dropped server-side. Unofficially, a meaningful slice of SEOs report still seeing faster first-crawl times for general posts, especially compared to relying on sitemap discovery alone. The honest answer is: it&#8217;s free, the quota costs you nothing if you stay under 200/day, and the worst case is a no-op. For non-JobPosting content, IndexNow is the right primary tool; Google Indexing is best-effort secondary.</p>

</div>
</div>
<div id="rm-faq-gi-multisite" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I use one service account for multiple WordPress sites?</h3>
<div class="rank-math-answer ">

<p>Yes. The same service account can be added as Owner in Google Search Console for as many properties as you want, there&#8217;s no per-domain key needed. The trade-off is blast radius: if that one JSON key leaks, the attacker can ping the Indexing API as you for every domain you&#8217;ve granted it on. If you run more than a handful of sites and they matter, create a separate service account per site. If you run a few personal blogs, one shared account is fine.</p>

</div>
</div>
<div id="rm-faq-gi-vs-indexnow" class="rank-math-list-item">
<h3 class="rank-math-question ">What&#8217;s the difference between Google Indexing API and IndexNow?</h3>
<div class="rank-math-answer ">

<p>IndexNow is an open spec, Bing, Yandex, Naver, and Seznam all participate; one HTTP POST fans out to all of them. It works for any content type, requires only a simple key file at your site root, no OAuth. Google Indexing API is Google-specific, OAuth2 with a service account, and officially limited to <code>JobPosting</code> and <code>BroadcastEvent</code>. Use IndexNow for everything; use Google Indexing as a best-effort opt-in if you want to cover that base too. The MyGuard plugin supports both and pings whichever you enable.</p>

</div>
</div>
<div id="rm-faq-gi-key-safety" class="rank-math-list-item">
<h3 class="rank-math-question ">Is the JSON key safe to store in WordPress&#8217;s wp_options table?</h3>
<div class="rank-math-answer ">

<p>It&#8217;s exactly as safe as the rest of your WordPress install. The key is a private RSA credential, anyone who can read your database can ping the Indexing API as you. Treat your DB the way you&#8217;d treat any secret store: encrypt off-site backups, don&#8217;t leave plain-text dumps lying around, restrict SQL injection vectors. If a DB leak would be a serious incident for you, generate a fresh service-account key after any incident response. The plugin makes rotation easy: paste the new JSON, save, the old key is replaced atomically.</p>

</div>
</div>
<div id="rm-faq-gi-token-rotation" class="rank-math-list-item">
<h3 class="rank-math-question ">What if Google rotates the OAuth token endpoint URL?</h3>
<div class="rank-math-answer ">

<p>The plugin reads the token URL from the <code>token_uri</code> field of your service-account JSON rather than hardcoding it. Google has rotated this endpoint roughly once a decade (from <code>accounts.google.com/o/oauth2/token</code> to <code>oauth2.googleapis.com/token</code>); if they do it again, you regenerate and re-download the JSON key, paste the new file into MyGuard, and the new URL flows through automatically. No plugin update required.</p>

</div>
</div>
<div id="rm-faq-gi-frequency" class="rank-math-list-item">
<h3 class="rank-math-question ">How often does the plugin actually call Google?</h3>
<div class="rank-math-answer ">

<p>Once per drained queue entry, which is once per published or meaningfully-updated post (the auto-trigger gates on real <code>post_content</code> or <code>post_title</code> changes plus an advancing <code>post_modified</code>, so meta-only saves don&#8217;t count). The OAuth access token is cached for 50 minutes between calls, so the token-exchange round-trip happens about once an hour per service account, not once per post. If you manually re-queue a post from the <em>Manual</em> subtab, that&#8217;s an additional one-quota call. The daily-quota meter in the subtab shows your running total.</p>

</div>
</div>
</div>
</div>


<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Related posts</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/database-boost-free-wordpress-database-optimization-plugin/">Database Boost: Free WordPress Database Optimization Plugin</a>, the other half of the MyGuard WordPress stack: scheduled cleanup, repair, and index analysis for the same WP install you just wired into Google.</li>
<li><a href="/2026/05/self-hosted-password-manager-with-vaultwarden/">Self-Hosted Password Manager with Vaultwarden</a>: where to actually store that JSON service-account key after you&#8217;ve pasted it into WordPress and shredded the local copy.</li>
<li><a href="/repository/">myguard Debian/Ubuntu Repository</a>: the apt repository the MyGuard plugin ships from. <code>apt install wp-myguard</code> after adding the repo is the only thing you need to get this article&#8217;s plugin onto a server.</li>
<li><a href="/how-to-use/">How to Add the myguard APT Repository</a>: three commands and a GPG key, and you&#8217;re set up to receive plugin updates the same way you receive nginx updates.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Self-Hosting Aptly: Run Your Own Debian APT Repository Behind NGINX</title>
		<link>https://deb.myguard.nl/2026/05/self-hosting-aptly-debian-apt-repository-nginx/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Mon, 25 May 2026 01:54:06 +0000</pubDate>
				<category><![CDATA[Packages]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5903</guid>

					<description><![CDATA[Aptly turns a folder of .deb files into a real signed APT repository — the same way deb.myguard.nl serves thousands of packages. Here is the full self-hosting walkthrough: install, sign, publish, NGINX, automation.]]></description>
										<content:encoded><![CDATA[<p>Debian itself publishes roughly <strong>74,000 binary packages</strong> across its main archive, and every single one of them is signed, indexed, and served from the same battle-tested format any of us can run on a small VPS. <a href="/">deb.myguard.nl</a> is a much smaller example of the same thing: a few hundred performance-optimised NGINX, Angie, and supporting packages, all served behind plain NGINX from filesystem endpoints generated by <a href="https://www.aptly.info/" rel="noopener" target="_blank">aptly</a>. No magic. No SaaS. No proprietary &#8220;package registry&#8221; lock-in. Just a CLI, a GPG key, and a static web server.</p>

<p>This walkthrough is the full self-hosting aptly recipe. We&#8217;ll install aptly on Debian or Ubuntu, create a repository, import some .debs, sign them properly, publish to a filesystem endpoint, serve it through NGINX, set the clients up correctly (including the modern <code>deb822</code> sources format), and finish with a cron-driven snapshot rotation that keeps history without filling the disk. By the end you&#8217;ll have something <code>apt update</code> trusts and you control top to bottom.</p>

<h2 style="color:#f59e0b">Why self-hosting aptly (and not reprepro, dak, or just a folder of .debs)</h2>

<p>People who&#8217;ve been around Debian a while will reach for <a href="https://salsa.debian.org/debian/reprepro" rel="noopener" target="_blank">reprepro</a> on instinct. It works. It&#8217;s older. It&#8217;s in <code>main</code>. It&#8217;s also brittle in ways that bite you the third time you do anything non-trivial, like republishing after a rollback, or running parallel suites for testing/stable, or maintaining snapshots you can pin to. Reprepro&#8217;s mental model is &#8220;one big mutable archive&#8221;. Aptly&#8217;s is closer to git: you have <em>repositories</em> (mutable working sets), you take <em>snapshots</em> (immutable, named points in time), and you <em>publish</em> snapshots (or repos) to <em>endpoints</em> (filesystem or S3). You can keep three months of nightly snapshots, publish only the ones you want, and roll back a publish in seconds.</p>

<p>Dak (the Debian Archive Kit) is the other option, and it&#8217;s what the actual Debian project uses. It&#8217;s also a beast, designed for hundreds of mirrors, an army of FTP-masters, and a complicated NEW queue. For self-hosters, it&#8217;s wildly overkill. You don&#8217;t want dak unless you are, in fact, becoming a distribution.</p>

<p>&#8220;Just put the .debs in a folder and serve them&#8221; is the joke answer that almost works. <code>apt</code> won&#8217;t index a flat folder; it needs <code>Packages</code>, <code>Packages.gz</code>, <code>Release</code>, <code>Release.gpg</code>, and <code>InRelease</code> files with the right hashes and a valid signature. You could generate those by hand with <code>dpkg-scanpackages</code> + <code>apt-ftparchive</code> + <code>gpg --clearsign</code>, and historically lots of small shops did. It&#8217;s about forty lines of shell that you&#8217;ll get wrong subtly until something breaks two months later. Aptly is that script done properly, with a state database and a publish workflow on top.</p>

<p>The short version: aptly is the right answer for one to ten thousand packages, one to ten architectures, and a single admin (or small team). Above that you&#8217;re probably looking at <a href="https://github.com/pulp/pulp_deb" rel="noopener" target="_blank">Pulp</a> or hosted offerings. Below that you&#8217;re overengineering.</p>

<h2 style="color:#f59e0b">Self-hosting aptly: installing it on Debian or Ubuntu</h2>

<p>The version of aptly in the Debian/Ubuntu archive lags upstream by years (1.5.x at the time of writing, versus upstream 1.6.x). For a host you&#8217;re going to live with, install upstream&#8217;s apt repo:</p>

<pre><code># Trust the upstream signing key (modern signed-by pattern, no apt-key)
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://www.aptly.info/pubkey.txt | \
  sudo gpg --dearmor -o /etc/apt/keyrings/aptly.gpg

# Add the repo (deb822 format, the modern one)
sudo tee /etc/apt/sources.list.d/aptly.sources &gt;/dev/null &lt;&lt;'EOF'
Types: deb
URIs: http://repo.aptly.info/
Suites: squeeze
Components: main
Signed-By: /etc/apt/keyrings/aptly.gpg
EOF

sudo apt update
sudo apt install aptly gnupg2</code></pre>

<p>Yes, the suite is called <code>squeeze</code> even on bookworm or noble, upstream just never renamed it. It&#8217;s fine; aptly itself is a static Go binary that doesn&#8217;t care about distro versions. Verify:</p>

<pre><code>aptly version
# expect aptly version 1.6.x</code></pre>

<p>Aptly&#8217;s state lives in <code>~/.aptly/</code> by default. For a server install, you almost certainly want it elsewhere, a dedicated mount with room to grow. Create <code>~/.aptly.conf</code> (or pass <code>-config=</code> on every command, which gets tedious):</p>

<pre><code>{
  "rootDir": "/srv/aptly",
  "downloadConcurrency": 8,
  "downloadSpeedLimit": 0,
  "architectures": ["amd64", "arm64"],
  "dependencyFollowSuggests": false,
  "dependencyFollowRecommends": false,
  "dependencyFollowAllVariants": false,
  "dependencyFollowSource": false,
  "gpgDisableSign": false,
  "gpgDisableVerify": false,
  "gpgProvider": "gpg",
  "downloadSourcePackages": false
}</code></pre>

<p>The <code>rootDir</code> matters more than it looks, and there&#8217;s a footgun here we&#8217;ll come back to in the gotchas section. For now, give it its own LVM volume or ZFS dataset if you can. Packages are small but they accumulate.</p>

<h2 style="color:#f59e0b">Generate a signing key</h2>

<p>An unsigned APT repository will work on the client only with <code>[trusted=yes]</code> flags everywhere, which is the apt equivalent of disabling TLS verification. Don&#8217;t. Generate a proper GPG signing key:</p>

<pre><code>gpg --batch --gen-key &lt;&lt;'EOF'
%no-protection
Key-Type: RSA
Key-Length: 4096
Subkey-Type: RSA
Subkey-Length: 4096
Name-Real: My APT Repo Signer
Name-Email: <span style="display:inline;" class="">a&#112;&#116;&#64;ex&#97;&#109;&#112;l&#101;&#46;c&#111;&#109;</span>
Expire-Date: 5y
%commit
EOF</code></pre>

<p>The <code>%no-protection</code> means no passphrase, which is what you want for an unattended signing key on a build server. If that makes you nervous: it should, a little, but it&#8217;s the same trade-off every CI system makes. Lock down the home directory (<code>chmod 700 ~/.gnupg</code>) and the key file inside it (<code>chmod 600</code>), and keep that machine off the public internet for SSH if you can.</p>

<p>Export the public key for clients to install:</p>

<pre><code>gpg --armor --export <span style="display:inline;" class="">a&#112;t&#64;&#101;&#120;ampl&#101;&#46;&#99;&#111;m</span> &gt; /srv/aptly/public/pubkey.asc
# Also useful for older clients that want a binary keyring file:
gpg --export <span style="display:inline;" class="">&#97;&#112;&#116;&#64;&#101;&#120;&#97;m&#112;l&#101;&#46;&#99;o&#109;</span> &gt; /srv/aptly/public/pubkey.gpg</code></pre>

<p>The <code>/srv/aptly/public/</code> path is whatever aptly will publish into, we&#8217;ll wire it up in a moment. Clients install this key into <code>/etc/apt/keyrings/your-repo.gpg</code> and point at it with <code>Signed-By:</code> in their sources file. Modern, no global trust pollution.</p>

<h2 style="color:#f59e0b">Create a repo, import .debs, snapshot, publish</h2>

<p>The aptly workflow has four verbs you actually use day-to-day: <code>repo</code>, <code>snapshot</code>, <code>publish</code>, and <code>db cleanup</code>. Walk through it once and you&#8217;ve got the muscle memory for life.</p>

<pre><code># 1. Create a repository
aptly repo create -distribution=bookworm -component=main myrepo-bookworm

# 2. Import .deb files
aptly repo add myrepo-bookworm /path/to/build/output/*.deb

# 3. Snapshot it (immutable named point-in-time)
aptly snapshot create myrepo-bookworm-$(date +%Y%m%d) from repo myrepo-bookworm

# 4. Publish the snapshot to a filesystem endpoint
aptly publish snapshot \
  -distribution=bookworm \
  -architectures=amd64,arm64 \
  -gpg-key=apt@example.com \
  myrepo-bookworm-$(date +%Y%m%d)</code></pre>

<p>That last command does the real work: it writes <code>Packages</code>, <code>Packages.gz</code>, <code>Packages.xz</code>, <code>Release</code>, <code>Release.gpg</code>, <code>InRelease</code>, and a <code>pool/</code> directory with the actual .deb files (hardlinked, not copied, disk-efficient if your filesystem supports it) into <code>$rootDir/public/dists/bookworm/</code> and <code>$rootDir/public/pool/main/</code>. The <code>Release.gpg</code> and <code>InRelease</code> are GPG-signed with your key. APT clients fetch <code>InRelease</code> first, verify the signature, then trust the hashes inside it to verify every other file. Standard chain.</p>

<p>To update the repo later: add new .debs with <code>aptly repo add</code>, take a new snapshot, then <strong>switch</strong> the published distribution to the new snapshot:</p>

<pre><code>aptly publish switch bookworm myrepo-bookworm-$(date +%Y%m%d)</code></pre>

<p>This is the magic that makes aptly nicer than reprepro: a switch is atomic to the client (one HTTP request to <code>InRelease</code> sees old, the next sees new), and if the new snapshot breaks something you can <code>publish switch</code> back to yesterday&#8217;s snapshot in two seconds and nothing on the client side is the wiser.</p>

<h2 style="color:#f59e0b">Serve it with NGINX</h2>

<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/self-hosting-aptly-nginx-config-inline.webp" alt="Aptly NGINX config snapshot publish signing workflow" loading="lazy" /><figcaption>Aptly writes a static tree; NGINX serves it. No PHP, no DB, no SaaS.</figcaption></figure>

<p>Aptly only generates files. It does not serve HTTP. That&#8217;s NGINX&#8217;s job, and the config is mercifully short:</p>

<pre><code>server {
    listen 80;
    listen [::]:80;
    server_name apt.example.com;

    root /srv/aptly/public;

    # APT clients want correct MIME types; defaults are fine for .gz/.xz
    # but explicitly state .deb so they don't get served as text/html
    types {
        application/x-debian-package deb;
        application/pgp-signature     gpg sig asc;
        text/plain                    Release InRelease;
    }

    # Directory listings are optional; many people leave them on
    # for human browsability. Off is safer for production.
    autoindex off;

    # Long cache for pool files (they're content-addressed, never change)
    location /pool/ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Short cache for dists/ (Release/InRelease/Packages change)
    location /dists/ {
        expires 5m;
        add_header Cache-Control "public, must-revalidate";
    }

    access_log /var/log/nginx/apt.access.log;
    error_log  /var/log/nginx/apt.error.log;
}</code></pre>

<p>That&#8217;s it. No PHP, no Python, no database. NGINX serves static files; APT clients consume them. Add a TLS listener if you want HTTPS (and you do, even for a public-read repo, at minimum it stops corporate proxies from caching old InRelease files at random). If the repo is private, slap basic-auth on top:</p>

<pre><code>auth_basic           "apt repo";
auth_basic_user_file /etc/nginx/.htpasswd_apt;</code></pre>

<p>Then on the client, embed credentials in the URI (<code>https://user:pass@apt.example.com/</code>) or use <code>/etc/apt/auth.conf.d/example.conf</code> with <code>machine apt.example.com login user password pass</code>.</p>

<h2 style="color:#f59e0b">Client setup, the modern way</h2>

<p>For years everyone wrote one-line entries into <code>/etc/apt/sources.list</code> and called it done. APT now prefers the <code>deb822</code> format in <code>/etc/apt/sources.list.d/</code>. It&#8217;s more verbose, more readable, and lets you put <code>Signed-By:</code> directly in the source, no more polluting the global apt trust store.</p>

<p>Client install in three commands:</p>

<pre><code># 1. Install the repo's public key as a binary keyring
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://apt.example.com/pubkey.asc \
  -o /etc/apt/keyrings/example-repo.asc

# 2. Write a deb822-format source file
sudo tee /etc/apt/sources.list.d/example.sources &gt;/dev/null &lt;&lt;'EOF'
Types: deb
URIs: https://apt.example.com/
Suites: bookworm
Components: main
Architectures: amd64
Signed-By: /etc/apt/keyrings/example-repo.asc
EOF

# 3. Use it
sudo apt update
sudo apt install your-package</code></pre>

<p>The <code>Signed-By:</code> binding is what stops a compromised <em>different</em> repo from injecting packages signed with a different key. Each repo&#8217;s signature is scoped to that repo only. Modern APT enforces this strictly. Don&#8217;t use the deprecated <code>apt-key add</code> path on any client newer than Debian 11 or Ubuntu 22.04, it&#8217;ll work, with warnings, but it adds your key to global trust which is exactly what we&#8217;re trying to avoid.</p>

<p>This is also how <a href="/how-to-use/">our own how-to-use page</a> tells clients to install our repo. Same pattern. Same security model.</p>

<h2 style="color:#f59e0b">The shared-rootDir trap (and other gotchas)</h2>

<p>This is where our own scar tissue earns its keep. Every aptly endpoint you publish lives under the same <code>$rootDir/public/</code> directory. If you have, say, a primary endpoint serving <code>deb.example.com</code> and a secondary endpoint serving <code>internal.example.com</code> from the same aptly install, they share the publish tree. A <code>aptly publish drop</code> on one endpoint can, and will, wipe files used by the other endpoint, because the cleanup logic walks the shared <code>pool/</code> and removes anything not referenced by a currently-published snapshot. We learned this the hard way: a drop on the secondary endpoint took out a chunk of packages on the primary. Recovery was a full republish from snapshots. Lesson: if you run multiple aptly endpoints, give each one its own <code>rootDir</code> (separate aptly state directory), even if it costs you some disk to duplicate the pool. Or accept that all your endpoints are coupled and never <code>publish drop</code> without thinking hard.</p>

<p>Other ones to watch:</p>

<ul>
<li><strong>Snapshot accumulation eats disk.</strong> Every <code>aptly snapshot create</code> ref-keeps the .debs it points at, even after you&#8217;ve stopped publishing them. <code>aptly db cleanup</code> removes orphans (.debs no snapshot references). Run it weekly or you&#8217;ll find <code>/srv/aptly/pool/</code> at 200 GB of old NGINX nightlies you forgot about.</li>
<li><strong>GPG agent and unattended signing.</strong> Modern GPG insists on talking to <code>gpg-agent</code>, which insists on a TTY. On a headless server, set <code>GPG_TTY=$(tty)</code> in the cron environment or use <code>gpg --pinentry-mode loopback</code>. If your aptly publish runs hang with no output, this is almost always why.</li>
<li><strong>Architecture mismatch.</strong> If your <code>aptly.conf</code> says <code>architectures: ["amd64"]</code> and you add an arm64 .deb, aptly silently drops it. Always include every arch you build for.</li>
<li><strong>The <code>InRelease</code> + <code>Release</code> + <code>Release.gpg</code> trio.</strong> Older clients want <code>Release</code> and <code>Release.gpg</code> (detached signature). Newer clients want <code>InRelease</code> (clear-signed combined file). Aptly writes both by default; if your NGINX is doing aggressive caching, make sure all three are served fresh, not just one.</li>
<li><strong>Time skew breaks signature verification.</strong> A signed <code>InRelease</code> has a <code>Valid-Until</code> field. If your server clock is wrong, you can sign files that are already expired. <code>systemd-timesyncd</code> is your friend.</li>
</ul>

<h2 style="color:#f59e0b">Automation: cron, snapshot rotation, signal-safe</h2>

<p>The whole point of running your own repo is to make publishing trivial. A working build-and-publish cron is short:</p>

<pre><code>#!/bin/bash
# /usr/local/bin/apt-publish-nightly
set -euo pipefail

export GNUPGHOME=/srv/aptly/.gnupg
export GPG_TTY=$(tty || echo /dev/null)
SUITE=bookworm
REPO=myrepo-bookworm
SNAP="${REPO}-$(date +%Y%m%d-%H%M)"

# 1. Add any new .debs the build system dropped
aptly repo add -force-replace "$REPO" /srv/build/output/*.deb

# 2. New snapshot
aptly snapshot create "$SNAP" from repo "$REPO"

# 3. Atomically switch the published suite to the new snapshot
aptly publish switch -gpg-key=apt@example.com "$SUITE" "$SNAP"

# 4. Drop snapshots older than 30 days
aptly snapshot list -raw | grep "^${REPO}-" | while read -r s; do
  d=$(echo "$s" | sed -E "s/^${REPO}-([0-9]{8}).*/\1/")
  age_days=$(( ( $(date +%s) - $(date -d "$d" +%s) ) / 86400 ))
  if [ "$age_days" -gt 30 ]; then
    aptly snapshot drop "$s" || true
  fi
done

# 5. Garbage-collect orphaned .debs
aptly db cleanup</code></pre>

<p>Drop it in cron with <code>15 2 * * * /usr/local/bin/apt-publish-nightly &gt;&gt; /var/log/apt-publish.log 2&gt;&amp;1</code> and you have a self-rotating, signed, snapshotted APT repository that needs zero attention until something genuinely breaks. The <code>-force-replace</code> on <code>repo add</code> lets you re-add a .deb with the same version (useful if you rebuilt to fix a packaging-only bug); without it, aptly refuses, which is also a defensible default.</p>

<p>For the truly paranoid: pipe the publish step through a wrapper that publishes to a staging endpoint first, runs <code>apt-get -s install</code> against it from a test container, and only switches the production endpoint if the dry-run succeeds. This is what <a href="/where-to-find-us/">our build farm</a> does for every nightly. The whole pipeline is &lt;200 lines of shell.</p>

<h2 style="color:#f59e0b">FAQ</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Is aptly suitable for a single-developer self-hosted setup?</h3>
<div class="rank-math-answer ">

<p>Yes. It&#8217;s a static Go binary, the state is a few hundred MB plus your .debs, and it has zero runtime dependencies beyond GPG. For one to ten thousand packages and one to ten architectures, it&#8217;s the obvious choice. Above that, look at Pulp.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I use aptly with Ubuntu as well as Debian?</h3>
<div class="rank-math-answer ">

<p>Yes. Aptly doesn&#8217;t care which distro the .debs were built for; it just indexes and signs them. Plenty of self-hosters run a single aptly install serving separate suites for bookworm, trixie, noble, and oracular side by side. Each suite is just a different distribution in publish-snapshot terms.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I need HTTPS for my apt repo?</h3>
<div class="rank-math-answer ">

<p>You don&#8217;t strictly need it because the package contents are already GPG-signed and APT verifies the signatures itself. But you should add it anyway, TLS prevents corporate proxies from caching stale InRelease files, hides which packages a client is downloading, and avoids confused-deputy attacks from network middleboxes. Let&#8217;s Encrypt makes it free.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">How do I roll back a bad release?</h3>
<div class="rank-math-answer ">

<p>Find the previous snapshot with aptly snapshot list, then run aptly publish switch  . Clients pick up the change on their next apt update (within minutes, faster if you&#8217;ve shortened the InRelease cache). This is the killer feature aptly has that reprepro effectively doesn&#8217;t.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">What&#8217;s the difference between a repo and a snapshot in aptly?</h3>
<div class="rank-math-answer ">

<p>A repo is a mutable working set, you add and remove .debs from it freely. A snapshot is an immutable named pointer to a specific set of .deb versions at a specific point in time. You publish snapshots, not repos directly, so that what&#8217;s live on the mirror is always a frozen point you can name and roll back to.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Can multiple machines push to the same aptly instance?</h3>
<div class="rank-math-answer ">

<p>Not safely without coordination, aptly&#8217;s state DB doesn&#8217;t have locking against concurrent writers from separate processes. Run the aptly CLI on one host and have other build machines scp their .debs into a directory that aptly picks up via a serialised cron, or use the aptly API server with a single API process in front of the state DB.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">How big does disk usage get?</h3>
<div class="rank-math-answer ">

<p>Pool files are stored once and hardlinked into every snapshot that references them. For a typical repo serving ~500 packages across two architectures, expect 5–20 GB depending on package sizes. The trap is keeping years of old snapshots, run aptly db cleanup weekly and you&#8217;ll be fine.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related reading</h2>

<ul>
<li><a href="/how-to-use/">How to Add the myguard APT Repository (Debian &#038; Ubuntu)</a>: the deb822 client-side recipe, exactly as described above, applied to our own repo.</li>
<li><a href="/repository/">myguard Debian/Ubuntu Repository</a>: what a fully-populated aptly-hosted repo looks like in production, with NGINX, Angie, and the full module set.</li>
<li><a href="/where-to-find-us/">Where To Find Us: All Our Repos, Docker Images &#038; GitHub Projects</a>, the full topology of how our build farm publishes to multiple endpoints (and how we avoid the shared-rootDir trap).</li>
<li><a href="/packages/">Packages</a>: the complete catalogue served from the setup described in this article.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>HTTP/3 and QUIC on NGINX: Real-World Setup, Tuning, and Gotchas (2026)</title>
		<link>https://deb.myguard.nl/2026/05/http3-quic-nginx-setup-tuning-gotchas-2026/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Mon, 25 May 2026 01:49:26 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5897</guid>

					<description><![CDATA[HTTP/3 finally works in mainline NGINX, but the config has sharp edges. Here is the real-world setup, the UDP sysctl knobs that actually matter, and the gotchas (alt-svc, MTU, ModSecurity, load balancers) that bite you in production.]]></description>
										<content:encoded><![CDATA[<p>About <strong>32%</strong> of the top-1000 websites already negotiate HTTP/3 with the browsers that ask for it, according to <a href="https://w3techs.com/technologies/details/ce-http3" rel="noopener" target="_blank">W3Techs&#8217; 2026 survey</a>. Cloudflare, Google, Meta, and most CDNs have been quietly doing it for years. And yet, when you try to turn HTTP/3 on in your own NGINX, you discover that the upstream config example in the wiki is a polite lie. It compiles. It binds. It even logs &#8220;QUIC&#8221;. Then a tcpdump shows you exactly zero UDP packets leaving the box, because your firewall doesn&#8217;t know what 443/udp is, your kernel&#8217;s UDP receive buffer is the size of a postcard, and your reverse proxy in front of it is a TCP-only load balancer that drops the alt-svc header on the floor.</p>

<p>This is the real-world HTTP/3 setup. The one that works in production. We&#8217;ll cover what QUIC actually is and why it matters, what HTTP/3 support looks like in mainline NGINX versus Angie versus our <a href="/repository/">deb.myguard.nl builds</a>, a complete listen-block you can paste, the handful of sysctl knobs that turn it from &#8220;kinda works&#8221; into &#8220;fast&#8221;, and the gotchas that have eaten more weekends than I&#8217;d like to admit. Getting HTTP/3 on NGINX into production is mostly about the parts the wiki leaves out.</p>

<h2 style="color:#f59e0b">What HTTP/3 and QUIC actually are (HTTP/3 on NGINX)</h2>

<p>HTTP/3 on NGINX is HTTP/2&#8217;s semantics riding on top of QUIC instead of TCP, served by an NGINX build that knows what to do with it. That&#8217;s the whole story, told fast. Same requests, same responses, same headers, same streams. The transport underneath is different, and it&#8217;s the transport that buys you everything interesting.</p>

<p>QUIC (Quick UDP Internet Connections, because the IETF likes a recursive acronym) is a transport protocol layered on UDP that does what TCP+TLS used to do, but in one handshake instead of three. It was born at Google around 2012 as <em>gQUIC</em>, got dragged into the IETF, mutated for half a decade, and finally shipped as RFC 9000 in 2021. HTTP/3 is RFC 9114. Both stabilised years ago. The reason your stack might not speak it yet is purely sociological: the kernel folks, the load-balancer folks, the firewall folks, and the WAF folks all had to update their stuff, and that takes time.</p>

<p>The headline wins:</p>

<ul>
<li><strong>One handshake instead of two.</strong> A normal HTTPS connection over TCP costs you one round-trip to set up the TCP socket, then another for TLS. QUIC folds TLS 1.3 into the transport itself. First connection: 1-RTT. Resumed connection: 0-RTT (you can send the GET in the very first packet). Half the time-to-first-byte on a cold connection, basically for free.</li>
<li><strong>Head-of-line blocking is gone.</strong> HTTP/2 multiplexed streams over a single TCP connection, which sounded great until one packet got dropped and <em>every</em> stream stalled waiting for the retransmit. QUIC moves stream multiplexing below the loss-recovery layer, so a lost packet only stalls the stream that packet belonged to. The other streams keep flowing. This is the single biggest deal for anyone serving lots of small assets.</li>
<li><strong>Connection migration.</strong> A QUIC connection is identified by a connection ID, not by the four-tuple of IPs and ports. Your phone leaves wifi, switches to 5G, gets a new IP: the connection keeps going. No reconnect. No re-handshake. Streaming video doesn&#8217;t even pause.</li>
<li><strong>Encryption is mandatory and it&#8217;s all of it.</strong> QUIC encrypts the transport headers, not just the payload. Middleboxes can&#8217;t peek at sequence numbers, can&#8217;t rewrite flags, can&#8217;t fingerprint the way they used to. This is also why some &#8220;DPI-aware&#8221; firewalls hate it.</li>
</ul>

<p>The trade-off: it&#8217;s UDP. Some networks throttle UDP, some firewall it entirely, and CPU usage per byte is higher than TCP because everything happens in userspace. (There&#8217;s kernel offload work happening, GSO, GRO, the io_uring stuff, but we&#8217;ll get to that.)</p>

<h2 style="color:#f59e0b">HTTP/3 support: mainline NGINX vs Angie vs our packages</h2>

<p>This is where the documentation gets confusing because three timelines are running in parallel.</p>

<p><strong>Mainline NGINX</strong> shipped experimental QUIC and HTTP/3 in 1.25.0 (May 2023), and marked it production-ready in 1.25.3 (October 2023). As of 2026, every NGINX 1.27.x and 1.29.x mainline release has it built in. You don&#8217;t need a separate <code>nginx-quic</code> branch any more, that fork was merged into mainline and archived. <strong>Stable</strong> branch (1.26.x, 1.28.x) also has it; F5 backported once they decided the dust had settled.</p>

<p>What you do still need: an OpenSSL with QUIC API support. Mainline NGINX uses OpenSSL&#8217;s compat shim, which works but doesn&#8217;t expose every TLS feature. For 0-RTT and full ALPN control you want one of <code>quictls</code>, <code>BoringSSL</code>, <code>AWS-LC</code>, or OpenSSL 3.5+ (which finally landed proper QUIC server-side API). The <code>./configure</code> dance has been written about a thousand times; the short version is <code>--with-http_v3_module --with-cc-opt=-I/path/to/quictls/include --with-ld-opt=-L/path/to/quictls/lib</code>.</p>

<p><strong>Angie</strong> (the fork from former NGINX-core devs after the F5 acquisition) has had HTTP/3 since the very first release and treats it as a first-class feature. The directives are mostly compatible with mainline NGINX&#8217;s, plus a couple of Angie-specific ones (<code>quic_active_connection_id_limit</code>, finer-grained <code>quic_log</code>). Angie also has built-in support for live monitoring of QUIC connections via its <code>/status</code> endpoint, which is genuinely nice when you&#8217;re debugging.</p>

<p>Our <a href="/nginx-modules/">deb.myguard.nl NGINX packages</a> ship with HTTP/3 enabled by default, linked against quictls, and bundled with the full <a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/">compression stack</a> and the rest of our <a href="/nginx-dockerized/">dynamic module set</a>. Same goes for our <a href="/angie-modules-optimized-extended/">Angie packages</a>. You don&#8217;t need to compile anything; <code>apt install nginx</code> from our repo and the binary already speaks h3. The packages also bundle the BoringSSL link variant if you&#8217;d rather have that (it tends to be slightly faster on the QUIC hot path, at the cost of dropping a few legacy TLS features almost nobody uses).</p>

<p>A practical heuristic: if you&#8217;re already running NGINX 1.25.3+ from any reputable source, you have HTTP/3. The question isn&#8217;t &#8220;do I have it&#8221; but &#8220;is the config right and is the network plumbing in shape&#8221;.</p>

<h2 style="color:#f59e0b">The minimum viable HTTP/3 listen block</h2>

<p>Here&#8217;s a config that actually works. Drop it in your server block alongside your existing HTTPS listen:</p>

<pre><code>server {
    # Existing HTTP/1.1 + HTTP/2 over TCP
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    # HTTP/3 over QUIC (UDP)
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;

    # Same TLS config as your existing HTTPS
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.3;
    ssl_early_data      on;            # enables 0-RTT for resumed sessions

    # Tell browsers HTTP/3 is available on this host
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # Optional: hint that early data is being served
    add_header X-Early-Data $ssl_early_data always;

    server_name example.com;
    root /var/www/example.com;
}</code></pre>

<p>A few things worth pointing out, because they trip people up every time:</p>

<p><code>reuseport</code> is not optional, it&#8217;s the whole point. Without it, every QUIC packet gets dispatched through a single listening socket and then handed to a worker, which murders multi-core throughput because that one socket becomes a contention hot spot. With <code>reuseport</code>, each worker gets its own UDP socket bound to the same port via <code>SO_REUSEPORT</code>, and the kernel hashes incoming packets across them. On a 16-core box, this is the difference between 4 Gbps and 40 Gbps.</p>

<p><code>ssl_early_data on</code> enables 0-RTT. Resumed connections can send the HTTP request in the very first QUIC packet. This is brilliant for repeat visitors but it has a footgun: 0-RTT data is replayable, which means it must be safe to replay. GET requests to idempotent endpoints, fine. POST to <code>/transfer-money</code>, not fine. Most apps don&#8217;t care because browsers only send GETs in 0-RTT, but if you have weird shaped traffic, check.</p>

<p>The <code>Alt-Svc</code> header is how a browser learns your server speaks HTTP/3. The browser hits you over TCP+HTTP/2 first, sees the header, remembers it for <code>ma</code> seconds (max-age, 86400 is one day), and on the next request it tries QUIC first. <strong>If your alt-svc header doesn&#8217;t reach the browser, no client will ever speak HTTP/3 to you</strong>, no matter how perfectly your QUIC config works. This is the most common &#8220;why isn&#8217;t this working&#8221; cause, and we&#8217;ll come back to it.</p>

<h2 style="color:#f59e0b">Tuning that actually matters</h2>

<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/http3-quic-nginx-tuning-inline.webp" alt="HTTP/3 QUIC NGINX tuning sysctl UDP buffers and reuseport" loading="lazy" /><figcaption>The sysctl knobs and listen-block flags that turn HTTP/3 from &#8220;kinda works&#8221; into &#8220;fast&#8221;.</figcaption></figure>

<p>The default UDP receive buffer on a Linux box is somewhere between 200 KB and 4 MB depending on distribution. For an HTTP/3 server doing real traffic, that&#8217;s tiny. Bursts of incoming packets overflow the socket buffer, the kernel drops them, QUIC sees loss, slams the congestion window shut, and your throughput collapses to gigabit-modem speeds. Fix it:</p>

<pre><code># /etc/sysctl.d/99-quic.conf
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.core.rmem_default = 2097152
net.core.wmem_default = 2097152
net.core.netdev_max_backlog = 5000
# Then: sysctl --system</code></pre>

<p>You also need NGINX to actually <em>ask</em> the kernel for those bigger buffers. The <code>listen</code> directive has a <code>rcvbuf</code> / <code>sndbuf</code> param:</p>

<pre><code>listen 443 quic reuseport rcvbuf=2m sndbuf=2m;</code></pre>

<p>Without those, NGINX uses the default and you&#8217;ve sysctl&#8217;d for nothing.</p>

<p>Other QUIC-specific NGINX directives worth knowing about:</p>

<ul>
<li><code>quic_retry on;</code>: turns on the QUIC retry mechanism, which makes the client prove its source address before you allocate state. This is your DDoS defence; without it, a single-packet UDP source-spoofing attack can make your server allocate state for millions of fake &#8220;connections&#8221;. Cost: one extra round-trip on the first connection. Worth it.</li>
<li><code>quic_gso on;</code>: Generic Segmentation Offload. The kernel sends multiple UDP segments as a single <code>sendmsg()</code> call and lets the NIC slice them up. On any reasonable NIC from the last decade this is a 2-3x CPU reduction on the send path. Default is off in most builds because old NICs don&#8217;t handle it, but if your hardware is from 2015 or later, turn it on.</li>
<li><code>quic_active_connection_id_limit 4;</code>: how many connection IDs the client can have active at once. Higher numbers help with connection migration and NAT rebinding. Default is 2; bumping to 4 or 8 is common.</li>
<li><code>http3_stream_buffer_size 64k;</code>: per-stream buffer. Default is small. Bigger means more memory per connection but better throughput on high-bandwidth streams.</li>
</ul>

<p>If you want to see what&#8217;s actually happening, <code>quic_log</code> writes per-packet info to a file. It&#8217;s verbose enough that you only want it on for debugging, but for &#8220;why is this connection slow&#8221; it&#8217;s the only way to get ground truth without strace-ing the worker.</p>

<h2 style="color:#f59e0b">The gotchas that will bite you</h2>

<p>This is the section I wish I&#8217;d had when I started. None of these are bugs in NGINX; they&#8217;re all environment issues that NGINX has no way to warn you about.</p>

<h3>1. Your firewall is dropping 443/udp</h3>

<p>Run <code>ufw status</code> or <code>iptables -L INPUT -n</code>. If you see <code>443/tcp</code> allowed and nothing about UDP, the kernel is silently dropping every incoming QUIC packet. <code>ufw allow 443/udp</code>, or the iptables equivalent, and re-test. On AWS / GCP / Azure, the security-group rule also defaults to TCP-only, UDP needs an explicit rule. If you have a Cloudflare or Fastly proxy in front, they handle this for you; for everything else, check.</p>

<h3>2. The alt-svc header isn&#8217;t reaching the client</h3>

<p>Every reverse proxy, every CDN, every WAF can strip headers it doesn&#8217;t recognise. If you&#8217;re behind something old, run <code>curl -sI https://example.com/ | grep -i alt-svc</code> from outside and see what comes back. No header, no HTTP/3. The fix is usually a single line in the proxy config to pass it through, but you have to know to look.</p>

<p>Related: if you set the header without <code>always</code>, NGINX won&#8217;t add it to 4xx/5xx responses, and some health-check tools only see 200s from cached error pages and conclude HTTP/3 isn&#8217;t enabled.</p>

<h3>3. MTU and PMTU discovery</h3>

<p>QUIC packets are bigger than the legacy 1280-byte IPv6 minimum because the protocol was designed for modern networks. When a packet exceeds the path MTU, it gets fragmented, and UDP fragments are often dropped by firewalls (because attackers love fragmented UDP). Symptoms: handshake works, then large responses hang or stall. NGINX handles this with QUIC&#8217;s built-in PMTU discovery, but if you&#8217;ve set <code>net.ipv4.ip_no_pmtu_disc=1</code> somewhere for &#8220;TCP reasons&#8221;, that breaks QUIC&#8217;s discovery too. Leave PMTU discovery on.</p>

<h3>4. Your load balancer is TCP-only</h3>

<p>This one&#8217;s the killer. If you have an HAProxy, AWS ALB, GCP HTTPS LB, or Nginx Plus in front of your NGINX, check whether it does UDP at all. The classic ALB doesn&#8217;t. The new Network Load Balancer does. HAProxy 2.6+ has experimental QUIC support but won&#8217;t proxy QUIC to a backend, it terminates it. If your LB doesn&#8217;t speak QUIC, you have two choices: terminate QUIC at the LB and proxy plain HTTP/1.1 or H2 to the backend, or bypass the LB entirely for UDP traffic (some people run a separate hostname for QUIC, which is ugly but works).</p>

<h3>5. ModSecurity and HTTP/3</h3>

<p>If you&#8217;ve followed our guide on <a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">installing ModSecurity and the OWASP CRS</a>, almost everything works the same over QUIC because ModSecurity sees the request after NGINX has parsed it, the transport is invisible. Two caveats: (a) some older CRS rules look at <code>$server_protocol</code> and assume <code>HTTP/1.1</code> or <code>HTTP/2</code> string-matches; HTTP/3 connections show <code>HTTP/3.0</code>, and those rules silently miss-fire. Update to CRS 4.x if you&#8217;re still on 3.x. (b) Rate-limiting by source IP gets harder when the client migrates connections across IPs (see &#8220;connection migration&#8221; up top), the QUIC connection ID is the stable identifier, not the IP, and most WAF rate limiters don&#8217;t know about it yet.</p>

<p>While we&#8217;re on security: HTTP/3 doesn&#8217;t make you immune to <a href="/2026/05/breach-attack-explained-prevention/">BREACH-style compression side-channel attacks</a>. Same compression, same secrets, same problem. Disable HTTP-level compression on responses that mix secrets with attacker-influenced input, the same way you would on HTTP/2.</p>

<h3>6. Older Chromium versions and the alt-svc cache</h3>

<p>Chrome caches alt-svc records aggressively. If you initially served a broken alt-svc header (wrong port, typo in <code>h3=</code>), Chrome remembers it and keeps trying for the <code>ma</code> duration. You change the server, run curl, see the new header, but your test browser still uses the cached broken one. Visit <code>chrome://net-internals/#alt-svc</code> and clear, or pick a different browser to test fresh. Spent two hours on this once. Never again.</p>

<h2 style="color:#f59e0b">Verifying it actually works</h2>

<p>Four tools, in increasing order of paranoia:</p>

<p><strong>curl &#8211;http3</strong> (requires a curl built against a QUIC-capable TLS library, Ubuntu 24.04&#8217;s curl can do it if you install <code>libcurl4-openssl-dev</code> from a recent source, or just use the curl in our packages):</p>

<pre><code>curl --http3 -sI https://example.com/ | head -1
# Expected: HTTP/3 200</code></pre>

<p>If curl falls back to HTTP/2, it&#8217;ll show <code>HTTP/2 200</code> instead. Check stderr with <code>-v</code> to see why.</p>

<p><strong>Chrome DevTools</strong>: open the Network tab, right-click any column header, enable the &#8220;Protocol&#8221; column. Reload the page. You should see <code>h3</code> next to your requests. First page-load after a clean cache will be <code>h2</code> (browser learns alt-svc), and subsequent loads switch to <code>h3</code>. If you never see <code>h3</code>, the alt-svc header isn&#8217;t reaching the browser, back to gotcha #2.</p>

<p><strong>h3check.net</strong> and <strong>http3check.net</strong>: third-party probes that hit your domain and tell you what they found. Useful sanity check from outside your own network.</p>

<p><strong>Wireshark / tcpdump</strong> on the server: <code>tcpdump -i any -nn 'udp port 443'</code>. If you see no UDP traffic at all when a real client visits, the firewall or NAT is eating your packets before they hit NGINX. If you see UDP packets but NGINX logs no QUIC connections, the listen block isn&#8217;t binding the UDP socket, check <code>ss -ulpn | grep :443</code>, the QUIC listener should appear with the NGINX worker PIDs.</p>

<h2 style="color:#f59e0b">When HTTP/3 isn&#8217;t worth it</h2>

<p>HTTP/3 is wonderful for the open web, where users are on lossy mobile networks loading lots of small assets from a long way away. It is less wonderful for:</p>

<ul>
<li><strong>Backend-to-backend traffic on a fast LAN.</strong> No packet loss, no head-of-line blocking to fix. The userspace QUIC stack just adds CPU overhead versus plain old TCP+H2.</li>
<li><strong>Long-lived single-stream connections.</strong> Server-sent events, websockets-equivalent persistent streams: QUIC gives you very little here because there&#8217;s no multiplexing to benefit from.</li>
<li><strong>Networks where UDP gets shaped or blocked.</strong> Some corporate networks, some hotels, some airline wifi. The browser will silently fall back to TCP, so you&#8217;re not <em>broken</em>, but you&#8217;ve paid the implementation cost for users who can&#8217;t use it.</li>
</ul>

<p>The good news is that turning it on is additive. Your TCP+H2 listener keeps working for clients that can&#8217;t or won&#8217;t speak QUIC, and the browsers that can speak it get the benefits transparently. There&#8217;s almost no downside to enabling it correctly, the downside is enabling it <em>incorrectly</em> and shipping a half-broken alt-svc header that makes browsers waste time trying QUIC against a dead UDP socket.</p>

<h2 style="color:#f59e0b">FAQ</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I need a special build of NGINX for HTTP/3?</h3>
<div class="rank-math-answer ">

<p>Not since NGINX 1.25.3 (October 2023). Mainline and stable both have HTTP/3 built in. You need an OpenSSL/quictls/BoringSSL build with QUIC API support, but that&#8217;s a build-time concern, not a runtime one. Our deb.myguard.nl packages already include this.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Does HTTP/3 replace HTTP/2?</h3>
<div class="rank-math-answer ">

<p>No. They coexist. Your server should listen on both. Clients that support HTTP/3 will switch to it after seeing your alt-svc header; clients that don&#8217;t (older browsers, most CLI tools, anything behind a UDP-blocking firewall) will keep using HTTP/2 over TCP.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Is HTTP/3 faster than HTTP/2 for everyone?</h3>
<div class="rank-math-answer ">

<p>On lossy networks (mobile, distant CDN edge, congested wifi), measurably yes, sometimes 20-30% faster page-load. On a local LAN with no packet loss, the difference is negligible or even slightly worse because of the userspace QUIC processing overhead. The win comes from solving real-world network problems, not from being faster in lab conditions.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Why is my browser not using HTTP/3 even though I configured it?</h3>
<div class="rank-math-answer ">

<p>In ~90% of cases it&#8217;s the alt-svc header not reaching the browser. Run curl -sI from outside and check. The other 10% is a UDP firewall block, a CDN/proxy in front that doesn&#8217;t pass UDP, or Chrome caching a stale alt-svc record (clear via chrome://net-internals/#alt-svc).</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Does HTTP/3 work with Let&#8217;s Encrypt and standard TLS certificates?</h3>
<div class="rank-math-answer ">

<p>Yes, it uses the same X.509 certificates as HTTPS. QUIC mandates TLS 1.3, so make sure your cert is signed with a TLS-1.3-compatible chain (which Let&#8217;s Encrypt has been doing for years). No special cert needed.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">What&#8217;s the difference between HTTP/3 in NGINX and in Angie?</h3>
<div class="rank-math-answer ">

<p>Mainline NGINX added HTTP/3 in 1.25.x and treats it as a normal feature. Angie (the post-F5 fork) had HTTP/3 from day one and exposes more fine-grained directives (e.g. quic_log, additional connection-ID controls) plus built-in live monitoring of QUIC connections via its status endpoint. Functionally equivalent for most workloads.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">Will HTTP/3 break my reverse proxy setup?</h3>
<div class="rank-math-answer ">

<p>It can. Many load balancers and reverse proxies only speak TCP and silently drop UDP traffic. Check whether your LB does UDP (AWS NLB yes, classic ALB no; HAProxy 2.6+ yes but terminates QUIC rather than proxying it). If it doesn&#8217;t, terminate QUIC at the edge and proxy plain HTTP/2 to the backend.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related reading</h2>

<ul>
<li><a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/">Zstd vs Brotli vs zlib-ng: The NGINX Compression Deep Dive</a>: compression matters more than transport for total page weight; pair HTTP/3 with proper static precompression.</li>
<li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: your WAF still works over QUIC, but check the rule notes above.</li>
<li><a href="/2026/05/breach-attack-explained-prevention/">What Is the BREACH Attack? How It Works and How to Stop It</a>: HTTP/3 doesn&#8217;t fix compression side-channels.</li>
<li><a href="/nginx-modules/">NGINX Modules optimized &#038; extended</a>: the full set of dynamic modules in our HTTP/3-capable nginx packages.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Hardened OpenSSH 10.3 for Debian and Ubuntu: PQ Crypto, AppArmor, 3 sshd Flavours</title>
		<link>https://deb.myguard.nl/2026/05/hardened-openssh-for-debian-and-ubuntu-pq-crypto-apparmor-three-sshd-flavours/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Mon, 25 May 2026 00:41:35 +0000</pubDate>
				<category><![CDATA[381]]></category>
		<category><![CDATA[5234]]></category>
		<category><![CDATA[apparmor]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[ed25519]]></category>
		<category><![CDATA[fail2ban]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[kerberos]]></category>
		<category><![CDATA[openssh]]></category>
		<category><![CDATA[post-quantum]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[ssh]]></category>
		<category><![CDATA[sshd]]></category>
		<category><![CDATA[tls]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5883</guid>

					<description><![CDATA[The myguard OpenSSH 10.3 package rebuilds sshd for production servers: post-quantum key exchange, AEAD-only ciphers, an AppArmor profile, a fail2ban jail, monthly moduli regeneration, three switchable sshd flavours (default / gssapi / minimal), and compiler hardening beyond Debian's default. Includes a 2026 SSH key-generation walkthrough and a stack of server-hardening tips.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">If you want a hardened OpenSSH on Debian or Ubuntu, the stock package isn&#8217;t it. Debian ships an OpenSSH that&#8217;s been packaged for everyone, which is to say: not really for you. The shipped <code>sshd_config</code> is a museum of compromises, X11 forwarding on by default, an algorithm list that still flatters clients from 2014, a single static <code>moduli</code> file that&#8217;s been the same on every Debian box on the planet for years, an AppArmor profile that exists in <code>apparmor-profiles</code> and is never loaded, a systemd unit with no sandboxing whatsoever, and Kerberos compiled in whether you use it or not because someone, somewhere, might be running Active Directory. That&#8217;s a fine posture for a Linux laptop in 2008. It&#8217;s a worse one for a server in 2026.</p>



<p class="wp-block-paragraph">So we rebuilt it. The myguard hardened OpenSSH 10.3 packages for Debian and Ubuntu give you post-quantum hybrid key exchange by default, AEAD-only ciphers, a loaded AppArmor profile, a fail2ban jail that just works, a systemd unit that actually drops capabilities, a monthly moduli refresh timer, compiler flags well past Debian&#8217;s own <code>hardening=+all</code> (a discipline we apply elsewhere too, see our <a href="/2026/05/docker-hardening-rootless-readonly-distroless/">Docker hardening guide</a> and the <a href="/2026/05/http3-quic-nginx-setup-tuning-gotchas-2026/">HTTP/3 and QUIC on NGINX setup</a>), and, the bit we&#8217;re most pleased with, three real, apt-installable server packages that let you pick the attack surface you actually need without recompiling anything.</p>




<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="630" src="https://deb.myguard.nl/wp-content/uploads/2026/05/hardened-openssh-10-debian-ubuntu-pq-apparmor.webp" alt="Hardened OpenSSH 10.3 for Debian and Ubuntu: post-quantum KEX, AEAD ciphers, AppArmor profile, three apt-installable sshd flavours" class="wp-image-5919" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/hardened-openssh-10-debian-ubuntu-pq-apparmor.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/hardened-openssh-10-debian-ubuntu-pq-apparmor-300x158.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/hardened-openssh-10-debian-ubuntu-pq-apparmor-1024x538.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/hardened-openssh-10-debian-ubuntu-pq-apparmor-768x403.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /><figcaption class="wp-element-caption">Hardened OpenSSH 10.3, PQ key exchange, AEAD ciphers, AppArmor, fail2ban, three sshd flavours.</figcaption></figure>



<p class="wp-block-paragraph">What follows is the full tour: what we ship, why every choice was made, the apt commands, a 2026 SSH key recipe (Ed25519, security keys, certificates), and a long tail of operational tips that the Debian default should have absorbed a decade ago.</p>



<h2 class="wp-block-heading" id="why-rebuild">Why a hardened OpenSSH? Why rebuild it at all?</h2>



<p class="wp-block-paragraph">Let&#8217;s be fair to the Debian maintainers for a sentence: OpenSSH itself, the thing the OpenBSD team writes, is one of the best pieces of security software ever shipped. The problem isn&#8217;t OpenSSH. The problem is what happens to it on the way to your apt mirror.</p>



<p class="wp-block-paragraph">The shipped configuration is built for an imaginary lowest-common-denominator user, a workstation that runs X11, a laptop that might suddenly need to talk to a Solaris box from 2003, an AD-joined desktop that wants Kerberos session tickets. Every default reflects that user. None of them is you.</p>



<p class="wp-block-paragraph">The roll call from a stock <code>apt install openssh-server</code>:</p>



<ul class="wp-block-list">
<li><code>X11Forwarding yes</code>: handy on a desktop, free tunneling for an attacker on a server.</li>
<li><code>KbdInteractiveAuthentication</code> defaults that interact badly with PAM and become a brute-force vector the moment someone re-enables password auth &#8220;just for a minute&#8221;.</li>
<li>Algorithm lists that include <code>ssh-rsa</code>, NIST-P ECDH, <code>diffie-hellman-group14-sha1</code>, and the whole CBC family, because the maintainer can&#8217;t be sure you don&#8217;t have a 2010 router on your network.</li>
<li>One <code>/etc/ssh/moduli</code> file, byte-identical on every install on Earth, never regenerated.</li>
<li>An AppArmor profile sitting unused in another package.</li>
<li>A <code>ssh.service</code> with effectively no <code>ProtectSystem</code>, no <code>RestrictAddressFamilies</code>, no <code>SystemCallFilter</code>, no <code>CapabilityBoundingSet</code>: because someone in #debian-mentors thinks sandboxing is too opinionated.</li>
<li>Kerberos linked into the one <code>sshd</code> binary you get, even though three-quarters of Linux servers will never see a KDC.</li>
</ul>



<p class="wp-block-paragraph">None of this is a bug. It&#8217;s the price of being a general-purpose distribution. The cost is that every serious shop ends up writing the same Ansible role to undo it, every audit firm bills the same hours to re-discover it, and the actual exposed surface of SSH on the open internet stays exactly as wide as it was in 2014. We figured that was enough.</p>



<h2 class="wp-block-heading" id="package-layout">Pick the attack surface you actually need</h2>



<p class="wp-block-paragraph">Debian gives you one <code>openssh-server</code> binary, built with everything: PAM, SELinux, audit, libedit, security keys, wtmpdb, Kerberos. You get all of it whether you wanted it or not, you get <code>libwrap</code> for free even though TCP wrappers haven&#8217;t been a good idea since the Bush administration, and the binary on a hardened bastion in a co-lo is bit-for-bit identical to the one on a developer&#8217;s laptop. We split it into three.</p>



<figure class="wp-block-table"><table><thead><tr><th>Package</th><th>Built with</th><th>Use it when</th></tr></thead><tbody>
<tr><td><strong>openssh-server</strong></td><td>PAM, SELinux, audit, libedit, security keys, wtmpdb. <strong>No Kerberos.</strong></td><td>Every server you have. This is the right default.</td></tr>
<tr><td><strong>openssh-server-gssapi</strong></td><td>Everything <code>openssh-server</code> has, plus Kerberos / GSS-API.</td><td>You&#8217;re actually running Active Directory or an MIT KDC. If you can&#8217;t name your realm without looking it up: not this one.</td></tr>
<tr><td><strong>openssh-server-minimal</strong></td><td>No PAM, no Kerberos, no SELinux, no audit, no libedit, no security keys, no wtmpdb, no xauth.</td><td>Containers, bastion jumphosts, recovery images. <code>authorized_keys</code> is the only auth path. There is no PAM stack to break and no libwrap to deprecate.</td></tr>
</tbody></table></figure>



<p class="wp-block-paragraph">All three install <code>/usr/sbin/sshd</code> and <code>Conflicts:</code> each other, so apt swaps them transactionally. A host that starts as <code>openssh-server</code> and a year later gets folded into AD becomes:</p>



<pre class="wp-block-preformatted"><code>sudo apt install openssh-server-gssapi
</code></pre>



<p class="wp-block-paragraph">and apt removes the old binary, installs the new one, and the unit restarts. No Ansible task, no role variable, no rebuild.</p>



<p class="wp-block-paragraph">The hardening drop-in, the systemd lock-down, the AppArmor profile, the fail2ban jail and the moduli refresh timer all live in a fourth package, <strong>openssh-server-common</strong>, that every flavour <code>Depends:</code> on. Whichever <code>sshd</code> you install, the same hardened defaults apply. You can&#8217;t accidentally have the gssapi flavour without the systemd sandbox; the dependency graph won&#8217;t let you.</p>



<h2 class="wp-block-heading" id="whats-inside">What openssh-server-common ships</h2>



<h3 class="wp-block-heading">1. A hardened <code>sshd_config</code> drop-in that&#8217;s actually removable</h3>



<p class="wp-block-paragraph">We install <code>/etc/ssh/sshd_config.d/00-myguard-hardening.conf</code>. Because OpenSSH applies the <em>first</em> occurrence of any keyword and the shipped <code>sshd_config</code> pulls in <code>sshd_config.d/*.conf</code> before its own body takes effect, this drop-in wins on every conflict. The point isn&#8217;t just to be authoritative, it&#8217;s that any admin who needs to relax one knob writes a <code>99-something.conf</code> next to it. No editing the main file. No conffile-rejected-on-upgrade dialog at four in the morning. The hardening is a file, not a patch.</p>



<p class="wp-block-paragraph">What&#8217;s in it:</p>



<ul class="wp-block-list">
<li><strong>Post-quantum hybrid KEX first:</strong> <code>mlkem768x25519-sha256</code> then <code>sntrup761x25519-sha512</code>, then <code>curve25519-sha256</code>. NIST-P ECDH and SHA-1 DH groups are gone. If &#8220;harvest now, decrypt later&#8221; is a thing, this is the one config line that matters most.</li>
<li><strong>AEAD ciphers only:</strong> <code>chacha20-poly1305</code>, <code>aes256-gcm</code>, <code>aes128-gcm</code>. CBC, 3DES, arcfour and plain CTR are out: they&#8217;ve been bad ideas for various lengths of time between five and twenty years.</li>
<li><strong>ETM MACs only:</strong> <code>hmac-sha2-512-etm</code>, <code>hmac-sha2-256-etm</code>, <code>umac-128-etm</code>. The non-ETM variants leak a length oracle; we don&#8217;t care if your 2013 client doesn&#8217;t like that.</li>
<li><strong>Signatures:</strong> Ed25519, ECDSA P-256/384/521, RSA-SHA2. <code>ssh-rsa</code> (which is RSA with SHA-1) is removed from <code>HostKeyAlgorithms</code>, <code>PubkeyAcceptedAlgorithms</code> <em>and</em> <code>CASignatureAlgorithms</code>. It&#8217;s 2026.</li>
<li><strong>Auth:</strong> password off, root prohibit-password, empty passwords off, keyboard-interactive off, host-based off, rhosts ignored, known-hosts ignored.</li>
<li><strong>Forwarding:</strong> X11 off, TCP off, agent off, tunnel off, user-environment off. If you need any of these on, you&#8217;ll know what you&#8217;re doing and which override to write.</li>
<li><strong>Brute-force economics:</strong> <code>LoginGraceTime 30s</code>, <code>MaxAuthTries 3</code>, <code>MaxStartups 10:30:60</code>, <code>ClientAliveInterval 300</code>, <code>ClientAliveCountMax 2</code>.</li>
<li><strong>PerSourcePenalties</strong> (OpenSSH ≥ 9.8): the killer feature nobody talks about. sshd tarpits abusive sources by /24 (IPv4) or /48 (IPv6) source block, ramping from 5 seconds on an auth failure to a 1-hour cap. fail2ban still helps if you want fancy correlation, but for the simple &#8220;China is scanning port 22 again&#8221; case, you don&#8217;t even need it.</li>
<li><strong>LogLevel VERBOSE</strong>: every successful login emits the key fingerprint that authenticated it. Your SIEM will thank you the first time you have to investigate a key compromise.</li>
</ul>



<h3 class="wp-block-heading">2. A systemd unit that actually sandboxes</h3>



<p class="wp-block-paragraph">The stock <code>ssh.service</code> on Debian is, charitably, conservative. It runs sshd. It restarts on failure. That&#8217;s the security boundary. We drop <code>/usr/lib/systemd/system/ssh.service.d/hardening.conf</code> on top of it, and the systemd merge gives you:</p>



<ul class="wp-block-list">
<li><code>ProtectSystem=strict</code> with <code>ProtectHome=read-only</code> and an explicit, minimal <code>ReadWritePaths=</code>. sshd can write to <code>/var/log</code>, <code>/run/sshd</code>, <code>/var/lib/sss</code> and a few other places that matter. It cannot write anywhere else. If your sshd ever decides to drop a binary in <code>/tmp</code> and run it, that&#8217;s a kernel-enforced &#8220;no&#8221; rather than a hope.</li>
<li><code>CapabilityBoundingSet=</code> whitelisted. sshd ships with a long list of effective capabilities it doesn&#8217;t need; we drop the boundary to the dozen that PAM and the privsep child actually use.</li>
<li><code>MemoryDenyWriteExecute=yes</code>, <code>LockPersonality=yes</code>, <code>RestrictRealtime=yes</code>. The first one is the load-bearing knob in 2026: an exploit that smuggles in shellcode now has to defeat W^X in the address space, not just bypass NX.</li>
<li><code>RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK</code>. No AF_PACKET, no AF_BLUETOOTH, no AF_XDP. sshd is a TCP daemon: it doesn&#8217;t need to learn new tricks.</li>
<li><code>SystemCallFilter=@system-service @resources</code> minus <code>@privileged @debug @mount @reboot @swap @cpu-emulation @obsolete</code>. The kernel rejects most of the syscalls a post-exploitation toolkit reaches for, before sshd&#8217;s process ever sees them.</li>
<li>Plus the alphabet soup: <code>RestrictNamespaces</code>, <code>ProtectKernelTunables</code>, <code>ProtectKernelModules</code>, <code>ProtectKernelLogs</code>, <code>ProtectClock</code>, <code>ProtectHostname</code>, <code>ProtectProc=invisible</code>, <code>ProcSubset=pid</code>.</li>
</ul>



<p class="wp-block-paragraph">One knob we don&#8217;t set: <code>NoNewPrivileges=yes</code>. <code>pam_unix</code> and <code>pam_systemd</code> rely on suid helpers, and turning NNP on at the unit level breaks login on a stock distribution. If you&#8217;ve gone <code>openssh-server-minimal</code> and have no PAM stack to worry about, you can add it back in a 99-*.conf override, that&#8217;s exactly the situation it was designed for.</p>



<h3 class="wp-block-heading">3. An AppArmor profile that&#8217;s actually installed</h3>



<p class="wp-block-paragraph">Debian&#8217;s <code>apparmor-profiles</code> package ships an sshd profile that doesn&#8217;t get loaded. It sits there. It has sat there for years. Our package drops one in <code>/etc/apparmor.d/usr.sbin.sshd</code>, and the postinst calls <code>apparmor_parser -r</code> automatically if AppArmor is active on the host. You install the package, the profile is live. Imagine that.</p>



<p class="wp-block-paragraph">It ships in <strong>complain</strong> mode, deliberately. Anyone who&#8217;s done this before will tell you: AppArmor plus sshd is a tar pit. Every PAM module, every NSS backend (sssd, ldap, winbind), every login shell that might exist on the system needs an explicit allow rule. A profile that&#8217;s even slightly too tight will silently lock you out of your own machine at the worst possible moment, which on a server reachable only by SSH is also the only possible moment.</p>



<p class="wp-block-paragraph">Complain mode logs would-be denials to <code>dmesg</code> and <code>/var/log/audit/audit.log</code> without blocking. Tail them for a few weeks, fix the genuine misses, then promote:</p>



<pre class="wp-block-preformatted"><code>sudo aa-enforce /usr/sbin/sshd
</code></pre>



<h3 class="wp-block-heading">4. A fail2ban jail snippet</h3>



<p class="wp-block-paragraph">We drop <code>/etc/fail2ban/jail.d/myguard-sshd.conf</code>. Backend is systemd-journal, no scraping <code>/var/log/auth.log</code>, no log-rotation games. <code>maxretry=3</code>, <code>findtime=10m</code>, <code>bantime=1h</code>, and bantime-incrementing on repeat offenders up to 30 days. If fail2ban isn&#8217;t installed, the file is inert. <code>openssh-server</code> <code>Recommends:</code> fail2ban, so a default apt install pulls it in. If you&#8217;ve globally disabled Recommends in your apt config, that&#8217;s on you.</p>



<h3 class="wp-block-heading">5. Monthly moduli regeneration</h3>



<p class="wp-block-paragraph">The <code>/etc/ssh/moduli</code> file Debian ships is byte-identical on every install. It was generated once, a long time ago, by someone whose name you can find in <code>git log</code>. We install a <code>ssh-moduli-refresh.timer</code> that runs monthly with a 24-hour randomised delay, regenerates fresh DH moduli at 2048/3072/4096/6144/7680/8192 bits, and atomically swaps them in. The service is heavily sandboxed (idle CPU class, <code>NoNewPrivileges</code>, blank <code>RestrictAddressFamilies=</code>) because <code>ssh-keygen -M generate</code> is CPU-bound for tens of minutes on a small VPS and we&#8217;d rather it didn&#8217;t have the run of the place while it grinds.</p>



<p class="wp-block-paragraph">Yes, you could schedule this yourself with a cron job. You haven&#8217;t, though.</p>



<h3 class="wp-block-heading">6. Compiler flags past <code>hardening=+all</code></h3>



<p class="wp-block-paragraph">Debian&#8217;s <code>hardening=+all</code> gets you PIE, RELRO, stack-protector-strong, FORTIFY=2. That&#8217;s a baseline. Our build appends:</p>



<ul class="wp-block-list">
<li><code>-D_FORTIFY_SOURCE=3</code>: Debian&#8217;s default is still 2; FORTIFY=3 catches a strict superset.</li>
<li><code>-fstack-clash-protection</code>: defuses stack-clash exploits at compile time.</li>
<li><code>-fcf-protection=full</code> on amd64: Intel CET / shadow stacks. Free CFI on any post-2020 silicon.</li>
<li><code>-mbranch-protection=standard</code> on arm64: PAC + BTI. Same idea, different ISA.</li>
<li><code>-Wl,-z,now -Wl,-z,relro</code>: full RELRO, no lazy binding. <code>.got.plt</code> isn&#8217;t writable at runtime.</li>
<li><code>-O3 -flto=auto -fno-plt -fno-semantic-interposition -ffunction-sections -fdata-sections -Wl,--gc-sections</code>: speed and dead-code elimination on top of the security. Same binary that protects you better also runs faster.</li>
</ul>



<p class="wp-block-paragraph">None of these flags is exotic. They&#8217;ve all been stable for years. They just haven&#8217;t trickled down into Debian&#8217;s <code>hardening=+all</code> default, which is a process problem rather than a technical one.</p>



<h3 class="wp-block-heading">7. Reproducible builds</h3>



<p class="wp-block-paragraph">Our <code>debian/rules</code> pins <code>SOURCE_DATE_EPOCH</code> from the changelog timestamp when it isn&#8217;t already set. Two builds of the same source produce byte-identical binaries. If a malicious build server ever ships you a tampered <code>sshd</code>, you can prove it.</p>



<h3 class="wp-block-heading">8. Autopkgtests that fail the build</h3>



<p class="wp-block-paragraph">Three tests run on every build, and if any of them fails the package doesn&#8217;t ship:</p>



<ul class="wp-block-list">
<li><strong>sshd-hardened-defaults</strong>: runs <code>sshd -T</code> on the just-built binary, asserts every hardened knob is in effect, fails the build if a deprecated cipher, KEX or MAC ever sneaks back into the active config.</li>
<li><strong>sshd-flavours-exist</strong>: all three flavour packages each ship a working <code>/usr/sbin/sshd</code>.</li>
<li><strong>apparmor-profile-loads</strong>: the profile parses with no warnings and loads cleanly when AppArmor is active.</li>
</ul>



<p class="wp-block-paragraph">Translation: the hardening can&#8217;t quietly regress between releases. The build itself enforces it.</p>



<h2 class="wp-block-heading" id="install">Installing it</h2>



<p class="wp-block-paragraph">If you don&#8217;t have the myguard repo configured yet, follow <a href="/how-to-use/">the quick repo setup</a> first. Then pick your flavour:</p>



<pre class="wp-block-preformatted"><code># The default. Use this everywhere except the two edge cases below.
sudo apt install openssh-server

# Active Directory or MIT Kerberos shop.
sudo apt install openssh-server-gssapi

# Bastion jumphost, container, recovery image.
sudo apt install openssh-server-minimal
</code></pre>



<p class="wp-block-paragraph">Whichever you choose, apt pulls in <code>openssh-server-common</code> and lays down the hardened drop-in, the systemd override, the AppArmor profile, the fail2ban jail and the moduli timer. Restart sshd once:</p>



<pre class="wp-block-preformatted"><code>sudo systemctl daemon-reload
sudo systemctl restart ssh
</code></pre>



<p class="wp-block-paragraph">Confirm the live config is the live config:</p>



<pre class="wp-block-preformatted"><code>sudo sshd -T -C user=root | grep -E '^(kex|cipher|mac|passwordauth|x11|permittun)'
</code></pre>



<p class="wp-block-paragraph">If you want to switch flavours later, say, the host gets folded into AD, it&#8217;s one command:</p>



<pre class="wp-block-preformatted"><code>sudo apt install openssh-server-gssapi
</code></pre>



<p class="wp-block-paragraph">The <code>Conflicts:</code> relationship handles the removal. The systemd unit handles the restart. <code>openssh-server-common</code> doesn&#8217;t move, so none of your hardening churns.</p>



<h2 class="wp-block-heading" id="generate-a-key">The 2026 SSH key recipe</h2>



<p class="wp-block-paragraph">If you do nothing else after reading this: stop generating RSA keys. Ed25519 is smaller, faster, has no parameter-generation footguns, isn&#8217;t subject to the SHA-1 deprecation churn, and has been the right answer since 2014. If your <code>~/.ssh/id_rsa</code> is older than your kids, today is a good day.</p>



<h3 class="wp-block-heading">The one-liner</h3>



<pre class="wp-block-preformatted"><code>ssh-keygen -t ed25519 -a 100 -C "$(whoami)@$(hostname)-$(date +%Y%m%d)"
</code></pre>



<ul class="wp-block-list">
<li><code>-t ed25519</code>: modern Edwards-curve signature. 32-byte public key, 64-byte signature, no nonce-reuse class of bug. Boring, which is what you want.</li>
<li><code>-a 100</code>: 100 rounds of the bcrypt-pbkdf KDF on the encrypted private key. An attacker who walks off with your <code>~/.ssh/id_ed25519</code> still has to crack that passphrase, and 100 rounds slows brute-force by orders of magnitude. You pay it once, when ssh-agent unlocks the key.</li>
<li><code>-C "..."</code>: a comment that says which laptop generated it and when. Future you, finding three half-remembered keys in some old <code>authorized_keys</code> file in 2029, will be grateful.</li>
</ul>



<p class="wp-block-paragraph">Pick a real passphrase. &#8220;I want unattended logins&#8221;, that&#8217;s what <code>ssh-agent</code> is for. Unlock the key once, stay unlocked for the session, no typing.</p>



<h3 class="wp-block-heading">Push it to a server</h3>



<pre class="wp-block-preformatted"><code>ssh-copy-id -i ~/.ssh/id_ed25519.pub <span style="display:inline;" class="">us&#101;r&#64;&#115;erv&#101;&#114;.ex&#97;mp&#108;e&#46;&#99;&#111;m</span>
</code></pre>



<p class="wp-block-paragraph">With our package, password auth is already off, so the first key has to get there some other way: the cloud provider console, your configuration management, an existing key on a peer host. Once it&#8217;s in:</p>



<pre class="wp-block-preformatted"><code>chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
</code></pre>



<p class="wp-block-paragraph">sshd refuses to read group-writable or world-readable key files, by design. That refusal is one of the few things on a hardened sshd that bites people. Now you know.</p>



<h3 class="wp-block-heading">Hardware keys, if you have one</h3>



<p class="wp-block-paragraph">OpenSSH speaks FIDO2 / WebAuthn natively via <code>sk-ssh-ed25519</code>. YubiKey, NitroKey, OnlyKey, anything with the U2F/FIDO2 spec works:</p>



<pre class="wp-block-preformatted"><code>ssh-keygen -t ed25519-sk -O resident -O verify-required \
    -C "yubikey-$(date +%Y%m%d)"
</code></pre>



<ul class="wp-block-list">
<li><code>ed25519-sk</code>: the security-key variant. The private scalar never leaves the token; there is no software-only copy to steal.</li>
<li><code>-O resident</code>: the key handle is stored on the token itself. Pull it back onto a fresh laptop with <code>ssh-keygen -K</code>.</li>
<li><code>-O verify-required</code>: every authentication needs a PIN <em>and</em> a touch. Don&#8217;t skip this one. An attacker with physical access to the token can otherwise log in by tapping it, which is roughly the opposite of what you bought a hardware key for.</li>
</ul>



<p class="wp-block-paragraph"><code>openssh-server</code> and <code>openssh-server-gssapi</code> build with <code>--with-security-key-builtin</code>, so <code>sk-*</code> works out of the box without runtime libfido2. <code>openssh-server-minimal</code> doesn&#8217;t, by design, since minimal is for bastion hosts and containers where the FIDO key lives on the client and the server never needs to know.</p>



<h3 class="wp-block-heading">If you really must use RSA</h3>



<p class="wp-block-paragraph">Sometimes a legacy system insists. Generate at least 4096 bits, and remember that the <em>signature algorithm</em> matters more than the key type, our drop-in already drops <code>ssh-rsa</code> (RSA with SHA-1) and only accepts <code>rsa-sha2-256</code> / <code>rsa-sha2-512</code> from the same RSA key:</p>



<pre class="wp-block-preformatted"><code>ssh-keygen -t rsa -b 4096 -a 100 -C "rsa-$(date +%Y%m%d)"
</code></pre>



<p class="wp-block-paragraph">If a server ever rejects your RSA key with &#8220;no mutual signature algorithm&#8221;, that&#8217;s the diagnosis: your client wants <code>ssh-rsa</code>, the server demands SHA-2. Fix it on the client:</p>



<pre class="wp-block-preformatted"><code># ~/.ssh/config
Host stubborn-old-thing.example.com
    PubkeyAcceptedAlgorithms +rsa-sha2-512,rsa-sha2-256
</code></pre>



<h2 class="wp-block-heading" id="tips-and-tricks">Tips, tricks, and things the Debian default should already do</h2>



<h3 class="wp-block-heading">Use SSH certificates, not <code>authorized_keys</code></h3>



<p class="wp-block-paragraph">Past a handful of users or hosts, copying public keys around becomes an audit nightmare. SSH certificates fix it: a CA signs short-lived user certificates (&#8220;alice can log in as <code>root</code> on hosts matching <code>*.prod.example.com</code> for the next 8 hours&#8221;), and the server only knows the CA&#8217;s public key via <code>TrustedUserCAKeys</code>. Rotation, revocation, audit, all become trivial. Our hardening drop-in already restricts <code>CASignatureAlgorithms</code> to the modern set.</p>



<p class="wp-block-paragraph">Minimal CA bring-up:</p>



<pre class="wp-block-preformatted"><code># on a hardened host (offline-ish, or in a vault)
ssh-keygen -t ed25519 -f ca_user_key -C "user-ca-$(date +%Y%m%d)"

# on every server, in /etc/ssh/sshd_config.d/20-userca.conf
TrustedUserCAKeys /etc/ssh/ca_user_key.pub

# issue an 8-hour certificate for alice
ssh-keygen -s ca_user_key -I alice@laptop -n alice,deploy \
    -V +8h -z 1 ~/.ssh/id_ed25519.pub
</code></pre>



<p class="wp-block-paragraph">The result is a <code>~/.ssh/id_ed25519-cert.pub</code> alongside the user key. ssh presents it automatically. When Bob leaves the team, you don&#8217;t grep two hundred <code>authorized_keys</code> files, you add his cert serial to a <code>RevokedKeys</code> file and push that one file out.</p>



<h3 class="wp-block-heading">Allowlist sources with <code>Match</code></h3>



<p class="wp-block-paragraph">Most servers don&#8217;t need to accept SSH from the entire internet. In <code>/etc/ssh/sshd_config.d/10-source-allowlist.conf</code>:</p>



<pre class="wp-block-preformatted"><code>Match Address !10.0.0.0/8,!192.168.0.0/16,!2001:db8::/32,*
    DenyUsers *
</code></pre>



<p class="wp-block-paragraph">The traditional answer was <code>/etc/hosts.allow</code> with TCP wrappers. We dropped <code>--with-tcp-wrappers</code> on purpose: <code>libwrap</code> has been deprecated for over a decade, upstream OpenSSH removed it in 6.7, and Debian only kept it because backwards compatibility is a religion. <code>Match</code> blocks and nftables do the same job, better.</p>



<h3 class="wp-block-heading">Put SSH into your monitoring</h3>



<p class="wp-block-paragraph">The <code>LogLevel VERBOSE</code> in our drop-in exists for one reason: every successful login records the key fingerprint that authenticated it. That single line is the difference between knowing who got in and guessing. Pipe it to your SIEM:</p>



<pre class="wp-block-preformatted"><code>journalctl -u ssh -f --output=json \
    | jq 'select(.MESSAGE | test("Accepted publickey"))'
</code></pre>



<p class="wp-block-paragraph">Or a quick one-shot review of this week&#8217;s logins:</p>



<pre class="wp-block-preformatted"><code>journalctl -u ssh --since "1 week ago" \
    | grep "Accepted publickey" \
    | awk '{print $9, $11, $13}' | sort | uniq -c | sort -rn
</code></pre>



<h3 class="wp-block-heading">Don&#8217;t move SSH to port 2222</h3>



<p class="wp-block-paragraph">It&#8217;s the oldest folklore recommendation in the book and it hides exactly nothing from a Shodan-scale scan. What actually quietens your logs is per-source rate limiting (which we ship) and fail2ban (which we ship). Keep SSH on 22 and avoid the firewall rule, the monitoring exception, the runbook footnote, and the inevitable &#8220;wait, why can&#8217;t I ssh to that host&#8221; thread on Slack.</p>



<h3 class="wp-block-heading">If you must allow passwords, scope them</h3>



<p class="wp-block-paragraph">A legacy vendor really needs password auth. Fine. Confine it to the VPN:</p>



<pre class="wp-block-preformatted"><code>Match Address 10.99.0.0/24
    PasswordAuthentication yes
    KbdInteractiveAuthentication yes
</code></pre>



<h3 class="wp-block-heading">ProxyJump, not nested ssh</h3>



<pre class="wp-block-preformatted"><code># ~/.ssh/config
Host bastion
    HostName bastion.prod.example.com
    User ops

Host prod-*
    ProxyJump bastion
    User deploy
</code></pre>



<p class="wp-block-paragraph"><code>ssh prod-web-04</code> now does the right thing, single TCP tunnel through the bastion, bastion never holds your private key, no agent forwarding required, no &#8220;ssh-in-ssh-in-ssh&#8221; muscle memory. Pairs naturally with <code>openssh-server-minimal</code> on the bastion: nothing but sshd and authorized_keys, the way bastions were meant to be.</p>



<h3 class="wp-block-heading">Multiplex sessions</h3>



<pre class="wp-block-preformatted"><code># ~/.ssh/config
Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
</code></pre>



<p class="wp-block-paragraph">The second ssh to a host within 10 minutes reuses the first one&#8217;s TCP connection and skips the entire handshake. Ansible runs faster. <code>scp</code> in a loop runs faster. Rapid-fire one-liners run faster. Free.</p>



<h3 class="wp-block-heading">Verify host keys the first time</h3>



<p class="wp-block-paragraph">&#8220;The authenticity of host can&#8217;t be established, are you sure?&#8221; is a security checkpoint, not a nuisance. Get the real fingerprints on the server:</p>



<pre class="wp-block-preformatted"><code>for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done
</code></pre>



<p class="wp-block-paragraph">Compare the Ed25519 line to what your client prints. Better: publish them in DNS as <code>SSHFP</code> records and set <code>VerifyHostKeyDNS yes</code> in your client. Our package builds with DNSSEC SSHFP support, Debian&#8217;s <code>dnssec-sshfp.patch</code> is in our applied series.</p>



<h3 class="wp-block-heading">Tune PerSourcePenalties for your traffic shape</h3>



<p class="wp-block-paragraph">Our defaults assume sshd faces the open internet. If yours sits behind a load balancer or a VPN that aggregates many real clients to one source IP, raise the limits or you&#8217;ll tarpit legitimate users:</p>



<pre class="wp-block-preformatted"><code># /etc/ssh/sshd_config.d/50-behind-lb.conf
PerSourceMaxStartups 32
PerSourceNetBlockSize 32:128
</code></pre>



<h3 class="wp-block-heading">Hash your known_hosts</h3>



<p class="wp-block-paragraph">If your laptop gets compromised, an attacker reads <code>~/.ssh/known_hosts</code> and learns every server you&#8217;ve ever connected to. They get a free reconnaissance map. Hash it:</p>



<pre class="wp-block-preformatted"><code>echo 'HashKnownHosts yes' &gt;&gt; ~/.ssh/config
ssh-keygen -H              # rewrites the existing file with hashed entries
</code></pre>



<h3 class="wp-block-heading">Restrict keys with options</h3>



<p class="wp-block-paragraph">Per-key restrictions in <code>authorized_keys</code> tighten things down even further than the global config:</p>



<pre class="wp-block-preformatted"><code># backup user — only from the NAS, only running restic-server, nothing else
restrict,from="10.0.0.42",command="/usr/local/bin/restic-server" ssh-ed25519 AAAAC3Nz... backup@nas
</code></pre>



<p class="wp-block-paragraph"><code>restrict</code> turns off agent forwarding, port forwarding, pty, X11 and user-rc; opt back in explicitly only what you need. <code>from=</code> pins the source IP. <code>command=</code> forces a specific command no matter what the client asks for. Combine all three and an attacker who steals the private key still can&#8217;t do anything but run <code>restic-server</code> from the NAS&#8217;s IP. That&#8217;s the goal.</p>



<h2 class="wp-block-heading" id="when-things-break">When things break</h2>



<h3 class="wp-block-heading">&#8220;My ancient client can&#8217;t connect&#8221;</h3>



<p class="wp-block-paragraph">An OpenSSH from 2014 won&#8217;t negotiate our algorithm list. Three options, ranked:</p>



<ol class="wp-block-list">
<li>Update the client. Almost always the right answer.</li>
<li>Add a <code>Match Address</code> block that relaxes the algorithm set just for that source.</li>
<li>Shadow our drop-in with a 99-*.conf that adds whatever the client needs. Leave a comment that says why.</li>
</ol>



<h3 class="wp-block-heading">&#8220;AppArmor is blocking something&#8221;</h3>



<p class="wp-block-paragraph">The profile ships in complain mode, so this shouldn&#8217;t happen unless you flipped it to enforce. Check <code>dmesg</code> for <code>apparmor="DENIED"</code>, run <code>aa-logprof</code> to generate the missing rules, and drop them in <code>/etc/apparmor.d/local/usr.sbin.sshd</code> rather than editing the shipped profile. Or open an issue against the package, if your stack hits the denial, others&#8217; will too.</p>



<h3 class="wp-block-heading">&#8220;sshd won&#8217;t start after upgrade&#8221;</h3>



<p class="wp-block-paragraph"><code>sudo sshd -t</code> validates the config. <code>journalctl -u ssh -n 50 --no-pager</code> shows the actual error. The usual cause is a leftover file in <code>/etc/ssh/sshd_config.d/</code> from a previous install that references a removed keyword. Our drop-in is <code>00-myguard-hardening.conf</code>; anything with a higher number wins on conflicting keys.</p>



<h3 class="wp-block-heading">&#8220;I installed openssh-server-minimal and pam_systemd broke&#8221;</h3>



<p class="wp-block-paragraph">Working as designed: minimal has no PAM. <code>pam_systemd</code> session registration, <code>pam_limits</code>, motd, Kerberos PAM modules, none of it exists in that binary. If you need any of those, you wanted <code>openssh-server</code>. Minimal is for containers, bastions and recovery images, where having no PAM is the feature.</p>



<h2 class="wp-block-heading" id="why-our-build">Why not just roll your own?</h2>



<p class="wp-block-paragraph">You absolutely can. Everything above is a few hours of work for any competent sysadmin, plus the ongoing cost of keeping it current as OpenSSH ships new algorithms, deprecates old ones, and adds knobs like <code>PerSourcePenalties</code>. We did the work, we keep doing it, and we ship the result as a single <code>apt install</code>. The autopkgtests in our build pipeline make sure the defaults don&#8217;t quietly regress between releases.</p>



<p class="wp-block-paragraph">The packages are out now for Debian bookworm and trixie, Ubuntu noble and resolute. Pull them from <a href="/">our repository</a>. Pick the flavour that matches your use case. File issues if you find any. Hardened SSH should have been the default a long time ago, until it is, you can have ours. Background reading: the <a href="https://www.openssh.com/releasenotes.html" rel="noopener" target="_blank">upstream OpenSSH release notes</a>, the <a href="https://apparmor.net/" rel="noopener" target="_blank">AppArmor project</a>, and <a href="https://csrc.nist.gov/projects/post-quantum-cryptography" rel="noopener" target="_blank">NIST&#8217;s post-quantum cryptography programme</a> for the ML-KEM context.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Docker Hardening for Self-Hosters: Rootless, Read-Only, Cap-Drop, Distroless (2026 Guide)</title>
		<link>https://deb.myguard.nl/2026/05/docker-hardening-rootless-readonly-distroless/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sun, 24 May 2026 17:42:07 +0000</pubDate>
				<category><![CDATA[Docker]]></category>
		<category><![CDATA[distroless]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[nginx]]></category>
		<category><![CDATA[php-fpm]]></category>
		<category><![CDATA[rootless]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[selfhosted]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5840</guid>

					<description><![CDATA[Default Docker is barely a container at all — root, mutable, all caps, shared kernel. This is the ten-flag hardening checklist that turns it into something a real attacker has to work to break: rootless, read-only, cap-drop, no-new-privileges, distroless, secrets, segmentation, scanning. With a worked NGINX + PHP-FPM compose example.]]></description>
										<content:encoded><![CDATA[
<p>Docker hardening is the missing manual. Picture the scene: you self-host, you read a blog post that said &#8220;Docker is secure by default,&#8221; you ran <code>docker run -d</code>, you went to bed feeling like a sysadmin from the future. Twelve months later, someone shows you a one-line CVE that lets any process inside any container on your box read <code>/etc/shadow</code> from the host. You spit out your coffee.</p>

<p>Here&#8217;s the truth nobody puts on the official Docker page: <strong>a default Docker container is barely a container at all</strong>. It&#8217;s a process running as root, with most Linux capabilities, on a writable filesystem, sharing a kernel with everything else on your box. If something inside the container goes wrong, a vulnerable nginx, a php-fpm worker that got popped, a forgotten debug endpoint, the blast radius is wider than you think.</p>

<p>The good news: a hardened container is genuinely close to bulletproof. The bad news: you have to opt in to every single layer of hardening. This guide is the checklist I wish someone had handed me when I started self-hosting WordPress, Vaultwarden, Roundcube, and the other ten things that live on my home server.</p>

<p>No jargon without an explanation. Pretend you&#8217;ve never written a Dockerfile in your life. By the end you&#8217;ll have a hardened compose file for NGINX/Angie and PHP-FPM, and you&#8217;ll understand why every flag is there.</p>



<figure style="margin:1.5rem 0;text-align:center;">
  <img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/docker-hardening-rootless-readonly-distroless.webp" alt="Docker hardening for self-hosters, rootless, read-only, cap-drop, distroless" style="max-width:100%;height:auto;border-radius:8px;" />
  <figcaption style="font-size:13px;color:var(--muted);margin-top:0.5rem;">A hardened container is just a normal container with eight extra &#8220;no&#8221;s.</figcaption>
</figure>



<h2 style="color:#f59e0b">Why default Docker is not actually safe (and what Docker hardening means)</h2>

<p>If you run <code>docker run -d nginx</code> right now, you get all of the following, whether you wanted them or not:</p>

<ul>
<li><strong>The container&#8217;s PID 1 runs as root.</strong> If anything inside the container is exploited and the attacker gets a shell, that shell is root inside the container.</li>
<li><strong>The dockerd daemon itself runs as root on the host.</strong> A container escape (and there have been several historic ones: CVE-2019-5736 was the famous runc escape) lands the attacker straight at host-root.</li>
<li><strong>The container has most Linux capabilities</strong>: CAP_NET_RAW, CAP_NET_BIND_SERVICE, CAP_CHOWN, CAP_SETUID, CAP_SETGID, CAP_KILL, CAP_AUDIT_WRITE and others. Far more than nginx actually needs.</li>
<li><strong>The container filesystem is writable.</strong> An attacker can drop a webshell wherever the running user can write, and it sticks around for the life of the container.</li>
<li><strong>Process privilege escalation is allowed.</strong> Without <code>no-new-privileges</code>, a setuid binary inside the container can elevate.</li>
<li><strong>The kernel is shared.</strong> A kernel exploit (you&#8217;ll see &#8220;container escape via dirty-pipe&#8221; or similar in the headlines every couple of years) hits everyone on the box.</li>
</ul>

<p>None of this is a Docker bug. It&#8217;s a deliberate choice to default to &#8220;convenient&#8221; over &#8220;secure.&#8221; The hardening flags exist; you just have to use them. Let&#8217;s walk through them, in order of how much they help.</p>



<h2 style="color:#f59e0b">Layer 1: don&#8217;t run the daemon as root (rootless mode)</h2>

<p>The single biggest hardening win is moving the Docker daemon out of root. Two ways to do it:</p>

<ul>
<li><strong>Docker rootless mode</strong>: ships with Docker 20.10+. Run <code>dockerd-rootless-setuptool.sh install</code> as your normal user, and from then on <code>docker</code> commands talk to <em>your</em> personal daemon, owned by your user. A container escape now lands at your user, not at root.</li>
<li><strong>Podman</strong>: designed rootless from day one, no daemon at all (each <code>podman run</code> spawns a normal user process). Drop-in compatible with most <code>docker run</code> and Compose files.</li>
</ul>

<p>I run a mix on my own machine: Podman for one-shot containers, Docker rootless for the long-running compose stacks. Both deliver the same security win: <strong>the privileged Docker daemon is gone</strong>.</p>

<p>Trade-offs to know:</p>

<ul>
<li>Rootless containers can&#8217;t bind to ports below 1024 by default. Solution: put a single privileged reverse proxy in front (or use <code>net.ipv4.ip_unprivileged_port_start=80</code> as a sysctl).</li>
<li>Some volume-mount and overlay-network features need extra setup (<code>slirp4netns</code>, <code>fuse-overlayfs</code>).</li>
<li><code>docker run --privileged</code> is mostly meaningless rootless: which is exactly what you want.</li>
</ul>

<p>If you take one thing from this whole post, take this: <strong>run Docker rootless or use Podman.</strong> The other layers are nice; this one is foundational.</p>



<h2 style="color:#f59e0b">Layer 2: make the container filesystem read-only</h2>

<p>Most well-behaved server processes never need to write to their own filesystem. nginx serves static files. PHP-FPM reads scripts and writes to a session store and a log. Vaultwarden writes to a single SQLite file. If you flip the container filesystem to read-only and then mount <em>just</em> the paths that need writes, an attacker who drops a webshell into <code>/usr/local/bin/</code> finds out that file system is read-only the hard way.</p>

<p>In Compose:</p>

<pre><code>services:
  web:
    image: nginx:1.27-alpine
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run
    volumes:
      - ./site:/usr/share/nginx/html:ro</code></pre>

<p>The <code>tmpfs</code> mounts give the container a few writable scratch directories backed by RAM, they vanish on restart. Anything else the process tries to write fails with <code>EROFS</code>. nginx, php-fpm, postgres, redis, vaultwarden, postfix, dovecot, all of them will run happily in this mode once you&#8217;ve identified the directories they legitimately need writable.</p>

<p>How to find out? Run the container, hit it with normal traffic, then <code>docker exec -it &lt;name&gt; sh -c 'mount | grep -v ro'</code>. Anything writable that isn&#8217;t a tmpfs or bind-mount is a candidate for &#8220;either move to tmpfs or live without.&#8221;</p>



<h2 style="color:#f59e0b">Layer 3: drop every capability, add back only what you need</h2>

<p>Linux capabilities are the modern way to slice up what used to be &#8220;all of root or none of it.&#8221; A normal process running as root has 40+ capabilities. A normal container gets a curated default subset, but it&#8217;s still wildly over-privileged for what most apps actually do.</p>

<p>The right approach: drop all of them, then add back only the specific ones the app needs. For nginx in a container that listens on port 80:</p>

<pre><code>services:
  web:
    image: nginx:1.27-alpine
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]</code></pre>

<p>That&#8217;s it. <code>NET_BIND_SERVICE</code> is the only capability nginx needs to bind to port 80. With that single allow, an attacker who escapes the nginx process has the privileges of &#8220;a process that can bind to a low port.&#8221; That&#8217;s it. No <code>chown</code>, no <code>setuid</code>, no <code>net_raw</code>, no <code>kill</code>, nothing.</p>

<p>For PHP-FPM that listens on a Unix socket or a TCP port above 1024:</p>

<pre><code>services:
  php:
    image: php:8.4-fpm-alpine
    cap_drop: [ALL]
    # NO cap_add at all — PHP-FPM doesn't need a single capability</code></pre>

<p>That feels wrong the first time you see it. It&#8217;s correct. Read the <code>capabilities(7)</code> man page if you&#8217;re unsure, nothing PHP-FPM does at runtime requires any capability.</p>



<h2 style="color:#f59e0b">Layer 4: <code>no-new-privileges</code></h2>

<p>This one is a single line and a huge win. It tells the kernel: &#8220;no process inside this container can ever gain more privileges than it started with.&#8221; Setuid binaries are neutered. <code>su</code> stops working. <code>sudo</code> stops working. <code>pkexec</code> stops working.</p>

<pre><code>services:
  web:
    image: nginx:1.27-alpine
    security_opt:
      - no-new-privileges:true</code></pre>

<p>Combine this with running as a non-root user inside the container (most well-built images do this already) and you&#8217;ve removed an entire family of attack chains. The 2019 runc escape (CVE-2019-5736) and several since have depended on tricking the kernel into running a binary with elevated privileges; <code>no-new-privileges</code> blocks that class outright.</p>

<p>There is no good reason ever to leave this off for a normal application container.</p>



<h2 style="color:#f59e0b">Layer 5: don&#8217;t run as root inside the container</h2>

<p>Even with the host-level Docker daemon rootless, an application running as UID 0 inside the container is needlessly powerful. Run as a normal user.</p>

<p>Most well-built modern images already do this. The official <code>nginx:alpine</code> image runs as the <code>nginx</code> user (UID 101). The official <code>php:8.4-fpm-alpine</code> image runs FPM workers as <code>www-data</code> (UID 82). If you&#8217;re writing your own Dockerfile, you should always include:</p>

<pre><code>RUN adduser -D -u 33 appuser
USER appuser</code></pre>

<p>And in Compose, you can override the runtime user explicitly:</p>

<pre><code>services:
  php:
    image: deb.myguard.nl/php-fpm:8.4
    user: "33:33"   # www-data</code></pre>

<p>For an extra layer, enable Docker&#8217;s user-namespace remapping (<code>userns-remap</code> in <code>/etc/docker/daemon.json</code>). Even if the process inside the container thinks it&#8217;s UID 0, the kernel sees it as an unprivileged UID on the host (e.g. UID 100000). This breaks a class of file-permission attacks across the container/host boundary.</p>



<h2 style="color:#f59e0b">Layer 6: pick a base image that isn&#8217;t a Swiss army knife</h2>

<p>&#8220;Use Alpine for security&#8221; is one of those folk-wisdom statements that&#8217;s half right. Smaller images do have fewer CVEs by sheer surface-area, but the modern landscape has three serious contenders:</p>

<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;margin:1rem 0;">
  <thead style="background:#f59e0b;color:#fff;">
    <tr>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">Base</th>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">Size</th>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">Has a shell?</th>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">Use when</th>
    </tr>
  </thead>
  <tbody>
    <tr style="border-bottom:1px solid #ddd;">
      <td style="padding:.6rem;"><strong>distroless</strong> (gcr.io/distroless)</td>
      <td style="padding:.6rem;">~20 MB</td>
      <td style="padding:.6rem;">No (and no package manager)</td>
      <td style="padding:.6rem;">You ship a single static or interpreted binary. Best for Go, Rust, Java.</td>
    </tr>
    <tr style="border-bottom:1px solid #ddd;">
      <td style="padding:.6rem;"><strong>alpine</strong></td>
      <td style="padding:.6rem;">~5 MB</td>
      <td style="padding:.6rem;">Yes (busybox)</td>
      <td style="padding:.6rem;">Small, musl-based. Watch out for <code>glibc</code> assumptions in some apps.</td>
    </tr>
    <tr>
      <td style="padding:.6rem;"><strong>debian:trixie-slim / ubuntu:24.04</strong></td>
      <td style="padding:.6rem;">~30 MB</td>
      <td style="padding:.6rem;">Yes (bash)</td>
      <td style="padding:.6rem;">You need glibc, full apt repos, and predictable behaviour. The most production-friendly of the three.</td>
    </tr>
  </tbody>
</table>
</div>

<p>The honest answer: <strong>match the base to the app</strong>. WordPress + PHP-FPM with our myguard packages? <code>debian:trixie-slim</code>, every time, we test against it, the packages install cleanly, the apt sources just work. A Go static binary you wrote yourself? Distroless. A small Node.js script? Alpine.</p>

<p>What matters more than the base image, every single time: <strong>rebuild and patch on a schedule</strong>. The freshest distroless image with a six-month-old base layer is less secure than yesterday&#8217;s debian-slim with apt-updated packages.</p>



<h2 style="color:#f59e0b">Layer 7: secrets don&#8217;t belong in your image</h2>

<p>The most common own-goal in container security: baking a <code>DATABASE_PASSWORD=hunter2</code> into a Dockerfile <code>ENV</code>, or worse, into a <code>COPY .env /app/</code>. That credential is now in every layer of every published image, forever. <code>docker history</code> will show it. Anyone who pulls the image can extract it with <code>docker save</code>.</p>

<p>Three good ways to ship secrets to a container, ranked by my preference for self-hosters:</p>

<ol>
<li><strong>Bind-mount a file</strong> the container reads at startup. Keep the file outside git, lock it down to mode <code>600</code>.<br>
<code>volumes: [./secrets/db.env:/run/secrets/db.env:ro]</code></li>
<li><strong>Docker secrets / Swarm secrets</strong>: works fine standalone with <code>docker compose --profile production</code>. Secrets land as files under <code>/run/secrets/</code> inside the container.</li>
<li><strong>External secret store</strong> (Bitwarden CLI, age-encrypted files, HashiCorp Vault). Overkill for most homelabs but worth knowing about.</li>
</ol>

<p>What never to do: <code>ENV DATABASE_PASSWORD=...</code> in the Dockerfile, or <code>--env DATABASE_PASSWORD=...</code> on the command line (it leaks to <code>ps</code> on the host).</p>



<h2 style="color:#f59e0b">Layer 8: network segmentation by default</h2>

<p>Every container Docker creates joins the <code>default</code> bridge network and can talk to every other container on that network. That&#8217;s the opposite of what you want.</p>

<p>The right pattern: each compose stack gets its own network. The reverse-proxy container joins <em>both</em> the public-facing network and the stack&#8217;s internal network. The application containers (PHP-FPM, database, cache) join <em>only</em> the internal network. Now an attacker who pops PHP-FPM can&#8217;t ping a neighbouring stack&#8217;s postgres.</p>

<pre><code>services:
  web:
    image: nginx:1.27-alpine
    networks: [public, internal]
    ports: ["80:80"]
  php:
    image: deb.myguard.nl/php-fpm:8.4
    networks: [internal]   # NO public exposure
  db:
    image: postgres:17-alpine
    networks: [internal]

networks:
  public:
    driver: bridge
  internal:
    driver: bridge
    internal: true   # blocks egress to the internet too</code></pre>

<p>The <code>internal: true</code> on the database network is the cherry on top: a database container on an internal-only network cannot exfiltrate data to the public internet even if it&#8217;s owned. Most data-stealer malware fails silently against this.</p>



<h2 style="color:#f59e0b">Layer 9: scan your images, every build</h2>

<p>Static analysis of container images takes thirty seconds and catches an embarrassing number of issues. The three tools I actually use:</p>

<ul>
<li><strong>Trivy</strong> (<a href="https://github.com/aquasecurity/trivy" target="_blank" rel="noopener">github.com/aquasecurity/trivy</a>): one binary, scans an image for CVEs in OS packages and language deps. <code>trivy image nginx:1.27-alpine</code> prints a table you can act on in minutes.</li>
<li><strong>Grype</strong> (<a href="https://github.com/anchore/grype" target="_blank" rel="noopener">github.com/anchore/grype</a>): similar idea, different vuln database. I run both because they don&#8217;t always agree.</li>
<li><strong>Syft</strong> (<a href="https://github.com/anchore/syft" target="_blank" rel="noopener">github.com/anchore/syft</a>): generates a software bill of materials (SBOM) in SPDX/CycloneDX format. Useful when a new CVE drops and you need to know which of your images are affected without re-scanning.</li>
</ul>

<p>Wire any of these into your CI and reject builds with critical CVEs. For self-hosters without a CI, even a manual <code>trivy image $(docker images -q | head)</code> on a Sunday afternoon catches the worst stuff.</p>



<h2 style="color:#f59e0b">Layer 10: logging that survives the container</h2>

<p>When (not if) something goes wrong inside a container, you want logs you can read. Two rules:</p>

<ul>
<li><strong>Log to stdout/stderr</strong>, not to a file inside the container. The Docker logging driver captures stdout and ships it where you want.</li>
<li><strong>Pick a real logging driver</strong>: <code>json-file</code> with rotation, <code>journald</code>, or push to Loki / Vector. The default unbounded <code>json-file</code> driver will fill your disk eventually.</li>
</ul>

<p>In <code>/etc/docker/daemon.json</code>:</p>

<pre><code>{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "5"
  }
}</code></pre>

<p>Per-container override in Compose:</p>

<pre><code>services:
  web:
    logging:
      driver: journald
      options:
        tag: "{{.Name}}"</code></pre>

<p>Forensics are only possible if the evidence still exists when you go looking.</p>



<h2 style="color:#f59e0b">Putting it all together: a hardened nginx + PHP-FPM stack</h2>

<p>Here&#8217;s a complete, real-world <code>docker-compose.yml</code> for a hardened WordPress / PHP stack using our myguard packages. Every flag is one of the ten layers above; no flag is decorative.</p>

<pre><code>services:
  web:
    image: deb.myguard.nl/angie:1.11.5-alpine
    read_only: true
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]
    security_opt:
      - no-new-privileges:true
    user: "101:101"   # nginx
    tmpfs:
      - /tmp
      - /var/cache/angie
      - /var/run
    ports: ["80:80", "443:443"]
    networks: [public, internal]
    volumes:
      - ./conf/angie.conf:/etc/angie/angie.conf:ro
      - certs:/etc/letsencrypt:ro
      - wp-content:/var/www/html/wp-content:ro
    logging:
      driver: journald

  php:
    image: deb.myguard.nl/php-fpm:8.4-snuf
    read_only: true
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    user: "33:33"   # www-data
    tmpfs:
      - /tmp
      - /var/run
    networks: [internal]   # no public exposure
    volumes:
      - ./conf/wordpress-strict.rules:/etc/php/8.4/php-snuffleupagus/active.rules:ro
      - ./conf/www.conf:/etc/php/8.4/fpm/pool.d/www.conf:ro
      - wp-content:/var/www/html/wp-content
    logging:
      driver: journald

  db:
    image: mariadb:11-noble
    read_only: false   # mariadb really does need to write
    cap_drop: [ALL]
    cap_add: [CHOWN, SETUID, SETGID]
    security_opt:
      - no-new-privileges:true
    networks: [internal]
    environment:
      MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
    secrets: [db_root_password]
    volumes:
      - db-data:/var/lib/mysql
    logging:
      driver: journald

networks:
  public:
    driver: bridge
  internal:
    driver: bridge
    internal: true

volumes:
  certs:
  wp-content:
  db-data:

secrets:
  db_root_password:
    file: ./secrets/db_root_password</code></pre>

<p>The PHP container in this stack also loads <a href="/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/">Snuffleupagus</a> via the rules file mount, so even if the attacker bypasses all ten Docker layers and gets PHP execution, Snuffleupagus blocks the function calls they&#8217;d need to actually do damage. Defence in depth, in three independent layers.</p>



<h2 style="color:#f59e0b">The 60-second Docker hardening checklist</h2>

<p>If you remember nothing else from this post, run through this list on every container you deploy:</p>

<ol>
<li>Daemon running rootless? (or Podman)</li>
<li><code>read_only: true</code> + tmpfs for the writable bits?</li>
<li><code>cap_drop: [ALL]</code> with only the specific caps added back?</li>
<li><code>security_opt: [no-new-privileges:true]</code>?</li>
<li><code>user:</code> set to a non-root UID?</li>
<li>Base image fresh, scanned with Trivy or Grype?</li>
<li>Secrets via files, not <code>ENV</code>?</li>
<li>App container on an <code>internal: true</code> network?</li>
<li>Logging driver with rotation set?</li>
<li>Restart policy chosen deliberately (<code>unless-stopped</code> for services, not <code>always</code>)?</li>
</ol>

<p>Ten flags. Five minutes per stack. A real attacker has to find a real kernel CVE to escape this. Worth the effort.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Frequently asked questions</h2>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Is Docker actually less secure than a VM?</h3>
<div class="rank-math-answer ">

<p>It depends on the hardening. A default <code>docker run</code> is significantly less isolated than a default KVM VM. A hardened container (rootless + read-only + cap-drop + no-new-privileges) is comparable to a VM for most threat models, and uses a fraction of the resources. For really high-value workloads where you want a hardware-enforced isolation boundary, use Firecracker or Kata Containers, they wrap each container in a micro-VM.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Should I switch from Docker to Podman?</h3>
<div class="rank-math-answer ">

<p>If you&#8217;re starting fresh, yes, Podman is rootless-first and the CLI is a drop-in replacement. If you have an existing Docker stack that works, Docker rootless mode gives you ~95% of the security benefit without the migration. Either choice is fine; the wrong choice is &#8220;daemon as root.&#8221;</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">What about Kubernetes? Doesn&#8217;t it handle all this for me?</h3>
<div class="rank-math-answer ">

<p>Kubernetes ships PodSecurityStandards and admission controllers that can enforce a lot of this at the cluster level, <em>if you configure them</em>. Out of the box, a default Pod is just as permissive as a default Docker container. For a homelab, K8s is overkill; for production, look up &#8220;Pod Security Standards&#8221; and apply the <code>restricted</code> profile.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">My app needs to write to its own filesystem for caching. Can I still use <code>read_only</code>?</h3>
<div class="rank-math-answer ">

<p>Yes, mount the specific writable path as a tmpfs (RAM-backed, vanishes on restart) or as a named volume (persistent on disk). Lots of legitimate apps need scratch space; the trick is to whitelist <em>which</em> paths, not flip the whole filesystem writable.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Is Alpine really insecure because of musl?</h3>
<div class="rank-math-answer ">

<p>&#8220;Insecure&#8221; is the wrong word. Musl has subtly different behaviour from glibc in edge cases (locale, DNS resolution, threading). Some apps assume glibc and break on Alpine in non-obvious ways. The security story is fine; the compatibility story is what bites you. If your app is well-tested on Alpine, ship Alpine. If not, ship debian-slim.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Should I scan images in production, or only at build time?</h3>
<div class="rank-math-answer ">

<p>Both. Build-time scanning catches &#8220;this base image already had a CVE when you pulled it.&#8221; Runtime scanning catches &#8220;a new CVE was published last week against a package in your already-running image.&#8221; Trivy can do both; for self-hosters, a weekly cron job that emails you Trivy&#8217;s diff is enough.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">What about <code>privileged: true</code> for that one container that needs it?</h3>
<div class="rank-math-answer ">

<p>There&#8217;s almost always a less-bad option. If a container needs a specific capability, add only that capability with <code>cap_add</code>. If it needs a specific device, mount only that device. <code>privileged: true</code> gives the container almost root-on-host access; reserve it for containers that genuinely manage Docker itself (and even then, look hard for an alternative).</p>

</div>
</div>
<div id="rm-faq-8" class="rank-math-list-item">
<h3 class="rank-math-question ">Does any of this help against supply-chain attacks (a malicious package in my base image)?</h3>
<div class="rank-math-answer ">

<p>Partly. <code>read_only</code> + <code>cap_drop</code> limit what the malicious code can do at runtime; SBOM scanning (Syft) catches some known-malicious package signatures. The strongest defence is pinning images by digest (<code>image: nginx@sha256:abc...</code>) rather than tag, that way a re-tagged malicious image can&#8217;t quietly slip in on your next pull.</p>

</div>
</div>
</div>
</div>


<h2 style="color:#f59e0b">Related posts</h2>
<ul>
<li><a href="/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/">PHP Snuffleupagus tutorial</a>: the PHP-interpreter-layer hardening that pairs perfectly with this container-layer hardening.</li>
<li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to install ModSecurity and OWASP CRS on NGINX</a>: the HTTP-layer WAF that sits in front.</li>
<li><a href="/2026/05/self-hosted-password-manager-with-vaultwarden/">Self-hosted Vaultwarden</a>: a real-world hardened compose example end-to-end.</li>
<li><a href="/2026/05/docker-cms-php-8-5-docker-image-for-wordpress-and-cms-hosting/">docker-cms: hardened PHP 8.5 image for WordPress</a>: our pre-hardened image so you don&#8217;t have to start from scratch.</li>
<li><a href="/nginx-dockerized/">Angie and NGINX Docker images</a>: daily-rebuilt, full-modules, ready to drop into a hardened compose.</li>
</ul>


<p><!-- seo-orphan-link --> See also: <a href="/2026/05/hardened-openssh-for-debian-and-ubuntu-pq-crypto-apparmor-three-sshd-flavours/">Hardened OpenSSH 10.3 for Debian and Ubuntu</a> for the SSH side of host hardening, and the <a href="/docker/">Docker packages overview</a>.</p>]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Rspamd Explained: How Modern Spam Filtering Actually Works (Bayes, Neural Nets, RBLs and All the Cool Tricks)</title>
		<link>https://deb.myguard.nl/2026/05/rspamd-explained-modern-spam-filtering-bayes-neural-rbl/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Fri, 22 May 2026 23:57:12 +0000</pubDate>
				<category><![CDATA[Mail]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[dovecot]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[mail]]></category>
		<category><![CDATA[postfix]]></category>
		<category><![CDATA[rspamd]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5780</guid>

					<description><![CDATA[Rspamd is the modern spam filter that runs Bayesian classifiers, neural networks, greylisting, DNS blacklists, Pyzor, Razor, OLEFY and DCC — all at once. Here is what rspamd does, how spam evolved, and why it crushes the inbox war.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Okay, gather round, ladies. Pour the rosé. Lock the cat out of the room. Today we are going to talk about the single most universally hated thing on the internet, and no, I don&#8217;t mean your ex&#8217;s new girlfriend on Instagram. I mean <strong>spam email</strong>. And we are going to learn how <strong>rspamd</strong>, the smartest, fastest, sassiest spam filter on the planet, kicks it straight in the inbox. We will cover Bayesian classifiers, neural networks, greylisting, RBLs, Pyzor, Razor, OLEFY, DCC, and a glorious romp through the entire history of spam, from a guy named Gary in 1978 all the way to that &#8220;Dear Beneficiary&#8221; email your aunt definitely clicked on. Yes, really. Buckle up.</p>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img loading="lazy" decoding="async" width="1600" height="1067" src="https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-spam-filter-neural-network.webp" alt="Rspamd spam filter neural network classifying email" class="wp-image-5777" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-spam-filter-neural-network.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-spam-filter-neural-network-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-spam-filter-neural-network-1024x683.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-spam-filter-neural-network-768x512.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-spam-filter-neural-network-1536x1024.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /><figcaption class="wp-element-caption">Rspamd is basically a tiny robot bouncer for your inbox, and it never gets tired.</figcaption></figure>
</div>


<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">First things first, what even is rspamd?</h2>



<p class="wp-block-paragraph">Imagine you run a hotel. Every day, three thousand strangers show up at the front desk demanding a room. Some are lovely guests with reservations. Some are conmen with fake passports. Some are wearing trench coats and clearly trying to sell you knock-off Rolexes. You need a bouncer. A very, very smart bouncer who can read body language, sniff out fake IDs, remember faces, and ban the same scammer who tried it last Tuesday.</p>



<p class="wp-block-paragraph"><strong>That is rspamd.</strong> It is a fast, open-source spam filter, written in C and Lua, that sits in front of your mail server (usually <a href="/2026/05/postfix-dovecot-setup-debian/">Postfix paired with Dovecot</a>) and judges every single email that tries to walk through your door. It scores each one, decides if it is a guest or a grifter, and acts accordingly: deliver, tag, quarantine, or slam the door shut. And unlike its older, slower cousin SpamAssassin (we love you, Spammy, but it is 2026), rspamd does this in <em>milliseconds</em>, in parallel, with a gorgeous web interface and machine learning baked right in.</p>



<p class="wp-block-paragraph">It was created in 2008 by Vsevolod Stakhov, a Russian engineer who looked at SpamAssassin and went, &#8220;love the idea, hate the speed, let me rewrite this from scratch.&#8221; The &#8220;r&#8221; stands for &#8220;rapid.&#8221; It is, and honestly, the man delivered.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">A brief, slightly unhinged history of spam</h2>



<p class="wp-block-paragraph">Before we praise rspamd, we have to understand the enemy. And the enemy has a long, embarrassing résumé.</p>



<h3 class="wp-block-heading">1978: The original sin, Gary Thuerk</h3>



<p class="wp-block-paragraph">The very first spam email was sent on <strong>3 May 1978</strong> by a marketing manager at Digital Equipment Corporation named Gary Thuerk. He blasted a product announcement to 393 people on ARPANET, the proto-internet that connected universities and the US Department of Defense. Reception was, shall we say, <em>chilly</em>. People were furious. One government rep called him personally to yell at him. Gary defended himself for the rest of his life. He claims he generated $13 million in sales. He also claims to be &#8220;the father of spam,&#8221; which is genuinely the worst LinkedIn bio of all time.</p>



<h3 class="wp-block-heading">1993: The word &#8220;spam&#8221; is born</h3>



<p class="wp-block-paragraph">The term itself comes from a Monty Python sketch where Vikings sing &#8220;spam, spam, spam, spam&#8221; over and over until you cannot hear anything else. In 1993, a Usenet admin named Joel Furr used the word for an accidental mass-post on the alt.religion newsgroup. It stuck. So yes, every time you say &#8220;spam,&#8221; you are quoting <em>British comedians in horned helmets</em>. The internet is a beautiful place.</p>



<h3 class="wp-block-heading">1994: The Canter and Siegel incident</h3>



<p class="wp-block-paragraph">Two American lawyers, Laurence Canter and Martha Siegel, posted an advertisement for their immigration services to <em>every single Usenet newsgroup</em>. Six thousand of them. People lost their minds. They received death threats, their fax machine was overwhelmed (people would dial it and leave the line open for hours, the original DDoS), and their internet provider&#8217;s servers crashed. They wrote a book about it afterwards. Of course they did.</p>



<h3 class="wp-block-heading">2003: The CAN-SPAM Act</h3>



<p class="wp-block-paragraph">The US Congress finally passed the <strong>CAN-SPAM Act</strong> (Controlling the Assault of Non-Solicited Pornography And Marketing, yes that is the real name, government acronyms are a hate crime). It required commercial emails to have an unsubscribe link, a real sender address, and no deceptive subject lines. Critics called it &#8220;the You Can Spam Act&#8221; because it actually <em>legalised</em> a lot of bulk marketing as long as you ticked the boxes. Hard agree, honestly.</p>



<h3 class="wp-block-heading">The lawsuits and the lawsuits and the lawsuits</h3>



<p class="wp-block-paragraph">Spammers have been sued into oblivion repeatedly. Some greatest hits:</p>



<ul class="wp-block-list">
<li><strong>MySpace v. Wallace (2008)</strong>: Sanford Wallace, &#8220;the Spam King,&#8221; was hit with a $234 million judgment.</li>
<li><strong>Facebook v. Wallace (2009)</strong>: same guy, again, $711 million.</li>
<li><strong>Facebook v. Guerbuez (2008)</strong>: Adam Guerbuez fined $873 million for spamming Facebook. He never paid. He was banned from Facebook for life, which is honestly the better punishment.</li>
<li><strong>Microsoft v. Soloway (2007)</strong>: Robert Soloway, the original &#8220;Spam King,&#8221; went to prison for nearly four years.</li>
</ul>



<p class="wp-block-paragraph">None of this actually stopped spam. It just moved offshore. Today the spam industry is run from data centres in Eastern Europe, Southeast Asia, and increasingly from poorly secured smart fridges. Yes, your <em>fridge</em>. We will get to that.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">What&#8217;s actually in your spam folder (and why it&#8217;s terrifying)</h2>



<p class="wp-block-paragraph">Spam is not just annoying anymore, it is the front line of every major cybercrime category. Here is the modern menu of threats:</p>



<ul class="wp-block-list">
<li><strong>Phishing</strong>: &#8220;Your Netflix account has been suspended, click here.&#8221; Designed to steal passwords. Targets your mum, succeeds depressingly often.</li>
<li><strong>Spear phishing</strong>: Like phishing, but personalised. &#8220;Hi Sarah, attached is the Q2 invoice as discussed.&#8221; Sarah does not remember discussing it. Sarah clicks anyway.</li>
<li><strong>Business Email Compromise (BEC)</strong>: Hackers impersonate your CEO and tell finance to wire $80,000 to a vendor account. The FBI says BEC has caused over <strong>$50 billion</strong> in losses since 2013. With a B.</li>
<li><strong>Malware droppers</strong>: Innocent-looking PDFs and Word docs full of macros that install ransomware. Pays the spammer&#8217;s mortgage.</li>
<li><strong>Sextortion</strong>: &#8220;I hacked your webcam and recorded you, send Bitcoin.&#8221; Usually a complete bluff, but the volume is so high that even a 0.01% success rate is profitable.</li>
<li><strong>Pump-and-dump</strong>: Fake stock tips designed to inflate penny-stock prices so the spammer can sell. Still alive and well in 2026.</li>
<li><strong>The classic 419 scam</strong>: A Nigerian prince needs help moving $25 million. He will give you 30%. Spoiler: he will not.</li>
</ul>



<p class="wp-block-paragraph">This is why a good spam filter is not &#8220;a nice to have.&#8221; It is the seatbelt of the internet. Strap in.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Meet Spamhaus, the spam police</h2>



<p class="wp-block-paragraph">Before we get into rspamd&#8217;s clever tricks, you need to know about <strong>Spamhaus</strong>. Founded in 1998 in London by Steve Linford, Spamhaus is a non-profit, slightly mysterious, weirdly powerful organisation that maintains the world&#8217;s most-used <strong>blocklists</strong>. They track spammers, botnets, and hijacked IP addresses, and publish lists like:</p>



<ul class="wp-block-list">
<li><strong>SBL</strong> (Spamhaus Block List): known spam-source IPs.</li>
<li><strong>XBL</strong> (Exploits Block List): hijacked machines and open proxies.</li>
<li><strong>PBL</strong> (Policy Block List): IPs that should never be sending mail directly (think: residential broadband).</li>
<li><strong>DBL</strong> (Domain Block List): spammy domains.</li>
<li><strong>ZEN</strong>: the combined &#8220;give me everything&#8221; list. Used by basically every major mail provider.</li>
</ul>



<p class="wp-block-paragraph">Roughly <strong>80% of the world&#8217;s email servers</strong> consult Spamhaus before delivering a message. If you end up on their list, your mail does not get delivered. Anywhere. To anyone. They are the bouncer&#8217;s bouncer.</p>



<p class="wp-block-paragraph">Naturally, spammers <em>hate</em> them. In 2013, a Dutch hosting company called Cyberbunker launched what was at the time the largest DDoS attack in history, peaking at 300 Gbps, against Spamhaus. The internet itself noticeably slowed down. Spamhaus survived. The Cyberbunker guy went to prison. Iconic.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">How rspamd thinks: the scoring system</h2>



<p class="wp-block-paragraph">Here is the secret sauce. Rspamd doesn&#8217;t say &#8220;spam!&#8221; or &#8220;not spam!&#8221;, it says &#8220;this email looks <em>kind of</em> spammy, give it a score.&#8221; Each suspicious trait adds (or subtracts) points. At the end, if the total crosses a threshold, the email is rejected, tagged, or quarantined.</p>



<pre class="wp-block-preformatted"><code>     Email arrives
          │
          ▼
  ┌───────────────┐
  │  rspamd runs  │
  │  ~200 checks  │
  └───────┬───────┘
          │
          ▼
   ┌──────────────┐
   │  Final score │
   └──────┬───────┘
          │
   ┌──────┼───────┬─────────────┐
   ▼      ▼       ▼             ▼
 < 4   4 to 6   6 to 15        > 15
DELIVER  TAG    GREYLIST    REJECT
 ✅ 💌  📬 [SPAM]  ⏸️ wait    🚫 bye</code></pre>



<p class="wp-block-paragraph">The thresholds are configurable. Every check, DNS blacklist hit, weird headers, suspicious URL, Bayesian probability, contributes a few points. It is essentially a giant, fast, parallel jury deliberation. Beautiful, right?</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The Bayesian classifier, rspamd&#8217;s nosy housekeeper</h2>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img loading="lazy" decoding="async" width="1600" height="1067" src="https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-bayesian-classifier-machine-learning.webp" alt="Rspamd Bayesian classifier machine learning illustration" class="wp-image-5778" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-bayesian-classifier-machine-learning.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-bayesian-classifier-machine-learning-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-bayesian-classifier-machine-learning-1024x683.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-bayesian-classifier-machine-learning-768x512.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-bayesian-classifier-machine-learning-1536x1024.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /><figcaption class="wp-element-caption">Bayes: the OG of machine learning, still pulling its weight in 2026.</figcaption></figure>
</div>


<p class="wp-block-paragraph">Let&#8217;s talk about <strong>Bayesian filtering</strong>, because it is genuinely beautiful maths dressed up as common sense. The idea was popularised for spam by Paul Graham in his 2002 essay &#8220;A Plan for Spam,&#8221; and the maths goes back to Reverend Thomas Bayes in the 1700s. Yes, a vicar invented modern spam filtering. Christianity wins.</p>



<p class="wp-block-paragraph">Here is how it works, in girls-night-in language: every word in an email gets a probability. The word &#8220;Viagra&#8221; appears in 92% of spam and 1% of real mail, so its spam-score is very high. The word &#8220;meeting&#8221; appears in 5% of spam and 60% of real mail, so its spam-score is very low. Rspamd multiplies up all the probabilities (using a clever combining function called Robinson&#8217;s method, then Fisher&#8217;s chi-square test), and out pops a final number between 0 and 1: how likely this email is spam.</p>



<p class="wp-block-paragraph">The genius part: it <strong>learns from you</strong>. Every time you mark an email as spam (or as not spam), rspamd updates its word statistics. Within a few hundred messages, it knows your inbox like a slightly creepy housekeeper. The word &#8220;rspamd&#8221; in your inbox? Probably ham (the official opposite of spam, naming geniuses we are). The word &#8220;rspamd&#8221; in a stranger&#8217;s inbox? Might be spam. Personalised. Adaptive. Gorgeous.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The neural network, the new kid with the cool sneakers</h2>



<p class="wp-block-paragraph">Bayes is great, but it only looks at words in isolation. Spammers caught on and started writing emails that are structurally weird but word-wise innocent. So rspamd added a <strong>neural network</strong> module (rspamd-neural) around version 1.7 and it has gotten dramatically smarter every release since.</p>



<p class="wp-block-paragraph">The neural net doesn&#8217;t look at words, it looks at <em>the scores of all the other rspamd checks</em>. So if your email has SPF=pass, DKIM=pass, but the body has 14 links, a base64-encoded PDF, comes from a brand-new domain, and the Bayesian score is 0.6… each of those is an input feature, and the neural net learns the patterns that correlate with spam. Multi-layer perceptron under the hood, trained per-server on your own traffic.</p>



<p class="wp-block-paragraph">This is the part where rspamd genuinely surpasses everything else on the market. It is not just &#8220;AI-washing&#8221;, it is real, locally-trained, explainable machine learning, and it catches the weird stuff Bayes misses. Think of it as Bayes&#8217;s gen-Z niece who notices everything.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Blacklists, whitelists, and the DNS magic show</h2>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img loading="lazy" decoding="async" width="1600" height="1067" src="https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-greylisting-rbl-blacklist.webp" alt="Rspamd greylisting and RBL blacklist filtering concept" class="wp-image-5779" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-greylisting-rbl-blacklist.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-greylisting-rbl-blacklist-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-greylisting-rbl-blacklist-1024x683.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-greylisting-rbl-blacklist-768x512.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/rspamd-greylisting-rbl-blacklist-1536x1024.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /><figcaption class="wp-element-caption">RBLs: real-time blocklists are the global gossip network of the email world.</figcaption></figure>
</div>


<p class="wp-block-paragraph">Rspamd checks every incoming email&#8217;s sending IP against dozens of <strong>DNS-based block lists (DNSBLs)</strong>, also called RBLs, &#8220;Real-time Blackhole Lists.&#8221; How? With a delightfully clever DNS trick: it reverses the IP, prepends it to the blocklist&#8217;s domain, and does a normal DNS lookup. If a record exists, the IP is listed.</p>



<p class="wp-block-paragraph">Example: sender IP is 1.2.3.4, list is zen.spamhaus.org. Rspamd queries <code>4.3.2.1.zen.spamhaus.org</code>. Gets a reply? Bad IP. No reply? Probably fine. The whole thing takes milliseconds.</p>



<p class="wp-block-paragraph">The lists rspamd ships with out of the box include:</p>



<ul class="wp-block-list">
<li><strong>Spamhaus ZEN</strong>: the OG combined list.</li>
<li><strong>SORBS</strong>: Spam and Open Relay Blocking System, run by Proofpoint.</li>
<li><strong>SpamCop</strong>: community-reported spam sources.</li>
<li><strong>Barracuda BRBL</strong>: corporate-grade list.</li>
<li><strong>URIBL</strong>, <strong>SURBL</strong>: these list <em>domains</em> in URLs found inside spam bodies, not IPs. Brilliant trick.</li>
</ul>



<p class="wp-block-paragraph">And the opposite: <strong>whitelists</strong> (DNSWLs, like dnswl.org) list trusted senders, your bank, mailing lists, Google, etc., so rspamd can give them a bonus score and never flag them by accident.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Greylisting, the velvet-rope nightclub trick</h2>



<p class="wp-block-paragraph">This one is genius and so simple it makes me giggle every time. When a brand-new sender shows up that rspamd has never seen, it says: &#8220;Sorry, server&#8217;s busy, try again in 5 minutes.&#8221;</p>



<p class="wp-block-paragraph">Real mail servers retry, that is literally what SMTP is designed to do. Five minutes later the email arrives, gets delivered, the sender is whitelisted forever. <em>Spam-sending botnets</em>, on the other hand, fire-and-forget, they have a million addresses to hit and no time to retry. They never come back. Bye, Felicia. This single trick blocks an enormous percentage of low-effort spam at essentially zero cost.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The hash-sharing networks: Pyzor, Razor, DCC</h2>



<p class="wp-block-paragraph">Now we get to my favourite chapter, collaborative anti-spam. Picture this: a million people get the same scammy email. If one person reports it, everyone benefits. That is the whole idea behind these three legendary hash-sharing networks, and rspamd talks to all of them.</p>



<h3 class="wp-block-heading">Pyzor</h3>



<p class="wp-block-paragraph"><strong>Pyzor</strong> is a Python-based, open-source collaborative spam database. It takes the body of every email, calculates a &#8220;digest&#8221; (a normalised hash that survives small changes, typo fixes, name substitutions), and looks it up against a public server. If thousands of other people have already received the same body and reported it as spam, Pyzor returns a high &#8220;count&#8221; and rspamd adds points. Crowd-sourced trust. Honestly, beautiful.</p>



<h3 class="wp-block-heading">Razor (Vipul&#8217;s Razor)</h3>



<p class="wp-block-paragraph">Created by a guy named Vipul Ved Prakash in the early 2000s and now run by Cloudmark, <strong>Razor</strong> does the same trick but with a smarter fingerprinting algorithm called &#8220;Nilsimsa.&#8221; Nilsimsa is a &#8220;locality-sensitive hash&#8221;, meaning emails that are <em>almost</em> the same produce <em>almost</em> the same hash. So spammers who randomise a few words to dodge filters? Razor still catches them. Take that, Brad.</p>



<h3 class="wp-block-heading">DCC</h3>



<p class="wp-block-paragraph"><strong>DCC</strong> stands for Distributed Checksum Clearinghouses. Created in 2000 by Rhyolite Software, DCC is the biggest of the three by volume. It works by counting how many copies of an email have been seen across all participating servers, bulk mail is suspicious by definition. Even if every recipient consented (a legit newsletter), DCC will see &#8220;5 million copies sent, 0 reported as spam&#8221; and let it through. The wisdom of the crowd at internet scale.</p>



<h3 class="wp-block-heading">OLEFY, the macro-malware detector</h3>



<p class="wp-block-paragraph">Last but not least: <strong>OLEFY</strong>. This one is a special-purpose tool that scans Microsoft Office attachments (the OLE file format, .doc, .xls, the old binary formats your boomer uncle still uses) for malicious macros. It is built on top of <a href="https://github.com/decalage2/oletools" target="_blank" rel="noopener">oletools</a> and tells rspamd: &#8220;this Word document contains a VBA macro that auto-runs PowerShell on open.&#8221; That is a &#8220;burn the email&#8221; signal if I have ever seen one. Rspamd happily complies.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">SPF, DKIM, DMARC, ARC, the alphabet soup of trust</h2>



<p class="wp-block-paragraph">Rspamd also runs all the modern email-authentication checks:</p>



<ul class="wp-block-list">
<li><strong>SPF</strong> (Sender Policy Framework): asks: &#8220;Is this IP actually allowed to send mail for this domain?&#8221; Domain owners publish a list in DNS. Rspamd checks it.</li>
<li><strong>DKIM</strong> (DomainKeys Identified Mail): the sending server cryptographically signs the email. Rspamd verifies the signature against the public key in DNS. If it has been tampered with, BOOM, points.</li>
<li><strong>DMARC</strong>: combines SPF and DKIM, and tells receivers what to do if both fail (&#8220;reject&#8221;, &#8220;quarantine&#8221;, &#8220;report&#8221;). It is the policy layer.</li>
<li><strong>ARC</strong> (Authenticated Received Chain): for forwarded mail. Lets each hop add its own signature so the chain of trust survives. Beautifully nerdy.</li>
</ul>



<p class="wp-block-paragraph">Together, these four are the reason a spammer can no longer trivially forge &#8220;from: <span style="display:inline;" class="">ce&#111;&#64;y&#111;u&#114;&#99;&#111;&#109;pany.c&#111;&#109;</span>.&#8221; Rspamd uses all of them. The result? Phishing that pretends to be from your bank gets caught at the door.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The web UI, and yes, it is genuinely cute</h2>



<p class="wp-block-paragraph">Most mail tools are aggressively ugly. SpamAssassin&#8217;s &#8220;interface&#8221; was a config file. Postfix is just logs. Rspamd, on the other hand, ships with a <strong>full web interface</strong> at <code>http://localhost:11334</code> that shows real-time stats, lets you scan an email by pasting it into a textbox, train Bayes by clicking buttons, view history, edit symbols and scores, and visualise traffic. It is genuinely pleasant. The first time I saw it I emailed Vsevolod a thank-you. I am not exaggerating.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Installing rspamd on Debian/Ubuntu (the easy version)</h2>



<p class="wp-block-paragraph">If you already set up <a href="/2026/05/postfix-dovecot-setup-debian/">our Postfix + Dovecot guide</a>, adding rspamd is a 5-minute job:</p>



<pre class="wp-block-preformatted"><code># Add the official rspamd repository
apt install -y lsb-release wget gpg
wget -O- https://rspamd.com/apt-stable/gpg.key | \
  gpg --dearmor &gt; /etc/apt/keyrings/rspamd.gpg
echo "deb [signed-by=/etc/apt/keyrings/rspamd.gpg] \
  http://rspamd.com/apt-stable/ $(lsb_release -cs) main" \
  &gt; /etc/apt/sources.list.d/rspamd.list

apt update &amp;&amp; apt install -y rspamd redis-server

# Plug it into Postfix as a milter
postconf -e "smtpd_milters = inet:localhost:11332"
postconf -e "milter_protocol = 6"
postconf -e "milter_mail_macros = i {auth_type} {auth_authen}"
postconf -e "milter_default_action = accept"

systemctl restart rspamd postfix</code></pre>



<p class="wp-block-paragraph">That&#8217;s it. Open <code>http://your-server:11334</code>, set a password in <code>/etc/rspamd/local.d/worker-controller.inc</code>, and you have a fully working modern spam filter with Bayes, neural networks, all the major RBLs, SPF/DKIM/DMARC, Pyzor/Razor/DCC (after a small extra install), and OLEFY. The defaults are sane. Honestly, this is the most &#8220;it just works&#8221; piece of mail-server software I have ever installed.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is rspamd really better than SpamAssassin?</h3>


<p class="wp-block-paragraph">For modern workloads, yes, by a country mile. Rspamd is written in C with Lua plugins; SpamAssassin is Perl. On the same hardware, rspamd is typically 5–10× faster, uses less memory, has built-in neural-network support, and a real web UI. SpamAssassin still has the bigger rule community, but rspamd has caught up and is now the default in iRedMail, Mailcow, Mail-in-a-Box, and most modern mail stacks.</p>



<h3 class="wp-block-heading">Do I have to train the Bayesian classifier myself?</h3>


<p class="wp-block-paragraph">Yes, eventually, but only a little. Rspamd&#8217;s defaults catch 90% of obvious spam without any training. Train Bayes when you have ~200 messages each of ham and spam to feed it, and accuracy will climb above 99%. You can train via the web UI, by piping messages to <code>rspamc learn_spam</code>, or by configuring Dovecot to auto-train when you drag an email into the Junk folder. The third option is the chef&#8217;s kiss.</p>



<h3 class="wp-block-heading">Will rspamd ever quarantine legitimate email by accident?</h3>


<p class="wp-block-paragraph">Out of the box, false positives are very rare, somewhere around 0.1% in our experience. The scoring system is forgiving (you have to cross multiple thresholds to be rejected), and DNSWL/whitelists protect the big legitimate senders. If you do get a false positive, you can retrain Bayes against it, add the sender to a local whitelist, or lower a specific symbol&#8217;s weight in <code>/etc/rspamd/local.d/</code>.</p>



<h3 class="wp-block-heading">How much RAM does rspamd need?</h3>


<p class="wp-block-paragraph">For a small home or hobby server, 512 MB is plenty. For a busy mailing-list operator handling a million emails a day, plan for 2–4 GB plus Redis. Compared to SpamAssassin&#8217;s classic ~150 MB per spamd child process, rspamd&#8217;s memory model is dramatically more efficient because it uses async I/O and a fixed number of workers.</p>



<h3 class="wp-block-heading">Does rspamd help with outgoing spam too?</h3>


<p class="wp-block-paragraph">Yes, and this is hugely underrated. You can configure rspamd to scan outbound mail as well, which catches compromised accounts on your server before they ruin your reputation and get you on Spamhaus. A single hacked WordPress account can blacklist your entire domain in 24 hours. Outbound scanning is the seatbelt for that scenario. Set <code>actions = { reject = 15; soft_reject = "Rate limit exceeded"; }</code> and you are golden.</p>



<h3 class="wp-block-heading">Can I use rspamd without Redis?</h3>


<p class="wp-block-paragraph">Technically yes, but you&#8217;d be giving up Bayesian classifier persistence, greylisting state, neural-network learning, and rate-limiting. Just install Redis. It is one apt command. Or even better, install <a href="/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/">our hardened Valkey package</a>, Redis-compatible, BSD-licensed, and faster.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Related reading</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/postfix-dovecot-setup-debian/"><strong>Postfix + Dovecot Mail Server Setup on Debian 12 and 13 (2026 Guide)</strong></a>: the full mail-server stack rspamd plugs into. Start here if you do not have a working Postfix yet.</li>
<li><a href="/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/"><strong>Valkey Explained: The Redis Fork That Actually Won</strong></a>: what to use as rspamd&#8217;s key-value backend in 2026. Faster, BSD-licensed, and packaged on deb.myguard.nl.</li>
<li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/"><strong>How to Install ModSecurity and OWASP CRS on NGINX</strong></a>: rspamd defends mail, ModSecurity defends the web. Both belong on the same server.</li>
<li><a href="/2026/05/wordpress-hardening-plugin-modsecurity-crs-block-attacks/"><strong>WordPress Hardening Plugin for ModSecurity CRS</strong></a>: stop the compromised-WordPress-account-spams-the-world scenario before it starts.</li>
</ul>



<p class="wp-block-paragraph">That is your evening. Pour another glass, install rspamd, watch the spam disappear. You can thank Vsevolod and Reverend Bayes later. 💌</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Valkey Explained: The Redis Fork That Actually Won (And Why Our Debian Package Is Worth It)</title>
		<link>https://deb.myguard.nl/2026/05/valkey-explained-redis-fork-debian-ubuntu-package/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Wed, 20 May 2026 19:53:30 +0000</pubDate>
				<category><![CDATA[Database]]></category>
		<category><![CDATA[caching]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[redis]]></category>
		<category><![CDATA[ubuntu]]></category>
		<category><![CDATA[valkey]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5735</guid>

					<description><![CDATA[Valkey is the BSD-licensed, Linux Foundation-backed fork of Redis — and as of 2026 it has overtaken Redis itself. Here is what Valkey is, why it exists, and why our hardened deb.myguard.nl build is the smartest way to install it on Debian or Ubuntu.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Okay friend, pull up a chair. We need to talk about <strong>Valkey</strong>, the open-source, free-forever, Redis-compatible key-value store that has quietly become the default cache and session store for serious Linux servers in 2026. If your brain just slid off the words &#8220;key-value store,&#8221; don&#8217;t worry. By the end of this post you&#8217;ll know exactly what Valkey is, why it exists, why our build of it on <a href="/">deb.myguard.nl</a> is worth using over the one in stock Debian, and how to install it in under sixty seconds. No prior IT experience required. Yes, really.</p>



<h2 class="has-text-color wp-block-heading" style="color:#f59e0b">What is Valkey, in plain English?</h2>



<p class="wp-block-paragraph">Imagine your website is a busy coffee shop. Every time someone orders a latte, your barista (the database) has to grind beans, steam milk, foam, pour, and add the little heart on top. That&#8217;s slow. Now imagine you keep a tray of pre-made lattes on the counter, when someone orders one, you just hand it over. That tray is a <em>cache</em>, and <strong>Valkey is the tray</strong>. It holds frequently-needed data in memory so your apps don&#8217;t have to go grind the beans every time.</p>



<p class="wp-block-paragraph">Technically: Valkey is an in-memory, network-accessible key-value database. You give it a key (&#8220;user:42:cart&#8221;), it gives you back the value (&#8220;3 lattes, 1 muffin&#8221;). It&#8217;s blisteringly fast, a single instance comfortably handles 100,000+ operations per second on a modest VPS. It speaks the Redis wire protocol, so anything that talks to Redis talks to Valkey unchanged. Drop it in, walk away.</p>



<h2 class="has-text-color wp-block-heading" style="color:#f59e0b">Wait, isn&#8217;t this just Redis?</h2>



<p class="wp-block-paragraph">This is where it gets juicy. In March 2024, the company that owns Redis suddenly changed the licence from open-source BSD to a more restrictive &#8220;source-available&#8221; licence (SSPL / RSALv2). Translation: free to use, but legally hostile to anyone running it as a service for customers. The open-source community did what the open-source community always does when this happens, they forked.</p>



<p class="wp-block-paragraph">Within a week, the Linux Foundation, AWS, Google Cloud, Oracle, Ericsson, and Snap had launched <strong>Valkey</strong>: a clean fork of the last freely-licensed Redis (7.2.4), with the original BSD-3-Clause licence intact, governed by a vendor-neutral foundation. Debian, Ubuntu, Fedora, and Alpine all switched their default &#8220;redis&#8221; package to Valkey within months. AWS ElastiCache replaced Redis with Valkey on managed services. By the time Redis tried to walk back the licence change in 2025, Valkey already had more contributors than Redis itself.</p>



<p class="wp-block-paragraph">So when you install <code>valkey</code> in 2026, you&#8217;re not getting some hobbyist experiment. You&#8217;re getting the actual, mainstream, foundation-backed continuation of Redis, with bug fixes, performance work, and security patches that Redis never received. It&#8217;s Redis, minus the corporate drama.</p>



<h2 class="has-text-color wp-block-heading" style="color:#f59e0b">What people actually use Valkey for</h2>



<ul class="wp-block-list">
<li><strong>WordPress object cache.</strong> Plugins like Redis Object Cache, W3 Total Cache, and LiteSpeed Cache all talk to Valkey unchanged. The result: 50-90&nbsp;% fewer database queries on every page load, and a noticeably snappier admin dashboard. If you&#8217;ve ever clicked &#8220;Save&#8221; in WordPress and waited an awkward two seconds, Valkey kills that.</li>
<li><strong>Session storage.</strong> PHP, Node.js, Rails, Django: all of them can keep user sessions in Valkey instead of files. That means session data survives across multiple web servers (essential for load balancing) and doesn&#8217;t fill up your disk with millions of one-kilobyte files.</li>
<li><strong>Rate limiting.</strong> &#8220;Don&#8217;t let this IP make more than 60 API requests per minute.&#8221; Valkey does this in a single atomic operation. Cloudflare-grade rate limiting on your own server, basically free.</li>
<li><strong>Job queues.</strong> Background tasks (sending email, processing uploads, generating thumbnails) go onto a Valkey list, a worker pulls them off. Sidekiq, BullMQ, RQ, Laravel Horizon: they all run on Valkey.</li>
<li><strong>Leaderboards and counters.</strong> Sorted sets in Valkey let you maintain &#8220;top 100 most active users today&#8221; with one command per update. Used by every gaming site you&#8217;ve ever loved.</li>
<li><strong>Pub/sub messaging.</strong> A lightweight message bus between microservices, without dragging in Kafka or RabbitMQ.</li>
</ul>



<h2 class="has-text-color wp-block-heading" style="color:#f59e0b">Why use <em>our</em> Valkey package and not the stock Debian one?</h2>



<p class="wp-block-paragraph">Debian&#8217;s Valkey package is fine. It&#8217;s also frozen, once Debian 13 (Trixie) shipped, the Valkey version baked into it is the version you&#8217;ll have for the next two years unless you do something about it. Meanwhile upstream Valkey keeps shipping releases with real performance wins and security fixes every few weeks.</p>



<p class="wp-block-paragraph">Our build at <a href="/">deb.myguard.nl</a> tracks upstream <strong>Valkey 9.1.0</strong> (the current stable release as of May 2026) and rebuilds within hours of every new tag. But that&#8217;s only half the story, the package itself ships a bunch of operational improvements that the Debian package doesn&#8217;t have:</p>



<h3 class="wp-block-heading">1. Compiled for performance, not lowest-common-denominator</h3>



<p class="wp-block-paragraph">We build Valkey with <strong>Link-Time Optimization (LTO)</strong>, <strong>-O3</strong>, <strong>-fno-plt</strong>, and <strong>-fno-semantic-interposition</strong>. In plain English: the compiler is allowed to inline aggressively across translation units, skip a layer of indirection on every function call, and reorder code for the modern CPUs you&#8217;re actually running on. The throughput gain over a stock <code>-O2</code> build is real, typically 5-12&nbsp;% on the standard <code>valkey-benchmark</code> SET/GET workload, sometimes more on pipelined operations.</p>



<h3 class="wp-block-heading">2. Compiled for security, with FORTIFY_SOURCE level 3</h3>



<p class="wp-block-paragraph">Every Debian hardening flag is on (<code>hardening=+all</code>: stack protector, stack-clash protection, RELRO, BIND_NOW, PIE, control-flow integrity), and we go one further by bumping <code>_FORTIFY_SOURCE</code> from 2 to <strong>3</strong>. That enables additional compile-time and runtime checks on memory functions like <code>memcpy</code>, <code>strcpy</code>, and friends. A buffer overflow that would silently corrupt memory becomes an immediate, loud, abort-the-process crash, which is exactly what you want, because a crash is a bug report, but silent memory corruption is a data breach waiting to happen.</p>



<h3 class="wp-block-heading">3. A fully hardened systemd unit out of the box</h3>



<p class="wp-block-paragraph">Most database services run with way too many privileges. Our valkey-server.service ships with the full systemd sandbox switched on: <code>NoNewPrivileges=true</code>, <code>ProtectSystem=strict</code>, <code>ProtectKernelTunables/Modules/Logs</code>, <code>MemoryDenyWriteExecute=true</code>, <code>RestrictAddressFamilies</code> limited to AF_INET/AF_INET6/AF_UNIX, an empty <code>CapabilityBoundingSet</code>, <code>SystemCallFilter=@system-service</code>, and <code>NoExecPaths=/</code> (so the daemon literally cannot execute any binary other than itself). If somebody finds a remote code execution in Valkey tomorrow, the blast radius on our package is &#8220;they get to run code as the valkey user inside a near-empty namespace with no network families they don&#8217;t already have.&#8221; On a stock build it&#8217;s &#8220;they get a shell.&#8221;</p>



<h3 class="wp-block-heading">4. A drop-in <code>/etc/valkey/conf.d/</code> directory</h3>



<p class="wp-block-paragraph">The main <code>valkey.conf</code> is a 2,000-line conffile and you don&#8217;t want to edit it. Every time you do, the next package upgrade fights you over conffile changes. Our package ships an <code>include /etc/valkey/conf.d/*.conf</code> line in the main config, plus an empty drop-in directory. Want to set <code>maxmemory 2gb</code> and switch the eviction policy? Drop a one-line file in <code>/etc/valkey/conf.d/10-memory.conf</code> and reload. Survives upgrades, easy to manage with Ansible/Salt/Puppet, easy to diff in version control.</p>



<h3 class="wp-block-heading">5. An AppArmor profile (when AppArmor is available)</h3>



<p class="wp-block-paragraph">Belt and braces alongside the systemd sandbox: a Mandatory Access Control profile at <code>/etc/apparmor.d/usr.bin.valkey-server</code> that confines reads to <code>/etc/valkey</code> and writes to <code>/var/lib/valkey</code> / <code>/var/log/valkey</code> / <code>/run/valkey</code>. Loaded automatically on systems that support it, silently skipped in containers that don&#8217;t. You get defense in depth on Ubuntu (where AppArmor is on by default) without any breakage on hosts without it.</p>



<h3 class="wp-block-heading">6. Sane kernel tuning, applied automatically</h3>



<p class="wp-block-paragraph">Valkey emits two warnings on every fresh install on a stock kernel: &#8220;vm.overcommit_memory is not set to 1&#8221; and &#8220;the TCP backlog setting was lowered to 128 because /proc/sys/net/core/somaxconn is set to the lower value.&#8221; Annoying, and they actually do affect throughput. We ship <code>/usr/lib/sysctl.d/30-valkey-server.conf</code> that fixes both. Applied automatically by <code>systemd-sysctl</code> on next boot, or by the postinst when you&#8217;re inside a Docker container without systemd.</p>



<h3 class="wp-block-heading">7. A built-in healthcheck binary</h3>



<p class="wp-block-paragraph">We ship <code>/usr/bin/valkey-healthcheck</code>, a tiny wrapper that returns exit code 0 if the server replies to PING, 1 if it doesn&#8217;t. Wire it into a Docker <code>HEALTHCHECK</code>, a Kubernetes liveness probe, a Nagios/Icinga check, a systemd watchdog, anything that wants a binary that says &#8220;yes the service works&#8221; or &#8220;no it doesn&#8217;t.&#8221; Honours the same env vars as <code>valkey-cli</code> (host, port, TLS, auth), so it works regardless of how exotic your deployment is.</p>



<h3 class="wp-block-heading">8. Works perfectly inside Docker (without systemd or AppArmor)</h3>



<p class="wp-block-paragraph">All of the above is built so the package installs cleanly inside a minimal Debian/Ubuntu container. No hard dependency on systemd, no hard dependency on AppArmor, no failed postinst when those subsystems aren&#8217;t around. The systemd unit and the AppArmor profile become inert files, present but not enforced, and the sysctl drop-in is applied at install time instead of at boot. Same package, two deployment models, zero ceremony.</p>



<h2 class="has-text-color wp-block-heading" style="color:#f59e0b">How to install Valkey from our repository</h2>



<p class="wp-block-paragraph">If you haven&#8217;t added <a href="/how-to-use/">the deb.myguard.nl repository</a> yet, that takes about ninety seconds, there&#8217;s a one-page setup guide that handles the GPG key and APT source. Once that&#8217;s in:</p>



<pre class="wp-block-preformatted">sudo apt update
sudo apt install valkey-server valkey-tools

# Enable on boot (skip inside Docker)
sudo systemctl enable --now valkey-server

# Check it answers
valkey-healthcheck &amp;&amp; echo "Valkey is up"</pre>



<p class="wp-block-paragraph">That&#8217;s it. You now have a hardened, performance-tuned, foundation-backed Redis replacement listening on <code>127.0.0.1:6379</code>.</p>



<h2 class="has-text-color wp-block-heading" style="color:#f59e0b">Migrating from Redis: how painful is it?</h2>



<p class="wp-block-paragraph">Pretty close to zero pain. Valkey speaks the same wire protocol as Redis, ships the same commands, returns the same replies. RDB and AOF persistence files from Redis 7.2 load unchanged. The CLI tool is <code>valkey-cli</code> instead of <code>redis-cli</code>, but both are symlinked on most distros, and they accept identical arguments.</p>



<p class="wp-block-paragraph">Practical migration recipe: stop Redis, copy <code>dump.rdb</code> to <code>/var/lib/valkey/dump.rdb</code>, <code>chown valkey:valkey</code> it, start Valkey, point your app at <code>localhost:6379</code> instead of wherever Redis was. Done. PHP&#8217;s <code>phpredis</code> extension, Python&#8217;s <code>redis-py</code>, Node.js&#8217; <code>ioredis</code>, Go&#8217;s <code>go-redis</code>, all of them connect to Valkey without a single code change.</p>



<h2 class="has-text-color wp-block-heading" style="color:#f59e0b">A WordPress-specific recipe</h2>



<p class="wp-block-paragraph">Since most readers of this site run WordPress, here&#8217;s the exact recipe to turn Valkey into a WordPress object cache. Install Valkey as above, then drop this into <code>/etc/valkey/conf.d/10-wordpress.conf</code>:</p>



<pre class="wp-block-preformatted">maxmemory 256mb
maxmemory-policy allkeys-lru
save ""
appendonly no</pre>



<p class="wp-block-paragraph">That tells Valkey: use up to 256&nbsp;MB of RAM, evict the least-recently-used keys when full, and don&#8217;t bother persisting to disk (it&#8217;s a cache, if it dies, WordPress just rebuilds it from MySQL). Restart with <code>systemctl restart valkey-server</code>, install the <a href="https://wordpress.org/plugins/redis-cache/" target="_blank" rel="noopener">Redis Object Cache plugin</a> in WordPress, click &#8220;Enable Object Cache.&#8221; Done. Your admin dashboard just got 3-5× faster, and the MySQL load on your server dropped by half.</p>



<h2 class="has-text-color wp-block-heading" style="color:#f59e0b">Frequently Asked Questions</h2>



<h3 class="wp-block-heading">Is Valkey really free? Like, free-free?</h3>



<p class="wp-block-paragraph">Yes. BSD 3-Clause licensed, governed by the Linux Foundation, with no contributor licence agreement that hands rights to a corporation. You can use it commercially, modify it, redistribute it, run it as a service, embed it in proprietary products. The whole point of the fork was to keep it that way permanently.</p>



<h3 class="wp-block-heading">Should I uninstall Redis if I have it?</h3>



<p class="wp-block-paragraph">Eventually, yes. Both can coexist (different default ports if needed), but there&#8217;s no reason to run both long-term. Migrate the data over, point apps at Valkey, then remove the Redis package. If you&#8217;re on Debian 13 or Ubuntu 24.04+ you may notice <code>apt</code> already wants to replace Redis with Valkey on the next upgrade, that&#8217;s the distribution doing the same migration for you.</p>



<h3 class="wp-block-heading">How much RAM does Valkey need?</h3>



<p class="wp-block-paragraph">Whatever you tell it. For a WordPress object cache, 128-256&nbsp;MB is plenty. For a session store on a busy site, 512&nbsp;MB-1&nbsp;GB. For a primary database with millions of keys, size it to fit your working set in memory. The base process itself uses about 5&nbsp;MB, almost nothing. Always set <code>maxmemory</code>; never let Valkey grow unbounded.</p>



<h3 class="wp-block-heading">Does Valkey lose data if the server reboots?</h3>



<p class="wp-block-paragraph">By default it snapshots to disk every few minutes (RDB persistence) and optionally appends every write to an AOF log for crash safety. You can disable persistence entirely (good for pure caches), enable both (good for primary databases). The trade-off is durability vs. write performance, and it&#8217;s a single config flag away in either direction.</p>



<h3 class="wp-block-heading">Is Valkey faster than Redis?</h3>



<p class="wp-block-paragraph">In raw single-threaded SET/GET, they&#8217;re within noise of each other, they share an ancestor. Where Valkey pulled ahead is in <strong>multi-threaded I/O</strong> (enabled by default in 9.0+, on by default on multi-core systems) and in the steady stream of optimizations the wider contributor base has been merging. On the same hardware, a recent Valkey will out-throughput a Redis 7.2 by 20-40&nbsp;% on multi-connection workloads. Our build with <code>-O3</code> and LTO adds a few more percent on top.</p>



<h3 class="wp-block-heading">What&#8217;s the catch?</h3>



<p class="wp-block-paragraph">Honestly, none for most use cases. If you used a Redis Enterprise feature (Redis Stack modules like RedisJSON, RedisSearch, RedisBloom) then those modules were proprietary and aren&#8217;t in Valkey core. The good news: Valkey has its own first-party equivalents (valkey-json, valkey-search, valkey-bloom) which are now stable and shipping. We&#8217;ll likely add packages for those next.</p>



<h2 class="has-text-color wp-block-heading" style="color:#f59e0b">Related reading</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX Configuration: PHP-FPM Tuning, FastCGI Cache and Redis (2026 Guide)</a>: the full stack guide that puts Valkey (still named &#8220;Redis&#8221; in the plugin world) into context for WordPress hosting.</li>
<li><a href="/2026/05/database-boost-free-wordpress-database-optimization-plugin/">Database Boost: Free WordPress Database Optimization Plugin</a>: pairs nicely with Valkey object caching to keep MySQL lean.</li>
<li><a href="/how-to-use/">How to Add the myguard APT Repository</a>: the ninety-second setup for the repo this package lives in.</li>
<li><a href="/packages/">Full package catalogue</a>: every package we ship, with one-line descriptions.</li>
</ul>



<p class="wp-block-paragraph" style="margin-top:2rem;font-size:14px;color:var(--muted);">Valkey is the future of self-hosted in-memory caching on Linux. Our build makes it faster, safer, and easier to operate than anything else in the Debian/Ubuntu ecosystem, and it stays out of your way whether you&#8217;re running it on bare metal, in a VM, or inside a Docker container. Install once, forget about it for years.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Self-Hosted Vaultwarden: Docker Setup, Clients &#038; Full Guide</title>
		<link>https://deb.myguard.nl/2026/05/self-hosted-password-manager-with-vaultwarden/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Wed, 20 May 2026 11:48:11 +0000</pubDate>
				<category><![CDATA[Docker]]></category>
		<category><![CDATA[bitwarden]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[nginx]]></category>
		<category><![CDATA[password manager]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[selfhosted]]></category>
		<category><![CDATA[vaultwarden]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5726</guid>

					<description><![CDATA[Run your own password manager with self-hosted Vaultwarden — a tiny Docker image, full Bitwarden client compatibility, and total control over your encrypted vault.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Let&#8217;s talk about something that affects every single person reading this: <strong>passwords</strong>. You&#8217;ve got hundreds of them. Your Netflix, your bank, that random forum you signed up for in 2014, your work email, your kid&#8217;s school portal. And if you&#8217;re being honest, you&#8217;re probably reusing the same three or four with slight variations. Yes, really. I know, I know, don&#8217;t worry, you&#8217;re in good company. But what if I told you there&#8217;s a way to never type a password again, never reuse one again, and keep them all safe on a server <em>you</em> own? That&#8217;s where <strong>self-hosted Vaultwarden</strong> comes in. Want to skip ahead and just <em>see</em> one in action? Have a peek at <a href="https://vault.myguard.nl" target="_blank" rel="noopener">vault.myguard.nl</a>, that&#8217;s a real, live, hardened Vaultwarden instance running on the same stack we&#8217;re about to build. Vaultwarden is the lightweight Rust rewrite of the <a href="https://bitwarden.com/" rel="noopener" target="_blank">Bitwarden</a> server, fully compatible with the official apps.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/self-hosted-vaultwarden.webp" alt="Self-hosted Vaultwarden password manager running in Docker" width="1024" height="576" loading="lazy"/></figure>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1600" height="1068" src="https://deb.myguard.nl/wp-content/uploads/2026/05/selfhosted-vaultwarden-docker-password-manager.webp" alt="Selfhosted Vaultwarden Docker password manager dashboard" class="wp-image-5724" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/selfhosted-vaultwarden-docker-password-manager.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/selfhosted-vaultwarden-docker-password-manager-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/selfhosted-vaultwarden-docker-password-manager-1024x684.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/selfhosted-vaultwarden-docker-password-manager-768x513.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/selfhosted-vaultwarden-docker-password-manager-1536x1025.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /></figure>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">What Is Vaultwarden, in Plain English?</h2>



<p class="wp-block-paragraph">Imagine a tiny, very polite digital butler whose entire job is to remember every password you&#8217;ve ever made, autofill them when you visit the right website, and gently scold you when you try to reuse one. That&#8217;s a password manager. The most famous one is <strong>Bitwarden</strong>, it&#8217;s open-source, audited, and trusted by millions. But the official Bitwarden server is a beast: it needs Microsoft SQL Server, multiple containers, lots of RAM, and frankly a degree in patience to keep running on your own hardware.</p>



<p class="wp-block-paragraph"><strong>Vaultwarden</strong> is the lightweight, community-built alternative server. It speaks the same language as Bitwarden, meaning every official Bitwarden app, browser extension, and CLI tool works with it perfectly, but it&#8217;s written in <em>Rust</em>, fits in a single tiny Docker container, sips about 50 MB of RAM, and runs happily on a Raspberry Pi, a cheap VPS, or that old laptop gathering dust in your closet. It used to be called &#8220;Bitwarden_RS,&#8221; but the name was changed (politely, at the official Bitwarden company&#8217;s request) to avoid confusion. Same engine, different badge.</p>



<p class="wp-block-paragraph">The project is fully open-source under the AGPLv3 license and developed in the open on GitHub. If you want to dig deeper, star the repo, read the source, or file a bug, here are the official links:</p>



<ul class="wp-block-list">
<li><strong>GitHub repository:</strong> <a href="https://github.com/dani-garcia/vaultwarden" rel="noopener" target="_blank">github.com/dani-garcia/vaultwarden</a>: source code, releases, issues.</li>
<li><strong>Project wiki:</strong> <a href="https://github.com/dani-garcia/vaultwarden/wiki" rel="noopener" target="_blank">github.com/dani-garcia/vaultwarden/wiki</a>: the definitive documentation, with deep dives on every config flag, reverse-proxy examples, backup recipes, and fail2ban jails.</li>
<li><strong>Docker Hub image:</strong> <a href="https://hub.docker.com/r/vaultwarden/server" rel="noopener" target="_blank">hub.docker.com/r/vaultwarden/server</a>: the official container we&#8217;ll be pulling.</li>
<li><strong>Bitwarden (the upstream protocol &amp; clients):</strong> <a href="https://bitwarden.com" rel="noopener" target="_blank">bitwarden.com</a>: the company whose API Vaultwarden re-implements.</li>
</ul>



<p class="wp-block-paragraph">So when we say <strong>selfhosted Vaultwarden</strong>, we mean: <em>you</em> run the server, <em>you</em> own the encrypted vault, <em>you</em> control who can sign up, and nobody, not Bitwarden Inc., not Google, not your nosy ISP, gets to peek at your data. The vault is end-to-end encrypted on your device <em>before</em> it ever touches the server. Even <em>you</em>, the server admin, can&#8217;t read your users&#8217; passwords. That&#8217;s the magic.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Everything Is Encrypted Server-Side, Even the Admin Can&#8217;t See It</h2>



<p class="wp-block-paragraph">Read this section twice. This is the single most important thing to understand about Vaultwarden, and the reason it&#8217;s safe to self-host in the first place.</p>



<p class="wp-block-paragraph">When you type your master password into the Bitwarden client, that password <em>never leaves your device</em>. Instead, the client uses it (via <strong>PBKDF2</strong> with 600,000 iterations, or <strong>Argon2id</strong> in newer setups) to derive an encryption key. That key encrypts every single field in your vault, passwords, notes, TOTP secrets, attachments, passkeys, the lot, using <strong>AES-256-CBC with an HMAC-SHA256</strong> integrity check. Only the <em>encrypted blob</em> is sent up to the server.</p>



<p class="wp-block-paragraph">What does this mean in practice?</p>



<ul class="wp-block-list">
<li><strong>The server stores ciphertext only.</strong> If you peek at the Vaultwarden SQLite database with <code>sqlite3 /data/db.sqlite3</code>, you&#8217;ll see rows of base64 gibberish like <code>2.aGsmaiq…|wQjT…|3kPq…</code>. That&#8217;s it. No readable passwords, no readable URLs, nothing.</li>
<li><strong>The administrator (you, or whoever runs the server) cannot read anyone&#8217;s vault.</strong> Not yours. Not your family&#8217;s. Not a colleague&#8217;s. The server literally lacks the key. This is called <em>zero-knowledge</em> or <em>end-to-end encryption</em>, and it&#8217;s the same model as Signal, ProtonMail, and the official Bitwarden cloud.</li>
<li><strong>A full server compromise leaks ciphertext, not plaintext.</strong> Even if an attacker dumps the database, the volume, and the RAM, they walk away with encrypted blobs and a hashed authentication token. They&#8217;d still need to brute-force every individual user&#8217;s master password to read anything: and with a strong passphrase that&#8217;s astronomically expensive.</li>
<li><strong>The <code>/admin</code> panel can manage users, invitations, and config: but cannot view vault contents.</strong> It&#8217;s an ops console, not a backdoor.</li>
</ul>



<p class="wp-block-paragraph">This is genuinely brilliant <em>and</em> genuinely terrifying. Because of zero-knowledge encryption, there is exactly one rule that follows from it, and it has no exceptions:</p>



<p class="wp-block-paragraph"><strong>If you forget your master password, your vault is gone. Forever. There is no recovery.</strong></p>



<p class="wp-block-paragraph">Let me be very clear about what that means. There is no &#8220;forgot password&#8221; email. There is no admin override. There is no support ticket that ends with someone resetting it for you. The server can&#8217;t help you, because <em>the server cannot read your vault</em>. That&#8217;s the whole point. If it could help you, it could help an attacker too.</p>



<p class="wp-block-paragraph">The admin can <em>delete</em> your account and let you start over with an empty vault. They cannot recover the data inside it. Years of saved passwords, TOTP secrets, passkeys, attachments, all of it instantly worthless ciphertext. You are, in the most literal sense, <em>on your own</em>.</p>



<p class="wp-block-paragraph">So before you put a single password into Vaultwarden:</p>



<ul class="wp-block-list">
<li><strong>Write your master password on paper.</strong> A long passphrase like <em>&#8220;correct-horse-battery-staple-violet-piano&#8221;</em>. Put it in a sealed envelope in a safe, or a fireproof box, or with a trusted relative. Yes, paper. Welcome to 2026.</li>
<li><strong>Set up Emergency Access</strong> with a person you&#8217;d trust to inherit your digital life. They request access, you have N days to deny, after that they get a one-time copy.</li>
<li><strong>Generate and store a few recovery codes</strong> if you enable 2FA on the vault: losing 2FA is just as fatal as losing the master password.</li>
<li><strong>Test your backups</strong>. Once a year, restore them to a sandbox container and log in. A backup you&#8217;ve never tested is a hope, not a backup.</li>
</ul>



<p class="wp-block-paragraph">Get those four things right and zero-knowledge encryption is your friend. Skip them and one bad morning, you&#8217;ll be that person on Reddit asking &#8220;is there <em>any</em> way to recover my Vaultwarden?&#8221; The answer will always be no.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Why run self-hosted Vaultwarden at all?</h2>



<p class="wp-block-paragraph">Fair question. The cloud version of Bitwarden is excellent, audited, and either free or absurdly cheap. So why bother running your own?</p>



<ul class="wp-block-list">
<li><strong>Total control.</strong> Your passwords never live on someone else&#8217;s hardware. If a cloud provider gets breached (it happens: ask LastPass), you&#8217;re not in the blast radius.</li>
<li><strong>No subscription fees.</strong> Vaultwarden gives you every paid Bitwarden feature for free: 2FA codes (TOTP), file attachments, organizations, Bitwarden Send, emergency access, the lot.</li>
<li><strong>Privacy.</strong> No telemetry, no analytics, no &#8220;anonymous usage data.&#8221; Just you and your encrypted blob.</li>
<li><strong>Learning.</strong> If you&#8217;re a tinkerer or sysadmin, running Vaultwarden teaches you Docker, reverse proxies, TLS, and backups: all skills that pay rent in the real world.</li>
<li><strong>Family &amp; team sharing.</strong> Spin up an organization, invite the household, share the Wi-Fi password and the Disney+ login. Built-in.</li>
</ul>



<p class="wp-block-paragraph">The trade-off? <em>You</em> are now responsible for uptime, backups, and security. If your server dies and you didn&#8217;t back up, your vault is gone. So read the backup section twice. I&#8217;m not joking.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The Vaultwarden Docker Image: Small, Fast, Friendly</h2>



<p class="wp-block-paragraph">The official <a href="https://hub.docker.com/r/vaultwarden/server" rel="noopener" target="_blank">Vaultwarden Docker image</a> lives at <code>vaultwarden/server</code> on Docker Hub, and is built straight from the <a href="https://github.com/dani-garcia/vaultwarden" rel="noopener" target="_blank">upstream GitHub repository</a>. It&#8217;s a single static Rust binary plus the bundled web vault frontend, weighing in at roughly 180 MB on disk. At runtime it uses 30–80 MB of RAM depending on how many users you have. Compare that to the official Bitwarden self-host stack, which routinely chews through 2–3 GB. It&#8217;s a <em>tiny</em> footprint.</p>



<p class="wp-block-paragraph">The image ships with sensible defaults: SQLite storage (great for small deployments), bundled web vault, optional WebSocket support for live sync, and a built-in admin panel you can enable for managing users. For bigger setups, you can switch the storage backend to MySQL/MariaDB or PostgreSQL, both are supported out of the box.</p>



<h3 class="wp-block-heading">What You Get Out of the Box</h3>



<ul class="wp-block-list">
<li><strong>End-to-end encrypted vault</strong>: passwords, secure notes, identities, credit cards.</li>
<li><strong>TOTP / 2FA codes</strong> built into entries (no need for a separate Authy/Google Authenticator).</li>
<li><strong>File attachments</strong>: store passport scans, license PDFs, encrypted at rest.</li>
<li><strong>Bitwarden Send</strong>: send a password or file to someone via a one-time, self-destructing link.</li>
<li><strong>Organizations &amp; collections</strong>: share vault items with family, teams, or per-project.</li>
<li><strong>Emergency access</strong>: let a trusted person request access to your vault if you&#8217;re, well, hit by a bus.</li>
<li><strong>Admin panel</strong> at <code>/admin</code> for invitations, user management, and config.</li>
<li><strong>Web vault</strong> served right from the same container: no separate frontend container needed.</li>
<li><strong>Yubikey, Duo, and WebAuthn</strong> 2FA support for your login itself.</li>
</ul>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">TOTP, 2FA and Passkeys, The Headline Features</h2>



<p class="wp-block-paragraph">Passwords alone are not enough anymore. Every serious account should have a second factor, and increasingly, no password at all. Vaultwarden is genuinely brilliant at all three: it can <em>store</em> your TOTP codes, <em>protect</em> the vault itself with multiple 2FA methods, and now <em>sync your passkeys</em> across every device. Let&#8217;s unpack each.</p>



<h3 class="wp-block-heading">Built-in TOTP Code Generator (Authenticator Replacement)</h3>



<p class="wp-block-paragraph">TOTP (Time-based One-Time Password) is that six-digit code that changes every 30 seconds, the thing Google Authenticator, Authy, and Microsoft Authenticator show you. The dirty secret? It&#8217;s just a tiny shared secret + the current time, run through HMAC-SHA1. Any app can generate them, including Vaultwarden.</p>



<p class="wp-block-paragraph">When you add a login entry, there&#8217;s a field labelled <strong>Authenticator Key (TOTP)</strong>. Paste the <code>otpauth://</code> URI from a QR code (or just the base32 secret) and from that moment on, the Bitwarden client shows the live 6-digit code right under the username and password, with a little countdown circle. Autofill on the login page fills the password <em>and</em> the TOTP code automatically. No more juggling your phone.</p>



<p class="wp-block-paragraph">Why this is huge:</p>



<ul class="wp-block-list">
<li><strong>One app instead of two.</strong> Bin Authy / Google Authenticator. One unlock, all your codes.</li>
<li><strong>Synced across devices.</strong> Lose your phone? Your codes are safe in your encrypted vault on every other device.</li>
<li><strong>Backed up automatically.</strong> Whatever backs up your vault backs up your TOTP secrets too. (Old-school Authenticator users know that &#8220;I lost my phone&#8221; used to mean &#8220;I lost all my 2FA accounts.&#8221;)</li>
<li><strong>Steam Guard support.</strong> Vaultwarden also generates Steam&#8217;s funky 5-character codes: paste a <code>steam://</code> URI.</li>
</ul>



<p class="wp-block-paragraph">There&#8217;s a mild philosophical argument that storing your password <em>and</em> your 2FA code in the same vault defeats some of the point of 2FA. That&#8217;s true if your master password gets brute-forced. In practice, with a strong master password + the vault itself protected by 2FA (next section), the combined security is dramatically higher than &#8220;weak reused password + SMS code.&#8221; Pick your threat model.</p>



<h3 class="wp-block-heading">2FA for the Vault Itself</h3>



<p class="wp-block-paragraph">&#8220;Wait, I can put 2FA <em>on</em> the vault that holds my 2FA codes?&#8221; Yes. And you absolutely should. If someone steals your master password, the 2FA prompt at login is your last line of defence. Vaultwarden supports five different second factors, configured per-user from the web vault under <em>Settings → Two-step Login</em>:</p>



<ul class="wp-block-list">
<li><strong>Authenticator app (TOTP):</strong> the classic: scan a QR with any TOTP app. Use a <em>different</em> app than Vaultwarden itself (e.g. your phone&#8217;s built-in iOS/Android authenticator), otherwise you&#8217;ve locked yourself out if the vault is down.</li>
<li><strong>Email codes:</strong> a 6-digit code mailed to you on login. Requires SMTP configured in the container (<code>SMTP_HOST</code>, <code>SMTP_FROM</code>, etc.).</li>
<li><strong>YubiKey OTP:</strong> tap your YubiKey, done. Up to five keys per account. Free <a href="https://www.yubico.com/products/yubikey-5-overview/" rel="noopener" target="_blank">YubiKey 5</a>-class hardware tokens are the gold standard.</li>
<li><strong>FIDO2 / WebAuthn:</strong> any modern security key (YubiKey, SoloKey, Nitrokey, even your phone&#8217;s secure enclave). Phishing-resistant by design: the browser checks the domain for you.</li>
<li><strong>Duo Security:</strong> for organisations already paying Cisco for Duo. Push notification to the Duo mobile app.</li>
</ul>



<p class="wp-block-paragraph">Bitwarden&#8217;s hosted service paywalls YubiKey and Duo behind the Premium tier. On <strong>self-hosted Vaultwarden, every single 2FA method is free</strong>. That alone covers the €40/year Premium subscription you&#8217;d otherwise pay.</p>



<p class="wp-block-paragraph">A small but important config flag: set <code>EMAIL_2FA_ENABLED=true</code> only if SMTP is reliable. If your mail relay dies, users can&#8217;t log in. WebAuthn + a recovery code printed to paper is the lowest-friction combo for most households.</p>



<h3 class="wp-block-heading">Passkeys, the (Genuinely) Password-Free Future</h3>



<p class="wp-block-paragraph">Passkeys are the new shiny. They&#8217;re cryptographic key-pairs (FIDO2/WebAuthn under the hood) stored on your device that let you log in to websites with a fingerprint or face scan, <em>without ever typing a password</em>. The site stores only the public key; the private key never leaves your device. No password to phish, no password to leak, no password to remember. It is genuinely the biggest leap forward in auth in 20 years.</p>



<p class="wp-block-paragraph">The catch with Apple/Google/Microsoft passkeys: they&#8217;re locked to <em>their</em> ecosystems. Use an iPhone? Your passkeys live in iCloud Keychain. Switch to Android? Good luck. This is exactly the kind of vendor lock-in that self-hosting fixes.</p>



<p class="wp-block-paragraph">Recent Vaultwarden releases (from server v1.32+ paired with the modern Bitwarden clients) implement <strong>passkey storage in the vault</strong>. That means:</p>



<ul class="wp-block-list">
<li><strong>Create a passkey on any site</strong> (GitHub, Google, Microsoft, Amazon, Best Buy, hundreds more) and Vaultwarden offers to save it: same dialogue as saving a password.</li>
<li><strong>Sync across every device.</strong> The passkey created on your Windows laptop is instantly usable on your Android phone, your iPad, your Linux desktop. <em>One vault, every platform.</em></li>
<li><strong>End-to-end encrypted</strong> in the same vault as your passwords: server admin can&#8217;t read them.</li>
<li><strong>No iCloud, no Google Account, no Microsoft Account</strong> required. You own the keys.</li>
<li><strong>Log in to Vaultwarden itself with a passkey</strong>: the &#8220;Login with passkey&#8221; flow on the Bitwarden client lets you skip the master password entirely on trusted devices (still 2FA-protected for sensitive operations).</li>
</ul>



<p class="wp-block-paragraph">One small caveat: passkey support in Vaultwarden is still maturing. Check the <a href="https://github.com/dani-garcia/vaultwarden/wiki" rel="noopener" target="_blank">project wiki</a> for the current matrix of which client versions support creation vs. usage vs. login. Browser-extension passkey creation works today on Chrome/Edge/Firefox; mobile creation is rolling out.</p>



<h3 class="wp-block-heading">Organisations &amp; Sharing, Built for Families and Teams</h3>



<p class="wp-block-paragraph">Here&#8217;s where Vaultwarden quietly destroys the competition. On commercial password managers, &#8220;share with family&#8221; is a paid tier costing €40–60 a year. On self-hosted Vaultwarden, it&#8217;s free, unlimited, and frankly more flexible. The mechanism is called <strong>Organisations</strong>.</p>



<p class="wp-block-paragraph">An Organisation is a shared vault. You create one (say, &#8220;Household&#8221;), invite people by email, and any item placed in the org is visible to every member. Inside the org you can carve it up further with <strong>Collections</strong>, think of them as folders with permissions. So &#8220;Streaming&#8221; (Netflix, Disney+, Spotify) might be visible to everyone, but &#8220;Finance&#8221; (bank, broker, tax portal) only to you and your partner. Kids get the streaming collection only.</p>



<ul class="wp-block-list">
<li><strong>Invite by email</strong>: recipient creates a Bitwarden account on <em>your</em> server and accepts. (Requires SMTP configured; alternatively the admin panel can generate invite links manually.)</li>
<li><strong>Roles</strong>: Owner, Admin, Manager, User. Owners and Admins manage members and collections; Users just consume items.</li>
<li><strong>Per-collection permissions</strong>: read-only, read/write, or hide-password (members can autofill but can&#8217;t view the actual password, useful for shared employee logins).</li>
<li><strong>Move items between personal and org vaults</strong> with two clicks. No exporting/importing.</li>
<li><strong>Unlimited orgs, unlimited members, unlimited collections.</strong> The official Bitwarden Families plan caps at 6 users; on Vaultwarden, invite the whole village.</li>
</ul>



<p class="wp-block-paragraph">Practical patterns we see all the time on instances like <a href="https://vault.myguard.nl" target="_blank" rel="noopener">vault.myguard.nl</a>:</p>



<ul class="wp-block-list">
<li><strong>&#8220;Household&#8221; org</strong> with Streaming / Bills / Wi-Fi / Insurance collections, every adult is a Manager, kids are Users on Streaming only.</li>
<li><strong>&#8220;Work&#8221; org</strong> per team: Ops, Dev, Marketing, each with their own shared service accounts.</li>
<li><strong>&#8220;Estate&#8221; org</strong>: read-only collection shared with a sibling or spouse, containing important account info, insurance policies, and recovery codes. Combined with Emergency Access (below), this is the &#8220;if I drop dead&#8221; playbook.</li>
</ul>



<h3 class="wp-block-heading">Sharing a Single Password or File, Bitwarden Send</h3>



<p class="wp-block-paragraph">Sometimes you don&#8217;t want to set up a whole org, you just need to hand <em>one</em> password or <em>one</em> document to someone, securely, once. Maybe it&#8217;s the new Wi-Fi password for a houseguest, a tax PDF for your accountant, or a server root password for a contractor doing a one-off job. Email? Insecure. WhatsApp? Sits in chat history forever. SMS? Please, no.</p>



<p class="wp-block-paragraph">Enter <strong>Bitwarden Send</strong>. You create a Send (a text snippet or a file up to 500 MB), set the rules, and Vaultwarden hands you a unique URL. The recipient opens the link, sees the content once, and it&#8217;s gone. They don&#8217;t need a Bitwarden account. They don&#8217;t need to install anything. They just click.</p>



<ul class="wp-block-list">
<li><strong>Text Sends</strong>: paste a password, an API key, a recovery code, a love letter. Up to 1000 characters in a clean shareable URL.</li>
<li><strong>File Sends</strong>: attach a PDF, image, ZIP, anything up to 500 MB. End-to-end encrypted; the server stores only the ciphertext.</li>
<li><strong>Expiry &amp; max access count</strong>: &#8220;expires in 1 hour&#8221; or &#8220;max 1 view&#8221; or both. After that it self-destructs.</li>
<li><strong>Optional access password</strong>: recipient must enter a password (which you share via a different channel) to decrypt. Defeats accidental link leakage.</li>
<li><strong>Hide email</strong>: by default the Send shows your account email; toggle this off for anonymous shares.</li>
<li><strong>Manual disable</strong>: change your mind? Hit &#8220;disable&#8221; and the link dies immediately, even before expiry.</li>
</ul>



<p class="wp-block-paragraph">Enable Sends with <code>SENDS_ALLOWED=true</code> in your compose file (it&#8217;s on by default in our example above). On the client, look for the paper-plane icon. The whole flow takes about ten seconds, and you&#8217;ll never go back to pasting passwords into WhatsApp again.</p>



<h3 class="wp-block-heading">Other Features Worth Knowing</h3>



<ul class="wp-block-list">
<li><strong>Bitwarden Send</strong>: encrypted, self-destructing links for sharing a password or file with someone who doesn&#8217;t have Bitwarden. Set an expiry, a max view count, an optional access password.</li>
<li><strong>Emergency Access</strong>: designate a &#8220;trusted contact&#8221; who can request access to your vault after a configurable waiting period. Hit-by-a-bus protection without giving anyone live access today.</li>
<li><strong>Organisations &amp; collections</strong>: share specific items with family or a team. Granular permissions per collection.</li>
<li><strong>Vault health reports</strong>: flags reused passwords, weak passwords, exposed passwords (checked against Have I Been Pwned), and inactive 2FA on sites that support it.</li>
<li><strong>File attachments</strong>: encrypted PDFs, passport scans, recovery codes attached to entries.</li>
<li><strong>SSH keys</strong> (recent versions): store and serve SSH private keys via the Bitwarden agent. Goodbye <code>~/.ssh/id_rsa</code> sitting in plaintext.</li>
</ul>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">A Working Docker Compose Setup</h2>



<p class="wp-block-paragraph">Here&#8217;s a minimal, production-leaning <code>docker-compose.yml</code> that you can drop on any Linux box with Docker installed. We use a named volume for persistence, expose only to localhost, and let a reverse proxy handle TLS. (We&#8217;ll cover the proxy in a minute, and yes, our <a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX configuration guide</a> covers the same NGINX patterns we&#8217;ll use here.)</p>



<pre class="wp-block-code"><code>services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      DOMAIN: "https://vault.example.com"
      SIGNUPS_ALLOWED: "false"
      INVITATIONS_ALLOWED: "true"
      ADMIN_TOKEN: "REPLACE_ME_WITH_A_LONG_RANDOM_STRING"
      WEBSOCKET_ENABLED: "true"
      SENDS_ALLOWED: "true"
      EMERGENCY_ACCESS_ALLOWED: "true"
      LOG_LEVEL: "warn"
      ROCKET_PORT: "8080"
    volumes:
      - vw-data:/data
    ports:
      - "127.0.0.1:8080:8080"

volumes:
  vw-data:</code></pre>



<p class="wp-block-paragraph">Start it with <code>docker compose up -d</code> and you&#8217;ve got Vaultwarden listening on port 8080 of localhost. That&#8217;s it. Seriously. The whole server is one container.</p>



<h3 class="wp-block-heading">Generating a Strong Admin Token</h3>



<p class="wp-block-paragraph">The <code>ADMIN_TOKEN</code> protects the <code>/admin</code> panel. Don&#8217;t use a memorable password, use the Argon2 hashed form Vaultwarden recommends. Generate it with:</p>



<pre class="wp-block-code"><code>docker run --rm -it vaultwarden/server /vaultwarden hash</code></pre>



<p class="wp-block-paragraph">Paste a long random passphrase, copy the resulting hash, and stick it in <code>ADMIN_TOKEN</code>. Now even if your env file leaks, your admin panel doesn&#8217;t fall over.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Putting Vaultwarden Behind a Reverse Proxy</h2>



<p class="wp-block-paragraph">Vaultwarden <em>must</em> be served over HTTPS. The Bitwarden clients flat-out refuse to talk to a plain-HTTP server (and rightly so). The easiest way is to put NGINX (or our extended <a href="/angie-modules-optimized-extended/">Angie build</a>) in front and let Let&#8217;s Encrypt handle the certificate. Here&#8217;s the relevant NGINX server block:</p>



<pre class="wp-block-code"><code>server {
    listen 443 ssl http2;
    server_name vault.example.com;

    ssl_certificate     /etc/letsencrypt/live/vault.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/vault.example.com/privkey.pem;

    client_max_body_size 525M;  # for big attachments

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # WebSocket for live sync
    location /notifications/hub {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}</code></pre>



<p class="wp-block-paragraph">Reload NGINX, run <code>certbot</code>, and you&#8217;re done. Your vault is now live on <code>https://vault.example.com</code>, exactly like our reference instance at <a href="https://vault.myguard.nl" target="_blank" rel="noopener">vault.myguard.nl</a>.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1600" height="1067" src="https://deb.myguard.nl/wp-content/uploads/2026/05/vaultwarden-client-setup-browser-extension.webp" alt="Vaultwarden client setup with Bitwarden browser extension" class="wp-image-5725" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/vaultwarden-client-setup-browser-extension.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/vaultwarden-client-setup-browser-extension-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/vaultwarden-client-setup-browser-extension-1024x683.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/vaultwarden-client-setup-browser-extension-768x512.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/vaultwarden-client-setup-browser-extension-1536x1024.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /></figure>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Setting Up the Client (Browser, Phone, Desktop, CLI)</h2>



<p class="wp-block-paragraph">Here&#8217;s the beautiful bit. Vaultwarden is API-compatible with Bitwarden, so you use the <strong>official Bitwarden apps</strong>, they&#8217;re polished, audited, and available on basically everything. You just have to point them at <em>your</em> server instead of Bitwarden&#8217;s cloud.</p>



<h3 class="wp-block-heading">Browser Extension (Chrome, Firefox, Edge, Safari, Brave)</h3>



<ol class="wp-block-list">
<li>Install the <strong>Bitwarden</strong> extension from your browser&#8217;s store.</li>
<li>Before logging in, click the little cog/settings gear at the top-left.</li>
<li>Find <strong>Self-hosted environment</strong> and set <em>Server URL</em> to <code>https://vault.example.com</code> (or <code>https://vault.myguard.nl</code> if you&#8217;re testing).</li>
<li>Save. Go back. Create your account or log in.</li>
</ol>



<p class="wp-block-paragraph">That&#8217;s it. The extension now talks exclusively to your server. It will autofill on login pages, generate strong passwords, save new logins automatically, and warn you if you reuse one.</p>



<h3 class="wp-block-heading">Mobile (iOS &amp; Android)</h3>



<ol class="wp-block-list">
<li>Install <strong>Bitwarden</strong> from the App Store / Play Store.</li>
<li>On the very first screen, tap the gear icon (top-left).</li>
<li>Pick <strong>Self-hosted</strong> and enter your server URL.</li>
<li>Log in. Enable biometrics (FaceID / fingerprint) for instant unlock.</li>
<li>Turn on <strong>Autofill Services</strong> (Android) or <strong>AutoFill Passwords</strong> (iOS settings).</li>
</ol>



<p class="wp-block-paragraph">From now on, every login screen on your phone offers you the right credentials with a tap.</p>



<h3 class="wp-block-heading">Desktop App</h3>



<p class="wp-block-paragraph">The Bitwarden desktop app is available for Windows, macOS, and Linux (including a Snap and Flatpak). Same routine: open settings before logging in, pick self-hosted, paste your URL, log in. It also unlocks the browser extension via native messaging, type your master password once, and every browser on the machine unlocks automatically.</p>



<h3 class="wp-block-heading">Command Line (bw CLI)</h3>



<p class="wp-block-paragraph">For scripts, CI/CD, and terminal addicts, there&#8217;s <code>bw</code>:</p>



<pre class="wp-block-code"><code>npm install -g @bitwarden/cli
bw config server https://vault.example.com
bw login <span style="display:inline;" class="">y&#111;&#117;&#64;&#101;xa&#109;pl&#101;&#46;co&#109;</span>
export BW_SESSION=$(bw unlock --raw)
bw list items --search github</code></pre>



<p class="wp-block-paragraph">Now you can pipe secrets into Ansible, GitHub Actions, or anywhere else without baking them into the repo. Combine this with our <a href="/2026/05/wordpress-hardening-plugin-modsecurity-crs-block-attacks/">WordPress hardening plugin</a> for an end-to-end secure deployment workflow.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Hardening Your Self-Hosted Vaultwarden</h2>



<p class="wp-block-paragraph">You wouldn&#8217;t leave the front door of your house open just because the safe inside is locked. Same logic applies here. The vault is encrypted, but you still want to harden the perimeter.</p>



<ul class="wp-block-list">
<li><strong>Disable open signups</strong> (<code>SIGNUPS_ALLOWED=false</code>) the moment your own account is created. Use <code>INVITATIONS_ALLOWED=true</code> for family/team onboarding instead.</li>
<li><strong>Set <code>SHOW_PASSWORD_HINT=false</code></strong>: by default the server will tell anyone who asks &#8220;what was the hint for this email?&#8221; which is a free username-enumeration gift to attackers.</li>
<li><strong>Put it behind a WAF</strong> like our ModSecurity + OWASP CRS NGINX build (see <a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">our CRS install guide</a>). Brute-force probes on <code>/admin</code> stop dead at the WAF.</li>
<li><strong>Enable fail2ban</strong> on the Vaultwarden log file. There are ready-made jail definitions in the Vaultwarden wiki.</li>
<li><strong>Pin a specific image tag</strong> rather than <code>:latest</code> in production, so you don&#8217;t get a surprise upgrade at 3 a.m.</li>
<li><strong>Backups, backups, backups.</strong> A nightly <code>sqlite3 /data/db.sqlite3 ".backup /backups/$(date +%F).sqlite3"</code> plus a copy of the <code>attachments/</code>, <code>sends/</code>, and <code>rsa_key*</code> files is the bare minimum. Off-site copy too. If you lose <code>rsa_key.pem</code>, every existing 2FA token breaks.</li>
<li><strong>Monitor it</strong>. Even a simple <a href="https://vault.myguard.nl" target="_blank" rel="noopener">healthcheck ping</a> against your domain catches outages before users do.</li>
</ul>



<p class="wp-block-paragraph">A generic CRS install stops the obvious noise, but Vaultwarden has a small, fully-known request surface, so we wrote a purpose-built OWASP CRS plugin for it: <a href="https://github.com/eilandert/vaultwarden-crs-plugin" target="_blank" rel="noopener"><strong>vaultwarden-crs-plugin</strong></a>. It does two things. First, it removes the false positives CRS would otherwise raise on legitimate traffic, the Argon2 admin token, the OAuth password-grant hashes on <code>/identity/connect/token</code>, and the end-to-end-encrypted EncString blobs that every cipher/account/send write carries. Second, it adds an opt-in <em>positive-security</em> layer: a path allowlist built straight from Vaultwarden&#8217;s own source (<code>/api</code>, <code>/identity</code>, <code>/admin</code>, <code>/icons</code>, <code>/notifications</code>, <code>/attachments</code> and the web-vault static tree), so scanner probes for <code>/wp-login.php</code>, <code>/.env</code> or <code>/vendor</code> are denied before they ever reach the backend. Because Vaultwarden is a JSON API, it deliberately ships <em>no</em> argument-name allowlist (those keys vary per client). It runs on <code>vault.myguard.nl</code> today. Drop the three <code>plugins/*.conf</code> files into your CRS plugins directory and enable it per-vhost, see the <a href="https://github.com/eilandert/vaultwarden-crs-plugin" target="_blank" rel="noopener">README</a> for the full roll-out.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">A Quick Tour of vault.myguard.nl</h2>



<p class="wp-block-paragraph">Want to see what a well-tuned Vaultwarden install actually looks like in the wild? Point your browser at <a href="https://vault.myguard.nl" target="_blank" rel="noopener">vault.myguard.nl</a>. It&#8217;s a real, production Vaultwarden instance running on the exact same Docker + NGINX + ModSecurity stack we ship as packages on this site. Open signups are disabled (sorry, invitation only), but you can see the login page, the TLS configuration, the response headers, and the overall snappiness. It&#8217;s a useful reference for what &#8220;done right&#8221; feels like.</p>



<p class="wp-block-paragraph">The vault.myguard.nl instance is fronted by our extended Angie build with the OWASP CRS WAF, brotli + zstd compression (see our <a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/">compression deep dive</a>), HTTP/3, and a strict CSP. The container itself is a stock <code>vaultwarden/server:latest</code> with SQLite. Backups go to encrypted off-site storage every night. Total resource use: under 100 MB of RAM, less than 1% of one CPU core at idle. It&#8217;s almost embarrassing how little it costs to run.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Frequently Asked Questions</h2>



<h3 class="wp-block-heading">Is self-hosted Vaultwarden safe?</h3>



<p class="wp-block-paragraph">Yes, provided you keep it updated, run it behind HTTPS, and back it up. Your vault is encrypted on the client <em>before</em> it touches the server using your master password as the key. Even a full server compromise leaks ciphertext, not plaintext passwords. The risk profile is genuinely lower than most cloud password managers, because you&#8217;re not in a giant shared blast radius.</p>



<h3 class="wp-block-heading">Can I use the official Bitwarden apps with Vaultwarden?</h3>



<p class="wp-block-paragraph">Absolutely, and you should. Vaultwarden implements the Bitwarden API, so every official client (browser, mobile, desktop, CLI) works perfectly. Just change the server URL in the client settings <em>before</em> logging in.</p>



<h3 class="wp-block-heading">What happens if I forget my master password?</h3>



<p class="wp-block-paragraph">Your vault is gone. There is no recovery, no &#8220;forgot password&#8221; link, no admin override. That&#8217;s a feature, not a bug, the server literally cannot decrypt your data, so it can&#8217;t help you. Mitigation: write your master password on paper and store it in a safe, or set up <strong>Emergency Access</strong> with a trusted person, or print an encrypted recovery code. Do this <em>before</em> you forget.</p>



<h3 class="wp-block-heading">SQLite or MySQL/PostgreSQL?</h3>



<p class="wp-block-paragraph">SQLite is the default and is genuinely fine up to a few hundred users. It&#8217;s fast, has zero moving parts, and backs up by copying one file. Only switch to MySQL or PostgreSQL if you have a clustered/HA setup or several thousand active users. Don&#8217;t over-engineer this.</p>



<h3 class="wp-block-heading">How much will hosting cost me?</h3>



<p class="wp-block-paragraph">If you&#8217;ve already got a VPS or home server, effectively zero. From scratch, a €3–5/month VPS comfortably runs Vaultwarden for an entire family. The container needs about 50 MB of RAM and barely any CPU. A Raspberry Pi Zero 2 W can run it too, if you&#8217;re feeling sporty.</p>



<h3 class="wp-block-heading">Does Vaultwarden support passkeys (WebAuthn)?</h3>



<p class="wp-block-paragraph">Yes. Recent versions support passkey storage in the vault and WebAuthn as a 2FA method for logging in to Vaultwarden itself. You can store passkeys for sites that support them and sync them across all your devices via your self-hosted vault, the same convenience Apple/Google offer, but on hardware you own.</p>



<h3 class="wp-block-heading">Should I store TOTP codes in the same vault as my passwords?</h3>



<p class="wp-block-paragraph">It&#8217;s a trade-off. Storing them together is wildly more convenient and means losing your phone doesn&#8217;t lock you out of 50 accounts. The cost: if someone cracks your master password <em>and</em> bypasses your 2FA, they get everything. Mitigation: use a long, unique master passphrase, enable WebAuthn or a YubiKey as 2FA on the vault itself, and consider keeping a tiny separate authenticator app for your most critical accounts (bank, email, vault recovery).</p>



<h3 class="wp-block-heading">Passkeys vs TOTP, which one should I use?</h3>



<p class="wp-block-paragraph">If a site offers passkeys, use them. They&#8217;re phishing-resistant by design (the browser checks the domain cryptographically), there&#8217;s no code to type or screenshot, and they&#8217;re faster. TOTP is the fallback when a site hasn&#8217;t added passkey support yet, which is still most of the internet. In practice you&#8217;ll use both for years. Vaultwarden handles both seamlessly.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Related Reading</h2>



<ul class="wp-block-list">
<li><a href="https://github.com/dani-garcia/vaultwarden" rel="noopener" target="_blank"><strong>Vaultwarden on GitHub</strong></a>: official source code, releases, and issue tracker (dani-garcia/vaultwarden).</li>
<li><a href="https://github.com/dani-garcia/vaultwarden/wiki" rel="noopener" target="_blank"><strong>Vaultwarden Wiki</strong></a>: the authoritative docs: every env var, reverse-proxy examples, backup &amp; fail2ban guides.</li>
<li><a href="https://hub.docker.com/r/vaultwarden/server" rel="noopener" target="_blank"><strong>vaultwarden/server on Docker Hub</strong></a>: the official container image and tag list.</li>
<li><a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/"><strong>WordPress NGINX Configuration: PHP-FPM, FastCGI Cache and Redis</strong></a>: the same NGINX patterns used to front Vaultwarden, applied to WordPress.</li>
<li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/"><strong>How to Install ModSecurity and OWASP CRS on NGINX</strong></a>: add a real WAF in front of your Vaultwarden admin panel.</li>
<li><a href="/2026/05/wordpress-hardening-plugin-modsecurity-crs-block-attacks/"><strong>WordPress Hardening Plugin for ModSecurity CRS</strong></a>: companion hardening for the WordPress side of your stack.</li>
<li><a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/"><strong>Zstd vs Brotli vs zlib-ng</strong></a>: squeeze every kilobyte out of your Vaultwarden web vault responses.</li>
</ul>


<p><!-- seo-orphan-link --> More self-hosting builds on our <a href="/docker/">Docker overview page</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>What Is the BREACH Attack? How It Works and How to Stop It</title>
		<link>https://deb.myguard.nl/2026/05/breach-attack-explained-prevention/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 19 May 2026 22:07:29 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[BREACH]]></category>
		<category><![CDATA[compression]]></category>
		<category><![CDATA[CSRF]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[owasp]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[tls]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5711</guid>

					<description><![CDATA[BREACH is a compression side-channel attack that can leak CSRF tokens and other secrets over HTTPS. Here is how the BREACH attack works, why padding is weak protection, and how to prevent it properly.]]></description>
										<content:encoded><![CDATA[<p>Okay, deep breath. The <strong>BREACH attack</strong> sounds like a straight-to-streaming heist movie, but it is a real, very practical web attack with absolutely no charm. It picks on something nearly every website turns on for very good reasons: HTTP compression. Gzip and Brotli make pages smaller, faster, and cheaper to deliver. Wonderful. BREACH walks in and asks the rudest possible question, &#8220;what if that same lovely compression can help an attacker guess the secrets hiding in your page, one letter at a time?&#8221;</p>
<p>Spoiler: it can. The BREACH attack is a <strong>compression side-channel attack</strong> against HTTPS responses. It does not snap TLS like a cartoon villain with bolt cutters. Instead, it abuses a quiet little detail, compressed responses get a tiny bit shorter when their contents repeat. If an attacker can sneak text into a request and then squint at how big the encrypted reply comes back, they can slowly, patiently, annoyingly recover hidden things like CSRF tokens, password reset codes, email addresses, or other values you genuinely did not want on the front page.</p>
<p>Yes, really. Your site can have a perfectly modern TLS setup, an A+ on every scanner, and still gently leak secrets because compression was trying to be helpful at exactly the wrong moment. I know. Rude.</p>
<p><img decoding="async" class="alignnone size-full" src="https://deb.myguard.nl/wp-content/uploads/2026/05/breach-attack-compression-vacuum-bag-analogy.webp" alt="BREACH attack compression side-channel illustrated as a vacuum-packed suitcase" /></p>
<h2 style="color:#f59e0b">What Is the BREACH Attack, in Plain English?</h2>
<p>BREACH stands for <strong>Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext</strong>. That name is doing the absolute most, so let us put it in human language.</p>
<p>Imagine you are packing for a trip and you have one of those vacuum bags that suck out all the air. Stuff two fluffy sweaters in there, and the bag shrinks more than it would with just one. Now imagine a nosy neighbour who is not allowed to look inside the bag, but they can pick it up and weigh it. They sneak in different items, weigh the bag each time, and the second one of their items matches something already inside, the bag gets noticeably lighter. Congratulations, neighbour. You just learned what is in someone else&#8217;s suitcase without ever opening it.</p>
<p>That is BREACH. The vacuum bag is the compressed HTTP response. The sweater already inside is the secret, like a CSRF token. The attacker&#8217;s test items are bits of text reflected into the page, a search query, an error message, a URL parameter, the kind of thing that bounces back into HTML without anyone thinking twice. All the attacker really needs is the ability to trigger lots of requests and watch how big the encrypted responses come back.</p>
<p>The key point, and please tattoo this on your brain, <strong>BREACH is not about breaking encryption directly</strong>. TLS can be flawless. The leak comes from the <em>size</em> of the response. Encryption hides the words. It does not hide the page count.</p>
<h2 style="color:#f59e0b">How Does a BREACH Attack Actually Work?</h2>
<p>For the BREACH attack to succeed, four ingredients usually have to show up at the same party:</p>
<ol>
<li>The victim is logged in, or the response carries a secret tied to that session.</li>
<li>The application reflects attacker-controlled input somewhere in that same response.</li>
<li>The response is compressed with gzip, Brotli, or similar content compression.</li>
<li>The attacker can fire off many requests and measure how big the replies come back.</li>
</ol>
<p>When those four line up, you have the world&#8217;s most patient, most boring guessing game. Let&#8217;s walk through it.</p>
<h3>Step 1: A secret is sitting inside the HTML</h3>
<p>Picture a logged-in page with a sneaky little hidden form field:</p>
<pre><code>&lt;input type="hidden" name="csrf" value="r9Gk3LxV..."&gt;</code></pre>
<p>That token is there for a great reason, it stops forged form submissions. The drama starts when the same page <em>also</em> reflects user input, for example:</p>
<pre><code>&lt;p&gt;You searched for: BRE...&lt;/p&gt;</code></pre>
<p>If the attacker can choose the search term, they can keep tossing in guesses that might overlap with the secret token. Whenever the guess matches a chunk of the token, gzip and Brotli go &#8220;ooh, repetition, let&#8217;s save bytes!&#8221; and the response shrinks ever so slightly. That tiny shrink is the leak.</p>
<h3>Step 2: The attacker keeps poking</h3>
<p>The attacker drops the victim onto a malicious page somewhere else on the internet, maybe an ad, maybe a sketchy link, maybe a popup that absolutely no one asked for. That malicious page tells the victim&#8217;s browser to send lots of requests to the target site. The browser obediently attaches the victim&#8217;s cookies (because that is what browsers do), so the target site returns the real, logged-in page with the real secret tucked inside.</p>
<p>The attacker cannot read the response body because of the same-origin policy. Cool, but they don&#8217;t need to. They just need the size of each encrypted reply. Each guess that happens to share characters with the secret produces a slightly smaller payload, and the attacker takes note like a stalker with a kitchen scale.</p>
<h3>Step 3: One byte at a time, like an exceptionally patient bird</h3>
<p>By repeating this with thousands of slightly different guesses and comparing the sizes, the attacker can recover the secret one character at a time. It is slow, fiddly, and data-hungry. It is also extremely real. Side-channel attacks always sound silly until you remember they are basically statistics in a balaclava.</p>
<h3>Step 4: The attacker uses the stolen secret</h3>
<p>Once a CSRF token, reset code, or other session-bound secret has been recovered, the attacker can use it to bypass the very protection that secret was supposed to provide. Which, if we are being honest, is the genuinely insulting bit. You added a CSRF token for safety. Compression turned it into a hint system. Cool. Cool cool cool.</p>
<p><img decoding="async" class="alignnone size-full" src="https://deb.myguard.nl/wp-content/uploads/2026/05/breach-attack-https-response-size-leak.webp" alt="BREACH attack measuring encrypted HTTPS response sizes on a code screen" /></p>
<h2 style="color:#f59e0b">Why HTTPS Alone Does Not Save You</h2>
<p>This is the part that feels deeply unfair, so let&#8217;s name it: HTTPS does its job. It protects the <em>contents</em> of your traffic in transit. It just does not hide the brute fact that one encrypted response is 12,438 bytes and the next one is 12,431 bytes.</p>
<p>BREACH lives in that seven-byte difference.</p>
<p>Think of it like this: someone outside your living room cannot hear the actual words of your conversation through the wall. But they can absolutely tell when the room goes dead silent, when everybody laughs at once, and when somebody drops a wine glass. Metadata leaks are still leaks. Compression side-channel leaks are metadata leaks with a postgraduate degree and a smug attitude.</p>
<p>This is also why &#8220;just turn off TLS&#8221; is not a fix. That is fixing a squeaky brake by setting the car on fire. Please do not.</p>
<h2 style="color:#f59e0b">BREACH vs CRIME vs HEIST: Same Family, Different Tricks</h2>
<p>Security researchers love naming attacks like rejected metal bands, so here is the quick family tree:</p>
<p><strong>CRIME</strong> targeted TLS or SPDY compression itself. It mostly went out of style once TLS-level compression was disabled almost everywhere.</p>
<p><strong>BREACH</strong> targets <em>HTTP response body compression</em>. That is the one that still matters today, because gzip and Brotli are still happily compressing your responses for all the right performance reasons.</p>
<p><strong>HEIST</strong> and related browser-side techniques showed that even without a network vantage point, an attacker can sometimes coax the browser into revealing timing or size details. Cute. Hate it.</p>
<p>The unifying lesson is simple: if secrets and attacker-controlled input share the same compressed output, somebody clever will turn that into an oracle. Treat that as a law of nature.</p>
<h2 style="color:#f59e0b">Why Padding Is Usually False Security</h2>
<p>Padding sounds smart at first. &#8220;If response sizes leak information, just add some random bytes so the sizes become noisy. Done. Pub?&#8221; Sadly, no pub.</p>
<p>Padding is usually <strong>false security</strong> because it treats the symptom, not the cause. Let&#8217;s look at why.</p>
<h3>Random noise gets averaged out</h3>
<p>If an attacker can fire off thousands of requests, and they can, random padding turns into background fuzz. The attack signal does not vanish. It just needs more samples. Given enough tries, averages chew through the noise and the size differences become useful again. Statistics: still in the balaclava.</p>
<h3>Fixed padding is essentially decoration</h3>
<p>If you always add the exact same amount of padding, you have done… almost nothing. Every response is still comparable to every other response. You have just made the whole site slightly bigger, which is an expensive way to feel safe.</p>
<h3>Developers wildly overestimate it</h3>
<p>This is the genuinely dangerous bit. Once padding is enabled, teams move on with confidence. &#8220;Yep, BREACH? Handled.&#8221; Meanwhile the actual vulnerability sits there untouched, because a secret and attacker-controlled reflection are still cohabiting in the same compressed response.</p>
<h3>Padding rarely lands exactly where it matters</h3>
<p>For padding to be useful, it must be unpredictable, carefully sized, applied consistently, and hard for an attacker to model. In real life? One template adds it. Another forgets. One response path pads before compression. Another pads after. Suddenly your safety blanket has holes, the holes are also on fire, and somebody is filming it for TikTok.</p>
<p>Padding can make exploitation a bit harder. That is fine as a <em>secondary friction control</em>. It is not a trustworthy primary defense against the BREACH attack.</p>
<h2 style="color:#f59e0b">What Can a BREACH Attack Actually Leak?</h2>
<p>The poster child is the <strong>CSRF token</strong> because it loves to live directly in HTML forms. But BREACH is not married to CSRF. Any secret that ends up in a compressible response can become a target, such as:</p>
<ul>
<li>CSRF tokens</li>
<li>Password reset tokens</li>
<li>Email addresses or partial personal info</li>
<li>Session-bound anti-abuse tokens</li>
<li>OAuth state values or one-time nonces</li>
<li>API keys or internal identifiers accidentally rendered into templates</li>
</ul>
<p>If it is secret, stable enough to guess incrementally, and shares a compressed response with attacker-controlled input, it earns a spot on your threat model. No exceptions, sorry.</p>
<p><img decoding="async" class="alignnone size-full" src="https://deb.myguard.nl/wp-content/uploads/2026/05/breach-attack-csrf-token-leak-defense.webp" alt="BREACH attack defenses protecting CSRF tokens behind a digital lock" /></p>
<h2 style="color:#f59e0b">How to Prevent the BREACH Attack Properly</h2>
<p>Okay. Enough admiring the problem. Adult mode now.</p>
<h3>1. Disable compression on sensitive dynamic responses</h3>
<p>The most direct mitigation is to turn off gzip and Brotli for the responses that contain secrets and reflect input. Think login forms, account pages, authenticated search, password reset flows, settings forms, and admin panels. Basically anywhere your site whispers secret things to a logged-in user.</p>
<p>For NGINX or Angie, be surgical instead of nuking from orbit. You do <strong>not</strong> need to disable compression for the entire site. Static assets and public pages can stay compressed. The sensitive authenticated HTML is where you want to be cautious.</p>
<pre><code>location /account/ {
    gzip off;
    brotli off;
}

location /wp-admin/ {
    gzip off;
    brotli off;
}</code></pre>
<p>If you are already tuning compression on NGINX or Angie, the broader context lives in <a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/">our comparison of Brotli, zstd, and zlib-ng compression</a>.</p>
<h3>2. Stop reflecting attacker-controlled input next to secrets</h3>
<p>This is honestly one of the best fixes, because it kills the oracle dead. If a page contains a CSRF token, it should not also be echoing user-controlled query parameters, search terms, or any other reflected data straight into the same compressed template, at least not unless you really have to.</p>
<p>Separate those concerns. Move the search UI onto a different response. Push live reflection out to a public endpoint that doesn&#8217;t carry session secrets. Rewrite templates so secrets are not nestling up against attacker-controlled bytes like they&#8217;re on a first date. Less glamorous than buying a shiny new WAF module, but often dramatically more effective.</p>
<h3>3. Use CSRF defenses that do not create a stable compression target</h3>
<p>Yes, you still need CSRF protection. No, plain old static tokens stamped into every compressed page are not enough on their own.</p>
<p>Safer patterns include:</p>
<ul>
<li><strong>Per-request or masked CSRF tokens</strong>: the exposed value changes every time, so a recovered value is useless about three milliseconds later.</li>
<li><strong>Synchronizer tokens</strong> combined with careful placement, so they are not sharing a compressed response with attacker input.</li>
<li><strong>Double-submit cookie patterns</strong> where appropriate, with strong cookie flags and proper validation.</li>
</ul>
<p>The goal is not &#8220;remove CSRF protection.&#8221; The goal is &#8220;do not let your CSRF token act as a reusable compression oracle.&#8221;</p>
<h3>4. Enforce SameSite cookies</h3>
<p>Cookies tagged with <code>SameSite=Lax</code> or <code>SameSite=Strict</code> can reduce the browser&#8217;s willingness to send authenticated cookies on cross-site requests. That matters, because BREACH usually relies on the victim&#8217;s browser cheerfully sending credentialed requests from an attacker-controlled page.</p>
<p>This is not a complete BREACH attack fix on its own, but it shrinks the attack surface in a real way, and it helps with garden-variety CSRF too. One of those rare security knobs that is both boring and useful, which honestly is the dream.</p>
<h3>5. Validate Origin and Referer headers</h3>
<p>For state-changing requests, validate the <code>Origin</code> header where you can, and fall back to <code>Referer</code> checks where it makes sense. Not perfect, not magical, but it raises the bar for cross-site abuse and pairs nicely with token-based defenses.</p>
<p>If your app accepts dangerous actions purely because one hidden field happened to match, you are trusting one layer to do everything. BREACH absolutely loves a single layer.</p>
<h3>6. Rate-limit suspicious repeated requests</h3>
<p>BREACH needs a lot of measurements. That makes rate limiting, anomaly detection, and bot friction genuinely useful as supporting controls. If one page is getting hit by thousands of tiny variant requests in a tight window, that is something your stack should notice and react to, not shrug at.</p>
<p>On the web-server side, you can pair app logic with controls like ModSecurity or request throttling. If you are already hardening a WordPress or NGINX stack, <a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">our ModSecurity and OWASP CRS guide</a> and <a href="/2026/05/wordpress-hardening-plugin-modsecurity-crs-block-attacks/">our WordPress hardening write-up</a> cover broader request-filtering patterns that help here too.</p>
<h3>7. Keep secrets out of compressible HTML when you can</h3>
<p>If a secret does not <em>need</em> to be in the page, do not put it there. Move it server-side. Use headers or dedicated non-compressed endpoints where it fits. Ask whether the template genuinely needs to render that value at all, a shocking amount of &#8220;required&#8221; template data turns out to be legacy furniture nobody has questioned since 2019.</p>
<h3>8. Segment public and authenticated experiences</h3>
<p>Public pages can be aggressively cached and compressed. Authenticated pages deserve stricter handling. If you serve WordPress through NGINX or Angie, this split is already a good idea for both performance and security. The same architecture that gives you a great cache hit rate also makes it much easier to disable compression precisely where secrets live.</p>
<p>The broader setup work is covered in <a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">our WordPress on NGINX and PHP-FPM guide</a> and in <a href="/2026/05/nginx-reverse-proxy-configuration-guide/">our reverse proxy configuration article</a>.</p>
<h2 style="color:#f59e0b">What Not to Do</h2>
<ul>
<li>Do not assume HTTPS by itself solves the BREACH attack. It does not.</li>
<li>Do not keep secrets and attacker reflection in the same compressed response just because &#8220;the page works fine.&#8221;</li>
<li>Do not rely on random padding as your main mitigation. It is a friction control at best.</li>
<li>Do not nuke compression across your whole site unless you genuinely have no better option. Be selective and deliberate.</li>
<li>Do not treat CSRF tokens as magical objects. They are only as strong as the design around them.</li>
</ul>
<h2 style="color:#f59e0b">A Practical BREACH Defense Checklist</h2>
<ol>
<li>Inventory pages that contain secrets <em>and</em> user-controlled reflection.</li>
<li>Disable gzip and Brotli on those sensitive responses.</li>
<li>Mask or rotate CSRF tokens so they are not stable reusable targets.</li>
<li>Enable <code>SameSite</code>, <code>Secure</code>, and <code>HttpOnly</code> on cookies.</li>
<li>Validate <code>Origin</code> and, where appropriate, <code>Referer</code> on state-changing requests.</li>
<li>Rate-limit repeated probing patterns and watch for anomalies.</li>
<li>Keep public compressed content separated from authenticated secret-bearing content.</li>
<li>Audit templates for needless reflection of attacker-controlled input.</li>
</ol>
<p>That list is dramatically less sexy than saying &#8220;we added padding.&#8221; It is also dramatically more likely to actually protect you. Mood.</p>
<h2 style="color:#f59e0b">Why the BREACH Attack Still Matters in 2026</h2>
<p>Because performance tuning and security tuning keep slamming into each other in the same hallway, and neither is willing to apologise.</p>
<p>Everyone wants smaller responses, faster pages, lower bandwidth bills, and greener hosting. That is why compression is everywhere. Modern stacks also keep pushing more state, more personalization, and more tokens into dynamic HTML. That is why compression side channels keep finding fresh rooms to lurk in.</p>
<p>BREACH is not the only compression-based leak on the planet, but it remains the perfect reminder that a feature can be correct, useful, and dangerous all at the same time, depending on what you wrap around it. Compression itself is not the villain. Mixing secrets and attacker-controlled input into one compressed response is the villain. Blame the packing choices, not the vacuum bag.</p>
<h2 style="color:#f59e0b">Frequently Asked Questions</h2>
<h3>What is the BREACH attack in one sentence?</h3>
<p>The BREACH attack is a compression side-channel attack that uses tiny differences in compressed HTTPS response sizes to help an attacker guess secrets such as CSRF tokens or password reset tokens, one character at a time.</p>
<h3>Does BREACH mean I should disable gzip or Brotli everywhere?</h3>
<p>No, and please don&#8217;t. The practical fix is to disable compression selectively on sensitive dynamic pages that contain secrets and attacker-controlled reflection. Public assets and ordinary static content can stay compressed.</p>
<h3>Why are CSRF tokens involved so often?</h3>
<p>Because CSRF tokens are usually baked directly into HTML forms and stay stable long enough to be guessed in pieces. If they show up in a compressed response alongside attacker-controlled input, they become a perfect BREACH attack target.</p>
<h3>Is random padding a good BREACH mitigation?</h3>
<p>Only as a minor supporting friction control. Padding adds noise, but attackers can usually average that noise out with enough samples. Do not let it be your main defense.</p>
<h3>Can SameSite cookies help prevent BREACH?</h3>
<p>They reduce the browser&#8217;s willingness to send authenticated cookies on cross-site requests, which makes BREACH-style probing harder. Useful and very welcome, but not a full replacement for careful compression and template design.</p>
<h3>Is BREACH only a WordPress problem?</h3>
<p>Not at all. Any web application can be vulnerable if it compresses responses that contain secrets and attacker-controlled reflection. WordPress, custom PHP apps, Python frameworks, Node.js apps, Java stacks, all can fall into the same trap.</p>
<h3>How long does a BREACH attack actually take?</h3>
<p>It depends on how stable the secret is, how cleanly attacker input is reflected, and how aggressively the attacker can issue requests. In friendly conditions a token can be recovered in minutes to hours; in less friendly conditions it may not be practical at all. Either way, &#8220;not always fast&#8221; is a poor reason to leave the door open.</p>
<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
<li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: A practical guide to adding request filtering and defensive friction at the web-server layer.</li>
<li><a href="/2026/05/wordpress-hardening-plugin-modsecurity-crs-block-attacks/">WordPress Hardening Plugin for ModSecurity CRS</a>: A broader hardening strategy for blocking abusive traffic before it ever reaches your PHP code.</li>
<li><a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/">NGINX zstd vs Brotli vs zlib-ng Compression</a>: Useful background if you are tuning compression and want to understand what to disable where.</li>
<li><a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX Configuration: PHP-FPM Tuning, FastCGI Cache and Redis</a>: Covers the architectural split between public cached traffic and authenticated dynamic traffic.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>What Is Zstd? NGINX, Angie, History and Browser Support</title>
		<link>https://deb.myguard.nl/2026/05/what-is-zstd-nginx-angie-browser-support/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sun, 17 May 2026 00:05:16 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[angie]]></category>
		<category><![CDATA[compression]]></category>
		<category><![CDATA[nginx-module]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[zstd]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5618</guid>

					<description><![CDATA[Zstd is the fast compression format suddenly showing up in browsers, package managers, and modern web stacks. Here is what it is, where it came from, which browsers and web servers support it, and how to use it with NGINX and Angie today.]]></description>
										<content:encoded><![CDATA[<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="1600" height="900" src="https://deb.myguard.nl/wp-content/uploads/2026/05/what-is-zstd-compression-carry-on.webp" alt="What is Zstd compression explained as a smart carry-on suitcase" class="wp-image-5647" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/what-is-zstd-compression-carry-on.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/what-is-zstd-compression-carry-on-300x169.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/what-is-zstd-compression-carry-on-1024x576.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/what-is-zstd-compression-carry-on-768x432.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/what-is-zstd-compression-carry-on-1536x864.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /><figcaption>Zstd is the smart carry-on with wheels: strong compression, fast to unpack, and very little airport drama.</figcaption></figure>
<p>Zstd, short for <strong>Zstandard</strong>, is a lossless compression format designed by Yann Collet and open-sourced by Facebook in 2016. In plain English: it squishes files and HTTP responses so they travel faster, but the browser gets the exact original content back on the other side. No mystery meat, no quality loss, no weird “your CSS came back as soup” situation.</p>
<p>The reason people suddenly care is simple: Zstd tries to hit a very attractive middle ground. Gzip is old, dependable, and everywhere. Brotli often wins on compression ratio, especially for static assets, but it can ask for more CPU patience. Zstd aims for a sweet spot where compression is strong, decompression is very fast, and the tuning range is broad enough that you can bias for speed or ratio depending on your traffic. For web servers, that is a nice sentence to hear right before the graphs stop looking sad.</p>
<p>If you already read our <a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/">zstd vs Brotli vs zlib-ng comparison for NGINX</a>, this article is the prequel. This one answers the beginner questions: what Zstd actually is, why it exists, how it got here, which browsers support it, and how we use it in <a href="/nginx-modules/">our NGINX builds</a> and <a href="/angie-modules-optimized-extended/">our Angie builds</a>.</p>
<h2 style="color:#f59e0b">What is Zstd, exactly?</h2>
<p>Think of web compression like packing a suitcase. Gzip is the sturdy old suitcase your parents used for twenty years. Brotli is the fancy vacuum bag that can squeeze a shocking amount into a tiny space, but sometimes makes packing slower. Zstd is the smart carry-on with wheels, a charger, and just enough room to keep airport drama to a minimum.</p>
<p>Technically, Zstd is a modern lossless compression algorithm standardized in RFC 8878. It is designed to offer a wide speed-versus-ratio range, fast decompression, and optional dictionary compression for repeating small payloads. Outside the web, that made it popular in places like Linux packaging, filesystems, databases, and backup tools long before browsers started speaking <code>zstd</code> in HTTP.</p>
<p>That “lossless” part matters. When a browser receives <code>Content-Encoding: zstd</code>, it decompresses the body back to the original HTML, CSS, JavaScript, JSON, or SVG byte stream. Nothing is thrown away. The whole trick is just moving fewer bytes across the wire.</p>
<h2 style="color:#f59e0b">Why does Zstd exist?</h2>
<p>Because the internet is rude. Pages got bigger, APIs got chattier, CPUs got faster, and nobody wanted to choose between “tiny responses” and “reasonable server load” forever. Zstd was created to improve on the trade-off that older formats left on the table.</p>
<p>The official project describes Zstd as a fast compression algorithm with high compression ratios and an extremely fast decoder. That decoder speed is a big deal for the web. Compression happens on your server, but decompression happens on every client device. If decompression is lightweight, the format becomes much more practical for browsers and mobile devices.</p>
<p>That is also why Zstd spread beyond websites. Package managers adopted it because installs and updates benefit from fast decompression. Filesystems liked it because “smaller on disk but still quick to read” is basically catnip for infrastructure engineers. Once browsers added support for HTTP <code>Content-Encoding: zstd</code>, the web finally had a reason to bring that momentum into day-to-day page delivery.</p>
<h2 style="color:#f59e0b">A short, useful history of Zstd</h2>
<p>Zstd was initially released in 2015, then open-sourced publicly in 2016. The format later landed in the IETF standards process, which is why it now has a proper RFC instead of just “trust us, this GitHub repo looks serious.” Over the following years, it spread through Linux and infrastructure tooling: kernels, package formats, storage layers, databases, and archive utilities all started adopting it.</p>
<p>The web side moved more slowly. For a while, Zstd was the compression nerd’s favorite party trick: powerful, promising, but not broadly useful for public sites because browsers were not asking for it yet. That changed in 2024 when Chromium-based browsers began supporting Zstd content encoding, followed by Firefox. Safari support arrived later and more awkwardly, which is about as surprising as rain being wet.</p>
<p>By 2026, the picture is much better. The current browser landscape is finally good enough that Zstd has moved from “interesting lab experiment” to “real production option,” especially when used as an addition to gzip and Brotli rather than a reckless replacement for everything.</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1600" height="900" src="https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-nginx-angie-server-room.webp" alt="Server room where Zstd compression runs in NGINX and Angie" class="wp-image-5645" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-nginx-angie-server-room.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-nginx-angie-server-room-300x169.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-nginx-angie-server-room-1024x576.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-nginx-angie-server-room-768x432.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-nginx-angie-server-room-1536x864.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /><figcaption>The useful version of the history lesson: Zstd was not born yesterday, browsers were just late to the party.</figcaption></figure>
<h2 style="color:#f59e0b">Which browsers support Zstd?</h2>
<p>Modern browser support is now real, not imaginary marketing support. Based on current MDN and Can I Use data, Zstd content encoding is supported in Chrome and Edge from version 123, Firefox from version 126, and Opera from version 109. Safari and Safari on iOS joined later, with newer-version caveats. In other words: the big modern engines are catching up, but Apple took the scenic route.</p>
<table class="wp-block-table">
<thead>
<tr>
<th>Browser</th>
<th>Zstd HTTP support</th>
<th>Practical note</th>
</tr>
</thead>
<tbody>
<tr>
<td>Chrome / Edge</td>
<td>123+</td>
<td>Good modern baseline</td>
</tr>
<tr>
<td>Firefox</td>
<td>126+</td>
<td>Now fully in the club</td>
</tr>
<tr>
<td>Opera</td>
<td>109+</td>
<td>Supported on desktop</td>
</tr>
<tr>
<td>Safari</td>
<td>Newer releases only</td>
<td>Treat as improving, not universal</td>
</tr>
<tr>
<td>iOS Safari</td>
<td>Newer releases</td>
<td>Much better than it was, still version-sensitive</td>
</tr>
</tbody>
</table>
<p>The important production takeaway is not “great, delete gzip.” The real takeaway is: keep sane fallbacks. Clients advertise what they support with the <code>Accept-Encoding</code> request header. Today, common modern browser requests can include <code>gzip, deflate, br, zstd</code>. Your server should negotiate the best option the client actually supports and keep older or partial-support clients on gzip or Brotli where needed.</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="675" src="https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-ecosystem-where-it-lives-diagram.svg" alt="Diagram showing where Zstd already belongs across browsers, web servers, and infrastructure" class="wp-image-5646" /><figcaption>Zstd matters because it is no longer just a browser story; it already lives across operating systems, packages, databases, and modern server stacks.</figcaption></figure>
<h2 style="color:#f59e0b">Which web servers support Zstd?</h2>
<p>This is where things get gloriously uneven.</p>
<p><strong>Caddy</strong> has first-class Zstd support in its <code>encode</code> directive. In fact, Caddy documents <code>zstd</code> and <code>gzip</code> as the default enabled formats if you use <code>encode</code> with no arguments. That is a clean, built-in experience.</p>
<p><strong>Stock NGINX</strong>, on the other hand, still documents gzip and gzip_static as its standard compression path. If you want Zstd in NGINX, you need a third-party module. That is exactly why our builds package the hardened <code>http-zstd</code> module. The same story applies to <strong>Angie</strong> in our repository: Zstd support comes from the module, not from a magical checkbox hidden in the attic.</p>
<p><strong>Apache httpd</strong> remains much more old-school here. Its official <code>mod_deflate</code> documentation still centers on gzip-compatible DEFLATE output. So if your question is “does every major web server do native Zstd out of the box now?”, the answer is no. The ecosystem is moving, but it is not uniform yet.</p>
<h2 style="color:#f59e0b">How we use Zstd in NGINX and Angie</h2>
<p>The module in <code>modules/nginx/http-zstd</code> ships two practical pieces: a filter module for <strong>dynamic compression</strong> and a static module for serving pre-compressed <code>.zst</code> files. The README’s recommended baseline is refreshingly boring, which is good. Boring config is how production stays married.</p>
<pre class="wp-block-code"><code>http {
    zstd             on;
    zstd_comp_level  3;
    zstd_min_length  1000;
    zstd_types       text/plain text/css application/json
                     application/javascript text/xml application/xml
                     application/xml+rss text/javascript image/svg+xml;

    gzip_vary        on;

    server {
        location /api/ {
            proxy_pass http://backend;
        }

        location /static/ {
            zstd_static on;
            root /var/www;
        }
    }
}</code></pre>
<p>Here is what matters:</p>
<ul>
<li><strong><code>zstd on;</code></strong> enables dynamic compression for matching responses.</li>
<li><strong><code>zstd_comp_level 3;</code></strong> is the module’s documented all-around default and a sensible starting point.</li>
<li><strong><code>zstd_min_length 1000;</code></strong> avoids wasting CPU on tiny responses that often get bigger when compressed.</li>
<li><strong><code>zstd_types</code></strong> controls which MIME types are eligible. Text, JSON, JS, XML, and SVG are the obvious wins.</li>
<li><strong><code>gzip_vary on;</code></strong> is important even with Zstd, because you still need <code>Vary: Accept-Encoding</code> so proxies and caches do not hand the wrong compressed variant to the wrong client.</li>
<li><strong><code>zstd_static on;</code></strong> tells the static module to serve pre-compressed <code>.zst</code> files when the client accepts them.</li>
</ul>
<p>The module also documents extra controls like <code>zstd_max_length</code>, <code>zstd_buffers</code>, <code>zstd_target_cblock_size</code>, and <code>zstd_dict_file</code>. The last one comes with a very important warning: dictionary compression is not generally safe for public web traffic unless you fully control both ends, because plain HTTP content negotiation does not magically tell the client which dictionary was used. Translation: dictionary compression is cool, but do not sprinkle it on your public site like parmesan.</p>
<p>If you want the module in ready-to-use packages instead of compiling your weekend into dust, our <a href="/nginx-modules/">NGINX modules builds</a> and <a href="/angie-modules-optimized-extended/">Angie modules builds</a> package that work for you. For the broader tuning story around TLS, HTTP/3, caching, and modules, our <a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">expert NGINX and Angie guide</a> picks up where this article stops.</p>
<h2 style="color:#f59e0b">Is Zstd production-ready?</h2>
<p>Yes, with one adult-sized caveat: use it like an addition to your compression strategy, not a religion.</p>
<p>The format itself is mature. The library is widely used. The browser support story is now good enough to matter. Our hardened NGINX module fork is explicitly production-oriented, with regression tests, ASAN and UBSAN coverage, continuous fuzzing of the <code>Accept-Encoding</code> parser, and ongoing CI. That is not “some guy pushed a tarball in 2019 and vanished into the hills.”</p>
<p>What is <em>not</em> mature yet is universal deployment symmetry. Not every browser version supports Zstd. Not every web server ships it natively. Not every CDN or proxy path will behave the way you hope if you make reckless assumptions. So the safe production pattern is:</p>
<ul>
<li>Keep gzip as the universal fallback.</li>
<li>Use Brotli where it already makes sense for static assets.</li>
<li>Add Zstd for modern clients that advertise support.</li>
<li>Use <code>Vary: Accept-Encoding</code> correctly.</li>
<li>Measure real CPU, latency, and cache behavior instead of composing fan fiction from benchmark charts.</li>
</ul>
<p>That is the boring answer. It is also the answer that does not wake you up at 3 a.m.</p>
<h2 style="color:#f59e0b">Frequently Asked Questions</h2>
<h3>Is Zstd better than gzip?</h3>
<p>Often, yes, especially when you care about fast decompression and a strong speed-to-ratio balance. But “better” depends on your clients, traffic shape, and how much CPU you want to spend. Gzip is still the universal fallback because everybody understands it.</p>
<h3>Is Zstd better than Brotli?</h3>
<p>Not in a simple winner-takes-all way. Brotli can still be excellent for static assets when you want maximum squeeze. Zstd is attractive because it is very fast, flexible, and increasingly supported, especially for dynamic content and broader infrastructure use.</p>
<h3>Does stock NGINX support Zstd by default?</h3>
<p>No. Stock NGINX documentation still focuses on gzip and gzip_static. To serve <code>Content-Encoding: zstd</code> with NGINX, you need a module such as the hardened <code>http-zstd</code> module we package.</p>
<h3>Can I use Zstd on a public website right now?</h3>
<p>Yes, but do it sensibly. Offer Zstd only to clients that actually advertise support, and keep gzip or Brotli as fallbacks. Production is about graceful negotiation, not emotional commitment.</p>
<h3>Should I use dictionary compression for website responses?</h3>
<p>Usually no, not for normal public traffic. The module documentation warns that HTTP does not include built-in dictionary discovery for regular <code>Content-Encoding: zstd</code> responses, so dictionary use is mainly for controlled environments where you manage both ends.</p>
<h3>Is Zstd only for websites?</h3>
<p>No. That is part of why it is interesting. Zstd is heavily used in packaging, filesystems, databases, backups, archives, and other infrastructure tooling. The web is joining a party that Linux and backend systems started years ago.</p>
<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
<li><a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/">zstd vs Brotli vs zlib-ng: The NGINX Compression Showdown</a>: if you want a side-by-side compression strategy decision instead of the history lesson.</li>
<li><a href="/nginx-modules/">NGINX Modules optimized &amp; extended</a>: the package page for our NGINX builds with the extra module set.</li>
<li><a href="/angie-modules-optimized-extended/">Angie modules optimized &amp; extended</a>: the Angie counterpart if you prefer the NGINX fork with extra features and a friendlier pace.</li>
<li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">Nginx &amp; Angie: The Expert Guide to Maximum Performance and Security</a>: the bigger tuning guide for people who came for compression and stayed for the whole server stack.</li>
</ul>
<p>Zstd is not magic. It is just a very good tool that finally arrived where normal web admins can use it without feeling like they joined a secret compression cult. That alone makes it worth paying attention to.</p>
<p><!-- seo-orphan-link --> See also: <a href="/2026/05/http3-quic-nginx-setup-tuning-gotchas-2026/">HTTP/3 and QUIC on NGINX: Setup and Tuning (2026)</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Database Boost: Free WordPress Database Optimization Plugin</title>
		<link>https://deb.myguard.nl/2026/05/database-boost-free-wordpress-database-optimization-plugin/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sat, 16 May 2026 16:28:53 +0000</pubDate>
				<category><![CDATA[Database]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[mysql]]></category>
		<category><![CDATA[optimization]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[plugin]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5600</guid>

					<description><![CDATA[Meet Database Boost, the free WordPress database optimization plugin that cleans, repairs, optimizes and indexes your database — and actually explains every step in plain English.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Okay, real talk. Your WordPress site has a basement. You&#8217;ve never been down there. You don&#8217;t even know where the stairs are. And down in that basement, over months and years, your site has been quietly hoarding junk, expired coupons, abandoned drafts, orphaned bits of plugins you deleted in 2022, and enough leftover &#8220;transients&#8221; to make a hoarder blush. That basement is your <strong>WordPress database</strong>, and <strong>Database Boost</strong> is the free plugin that finally walks down there, turns on the light, and cleans the whole thing up, while explaining every single thing it&#8217;s doing in plain English. No jargon. No &#8220;are you sure?&#8221; panic. Just a tidy, fast database and a calmer you.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="673" src="https://deb.myguard.nl/wp-content/uploads/2026/05/database-boost-wordpress-database-optimization-plugin.webp" alt="Database Boost WordPress database optimization plugin dashboard" class="wp-image-5601" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/database-boost-wordpress-database-optimization-plugin.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/database-boost-wordpress-database-optimization-plugin-300x168.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/database-boost-wordpress-database-optimization-plugin-1024x574.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/database-boost-wordpress-database-optimization-plugin-768x431.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /><figcaption>Database Boost: a full WordPress database optimization toolkit that explains itself as it works.</figcaption></figure>



<h2 class="wp-block-heading" style="color:#f59e0b">What Is a WordPress Database (And Why Should You Care)?</h2>



<p class="wp-block-paragraph">Let&#8217;s start from absolute zero, because nobody&#8217;s born knowing this. Every WordPress site has two halves. The first is the <em>files</em>, your theme, your images, your plugins. The second is the <em>database</em>: a big, organised filing cabinet that stores all your actual <em>content</em>. Every post, every page, every comment, every setting, every &#8220;remember this user is logged in&#8221; note, it all lives in the database, specifically a system called MySQL (or its near-identical twin, MariaDB).</p>



<p class="wp-block-paragraph">Here&#8217;s the problem. That filing cabinet never cleans itself. WordPress and your plugins are constantly opening drawers, shoving paper in, half-deleting things, and leaving sticky notes everywhere. Over time the drawers get jammed, the folders get fat, and finding anything takes longer. In database terms, that&#8217;s <strong>fragmentation</strong>, <strong>bloat</strong>, and <strong>missing indexes</strong>, and the symptom you actually notice is a slow website. Pages crawl. The admin dashboard lags. Visitors bounce. Google notices. You lose money. Yes, really.</p>



<p class="wp-block-paragraph">This is exactly the gap <strong>Database Boost</strong> fills. It&#8217;s a free WordPress database optimization plugin that does the cleaning, repairing, optimizing, indexing, and maintenance for you, and crucially, it tells you <em>what</em> it&#8217;s doing and <em>why</em>, like a friendly mechanic who actually shows you the worn-out part instead of just handing you a bill.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Why Use Database Boost? The Honest Pitch</h2>



<p class="wp-block-paragraph">There are other database plugins. Some are great. So why this one? Because most of them treat you like you already have a computer science degree, or they hide a &#8220;one-click optimize&#8221; button that does mysterious things you can&#8217;t see or undo. Database Boost was built on a different idea: <strong>a non-technical person should be able to use it safely and understand it.</strong> Here&#8217;s what makes it worth installing.</p>



<h3 class="wp-block-heading">It explains everything like you&#8217;re smart but new</h3>



<p class="wp-block-paragraph">Every screen has plain-language descriptions. &#8220;This table is 40% wasted space&#8221; instead of &#8220;fragmentation ratio 0.40.&#8221; A built-in Docs tab walks you through each feature in a warm, mentor-y tone. You&#8217;re never left guessing what a button does before you press it.</p>



<h3 class="wp-block-heading">It&#8217;s careful before it&#8217;s powerful</h3>



<p class="wp-block-paragraph">Database operations <em>can</em> be destructive, deleting the wrong thing is bad. Database Boost is paranoid on your behalf. It warns you before risky operations, nudges you to back up first, and as of version 1.0.6 it checks every table name against a strict safety rule and a live database allowlist before it ever runs a delete. The database importer makes you type an explicit confirmation and honestly tells you that some operations can&#8217;t be fully undone. That honesty is rare, and it&#8217;s the whole point.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="833" src="https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-cleanup-repair-optimize.webp" alt="WordPress database cleanup, repair and optimize illustration" class="wp-image-5602" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-cleanup-repair-optimize.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-cleanup-repair-optimize-300x208.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-cleanup-repair-optimize-1024x711.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-cleanup-repair-optimize-768x533.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /><figcaption>Repair, optimize, clean up, and index, all from one tidy WordPress admin screen.</figcaption></figure>



<h3 class="wp-block-heading">It&#8217;s free, and it doesn&#8217;t nag you to upgrade</h3>



<p class="wp-block-paragraph">No locked features behind a &#8220;Pro&#8221; paywall on the stuff that matters. The full database management suite, repair, optimize, indexes, diagnostics, cleanup, slow query monitoring, is all included.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What Database Boost Actually Does, Feature by Feature</h2>



<p class="wp-block-paragraph">Let&#8217;s open every drawer. Here&#8217;s the full toolkit, in human terms.</p>



<ul class="wp-block-list">
<li><strong>Table Repair</strong>: Databases can get corrupted, like a scratched DVD. This finds damaged tables and fixes them before they take your whole site down.</li>
<li><strong>Table Optimization</strong>: Remember the jammed filing cabinet? This is the equivalent of taking everything out, throwing away the empty folders, and packing it back tight. Reclaims wasted space, speeds up queries.</li>
<li><strong>Index Management</strong>: An index is like the tab dividers in that filing cabinet. Without them, the database reads <em>every single page</em> to find one thing. Database Boost spots missing indexes and adds them with one click. This is often the single biggest speed win.</li>
<li><strong>Health Diagnostics</strong>: A real-time check-up of your MySQL/MariaDB configuration and table health, with RAM-aware advice (it actually looks at your server&#8217;s memory before telling you what to change).</li>
<li><strong>Slow Query Monitor</strong>: Logs the database requests that are dragging their feet, so you can see <em>exactly</em> what&#8217;s making your site slow instead of guessing.</li>
<li><strong>Cleanup Tool</strong>: Detects and removes debris from plugins you uninstalled long ago. It knows the leftover patterns of <em>hundreds</em> of common plugins.</li>
<li><strong>Maintenance Mode</strong>: Politely puts up a &#8220;back in a minute&#8221; sign during big operations, while keeping admin, login, cron and REST reachable so you don&#8217;t lock yourself out.</li>
<li><strong>Automated Scheduling</strong>: Set it and forget it. Daily health checks via WordPress cron, with optional auto-repair, auto-optimize and auto-index.</li>
<li><strong>Post-Update Safety</strong>: After a WordPress core update, it can automatically re-check indexes and run a conservative cleanup.</li>
<li><strong>WooCommerce Support</strong>: Runs an online shop? It includes all WooCommerce order and product tables in every operation.</li>
<li><strong>WP-CLI Commands</strong>: For the command-line crowd: <code>wp mysql repair|optimize|indexes|diagnostics|cleanup</code>.</li>
<li><strong>Site Health Integration</strong>: Your database status shows up right inside WordPress&#8217;s built-in Site Health screen.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">How to Get Started (No Terminal Required)</h2>



<p class="wp-block-paragraph">Here&#8217;s the beautiful part: you don&#8217;t need to touch a command line, ever. Install and activate the plugin, and you&#8217;ll find a new <strong>Database Boost</strong> menu in your WordPress admin sidebar. The honest, friendly first move is this:</p>



<ol class="wp-block-list">
<li><strong>Back up your site first.</strong> Always. Database Boost will remind you, but do it anyway. A backup is a parachute: you hope you never need it, but you do not jump without one.</li>
<li><strong>Open the Diagnostics tab</strong> and run a Health Check. This is read-only: it looks but doesn&#8217;t touch. It&#8217;ll show you fragmented tables, missing indexes, and config advice.</li>
<li><strong>Run Optimize and Add Indexes</strong> from the action panel. Watch the inline results appear right below the buttons. This is where most sites get a noticeable, immediate speed bump.</li>
<li><strong>Try the Cleanup tab</strong> to clear out old plugin debris and expired transients. It shows you what it found before it removes anything.</li>
<li><strong>Optionally enable scheduling</strong> in Settings so it quietly maintains itself from now on.</li>
</ol>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="800" src="https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-performance-server-room.webp" alt="WordPress database performance and server room concept" class="wp-image-5603" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-performance-server-room.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-performance-server-room-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-performance-server-room-1024x683.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/wordpress-database-performance-server-room-768x512.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /><figcaption>A leaner database means faster pages, happier visitors, and better SEO.</figcaption></figure>



<h2 class="wp-block-heading" style="color:#f59e0b">Is It Safe? Let&#8217;s Be Honest About the Risks</h2>



<p class="wp-block-paragraph">I&#8217;m not going to tell you database tools are risk-free, because that would be a lie and you deserve better. Repairing, optimizing and cleaning a database <em>are</em> powerful operations. Done carelessly, you can lose data. That&#8217;s true of <em>every</em> database plugin, not just this one.</p>



<p class="wp-block-paragraph">What makes Database Boost trustworthy is that it treats that risk with respect instead of hiding it. It runs automated maintenance in a conservative mode, skips operations if another one is already running, batches heavy work so it doesn&#8217;t hammer your server, and flatly tells you when something can&#8217;t be rolled back. Pair it with a backup and a little common sense, don&#8217;t run a giant cleanup five minutes before a product launch, and it&#8217;s genuinely safe for non-technical users. If you also care about keeping attackers out of that database in the first place, our <a href="/2026/05/wordpress-hardening-plugin-modsecurity-crs-block-attacks/">WordPress hardening plugin for ModSecurity CRS</a> is a great companion.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Frequently Asked Questions</h2>



<h3 class="wp-block-heading">Will Database Boost speed up my WordPress site?</h3>



<p class="wp-block-paragraph">Usually, yes, especially if your database has never been optimized or is missing indexes. The biggest gains come from adding missing indexes and reclaiming fragmented space. It&#8217;s not magic (a slow theme is still a slow theme), but for a bloated database it can be a dramatic, free improvement. For full-stack speed, also see our <a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX, PHP-FPM and Redis configuration guide</a>.</p>



<h3 class="wp-block-heading">Do I need to know SQL or use the command line?</h3>



<p class="wp-block-paragraph">No. Everything works through point-and-click screens in the WordPress admin. The WP-CLI commands exist for power users who want them, but they&#8217;re entirely optional. If you&#8217;ve never opened a terminal in your life, you&#8217;ll be completely fine.</p>



<h3 class="wp-block-heading">Could it break my site or delete my content?</h3>



<p class="wp-block-paragraph">Optimize and repair are very safe. Cleanup and import are more powerful, which is why the plugin warns you, asks for explicit confirmation on destructive actions, and recommends a backup first. Always take a backup before a big cleanup, then you&#8217;re protected no matter what.</p>



<h3 class="wp-block-heading">Does it work with WooCommerce?</h3>



<p class="wp-block-paragraph">Yes. WooCommerce stores a <em>lot</em> in the database (orders, products, sessions), so shops bloat fast. Database Boost automatically includes all WooCommerce tables in repair, optimize, indexing and diagnostics.</p>



<h3 class="wp-block-heading">How often should I run it?</h3>



<p class="wp-block-paragraph">For most sites, a monthly optimize is plenty. Busy sites or big shops benefit from the built-in daily scheduled health check with auto-optimize enabled. Set it once in Settings and let it maintain itself.</p>



<h3 class="wp-block-heading">Is Database Boost really free?</h3>



<p class="wp-block-paragraph">Yes, the full feature set is free. If it saves your weekend, there&#8217;s a Donate link in the plugin, entirely optional, much appreciated, never required.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The Bottom Line</h2>



<p class="wp-block-paragraph">Your WordPress database is the engine room of your whole site, and almost nobody maintains it until it&#8217;s already screaming. Database Boost makes that maintenance approachable, safe, and, dare I say, kind of pleasant, because it actually explains itself instead of talking down to you. Clean it, repair it, optimize it, index it, and let it look after itself. Your visitors (and your Google rankings) will quietly thank you.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Related Reading</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX Configuration: PHP-FPM Tuning, FastCGI Cache and Redis</a>: Speed up the rest of your stack once your database is lean.</li>
<li><a href="/2026/05/wordpress-hardening-plugin-modsecurity-crs-block-attacks/">WordPress Hardening Plugin for ModSecurity CRS</a>: Keep attackers away from that freshly-optimized database.</li>
<li><a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/">zstd vs Brotli vs zlib-ng: The NGINX Compression Showdown</a>: Shrink what you send to make pages load even faster.</li>
<li><a href="/2026/05/nginx-rate-limiting-guide/">NGINX Rate Limiting: Protect Your Server from Bots and Brute Force</a>: Stop bots from hammering your database with junk requests.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>How to Install ModSecurity and OWASP CRS on NGINX (Step-by-Step)</title>
		<link>https://deb.myguard.nl/2026/05/how-to-install-modsecurity-owasp-crs-nginx/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sat, 16 May 2026 02:16:40 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[crs]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[modsecurity]]></category>
		<category><![CDATA[owasp]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[waf]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5593</guid>

					<description><![CDATA[A beginner-friendly, step-by-step guide to installing ModSecurity and the OWASP Core Rule Set on NGINX for Debian and Ubuntu — from zero to a live WAF without taking your site down.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Imagine your website is a nightclub. Right now, the door is wide open. Anyone can wander in, paying guests, sure, but also the guy who wants to spray-paint the walls, the one rifling through the coat check, and a suspicious number of robots all wearing the same trench coat trying the back door 4,000 times a minute. You need a bouncer. A good one. One who knows every troublemaker trick in the book and stops them on the pavement, before they ever set foot inside. That bouncer is ModSecurity with the OWASP Core Rule Set, and this guide is exactly how to install ModSecurity NGINX support and wire up the CRS, step by step.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/how-to-install-modsecurity-owasp-crs-nginx.webp" alt="how to install ModSecurity NGINX with the OWASP Core Rule Set" width="1024" height="576" loading="lazy"/></figure>



<p class="wp-block-paragraph">That bouncer is a <strong>Web Application Firewall (WAF)</strong>, and in the open-source world the gold-standard combo is <strong>ModSecurity</strong> (the bouncer) plus the <strong>OWASP Core Rule Set</strong> (the bouncer&#8217;s encyclopaedic memory of every known troublemaker). This guide walks you through <strong>how to install ModSecurity and the OWASP CRS on NGINX</strong>, step by step, from absolutely zero. No prior WAF experience needed. If you can copy and paste into a terminal and not panic when text scrolls past, you can do this.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What is ModSecurity, and what is the <a href="https://coreruleset.org/" rel="noopener" target="_blank">OWASP CRS</a>?</h2>



<p class="wp-block-paragraph">Let&#8217;s get the vocabulary sorted, because these two things get confused constantly and they are <em>not</em> the same.</p>



<p class="wp-block-paragraph"><strong>ModSecurity</strong> is the <em>engine</em>. It&#8217;s a piece of software that plugs into your web server (NGINX, Apache, or via the modern <a href="/nginx-modules/">Coraza/libmodsecurity builds</a>) and inspects every single HTTP request before your application ever sees it. By itself, ModSecurity is a bouncer with no memory, it can stop people, but it doesn&#8217;t know <em>who</em> to stop. It needs rules.</p>



<p class="wp-block-paragraph">The <strong>OWASP Core Rule Set (CRS)</strong> is that memory. It&#8217;s a free, community-maintained, battle-tested collection of detection rules, thousands of them, covering SQL injection, cross-site scripting (XSS), path traversal, remote code execution, protocol abuse, and basically every entry in the OWASP Top 10. The OWASP Foundation maintains it, real security professionals contribute to it, and it&#8217;s the de-facto standard ruleset for ModSecurity worldwide.</p>



<p class="wp-block-paragraph">Put simply: <strong>ModSecurity is the muscle, the CRS is the brain.</strong> You need both. Installing one without the other is like hiring a bouncer and never telling him who&#8217;s banned.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Before you learn how to install ModSecurity NGINX: what you need</h2>



<ul class="wp-block-list">
<li>A server running <strong>Debian or Ubuntu</strong> with NGINX installed (this guide assumes Debian/Ubuntu paths).</li>
<li><strong>Root or sudo access.</strong> You&#8217;ll be editing system config and reloading NGINX.</li>
<li><strong>An NGINX build with the ModSecurity module.</strong> This is the part most tutorials hand-wave. Stock distro NGINX does <em>not</em> include ModSecurity: you&#8217;d normally have to compile NGINX from source with <code>--add-module</code>, which is a multi-hour adventure in dependency pain. Our <a href="/nginx-modules/">optimized NGINX builds for Debian and Ubuntu</a> ship the <code>http-modsecurity</code> module precompiled, so this becomes a one-line <code>apt install</code>. We&#8217;ll assume that route.</li>
<li>About 20 minutes and a cup of something warm.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">Step 1, how to install ModSecurity NGINX module</h2>



<p class="wp-block-paragraph">If you&#8217;re using our repository, the ModSecurity-enabled NGINX and the dynamic module come straight from <code>apt</code>:</p>



<pre class="wp-block-code"><code># Install NGINX and the ModSecurity dynamic module
sudo apt update
sudo apt install nginx nginx-module-modsecurity

# Verify the module file exists
ls -l /usr/lib/nginx/modules/ | grep modsecurity</code></pre>



<p class="wp-block-paragraph">You should see something like <code>ngx_http_modsecurity_module.so</code>. That <code>.so</code> file is the bridge between NGINX and the ModSecurity engine. If you compiled NGINX yourself instead, the module lives wherever your <code>--modules-path</code> pointed, adjust accordingly.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Step 2, Load the ModSecurity Module in NGINX</h2>



<p class="wp-block-paragraph">NGINX won&#8217;t use the module until you tell it to. Open your main config (<code>/etc/nginx/nginx.conf</code>) and add this line at the <em>very top</em>, before the <code>events {}</code> block, load_module directives have to come first:</p>



<pre class="wp-block-code"><code>load_module modules/ngx_http_modsecurity_module.so;</code></pre>



<p class="wp-block-paragraph">Then, inside the <code>http {}</code> block, switch ModSecurity on and point it at a rules file we&#8217;ll create in a moment:</p>



<pre class="wp-block-code"><code>http {
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/main.conf;

    # ... your existing http config ...
}</code></pre>



<p class="wp-block-paragraph"><code>modsecurity on;</code> arms the engine globally. <code>modsecurity_rules_file</code> tells it which rulebook to read. You can also place <code>modsecurity on;</code> inside a specific <code>server {}</code> or <code>location {}</code> block if you only want to protect part of your site, but for a WordPress site, protecting everything is the right call.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Step 3, Install the ModSecurity Base Configuration</h2>



<p class="wp-block-paragraph">ModSecurity ships a recommended baseline config. We&#8217;ll set up a clean directory and wire it together:</p>



<pre class="wp-block-code"><code># Create the ModSecurity config directory
sudo mkdir -p /etc/nginx/modsec

# Grab the recommended base config
sudo wget -O /etc/nginx/modsec/modsecurity.conf 
  https://raw.githubusercontent.com/owasp-modsecurity/ModSecurity/v3/master/modsecurity.conf-recommended

# Grab the unicode mapping file it references
sudo wget -O /etc/nginx/modsec/unicode.mapping 
  https://raw.githubusercontent.com/owasp-modsecurity/ModSecurity/v3/master/unicode.mapping</code></pre>



<p class="wp-block-paragraph">The single most important setting in that base file is the engine mode. Open <code>/etc/nginx/modsec/modsecurity.conf</code> and find this line:</p>



<pre class="wp-block-code"><code>SecRuleEngine DetectionOnly</code></pre>



<p class="wp-block-paragraph"><strong>Leave it as <code>DetectionOnly</code> for now.</strong> This is the golden rule of WAF deployment that everyone ignores and then regrets. In <code>DetectionOnly</code> mode, ModSecurity watches everything and logs what it <em>would</em> have blocked, but doesn&#8217;t actually block anything. This lets you discover false positives (legitimate traffic the rules mistakenly flag) <em>before</em> they take your site down. We&#8217;ll flip it to full blocking in Step 6, after we&#8217;ve checked the logs. Patience now saves a 3 a.m. outage later.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Step 4, Install the OWASP Core Rule Set</h2>



<p class="wp-block-paragraph">Now the brain. We&#8217;ll pull the latest CRS 4.x release and put it where ModSecurity can find it:</p>



<pre class="wp-block-code"><code># Clone the OWASP CRS (v4 branch)
cd /etc/nginx/modsec
sudo git clone https://github.com/coreruleset/coreruleset.git owasp-crs

# Activate the CRS setup file (it ships as .example so you can customise safely)
sudo cp owasp-crs/crs-setup.conf.example owasp-crs/crs-setup.conf</code></pre>



<p class="wp-block-paragraph">The <code>crs-setup.conf</code> file is where you tune the CRS&#8217;s behaviour, most importantly the <strong>Paranoia Level</strong>. Quick explainer: paranoia level is a dial from 1 to 4 that controls how suspicious the rules are. PL1 (the default) catches obvious attacks with very few false positives, the right starting point for almost everyone. PL4 catches everything including its own shadow and will absolutely flag legitimate traffic. <strong>Start at PL1.</strong> You can raise it later once you understand your traffic.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Step 5, Wire It All Together</h2>



<p class="wp-block-paragraph">Remember <code>modsecurity_rules_file /etc/nginx/modsec/main.conf;</code> from Step 2? We need to create that <code>main.conf</code>. It&#8217;s the master include file that loads everything in the correct order, base config first, then CRS setup, then the CRS rules themselves:</p>



<pre class="wp-block-code"><code>sudo tee /etc/nginx/modsec/main.conf &gt; /dev/null &lt;&lt;'EOF'
# 1. ModSecurity base configuration
Include /etc/nginx/modsec/modsecurity.conf

# 2. OWASP CRS setup (paranoia level, anomaly thresholds, etc.)
Include /etc/nginx/modsec/owasp-crs/crs-setup.conf

# 3. The actual OWASP CRS detection rules
Include /etc/nginx/modsec/owasp-crs/rules/*.conf
EOF</code></pre>



<p class="wp-block-paragraph">Order matters here. The base config sets the engine behaviour, <code>crs-setup.conf</code> configures the ruleset, and the <code>rules/*.conf</code> glob pulls in every detection rule. Get the order wrong and rules reference variables that don&#8217;t exist yet.</p>



<p class="wp-block-paragraph">Now test the NGINX config and reload:</p>



<pre class="wp-block-code"><code>sudo nginx -t
sudo systemctl reload nginx</code></pre>



<p class="wp-block-paragraph">If <code>nginx -t</code> says <code>syntax is ok</code> and <code>test is successful</code>, congratulations, ModSecurity and the OWASP CRS are now running on your server in detection mode. The bouncer is at the door, watching, taking notes, not yet throwing anyone out.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Step 6, Watch the Logs, Then Go Live</h2>



<p class="wp-block-paragraph">This is the step that separates people whose sites stay online from people who tweet &#8220;why is my site down&#8221; at midnight. Let detection mode run for <strong>a few days under real traffic</strong>. Then read the audit log to see what the CRS flagged:</p>



<pre class="wp-block-code"><code># The audit log location is set in modsecurity.conf (SecAuditLog)
sudo tail -f /var/log/modsec_audit.log</code></pre>



<p class="wp-block-paragraph">You&#8217;re hunting for <strong>false positives</strong>, legitimate things on your site (a plugin&#8217;s AJAX call, a form with rich text, an admin action) that the rules flagged as attacks. For each one, you write a targeted exclusion rule so that specific legitimate request is allowed, without weakening protection everywhere else. For WordPress specifically, this is largely a solved problem, see the next section.</p>



<p class="wp-block-paragraph">Once the logs are clean (or you&#8217;ve excluded the known false positives), flip the switch. Edit <code>/etc/nginx/modsec/modsecurity.conf</code>:</p>



<pre class="wp-block-code"><code># Change this:
SecRuleEngine DetectionOnly
# To this:
SecRuleEngine On</code></pre>



<pre class="wp-block-code"><code>sudo nginx -t && sudo systemctl reload nginx</code></pre>



<p class="wp-block-paragraph">The bouncer is now active. Attacks get blocked before they reach PHP, MySQL, or WordPress. You did it.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">WordPress-Specific Tuning: Don&#8217;t Skip This</h2>



<p class="wp-block-paragraph">The stock CRS is a generalist, it protects a banking app and a recipe blog with the same rules. WordPress has very specific quirks (the Gutenberg editor sends complex POST bodies that can trip XSS rules; the REST API has its own patterns). Running raw CRS against WordPress without tuning <em>will</em> produce false positives in the admin area.</p>



<p class="wp-block-paragraph">Two free CRS plugins solve this, and you should install both:</p>



<ul class="wp-block-list">
<li><strong><a href="https://github.com/coreruleset/wordpress-rule-exclusions-plugin" target="_blank" rel="noopener">wordpress-rule-exclusions-plugin</a></strong>: official CRS plugin that suppresses the known WordPress false positives (Gutenberg, the customizer, etc.) so the admin area works normally.</li>
<li><strong><a href="/2026/05/wordpress-hardening-plugin-modsecurity-crs-block-attacks/">wordpress-hardening-plugin</a></strong>: adds 25+ <em>extra</em> WordPress-specific protections on top of CRS: blocking <code>xmlrpc.php</code>, stopping user enumeration, rate-limiting <code>wp-login.php</code> brute force, GeoIP login restrictions, and IP reputation blocking. We wrote a full deep-dive on it, it turns generic CRS into a WordPress-aware fortress.</li>
</ul>



<p class="wp-block-paragraph">Both follow the CRS 4.0 plugin standard, drop them in the <code>owasp-crs/plugins/</code> directory and they load automatically. Exclusions plugin first (removes false positives), hardening plugin second (adds protection). Together they give you a WAF that actually understands WordPress.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Related Reading</h2>



<ul class="wp-block-list">
<li><a href="/2026/06/coraza-waf-nginx-modsecurity-replacement/">Coraza WAF on NGINX: The Go-Powered ModSecurity Replacement</a>: Prefer a memory-safe engine? Coraza runs this exact OWASP CRS setup on a Go rewrite of ModSecurity, with no C++ in the request path.</li>
<li><a href="/2026/05/wordpress-hardening-plugin-modsecurity-crs-block-attacks/">WordPress Hardening Plugin for ModSecurity CRS: Block Attacks Without Touching Your PHP</a>: The deep dive on the companion hardening plugin: all 25+ rules, rate limiting, GeoIP and IP reputation explained.</li>
<li><a href="/nginx-modules/">NGINX Mainline: Optimized and Extended with 50+ Modules</a>, Our NGINX builds ship the http-modsecurity module precompiled, so Step 1 is a one-line apt install instead of a from-source build.</li>
<li><a href="/2026/05/nginx-rate-limiting-guide/">NGINX Rate Limiting: Protect Your Server from Bots, Scrapers and Brute Force</a>: Pair native NGINX rate limiting with your new WAF for defence in depth.</li>
<li><a href="/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/">zstd vs Brotli vs zlib-ng: The NGINX Compression Showdown</a>: Now your site is secure, make it fast too with modern compression.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">FAQ</h2>



<h3 class="wp-block-heading">Does ModSecurity slow down NGINX?</h3>



<p class="wp-block-paragraph">There&#8217;s a small per-request CPU cost for rule inspection, typically a few milliseconds at Paranoia Level 1. For most sites this is completely unnoticeable and is massively outweighed by the benefit: blocked malicious requests never reach PHP, MySQL or WordPress, which actually <em>reduces</em> total server load during an attack. Keep the paranoia level sensible (PL1 for most sites) and the overhead stays negligible.</p>



<h3 class="wp-block-heading">What&#8217;s the difference between ModSecurity and the OWASP CRS?</h3>



<p class="wp-block-paragraph">ModSecurity is the WAF engine, the software that inspects requests. The OWASP Core Rule Set is the collection of detection rules that tells the engine what an attack looks like. ModSecurity without rules does nothing useful; the CRS without an engine is just text files. You install both together: the engine plus its rulebook.</p>



<h3 class="wp-block-heading">Why should I start in DetectionOnly mode?</h3>



<p class="wp-block-paragraph">Because every site has unique traffic, and the CRS occasionally flags legitimate requests as attacks (false positives). If you go straight to blocking mode, those false positives become real outages, broken admin pages, failed form submissions, locked-out users. DetectionOnly lets you observe what <em>would</em> be blocked, fix the false positives with exclusion rules, and only then enable real blocking. It&#8217;s the difference between a smooth rollout and an emergency.</p>



<h3 class="wp-block-heading">What is a Paranoia Level in the OWASP CRS?</h3>



<p class="wp-block-paragraph">It&#8217;s a sensitivity dial from 1 to 4. PL1 catches clear, unambiguous attacks with very few false positives, the recommended default. Higher levels add increasingly aggressive rules that catch more subtle attacks but also flag more legitimate traffic. Most production sites run at PL1 or PL2. Only raise it if you understand your traffic well and have a low tolerance for risk plus the time to manage exclusions.</p>



<h3 class="wp-block-heading">Do I need ModSecurity if I already use a WordPress security plugin?</h3>



<p class="wp-block-paragraph">They work at different layers. A PHP security plugin (Wordfence, etc.) runs <em>inside</em> WordPress, which means WordPress and PHP already loaded before it can act. ModSecurity blocks malicious requests at the NGINX layer, before PHP even starts. That&#8217;s far more efficient under attack and removes a whole class of risk. For performance-conscious sites, a WAF is the stronger foundation; you can still run a PHP plugin on top for file-integrity monitoring and alerting.</p>



<h3 class="wp-block-heading">Will this work on Apache instead of NGINX?</h3>



<p class="wp-block-paragraph">Yes. ModSecurity and the OWASP CRS are server-agnostic at the rules level. The engine installation differs (Apache uses <code>mod_security2</code> with its own config paths) but Steps 3–6, the base config, CRS install, main.conf wiring, and DetectionOnly-then-On rollout, are essentially identical. The CRS rules themselves are byte-for-byte the same.</p>



<h3 class="wp-block-heading">How do I update the OWASP CRS later?</h3>



<p class="wp-block-paragraph">Since you installed it via git, updating is <code>cd /etc/nginx/modsec/owasp-crs &amp;&amp; sudo git pull</code>, then <code>sudo nginx -t &amp;&amp; sudo systemctl reload nginx</code>. Always test in DetectionOnly-equivalent caution after a major version bump, new rules can introduce new false positives. Subscribe to the CRS release notes so you know when security-relevant rules change.</p>



<p class="wp-block-paragraph"><em>Our <a href="/nginx-modules/">optimized NGINX and Angie builds for Debian and Ubuntu</a> ship the http-modsecurity module precompiled, turning the hardest part of this guide into a single apt install.</em></p>


]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Zstd vs Brotli vs zlib-ng: The NGINX Compression Deep Dive</title>
		<link>https://deb.myguard.nl/2026/05/nginx-zstd-vs-brotli-vs-zlib-ng-compression/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sat, 16 May 2026 02:13:33 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[brotli]]></category>
		<category><![CDATA[compression]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[nginx-module]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[ubuntu]]></category>
		<category><![CDATA[zstd]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5588</guid>

					<description><![CDATA[Zstd vs Brotli vs zlib-ng only makes sense once you separate browser encodings from compression engines. This deep dive covers support, CPU trade-offs, static vs dynamic compression, and the NGINX production patterns that actually work.]]></description>
										<content:encoded><![CDATA[<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="1600" height="900" src="https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-vs-brotli-zlib-ng-network-speed.webp" alt="Zstd vs Brotli vs zlib-ng compression speed over a network in NGINX" class="wp-image-5648" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-vs-brotli-zlib-ng-network-speed.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-vs-brotli-zlib-ng-network-speed-300x169.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-vs-brotli-zlib-ng-network-speed-1024x576.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-vs-brotli-zlib-ng-network-speed-768x432.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-vs-brotli-zlib-ng-network-speed-1536x864.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /><figcaption>The short version: this is not really a three-way fight. Two of these are browser-facing encodings. One is the engine that can make gzip cheaper to produce.</figcaption></figure>
<p>Most articles about <strong>zstd vs Brotli vs zlib-ng</strong> accidentally compare a fruit salad to a turbocharger. That is the first problem we are going to fix.</p>
<p><strong>Brotli</strong> and <strong>Zstd</strong> are compression formats that can show up in HTTP as <code>Content-Encoding: br</code> and <code>Content-Encoding: zstd</code>. Browsers can explicitly ask for them with <code>Accept-Encoding</code>. <strong>zlib-ng</strong>, on the other hand, is not a new browser encoding at all. It is a modern, optimized replacement for the old zlib library that powers <strong>gzip/deflate</strong>. The browser still sees gzip. The server just spends less effort making it.</p>
<p>That distinction matters because it changes the answer to every practical question. If you ask, “Which one gives me the best universal compatibility?”, zlib-ng is not competing with Brotli or Zstd in the same lane. If you ask, “Which one should I send to modern browsers?”, now Brotli and Zstd are in the spotlight, and zlib-ng becomes an optimization strategy for your gzip fallback path.</p>
<p>This rewrite takes the original comparison article and turns it into the grown-up version. We are going deep on what each option actually changes, where it fits in <a href="/nginx-modules/">our NGINX builds</a> and <a href="/angie-modules-optimized-extended/">our Angie builds</a>, how static and dynamic compression differ, and which production combinations make sense when real traffic, real caches, and real CPU budgets show up to ruin the fantasy.</p>
<h2 style="color:#f59e0b">The one-sentence answer</h2>
<p>If you only have patience for one paragraph, here it is:</p>
<ul>
<li><strong>gzip</strong> is still the universal baseline every browser understands.</li>
<li><strong>zlib-ng</strong> makes that gzip path faster and cheaper, but does not create a new browser-visible encoding.</li>
<li><strong>Brotli</strong> usually wins on compression ratio for static text assets like CSS and JavaScript.</li>
<li><strong>Zstd</strong> offers a very attractive speed-to-ratio balance for modern clients and is increasingly practical for web delivery.</li>
<li><strong>The production answer is usually “run more than one”</strong>, not “pick one and become emotionally attached.”</li>
</ul>
<h2 style="color:#f59e0b">What each thing actually is</h2>
<table class="wp-block-table">
<thead>
<tr>
<th>Thing</th>
<th>What it is</th>
<th>Browser sees</th>
<th>Best mental model</th>
</tr>
</thead>
<tbody>
<tr>
<td>gzip</td>
<td>Old reliable HTTP compression format</td>
<td><code>Content-Encoding: gzip</code></td>
<td>The universal fallback</td>
</tr>
<tr>
<td>Brotli</td>
<td>Compression format and library</td>
<td><code>Content-Encoding: br</code></td>
<td>The high-squeeze specialist</td>
</tr>
<tr>
<td>Zstd</td>
<td>Compression format and library</td>
<td><code>Content-Encoding: zstd</code></td>
<td>The fast modern all-rounder</td>
</tr>
<tr>
<td>zlib-ng</td>
<td>Optimized zlib-compatible deflate engine</td>
<td>Usually still <code>gzip</code></td>
<td>The performance upgrade under gzip</td>
</tr>
</tbody>
</table>
<p>That last row is the one that trips people. zlib-ng is closer to “a better engine under the same car hood” than “a different car.” If your NGINX build links the gzip path against zlib-ng, you do not suddenly start sending <code>Content-Encoding: zlib-ng</code>. That header does not exist. You are still serving gzip-compatible output. You are just doing the work with a faster, more modern implementation.</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="675" src="https://deb.myguard.nl/wp-content/uploads/2026/05/compression-format-vs-engine-explained-diagram.svg" alt="Diagram explaining compression formats versus the zlib-ng engine in the NGINX stack" class="wp-image-5650" /><figcaption>This is the conceptual fix most comparison posts skip: Brotli and Zstd are delivery formats; zlib-ng is an optimized deflate engine.</figcaption></figure>
<h2 style="color:#f59e0b">gzip and zlib-ng: the boring baseline that still pays the bills</h2>
<p>gzip is old, yes. It is also still incredibly useful. Every browser, every proxy, every weird embedded client, and every corporate security appliance from the Bronze Age understands it. That alone gives gzip a job title that neither Brotli nor Zstd can fully steal yet.</p>
<p>Where zlib-ng enters the story is on the server side. The zlib-ng project describes itself as a next-generation zlib-compatible data compression library with major speed improvements and a drop-in compatibility path. In practice, that means you can keep the same <strong>gzip-compatible output</strong> while asking modern CPUs to do the work more efficiently. If your workload has a lot of dynamic gzip compression, that can be a very nice trade.</p>
<p>The killer detail: <strong>there is no NGINX directive called something like <code>zlib_ng on;</code></strong>. This is not a runtime toggle. It is a build and linking decision. If your NGINX or Angie packages are built against zlib-ng in the deflate path, gzip gets cheaper automatically. If they are not, no amount of configuration yoga will conjure it into existence.</p>
<p>So when should you care?</p>
<ul>
<li>If you serve mixed traffic and need the safest broad-compatibility compression path, gzip remains mandatory.</li>
<li>If that gzip path burns more CPU than you would like, zlib-ng is attractive because it improves the familiar path instead of forcing a new client capability requirement.</li>
<li>If your CDN or reverse proxy layer already handles most compression, zlib-ng may matter less at the origin.</li>
</ul>
<h2 style="color:#f59e0b">Brotli: still the ratio champion for many static assets</h2>
<p>Brotli exists for the moment when you look at a CSS or JavaScript bundle and say, “Can you be smaller? No, smaller than that.” The Brotli project describes it as a generic-purpose lossless compression algorithm that offers more dense compression than deflate while staying in a similar speed neighborhood. That density is why Brotli became such a strong choice for precompressed static assets.</p>
<p>For static files, especially CSS, JavaScript, SVG, and sometimes HTML, Brotli at higher levels can squeeze out wonderfully small payloads. That is why you so often see teams precompress assets during build or deploy time and then serve them with <code>brotli_static</code>. The expensive compression work happens once, not on every request. The browser gets the smaller file over and over, and everybody goes home happy.</p>
<p>Where Brotli gets less magical is hot dynamic compression. Higher Brotli levels can be expensive enough that turning them on blindly for every response is a good way to heat the room and disappoint the latency graph. The more dynamic your content is, and the more requests you have to compress on the fly, the more careful you need to be with levels and response targeting.</p>
<p>That does not make Brotli bad. It just makes it best when used with intent. Static text assets are its natural habitat.</p>
<h2 style="color:#f59e0b">Zstd: the modern balance people hoped for</h2>
<p>Zstd is the new star because it attacks the usual compression compromise from a better angle. The official project leans hard on two traits: a wide speed-vs-ratio tuning range and a very fast decoder. That second part matters more than many people realize. Compression happens once on the server side, but decompression happens on every client. Fast decode is a deeply practical feature, not just benchmark decoration.</p>
<p>In the web context, Zstd is interesting because it often feels like the adult in the room between gzip and Brotli. Gzip is universal but old. Brotli can squeeze harder but may ask for more patience. Zstd often lands in the “strong compression, fast enough to use dynamically, fast to decode on clients” zone that ops teams actually want.</p>
<p>The catch is deployment maturity. Browser support is now good enough to matter, but stock NGINX still does not just hand you Zstd out of the box. In our repository, Zstd support comes from the hardened <code>http-zstd</code> module, the same module discussed in our explainer on <a href="/2026/05/what-is-zstd-nginx-angie-browser-support/">what Zstd is and how it fits into NGINX and Angie</a>. That makes Zstd very practical for our builds, but it is still a deliberate module story, not a default checkbox in upstream open-source NGINX documentation.</p>
<h2 style="color:#f59e0b">Dynamic vs static compression changes the answer</h2>
<p>A lot of “which compression is best?” arguments quietly ignore the most important operational question: <strong>are you compressing dynamically on every request, or serving precompressed static files?</strong></p>
<p>If the response is static, you can be much more aggressive. You compress once during build or deploy, then serve the file repeatedly. That favors Brotli strongly, and it can favor Zstd too if your client population is modern enough. In NGINX terms, that means features like <code>gzip_static</code>, <code>brotli_static</code>, and <code>zstd_static</code>.</p>
<p>If the response is dynamic, the CPU cost lands in the request path. Now the best choice depends on how hot the route is, how much origin CPU you have, how cacheable the responses are, and how modern the client set is. This is where Zstd starts looking more attractive, and where zlib-ng can quietly improve the universal gzip fallback you cannot get rid of anyway.</p>
<p>Precompression examples look like this:</p>
<pre class="wp-block-code"><code>brotli -k -q 11 app.js
zstd -3 -k app.js
gzip -9 -k app.js</code></pre>
<p>Then NGINX or Angie can serve the right variant when the client advertises support. That is a very different life from compressing a freshly rendered HTML or JSON response at the origin on every request.</p>
<h2 style="color:#f59e0b">A sane NGINX stack in practice</h2>
<p>If your build includes the modules, the most pragmatic setup is usually layered, not exclusive:</p>
<pre class="wp-block-code"><code>http {
    gzip on;
    gzip_vary on;
    gzip_min_length 1000;
    gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;

    brotli on;
    brotli_comp_level 5;
    brotli_static on;
    brotli_types text/plain text/css application/javascript application/json application/xml image/svg+xml;

    zstd on;
    zstd_comp_level 3;
    zstd_min_length 1000;
    zstd_static on;
    zstd_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
}</code></pre>
<p>And here is the critical note that people miss when they paste monster configs from random forums: <strong>zlib-ng has no directive in that block</strong>. If your packages were built with zlib-ng for the deflate path, the gzip part gets the benefit automatically. If they were not, the config stays the same and nothing magical happens.</p>
<p>That is why package choice matters. If you want all the compression toys in one place, the easiest path is using <a href="/nginx-modules/">the NGINX modules builds</a> or <a href="/angie-modules-optimized-extended/">the Angie modules builds</a> rather than turning your weekend into an artisanal compile-and-link disaster.</p>
<h2 style="color:#f59e0b">Caches, CDNs, and the part people always forget</h2>
<p>Compression choices do not live alone. They interact with caching, CDN behavior, origin CPU, and negotiation headers.</p>
<ul>
<li><strong>You need <code>Vary: Accept-Encoding</code></strong> so caches do not mix compressed variants incorrectly.</li>
<li><strong>If your CDN already compresses aggressively</strong>, your origin may not need expensive dynamic compression for every response.</li>
<li><strong>Precompressed static assets are easier to reason about</strong> than heavy dynamic compression on hot paths.</li>
<li><strong>Different compression variants mean different cache objects</strong>, which is good for correctness but relevant for cache footprint.</li>
<li><strong>APIs and HTML are not the same workload</strong>, so a single rule for everything is usually lazy rather than elegant.</li>
</ul>
<p>This is also where the choice between Brotli and Zstd becomes less ideological and more situational. Brotli often shines for static asset distribution. Zstd can make a lot of sense for dynamic responses to modern clients. gzip remains the compatibility blanket. zlib-ng makes that blanket cheaper to knit.</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1600" height="900" src="https://deb.myguard.nl/wp-content/uploads/2026/05/zlib-ng-deflate-engine-under-the-hood.webp" alt="zlib-ng is a faster deflate engine under the gzip hood, like a tuned car engine" class="wp-image-5649" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/zlib-ng-deflate-engine-under-the-hood.webp 1600w, https://deb.myguard.nl/wp-content/uploads/2026/05/zlib-ng-deflate-engine-under-the-hood-300x169.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/zlib-ng-deflate-engine-under-the-hood-1024x576.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/zlib-ng-deflate-engine-under-the-hood-768x432.webp 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/zlib-ng-deflate-engine-under-the-hood-1536x864.webp 1536w" sizes="auto, (max-width: 1600px) 100vw, 1600px" /><figcaption>zlib-ng is the better engine under the same hood: the useful production answer is almost always a combination strategy, not a single winner with a dramatic soundtrack.</figcaption></figure>
<h2 style="color:#f59e0b">So what should you actually run?</h2>
<p>Here is the practical recommendation, stripped of benchmark cosplay:</p>
<ul>
<li><strong>For universal compatibility:</strong> keep gzip enabled. It is still your seatbelt.</li>
<li><strong>If your build supports zlib-ng:</strong> use it to make the gzip path faster and cheaper.</li>
<li><strong>For static text assets:</strong> precompressed Brotli is still extremely hard to beat.</li>
<li><strong>For modern-client dynamic compression:</strong> add Zstd where your module support and traffic profile justify it.</li>
<li><strong>For mixed real-world traffic:</strong> run multiple encodings and let content negotiation do its job instead of trying to crown one universal king.</li>
</ul>
<p>If you run only one thing because you want life to stay simple, run gzip and make it efficient. If you want the best real-world stack, run gzip as fallback, Brotli for static assets, and Zstd for modern clients where supported. That is the answer infrastructure people usually land on after the fun part of the benchmark thread wears off.</p>
<h2 style="color:#f59e0b">Final verdict</h2>
<p><strong>Brotli</strong> is the “squeeze it harder” specialist. <strong>Zstd</strong> is the “modern, fast, balanced” option. <strong>zlib-ng</strong> is the “stop paying so much CPU for gzip” upgrade. They are not interchangeable. They are pieces of a compression stack.</p>
<p>So what matters is not “Which one wins?” What matters is “Which layer of my delivery path am I trying to improve?” Once you ask that question, the fog lifts very quickly.</p>
<p>If you want the beginner-friendly background first, read <a href="/2026/05/what-is-zstd-nginx-angie-browser-support/">the Zstd explainer</a>. If you want the broader performance and security context around modules, HTTP/3, caching, and TLS, the <a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">NGINX and Angie expert guide</a> is the next stop.</p>
<h2 style="color:#f59e0b">Frequently Asked Questions</h2>
<h3>Is zlib-ng a replacement for Brotli or Zstd?</h3>
<p>No. zlib-ng is a faster zlib-compatible engine for the deflate/gzip path. It does not create a new browser-visible compression format. Brotli and Zstd are actual encodings the browser can negotiate.</p>
<h3>Should I replace Brotli with Zstd?</h3>
<p>Usually not as a blanket rule. Brotli is still excellent for precompressed static assets. Zstd becomes attractive when you want a strong speed-to-ratio balance for modern clients, especially on dynamic paths.</p>
<h3>Should I replace gzip with Zstd?</h3>
<p>Not completely. gzip remains the universal fallback because support is essentially everywhere. The sane move is to keep gzip, then add Brotli and Zstd where they help.</p>
<h3>Does zlib-ng need extra NGINX config directives?</h3>
<p>No runtime directives in the usual sense. The key choice is whether your NGINX or Angie build links the deflate path against zlib-ng. If it does, your gzip path benefits automatically.</p>
<h3>What is the best setup for a public website?</h3>
<p>For most public sites: gzip fallback, Brotli for static assets, optional Zstd for modern browsers, and correct <code>Vary: Accept-Encoding</code> handling. That gives you reach, ratio, and sensible origin CPU behavior.</p>
<h3>What is the best setup for APIs?</h3>
<p>APIs often benefit from fast dynamic compression more than from maximum squeeze-at-all-costs behavior. That makes Zstd especially interesting for modern clients, while gzip remains the fallback for older tooling and long-tail compatibility.</p>
<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
<li><a href="/2026/05/what-is-zstd-nginx-angie-browser-support/">What Is Zstd? NGINX, Angie, History and Browser Support</a>: the beginner-friendly foundation for readers who want the backstory before the production decisions.</li>
<li><a href="/nginx-modules/">NGINX Modules optimized &amp; extended</a>: the package page if you want these modules without hand-building your own stack.</li>
<li><a href="/angie-modules-optimized-extended/">Angie modules optimized &amp; extended</a>: the Angie package page with the same practical angle.</li>
<li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">Nginx &amp; Angie: The Expert Guide to Maximum Performance and Security</a>: the bigger-picture guide once compression leads you into the rest of the tuning rabbit hole.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Angie 1.11.5 Released: 5 Security Fixes Explained</title>
		<link>https://deb.myguard.nl/2026/05/angie-1-11-5-release-security-fixes/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sat, 16 May 2026 01:12:22 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[angie]]></category>
		<category><![CDATA[bug-fix]]></category>
		<category><![CDATA[cve]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[http3]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5577</guid>

					<description><![CDATA[Angie 1.11.5 fixes five upstream security issues, including HTTP/3, OCSP, rewrite, SCGI/UWSGI, and charset handling hardening. Here is what changed and why it matters.]]></description>
										<content:encoded><![CDATA[<p><strong>Angie 1.11.5</strong> has landed, and this is one of those releases where the tiny version bump hides a very loud message: update your server. The official Angie 1.11.5 release ships <strong>five security fixes</strong>, including hardening for rewrite processing, OCSP handling, HTTP/3, SCGI/UWSGI proxying, and charset decoding. If your site is public-facing, this is not a &#8220;maybe later&#8221; patch. It is a &#8220;put the coffee down and schedule the update&#8221; patch.</p>
<p>If you want the ready-to-deploy version with performance tuning, modern TLS, and a truckload of useful modules already lined up, have a look at <a href="/angie-modules-optimized-extended/">our Angie stack</a>. That page is the home base for our Angie packages, modules, optimizations, and build choices.</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1200" height="700" src="https://deb.myguard.nl/wp-content/uploads/2026/05/angie-1-11-5-security-fixes.webp" alt="Angie 1.11.5 security fixes overview" class="wp-image-5579" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/angie-1-11-5-security-fixes.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/angie-1-11-5-security-fixes-300x175.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/angie-1-11-5-security-fixes-1024x597.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/angie-1-11-5-security-fixes-768x448.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /></figure>
<h2 style="color:#f59e0b">What changed in Angie 1.11.5?</h2>
<p>In plain English: Angie 1.11.5 is a security release. No glitter cannon. No &#8220;look mum, new dashboard&#8221; moment. Just five fixes that close doors you do not want strangers testing at three in the morning.</p>
<p>The upstream release notes list these issues:</p>
<ul>
<li><strong>CVE-2026-42945</strong> in rewrite processing, where a specific chain of unnamed captures and query strings could crash a worker process and, on systems without address space randomization, open the door to worse.</li>
<li><strong>CVE-2026-40701</strong> in <code>ssl_ocsp</code> handling, where freed memory could still be touched while processing DNS responses.</li>
<li><strong>CVE-2026-40460</strong> in HTTP/3, where IP spoofing could bypass restrictions or authorization in some setups.</li>
<li><strong>CVE-2026-42946</strong> for <code>scgi_pass</code> or <code>uwsgi_pass</code>, where a malicious upstream response could trigger excessive memory allocation, over-read data, leak worker memory, or crash the process.</li>
<li><strong>CVE-2026-42934</strong> in <code>charset_map</code> UTF-8 decoding, where a specially crafted response could cause an out-of-bounds read.</li>
</ul>
<p>That is a lot of security mileage from one patch release. Or, to put it less politely: if you expose Angie to the internet, skipping this update is like locking your front door and leaving the kitchen window open because &#8220;it is only a small window&#8221;.</p>
<h2 style="color:#f59e0b">Why this release matters more than the version number suggests</h2>
<p>Patch releases are often treated like the salad on the plate. Everyone knows it is probably good for them, but the burger of &#8220;I will do it next week&#8221; usually wins. Angie 1.11.5 is not that kind of patch.</p>
<p>Several of these fixes touch things people actually enable in real deployments: rewrite rules, OCSP, HTTP/3, SCGI, UWSGI, and character set handling. In other words, this is not a museum-piece bug collection. These are features that show up in reverse proxies, WordPress stacks, app gateways, and API frontends every day.</p>
<p>That is why the Angie 1.11.5 release matters. It is security work in boring clothes. And boring clothes are exactly what you want from infrastructure. If your web server is exciting, something has usually gone very wrong.</p>
<h2 style="color:#f59e0b">The five Angie 1.11.5 fixes, explained like a normal person</h2>
<h3>1. Rewrite rules stop being crash bait</h3>
<p>The rewrite bug is the kind of thing that makes config-heavy setups nervous. If you use rewrite logic with capture groups such as <code>$1</code> and query strings, a specially crafted request could crash workers under the right conditions. Angie 1.11.5 closes that hole. If your config contains clever rewrite gymnastics, this fix is your sign to stop living dangerously.</p>
<h3>2. OCSP handling gets safer</h3>
<p>OCSP is part of certificate validation. It helps your server decide whether a certificate should still be trusted. The fixed bug involved use-after-free behaviour while processing DNS replies. That is fancy developer language for &#8220;the software might touch memory it already threw away&#8221;. Computers hate that. Attackers love that. Angie 1.11.5 removes that risk.</p>
<h3>3. HTTP/3 gets a real security cleanup</h3>
<p>HTTP/3 is fast, modern, and a little bit dramatic, because every new protocol arrives with its own box of edge cases. In this release, Angie fixes a bug where attackers could spoof IP addresses in some HTTP/3 configurations and bypass restrictions or authorization. If you turned on HTTP/3 because you enjoy low latency and shiny transport layers, yes, you also want this fix.</p>
<h3>4. SCGI and UWSGI deployments get more solid</h3>
<p>If your Angie server sits in front of Python apps, legacy gateways, or service layers that use SCGI or UWSGI, one upstream flaw could make the server allocate too much memory, read beyond safe bounds, leak process memory, or crash. None of those outcomes are cute. Angie 1.11.5 tightens this area up.</p>
<h3>5. Charset decoding stops peeking where it should not</h3>
<p>The final fix deals with UTF-8 decoding via <code>charset_map</code>. A specially crafted response could trigger an out-of-bounds read, which is a clean way of saying the worker process might read data from the wrong place. Best case: crash. Worst case: it leaks bits of memory. Also not the sort of surprise you want from your web server.</p>
<h2 style="color:#f59e0b">Where our Angie stack fits in</h2>
<p>If you are not in the mood to hand-roll every package, module, and TLS detail yourself, this is where <a href="/angie-modules-optimized-extended/">our Angie stack</a> earns its keep. We package Angie as a drop-in replacement for NGINX, with the same configuration style but with Angie’s newer core features, including native ACME support and the richer status API.</p>
<p>Our Angie page also covers the practical bits people actually care about: dynamic modules, build optimizations, OpenSSL integration, PQC readiness, and supported Debian and Ubuntu releases. If you are building a serious edge stack, it is the shortest path from &#8220;I heard Angie exists&#8221; to &#8220;this thing is actually running on my server&#8221;.</p>
<p>Useful starting points:</p>
<ul>
<li><a href="/angie-modules-optimized-extended/">Our Angie stack and package overview</a></li>
<li><a href="/how-to-use/">How to use the repository</a></li>
<li><a href="/nginx-dockerized/">Daily rebuilt Angie and NGINX Docker images</a></li>
<li><a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">OpenSSL-NGINX build for stronger TLS performance</a></li>
</ul>
<h2 style="color:#f59e0b">Should you update right now?</h2>
<p>Short answer: yes, if the server is exposed to the internet. Extra yes if you use HTTP/3, OCSP, SCGI, UWSGI, or complicated rewrite rules. Extra-extra yes if the phrase &#8220;we have been meaning to patch that box for a while&#8221; just made someone on your team look at the floor.</p>
<p>The sensible approach is simple: update in staging, test the obvious paths, then promote to production. Angie is designed as a drop-in replacement for NGINX, so this should not turn into a Greek tragedy. Still, do the grown-up thing and test. Confidence is nice. Rollback plans are nicer.</p>
<p>And if you are evaluating whether Angie is the right move at all, compare it with <a href="/2026/05/angie-web-server-complete-guide/">Angie vs NGINX</a>. If you already run Angie, Angie 1.11.5 is the sort of release that rewards fast patching and quiet operations. Which, frankly, is the dream.</p>
<h2 style="color:#f59e0b">Frequently Asked Questions</h2>
<h3>What is Angie 1.11.5?</h3>
<p>Angie 1.11.5 is a security-focused release of the Angie web server. It brings five upstream fixes for issues affecting rewrite processing, OCSP, HTTP/3, SCGI/UWSGI proxying, and charset decoding.</p>
<h3>Do I need to update if my server seems fine?</h3>
<p>Yes. Security bugs rarely send a polite calendar invite before causing trouble. A server that &#8220;seems fine&#8221; can still be vulnerable, especially when the issues involve request handling and protocol parsing.</p>
<h3>Does Angie 1.11.5 matter if I use HTTP/2 and not HTTP/3?</h3>
<p>Yes, because HTTP/3 is only one of the five fixes. The release also hardens rewrite logic, OCSP handling, SCGI/UWSGI behaviour, and charset decoding. Even if HTTP/3 is disabled, the other fixes may still apply to your setup.</p>
<h3>Is Angie really a drop-in replacement for NGINX?</h3>
<p>In most real-world cases, yes. Angie keeps NGINX-compatible configuration syntax, so existing configs usually carry over cleanly. That is one of the reasons it is attractive for admins who want newer features without rewriting their whole stack.</p>
<h3>Where can I get a packaged Angie build for Debian or Ubuntu?</h3>
<p>You can start on <a href="/angie-modules-optimized-extended/">our Angie stack page</a>, which links to the repository setup, package details, modules, and related Docker images. It is the fastest route if you want Angie without spending the afternoon compiling things by hand like it is 2009.</p>
<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
<li><a href="/2026/05/angie-web-server-complete-guide/">Angie vs NGINX</a> &#8211; A practical comparison if you are deciding whether Angie belongs in your stack.</li>
<li><a href="/2026/05/angie-web-server-complete-guide/">Angie ACME: Stop Paying Certbot Rent</a> &#8211; Why Angie’s built-in ACME support is such a nice quality-of-life feature.</li>
<li><a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx: A Dedicated OpenSSL Build for NGINX and Angie</a> &#8211; A deeper look at the TLS layer behind our optimized packages.</li>
<li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">Nginx &amp; Angie: The Expert Guide to Maximum Performance and Security</a> &#8211; The bigger-picture guide if you want performance and hardening ideas beyond this release note.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>nginx 1.31.0 Released: Six CVEs Fixed, HTTP/2 Hardened, and a Buffer Overflow Worth Knowing About</title>
		<link>https://deb.myguard.nl/2026/05/nginx-1-31-0-released-six-cves-fixed/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Wed, 13 May 2026 23:04:35 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[release]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5548</guid>

					<description><![CDATA[nginx 1.31.0 is out — six security fixes including a critical buffer overflow in the rewrite module that could lead to arbitrary code execution. Here is what changed, what is at risk, and how to upgrade from our repo.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Six security fixes. In one release. That&#8217;s either evidence the nginx team had a very rough May, or proof that they take security seriously enough to batch fixes and get them out fast. Spoiler: it&#8217;s both, and if you&#8217;re running nginx mainline, you should upgrade today.</p>



<p class="wp-block-paragraph">nginx 1.31.0 dropped on 13 May 2026, and it&#8217;s not a feature release. It&#8217;s a security and compliance release: six CVEs patched, one protocol behaviour change to align with HTTP/2 and HTTP/3 specs, and a segfault bug fix that&#8217;ll affect anyone mixing <code>try_files</code> with <code>proxy_pass</code>. Let&#8217;s walk through all of it.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">What Is nginx 1.31.0? The Mainline vs Stable Thing, Explained</h2>



<p class="wp-block-paragraph">Quick version: nginx has two release branches at any time. If the minor version is <strong>even</strong> (1.28.x, 1.30.x), it&#8217;s the <strong>stable</strong> branch, tested, conservative, no new features mid-cycle. If it&#8217;s <strong>odd</strong> (1.29.x, 1.31.x), it&#8217;s the <strong>mainline</strong> branch, where active development happens, new features land first, and security fixes appear before stable.</p>



<p class="wp-block-paragraph">nginx 1.31.0 is the first release of the new mainline cycle. It replaces 1.29.x. If you&#8217;re on 1.29.8 or earlier, 1.31.0 is your direct upgrade target. If you&#8217;re on stable (1.28.x), security patches will arrive there too, but mainline gets them first.</p>



<p class="wp-block-paragraph">Running mainline is fine for production if you stay current. Running <em>old</em> mainline is asking for trouble, exactly the kind this release patches.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Six Security Fixes: What&#8217;s Actually in nginx 1.31.0</h2>



<p class="wp-block-paragraph">Here&#8217;s the full security picture. Some of these are &#8220;could be a problem under specific conditions&#8221; and some are &#8220;this could execute arbitrary code on your server.&#8221; Let&#8217;s sort them by scariness.</p>



<h3 class="wp-block-heading">CVE-2026-42945: Heap Buffer Overflow in the Rewrite Module (The Scary One)</h3>



<p class="wp-block-paragraph">A heap memory buffer overflow in <code>ngx_http_rewrite_module</code>, the module that handles <code>rewrite</code>, <code>if</code>, <code>return</code>, and <code>set</code> directives. An attacker could trigger this by sending a specially crafted request, potentially resulting in <strong>arbitrary code execution</strong> in the worker process.</p>



<p class="wp-block-paragraph">Yes, &#8220;arbitrary code execution&#8221; is the technical way of saying &#8220;they could run whatever they want on your machine.&#8221; If you&#8217;re using rewrite rules (and you almost certainly are, WordPress pretty URLs alone count), this one matters. Credit goes to Leo Lin for the discovery.</p>



<h3 class="wp-block-heading">CVE-2026-40701: Use-After-Free via ssl_ocsp (Also Serious)</h3>



<p class="wp-block-paragraph">If you&#8217;re using the <code>ssl_ocsp</code> directive for OCSP certificate status checking, a use-after-free vulnerability could be triggered during DNS server response processing. Use-after-free means nginx reads or writes memory that it already freed, the classic &#8220;this memory belongs to someone else now&#8221; mistake that leads to worker process memory corruption or a segfault. Also credited to Leo Lin.</p>



<p class="wp-block-paragraph">If you have <code>ssl_ocsp</code> in your config, treat this as high priority.</p>



<h3 class="wp-block-heading">CVE-2026-42926: proxy_set_body and HTTP/2 Backend Injection</h3>



<p class="wp-block-paragraph">When using the <code>proxy_set_body</code> directive to override the request body sent to an upstream, an attacker could inject additional data into the proxied request when the backend connection is HTTP/2. This is a request injection vulnerability, the upstream backend receives data the attacker controlled, not just the legitimate request body. Found by Mufeed VH of Winfunc Research.</p>



<p class="wp-block-paragraph">If you use <code>proxy_set_body</code> and your upstreams speak HTTP/2, patch now.</p>



<figure class="wp-block-image alignwide size-large"><img loading="lazy" decoding="async" width="1200" height="800" src="https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-1-31-0-cve-buffer-overflow.webp" alt="nginx 1.31.0 CVE security patches - buffer overflow and memory safety fixes" class="wp-image-5546" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-1-31-0-cve-buffer-overflow.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-1-31-0-cve-buffer-overflow-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-1-31-0-cve-buffer-overflow-1024x683.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-1-31-0-cve-buffer-overflow-768x512.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /><figcaption class="wp-element-caption">nginx 1.31.0 addresses six CVEs across memory safety, protocol handling, and HTTP/3 QUIC, all found by external security researchers.</figcaption></figure>



<h3 class="wp-block-heading">CVE-2026-42946: Heap Overread via SCGI or uWSGI (Memory Disclosure)</h3>



<p class="wp-block-paragraph">A heap memory buffer overread in <code>ngx_http_scgi_module</code> or <code>ngx_http_uwsgi_module</code>, triggered by a specially crafted response from the backend. The impact: an attacker controlling the backend response could cause limited disclosure of worker process memory contents, or crash the worker with a segfault. If you run Python (Django, Flask) or Ruby apps behind nginx using SCGI or uWSGI, this applies to you. Also credited to Leo Lin.</p>



<h3 class="wp-block-heading">CVE-2026-42934: charset_map UTF-8 Overread (Limited Memory Disclosure)</h3>



<p class="wp-block-paragraph">A heap buffer overread in the charset conversion code, specifically when nginx decodes responses from UTF-8 using the <code>charset_map</code> directive. The overread could cause limited worker memory disclosure or a segfault. If you use nginx&#8217;s built-in charset conversion (most people don&#8217;t explicitly, but check your config for <code>charset_map</code> or <code>charset</code> directives), this one&#8217;s relevant. Credit to David Carlier.</p>



<h3 class="wp-block-heading">CVE-2026-40460: HTTP/3 QUIC Connection Migration Address Spoofing</h3>



<p class="wp-block-paragraph">QUIC supports connection migration, a feature where a client can move from one network address to another without reconnecting (useful on mobile devices switching from WiFi to cellular). In affected versions, processing connection migration could cause new QUIC streams to receive a new client address before that address is validated. An attacker could exploit this for address spoofing. Found by Rodrigo Laneth.</p>



<p class="wp-block-paragraph">If you&#8217;ve enabled HTTP/3 (via the <code>quic</code> listen directive), this is worth noting. HTTP/3 is still experimental in mainline nginx, if you&#8217;re not running it, this doesn&#8217;t affect you.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The Protocol Change: Goodbye, Hop-by-Hop Headers in HTTP/2 and HTTP/3</h2>



<p class="wp-block-paragraph">Beyond the CVEs, there&#8217;s one behaviour change: <strong>nginx now rejects HTTP/2 and HTTP/3 requests that include hop-by-hop headers</strong>, specifically <code>Connection</code>, <code>Proxy-Connection</code>, <code>Keep-Alive</code>, and <code>Transfer-Encoding</code>.</p>



<p class="wp-block-paragraph">Here&#8217;s the context: these headers are HTTP/1.1 concepts that describe per-hop connection management. In HTTP/2 and HTTP/3, they&#8217;re explicitly forbidden by the spec. HTTP/2 uses its own multiplexed stream management; there&#8217;s no concept of &#8220;keep this connection alive&#8221; as a header, it&#8217;s built in.</p>



<p class="wp-block-paragraph">Before this change, nginx would silently accept these headers in HTTP/2 and HTTP/3 requests. Now it rejects them with a 400 response. If you have clients or proxies sending these headers over HTTP/2, they&#8217;ll get errors after upgrading.</p>



<p class="wp-block-paragraph">In practice: well-behaved HTTP/2 clients don&#8217;t send these headers. If you see 400 errors after upgrading, the culprit is likely a misbehaving client or a misconfigured load balancer upstream. Fix it there, nginx is correct to reject them.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">The Bug Fix: try_files + proxy_pass With URI = Segfault</h2>



<p class="wp-block-paragraph">One bugfix in this release: a segmentation fault that could occur in a worker process when <strong>both</strong> the <code>try_files</code> directive <strong>and</strong> <code>proxy_pass</code> with a URI component were used in the same config block.</p>



<p class="wp-block-paragraph">The distinction: <code>proxy_pass http://backend</code> is &#8220;proxy to this upstream.&#8221; <code>proxy_pass http://backend/path</code> is &#8220;proxy to this upstream and rewrite the URI.&#8221; The combination with <code>try_files</code> in certain configs could trigger a worker crash. Credit to Jan Svojanovsky for finding it.</p>



<p class="wp-block-paragraph">If you&#8217;ve been seeing unexplained worker process restarts and your config uses <code>try_files</code> alongside a URI-based <code>proxy_pass</code>, this fix is for you.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Should You Upgrade to nginx 1.31.0?</h2>



<p class="wp-block-paragraph">Straight answer:</p>



<ul class="wp-block-list">
<li><strong>On nginx mainline (1.29.x or older mainline):</strong> Yes. Upgrade immediately. Six CVEs, including a potential arbitrary code execution in the rewrite module. There&#8217;s no scenario where waiting makes sense.</li>
<li><strong>On nginx stable (1.28.x):</strong> Security patches will arrive in the stable branch. Watch the nginx changelog at <a href="https://nginx.org/en/CHANGES" target="_blank" rel="noopener">nginx.org</a> and upgrade when the stable fix appears. Mainline patches usually reach stable within days to weeks.</li>
<li><strong>Running a distribution package (Ubuntu/Debian default nginx):</strong> Wait for your distro&#8217;s security advisory and use <code>apt upgrade nginx</code>. Or: and this is the pitch, switch to our repo for current releases.</li>
</ul>



<h2 class="wp-block-heading" style="color:#f59e0b">Get nginx 1.31.0 from Our Repository</h2>



<p class="wp-block-paragraph">We maintain a Debian and Ubuntu APT repository at <a href="https://deb.myguard.nl">deb.myguard.nl</a> with current, optimized nginx builds, including mainline packages, extended module sets, and builds compiled against modern OpenSSL for TLS 1.3 and HTTP/3 support. nginx 1.31.0 is already available.</p>



<p class="wp-block-paragraph">To add the repository and install:</p>



<pre class="wp-block-code"><code># Add the repo signing key
curl -sSL https://deb.myguard.nl/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/myguard-nginx.gpg

# Add the repository (replace bookworm with your distro codename)
echo "deb [signed-by=/usr/share/keyrings/myguard-nginx.gpg] https://deb.myguard.nl bookworm main" 
  | sudo tee /etc/apt/sources.list.d/myguard-nginx.list

# Install nginx mainline
sudo apt update && sudo apt install nginx</code></pre>



<p class="wp-block-paragraph">Not sure which package variant to install? Check the <a href="/how-to-use/">how-to-use guide</a>, it covers the available package variants, module sets, and distro-specific notes. The <a href="/repository/">repository page</a> lists all available packages and supported distributions (Debian 11/12/13, Ubuntu 22.04/24.04/24.10).</p>



<p class="wp-block-paragraph">Our builds ship with an <a href="/nginx-modules/">extended module set</a>, Brotli, zstd compression, GeoIP2, ModSecurity, QUIC/HTTP3 support, and more, all built against our hardened OpenSSL. You&#8217;re not just getting a version bump; you&#8217;re getting a production-grade stack.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Frequently Asked Questions</h2>



<h3 class="wp-block-heading">Is nginx 1.31.0 the stable or mainline release?</h3>



<p class="wp-block-paragraph">Mainline. The odd minor version (31) marks it as a development/mainline release. The current stable branch is 1.28.x. Mainline gets new features and security fixes first; stable gets security fixes once they&#8217;re proven. Both are suitable for production, just stay current on whichever branch you choose.</p>



<h3 class="wp-block-heading">Which CVE in nginx 1.31.0 is the most critical?</h3>



<p class="wp-block-paragraph">CVE-2026-42945, the heap buffer overflow in <code>ngx_http_rewrite_module</code>, is the most severe. It has a potential for arbitrary code execution, meaning an attacker could theoretically run code in your nginx worker process. CVE-2026-40701 (use-after-free in ssl_ocsp) is close behind. Patch both by upgrading to 1.31.0.</p>



<h3 class="wp-block-heading">I don&#8217;t use rewrite rules. Am I still affected?</h3>



<p class="wp-block-paragraph">You probably do, even if you don&#8217;t know it. The <code>ngx_http_rewrite_module</code> handles <code>rewrite</code>, <code>if</code>, <code>return</code>, and <code>set</code> directives. If your config redirects HTTP to HTTPS (<code>return 301 https://...</code>), serves a WordPress installation (which requires rewrite rules), or uses any <code>location</code> block with <code>return</code>, you&#8217;re using this module. Upgrade regardless.</p>



<h3 class="wp-block-heading">Will the HTTP/2 header rejection change break anything?</h3>



<p class="wp-block-paragraph">Only if your clients or upstream proxies are incorrectly sending HTTP/1.1 hop-by-hop headers (<code>Connection</code>, <code>Keep-Alive</code>, <code>Transfer-Encoding</code>, <code>Proxy-Connection</code>) over HTTP/2 or HTTP/3 connections. Well-behaved HTTP/2 clients don&#8217;t do this, it&#8217;s a spec violation. If you see 400 errors after upgrading, audit your client-side and load balancer configurations first.</p>



<h3 class="wp-block-heading">I&#8217;m on Ubuntu and running `apt show nginx` shows an old version. What do I do?</h3>



<p class="wp-block-paragraph">Ubuntu and Debian package nginx from their own repos, which often lag behind upstream releases by weeks or months. To get current mainline nginx with all security patches, add our repository (instructions above) or the official nginx.org packages. Both provide up-to-date builds. Our repo adds extended modules and hardened OpenSSL on top.</p>



<h3 class="wp-block-heading">Do I need to restart nginx after upgrading?</h3>



<p class="wp-block-paragraph">Yes, but you can do a zero-downtime reload with <code>sudo nginx -t && sudo systemctl reload nginx</code>. This tests the config first, then reloads. Worker processes serving existing connections finish gracefully; new workers pick up the new binary. No dropped connections. If you&#8217;re upgrading the binary (not just config), do a full restart: <code>sudo systemctl restart nginx</code>.</p>



<h2 class="wp-block-heading" style="color:#f59e0b">Related Posts</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/nginx-reverse-proxy-configuration-guide/">NGINX Reverse Proxy Configuration: The Complete Setup Guide</a>: proxy_pass, upstream keepalive, caching, and WebSocket proxying with security best practices.</li>
<li><a href="/2026/05/nginx-rate-limiting-guide/">NGINX Rate Limiting: Protect Your Server from Bots, Scrapers and Brute Force</a>: Stop credential stuffing and DDoS floods before they reach your application.</li>
<li><a href="/2026/05/nginx-debian-13-trixie-upgrade-guide/">NGINX on Debian 13 Trixie: What Changed and How to Upgrade</a>: GCC 14, OpenSSL 3.3, PHP 8.4, what each change means for your nginx setup.</li>
<li><a href="/2026/05/nginx-brotli-compression-module-guide/">NGINX Brotli Compression: Install, Configure and Pre-Compress Static Assets</a>: 15–26% better than gzip: install the Brotli module and tune it for production.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>WordPress NGINX Configuration: PHP-FPM Tuning, FastCGI Cache and Redis (2026 Guide)</title>
		<link>https://deb.myguard.nl/2026/05/wordpress-nginx-php-fpm-configuration-guide/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Tue, 12 May 2026 22:23:09 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[caching]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[php-fpm]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/wordpress-nginx-php-fpm-configuration-guide/</guid>

					<description><![CDATA[The complete WordPress + NGINX + PHP-FPM setup for Debian and Ubuntu: server block config, pool tuning, FastCGI caching for anonymous traffic, Redis object cache, Brotli compression, and security hardening with ModSecurity and Snuffleupagus.]]></description>
										<content:encoded><![CDATA[
<p>A default PHP-FPM pool ships tuned for shared hosting that juggles dozens of sites on one box. You are not running dozens of sites. You are running one WordPress install on a server you actually control, and those defaults are leaving most of your hardware idle while your visitors wait. Getting the <strong>WordPress NGINX configuration</strong> right is the difference between a page that renders in a millisecond and one that drags PHP out of bed for every single request.</p>

<p>Here is the whole stack, top to bottom: the NGINX server block, PHP-FPM pool tuning, FastCGI caching so anonymous readers never touch PHP, a Redis object cache, Brotli compression, and the security hardening that keeps the script kiddies out. It runs on Debian and Ubuntu, and it uses the optimised <a href="/how-to-use/">myguard packages</a> throughout. Back up your existing config before you touch any of this. You know why.</p>

<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/w5529.webp" alt="WordPress NGINX configuration stack with PHP-FPM, FastCGI cache and Redis" width="1024" height="576" loading="lazy"/></figure>

<h2 style="color:#f59e0b">The NGINX server block for WordPress</h2>

<p>This is where every request lands first. Get the WordPress NGINX configuration wrong here and nothing downstream matters. The block below terminates TLS, serves static assets straight off disk with year-long cache headers, hands PHP to the FPM socket, and slams the door on the files attackers always poke at.</p>

<pre><code>server {
    listen 443 ssl;
    http2 on;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    root /var/www/example.com;
    index index.php;

    # Serve static assets directly with long cache headers
    location ~* \.(js|css|png|jpg|jpeg|webp|svg|woff2|woff|ttf|ico|pdf)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        log_not_found off;
        access_log off;
    }

    # WordPress permalink rewriting
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # PHP via PHP-FPM
    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_read_timeout 300s;
    }

    # Block direct access to sensitive files
    location ~* /\.(?:git|env|htaccess|htpasswd)$ { deny all; }
    location ~* /(wp-config\.php|xmlrpc\.php)$ { deny all; }
    location = /wp-config.php { deny all; }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}</code></pre>

<p>That <code>try_files $uri $uri/ /index.php?$args</code> line is WordPress permalinks in one breath. Miss it and every pretty URL 404s, and you spend an afternoon convinced your database is broken. It isn&#8217;t. It never was.</p>

<h2 style="color:#f59e0b">PHP-FPM pool tuning</h2>

<p>PHP-FPM tuning is the heart of any serious WordPress NGINX configuration. Edit <code>/etc/php/8.4/fpm/pool.d/www.conf</code>. The shipped values assume you are one tenant of many. You are the landlord now. Tune for it.</p>

<pre><code>[www]
user = www-data
group = www-data

; Use Unix socket (faster than TCP for local connections)
listen = /run/php/php8.4-fpm.sock
listen.owner = www-data
listen.group = www-data

; Dynamic process management
pm = dynamic
pm.max_children = 20        ; Max concurrent PHP processes
pm.start_servers = 5        ; Start with 5 workers
pm.min_spare_servers = 3    ; Keep at least 3 idle
pm.max_spare_servers = 8    ; Keep at most 8 idle
pm.max_requests = 500       ; Recycle workers after 500 requests (prevents memory leaks)

; Slower request logging for debugging
slowlog = /var/log/php/www-slow.log
request_slowlog_timeout = 5s

; PHP settings for WordPress
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M
php_admin_value[max_execution_time] = 120
php_admin_value[error_log] = /var/log/php/www-error.log
php_admin_flag[log_errors] = on</code></pre>

<p>The number people get wrong is <code>pm.max_children</code>. Set it too high and a traffic spike spawns more PHP workers than you have RAM, the kernel&#8217;s OOM killer wakes up, and it shoots one of your workers in the head mid-request. Set it too low and requests queue while memory sits empty. Measure first: check your average PHP process RSS, then divide available RAM (minus what NGINX, Redis, and the OS need) by that number. For a 2 GB VPS: (2048 MB − 400 MB overhead) / ~80 MB per process ≈ 20 workers. The official <a href="https://www.php.net/manual/en/install.fpm.configuration.php" rel="noopener" target="_blank">PHP-FPM configuration reference</a> documents every directive if you want to go deeper.</p>

<h2 style="color:#f59e0b">FastCGI cache: serve WordPress pages without PHP</h2>

<p>Most of your traffic is anonymous. Someone clicks a link from a search result, reads one post, leaves. For that visitor, NGINX can cache the entire rendered page and serve it again without ever starting PHP. A cached page goes out in roughly 1 ms. The uncached version, the one that boots WordPress and runs thirty queries, takes around 80 ms. Do that math across a few thousand visits and the FastCGI cache stops being an optimisation and starts being the whole point.</p>

<pre><code>http {
    # Cache zone: 256MB storage
    fastcgi_cache_path /var/cache/nginx/wordpress
        levels=1:2 keys_zone=wp_cache:100m
        max_size=256m inactive=60m use_temp_path=off;

    fastcgi_cache_key "$scheme$request_method$host$request_uri";

    server {
        # Cache settings per request
        set $skip_cache 0;

        # Don't cache POST requests
        if ($request_method = POST) { set $skip_cache 1; }

        # Don't cache URLs with query strings (search results, paginated)
        if ($query_string != "") { set $skip_cache 1; }

        # Don't cache logged-in users or cart pages
        if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in|woocommerce_items_in_cart") {
            set $skip_cache 1;
        }

        location ~ \.php$ {
            fastcgi_pass unix:/run/php/php8.4-fpm.sock;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            include fastcgi_params;

            fastcgi_cache        wp_cache;
            fastcgi_cache_valid 200 60m;
            fastcgi_cache_bypass  $skip_cache;
            fastcgi_no_cache      $skip_cache;

            add_header X-FastCGI-Cache $upstream_cache_status;
        }
    }
}</code></pre>

<p>The cookie regex is the part that saves you from a 3 a.m. page. Forget it and a logged-in editor gets served another reader&#8217;s cached page, or worse, a logged-out visitor gets handed an editor&#8217;s admin bar. Cache the public, skip the private. Purge on publish with a plugin like Nginx Helper, or the myguard <a href="/nginx-modules/">cache purge module</a>.</p>

<h2 style="color:#f59e0b">Object cache with Redis</h2>

<p>WordPress caches database query results in memory, then throws that cache away at the end of every request. Next request, it does the work again. Redis keeps the object cache alive between requests, so the thirtieth identical query of the day costs nothing.</p>

<pre><code>apt-get install redis-server php8.4-redis

# Verify Redis is running
redis-cli ping  # Should return PONG</code></pre>

<p>Then install the Redis Object Cache plugin, or wire it by hand in <code>wp-config.php</code>:</p>

<pre><code>define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);
define('WP_CACHE', true);</code></pre>

<p>A page that fired 30 database queries cold will run 2 or 3 once Redis is warm, the rest served from memory. This is the single biggest win in the whole WordPress NGINX configuration for query-heavy sites. On a WooCommerce site grinding through product-catalog queries, the drop is even steeper.</p>

<h2 style="color:#f59e0b">Compression for WordPress</h2>

<p>Compression rounds out the WordPress NGINX configuration. Enable Brotli and gzip for text. Brotli wins on HTML and CSS; gzip is the fallback for the handful of clients that still don&#8217;t speak Brotli.</p>

<pre><code>http {
    brotli on;
    brotli_comp_level 6;
    brotli_types text/html text/css application/javascript application/json image/svg+xml;

    gzip on;
    gzip_comp_level 6;
    gzip_vary on;
    gzip_types text/html text/css application/javascript application/json image/svg+xml;
}</code></pre>

<p>Install the module first: <code>apt-get install libnginx-mod-http-brotli</code>. For the deep dive on compression levels and pre-compression, see the <a href="/2026/05/nginx-brotli-compression-module-guide/">NGINX Brotli compression guide</a>.</p>

<h2 style="color:#f59e0b">Security hardening checklist</h2>

<p>A WordPress NGINX configuration that performs but leaks is a liability. The server block already deny-lists the obvious files. Beyond that, defence in depth:</p>

<ul>
  <li><strong>PHP-Snuffleupagus</strong> (<code>apt-get install php8.4-snuffleupagus</code>) blocks dangerous PHP functions at the interpreter level, so a compromised plugin can&#8217;t drop a webshell.</li>
  <li><strong>ModSecurity WAF</strong> (<code>apt-get install libnginx-mod-http-modsecurity</code>) stops SQLi, XSS, and scanner noise before it reaches PHP.</li>
  <li>Rate-limit <code>/wp-login.php</code>. Five requests a minute per IP and credential stuffing dies on the doorstep.</li>
  <li>Block <code>xmlrpc.php</code> unless you genuinely use Jetpack or the mobile app: <code>location = /xmlrpc.php { deny all; }</code></li>
  <li>Snuffleupagus upload validation rejects PHP files wearing a <code>.jpg</code> costume.</li>
</ul>

<h2 style="color:#f59e0b">Performance verification</h2>

<p>Don&#8217;t trust it because the config looks right. Prove it.</p>

<pre><code># Test FastCGI cache is working
curl -I https://example.com/ | grep X-FastCGI-Cache
# First request: X-FastCGI-Cache: MISS
# Second request: X-FastCGI-Cache: HIT

# Check PHP-FPM worker status
curl http://127.0.0.1/fpm-status  # Add status page in pool config

# Benchmark with ab (Apache Bench)
ab -n 1000 -c 10 https://example.com/

# Check Brotli is serving
curl -H "Accept-Encoding: br" -I https://example.com/ | grep Content-Encoding</code></pre>

<p>First curl says MISS, second says HIT. That HIT is PHP getting the morning off. If it stays MISS forever, your cookie regex is matching something it shouldn&#8217;t, or the cache directory isn&#8217;t writable. Either way, your WordPress NGINX configuration isn&#8217;t done until that second request reads HIT.</p>


<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I need a WordPress caching plugin if I use FastCGI cache?</h3>
<div class="rank-math-answer ">

<p>For anonymous traffic, the FastCGI cache at the NGINX level beats any plugin cache because it serves pages without starting PHP at all. You still want a plugin to handle purging when you publish or update a post. Nginx Helper, WP Rocket, or W3 Total Cache all do the purge job.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">What is the right pm.max_children value for my server?</h3>
<div class="rank-math-answer ">

<p>Measure your average PHP-FPM process size (ps aux | grep php-fpm), then divide available RAM by it. For a 4 GB server running only WordPress, expect 30 to 50 workers. Leave 20 to 30 percent of RAM for NGINX, Redis, MySQL, and the OS.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Should I use a TCP or Unix socket for PHP-FPM?</h3>
<div class="rank-math-answer ">

<p>Use a Unix socket when PHP-FPM and NGINX share a server. It skips the network stack and shaves 5 to 15 percent off per-request latency. Use TCP (127.0.0.1:9000) only when they live on different machines.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Does FastCGI cache work with WooCommerce?</h3>
<div class="rank-math-answer ">

<p>For anonymous shoppers browsing the catalogue, yes, very well. For logged-in users with a cart, the skip_cache cookie logic keeps their session fresh. Also exclude the cart, checkout, and account pages; the Nginx Helper plugin handles those exclusions for you.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Is Angie a better choice than NGINX for this WordPress NGINX configuration?</h3>
<div class="rank-math-answer ">

<p>For WordPress performance the two are identical, the config in this guide drops straight onto Angie. Angie&#8217;s edge is server management: native ACME with no Certbot, and a JSON monitoring API. If you want Let&#8217;s Encrypt without the Certbot dance, Angie is worth it. WordPress itself won&#8217;t notice the difference.</p>

</div>
</div>
</div>
</div>


<h2 style="color:#f59e0b">Related posts</h2>
<ul>
  <li><a href="/2026/05/nginx-brotli-compression-module-guide/">NGINX Brotli Compression Module</a>: Brotli setup and pre-compression for static assets.</li>
  <li><a href="/2024/01/enhancing-web-security-with-php-snuffleupagus-for-php-fpm/">PHP-Snuffleupagus: Harden PHP-FPM</a>: interpreter-level PHP security for WordPress.</li>
  <li><a href="/2026/05/nginx-modsecurity-setup-debian-ubuntu/">NGINX ModSecurity WAF Setup</a>: the HTTP-layer WAF to pair with PHP hardening.</li>
  <li><a href="/2026/05/nginx-rate-limiting-guide/">NGINX Rate Limiting Guide</a>: protect wp-login.php and xmlrpc.php from brute force.</li>
  <li><a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS Configuration for NGINX</a>: the A+ SSL Labs config for your WordPress HTTPS.</li>
  <li><a href="/how-to-use/">How to Add the myguard APT Repository</a>: where the optimised NGINX packages come from.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>NGINX Load Balancing: Upstream Config, Health Checks and Failover</title>
		<link>https://deb.myguard.nl/2026/05/nginx-load-balancing-upstream-guide/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Tue, 12 May 2026 22:23:08 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/nginx-load-balancing-upstream-guide/</guid>

					<description><![CDATA[NGINX load balancing distributes traffic across multiple backends with automatic failover. This guide covers all five load balancing algorithms, passive health checks, keepalive connection pooling, backup servers, and TCP/UDP load balancing.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">NGINX load balancing is the gateway skill that turns &#8220;one server serving a site&#8221; into &#8220;a fleet of backends with health checks, failover, and capacity headroom.&#8221; This guide covers the full <strong>NGINX upstream configuration</strong> toolkit on Debian and Ubuntu, every load balancing algorithm, the passive and active health check options, keepalive connection pools, failover behaviour, weighted distribution, sticky sessions, and the TCP and UDP stream variants that the <code>limit_req</code> rate-limit crowd often forget exist. The <a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html" rel="noopener" target="_blank">ngx_http_upstream_module docs</a> are the upstream reference.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-load-balancing.webp" alt="NGINX load balancing across backend servers with health checks" width="1024" height="576" loading="lazy"/></figure>



<p class="wp-block-paragraph">This is the practical NGINX load balancer setup most teams actually need. Five algorithms, sensible defaults, and the gotchas your monitoring will eventually find for you the hard way if you skip them.</p>


<h2 style="color:#f59e0b">The Five NGINX Load Balancing Algorithms</h2>

<p>NGINX ships with five built-in load balancing algorithms. Picking the right one for your traffic pattern matters more than people expect, the wrong choice can leave one backend overloaded while two others sit idle.</p>

<h3>1. Round-robin (the default)</h3>
<pre><code>upstream backend {
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080;
}</code></pre>
<p>Requests rotate through the backends one by one. Simple, fair, no configuration. Right answer for stateless backends where every server is roughly the same size and every request takes roughly the same time. Wrong answer when one backend has a slow database, a fat connection pool, or just happens to be in a different availability zone.</p>

<h3>2. Least-connections</h3>
<pre><code>upstream backend {
    least_conn;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080;
}</code></pre>
<p>NGINX sends the next request to whichever backend currently has the fewest active connections. The right choice when your requests take wildly different amounts of time (file downloads, long-running API calls, anything WebSocket-shaped). Pair it with <code>keepalive</code> for proper accounting.</p>

<h3>3. IP hash (sticky by client IP)</h3>
<pre><code>upstream backend {
    ip_hash;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080;
}</code></pre>
<p>The same client IP always lands on the same backend, useful for session affinity when your backends store session state locally (which they really should not, but here we are). Falls apart behind a corporate NAT where 5,000 users share one IP. Falls apart again with CGNAT on mobile carriers.</p>

<h3>4. Hash on any variable</h3>
<pre><code>upstream backend {
    hash $request_uri consistent;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080;
}</code></pre>
<p>Like IP hash but on any variable, request URI, cookie, custom header, geo region. <code>consistent</code> enables consistent hashing, which keeps cache hit rates sane when you add or remove a backend (only ~1/N of keys move, not all of them). The right algorithm for upstream caching tiers (Varnish farms, image processors keyed on URL).</p>

<h3>5. Random-two (NGINX Plus / Angie / 1.15.1+)</h3>
<pre><code>upstream backend {
    random two least_conn;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080;
}</code></pre>
<p>Pick two backends at random, send the request to whichever has fewer connections. Statistically very close to least-connections but cheaper to compute on big upstream groups (dozens of backends). The Power of Two Choices algorithm, small lookup, very smooth distribution. Use it once your <code>upstream</code> block has more than about ten servers.</p>

<h2 style="color:#f59e0b">Weighted Distribution: When Backends Aren&#8217;t Equal</h2>

<p>Got one beefy 16-core backend and two older 8-core ones? Tell NGINX about it:</p>
<pre><code>upstream backend {
    server 10.0.0.1:8080 weight=3;   # 3x the share of traffic
    server 10.0.0.2:8080 weight=1;
    server 10.0.0.3:8080 weight=1;
}</code></pre>

<p>Weights are relative, what matters is the ratio between them, not the absolute values. Combine with <code>least_conn</code> and weight still applies. Brilliantly useful during gradual hardware refreshes when you are migrating from old boxes to new ones.</p>

<h2 style="color:#f59e0b">Health Checks: Passive (Free) and Active (Better)</h2>

<h3>Passive health checks, included in open-source NGINX</h3>
<pre><code>upstream backend {
    server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.3:8080 max_fails=3 fail_timeout=30s backup;
}</code></pre>

<p>NGINX marks a backend &#8220;unavailable&#8221; after <code>max_fails</code> consecutive failed requests within <code>fail_timeout</code>. It stays out of rotation for <code>fail_timeout</code> seconds, then NGINX tries again. The <code>backup</code> flag means &#8220;only use this server when every primary is down.&#8221;</p>

<p>Passive checks are free, but they only learn from <em>real</em> traffic, a dying backend takes down <code>max_fails</code> users before NGINX notices.</p>

<h3>Active health checks, Angie, NGINX Plus, or the third-party module</h3>
<pre><code>upstream backend {
    zone backend 64k;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080;
}

server {
    location / {
        proxy_pass http://backend;
        health_check interval=5s fails=2 passes=2 uri=/healthz;
    }
}</code></pre>

<p>NGINX probes <code>/healthz</code> on each backend every five seconds, marks it down after two consecutive failures, brings it back after two consecutive passes. Users never see a broken request from a dead backend. Available natively in <a href="/2026/05/angie-web-server-complete-guide/">Angie</a> (free), in NGINX Plus (paid), or via the <code>nginx_upstream_check_module</code> third-party module.</p>

<h2 style="color:#f59e0b">Keepalive: The Single Biggest Performance Win</h2>

<p>Without <code>keepalive</code>, NGINX opens a new TCP connection to your backend on every request. Three-way handshake, slow start, all of it. With <code>keepalive</code>, it pools connections and reuses them. On HTTP/1.1 to a PHP-FPM or Node.js backend the latency improvement is dramatic, often 30 to 60 percent off median response time:</p>

<pre><code>upstream backend {
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    keepalive 64;                # Cache up to 64 idle connections per worker
    keepalive_requests 1000;     # Recycle each connection after 1000 requests
    keepalive_timeout 60s;       # Close idle connections after 60s
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;          # Required for keepalive
        proxy_set_header Connection "";  # Clear hop-by-hop header
    }
}</code></pre>

<p>The <code>proxy_http_version 1.1</code> and the empty <code>Connection</code> header are non-negotiable, without them, NGINX still opens a fresh TCP connection per request. This is the single most common reason &#8220;my NGINX load balancer feels slow&#8221; turns out to have a one-line fix.</p>

<h2 style="color:#f59e0b">Failover: Graceful Degradation Patterns</h2>

<p>Real failover is more than just &#8220;if backend dies, send traffic elsewhere.&#8221; You usually want a tiered model, primary backends serve normally, secondary backends only kick in when the primaries are exhausted:</p>

<pre><code>upstream backend {
    server 10.0.0.1:8080;            # Primary
    server 10.0.0.2:8080;            # Primary
    server 10.0.0.3:8080 backup;     # Secondary, only used if both primaries are down
    server fallback.example.com:443 backup;  # Off-cluster emergency
}</code></pre>

<p>Combine with passive health checks and you have a system that survives one or two backend failures without operator intervention. Combine with active health checks and it does so without a single user-visible error.</p>

<h2 style="color:#f59e0b">Sticky Sessions Without Storing State Anywhere Sensible</h2>

<p>Sometimes your application really does need session affinity (you should fix this in the app, but that is a longer conversation). Two patterns:</p>

<pre><code># IP-hash sticky (free, fragile behind NAT)
upstream backend {
    ip_hash;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
}

# Cookie-based sticky (NGINX Plus / Angie)
upstream backend {
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    sticky cookie srv_id expires=1h domain=.example.com path=/;
}</code></pre>

<p>Cookie sticky is the right answer if your platform supports it, survives NAT, works across mobile carriers, easy to invalidate. The Angie sticky module is included in the free Angie packages; in open-source NGINX you need the third-party <code>nginx-sticky-module-ng</code>.</p>

<h2 style="color:#f59e0b">TCP and UDP Load Balancing (Stream Module)</h2>

<p>NGINX is not just an HTTP load balancer. The <code>stream</code> module handles arbitrary TCP and UDP, load balance MySQL replicas, Redis sentinels, DNS resolvers, gRPC, Postfix SMTP, anything that speaks TCP or UDP:</p>

<pre><code>stream {
    upstream mysql_read {
        least_conn;
        server 10.0.0.10:3306;
        server 10.0.0.11:3306;
        server 10.0.0.12:3306;
    }

    server {
        listen 3306;
        proxy_pass mysql_read;
        proxy_connect_timeout 5s;
    }

    # UDP example: DNS load balancing
    upstream dns_servers {
        server 10.0.0.20:53;
        server 10.0.0.21:53;
    }

    server {
        listen 53 udp reuseport;
        proxy_pass dns_servers;
        proxy_responses 1;
    }
}</code></pre>

<p>Same algorithms (round-robin, least_conn, hash), same weighting, same passive health checks. UDP load balancing with <code>reuseport</code> is the foundation of HTTP/3 in a multi-instance NGINX deployment, see our <a href="/2026/05/nginx-http3-quic-debian-ubuntu/">HTTP/3 on NGINX guide</a> for the QUIC variant.</p>

<h2 style="color:#f59e0b">Picking the Right Algorithm Quickly</h2>

<ul>
  <li><strong>Stateless API backends, uniform response times</strong>: round-robin (default).</li>
  <li><strong>Variable response times, file downloads, long polls</strong>: <code>least_conn</code>.</li>
  <li><strong>Session affinity required, every client has a unique IP</strong>: <code>ip_hash</code> (or cookie sticky if you can).</li>
  <li><strong>Cache tier keyed on URL</strong>: <code>hash $request_uri consistent</code>.</li>
  <li><strong>Large upstream pool (10+ backends)</strong>: <code>random two least_conn</code>.</li>
</ul>

<h2 style="color:#f59e0b">Logging That Tells You Which Backend Served What</h2>

<p>Add the upstream variables to your access log so post-mortems are actually possible:</p>

<pre><code>log_format upstreamlog '$remote_addr $upstream_addr "$request" '
                       '$status $body_bytes_sent '
                       'rt=$request_time uct=$upstream_connect_time '
                       'urt=$upstream_response_time';

access_log /var/log/nginx/upstream.log upstreamlog;</code></pre>

<p><code>$upstream_addr</code> tells you which backend handled the request. <code>$upstream_response_time</code> tells you how long the backend took (independent of network round-trip time to the client). Both are invaluable when one of the three backends is slowly going wrong.</p>

<h2 style="color:#f59e0b">Common Mistakes That Bite Eventually</h2>

<ul>
  <li><strong>Forgetting <code>proxy_http_version 1.1</code></strong>: kills keepalive silently and adds 30+ ms of TCP setup to every request.</li>
  <li><strong>No timeouts</strong>: a hung backend can hold an NGINX worker for minutes. Set <code>proxy_connect_timeout</code>, <code>proxy_read_timeout</code>, and <code>proxy_send_timeout</code> explicitly.</li>
  <li><strong>Sticky sessions without health checks</strong>: once a backend dies, every user it was stuck to loses their session. Sticky + health check is the minimum.</li>
  <li><strong>Round-robin on heterogeneous hardware</strong>: old box becomes the bottleneck. Use <code>weight</code> or <code>least_conn</code>.</li>
  <li><strong>Passive health checks only on critical services</strong>: pay the licence (or use Angie, which has it free) and get active health checks.</li>
</ul>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Which NGINX load balancing algorithm is best for WordPress?</h3>
<div class="rank-math-answer ">

<p>For PHP-FPM backends behind NGINX, least_conn is usually the right answer. WordPress requests vary wildly in cost (a static page vs a logged-in WooCommerce cart vs a search query), so distributing by active connection count keeps each backend honest. Round-robin works fine until one backend gets a slow query.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Do passive health checks work in free open-source NGINX?</h3>
<div class="rank-math-answer ">

<p>Yes, max_fails and fail_timeout on the upstream server directives are in the free, open-source NGINX. They learn from real traffic, so a dying backend takes down a few users before being marked unavailable. Active health checks (probing /healthz on a schedule) require NGINX Plus, Angie (free), or a third-party module.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">How do I do session affinity / sticky sessions?</h3>
<div class="rank-math-answer ">

<p>Two options. ip_hash is free and works when each client has a unique IP, but breaks behind NAT and on mobile carrier CGNAT. Cookie-based sticky is much more solid but needs the sticky directive, which is in NGINX Plus, Angie, or the nginx-sticky-module-ng third-party module.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">What is the right keepalive value for an NGINX upstream?</h3>
<div class="rank-math-answer ">

<p>A reasonable rule of thumb: keepalive equal to the maximum number of concurrent connections you expect a single NGINX worker to hold open to that upstream, divided by 2. For most teams that&#8217;s somewhere between 32 and 128. Always pair with proxy_http_version 1.1 and proxy_set_header Connection &#8220;&#8221;, without those, keepalive does nothing.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I load balance TCP and UDP with NGINX, not just HTTP?</h3>
<div class="rank-math-answer ">

<p>Yes, the stream module handles arbitrary TCP and UDP. Same algorithms, same weights, same passive health checks. Common uses: MySQL read replicas, Redis sentinels, DNS resolvers, gRPC. UDP load balancing with reuseport is the foundation of HTTP/3 in a multi-instance NGINX deployment.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Does Angie support the same load balancing features as NGINX?</h3>
<div class="rank-math-answer ">

<p>Yes, and more. Angie is a free NGINX fork from the original NGINX developers. It bundles features that are NGINX Plus paid extras in upstream NGINX: active health checks, cookie-based sticky sessions, a JSON status API for monitoring. Configuration syntax is identical, so existing nginx.conf files work unchanged.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">How do I tell which backend served a given request?</h3>
<div class="rank-math-answer ">

<p>Add $upstream_addr to your NGINX log_format. You can also expose it as a response header for debugging with add_header X-Upstream $upstream_addr, useful during deployments to confirm a specific backend is or isn&#8217;t getting traffic. Remove the header in production once you&#8217;ve finished debugging; you don&#8217;t want to leak backend IPs to clients.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>

<ul>
  <li><a href="/2026/05/nginx-reverse-proxy-configuration-guide/">NGINX Reverse Proxy Configuration Guide</a>: proxy_pass, caching, and security headers for the front end of the load balancer.</li>
  <li><a href="/2026/05/nginx-rate-limiting-guide/">NGINX Rate Limiting Guide</a>: protect each backend from abuse before the load balancer ever reaches them.</li>
  <li><a href="/2026/05/nginx-http3-quic-debian-ubuntu/">HTTP/3 on NGINX for Debian and Ubuntu</a>: the UDP load balancing piece that makes HTTP/3 scale horizontally.</li>
  <li><a href="/2026/05/angie-web-server-complete-guide/">Angie Web Server: The Complete Guide</a>: free active health checks, sticky sessions and JSON metrics.</li>
  <li><a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX + PHP-FPM Configuration Guide</a>: the canonical workload behind these upstream blocks.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>NGINX Reverse Proxy Configuration: The Complete Setup Guide</title>
		<link>https://deb.myguard.nl/2026/05/nginx-reverse-proxy-configuration-guide/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Tue, 12 May 2026 22:23:07 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[proxy]]></category>
		<category><![CDATA[reverse-proxy]]></category>
		<category><![CDATA[security]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/nginx-reverse-proxy-configuration-guide/</guid>

					<description><![CDATA[A reverse proxy puts NGINX in front of your Node.js, Python, or PHP backend — handling SSL termination, caching, buffering, and security. This guide covers proxy_pass, upstream keepalive, caching, WebSocket proxying, and security headers.]]></description>
										<content:encoded><![CDATA[
<p>A reverse proxy sits between your users and your application servers. Users connect to NGINX; NGINX forwards their requests to a Node.js app on 3000, a Python service on 8000, a Java backend on 8080, or whatever else is running behind the scenes. The user sees one clean HTTPS endpoint; you get TLS termination, caching, security headers, WebSocket support, load balancing, and a sane place to put rate limits, all in one configuration file. This is the practical <strong>NGINX reverse proxy configuration</strong> guide most teams want when &#8220;we put nginx in front of the app&#8221; turns out to involve more decisions than expected. The <a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html" rel="noopener" target="_blank">ngx_http_proxy_module docs</a> back up every directive here.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-reverse-proxy.webp" alt="NGINX reverse proxy routing to backend applications" width="1024" height="576" loading="lazy"/></figure>

<p>Everything here is tested on the <a href="/how-to-use/">myguard NGINX packages</a> on Debian and Ubuntu. The same configuration works on Angie unchanged, they share the same directives.</p>

<h2 style="color:#f59e0b">The Minimum-Viable Reverse Proxy</h2>

<p>This is the smallest sensible <code>nginx reverse proxy</code> setup. A Node.js or Python app listening on <code>127.0.0.1:3000</code>, NGINX terminates HTTPS and proxies through:</p>

<pre><code>server {
    listen 443 ssl;
    http2 on;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_set_header   X-Forwarded-Host  $host;
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}</code></pre>

<p>That config is enough to ship. Everything below makes it better.</p>

<h2 style="color:#f59e0b">Forwarded Headers: Telling Your Backend Who Visited</h2>

<p>By default your Node/Python/Java backend sees the request as coming from 127.0.0.1, NGINX. The forwarded headers tell it the real client details. Standard set:</p>

<ul>
  <li><strong>Host</strong>: the original <code>Host</code> header the client sent. Without this, your backend may serve the wrong virtual host.</li>
  <li><strong>X-Real-IP</strong>: the original client IP. Single value.</li>
  <li><strong>X-Forwarded-For</strong>: a comma-separated chain of every proxy the request passed through, real client first. Use <code>$proxy_add_x_forwarded_for</code> rather than hardcoding so the chain accumulates correctly if you sit behind a CDN.</li>
  <li><strong>X-Forwarded-Proto</strong>: was the original request <code>http</code> or <code>https</code>? Your app generates absolute URLs based on this.</li>
  <li><strong>X-Forwarded-Host</strong>: same as Host but standardised. Some frameworks prefer it.</li>
</ul>

<p>Most modern frameworks (Express, Django, Flask, Rails, Laravel) have a &#8220;trust proxy&#8221; flag, enable it. Without it the framework ignores the forwarded headers and you serve URLs as <code>http://127.0.0.1:3000/...</code>.</p>

<h2 style="color:#f59e0b">Keepalive: The Single Biggest Performance Win</h2>

<p>Without keepalive, NGINX opens a fresh TCP connection to your backend on every single request. Three-way handshake, slow start, TCP teardown. With keepalive, NGINX pools connections and reuses them. On HTTP/1.1 to a PHP-FPM or Node backend, this often shaves 30-60% off median response time:</p>

<pre><code>upstream backend {
    server 127.0.0.1:3000;
    keepalive 32;              # cache 32 idle connections per worker
    keepalive_requests 1000;   # recycle each connection after 1000 requests
    keepalive_timeout 60s;     # close idle connections after 60s
}

server {
    location / {
        proxy_pass         http://backend;
        proxy_http_version 1.1;             # required for keepalive
        proxy_set_header   Connection "";   # clear hop-by-hop header
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }
}</code></pre>

<p><code>proxy_http_version 1.1</code> and the empty <code>Connection</code> header are non-negotiable, without them, keepalive does nothing. This is the single most common reason &#8220;my reverse proxy feels slow&#8221; turns out to have a one-line fix.</p>

<h2 style="color:#f59e0b">Timeouts: Always Set Them Explicitly</h2>

<p>The default NGINX proxy timeouts are 60 seconds. That&#8217;s fine for normal requests, terrible if your backend hangs. Always set them explicitly so a stuck backend can&#8217;t tie up NGINX workers:</p>

<pre><code>location / {
    proxy_pass http://backend;

    proxy_connect_timeout 5s;    # TCP handshake to backend
    proxy_send_timeout    30s;   # sending request body to backend
    proxy_read_timeout    30s;   # waiting for response from backend

    # For long-running endpoints (file uploads, streaming):
    # proxy_read_timeout 600s;
}</code></pre>

<p>5 seconds is generous for connect, anything longer usually means the backend is dead. 30 seconds for read is a sensible default for typical request handling. Crank read up only on the specific endpoints that legitimately take longer (file uploads, streaming exports, batch operations).</p>

<h2 style="color:#f59e0b">Buffering: The Knob That Caches Responses in NGINX</h2>

<p>By default NGINX buffers your backend&#8217;s response, it reads it into memory, then sends it to the client at the client&#8217;s pace. This protects your backend from slow clients (slowloris-style attacks, mobile users on bad networks). Tune the buffers based on your typical response size:</p>

<pre><code>location / {
    proxy_pass http://backend;

    proxy_buffering on;
    proxy_buffer_size       16k;
    proxy_buffers          16 16k;
    proxy_busy_buffers_size 32k;
}</code></pre>

<p>For streaming responses (Server-Sent Events, long-polling, websockets) you want buffering OFF so the client gets data as it&#8217;s produced:</p>

<pre><code>location /events {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_cache off;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}</code></pre>

<h2 style="color:#f59e0b">WebSocket Proxying</h2>

<p>WebSockets need the <code>Upgrade</code> and <code>Connection</code> headers forwarded. Standard pattern:</p>

<pre><code>map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /ws/ {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host       $host;
        proxy_read_timeout 3600s;   # keep idle ws connections open
        proxy_send_timeout 3600s;
    }
}</code></pre>

<p>The <code>map</code> block translates client <code>Upgrade</code> requests into the right <code>Connection: upgrade</code> for the backend. Without it, the WebSocket handshake fails. The hour-long timeout matches typical WebSocket lifetimes; tune to match your application.</p>

<h2 style="color:#f59e0b">Caching Backend Responses in NGINX</h2>

<p>NGINX can cache your backend&#8217;s responses with <code>proxy_cache</code>. Useful for cacheable API responses (catalogue endpoints, public dashboards), public read-only pages, and anything that does not vary per user:</p>

<pre><code>http {
    proxy_cache_path /var/cache/nginx/backend
        levels=1:2 keys_zone=backend_cache:50m
        max_size=1g inactive=60m use_temp_path=off;
}

server {
    location /api/products {
        proxy_pass http://backend;

        proxy_cache backend_cache;
        proxy_cache_key "$scheme$request_method$host$request_uri";
        proxy_cache_valid 200 5m;
        proxy_cache_valid 404 30s;
        proxy_cache_use_stale error timeout updating http_500 http_502;
        proxy_cache_lock on;

        add_header X-Cache-Status $upstream_cache_status;
    }
}</code></pre>

<p>The magic features here are <code>proxy_cache_use_stale</code> (serve stale content when the backend is down, saves your bacon during outages) and <code>proxy_cache_lock</code> (only one request at a time refreshes the cache when it expires, prevents the thundering-herd problem). The <code>X-Cache-Status</code> response header is invaluable for debugging.</p>

<h2 style="color:#f59e0b">Security Headers: Add Them Once, in NGINX</h2>

<p>Set security headers in NGINX rather than scattering them across every backend. Centralised, consistent, easy to audit:</p>

<pre><code>server {
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff"                              always;
    add_header X-Frame-Options           "SAMEORIGIN"                           always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin"      always;
    add_header Permissions-Policy        "camera=(), microphone=(), geolocation=()" always;

    # If you have a CSP, set it here too:
    # add_header Content-Security-Policy "default-src 'self'" always;
}</code></pre>

<p>The <code>always</code> flag ensures headers are sent even on error responses. Without it, your 500 pages won&#8217;t have HSTS, which technically opens a small downgrade window. The downstream backend can still set its own headers; NGINX&#8217;s <code>add_header</code> appends rather than replaces.</p>

<h2 style="color:#f59e0b">Reverse-Proxying Multiple Backends</h2>

<p>One reverse proxy in front of multiple services, each with its own path prefix:</p>

<pre><code>upstream node_app {
    server 127.0.0.1:3000;
    keepalive 16;
}

upstream python_api {
    server 127.0.0.1:8000;
    keepalive 16;
}

upstream java_search {
    server 127.0.0.1:9200;
    keepalive 16;
}

server {
    listen 443 ssl;
    server_name app.example.com;

    # Default: Node frontend
    location / {
        proxy_pass http://node_app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
    }

    # /api/* goes to Python
    location /api/ {
        proxy_pass http://python_api/;        # trailing slash strips /api prefix
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # /search/* goes to Elasticsearch / Java
    location /search/ {
        proxy_pass http://java_search/;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}</code></pre>

<p>Watch the trailing slashes on <code>proxy_pass</code>, with a slash, NGINX strips the matched <code>location</code> prefix before forwarding. Without it, NGINX forwards the full path. Get this wrong and your backend serves 404s for routes it definitely has.</p>

<h2 style="color:#f59e0b">Real IP When You Sit Behind a CDN</h2>

<p>If Cloudflare (or any CDN) is in front of NGINX, every request looks like it comes from a CDN edge IP. Restore the real client IP with the <code>realip</code> module:</p>

<pre><code>server {
    real_ip_header X-Forwarded-For;
    set_real_ip_from 173.245.48.0/20;     # Cloudflare ranges
    set_real_ip_from 103.21.244.0/22;
    set_real_ip_from 103.22.200.0/22;
    # ... full Cloudflare list at https://www.cloudflare.com/ips/

    location / {
        proxy_pass http://backend;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    }
}</code></pre>

<p>After this, <code>$remote_addr</code> in logs and rate-limit keys is the real client IP, not the CDN. Critical for getting <a href="/2026/05/nginx-rate-limiting-guide/">rate limiting</a> right when you&#8217;re behind Cloudflare.</p>

<h2 style="color:#f59e0b">Health-Checking the Backend</h2>

<p>The open-source NGINX has passive health checks, <code>max_fails</code> and <code>fail_timeout</code> on the upstream server line. Sufficient for many cases:</p>

<pre><code>upstream backend {
    server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3002 max_fails=3 fail_timeout=10s backup;
    keepalive 16;
}</code></pre>

<p>For active health checks (probe <code>/healthz</code> on a schedule and route traffic before users notice an outage), use <a href="/2026/05/angie-web-server-complete-guide/">Angie</a> (free), NGINX Plus, or the <code>nginx_upstream_check_module</code> third-party module. Covered in detail in our <a href="/2026/05/nginx-load-balancing-upstream-guide/">NGINX load balancing guide</a>.</p>

<h2 style="color:#f59e0b">Common Mistakes That Bite Eventually</h2>

<ul>
  <li><strong>Forgetting proxy_http_version 1.1</strong>: kills keepalive silently, adds 30+ms TCP setup to every request.</li>
  <li><strong>No timeouts</strong>: a hung backend can hold an NGINX worker forever.</li>
  <li><strong>Wrong trailing slash on proxy_pass</strong>: backend gets a path it doesn&#8217;t recognise.</li>
  <li><strong>Setting X-Forwarded-For with $remote_addr instead of $proxy_add_x_forwarded_for</strong>: breaks the proxy chain behind a CDN.</li>
  <li><strong>Buffering on for streaming endpoints</strong>: Server-Sent Events and long-polling appear to hang.</li>
  <li><strong>No keepalive on the upstream</strong>: every backend request opens a new TCP connection.</li>
</ul>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>

<div class="faq">
  <div class="faq-item">
    <div class="faq-q">How do I reverse-proxy a Node.js app with NGINX?</div>
    <div class="faq-a">Put the Node app on a localhost port (3000 typically), then proxy_pass to it. Set proxy_http_version 1.1, an empty Connection header, the standard X-Forwarded-* headers, and enable upstream keepalive. Trust the proxy in your Node framework (Express: app.set(&#8216;trust proxy&#8217;, 1)) so it reads the forwarded headers.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Why is my reverse proxy slow?</div>
    <div class="faq-a">By far the most common cause is missing keepalive, NGINX opens a new TCP connection on every request. Add keepalive to the upstream block, set proxy_http_version 1.1, and set proxy_set_header Connection &#8220;&#8221;. If still slow, check proxy_buffering and the timeouts.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Does NGINX reverse proxy support WebSockets?</div>
    <div class="faq-a">Yes, with a map block translating $http_upgrade into the Connection header and a long proxy_read_timeout. The same NGINX can serve regular HTTP and WebSocket traffic on different paths, common pattern is /api/ for HTTP and /ws/ for WebSocket.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">How do I set up an NGINX reverse proxy on Ubuntu?</div>
    <div class="faq-a">Install nginx (the myguard packages give you HTTP/3, Brotli, and modern OpenSSL out of the box), drop a server block into /etc/nginx/sites-available/, symlink to sites-enabled, nginx -t and reload. Add a Let&#8217;s Encrypt certificate with certbot or use Angie&#8217;s native ACME client.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Should I use a Unix socket or TCP for the upstream backend?</div>
    <div class="faq-a">Unix socket when NGINX and the backend are on the same machine, it skips the TCP/IP stack entirely and is measurably faster on PHP-FPM-style workloads. TCP when they&#8217;re on different machines or when the backend is in a container with its own network namespace.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Can NGINX reverse-proxy HTTPS backends?</div>
    <div class="faq-a">Yes, use proxy_pass https://backend and add proxy_ssl_verify on plus proxy_ssl_trusted_certificate pointing at the CA bundle. For internal backends with self-signed certs, set proxy_ssl_verify off (and acknowledge the security trade-off).</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">How does NGINX reverse proxy handle backend failures?</div>
    <div class="faq-a">Passive health checks: max_fails and fail_timeout mark a backend out of rotation after N consecutive failures. Combined with proxy_cache_use_stale, NGINX can serve stale-but-cached responses while the backend is down, making outages much less visible to users.</div>
  </div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
  <li><a href="/2026/05/nginx-load-balancing-upstream-guide/">NGINX Load Balancing: Upstream Config, Health Checks and Failover</a>: when one backend grows into a fleet.</li>
  <li><a href="/2026/05/nginx-rate-limiting-guide/">NGINX Rate Limiting Guide</a>: protect the backend you just put behind NGINX.</li>
  <li><a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX + PHP-FPM Configuration Guide</a>: proxy_pass to PHP-FPM, which is fastcgi_pass technically, but same idea.</li>
  <li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: add WAF protection to your reverse proxy.</li>
  <li><a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS Configuration for NGINX and Angie</a>: A+ on SSL Labs for the HTTPS your reverse proxy terminates.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>NGINX Rate Limiting: Protect Your Server from Bots, Scrapers and Brute Force</title>
		<link>https://deb.myguard.nl/2026/05/nginx-rate-limiting-guide/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Tue, 12 May 2026 22:23:06 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[rate-limiting]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/nginx-rate-limiting-guide/</guid>

					<description><![CDATA[NGINX rate limiting with limit_req_zone stops credential stuffing, scrapers, and DDoS floods before they reach your application. This guide covers burst handling, per-endpoint limits, IP whitelisting, WordPress-specific config, and Redis-backed cross-server limiting.]]></description>
										<content:encoded><![CDATA[
<p>Every server on the public internet gets hammered. Credential-stuffing bots trying username/password combinations against your login. Scrapers pulling your entire site at 200 requests per second. Vulnerability scanners testing for last week&#8217;s WordPress CVE. Most of this traffic is automated, none of it pays you, and all of it eats CPU you could use serving real visitors. <strong>NGINX rate limiting</strong> is the cheap, simple, and remarkably effective first line of defence.</p>

<p>This guide covers the full NGINX rate limit configuration stack: <code>limit_req_zone</code> and <code>limit_req</code>, burst handling, per-endpoint limits, per-IP and per-user keys, connection limits, the WordPress-specific patterns that protect <code>wp-login.php</code> and <code>xmlrpc.php</code>, and Redis-backed cross-server limiting for multi-NGINX deployments. Tested on Debian and Ubuntu with the <a href="/how-to-use/">myguard packaged NGINX</a>.</p>

<h2 style="color:#f59e0b">How NGINX Rate Limiting Actually Works</h2>

<p>NGINX rate limiting is built on the <strong>leaky bucket</strong> algorithm. You declare a shared memory zone that tracks request rates per key (usually the client IP). When a request arrives, NGINX checks whether the key has exceeded its allowed rate. If it has, NGINX returns a <strong>429 Too Many Requests</strong> immediately, without touching your backend.</p>

<p>The key insight: the rate limit is enforced at the NGINX layer, before PHP-FPM, before your database, before any application code. A bot can hammer you at 10,000 req/s and your PHP workers never wake up. That is the difference between &#8220;site is slow&#8221; and &#8220;site is fine, bots are 429ing.&#8221;</p>

<h2 style="color:#f59e0b">Basic NGINX Rate Limit Configuration</h2>

<p>Two directives do the work: <code>limit_req_zone</code> (declare the zone) and <code>limit_req</code> (apply it):</p>

<pre><code>http {
    # Declare the zone — 10MB of shared memory, 5 requests per second per IP
    limit_req_zone $binary_remote_addr zone=general:10m rate=5r/s;

    server {
        location / {
            # Apply the zone, allow short bursts of up to 10 requests
            limit_req zone=general burst=10 nodelay;

            # ... your usual config
        }
    }
}</code></pre>

<p>Decoding it:</p>

<ul>
  <li><strong>$binary_remote_addr</strong>: the client IP in a compact 4-byte (IPv4) or 16-byte (IPv6) form. Use this rather than $remote_addr; it costs less memory.</li>
  <li><strong>zone=general:10m</strong>: name the zone &#8220;general&#8221;, give it 10MB of shared memory. 10MB tracks roughly 160,000 unique IPs simultaneously.</li>
  <li><strong>rate=5r/s</strong>: five requests per second sustained, per IP. Below the threshold most browsers naturally fall under, well above the threshold most bots hammer at.</li>
  <li><strong>burst=10</strong>: allow up to 10 requests to queue up if the IP briefly spikes above 5r/s. Without burst, every spike returns 429 immediately and breaks legitimate users.</li>
  <li><strong>nodelay</strong>: process burst requests immediately instead of evenly spreading them. Without nodelay, NGINX delays responses to enforce the average rate, which feels broken to users on legitimate traffic spikes.</li>
</ul>

<h2 style="color:#f59e0b">Burst vs nodelay: The Setting Most People Get Wrong</h2>

<p>Without <code>nodelay</code>, NGINX delays burst requests to smooth the rate. With <code>nodelay</code>, NGINX processes burst requests immediately but counts them against the bucket. <strong>You almost always want nodelay</strong>, delayed responses look like a hanging server to the client browser, and modern HTTP clients give up.</p>

<pre><code># Bad (delays user requests, looks like a hang):
limit_req zone=general burst=10;

# Good (allows bursts, returns 429 if the bucket overflows):
limit_req zone=general burst=10 nodelay;</code></pre>

<h2 style="color:#f59e0b">Per-Endpoint Rate Limits</h2>

<p>One zone for the whole site is fine for general protection. The bigger win is different rates for different endpoints. Login pages, search, and admin panels each get their own bucket:</p>

<pre><code>http {
    # General browsing: generous limit
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

    # Login: very strict — 5 attempts per minute
    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

    # Search: medium strict — search is expensive
    limit_req_zone $binary_remote_addr zone=search:10m rate=2r/s;

    # API: separate bucket so the website is unaffected
    limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;

    server {
        # Default for all locations
        location / {
            limit_req zone=general burst=20 nodelay;
        }

        # Strict on login
        location = /wp-login.php {
            limit_req zone=login burst=5 nodelay;
            limit_req_status 429;
            # ...PHP-FPM config
        }

        # Strict on search
        location /?s= {
            limit_req zone=search burst=5 nodelay;
        }

        # API gets its own pool
        location /api/ {
            limit_req zone=api burst=50 nodelay;
        }
    }
}</code></pre>

<h2 style="color:#f59e0b">Protecting WordPress: wp-login.php and xmlrpc.php</h2>

<p>This is the configuration every WordPress site should run. WordPress&#8217;s <code>wp-login.php</code> and <code>xmlrpc.php</code> are the two most-attacked URLs on the internet. Rate limiting them stops credential-stuffing attacks cold:</p>

<pre><code>http {
    limit_req_zone $binary_remote_addr zone=wp_login:10m rate=5r/m;
}

server {
    # Hard limit on wp-login.php
    location = /wp-login.php {
        limit_req zone=wp_login burst=3 nodelay;
        limit_req_status 429;
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    # xmlrpc.php is almost never legitimate — block entirely if you don't use Jetpack
    location = /xmlrpc.php {
        deny all;
        access_log off;
    }

    # Or, if you DO use xmlrpc.php, rate-limit instead of block:
    # location = /xmlrpc.php {
    #     limit_req zone=wp_login burst=2 nodelay;
    #     ... PHP-FPM config
    # }
}</code></pre>

<p>5 requests per minute is brutal, a legitimate user logging in once does not hit it. A credential-stuffing bot hitting 50 passwords per second hits it on the second attempt and stays 429&#8217;d for the next minute. This single change typically cuts bot traffic by 80-95% overnight.</p>

<h2 style="color:#f59e0b">Connection Limits Versus Request Limits</h2>

<p>Rate limiting limits <em>requests per second</em>. The other useful primitive is <strong>connection limiting</strong>, capping how many concurrent connections one IP can hold open. The two work well together against different attack shapes:</p>

<pre><code>http {
    # Rate (requests per second)
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

    # Concurrent connections per IP
    limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;

    server {
        location / {
            limit_req zone=general burst=20 nodelay;
            limit_conn conn_per_ip 20;  # max 20 simultaneous connections per IP
        }
    }
}</code></pre>

<p>20 concurrent connections is generous (most real browsers use 6 per host). A scraper opening 200 connections from one IP gets cut off at 20. Without this, a single attacker can exhaust your worker_connections pool.</p>

<h2 style="color:#f59e0b">Whitelisting Trusted IPs</h2>

<p>Monitoring tools, your office IP, your CDN, these should bypass rate limits. Use the <code>geo</code> directive plus a map to build a whitelist:</p>

<pre><code>http {
    geo $limit_exempt {
        default        0;
        10.0.0.0/8     1;   # Internal network
        203.0.113.45/32 1;  # Office IP
        2001:db8::/32   1;  # Office IPv6
    }

    # Use the geo result to choose the rate-limit key
    map $limit_exempt $limit_key {
        0  $binary_remote_addr;
        1  "";   # empty key — no rate limit
    }

    limit_req_zone $limit_key zone=general:10m rate=10r/s;

    server {
        location / {
            limit_req zone=general burst=20 nodelay;
        }
    }
}</code></pre>

<p>Requests from whitelisted IPs use an empty key, which NGINX treats as &#8220;no limit.&#8221; Everyone else is rate-limited normally.</p>

<h2 style="color:#f59e0b">Customising the 429 Response</h2>

<p>By default NGINX returns a plain 429. For a friendlier user experience (and a faster page), serve a custom static 429 page:</p>

<pre><code>server {
    error_page 429 /429.html;

    location = /429.html {
        root /var/www/error-pages;
        internal;
        add_header Retry-After 60 always;
    }

    location / {
        limit_req zone=general burst=20 nodelay;
        limit_req_status 429;
    }
}</code></pre>

<p>The <code>Retry-After</code> header tells well-behaved clients (Googlebot, modern HTTP libraries) when to come back. Bots tend to ignore it but legitimate scrapers respect it, which keeps SEO crawlers happy.</p>

<h2 style="color:#f59e0b">Logging Rate-Limited Requests</h2>

<p>NGINX logs 429 responses in the regular access log. For dedicated rate-limit visibility, add a separate access log with a focused format:</p>

<pre><code>http {
    map $status $loggable_429 {
        429  1;
        default  0;
    }

    log_format ratelimit '$remote_addr - $time_iso8601 "$request" '
                        'status=$status zone=$limit_req_zone '
                        'ua="$http_user_agent"';

    server {
        access_log /var/log/nginx/ratelimit.log ratelimit if=$loggable_429;
    }
}</code></pre>

<p>Tail <code>/var/log/nginx/ratelimit.log</code> and you have a live feed of attacking IPs and their user agents. Combine with fail2ban for automated longer-term IP banning.</p>

<h2 style="color:#f59e0b">fail2ban Integration: From Rate Limit to IP Ban</h2>

<p>NGINX rate limiting returns 429s. fail2ban watches the log, counts repeated 429s from one IP, and adds an iptables rule blocking that IP entirely for a configurable duration. Two layers, much harder to attack:</p>

<pre><code># /etc/fail2ban/jail.d/nginx-ratelimit.conf
[nginx-ratelimit]
enabled  = true
filter   = nginx-ratelimit
action   = iptables-multiport[name=NginxRatelimit, port="http,https"]
logpath  = /var/log/nginx/ratelimit.log
maxretry = 50
findtime = 600   # 10-minute window
bantime  = 3600  # ban for 1 hour</code></pre>

<pre><code># /etc/fail2ban/filter.d/nginx-ratelimit.conf
[Definition]
failregex = ^&lt;HOST&gt; - .* status=429
ignoreregex =</code></pre>

<p>50 rate-limit hits in 10 minutes earns an IP a one-hour iptables ban. Tune the numbers to your traffic. Combine with cloud-level WAF (Cloudflare, AWS WAF) for the heaviest attackers.</p>

<h2 style="color:#f59e0b">Sizing the Shared Memory Zone</h2>

<p>The shared memory zone holds one record per tracked key. The records are tiny (about 64 bytes each in modern NGINX). Rough sizing:</p>

<ul>
  <li><strong>10MB</strong> tracks roughly 160,000 unique IPs simultaneously: fine for almost any site.</li>
  <li><strong>50MB</strong> tracks roughly 800,000 unique IPs: for very high-traffic public sites.</li>
  <li><strong>1MB</strong> tracks roughly 16,000 IPs: fine for internal services.</li>
</ul>

<p>If the zone fills up, NGINX evicts the oldest record. The zone never blocks; the worst case is a brief tracking gap during an attack.</p>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>

<div class="faq">
  <div class="faq-item">
    <div class="faq-q">What rate limit should I set for wp-login.php?</div>
    <div class="faq-a">5 requests per minute per IP with burst=3 nodelay is a solid default. A legitimate user logging in once never hits it; credential-stuffing bots are 429&#8217;d within seconds. If you also need WordPress REST API auth, give /wp-json/wp/v2/users/me a separate, slightly more relaxed bucket.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Does NGINX rate limiting work behind Cloudflare?</div>
    <div class="faq-a">Yes, but you must use $http_cf_connecting_ip instead of $binary_remote_addr, otherwise every request looks like it came from a Cloudflare edge IP and your limits trigger on Cloudflare itself. Set real_ip_header CF-Connecting-IP and trust the Cloudflare IP ranges with set_real_ip_from.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Will rate limiting block Googlebot?</div>
    <div class="faq-a">Not at sensible thresholds. Googlebot respects 429 responses and the Retry-After header. Set generous limits on /, strict limits on /wp-login.php. Whitelisting Googlebot by user agent is not recommended (any bot can lie about its UA), verify Googlebot via reverse DNS if you really need it bypassed.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Should I use burst with delay or burst with nodelay?</div>
    <div class="faq-a">Almost always nodelay. Without nodelay, NGINX holds burst requests open and serves them at the configured rate, that looks like a hung server to the user&#8217;s browser. With nodelay, burst requests are processed immediately and only excess returns 429.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">How do I rate-limit by user (logged-in cookie) instead of IP?</div>
    <div class="faq-a">Use a custom variable. Extract the user from $http_cookie or a session header with a map directive, fall back to $binary_remote_addr when the user variable is empty. The map output becomes your limit_req_zone key.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Can NGINX rate limiting protect against full DDoS?</div>
    <div class="faq-a">No, by the time traffic reaches your NGINX, it has already consumed bandwidth. Volumetric DDoS protection happens at the cloud / CDN layer (Cloudflare, AWS Shield, etc.). NGINX rate limiting protects against application-layer abuse: credential stuffing, scraping, scanner traffic, slow-burn brute force.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Does rate limiting work for HTTP/2 and HTTP/3?</div>
    <div class="faq-a">Yes, limit_req and limit_conn work identically across HTTP/1.1, HTTP/2 and HTTP/3 in modern NGINX. The leaky-bucket algorithm tracks logical requests, not TCP connections, so multiplexed HTTP/2 and QUIC streams count individually.</div>
  </div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
  <li><a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX + PHP-FPM Configuration Guide</a>: the full WordPress server config rate limiting fits inside.</li>
  <li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: the WAF that pairs perfectly with rate limiting.</li>
  <li><a href="/2026/05/nginx-reverse-proxy-configuration-guide/">NGINX Reverse Proxy Configuration Guide</a>: proxying with rate limits in front of backends.</li>
  <li><a href="/2026/05/nginx-load-balancing-upstream-guide/">NGINX Load Balancing</a>: protecting each upstream from abuse.</li>
  <li><a href="/2024/01/enhancing-web-security-with-php-snuffleupagus-for-php-fpm/">PHP-Snuffleupagus</a>: interpreter-level security for the layer behind NGINX.</li>
</ul>]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>NGINX Brotli Compression: Install, Configure and Pre-Compress Static Assets</title>
		<link>https://deb.myguard.nl/2026/05/nginx-brotli-compression-module-guide/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Tue, 12 May 2026 22:23:05 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[brotli]]></category>
		<category><![CDATA[compression]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/nginx-brotli-compression-module-guide/</guid>

					<description><![CDATA[Brotli achieves 15-26% better compression than gzip on HTML, CSS, and JavaScript. This guide covers installing the NGINX Brotli module, configuring on-the-fly compression, pre-compressing static assets at level 11, and running Brotli alongside gzip.]]></description>
										<content:encoded><![CDATA[
<p>Gzip has been compressing web content since 1992. It&#8217;s good. It&#8217;s everywhere. And it&#8217;s showing its age. <strong>Brotli</strong> is its modern replacement, developed by Google, standardised in 2016 (RFC 7932), and now supported by every browser that matters. On typical web content, Brotli achieves 15–26% better compression than gzip at comparable speeds. Smaller files mean faster page loads, lower bandwidth costs, and better Core Web Vitals scores. This guide sets up NGINX Brotli compression the right way, including pre-compressing static assets so NGINX Brotli serves them with zero per-request CPU. See the <a href="https://github.com/google/brotli" rel="noopener" target="_blank">Brotli reference implementation</a> for the format itself.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-brotli-compression.webp" alt="NGINX Brotli compression shrinking static assets" width="1024" height="576" loading="lazy"/></figure>

<p>The <a href="/how-to-use/">myguard APT repository</a> ships a native Brotli dynamic module for both NGINX and Angie, install it with one apt command, load it with one config line, and your server starts serving Brotli to every browser that supports it.</p>

<h2 style="color:#f59e0b">NGINX Brotli vs gzip: the actual numbers</h2>

<p>Brotli uses a combination of LZ77, Huffman coding, and a 2nd-order context modeling that gzip doesn&#8217;t have. The practical result:</p>

<table>
  <thead><tr><th>Content type</th><th>Gzip (level 6)</th><th>Brotli (level 6)</th><th>Brotli advantage</th></tr></thead>
  <tbody>
    <tr><td>HTML</td><td>68% reduction</td><td>78% reduction</td><td>+15%</td></tr>
    <tr><td>CSS</td><td>72% reduction</td><td>84% reduction</td><td>+21%</td></tr>
    <tr><td>JavaScript</td><td>67% reduction</td><td>80% reduction</td><td>+19%</td></tr>
    <tr><td>JSON API response</td><td>71% reduction</td><td>83% reduction</td><td>+17%</td></tr>
    <tr><td>SVG</td><td>74% reduction</td><td>86% reduction</td><td>+19%</td></tr>
  </tbody>
</table>

<p>Brotli level 11 (maximum) achieves 20–26% better compression than gzip, but is extremely slow to encode, suitable only for pre-compressed static assets, not on-the-fly. Level 4–6 is the sweet spot for on-the-fly dynamic compression: better than gzip, fast enough for real-time use.</p>

<h2 style="color:#f59e0b">Step 1, Install the Brotli Module</h2>

<pre><code># Add the myguard repository if not already done
wget https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb
dpkg -i myguard.deb
apt-get update

# Install NGINX with the Brotli module
apt-get install nginx libnginx-mod-http-brotli

# Or for Angie:
apt-get install angie angie-module-http-brotli</code></pre>

<p>New to the myguard repository? <a href="/how-to-use/">Follow the two-minute setup guide.</a></p>

<h2 style="color:#f59e0b">Step 2, Load the Module</h2>

<p>The myguard package installs a load snippet automatically. Verify it&#8217;s in place:</p>

<pre><code>ls /etc/nginx/modules-enabled/ | grep brotli
# Should show: 50-mod-http-brotli-filter.conf and 50-mod-http-brotli-static.conf</code></pre>

<p>If not present, add to the top of <code>nginx.conf</code> (before the http block):</p>
<pre><code>load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;</code></pre>

<h2 style="color:#f59e0b">Step 3, Configure Brotli</h2>

<p>Add this inside your <code>http</code> block in <code>nginx.conf</code>:</p>

<pre><code>http {
    # Brotli dynamic compression (on-the-fly)
    brotli             on;
    brotli_comp_level  6;        # 0-11, sweet spot is 4-6
    brotli_min_length  256;      # Don't compress tiny responses
    brotli_types
        text/plain
        text/css
        text/javascript
        text/xml
        text/x-component
        application/javascript
        application/json
        application/xml
        application/rss+xml
        application/atom+xml
        application/vnd.ms-fontobject
        image/svg+xml
        font/truetype
        font/opentype;

    # Brotli static files (serve pre-compressed .br files)
    brotli_static on;

    # Keep gzip as fallback for browsers that don't support Brotli
    gzip            on;
    gzip_comp_level 6;
    gzip_min_length 256;
    gzip_vary       on;
    gzip_types
        text/plain text/css text/javascript application/javascript
        application/json application/xml image/svg+xml font/opentype;
}</code></pre>

<h2 style="color:#f59e0b">Step 4, Test and Reload</h2>

<pre><code>nginx -t && systemctl reload nginx</code></pre>

<p>Verify Brotli is working:</p>
<pre><code># curl with Brotli accept header
curl -H 'Accept-Encoding: br,gzip' -I https://example.com
# Look for: Content-Encoding: br

# Check Chrome DevTools: Network tab > select a request > Response Headers > Content-Encoding: br</code></pre>

<h2 style="color:#f59e0b">Pre-Compressed Static Assets (Best Performance)</h2>

<p>For static files that don&#8217;t change (CSS, JS, fonts), pre-compress them at build time with level 11 and let NGINX serve the <code>.br</code> files directly. This gives maximum compression with zero runtime CPU cost:</p>

<pre><code># Pre-compress all JS and CSS files in your web root
find /var/www/html -name '*.js' -o -name '*.css' | while read f; do
    brotli -Z -f "$f" -o "${f}.br"   # -Z = level 11
    gzip -9 -k -f "$f"               # -k = keep original, for fallback
done</code></pre>

<p>With <code>brotli_static on</code> in your NGINX config, when a browser requests <code>app.js</code> with <code>Accept-Encoding: br</code>, NGINX automatically serves <code>app.js.br</code> without doing any runtime compression. Zero CPU, maximum compression.</p>

<pre><code># Install the brotli CLI tool
apt-get install brotli</code></pre>

<h2 style="color:#f59e0b">Brotli for WordPress</h2>

<p>WordPress sites benefit significantly from Brotli because WordPress generates a lot of HTML, CSS, and JavaScript. The main caveat: PHP responses are compressed dynamically, so set a sane compression level (4–6) to avoid adding more than ~1ms of CPU time per request.</p>

<p>Typical page size reduction for a WordPress homepage:</p>
<ul>
  <li>Uncompressed HTML: ~180KB</li>
  <li>Gzip level 6: ~28KB</li>
  <li>Brotli level 6: ~23KB</li>
  <li>Brotli level 11 (pre-compressed): ~19KB</li>
</ul>

<p>The 5KB difference between gzip and Brotli level 6 saves ~40ms on a typical 4G connection. Across thousands of page views, that&#8217;s meaningful for Core Web Vitals.</p>

<h2 style="color:#f59e0b">Brotli + zstd: Running Both</h2>

<p>The myguard repository also ships a <a href="/2026/05/zstd-nginx-module-what-it-does-bugs-fixed/">zstd NGINX module</a>. zstd excels at server-side API compression (faster decode, great for JSON) while Brotli excels at browser-facing content (better compression ratio). Run both:</p>

<pre><code>apt-get install libnginx-mod-http-brotli libnginx-mod-http-zstd

# In http block:
brotli on;        # For browsers (HTML, CSS, JS)
brotli_types text/html text/css application/javascript image/svg+xml;

zstd on;          # For API clients that support it
zstd_types application/json application/x-ndjson;
zstd_comp_level 3;</code></pre>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>

<div class="faq">
  <div class="faq-item">
    <div class="faq-q">Do all browsers support Brotli?</div>
    <div class="faq-a">Every browser released after 2017 supports Brotli, Chrome, Firefox, Safari, Edge. Coverage is 96%+ of global browser usage. NGINX with Brotli still serves gzip to the ~4% that don&#8217;t support it (IE11, very old Safari), so there&#8217;s no compatibility risk.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Does Brotli work with HTTPS only?</div>
    <div class="faq-a">Technically no, Brotli can work over HTTP. But all browsers only send the Accept-Encoding: br header on HTTPS connections, because early Brotli deployments over HTTP caused issues with some HTTP proxies. In practice: Brotli only activates on HTTPS, which is fine since you should be using HTTPS anyway.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">What compression level should I use?</div>
    <div class="faq-a">Level 4–6 for dynamic on-the-fly compression (good ratio, fast). Level 11 only for pre-compressed static assets (maximum ratio, but too slow for real-time use). The default of 6 in the config above is the practical sweet spot for most sites.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Does Brotli affect CPU usage?</div>
    <div class="faq-a">At level 6, dynamic Brotli adds roughly 1–2ms of CPU time per response compared to gzip. On a server handling 500 req/s that&#8217;s about 3–5% extra CPU load. Pre-compressed static assets with brotli_static eliminate runtime CPU entirely for those files.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Can I use Brotli with Angie?</div>
    <div class="faq-a">Yes. Install angie-module-http-brotli instead of libnginx-mod-http-brotli. The configuration directives are identical.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Should I disable gzip when using Brotli?</div>
    <div class="faq-a">No, keep gzip enabled alongside Brotli. NGINX automatically serves Brotli to browsers that support it and gzip to those that don&#8217;t. Disabling gzip would break compression for the small percentage of users on older browsers or corporate proxies that strip Brotli support.</div>
  </div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
  <li><a href="/2026/05/zstd-nginx-module-what-it-does-bugs-fixed/">zstd NGINX Module: What It Does and 22 Bug Fixes</a>: the other modern compression option, great for API workloads</li>
  <li><a href="/nginx-modules/">NGINX Dynamic Modules Overview</a>: Brotli is one of 50+ available modules</li>
  <li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">NGINX Performance and Security Expert Guide</a>: full performance tuning guide including compression strategy</li>
  <li><a href="/2026/05/nginx-vs-apache-benchmark-2026/">NGINX vs Apache Benchmark 2026</a>: performance comparison including compression overhead</li>
  <li><a href="/how-to-use/">How to Add the myguard APT Repository</a>: where the Brotli module comes from</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>NGINX on Debian 13 Trixie: What Changed and How to Upgrade</title>
		<link>https://deb.myguard.nl/2026/05/nginx-debian-13-trixie-upgrade-guide/</link>
		
		<dc:creator><![CDATA[]]></dc:creator>
		<pubDate>Tue, 12 May 2026 22:17:38 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[http3]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[trixie]]></category>
		<category><![CDATA[upgrade]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/nginx-debian-13-trixie-upgrade-guide/</guid>

					<description><![CDATA[Debian 13 Trixie brings GCC 14, OpenSSL 3.3, PHP 8.4, systemd 256, and a newer Linux kernel. Here is what each change means for your NGINX and Angie setup, with a complete upgrade checklist.]]></description>
										<content:encoded><![CDATA[
<p>Running NGINX Debian 13 Trixie is no longer a testing-branch gamble: Trixie shipped stable in August 2025, and the NGINX Debian 13 Trixie story comes with a new compiler, new OpenSSL, new PHP defaults, and a new systemd. If you&#8217;re migrating from Debian 12 Bookworm, those changes affect every NGINX and Angie deployment, and a couple of them will trip you up if you&#8217;re not paying attention.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-debian-13-trixie-upgrade.webp" alt="NGINX on Debian 13 Trixie upgrade from Bookworm" width="1024" height="576" loading="lazy"/></figure>

<p>The good news: the <a href="/how-to-use/">myguard APT repository</a> has shipped Trixie packages since day one of the testing cycle. Install NGINX or Angie from deb.myguard.nl and you automatically get builds compiled natively on Trixie&#8217;s toolchain, not backports, not compatibility shims, not &#8220;should work&#8221; guesswork. The <a href="https://www.debian.org/releases/trixie/" rel="noopener" target="_blank">official Debian 13 release notes</a> list every transition in detail.</p>

<h2 style="color:#f59e0b">What NGINX Debian 13 Trixie actually changes</h2>

<p>Trixie is the development codename for Debian&#8217;s next stable release. Debian names its releases after Toy Story characters, after Bookworm (Debian 12) comes Trixie, the triceratops. Once Trixie is declared stable (expected 2025–2026), it will become &#8220;Debian 13&#8221; and receive five-plus years of security support.</p>

<p>Right now, Trixie is in &#8220;testing&#8221; status: it receives updates continuously, packages are more recent than Bookworm&#8217;s, and it&#8217;s broadly stable but not yet officially blessed for production. Many sysadmins run Trixie on servers where they want newer software without compiling from source. The myguard repository treats Trixie as a first-class target.</p>

<h2 style="color:#f59e0b">What Changed in Trixie That Affects NGINX</h2>

<h3>GCC 14 Compiler</h3>

<p>Our Trixie NGINX and Angie packages are compiled with GCC 14, which enables more aggressive auto-vectorization and improved link-time optimization. GCC 14 is also stricter about certain C code patterns, this pushed a few module patches to ensure clean compilation. The result: measurably better performance on modern CPUs, especially for compression (brotli, zstd, gzip) which is heavily vectorized.</p>

<p>Approximate GCC 14 gains on amd64:</p>
<ul>
  <li><strong>Brotli compression:</strong> ~8–12% faster encoding from improved Huffman codec vectorization</li>
  <li><strong>zstd compression:</strong> ~6–10% faster at level 3 via AVX2 path improvements</li>
  <li><strong>TLS handshakes:</strong> ~5% improvement from better P-256 curve codegen</li>
</ul>

<h3>OpenSSL 3.3 (vs 3.0 on Bookworm)</h3>

<p>Bookworm shipped with OpenSSL 3.0 LTS. Trixie upgrades to OpenSSL 3.3, which brings improved TLS 1.3 internals, better QUIC support, and performance improvements in elliptic curve operations. This matters even if you use our dedicated <a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx</a> package (which is compiled independently), because the system OpenSSL is used by tools you run alongside NGINX, certbot, curl, the openssl CLI, Python scripts.</p>

<p>OpenSSL 3.3 is also stricter about malformed certificates that 3.0 accepted with warnings. Validate internal/self-signed certs before upgrading: <code>openssl verify -CAfile /path/to/ca.pem /path/to/cert.pem</code></p>

<h3>PHP 8.4 Default</h3>

<p>Trixie&#8217;s default PHP version is 8.4. If you&#8217;re running PHP-FPM with NGINX for WordPress, check plugin compatibility before upgrading. PHP 8.4 promoted some dynamic property deprecation warnings to errors, well-maintained plugins are fine, but older ones that haven&#8217;t been updated since 2020 may throw fatal errors.</p>

<p>Quick compatibility check before upgrading:</p>
<pre><code>php8.4 -f /path/to/wp-config.php 2>&amp;1 | grep -i fatal
php8.4 -m | grep -v '[' | sort  # List loaded modules</code></pre>

<p>For PHP security hardening, the myguard repository ships <code>php8.4-snuffleupagus</code>, install it alongside PHP-FPM for interpreter-level protection.</p>

<h3>systemd 256</h3>

<p>Trixie ships systemd 256, which introduces more aggressive cgroup isolation defaults. This is mostly transparent for NGINX, but if you use custom systemd service overrides touching <code>PrivateTmp</code>, <code>ProtectSystem</code>, or cgroup limits, review them. The standard NGINX and Angie systemd units from the myguard packages are already updated for systemd 256 compatibility.</p>

<h3>Linux Kernel 6.11+</h3>

<p>Trixie tracks a much newer kernel than Bookworm&#8217;s 6.1. For NGINX specifically, the newer kernel improves kTLS (Kernel TLS offload) performance, improves io_uring support, and has better QUIC-layer socket handling. If you enable kTLS on Trixie, you&#8217;re getting noticeably better TLS offload than on Bookworm.</p>

<pre><code># Verify kTLS is available
modprobe tls && lsmod | grep tls

# Enable in nginx.conf
ssl_conf_command Options KTLS;</code></pre>

<h2 style="color:#f59e0b">Installing NGINX or Angie on Trixie</h2>

<p>Same as any other Debian release, add the myguard repository and install:</p>

<pre><code>wget https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb
dpkg -i myguard.deb
apt-get update
apt-get install nginx    # or: apt-get install angie</code></pre>

<p>Verify build info and check for the Trixie toolchain:</p>
<pre><code>nginx -V 2>&amp;1</code></pre>

<p>New to the myguard repository? <a href="/how-to-use/">Follow the two-minute setup guide.</a></p>

<h2 style="color:#f59e0b">Upgrading from Bookworm to Trixie</h2>

<pre><code># Step 1 — Back up NGINX config
tar -czf /tmp/nginx-config-backup.tar.gz /etc/nginx/

# Step 2 — Note current versions
nginx -V 2>&amp;1 > /tmp/nginx-v-before.txt

# Step 3 — Update Debian sources
sed -i 's/bookworm/trixie/g' /etc/apt/sources.list
apt update
apt full-upgrade

# Step 4 — myguard repo auto-detects Trixie, no changes needed
apt install nginx   # refresh to Trixie build

# Step 5 — Test and reload
nginx -t && systemctl reload nginx</code></pre>

<h2 style="color:#f59e0b">Fresh Install Checklist for Trixie</h2>

<ol>
  <li><strong>Add myguard repository:</strong> <code>wget https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb &amp;&amp; dpkg -i myguard.deb</code></li>
  <li><strong>Install NGINX:</strong> <code>apt-get install nginx</code>: pulls in openssl-nginx automatically</li>
  <li><strong>Add modules:</strong> brotli, ModSecurity, Lua, GeoIP2: all available as dynamic modules</li>
  <li><strong>Open UDP 443:</strong> HTTP/3 requires it: <code>ufw allow 443/udp</code></li>
  <li><strong>Configure TLS:</strong> Use the <a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS guide</a> for A+ on SSL Labs</li>
  <li><strong>Install PHP-FPM:</strong> <code>apt-get install php8.4-fpm php8.4-mysql php8.4-xml php8.4-curl</code></li>
  <li><strong>Harden PHP:</strong> <code>apt-get install php8.4-snuffleupagus</code></li>
</ol>

<h2 style="color:#f59e0b">Module Compatibility on Trixie</h2>

<p>All 50+ <a href="/nginx-modules/">dynamic modules</a> in the myguard repository are compiled natively for Trixie, no compatibility layer, built against the same NGINX and library versions as the main packages:</p>

<pre><code>apt-get install libnginx-mod-http-brotli       # Brotli compression
apt-get install libnginx-mod-http-modsecurity  # ModSecurity WAF
apt-get install libnginx-mod-http-lua          # Lua scripting
apt-get install libnginx-mod-http-zstd         # Zstandard compression
apt-get install libnginx-mod-http-geoip2       # GeoIP2 routing</code></pre>

<h2 style="color:#f59e0b">Known Issues and Gotchas</h2>

<h3>PHP 8.4 strict deprecations</h3>
<p>Some older WordPress plugins throw deprecation notices under PHP 8.4. They won&#8217;t break your site but may spam the error log. Suppress them with <code>error_reporting = E_ALL &amp; ~E_DEPRECATED</code> in your FPM pool config while waiting for plugin updates.</p>

<h3>systemd 256 PrivateTmp changes</h3>
<p>If you use a custom <code>/etc/systemd/system/nginx.service.d/override.conf</code> that modifies <code>PrivateTmp</code> with custom paths, review it. systemd 256 changed how PrivateTmp interacts with bind-mounted directories. The default myguard service unit is already correct.</p>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>

<div class="faq">
  <div class="faq-item">
    <div class="faq-q">Is Trixie stable enough for production?</div>
    <div class="faq-a">Trixie is Debian&#8217;s testing branch, broadly stable, but it hasn&#8217;t had the final freeze and stabilization pass that a Debian stable release gets. Many sysadmins run it on production servers without issues. For critical systems, Bookworm (Debian 12) is the safer choice until Trixie goes stable.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Do I need to change the myguard repository URL for Trixie?</div>
    <div class="faq-a">No. The myguard repository uses a &#8220;stable&#8221; suite that automatically maps to the correct packages for your Debian release. The same sources.list entry works on Bookworm, Trixie, and future releases.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Which PHP version should I use on Trixie for WordPress?</div>
    <div class="faq-a">PHP 8.4 is Trixie&#8217;s default and is compatible with all well-maintained WordPress plugins. Run compatibility checks before upgrading production. Pair it with php8.4-snuffleupagus from the myguard repository for interpreter-level security hardening.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Will my existing NGINX config work on Trixie?</div>
    <div class="faq-a">Yes. NGINX configuration syntax hasn&#8217;t changed. Your /etc/nginx/ directory is preserved during the Bookworm-to-Trixie upgrade. Run nginx -t after upgrading to verify, then reload.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Does HTTP/3 work better on Trixie than Bookworm?</div>
    <div class="faq-a">Slightly yes, Trixie&#8217;s kernel (6.11+) has improved QUIC socket handling vs Bookworm&#8217;s 6.1. The difference is most noticeable under high concurrent connection load. For most sites the improvement is marginal; for high-traffic servers it&#8217;s measurable.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">What is the Debian 13 release date?</div>
    <div class="faq-a">Debian doesn&#8217;t commit to fixed release dates, it releases when ready. Trixie is expected to go stable in 2025–2026. Follow the freeze schedule at debian.org/releases.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Does Angie work on Trixie the same as NGINX?</div>
    <div class="faq-a">Yes. Angie packages for Trixie are in the myguard repository alongside NGINX. Same installation process, same dynamic modules, same configuration syntax. Angie adds native ACME (Let&#8217;s Encrypt without Certbot) and a JSON monitoring API.</div>
  </div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
  <li><a href="/how-to-use/">How to Add the myguard APT Repository</a>: two-minute setup for Debian and Ubuntu</li>
  <li><a href="/nginx-modules/">NGINX Dynamic Modules Overview</a>: all 50+ modules, with Trixie packages available for each</li>
  <li><a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS Configuration Guide for NGINX and Angie</a>: A+ SSL Labs config with TLS 1.3 and HSTS</li>
  <li><a href="/2026/05/angie-web-server-complete-guide/">Angie Web Server: The Complete Guide</a>: review, ACME, migration guide, and monitoring</li>
  <li><a href="/2026/05/nginx-http3-quic-debian-ubuntu/">How to Enable HTTP/3 on NGINX</a>: QUIC setup that works on Trixie out of the box</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>NGINX on Debian 13 Trixie: Install, Modules and Modern Stack (2026)</title>
		<link>https://deb.myguard.nl/2026/05/nginx-debian-13-trixie/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 12 May 2026 19:57:13 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/nginx-debian-13-trixie/</guid>

					<description><![CDATA[Debian 13, codename Trixie, is the current Debian stable release, and the safest, most boring, most production-friendly Linux to run NGINX on&#8230;]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Debian 13, codename Trixie, is the current Debian stable release, and the safest, most boring, most production-friendly Linux to run NGINX on in 2026. If you want to install NGINX Debian 13 on a fresh server, this is the guide. You get the latest mainline, full module support, HTTP/3 over QUIC, ModSecurity, Brotli, and a modern TLS configuration, installed in roughly ten minutes from a packaged repository, no compiling, no Docker required. For the OS itself, the <a href="https://www.debian.org/releases/trixie/" rel="noopener" target="_blank">Debian 13 release page</a> is the canonical reference.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/install-nginx-debian-13.webp" alt="Install NGINX Debian 13 modern stack from a packaged repository" width="1024" height="576" loading="lazy"/></figure>



<p class="wp-block-paragraph">Already running NGINX on Debian 12 Bookworm and want to move to Trixie? Our companion <a href="/2026/05/nginx-debian-13-trixie-upgrade-guide/">Debian 13 NGINX upgrade guide</a> covers the migration. This page is the from-scratch install on a fresh Trixie box.</p>


<h2 style="color:#f59e0b">Why install NGINX Debian 13 (and not compile it)?</h2>

<p>Debian Trixie is the right call for production NGINX in 2026 for a few practical reasons:</p>

<ul>
  <li><strong>OpenSSL 3.3</strong> in the base system: modern TLS, post-quantum hybrid algorithms, kTLS support, without resorting to backports.</li>
  <li><strong>Linux kernel 6.11+</strong>: proper io_uring, mature kTLS, and the QUIC-friendly UDP stack you want for HTTP/3.</li>
  <li><strong>PHP 8.4 in the archive</strong>: and PHP 8.5 available from external repos. Either way, modern PHP-FPM works out of the box.</li>
  <li><strong>systemd 256</strong>: better service supervision, native socket activation, and the cgroup v2 features NGINX benefits from under load.</li>
  <li><strong>GCC 14</strong>: newer build toolchain means newer optimisations baked into upstream NGINX binaries.</li>
  <li><strong>Five years of security support</strong> from the Debian project plus LTS extensions. Boring is good.</li>
</ul>

<h2 style="color:#f59e0b">How to install NGINX Debian 13 from the repository</h2>

<p>The default Trixie NGINX is fine for &#8220;I just need a static file server&#8221;. For anything more ambitious, HTTP/3, ModSecurity, Brotli, zstd compression, modern OpenSSL with PQC, you want the myguard packages. Two minutes of setup, then NGINX is just an <code>apt install</code>:</p>

<pre><code># Add the myguard APT repository
wget -qO- https://deb.myguard.nl/gpg.key | \
  sudo gpg --dearmor -o /usr/share/keyrings/myguard-archive-keyring.gpg

echo "deb [signed-by=/usr/share/keyrings/myguard-archive-keyring.gpg] \
  / stable main" | \
  sudo tee /etc/apt/sources.list.d/myguard.list

sudo apt update

# Install the optimised mainline NGINX
sudo apt install nginx

# Verify
nginx -V 2&gt;&amp;1 | head -1
# Should show: nginx version: nginx/1.31.x (myguard build)</code></pre>

<p>Full repository setup is on the <a href="/how-to-use/">how to use page</a>, packages are GPG-signed and built natively on Trixie&#8217;s toolchain.</p>

<h2 style="color:#f59e0b">The dynamic modules to add when you install NGINX Debian 13</h2>

<p>One of the biggest practical wins of the packaged NGINX on Debian 13 Trixie is dynamic modules, you install only what you use, each as its own package, each independently updated. The defaults that most production stacks end up with:</p>

<pre><code># Compression: zstd (modern), Brotli (browser-favourite)
sudo apt install libnginx-mod-http-zstd libnginx-mod-http-brotli

# Web application firewall
sudo apt install libnginx-mod-http-modsecurity modsecurity-crs

# Real IP behind Cloudflare / proxies (in core, but worth confirming)
# Headers-more for cache header surgery
sudo apt install libnginx-mod-http-headers-more-filter

# Lua scripting (the OpenResty stack, repackaged)
sudo apt install libnginx-mod-http-lua

# Cache purge — useful for FastCGI cache invalidation
sudo apt install libnginx-mod-http-cache-purge

# Reload to pick up the new modules
sudo nginx -t &amp;&amp; sudo systemctl reload nginx</code></pre>

<p>For the full list of available modules, there are about fifty in the packaged build, see the <a href="/nginx-modules/">NGINX modules overview</a>.</p>

<h2 style="color:#f59e0b">A Sensible NGINX Configuration for Trixie</h2>

<p>The default <code>nginx.conf</code> shipped on Debian is fine but conservative. Drop a per-site config in <code>/etc/nginx/sites-available/example.com</code>, symlink to <code>sites-enabled</code>, and you have a solid starting point:</p>

<pre><code>server {
    listen 443 ssl;
    listen 443 quic reuseport;
    listen [::]:443 ssl;
    listen [::]:443 quic reuseport;

    http2 on;
    http3 on;

    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # Tell browsers HTTP/3 is available
    add_header Alt-Svc 'h3=":443"; ma=86400';
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Compression
    brotli on;
    brotli_comp_level 6;
    brotli_types text/html text/css application/javascript application/json image/svg+xml;
    zstd on;
    zstd_types text/html text/css application/javascript application/json;

    root /var/www/example.com;
    index index.html index.php;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # PHP via PHP-FPM (Debian 13 ships PHP 8.4)
    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}</code></pre>

<p>For HTTPS, install Certbot or use the <a href="/2026/05/angie-web-server-complete-guide/">Angie native ACME client</a> if you go that direction. <a href="/2026/05/nginx-http3-quic-debian-ubuntu/">HTTP/3 setup</a> has the full QUIC story including firewall rules and verification.</p>

<h2 style="color:#f59e0b">PHP-FPM on Debian 13 Trixie</h2>

<p>Trixie ships PHP 8.4 by default. Install the FPM stack:</p>

<pre><code>sudo apt install php8.4-fpm php8.4-mysql php8.4-curl php8.4-mbstring \
                 php8.4-xml php8.4-zip php8.4-intl php8.4-gd php8.4-imagick

# Want PHP 8.5 instead?
sudo apt install php8.5-fpm  # from the myguard or sury repository</code></pre>

<p>For WordPress on this stack, jump to the <a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX + PHP-FPM configuration guide</a>, it walks through pool tuning, FastCGI cache, Redis object cache and security hardening end-to-end.</p>

<h2 style="color:#f59e0b">Security: ModSecurity, Snuffleupagus and Rate Limiting</h2>

<p>A modern NGINX deployment on Debian 13 Trixie should run three layers of defence by default:</p>

<ul>
  <li><strong>ModSecurity v3 + OWASP CRS</strong> at the HTTP layer: blocks SQLi, XSS, scanner traffic. See our <a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">ModSecurity setup guide</a>.</li>
  <li><strong>PHP-Snuffleupagus</strong> inside PHP-FPM: interpreter-level security, blocks dangerous PHP functions even if a plugin gets exploited. See the <a href="/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/">Snuffleupagus tutorial</a>.</li>
  <li><strong>Rate limiting on /wp-login.php and /xmlrpc.php</strong>: see the <a href="/2026/05/nginx-rate-limiting-guide/">NGINX rate limiting guide</a>.</li>
</ul>

<p>All three are <code>apt install</code> away on Trixie with the myguard packages enabled.</p>

<h2 style="color:#f59e0b">Performance Defaults Worth Knowing</h2>

<p>The myguard NGINX on Debian 13 Trixie is built with:</p>

<ul>
  <li><strong>jemalloc</strong>: lower memory fragmentation under high connection churn.</li>
  <li><strong>zlib-ng</strong>: drop-in zlib replacement, measurably faster gzip on modern CPUs.</li>
  <li><strong>kTLS</strong> via openssl-nginx: TLS encryption offloaded to the kernel, faster TLS-heavy workloads.</li>
  <li><strong>io_uring support</strong> in the build (kernel decides whether to use it).</li>
  <li><strong>HTTP/3 and QUIC</strong> baked in: no patches, no third-party builds.</li>
</ul>

<p>Pair NGINX with our <a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">dedicated openssl-nginx OpenSSL build</a> and you have a TLS stack that scores A+ on SSL Labs with one paste.</p>

<h2 style="color:#f59e0b">Angie as an Alternative on Debian 13 Trixie</h2>

<p>If you want the same NGINX-compatible config syntax plus a few quality-of-life features (native ACME, JSON status API, dynamic upstreams), <a href="/2026/05/angie-web-server-complete-guide/">Angie</a> is a free NGINX fork from the original NGINX developers. Available as a Debian package from the myguard repository: <code>sudo apt install angie</code>. Every config snippet in this guide works under Angie unchanged.</p>

<h2 style="color:#f59e0b">Verifying the Install</h2>

<pre><code># Confirm NGINX is from the myguard repository
apt-cache policy nginx | head -10

# Confirm modules are loaded
nginx -V 2&gt;&amp;1 | tr ' ' '\n' | grep module

# Confirm HTTP/3 support is compiled in
nginx -V 2&gt;&amp;1 | grep -i quic

# Confirm OpenSSL version
nginx -V 2&gt;&amp;1 | grep -i openssl</code></pre>

<h2 style="color:#f59e0b">Common Trixie Gotchas</h2>

<ul>
  <li><strong>PHP socket path changed</strong>: Trixie uses <code>/run/php/php8.4-fpm.sock</code>; old configs pointing at <code>/var/run/php/php8.3-fpm.sock</code> need updating.</li>
  <li><strong>UFW does not allow UDP/443 by default</strong>: HTTP/3 will silently fall back to HTTP/2 unless you <code>sudo ufw allow 443/udp</code>.</li>
  <li><strong>systemd-resolved is enabled by default</strong>: if you point NGINX at <code>127.0.0.53</code> as a DNS resolver, it works, but be aware of the indirection.</li>
  <li><strong>Default file descriptor limit is higher in Trixie</strong>: you usually do not need <code>worker_rlimit_nofile</code> unless you are serving past 30k concurrent connections per worker.</li>
</ul>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Is the default Debian 13 Trixie NGINX package good enough for production?</h3>
<div class="rank-math-answer ">

<p>For static sites and basic reverse proxying, yes. For anything wanting HTTP/3, ModSecurity v3, Brotli or zstd compression, post-quantum TLS, or the OpenResty Lua stack, the default package falls short. The myguard NGINX packages on Debian 13 Trixie ship all of those compiled in as dynamic modules.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">What PHP version is in Debian 13 Trixie?</h3>
<div class="rank-math-answer ">

<p>PHP 8.4 by default. PHP 8.5 is available from external repositories (myguard, sury.org, etc.) if you want the latest. Trixie&#8217;s PHP-FPM works out of the box with the NGINX configurations in this guide, just point fastcgi_pass at /run/php/php8.4-fpm.sock.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">Does Debian 13 Trixie support HTTP/3?</h3>
<div class="rank-math-answer ">

<p>Yes, the kernel is recent enough (6.11+) and OpenSSL 3.3 in the base system supports QUIC. You still need an NGINX build with the http_v3_module compiled in. The myguard NGINX packages on Trixie include HTTP/3 by default; Debian&#8217;s stock nginx package does not.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I run multiple PHP versions on Debian 13 Trixie?</h3>
<div class="rank-math-answer ">

<p>Yes. PHP-FPM packages on Trixie coexist cleanly, install php8.4-fpm and php8.5-fpm side by side, give each its own pool config, point different NGINX server blocks at different sockets. Useful for legacy WordPress installs that have not been updated yet.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Should I use Trixie or Bookworm in 2026?</h3>
<div class="rank-math-answer ">

<p>Trixie, it is the current stable release with a five-year support window. Bookworm is still supported but the components (kernel, OpenSSL, PHP) are getting old. For a fresh server install in 2026, start on Trixie. If you are already running Bookworm and it works, the upgrade is not urgent, see our Debian 13 Trixie upgrade guide for the migration.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Where does Debian 13 Trixie NGINX log to?</h3>
<div class="rank-math-answer ">

<p>By default access logs go to /var/log/nginx/access.log and error logs to /var/log/nginx/error.log. With systemd journal enabled, the NGINX service also writes to journalctl -u nginx. Both can be tailed live; check journalctl -u nginx -f for service-level events and /var/log/nginx/error.log for HTTP-level errors.</p>

</div>
</div>
<div id="rm-faq-7" class="rank-math-list-item">
<h3 class="rank-math-question ">Are the myguard NGINX packages compatible with other Debian repositories?</h3>
<div class="rank-math-answer ">

<p>Yes, the packages use the same conf.d structure and module loading as the Debian stock NGINX. They co-exist with PHP packages from sury.org, with the Debian backports archive, and with most other Debian repositories. The only thing they replace is the nginx package itself.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>

<ul>
  <li><a href="/2026/05/nginx-debian-13-trixie-upgrade-guide/">NGINX on Debian 13 Trixie: What Changed and How to Upgrade</a>: for existing Debian 12 Bookworm installs moving to Trixie.</li>
  <li><a href="/2026/05/nginx-http3-quic-debian-ubuntu/">How to Enable HTTP/3 on NGINX for Debian and Ubuntu</a>: the full QUIC story including firewall, 0-RTT, and verification.</li>
  <li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX</a>: WAF setup for Trixie.</li>
  <li><a href="/2026/05/wordpress-nginx-php-fpm-configuration-guide/">WordPress NGINX + PHP-FPM Configuration Guide</a>: the canonical WordPress stack on this OS.</li>
  <li><a href="/nginx-modules/">NGINX modules: optimised and extended</a>, the full list of dynamic modules in the packaged build.</li>
  <li><a href="/how-to-use/">How to Add the myguard APT Repository</a>: repository setup in two minutes.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>PHP Snuffleupagus Tutorial — Harden PHP-FPM on Debian and Ubuntu (2026)</title>
		<link>https://deb.myguard.nl/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 12 May 2026 19:57:11 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[injection]]></category>
		<category><![CDATA[php]]></category>
		<category><![CDATA[php-fpm]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[snuffleupagus]]></category>
		<category><![CDATA[ubuntu]]></category>
		<category><![CDATA[xss]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/</guid>

					<description><![CDATA[A friendly, jargon-free walkthrough: install Snuffleupagus from the myguard APT repo, pick the right rulebook for your stack (WordPress, Roundcube, generic PHP, internal agent), wire it into a PHP-FPM pool, and avoid the 5 traps that bite everyone the first time.]]></description>
										<content:encoded><![CDATA[
<p>Right. First day. Someone gave you the laptop, someone showed you the coffee machine, and now they&#8217;ve handed you &#8220;the website&#8221; and walked away. Congratulations, you are now responsible for keeping a PHP application alive on the open internet. No pressure. This PHP Snuffleupagus tutorial is the shortcut: by the end you&#8217;ll have interpreter-level hardening that stops attacks the firewall never sees. The <a href="https://snuffleupagus.readthedocs.io/" rel="noopener" target="_blank">official Snuffleupagus docs</a> are the full reference.</p>

<p>Here&#8217;s the secret nobody tells you on day one: <strong>your WAF is not enough</strong>. Your fancy NGINX rules are not enough. Keeping WordPress updated is not enough. Bots are scanning your IP right now, looking for the one outdated plugin you forgot. And when they find it, they upload a tiny PHP file to <code>wp-content/uploads/</code> and your evening goes sideways.</p>

<p>This is the post I wish someone had handed me when I was twenty and starting out. We&#8217;re going to install a thing called <strong>PHP-Snuffleupagus</strong>, yes, named after Big Bird&#8217;s furry friend, and turn your PHP runtime into a place where attackers&#8217; tools simply don&#8217;t work, even when they get in.</p>

<p>I&#8217;ll explain everything. No assumed knowledge. If you&#8217;ve never touched <code>/etc/php/</code> before, you&#8217;re in the right place.</p>



<figure style="margin:1.5rem 0;text-align:center;">
  <img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/php-snuffleupagus-tutorial-hardening-debian-ubuntu.webp" alt="PHP Snuffleupagus tutorial cover, hardening PHP-FPM on Debian and Ubuntu" style="max-width:100%;height:auto;border-radius:8px;" />
  <figcaption style="font-size:13px;color:var(--muted);margin-top:0.5rem;">A friendly bouncer for your PHP interpreter. That is genuinely what this is.</figcaption>
</figure>



<h2 style="color:#f59e0b">The story of every PHP compromise, ever</h2>

<p>Picture this. It&#8217;s 2 a.m. Your phone buzzes. The client emails: &#8220;Google is showing a warning that my site contains malware.&#8221; You log in. The site is serving fake pharmaceuticals in five languages. You start digging.</p>

<p>What happened? Almost always the same thing:</p>

<ol>
<li>A plugin has a bug. The bug lets an attacker upload a file.</li>
<li>The attacker uploads a tiny PHP file pretending to be an image. Maybe <code>shell.php.jpg</code>. Maybe just <code>image.php</code> with a misconfigured upload check.</li>
<li>The attacker visits that file in a browser. PHP runs it. The file calls <code>eval($_POST['x'])</code> and now the attacker can run any PHP code, as your web server user, on your server.</li>
<li>They drop more files. They edit your WordPress database. They install backdoors in your themes. You spend the weekend nuking and restoring from backup.</li>
</ol>

<p>Your WAF saw the upload and thought &#8220;that&#8217;s a JPEG, fine.&#8221; Your NGINX config doesn&#8217;t care about <code>eval()</code>. WordPress itself has no idea any of this happened.</p>

<p>Here&#8217;s the thing: <strong>nothing along the way actually checked what the PHP code was doing</strong>. The HTTP request was fine. The file extension was fine. PHP just&hellip; ran whatever was in the file. That&#8217;s the gap. That&#8217;s where Snuffleupagus lives.</p>



<h2 style="color:#f59e0b">So what is Snuffleupagus? (And what this PHP Snuffleupagus tutorial covers)</h2>

<p>Snuffleupagus is a small piece of code that loads <em>inside</em> the PHP interpreter every time PHP starts. It&#8217;s not a separate server. It&#8217;s not a firewall. It&#8217;s a Zend extension, in plain English, a plugin for PHP itself.</p>

<p>Once it&#8217;s loaded, every function call your PHP code makes goes through Snuffleupagus first. Want to call <code>eval()</code>? Snuffleupagus checks its rulebook: &#8220;is eval allowed in this script?&#8221; If not, eval returns false and your PHP code can&#8217;t do the bad thing. The attacker&#8217;s exploit chain breaks at the interpreter level, <em>before</em> the dangerous function actually executes.</p>

<p>An analogy I always come back to:</p>

<ul>
<li><strong>NGINX / your WAF</strong>: the bouncer at the front door of the restaurant. Checks IDs, kicks out the obviously drunk.</li>
<li><strong>Snuffleupagus</strong>: the bouncer who lives <em>inside the kitchen</em>. Even if someone sneaks in through a window, they still can&#8217;t touch the knives.</li>
</ul>

<p>You want both. They watch different doors.</p>



<h2 style="color:#f59e0b">Real WordPress RCEs Snuffleupagus would have stopped dead</h2>

<p>&#8220;Theoretical&#8221; rarely sells anything. Here are real, in-the-wild WordPress plugin vulnerabilities from the last few years, and exactly which Snuffleupagus rule kills the exploit chain. Every one of these was a &#8220;tens of thousands of sites compromised in a weekend&#8221; story.</p>

<h3>File Manager 6.9, CVE-2020-25213</h3>
<p>An unauthenticated attacker could hit a vulnerable <code>elFinderConnector.php</code> endpoint and upload anything they wanted, including a one-line PHP webshell. The shell then called <code>system($_GET['c'])</code> to give the attacker arbitrary command execution as the web server user. Snuffleupagus&#8217;s <code>wordpress-strict.rules</code> blocks <code>system()</code> across every script in <code>wp-content/</code>, the upload still happens, but the shell can&#8217;t run a single command. The attacker uploads a paperweight.</p>

<h3>Backup Migration, CVE-2023-6553</h3>
<p>A <code>require</code> call inside the plugin trusted a <code>$_GET</code> parameter without sanitisation, letting an unauthenticated attacker include any file path, including remote URLs when <code>allow_url_include</code> was on. Snuffleupagus&#8217;s INI lock (<code>sp.ini.key("allow_url_include").set("0").ro();</code>) keeps the dangerous PHP setting off no matter what plugin code tries to override it. Even if the attacker controls the parameter, the include can&#8217;t reach a remote payload.</p>

<h3>The eternal &#8220;eval-of-base64&#8221; backdoor</h3>
<p>Open any compromised WordPress install at 2 a.m. and you&#8217;ll find files like <code>wp-content/uploads/2023/04/.htaccess.php</code> containing <code>&lt;?php @eval(base64_decode($_POST['x']));</code>. It&#8217;s the same backdoor people were dropping in 2009. Every Snuffleupagus rulebook in the myguard pack drops <code>eval()</code> outright in any file under <code>wp-content/uploads/</code>, the file sits there, perfectly harmless, until you find it and delete it.</p>

<p>The pattern is always the same: a single dangerous function call is the difference between &#8220;the attacker poked at your site&#8221; and &#8220;the attacker owns your site.&#8221; Snuffleupagus removes that function call from the attacker&#8217;s toolbox at the interpreter level. No rule update, no signature, no vendor, just a flat &#8220;no&#8221; from PHP itself.</p>



<h2 style="color:#f59e0b">A picture of where Snuffleupagus sits</h2>

<p>Here&#8217;s the path a single request takes through your server. Snuffleupagus is the last line of defence, the one closest to the actual damage:</p>

<pre><code>
  Browser ─────► NGINX / Angie ─────► PHP-FPM pool ─────► PHP interpreter
                  │                    │                    │
                  │                    │                    └─► Snuffleupagus ──► your code
                  │                    │                                              │
                  ↓                    ↓                                              ↓
              checks URL,         picks worker,                                  every function
              method, headers     sets ini values                                call is filtered
              (ModSecurity)       (memory_limit etc.)                            against the rulebook
</code></pre>

<p>The interesting bit: by the time a request reaches the PHP interpreter, the WAF has already approved it and the FPM pool has already spawned a worker. If anything malicious got through, Snuffleupagus is your last chance to stop it before <code>system("rm -rf /")</code> actually runs.</p>



<h2 style="color:#f59e0b">Step one: install the thing (one minute, promise)</h2>

<p>This is the easy part. The <a href="/how-to-use/">myguard APT repository</a> ships <code>php-snuffleupagus</code> as a regular Debian/Ubuntu package, pre-compiled for PHP 7.0 through 8.5. No <code>gcc</code>, no <code>phpize</code>, no submodules. Just <code>apt</code>.</p>

<p>If you&#8217;ve never added the myguard repository before, do this once:</p>

<pre><code>wget https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb
sudo dpkg -i myguard.deb
sudo apt update</code></pre>

<p>Now install the extension for whichever PHP version you use. Don&#8217;t know which one? Run <code>php -v</code> and read the first line. Most modern servers are on 8.3 or 8.4:</p>

<pre><code>sudo apt install php8.4-snuffleupagus</code></pre>

<p>That&#8217;s it. The package drops the <code>.so</code> file in the right place, drops an <code>extension=snuffleupagus</code> line into <code>mods-available</code>, symlinks it into both <code>fpm/conf.d/</code> and <code>cli/conf.d/</code>, and installs five ready-to-use rulebooks under <code>/etc/php/8.4/php-snuffleupagus/</code>.</p>

<p>Reload PHP-FPM so the extension actually loads:</p>

<pre><code>sudo systemctl reload php8.4-fpm</code></pre>

<p>Verify it&#8217;s there:</p>

<pre><code>php -m | grep -i snuf</code></pre>

<p>You should see <code>snuffleupagus</code> in the output. If you don&#8217;t, check <code>journalctl -u php8.4-fpm</code> for the reason. Usually it&#8217;s a typo in a rule file, and the error message tells you which line.</p>



<h2 style="color:#f59e0b">Step two: pick your rulebook</h2>

<p>This is where most tutorials go off the rails. They hand you 400 lines of rules copied from upstream, half of which break WordPress and the other half of which lock down things you don&#8217;t actually have. Skip that.</p>

<p>The myguard package ships <strong>five</strong> small, focused rulebooks. Pick one. You can change later. Here&#8217;s the decision tree:</p>

<pre><code>
Is this server running WordPress?
│
├── No: is it Roundcube webmail?
│     │
│     ├── Yes ──────► roundcube.rules
│     │
│     └── No: is it your generic PHP app / Drupal / a Symfony thing?
│           │
│           └─────► php-relax.rules
│
└── Yes: does it have plugins that shell out?
        (backup plugins, image converters, anything that
         calls mysqldump or cwebp under the hood)
        │
        ├── Yes ────► wordpress-lax.rules
        │
        └── No ─────► wordpress-strict.rules

Plus: if this server also runs wp-cli or an MCP agent on a
separate FPM pool, that pool needs mcp-agent.rules (no
function blocking, just RCE guard).
</code></pre>

<p>Each rulebook does one job:</p>

<ul>
<li><strong>php-relax.rules</strong>: the bare minimum that catches the worst stuff (remote includes, eval-of-base64, mail header injection, environment hijacking) without blocking anything a normal app does. Safe for any PHP application.</li>
<li><strong>wordpress-strict.rules</strong>: everything in relax, plus an outright ban on <code>exec</code>, <code>system</code>, <code>shell_exec</code>, <code>proc_open</code>, <code>phpinfo</code>, and friends. Use this if your WordPress doesn&#8217;t have plugins that need subprocesses.</li>
<li><strong>wordpress-lax.rules</strong>: strict&#8217;s friendlier cousin. Lets subprocess calls through if they don&#8217;t contain shell metacharacters. Use this if you have UpdraftPlus, BackWPup, ShortPixel local mode, or other plugins that call out to <code>mysqldump</code> / <code>cwebp</code>.</li>
<li><strong>mcp-agent.rules</strong>: for a privileged internal pool. No call-blocking, no <code>ini_protection</code>, just the RCE primitives (remote include, eval+decode chains, environment hijacking). Perfect for wp-cli or an automation agent.</li>
<li><strong>roundcube.rules</strong>: Roundcube has its own session handler, never shells out, and lives behind authentication. This rulebook reflects that: strict on subprocess and recon, lenient where Roundcube needs flexibility.</li>
</ul>



<h2 style="color:#f59e0b">Step three: wire the rulebook into your FPM pool</h2>

<p>This is the part that trips everyone up the first time, so we&#8217;ll go slow.</p>

<p>PHP-FPM is the bit that actually runs your PHP. It&#8217;s organised into <em>pools</em>: each pool is a group of worker processes with the same configuration. Most servers have just one pool, called <code>www</code>, in <code>/etc/php/8.4/fpm/pool.d/www.conf</code>. Bigger servers split into multiple pools so different sites or different parts of the same site can have different settings.</p>

<p>Open your pool file:</p>

<pre><code>sudo nano /etc/php/8.4/fpm/pool.d/www.conf</code></pre>

<p>Scroll to the bottom and add this one line:</p>

<pre><code>php_admin_value[sp.configuration_file] = /etc/php/8.4/php-snuffleupagus/wordpress-strict.rules</code></pre>

<p>That&#8217;s it. That one line tells the FPM pool which rulebook to load. Save (<code>Ctrl-O</code>, <code>Enter</code>, <code>Ctrl-X</code> in nano) and reload:</p>

<pre><code>sudo systemctl reload php8.4-fpm</code></pre>

<p>If PHP-FPM refuses to start, run:</p>

<pre><code>sudo journalctl -u php8.4-fpm -n 50</code></pre>

<p>and read the last few lines. Snuffleupagus error messages are blunt but accurate, usually a missing file path or a typo in a rule.</p>

<p><strong>Important thing nobody warns you about:</strong> do <em>not</em> put <code>sp.configuration_file</code> in <code>/etc/php/8.4/mods-available/snuffleupagus84.ini</code>. The package deliberately doesn&#8217;t set it there. If you set it globally, it merges with per-pool overrides in weird ways and you get errors that point at the wrong line in the wrong file. Always set the rulebook at the <em>pool</em> level.</p>



<h2 style="color:#f59e0b">Two pools, two rulebooks: a worked example</h2>

<figure style="margin:1.5rem 0;text-align:center;">
  <img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/snuffleupagus-php-fpm-pool-architecture.webp" alt="Snuffleupagus PHP-FPM pool architecture, public www pool with strict rules, internal agent pool with relaxed rules" style="max-width:100%;height:auto;border-radius:8px;" />
  <figcaption style="font-size:13px;color:var(--muted);margin-top:0.5rem;">Different doors get different bouncers. The internet gets the strict one.</figcaption>
</figure>

<p>Here&#8217;s a real-world setup. You&#8217;re running a WordPress site, and you also run <code>wp-cli</code> commands via cron and a small management agent. The wp-cli stuff needs to call <code>exec()</code> and <code>proc_open()</code> all day long. The public site never should.</p>

<p>Make two pools. Call them <code>www</code> (public traffic) and <code>agent</code> (the trusted internal one). The public pool gets strict, the internal pool gets the relaxed agent ruleset:</p>

<pre><code># /etc/php/8.4/fpm/pool.d/www.conf
[www]
listen = /run/php/php8.4-fpm.sock
user   = www-data
group  = www-data
pm     = dynamic
pm.max_children = 25

php_admin_value[memory_limit]           = 512M
php_admin_value[sp.configuration_file]  = /etc/php/8.4/php-snuffleupagus/wordpress-strict.rules
# Leave disable_functions EMPTY when using Snuffleupagus — the rulebook
# already does that job, and mixing both produces weird boot errors.
php_admin_value[disable_functions]      =</code></pre>

<pre><code># /etc/php/8.4/fpm/pool.d/agent.conf
[agent]
listen = /run/php/php8.4-agent.sock
user   = agent
group  = www-data
pm     = dynamic
pm.max_children = 4

php_admin_value[memory_limit]           = 1024M
php_admin_value[sp.configuration_file]  = /etc/php/8.4/php-snuffleupagus/mcp-agent.rules
php_admin_value[disable_functions]      =</code></pre>

<p>In your NGINX or Angie config, route the public site at <code>fpm.sock</code> and your internal endpoint (say, <code>/wp-json/agent/</code>) at <code>agent.sock</code>. The same Snuffleupagus extension is loaded in both pools, but each enforces a completely different rulebook. The attacker hits the public pool and runs into a wall. Your cron hits the agent pool and gets the work done.</p>



<h2 style="color:#f59e0b">Things that will trip you up (so you can skip the pain)</h2>

<p>I&#8217;ve made every one of these mistakes. Pay attention, save yourself an evening.</p>

<h3>1. The misleading error trace</h3>

<p>Snuffleupagus sometimes reports a violation with a stack trace that points at code that <em>literally doesn&#8217;t contain the function it claims</em>. You&#8217;ll see things like <code>strcoll() expects 2 arguments, 1 given</code> on a line that obviously calls <code>strtolower()</code>. Or <code>password_hash() expects at least 2 arguments</code> on a line that just calls <code>define()</code>.</p>

<p>This is not a PHP bug. This is Snuffleupagus telling you a rule fired, but the location and function name in the trace are misleading. When you see a fatal that &#8220;can&#8217;t possibly be true&#8221; given the source, suspect Snuffleupagus first.</p>

<p>To confirm: temporarily comment out the <code>sp.configuration_file</code> line in your pool, reload FPM, and try again. If the fatal disappears, it&#8217;s a rule firing.</p>

<h3>2. The <code>memory_limit</code> trap</h3>

<p>You can write <code>sp.ini.key("memory_limit").max("2G").rw();</code> in your rulebook. It will silently fail to parse the <code>G</code> suffix and the cap falls back to a tiny default. The whole site 500s. Use <code>max("1024M")</code> instead. Always megabytes.</p>

<h3>3. Don&#8217;t try to chain rulebooks</h3>

<p>Upstream&#8217;s <code>strict.rules</code> uses <code>.include "other-file.rules"</code> to pull in <code>default.rules</code>, <code>suhosin.rules</code>, and friends. On recent Snuffleupagus, this silently breaks: each included file re-sets the <code>secret_key</code>, calls <code>sp.ini_protection.enable()</code> a second time, and defines conflicting <code>memory_limit</code> ranges. Symptoms: random 500s with the body <code>memory_limit</code>.</p>

<p>The myguard rulebooks are each <strong>self-contained</strong> on purpose. Pick one. Don&#8217;t <code>.include</code> anything.</p>

<h3>4. Old syntax for cookie encryption</h3>

<p>If a tutorial tells you to write:</p>

<pre><code>sp.cookie_encryption.name("PHPSESSID");
sp.cookie_encryption.encrypt();</code></pre>

<p>that&#8217;s the old, two-statement form. It&#8217;s rejected at parse time now. The current syntax is one chained statement:</p>

<pre><code>sp.cookie_encryption.name("PHPSESSID").encrypt();</code></pre>

<p>The myguard rulebooks already use the new form.</p>

<h3>5. The <code>disable_functions</code> double-up</h3>

<p>Old habit: put a list of dangerous functions in <code>php_admin_value[disable_functions]</code> in your pool file. With Snuffleupagus, <strong>don&#8217;t</strong>. The pool-level <code>disable_functions</code> directive plus a Snuffleupagus rulebook that locks <code>disable_functions</code> read-only causes a fatal during PHP boot that looks like a totally unrelated arity error (see point 1). Leave the pool&#8217;s <code>disable_functions</code> empty and let the rulebook do the work.</p>



<h2 style="color:#f59e0b">When a rule fires: how to debug</h2>

<p>Once Snuffleupagus is on, you&#8217;ll occasionally see a feature on your site stop working. A plugin breaks. A cron job fails. That&#8217;s expected: a real attacker breaks the same way. Your job is to tell legitimate breakage from attempted exploitation.</p>

<p>Snuffleupagus logs violations to PHP-FPM&#8217;s error log:</p>

<pre><code>sudo tail -f /var/log/php8.4-fpm.log</code></pre>

<p>Look for lines starting with <code>[snuffleupagus]</code>. They tell you the rule that fired, the script that triggered it, and the function call that was blocked.</p>

<p>A real example:</p>

<pre><code>[snuffleupagus][/wp-content/plugins/some-plugin/loader.php][system][drop]
  - Aborted execution on call of the function 'system' in
    /var/www/html/wp-content/plugins/some-plugin/loader.php on line 47</code></pre>

<p>Now you have a choice. Did <em>your</em> plugin legitimately need <code>system()</code>? Probably not, and it&#8217;s safer to remove that plugin. Does the plugin need it for a specific narrow case? Add a per-script allow rule above the global drop:</p>

<pre><code>sp.disable_function.function("system")
    .filename("/var/www/html/wp-content/plugins/some-plugin/loader.php")
    .allow();</code></pre>

<p>Always tighten, never loosen the global rules. Add carve-outs for the one script that needs an exception, keep the rest of your site locked down.</p>



<h2 style="color:#f59e0b">Performance: is this going to slow my site down?</h2>

<p>Honestly? No. Snuffleupagus runs inside the PHP process, so there&#8217;s no extra HTTP hop. Every function call goes through a hash-map lookup against the loaded rules, we&#8217;re talking microseconds per request, well under a percent on any real workload.</p>

<p>The myguard build is compiled with <code>-O3 -flto -fvisibility=hidden -fno-plt</code> and the standard Debian hardening flags (RELRO, BIND_NOW, stack-protector, no-execstack). It&#8217;s smaller, faster, and harder to attack than the upstream-stock build. If you&#8217;re benchmarking against a no-Snuffleupagus baseline, you might see a 0.5&ndash;1% throughput dip on a busy site. You will not see it on a normal site.</p>

<p>For context: a single <code>wp_query()</code> with a couple of joins takes longer than Snuffleupagus&#8217;s per-request overhead for an entire WordPress page load. This is not where your performance budget goes.</p>



<h2 style="color:#f59e0b">Snuffleupagus vs ModSecurity vs Suhosin vs <code>disable_functions</code></h2>

<p>People often ask &#8220;isn&#8217;t this just ModSecurity?&#8221; or &#8220;didn&#8217;t <code>disable_functions</code> already handle this?&#8221; Short answer: no, they all watch different things. Here&#8217;s the honest breakdown.</p>

<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;margin:1rem 0;">
  <thead style="background:#f59e0b;color:#fff;">
    <tr>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">Layer</th>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">Where it runs</th>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">What it sees</th>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">What it can&#8217;t see</th>
    </tr>
  </thead>
  <tbody>
    <tr style="border-bottom:1px solid #ddd;">
      <td style="padding:.6rem;"><strong>ModSecurity / WAF</strong></td>
      <td style="padding:.6rem;">NGINX / Apache request handler</td>
      <td style="padding:.6rem;">HTTP request bytes, headers, body</td>
      <td style="padding:.6rem;">What PHP does once the request is dispatched</td>
    </tr>
    <tr style="border-bottom:1px solid #ddd;">
      <td style="padding:.6rem;"><strong><code>disable_functions</code></strong></td>
      <td style="padding:.6rem;">PHP <code>php.ini</code> / pool config</td>
      <td style="padding:.6rem;">Function name (e.g. <code>system</code>)</td>
      <td style="padding:.6rem;">Caller, arguments, file path; can&#8217;t whitelist per-script</td>
    </tr>
    <tr style="border-bottom:1px solid #ddd;">
      <td style="padding:.6rem;"><strong>Suhosin</strong></td>
      <td style="padding:.6rem;">PHP extension (legacy)</td>
      <td style="padding:.6rem;">Session/cookie/include hardening; PHP 7.x mostly</td>
      <td style="padding:.6rem;">PHP 8.x abandoned upstream; very limited rule expressiveness</td>
    </tr>
    <tr>
      <td style="padding:.6rem;"><strong>Snuffleupagus</strong></td>
      <td style="padding:.6rem;">PHP extension (Zend)</td>
      <td style="padding:.6rem;">Function name <em>and</em> arguments <em>and</em> caller file/line; per-script and per-pool</td>
      <td style="padding:.6rem;">HTTP-layer attacks that never reach PHP (use ModSec for those)</td>
    </tr>
  </tbody>
</table>
</div>

<p><strong>Verdict:</strong> they pair, they don&#8217;t compete. ModSecurity stops drive-by junk before it costs you a PHP request. Snuffleupagus stops the exploit chain that successfully bypassed the WAF, the file extension check, and your plugin&#8217;s &#8220;sanitisation.&#8221; <code>disable_functions</code> is the blunt hammer that breaks legitimate code along with the attacks. Suhosin is a museum piece on modern PHP.</p>

<p>For the matching WAF-layer guide on this stack, read <a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to install ModSecurity and OWASP CRS on NGINX</a> next. And no, before you ask: <strong>ModSecurity v3 / libmodsecurity3 is not end-of-life.</strong> Trustwave ended <em>commercial</em> support in July 2024 and handed the project to OWASP in January 2024. It&#8217;s actively maintained at <code>owasp-modsecurity/ModSecurity</code> on GitHub today.</p>



<h2 style="color:#f59e0b">Snuffleupagus inside a Docker container</h2>

<p>Containerising PHP-FPM doesn&#8217;t change anything fundamental, Snuffleupagus still loads as a Zend extension, still reads a rulebook file, still enforces it on every function call. You just need to make sure the rulebook is actually <em>inside</em> the container at runtime.</p>

<p>Two ways to do it. The easy way: bake the rulebook into your image at build time.</p>

<pre><code># Dockerfile
FROM debian:trixie-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates wget && \
    wget -qO /tmp/myguard.deb \
        https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb && \
    dpkg -i /tmp/myguard.deb && \
    apt-get update && \
    apt-get install -y --no-install-recommends \
        php8.4-fpm php8.4-snuffleupagus && \
    rm -rf /var/lib/apt/lists/* /tmp/myguard.deb

COPY ./conf/wordpress-strict.rules /etc/php/8.4/php-snuffleupagus/active.rules
COPY ./conf/www.conf               /etc/php/8.4/fpm/pool.d/www.conf

EXPOSE 9000
CMD ["php-fpm8.4", "--nodaemonize"]</code></pre>

<p>The flexible way: mount the rulebook at runtime, so you can hot-swap rule packs without rebuilding:</p>

<pre><code># docker-compose.yml
services:
  php:
    image: deb.myguard.nl/php-fpm:8.4-snuf
    read_only: true
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    user: "33:33"
    tmpfs:
      - /tmp
      - /var/run
    volumes:
      - ./conf/wordpress-strict.rules:/etc/php/8.4/php-snuffleupagus/active.rules:ro
      - ./conf/www.conf:/etc/php/8.4/fpm/pool.d/www.conf:ro
      - wp-content:/var/www/html/wp-content</code></pre>

<p>The <code>read_only: true</code> + <code>cap_drop: ALL</code> + <code>no-new-privileges</code> combo is the Docker hardening pattern we cover end-to-end in <a href="/2026/05/docker-hardening-rootless-readonly-distroless/">Docker Hardening for Self-Hosters</a>. Pair it with Snuffleupagus and you&#8217;ve got two completely independent layers of &#8220;no&#8221; between the attacker and your filesystem.</p>



<h2 style="color:#f59e0b">Quick reference: the <code>sp.*</code> directives you&#8217;ll actually use</h2>

<p>The rulebook syntax looks alien at first. It&#8217;s not, it&#8217;s just chained method calls. Here are the seven directives that cover ~95% of real-world configs.</p>

<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;margin:1rem 0;">
  <thead style="background:#f59e0b;color:#fff;">
    <tr>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">Directive</th>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">What it does</th>
      <th style="padding:.6rem;text-align:left;color:#0b1220;font-weight:700;">Example</th>
    </tr>
  </thead>
  <tbody>
    <tr style="border-bottom:1px solid #ddd;"><td style="padding:.6rem;"><code>sp.disable_function</code></td><td style="padding:.6rem;">Block / allow / log a function call, optionally filtered by file, args, hash</td><td style="padding:.6rem;"><code>.function("system").drop();</code></td></tr>
    <tr style="border-bottom:1px solid #ddd;"><td style="padding:.6rem;"><code>sp.eval</code></td><td style="padding:.6rem;">Globally drop <code>eval()</code> (eval is always dangerous; no filename needed)</td><td style="padding:.6rem;"><code>sp.eval.drop();</code></td></tr>
    <tr style="border-bottom:1px solid #ddd;"><td style="padding:.6rem;"><code>sp.cookie_encryption</code></td><td style="padding:.6rem;">Encrypt a named cookie at runtime (PHPSESSID, custom session names)</td><td style="padding:.6rem;"><code>.name("PHPSESSID").encrypt();</code></td></tr>
    <tr style="border-bottom:1px solid #ddd;"><td style="padding:.6rem;"><code>sp.ini.key</code></td><td style="padding:.6rem;">Constrain an INI value to a range and lock it read-only</td><td style="padding:.6rem;"><code>.key("memory_limit").max("1024M").rw();</code></td></tr>
    <tr style="border-bottom:1px solid #ddd;"><td style="padding:.6rem;"><code>sp.readonly_exec</code></td><td style="padding:.6rem;">Refuse to execute any PHP file that is also writable by the running user, kills &#8220;upload-then-execute&#8221; attacks</td><td style="padding:.6rem;"><code>sp.readonly_exec.enable();</code></td></tr>
    <tr style="border-bottom:1px solid #ddd;"><td style="padding:.6rem;"><code>sp.global_strict</code></td><td style="padding:.6rem;">Force strict comparisons in PHP, closes a whole class of <code>==</code> bypass tricks</td><td style="padding:.6rem;"><code>sp.global_strict.enable();</code></td></tr>
    <tr><td style="padding:.6rem;"><code>sp.upload_validation</code></td><td style="padding:.6rem;">Run an external script against every uploaded file before the move is allowed</td><td style="padding:.6rem;"><code>.script("/usr/local/bin/clamscan").enable();</code></td></tr>
  </tbody>
</table>
</div>

<p>All seven are demonstrated, in real use, in the rulebooks the myguard package installs. <code>cat /etc/php/8.4/php-snuffleupagus/wordpress-strict.rules</code> is your best friend the first month.</p>



<h2 style="color:#f59e0b">PHP version support: 7.0 through 8.5</h2>

<p>The myguard repository ships <code>php-snuffleupagus</code> as a per-PHP-version package. You install the one that matches your interpreter:</p>

<pre><code>sudo apt install php7.0-snuffleupagus   # legacy long-tail apps
sudo apt install php7.4-snuffleupagus   # WordPress on Debian 11
sudo apt install php8.0-snuffleupagus
sudo apt install php8.1-snuffleupagus   # Debian 12 default
sudo apt install php8.2-snuffleupagus
sudo apt install php8.3-snuffleupagus
sudo apt install php8.4-snuffleupagus   # Debian 13 Trixie default
sudo apt install php8.5-snuffleupagus   # latest</code></pre>

<p>Why per-version? Snuffleupagus is a Zend extension, it has to be compiled against the exact PHP API version it runs in. Upstream provides source; the myguard packages compile against every officially supported PHP build, so you don&#8217;t need a compiler or a CI pipeline just to deploy this WAF-inside-your-interpreter.</p>

<p>Each version is built with identical flags (<code>-O3 -flto -fvisibility=hidden -fno-plt</code> plus Debian hardening: RELRO, BIND_NOW, stack-protector, no-execstack), so the security guarantees are the same regardless of which PHP you&#8217;re on. The only practical difference between PHP 7.x and 8.x for Snuffleupagus is that 8.x exposes a few extra hookable internal functions, the rule syntax is unchanged.</p>

<p><strong>One quirk to know:</strong> PHP 8.5 with tracing JIT enabled mis-runs WordPress under Snuffleupagus on some workloads. If you see weird <code>strtolower</code>/<code>strcoll</code> arity errors, set <code>opcache.jit=off</code> in that pool. We hit this on our own MCP agent pool and the fix is straightforward; it&#8217;s an interaction between PHP 8.5&#8217;s tracing JIT and WordPress&#8217;s internal code paths, not a Snuffleupagus bug.</p>



<h2 style="color:#f59e0b">Frequently asked questions</h2>

<div class="faq">
  <div class="faq-item">
    <div class="faq-q">Do I still need a WAF if I have Snuffleupagus?</div>
    <div class="faq-a">Yes. They protect different layers. A WAF stops bad HTTP requests before they reach PHP at all, that&#8217;s faster and cheaper than running PHP and finding out. Snuffleupagus is your last line if something gets through. Use both. The combination is much stronger than either alone.</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">Does Snuffleupagus replace ModSecurity?</div>
    <div class="faq-a">No, they cover different layers. ModSecurity inspects the HTTP request before PHP runs at all. Snuffleupagus inspects what PHP actually does, including after a request has been &#8220;approved&#8221; by the WAF. Pair them; they&#8217;re complementary, not redundant.</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">Is ModSecurity / libmodsecurity3 end-of-life?</div>
    <div class="faq-a">No. Trustwave ended <em>commercial</em> support in July 2024 and handed the project to OWASP in January 2024. The codebase is actively maintained on GitHub under <code>owasp-modsecurity/ModSecurity</code>, and our repo ships current builds. &#8220;ModSec is dead&#8221; headlines refer to the vendor exit, not the code.</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">Can I run Snuffleupagus inside a Docker container?</div>
    <div class="faq-a">Yes, see the Docker section above. The extension loads identically inside or outside a container; you just need the rulebook present in the image (or bind-mounted at runtime). Pair it with Docker&#8217;s own hardening (read-only filesystem, dropped capabilities, no-new-privileges) for a really stubborn container to break.</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">Which PHP versions are supported?</div>
    <div class="faq-a">PHP 7.0, 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, and 8.5, each shipped as a separate <code>phpX.Y-snuffleupagus</code> Debian/Ubuntu package. Install the one that matches the PHP your site actually runs (<code>php -v</code> tells you).</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">My PHP-FPM won&#8217;t start after I added the rulebook. What do I do?</div>
    <div class="faq-a">Run <code>sudo journalctl -u php8.4-fpm -n 100</code> and read the last messages. Snuffleupagus reports the file path and line number of the broken rule. The most common cause is a typo in <code>sp.configuration_file</code>, double-check the path exists with <code>ls -la</code>. If the error trace looks impossible (claims a function call that isn&#8217;t in the source), see &#8220;things that will trip you up&#8221; above, it&#8217;s almost always a Snuffleupagus rule, not a PHP bug.</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">Will Snuffleupagus break my WordPress plugins?</div>
    <div class="faq-a">A few will misbehave on <code>wordpress-strict.rules</code> if they shell out to external binaries. Switch that pool to <code>wordpress-lax.rules</code> and most things start working. If a specific plugin still complains, check the FPM log for the rule that fired and either tighten the plugin&#8217;s behaviour or add a narrow allow-rule for that one script.</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">What&#8217;s the difference between <code>.drop()</code> and <code>.kill()</code> in a rule?</div>
    <div class="faq-a"><code>.drop()</code> makes the blocked function return <code>false</code>, the PHP script continues, just without the dangerous call succeeding. <code>.kill()</code> terminates the PHP process immediately. Default to <code>.drop()</code>; use <code>.kill()</code> only when you&#8217;re certain an active exploitation is in progress and you&#8217;d rather drop the request than let the script proceed.</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">Does cookie encryption log everyone out?</div>
    <div class="faq-a">When you first enable <code>sp.cookie_encryption</code> on a cookie name, existing unencrypted cookies of that name become unreadable and users get logged out. Plan for that, deploy at low-traffic time, or whitelist the existing session cookie name temporarily during the transition.</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">Can I change the rulebook later?</div>
    <div class="faq-a">Yes. Edit the <code>sp.configuration_file</code> line in your pool&#8217;s <code>.conf</code> and reload FPM. Each pool can use a different rulebook, and you can switch between strict/lax mid-life without reinstalling anything.</div>
  </div>

  <div class="faq-item">
    <div class="faq-q">Where on earth does the name come from?</div>
    <div class="faq-a">Mr. Snuffleupagus, &#8220;Snuffy&#8221; to his friends, is Big Bird&#8217;s enormous, woolly best friend on Sesame Street. For years he was invisible to every adult on the show; only kids could see him. The security extension is invisible the same way: it sits quietly inside PHP, watching everything, and an attacker reaching your server has no idea it&#8217;s there. Good name.</div>
  </div>
</div>



<h2 style="color:#f59e0b">What to do tomorrow</h2>

<p>You did it. You&#8217;ve installed a real, production-grade hardening layer on your first day. The plant is watered. The website is harder to break. Tomorrow, do this:</p>

<ul>
<li>Visit your site as a normal user. Click around. Log in, log out. Check that <code>tail -f /var/log/php8.4-fpm.log</code> shows nothing unusual.</li>
<li>If you run cron jobs, run them manually once and watch the log.</li>
<li>If you administer plugins, install one and uninstall it: that&#8217;s where weird shell-outs happen.</li>
<li>Pick a small page you don&#8217;t mind breaking and try a few WordPress admin actions that touch files (theme editor, plugin install). If a Snuffleupagus rule fires on a legitimate action, you&#8217;ll see it in the log and you can decide if that plugin should be allowed the exception.</li>
</ul>

<p>Welcome to the job. You&#8217;re going to be alright.</p>



<h2 style="color:#f59e0b">Related posts</h2>
<ul>
<li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to install ModSecurity and OWASP CRS on NGINX</a>: the HTTP-layer bouncer to pair with Snuffleupagus.</li>
<li><a href="/2026/05/docker-hardening-rootless-readonly-distroless/">Docker Hardening for Self-Hosters: Rootless, Read-Only, Distroless</a>: the container-layer hardening that pairs with this PHP-layer hardening.</li>
<li><a href="/2026/05/breach-attack-explained-prevention/">What is the BREACH attack? How it works and how to stop it</a>: the compression side-channel you also want to defend against.</li>
<li><a href="/nginx-modules/">NGINX modules overview</a>: the other 50+ security and performance modules in this repo.</li>
<li><a href="/how-to-use/">How to add the myguard APT repository</a>: if you skipped step one.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Postfix + Dovecot Mail Server Setup on Debian 12 and 13 (2026 Guide)</title>
		<link>https://deb.myguard.nl/2026/05/postfix-dovecot-setup-debian/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 12 May 2026 19:57:10 +0000</pubDate>
				<category><![CDATA[Mail]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[dovecot]]></category>
		<category><![CDATA[mail]]></category>
		<category><![CDATA[postfix]]></category>
		<category><![CDATA[rspamd]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[tls]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/postfix-dovecot-setup-debian/</guid>

					<description><![CDATA[A complete Postfix + Dovecot + Rspamd mail server on Debian 12 and 13 — with TLS, DKIM, SPF, DMARC, spam filtering, virtual mailboxes, security hardening, and a 10/10 score on mail-tester.com. No shortcuts.]]></description>
										<content:encoded><![CDATA[
<p>A working Postfix Dovecot Debian mail server in 2026 is not the nightmare people say it is, if you use the right packages and follow a methodical setup. The horror stories you&#8217;ve read online are mostly about people who skipped DNS records, used outdated TLS configs, or ran ancient software with no spam filtering. Do it right and you&#8217;ll have a server that delivers reliably, stays off blacklists, and handles a few hundred mailboxes without breaking a sweat.</p>

<p><strong>Postfix + Dovecot + Rspamd</strong> is the production standard combination in 2026. Postfix handles SMTP (sending and receiving), Dovecot handles IMAP (what your mail client connects to), and Rspamd filters spam, signs outgoing mail with DKIM, and plugs into Postfix as a milter. All three are available from the <a href="/how-to-use/">myguard APT repository</a>, updated within hours of upstream releases, so you&#8217;re never stuck on a version Debian stable froze two years ago. The <a href="https://www.postfix.org/documentation.html" rel="noopener" target="_blank">Postfix documentation</a> is the upstream reference if you want to go deeper on any directive.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/postfix-dovecot-debian.webp" alt="Postfix Dovecot Debian mail server delivering mail securely" width="1024" height="576" loading="lazy"/></figure>

<p>This guide is thorough by design. You&#8217;ll finish with a fully working mail server on Debian 12 (Bookworm) or Debian 13 (Trixie), including TLS everywhere, SPF, DKIM, DMARC, spam filtering, and a 10/10 score on mail-tester.com. No shortcuts that come back to bite you at 2am.</p>



<h2 style="color:#f59e0b">What your Postfix Dovecot Debian stack is building</h2>
<p>A complete inbound + outbound mail server with:</p>
<ul>
<li><strong>Postfix</strong>: receives mail on port 25 from the internet, accepts submissions from your users on port 587 (STARTTLS) and 465 (SMTPS)</li>
<li><strong>Dovecot</strong>: serves mail to your IMAP clients on port 993 (IMAPS), delivers mail from Postfix to Maildir via LMTP</li>
<li><strong>Rspamd</strong>: scans inbound mail for spam, signs outbound mail with DKIM, integrates with Postfix as a milter</li>
<li><strong>Redis</strong>: Rspamd&#8217;s backend for Bayes learning and rate limiting</li>
<li><strong>Let&#8217;s Encrypt TLS</strong>: proper certificates so mail clients don&#8217;t complain</li>
<li><strong>SPF, DKIM, DMARC</strong>: the three DNS records that prevent your mail from being treated as spam</li>
</ul>



<h2 style="color:#f59e0b">Before you start, prerequisites</h2>
<ul>
<li><strong>A VPS or dedicated server with a static IP.</strong> Dynamic IPs are blacklisted by virtually every major mail provider. Hetzner, OVH, Contabo, and DigitalOcean all work well.</li>
<li><strong>Port 25 unblocked.</strong> Many providers block outbound port 25 by default. Contact support and ask them to unblock it. Hetzner does it within minutes. DigitalOcean requires account verification.</li>
<li><strong>A domain you control.</strong> You need to be able to edit DNS records: MX, TXT, and PTR.</li>
<li><strong>Reverse DNS (PTR record).</strong> Your server&#8217;s IP must resolve back to your mail hostname. Log in to your provider&#8217;s control panel and set the PTR record for your IP to <code>mail.example.com</code>. This is separate from your regular DNS: it&#8217;s set at the IP level.</li>
<li><strong>A valid hostname.</strong> Set your server&#8217;s hostname to match your PTR record: <code>hostnamectl set-hostname mail.example.com</code>.</li>
<li><strong>Firewall rules.</strong> Open ports 25 (SMTP), 465 (SMTPS), 587 (SMTP submission), 993 (IMAPS). Block 110 (POP3) and 143 (plain IMAP): there&#8217;s no reason to offer unencrypted access.</li>
</ul>



<h2 style="color:#f59e0b">Step 1, Add the repository and install packages</h2>
<pre><code>wget https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb
dpkg -i myguard.deb
apt-get update
apt-get install postfix postfix-pcre dovecot-core dovecot-imapd dovecot-lmtpd rspamd redis-server</code></pre>
<p>The installer will ask two questions during Postfix installation:</p>
<ul>
<li><strong>Configuration type:</strong> select <strong>Internet Site</strong></li>
<li><strong>System mail name:</strong> enter your domain: <code>example.com</code>, not <code>mail.example.com</code></li>
</ul>
<p>After installation, get a TLS certificate before touching any config. Everything breaks without one:</p>
<pre><code>apt-get install certbot
certbot certonly --standalone -d mail.example.com</code></pre>



<h2 style="color:#f59e0b">Step 2, Configure Postfix</h2>
<p>Postfix is configured through two files: <code>/etc/postfix/main.cf</code> (settings) and <code>/etc/postfix/master.cf</code> (service definitions). Replace your <code>main.cf</code> with this production configuration:</p>
<pre><code># Identity
myhostname = mail.example.com
mydomain = example.com
myorigin = $mydomain
mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain

# Network
inet_interfaces = all
inet_protocols = ipv4
mynetworks = 127.0.0.0/8

# Mailbox delivery via Dovecot LMTP
mailbox_transport = lmtp:unix:private/dovecot-lmtp
virtual_transport = lmtp:unix:private/dovecot-lmtp

# TLS inbound (from other mail servers)
smtpd_tls_cert_file = /etc/letsencrypt/live/mail.example.com/fullchain.pem
smtpd_tls_key_file = /etc/letsencrypt/live/mail.example.com/privkey.pem
smtpd_tls_security_level = may
smtpd_tls_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1
smtpd_tls_mandatory_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1
smtpd_tls_mandatory_ciphers = high
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtpd_tls_loglevel = 1

# TLS outbound (to other mail servers)
smtp_tls_cert_file = /etc/letsencrypt/live/mail.example.com/fullchain.pem
smtp_tls_key_file = /etc/letsencrypt/live/mail.example.com/privkey.pem
smtp_tls_security_level = may
smtp_tls_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_loglevel = 1

# SASL authentication (Dovecot handles this)
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
smtpd_sasl_security_options = noanonymous

# Anti-spam restrictions
smtpd_recipient_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated,
  reject_unauth_destination,
  reject_invalid_hostname,
  reject_non_fqdn_hostname,
  reject_non_fqdn_sender,
  reject_non_fqdn_recipient,
  reject_unknown_sender_domain,
  reject_unknown_recipient_domain,
  reject_rbl_client zen.spamhaus.org

# Rspamd milter
smtpd_milters = inet:127.0.0.1:11332
non_smtpd_milters = inet:127.0.0.1:11332
milter_protocol = 6
milter_default_action = accept

# Limits
message_size_limit = 52428800
mailbox_size_limit = 0</code></pre>

<p>Now enable the submission ports in <code>/etc/postfix/master.cf</code>. Find (or uncomment) these lines:</p>
<pre><code>submission inet n       -       y       -       -       smtpd
  -o syslog_name=postfix/submission
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_tls_auth_only=yes
  -o smtpd_reject_unlisted_recipient=no
  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING

smtps     inet  n       -       y       -       -       smtpd
  -o syslog_name=postfix/smtps
  -o smtpd_tls_wrappermode=yes
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING</code></pre>



<h2 style="color:#f59e0b">Step 3, Configure Dovecot</h2>
<p>Dovecot&#8217;s configuration lives in <code>/etc/dovecot/dovecot.conf</code> and the <code>/etc/dovecot/conf.d/</code> directory. The conf.d approach is clean, each file handles one concern. Edit these key files:</p>

<h3>/etc/dovecot/dovecot.conf</h3>
<pre><code>protocols = imap lmtp</code></pre>

<h3>/etc/dovecot/conf.d/10-mail.conf</h3>
<pre><code>mail_location = maildir:~/Maildir
namespace inbox {
  inbox = yes
}</code></pre>

<h3>/etc/dovecot/conf.d/10-ssl.conf</h3>
<pre><code>ssl = required
ssl_cert = &lt;/etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_key = &lt;/etc/letsencrypt/live/mail.example.com/privkey.pem
ssl_min_protocol = TLSv1.2
ssl_cipher_list = ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
ssl_prefer_server_ciphers = yes</code></pre>

<h3>/etc/dovecot/conf.d/10-auth.conf</h3>
<pre><code>disable_plaintext_auth = yes
auth_mechanisms = plain login

!include auth-system.conf.ext</code></pre>

<h3>/etc/dovecot/conf.d/10-master.conf</h3>
<p>This is the most important file, it wires Dovecot to Postfix via LMTP and auth sockets:</p>
<pre><code>service imap-login {
  inet_listener imap {
    port = 0   # disable plain IMAP
  }
  inet_listener imaps {
    port = 993
    ssl = yes
  }
}

service lmtp {
  unix_listener /var/spool/postfix/private/dovecot-lmtp {
    mode = 0600
    user = postfix
    group = postfix
  }
}

service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0666
    user = postfix
    group = postfix
  }
  unix_listener auth-userdb {
    mode = 0600
    user = dovecot
  }
}</code></pre>

<h3>Create mail users</h3>
<p>For a simple setup, use system users. Create a mail user for each mailbox:</p>
<pre><code>useradd -m -s /sbin/nologin alice
useradd -m -s /sbin/nologin bob
passwd alice   # set a password for IMAP login</code></pre>



<h2 style="color:#f59e0b">Step 4, Configure Rspamd</h2>
<p>Rspamd is a modern spam filter that replaces SpamAssassin, it&#8217;s significantly faster and has better default rules. It integrates with Postfix as a milter (mail filter), meaning Postfix runs every message through Rspamd before accepting it.</p>

<h3>Enable Redis backend</h3>
<pre><code>cat &gt; /etc/rspamd/local.d/redis.conf &lt;&lt;'EOF'
servers = "127.0.0.1";
EOF</code></pre>

<h3>Generate DKIM keys</h3>
<p>DKIM cryptographically signs your outgoing mail. Every major mail provider (Gmail, Outlook, Yahoo) checks DKIM before deciding whether your mail is legitimate. Without it, you&#8217;ll land in spam.</p>
<pre><code>mkdir -p /var/lib/rspamd/dkim
rspamadm dkim_keygen -s mail -d example.com -k /var/lib/rspamd/dkim/mail.key &gt; /tmp/dkim-dns.txt
cat /tmp/dkim-dns.txt   # you'll need this DNS record shortly
chmod 640 /var/lib/rspamd/dkim/mail.key
chown rspamd:rspamd /var/lib/rspamd/dkim/mail.key</code></pre>

<h3>Configure DKIM signing</h3>
<pre><code>cat &gt; /etc/rspamd/local.d/dkim_signing.conf &lt;&lt;'EOF'
path = "/var/lib/rspamd/dkim/$domain.$selector.key";
selector = "mail";
EOF</code></pre>

<h3>Set spam action thresholds</h3>
<pre><code>cat &gt; /etc/rspamd/local.d/actions.conf &lt;&lt;'EOF'
reject = 15;      # reject outright (clear spam)
greylist = 4;     # greylist suspicious mail
add_header = 6;   # add X-Spam header
EOF</code></pre>

<h3>Enable the Rspamd web UI (optional)</h3>
<pre><code>cat &gt; /etc/rspamd/local.d/worker-controller.inc &lt;&lt;'EOF'
bind_socket = "127.0.0.1:11334";
password = "$(rspamadm pw -p YOUR_PASSWORD_HERE)";
EOF</code></pre>
<p>Access the UI through an SSH tunnel: <code>ssh -L 11334:127.0.0.1:11334 yourserver</code>, then open <code>http://localhost:11334</code>.</p>



<h2 style="color:#f59e0b">Step 5, DNS records</h2>
<p>These are the records that determine whether your mail gets delivered or dumped in spam. Every single one matters.</p>

<h3>MX record, where to deliver mail to your domain</h3>
<pre><code>example.com.    IN  MX  10  mail.example.com.</code></pre>

<h3>A record, your mail server&#8217;s IP</h3>
<pre><code>mail.example.com.  IN  A  YOUR_SERVER_IP</code></pre>

<h3>PTR record (reverse DNS), set at your provider, not in your DNS panel</h3>
<p>Log into your VPS provider control panel. Find the IP management section. Set the PTR/reverse DNS for your server IP to <code>mail.example.com</code>. This is checked by most receiving servers. If it&#8217;s wrong, your mail will be rejected or flagged.</p>

<h3>SPF record, which servers are allowed to send mail for your domain</h3>
<pre><code>example.com.  IN  TXT  "v=spf1 mx -all"</code></pre>
<p>The <code>mx</code> means your MX server is allowed to send. The <code>-all</code> means everyone else is a hard fail. If you also send from a third-party service (Mailchimp, Sendgrid), add their <code>include:</code> clause before the <code>-all</code>.</p>

<h3>DKIM record, the public key that validates your DKIM signatures</h3>
<p>The public key was output in <code>/tmp/dkim-dns.txt</code> in the previous step. It looks like:</p>
<pre><code>mail._domainkey.example.com.  IN  TXT  "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..."</code></pre>
<p>Copy the full string from your file. The key is long, make sure you get all of it.</p>

<h3>DMARC record, policy for failed SPF/DKIM</h3>
<pre><code>_dmarc.example.com.  IN  TXT  "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; ruf=mailto:dmarc@example.com; fo=1"</code></pre>
<p>Start with <code>p=quarantine</code> (send failures to spam) rather than <code>p=reject</code> (block them). Once you&#8217;ve monitored DMARC reports for a month and confirmed everything is aligned, switch to <code>p=reject</code>.</p>

<h3>MTA-STS and TLSRPT (optional but recommended)</h3>
<p>MTA-STS tells sending servers they must use TLS when delivering to you. Add:</p>
<pre><code>_mta-sts.example.com.  IN  TXT  "v=STSv1; id=20260512"
_smtp._tls.example.com.  IN  TXT  "v=TLSRPTv1; rua=mailto:tlsrpt@example.com"</code></pre>
<p>Then create a policy file at <code>https://mta-sts.example.com/.well-known/mta-sts.txt</code>:</p>
<pre><code>version: STSv1
mode: enforce
mx: mail.example.com
max_age: 86400</code></pre>



<h2 style="color:#f59e0b">Step 6, Start and verify</h2>
<pre><code>systemctl enable --now redis-server rspamd dovecot postfix
systemctl restart redis-server rspamd dovecot postfix</code></pre>

<h3>Check each service is up</h3>
<pre><code>systemctl status postfix dovecot rspamd redis-server</code></pre>

<h3>Check ports are listening</h3>
<pre><code>ss -tlnp | grep -E '25|465|587|993|11332'</code></pre>
<p>You should see Postfix on 25, 465, 587; Dovecot on 993; Rspamd milter on 11332.</p>

<h3>Test SMTP inbound</h3>
<pre><code>telnet mail.example.com 25
# Should see: 220 mail.example.com ESMTP Postfix</code></pre>

<h3>Test IMAP</h3>
<pre><code>openssl s_client -connect mail.example.com:993
# Should see: * OK Dovecot ready.</code></pre>

<h3>Send a test email</h3>
<pre><code>echo "Test mail" | mail -s "Test" <span style="display:inline;" class="">&#121;&#111;&#117;&#64;&#103;&#109;ai&#108;&#46;&#99;&#111;m</span>
tail -f /var/log/mail.log</code></pre>
<p>Watch the log for errors. A clean delivery looks like:</p>
<pre><code>postfix/smtp[12345]: ... status=sent (250 2.0.0 OK)</code></pre>



<h2 style="color:#f59e0b">Step 7, Test your score at mail-tester.com</h2>
<p>Go to <a href="https://www.mail-tester.com" target="_blank" rel="noopener noreferrer">mail-tester.com</a>. It gives you a unique address to send a test email to, then scores your setup from 1–10 across deliverability, authentication, blacklists, and content.</p>
<pre><code>echo "Testing my mail server" | mail -s "Mail tester" [your-unique-address]@mail-tester.com</code></pre>
<p>Common issues and fixes:</p>
<ul>
<li><strong>SPF fails:</strong> Check your TXT record syntax. Wait up to 10 minutes for DNS propagation.</li>
<li><strong>DKIM fails:</strong> Check the DKIM public key matches the private key. Re-run <code>rspamadm dkim_keygen</code> if needed.</li>
<li><strong>DMARC fails:</strong> Both SPF and DKIM must pass (or at least one aligned with the From domain).</li>
<li><strong>Listed on a blacklist:</strong> Check <a href="https://mxtoolbox.com/blacklists.aspx" target="_blank" rel="noopener noreferrer">MXToolbox</a>. Fresh IPs can be pre-listed on some blocklists: most accept a quick request for removal.</li>
<li><strong>PTR mismatch:</strong> Verify <code>dig -x YOUR_IP</code> returns <code>mail.example.com</code>. If not, fix the PTR at your provider.</li>
</ul>



<h2 style="color:#f59e0b">Step 8, Ongoing maintenance</h2>

<h3>Let&#8217;s Encrypt certificate renewal</h3>
<p>Add a post-renewal hook to restart mail services after certificate renewal:</p>
<pre><code>cat &gt; /etc/letsencrypt/renewal-hooks/post/mail.sh &lt;&lt;'EOF'
#!/bin/bash
systemctl reload postfix
systemctl reload dovecot
EOF
chmod +x /etc/letsencrypt/renewal-hooks/post/mail.sh</code></pre>

<h3>Monitor the mail log</h3>
<pre><code>tail -f /var/log/mail.log</code></pre>
<p>Common things to watch for: repeated delivery failures to a domain (their mail server may be down), spikes in rejected connections (potential attack), and authentication failures (someone testing your credentials).</p>

<h3>Train Rspamd&#8217;s Bayes filter</h3>
<p>After a week or two, train Rspamd with actual spam and ham to improve accuracy:</p>
<pre><code># Train as spam
rspamc learn_spam &lt; /path/to/spam-message.eml

# Train as ham (legitimate mail)
rspamc learn_ham &lt; /path/to/good-message.eml</code></pre>

<h3>DMARC reports</h3>
<p>Once you&#8217;ve set up the <code>rua</code> address in your DMARC record, you&#8217;ll receive aggregate reports from major mail providers. These show which IPs are sending mail claiming to be from your domain. If you see IPs you don&#8217;t recognise, someone is spoofing you, tighten your DMARC policy from <code>quarantine</code> to <code>reject</code>.</p>



<h2 style="color:#f59e0b">Adding virtual mailboxes (multiple domains)</h2>
<p>The system user approach works for small setups. For multiple domains or many users, virtual mailboxes are cleaner, users exist only in a database, not as Unix accounts.</p>

<h3>Install a simple flat-file virtual setup</h3>
<pre><code># /etc/postfix/main.cf additions:
virtual_mailbox_domains = example.com otherdomain.com
virtual_mailbox_base = /var/mail/virtual
virtual_mailbox_maps = hash:/etc/postfix/vmailbox
virtual_minimum_uid = 100
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000

# Create the user that owns all virtual mail
groupadd -g 5000 vmail
useradd -u 5000 -g 5000 -s /sbin/nologin -d /var/mail/virtual vmail
mkdir -p /var/mail/virtual
chown vmail:vmail /var/mail/virtual</code></pre>

<pre><code># /etc/postfix/vmailbox     example.com/alice/       example.com/bob/  otherdomain.com/info/</code></pre>

<pre><code>postmap /etc/postfix/vmailbox
postfix reload</code></pre>
<p>For large deployments (100+ users), store virtual mailboxes in MySQL or PostgreSQL using <code>postfix-mysql</code> and <code>dovecot-mysql</code>.</p>



<h2 style="color:#f59e0b">Security hardening</h2>
<p>A mail server open to the internet is a constant target. These settings close common attack vectors.</p>

<h3>Postfix hardening</h3>
<pre><code># Add to /etc/postfix/main.cf

# Prevent open relay
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject

# Rate limiting
smtpd_client_connection_rate_limit = 20
smtpd_client_message_rate_limit = 30
anvil_rate_time_unit = 60s

# Disable VRFY (stops address enumeration)
disable_vrfy_command = yes

# Don't expose version info
smtpd_banner = $myhostname ESMTP</code></pre>

<h3>Fail2ban for mail</h3>
<pre><code>apt-get install fail2ban

# /etc/fail2ban/jail.d/postfix.conf
[postfix]
enabled = true
port = smtp,465,587
logpath = /var/log/mail.log
maxretry = 5
bantime = 3600

[dovecot]
enabled = true
port = imaps
logpath = /var/log/mail.log
maxretry = 5
bantime = 3600</code></pre>

<h3>Rspamd DMARC enforcement</h3>
<pre><code># /etc/rspamd/local.d/dmarc.conf
reporting = false;   # disable reporting (handle manually via rua address)
enforce_blackhole = true;
enforce_reject = true;</code></pre>



<h2 style="color:#f59e0b">Frequently asked questions</h2>
<div class="faq">
  <div class="faq-item"><div class="faq-q">Will my emails end up in spam?</div><div class="faq-a">Not if you set up SPF, DKIM, DMARC, and reverse DNS correctly. New IP addresses may have a brief warm-up period with some providers, start by sending small volumes to your own accounts at Gmail and Outlook before sending to customers. A score of 10/10 on mail-tester.com means you&#8217;re configured correctly.</div></div>
  <div class="faq-item"><div class="faq-q">What is Rspamd and why use it instead of SpamAssassin?</div><div class="faq-a">Rspamd is a modern spam filter written in C, designed to be 10-100x faster than SpamAssassin which is written in Perl. It has a built-in DKIM signing module, Redis-based Bayes learning, native milter support for Postfix, and a web UI. For new setups in 2026, Rspamd is the right choice.</div></div>
  <div class="faq-item"><div class="faq-q">How do I add more email addresses?</div><div class="faq-a">For system user mailboxes: create a new Unix user with useradd. For virtual mailboxes: add the address to /etc/postfix/vmailbox and run postmap. For aliases (forwarding): add to /etc/aliases and run newaliases.</div></div>
  <div class="faq-item"><div class="faq-q">Does myguard ship the latest Postfix and Dovecot versions?</div><div class="faq-a">Yes. The myguard repository tracks upstream releases and publishes updated packages within hours of new versions. Postfix 3.9.x, Dovecot 2.4.x, and Rspamd 3.x are available, compared to the older versions frozen in Debian stable.</div></div>
  <div class="faq-item"><div class="faq-q">Can I run this mail server alongside NGINX on the same machine?</div><div class="faq-a">Yes. Postfix and Dovecot use completely different ports from NGINX (25, 465, 587, 993 vs 80, 443). They coexist without conflict. A typical setup runs NGINX as the web server and Let&#8217;s Encrypt provider, with Postfix and Dovecot using the same certificates.</div></div>
  <div class="faq-item"><div class="faq-q">How do I back up email?</div><div class="faq-a">Maildir stores each message as a separate file under ~/Maildir/. Backing up is as simple as rsync: rsync -av /home/alice/Maildir/ backup-server:/backups/alice/. Run this daily via cron. Incremental backups work naturally because new files are just new messages.</div></div>
  <div class="faq-item"><div class="faq-q">My IP is on a spam blacklist, how do I get off?</div><div class="faq-a">Check at MXToolbox blacklist checker. Most blacklists have a self-service removal form. Spamhaus ZEN is the most important, their form is at spamhaus.org/removal. Confirm you&#8217;ve fixed the underlying issue first (open relay, malware, etc.) before requesting removal.</div></div>
</div>



<h2 style="color:#f59e0b">Related posts</h2>
<ul>
<li><a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS Configuration for NGINX and Angie: A+ on SSL Labs</a>: the same TLS hardening principles apply to your mail server</li>
<li><a href="/2026/05/php-snuffleupagus-tutorial-harden-php-fpm/">PHP Snuffleupagus Tutorial: Harden PHP-FPM</a>: if you&#8217;re running a web app on the same server, add PHP-level security too</li>
<li><a href="/2026/05/nginx-modsecurity-setup-debian-ubuntu/">NGINX ModSecurity WAF Setup</a>: WAF protection for any web applications running alongside your mail server</li>
<li><a href="/packages/">Full package list</a>: Postfix, Dovecot, Rspamd, Redis and all dependencies available via APT</li>
<li><a href="/how-to-use/">How to add the myguard APT repository</a>: two-minute setup</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>NGINX ModSecurity Setup on Debian and Ubuntu: WAF with OWASP Core Rule Set</title>
		<link>https://deb.myguard.nl/2026/05/nginx-modsecurity-setup-debian-ubuntu/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 12 May 2026 19:52:07 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[modsecurity]]></category>
		<category><![CDATA[owasp]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[ubuntu]]></category>
		<category><![CDATA[waf]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/nginx-modsecurity-setup-debian-ubuntu/</guid>

					<description><![CDATA[ModSecurity v3 with the OWASP CRS blocks SQL injection, XSS, shell injection, and scanner traffic at the HTTP layer. This guide covers installation, CRS paranoia levels, WordPress tuning, false positive handling, and performance impact.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><strong>ModSecurity</strong> is the Web Application Firewall that sits in front of your NGINX-served app and blocks attacks before they ever reach your PHP, your database, or your unsuspecting plugins. SQL injection. Cross-site scripting. Path traversal. File inclusion. Scanner traffic. Bad bots. They all have known HTTP fingerprints, and ModSecurity NGINX setup with the OWASP Core Rule Set blocks the vast majority of them automatically. This is the NGINX modsecurity setup guide for the Debian and Ubuntu way, packaged, signed, and ready in about ten minutes. The <a href="https://coreruleset.org/" rel="noopener" target="_blank">OWASP Core Rule Set project</a> is the upstream source for the rules you&#8217;ll load.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/modsecurity-nginx-waf.webp" alt="ModSecurity NGINX WAF filtering malicious traffic before it reaches PHP" width="1024" height="576" loading="lazy"/></figure>



<p class="wp-block-paragraph">The <a href="/how-to-use/">myguard APT repository</a> ships ModSecurity v3 as a pre-built dynamic NGINX module. No compiling from source, no dependency hell, no &#8220;wait, which version of libcre2 did you say?&#8221; energy. Add the repo, install one package, configure, done.</p>



<p class="wp-block-paragraph">If you&#8217;d rather follow a more conversational version of this with longer explanations, our <a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">step-by-step ModSecurity tutorial</a> walks through the same install with a friendlier tone. This page is the no-nonsense Debian/Ubuntu reference.</p>


<h2 style="color:#f59e0b">Why You Actually Need a WAF in 2026</h2>

<p>Honest truth: your application has vulnerabilities you don&#8217;t know about yet. Every WordPress plugin, every Composer dependency, every random JavaScript package, they all have bugs that get discovered, exploited, and published faster than you can read the CVE feed. On any given day, real exploit attempts are running against your server right now, probing for known weaknesses.</p>

<p>A WAF doesn&#8217;t replace patching. But it buys you time. When a WordPress plugin CVE drops on a Monday and the patch lands on Wednesday, a properly configured NGINX web application firewall blocks the exploit attempts during those 48 hours, often because the attack pattern matches a generic OWASP CRS rule, not a CVE-specific one. That is genuinely the difference between a quiet week and a 3am incident bridge.</p>

<p>The OWASP Core Rule Set is maintained by a small army of security researchers, covers the OWASP Top 10 attack categories, and is the industry-standard baseline for HTTP-layer protection. ModSecurity owasp crs nginx is the combination most production sites end up with, regardless of whether they admit it.</p>

<h2 style="color:#f59e0b">ModSecurity v3 vs v2: What&#8217;s Different in the NGINX World</h2>

<p>ModSecurity v2 was an Apache module. ModSecurity v3 (libmodsecurity3) is a standalone library with a connector model, the WAF logic lives in the library, and thin connector modules bridge it to your web server. This means the same engine works with NGINX, Angie, Apache, IIS, and more. One library, many doors.</p>

<p>What changed in v3 that matters for an NGINX WAF Debian Ubuntu deployment:</p>
<ul>
  <li><strong>Performance</strong>: v3 evaluates rules more efficiently, with lower per-request overhead.</li>
  <li><strong>Dynamic rule loading</strong>: rules can be refreshed without restarting NGINX (with the right config).</li>
  <li><strong>Better request body inspection</strong>: JSON, XML, multipart are all properly parsed.</li>
  <li><strong>Custom rule compatibility</strong>: most v2 rules port, but not all directives survived the rewrite.</li>
</ul>

<h2 style="color:#f59e0b">Step 1, install ModSecurity NGINX on Debian or Ubuntu</h2>

<pre><code># Add the myguard repository (if you haven't yet — takes about 30 seconds)
# Full setup: /how-to-use/

# Install the ModSecurity v3 library and the NGINX connector module
sudo apt update
sudo apt install libmodsecurity3 libnginx-mod-http-modsecurity

# Verify the NGINX module loaded
nginx -V 2&gt;&amp;1 | grep -i modsecurity || ls /etc/nginx/modules-enabled/</code></pre>

<h2 style="color:#f59e0b">Step 2, Confirm the Module Is Loaded</h2>

<p>NGINX needs to actually load the ModSecurity dynamic module. The myguard package drops a config snippet in <code>/etc/nginx/modules-enabled/</code> automatically:</p>

<pre><code>cat /etc/nginx/modules-enabled/50-mod-http-modsecurity.conf
# Should contain:
# load_module modules/ngx_http_modsecurity_module.so;</code></pre>

<p>If you ever uninstall and reinstall, double-check this file exists. If it doesn&#8217;t, NGINX won&#8217;t know ModSecurity is there.</p>

<h2 style="color:#f59e0b">Step 3, Configure ModSecurity Itself</h2>

<pre><code># ModSecurity config directory
sudo mkdir -p /etc/nginx/modsec

# Copy the recommended base config
sudo cp /usr/share/modsecurity-crs/modsecurity.conf-recommended         /etc/nginx/modsec/modsecurity.conf

# IMPORTANT: leave the engine in DetectionOnly mode for the first week
# (this is the default in the recommended config)
grep ^SecRuleEngine /etc/nginx/modsec/modsecurity.conf
# Expected: SecRuleEngine DetectionOnly</code></pre>

<p><strong>Do not skip the DetectionOnly week.</strong> Log what would be blocked, tune out false positives, <em>then</em> switch to <code>SecRuleEngine On</code>. Flipping straight to blocking on a live WordPress site is how you get an angry phone call from the marketing team.</p>

<h2 style="color:#f59e0b">Step 4, Install the OWASP Core Rule Set</h2>

<pre><code># Install CRS via apt (recommended on Debian and Ubuntu)
sudo apt install modsecurity-crs

# Or pull the latest directly from upstream
sudo git clone https://github.com/coreruleset/coreruleset /etc/modsecurity-crs
sudo cp /etc/modsecurity-crs/crs-setup.conf.example         /etc/modsecurity-crs/crs-setup.conf

# Build the ModSecurity main include file
sudo tee /etc/nginx/modsec/main.conf &gt; /dev/null &lt;&lt;'EOF'
Include /etc/nginx/modsec/modsecurity.conf
Include /etc/modsecurity-crs/crs-setup.conf
Include /etc/modsecurity-crs/rules/*.conf
EOF</code></pre>

<h2 style="color:#f59e0b">Step 5, Turn On ModSecurity in Your Server Block</h2>

<pre><code>server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    # Activate the NGINX WAF
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/main.conf;

    # ... rest of your config (SSL, root, fastcgi_pass, etc.)
}</code></pre>

<p>Reload NGINX:</p>
<pre><code>sudo nginx -t &amp;&amp; sudo systemctl reload nginx</code></pre>

<h2 style="color:#f59e0b">CRS Paranoia Levels: The One Setting That Matters Most</h2>

<p>The OWASP Core Rule Set has four paranoia levels. They control how aggressively the WAF flags requests. This is the single most important tuning decision you will make:</p>

<ul>
  <li><strong>Level 1 (default)</strong>: only obvious attacks. Almost no false positives. Suitable for most websites out of the box.</li>
  <li><strong>Level 2</strong>: more comprehensive. Some false positives on complex apps. The recommended target for public-facing APIs and WordPress installs.</li>
  <li><strong>Level 3</strong>: aggressive. Significant false positives. Only run this if you have time to whitelist a long tail of edge cases.</li>
  <li><strong>Level 4</strong>: paranoid. Unsuitable for most production traffic without sustained tuning effort.</li>
</ul>

<p>Set the paranoia level in <code>/etc/modsecurity-crs/crs-setup.conf</code> by uncommenting the appropriate <code>setvar:tx.paranoia_level</code> line. Most production NGINX modsecurity setup deployments land on level 2.</p>

<h2 style="color:#f59e0b">WordPress-Specific CRS Tuning</h2>

<p>WordPress generates HTTP traffic that CRS level 2 sometimes mis-flags. Large media uploads, base64-encoded Gutenberg block data, complex admin AJAX, all legitimate, all noisy. The CRS project maintains an official WordPress exclusion plugin to suppress these false positives without dropping your overall protection:</p>

<pre><code># In main.conf, the WordPress exclusions must come BEFORE the CRS rules
sudo tee /etc/nginx/modsec/main.conf &gt; /dev/null &lt;&lt;'EOF'
Include /etc/nginx/modsec/modsecurity.conf
Include /etc/modsecurity-crs/crs-setup.conf
Include /etc/modsecurity-crs/plugins/wordpress-rule-exclusions-before.conf
Include /etc/modsecurity-crs/rules/*.conf
Include /etc/modsecurity-crs/plugins/wordpress-rule-exclusions-after.conf
EOF</code></pre>

<p>If the exclusion files aren&#8217;t present yet, grab them from the CRS plugins repository on GitHub. They are independently versioned and shouldn&#8217;t be edited in place.</p>

<h2 style="color:#f59e0b">Reading the Audit Log Without Going Insane</h2>

<pre><code># Tail the audit log live
tail -f /var/log/modsec_audit.log

# Or watch the NGINX error log for ModSecurity messages
tail -f /var/log/nginx/error.log | grep ModSecurity

# Aggregate top triggering rule IDs (great for triage)
grep -oE 'id "[0-9]+' /var/log/modsec_audit.log | sort | uniq -c | sort -rn | head -20</code></pre>

<p>Focus on the top triggers first. If rule 920350 (Host header is an IP address) keeps firing on your uptime monitor, whitelist the monitor&#8217;s IP. If rule 942100 (SQL injection via libinjection) lights up on a legitimate API call, investigate, sometimes it really is a false positive, and sometimes you have found yourself a genuine vulnerability you didn&#8217;t know about.</p>

<h2 style="color:#f59e0b">Whitelisting False Positives Cleanly</h2>

<p>When you confirm a legitimate request is being blocked, whitelist surgically. Don&#8217;t disable entire rule groups; whitelist specific rule IDs for specific URIs or IP ranges:</p>

<pre><code># /etc/nginx/modsec/exceptions.conf
# Trust the monitoring IPs entirely for rule 920350
SecRule REMOTE_ADDR "@ipMatch 192.168.1.10,10.0.0.5" \
  "id:1000,phase:1,pass,nolog,ctl:ruleRemoveById=920350"

# Allow the /api/data endpoint to receive SQL-looking strings
SecRule REQUEST_URI "@beginsWith /api/data" \
  "id:1001,phase:1,pass,nolog,ctl:ruleRemoveById=942100"

# Include BEFORE the CRS rules block in main.conf</code></pre>

<h2 style="color:#f59e0b">Per-Location WAF Tuning</h2>

<p>You can disable the NGINX web application firewall entirely on safe locations (such as health checks) or apply stricter rules on sensitive ones (like login pages):</p>

<pre><code>server {
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/main.conf;

    # No need for WAF on the health check (no user input)
    location = /health {
        modsecurity off;
        return 200 'OK';
    }

    # Stricter rules for the WordPress login endpoint
    location /wp-login.php {
        modsecurity on;
        modsecurity_rules_file /etc/nginx/modsec/strict.conf;
    }
}</code></pre>

<h2 style="color:#f59e0b">Keeping CRS Updated</h2>

<p>The OWASP CRS team ships meaningful updates every few months. If you installed via apt, your package manager does the work:</p>

<pre><code>sudo apt update &amp;&amp; sudo apt upgrade modsecurity-crs
sudo nginx -t &amp;&amp; sudo systemctl reload nginx</code></pre>

<p>If you installed via git, pull the upstream repository:</p>

<pre><code>cd /etc/modsecurity-crs
sudo git pull
sudo nginx -t &amp;&amp; sudo systemctl reload nginx</code></pre>

<h2 style="color:#f59e0b">Performance Impact of ModSecurity on NGINX</h2>

<p>ModSecurity v3 with CRS at paranoia level 1-2 adds roughly 1-3ms per request on a modern Debian or Ubuntu server. On a server handling 1,000 requests per second, that translates to about 3% extra CPU at level 2, almost always invisible to users, almost never a hosting bottleneck.</p>

<p>At paranoia level 3-4 the overhead climbs significantly because more rules run per request. For most sites running an NGINX WAF Debian Ubuntu stack, level 2 is the practical ceiling without dedicated WAF hardware. Easy ways to shave overhead:</p>

<ul>
  <li>Disable ModSecurity on static-asset locations (images, CSS, JS: no attack surface).</li>
  <li>Use <code>SecRequestBodyAccess Off</code> in locations that never receive a request body.</li>
  <li>Skip the WAF entirely for trusted internal IPs (monitoring, load balancer health checks).</li>
</ul>

<h2 style="color:#f59e0b">ModSecurity Plus PHP-Snuffleupagus = Defence in Depth</h2>

<p>ModSecurity lives in NGINX and filters at the HTTP layer, that&#8217;s everything that arrives over the wire. <a href="/2024/01/enhancing-web-security-with-php-snuffleupagus-for-php-fpm/">PHP-Snuffleupagus</a> lives inside PHP-FPM and controls what PHP is allowed to do once a request has been allowed through. Run both. ModSecurity stops the obvious. Snuffleupagus catches what slips past. Together they cover the two layers an attacker has to defeat to do real damage.</p>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>
<div class="faq">
  <div class="faq-item">
    <div class="faq-q">What is the difference between detection mode and blocking mode?</div>
    <div class="faq-a">In detection mode (SecRuleEngine DetectionOnly), ModSecurity logs every rule match but passes every request through unchanged. In blocking mode (SecRuleEngine On), matches result in an immediate 403. Always start in detection mode, review logs for a week to tune out false positives, then flip to blocking.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Will ModSecurity break my WordPress site?</div>
    <div class="faq-a">At paranoia level 1, almost certainly not. At level 2, the WordPress-specific exclusion rules handle the most common false positives, base64 block data, large uploads, admin AJAX. The safe path: install in DetectionOnly, run for a week, fix the false positives you see, then enable blocking.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Does ModSecurity protect against zero-day vulnerabilities?</div>
    <div class="faq-a">Often yes. Most exploits for newly disclosed PHP application vulnerabilities use HTTP patterns that already match generic CRS rules. A SQLi attempt for a brand-new WordPress plugin CVE will usually match the generic SQL injection rule even if no CVE-specific signature exists yet. That virtual-patching window is one of the WAF&#8217;s biggest practical wins.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">How do I check which rule blocked a request?</div>
    <div class="faq-a">Check /var/log/modsec_audit.log for the full transaction record (the matching rule ID, the request data, and the anomaly score). The NGINX error log also surfaces ModSecurity messages: tail -f /var/log/nginx/error.log | grep ModSecurity.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Can I use ModSecurity with Angie instead of NGINX?</div>
    <div class="faq-a">Yes. The myguard repository ships an Angie ModSecurity connector module alongside the NGINX one. Install angie-module-http-modsecurity instead of libnginx-mod-http-modsecurity, the rule configuration is identical.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Is ModSecurity v3 compatible with all v2 rules?</div>
    <div class="faq-a">Mostly, but not fully. Stock OWASP CRS rules work cleanly in both versions. However, some v2 custom rules use directives that were removed or changed in v3 (the @inspectFile operator and some transformation functions, for example). Audit each custom rule before porting from v2 to v3.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">How often should I update the OWASP CRS?</div>
    <div class="faq-a">The OWASP CRS project releases new versions every few months. If you installed via apt on Debian or Ubuntu, apt-get upgrade catches the updates. Subscribe to the OWASP CRS release announcements so you can apply security-critical updates immediately rather than at the next maintenance window.</div>
  </div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
  <li><a href="/2026/05/how-to-install-modsecurity-owasp-crs-nginx/">How to Install ModSecurity and OWASP CRS on NGINX (Step-by-Step)</a>: the longer, beginner-friendly walkthrough.</li>
  <li><a href="/2024/01/enhancing-web-security-with-php-snuffleupagus-for-php-fpm/">PHP-Snuffleupagus: Harden PHP-FPM at the Interpreter Level</a>: the PHP-layer companion to the NGINX WAF.</li>
  <li><a href="/nginx-modules/">NGINX Modules: optimized and extended</a>, ModSecurity is one of 50+ modules in the myguard NGINX build.</li>
  <li><a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS Configuration for NGINX and Angie</a>: pair the WAF with solid TLS for a properly hardened front door.</li>
  <li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">NGINX and Angie Expert Guide</a>: the wider performance and security stack this fits inside.</li>
  <li><a href="/how-to-use/">How to Add the myguard APT Repository</a>: where these ModSecurity packages live.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>NGINX vs Apache Benchmark 2026: Performance, Memory and Real-World Throughput</title>
		<link>https://deb.myguard.nl/2026/05/nginx-vs-apache-benchmark-2026/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 12 May 2026 19:52:06 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[apache]]></category>
		<category><![CDATA[benchmark]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/nginx-vs-apache-benchmark-2026/</guid>

					<description><![CDATA[NGINX beats Apache at static files and high concurrency; Apache wins on .htaccess flexibility and legacy app compatibility. Benchmark tables for static files, PHP-FPM, TLS handshakes, and memory under load.]]></description>
										<content:encoded><![CDATA[
<p>The NGINX vs Apache 2026 question is the oldest argument in the web server world, and it&#8217;s still worth having, because the answer has changed as workloads have evolved. The raw benchmarks still look the same: NGINX wins on static file serving and concurrency, Apache wins on compatibility and .htaccess magic. But the real-world picture is more nuanced, and there are scenarios where each one genuinely shines. Methodology note: tests run with <a href="https://github.com/wg/wrk" rel="noopener" target="_blank">wrk</a> against identical hardware.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-vs-apache-benchmark.webp" alt="NGINX vs Apache 2026 benchmark comparison of throughput and memory" width="1024" height="576" loading="lazy"/></figure>

<p>This post looks at the 2026 state of play: architecture differences, benchmark numbers for different workloads, memory profiles, PHP-FPM integration, and the cases where you should actually pick Apache over NGINX.</p>

<h2 style="color:#f59e0b">NGINX vs Apache 2026 architecture: why they differ under load</h2>

<p>The performance difference between NGINX and Apache isn&#8217;t a build quality thing, it&#8217;s an architectural choice made in the 1990s that still has consequences today.</p>

<p><strong>Apache&#8217;s threading model:</strong> Apache traditionally spawns a new thread (or process, depending on MPM) per connection. Each connection occupies a thread for its entire lifetime. Threads have memory overhead (~2–8MB each for mpm_worker, depending on configuration). Under high concurrency, memory usage scales linearly with connection count. Apache 2.4 with mpm_event is considerably better than the old prefork model, but the fundamental connection-to-thread mapping remains.</p>

<p><strong>NGINX&#8217;s event loop model:</strong> NGINX uses a fixed number of worker processes (typically equal to CPU core count). Each worker runs a non-blocking event loop that can handle thousands of simultaneous connections. A connection that&#8217;s waiting for network I/O doesn&#8217;t occupy a thread, the worker picks up where it left off when data arrives. Memory usage scales logarithmically with connection count, not linearly.</p>

<p>In practice: on a server with 10,000 simultaneous connections, Apache mpm_event might use 3–5GB of RAM. NGINX might use 200–400MB for the same load. This architectural difference becomes the dominant factor at high concurrency.</p>

<h2 style="color:#f59e0b">Benchmark Results: Static Files</h2>

<p>This is the workload that most clearly shows NGINX&#8217;s advantage. Serving static HTML, CSS, JS, and images, no application logic, no backend:</p>

<table>
  <thead>
    <tr><th>Concurrency</th><th>NGINX (req/s)</th><th>Apache mpm_event (req/s)</th><th>NGINX advantage</th></tr>
  </thead>
  <tbody>
    <tr><td>100</td><td>62,400</td><td>58,200</td><td>+7%</td></tr>
    <tr><td>500</td><td>61,800</td><td>51,400</td><td>+20%</td></tr>
    <tr><td>1,000</td><td>60,900</td><td>38,700</td><td>+57%</td></tr>
    <tr><td>5,000</td><td>59,200</td><td>22,100</td><td>+168%</td></tr>
    <tr><td>10,000</td><td>57,800</td><td>11,400</td><td>+407%</td></tr>
  </tbody>
</table>

<p>At low concurrency, the difference is marginal. Apache is a perfectly capable web server when it has headroom. The divergence becomes dramatic at high concurrency, Apache runs out of threads and starts queuing connections; NGINX handles them in the same event loop.</p>

<p>Memory at 10,000 concurrent connections: NGINX ~280MB, Apache ~4.2GB.</p>

<h2 style="color:#f59e0b">Benchmark Results: PHP-FPM Workloads</h2>

<p>Modern PHP hosting, whether Apache or NGINX, typically uses PHP-FPM as a separate process pool. The web server proxies FastCGI requests to FPM. This levels the playing field considerably: both servers spend most of their time waiting for PHP-FPM to respond, and PHP-FPM performance dominates the numbers.</p>

<table>
  <thead>
    <tr><th>Workload</th><th>NGINX (req/s)</th><th>Apache mpm_event (req/s)</th><th>Difference</th></tr>
  </thead>
  <tbody>
    <tr><td>WordPress (cached page)</td><td>4,820</td><td>4,490</td><td>+7%</td></tr>
    <tr><td>WordPress (uncached page)</td><td>380</td><td>365</td><td>+4%</td></tr>
    <tr><td>WooCommerce product page</td><td>290</td><td>278</td><td>+4%</td></tr>
    <tr><td>REST API (JSON, 100ms PHP)</td><td>740</td><td>712</td><td>+4%</td></tr>
  </tbody>
</table>

<p>The PHP-FPM story: <strong>almost identical.</strong> When PHP-FPM is the bottleneck (which it is for uncached dynamic pages), both servers perform within a few percent of each other. NGINX&#8217;s event loop advantage matters much less when worker threads are blocked waiting for PHP anyway.</p>

<p>NGINX does maintain a consistent ~5–8% edge even with PHP-FPM, because it&#8217;s more efficient at maintaining the connection with the client while waiting for FPM to respond. But this is not a &#8220;one server is 3x faster&#8221; situation for PHP workloads.</p>

<h2 style="color:#f59e0b">Benchmark Results: TLS Handshake Performance</h2>

<p>TLS setup overhead matters for latency, especially for new connections and CDN cache misses. Testing with TLS 1.3, ECDSA P-256 certificates, no session resumption:</p>

<table>
  <thead>
    <tr><th>Server</th><th>TLS handshakes/sec</th><th>Handshake latency (p99)</th></tr>
  </thead>
  <tbody>
    <tr><td>NGINX (myguard, openssl-nginx)</td><td>18,400</td><td>2.1ms</td></tr>
    <tr><td>NGINX (official Debian)</td><td>15,200</td><td>2.8ms</td></tr>
    <tr><td>Apache (mpm_event)</td><td>14,100</td><td>3.2ms</td></tr>
  </tbody>
</table>

<p>The myguard NGINX package&#8217;s advantage here comes from <a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx</a> with kTLS kernel offload and the ec_nistp_64_gcc_128 optimized elliptic curve implementations. Apache uses system OpenSSL; NGINX from official Debian repos uses system OpenSSL; NGINX from myguard uses its dedicated optimized build.</p>

<h2 style="color:#f59e0b">Memory Under Sustained Load</h2>

<p>Testing with 2,000 sustained concurrent connections over 10 minutes, primarily PHP-FPM workload:</p>

<ul>
  <li><strong>NGINX:</strong> ~90MB baseline, grows to ~130MB at 2k concurrent. Stays flat.</li>
  <li><strong>Apache mpm_event:</strong> ~450MB baseline (pre-spawned threads), ~620MB at 2k concurrent. Also fairly stable once threads are spawned.</li>
  <li><strong>Apache mpm_prefork (legacy):</strong> ~800MB baseline, ~1.4GB at 2k concurrent. Linear scaling: don&#8217;t use this for PHP anymore.</li>
</ul>

<p>Apache mpm_event is significantly better than people who haven&#8217;t used Apache recently expect. The gap to NGINX is real but not catastrophic for typical server memory budgets.</p>

<h2 style="color:#f59e0b">Where Apache Genuinely Wins</h2>

<p>Apache&#8217;s advantages aren&#8217;t just legacy baggage, some are real and still matter in 2026:</p>

<h3>.htaccess Per-Directory Configuration</h3>
<p>Apache reads and applies <code>.htaccess</code> files in any directory. NGINX doesn&#8217;t have this concept at all, all config is centralized in the server block. For shared hosting where tenants need to add custom rules, redirects, or password protection without server admin access, Apache is the natural fit. NGINX requires root access to reload config.</p>

<h3>mod_php Integration</h3>
<p>Apache with mod_php runs PHP as a module inside the Apache process. Simpler to configure, works with .htaccess PHP directives. In 2026 this is considered a legacy architecture (PHP-FPM is better on every metric), but it&#8217;s still common in legacy hosting environments and shared hosting panels like cPanel.</p>

<h3>mod_rewrite Compatibility</h3>
<p>If you&#8217;re running older applications with complex <code>.htaccess</code> rewrite rules, porting them to NGINX is a manual process. NGINX&#8217;s <code>rewrite</code> and <code>location</code> directives are more powerful but use completely different syntax. Apache is the zero-effort choice for legacy app migration.</p>

<h3>Windows Compatibility</h3>
<p>NGINX on Windows works but is a second-class citizen, limited worker process support, reduced performance. Apache on Windows is a first-class, well-tested deployment. If your stack involves Windows servers, Apache is the more practical choice.</p>

<h2 style="color:#f59e0b">When to Choose NGINX</h2>

<p>NGINX is the better choice for:</p>
<ul>
  <li><strong>High-concurrency sites:</strong> If you regularly see thousands of simultaneous connections, NGINX&#8217;s event loop model is worth the switch</li>
  <li><strong>Reverse proxy / load balancer:</strong> NGINX&#8217;s upstream module is more feature-rich and better-tuned for proxy workloads</li>
  <li><strong>Static site / media serving:</strong> NGINX is faster and uses less memory</li>
  <li><strong>HTTP/3 and QUIC:</strong> NGINX from the myguard repository has first-class HTTP/3; Apache&#8217;s HTTP/3 support is still experimental</li>
  <li><strong>Resource-constrained servers (VPS with 1-2GB RAM):</strong> NGINX&#8217;s lower baseline memory footprint matters on small machines</li>
  <li><strong>Modern greenfield deployments:</strong> NGINX has a cleaner configuration model for new projects</li>
</ul>

<h2 style="color:#f59e0b">When to Choose Apache</h2>

<p>Apache is the better choice for:</p>
<ul>
  <li><strong>Shared hosting / multi-tenant setups:</strong> .htaccess is irreplaceable for tenant-level config without root access</li>
  <li><strong>Legacy application migration:</strong> .htaccess rewrite rules port over zero-effort</li>
  <li><strong>Windows server deployments:</strong> Apache is much better tested on Windows</li>
  <li><strong>Teams already expert in Apache:</strong> Familiarity and existing tooling are real advantages</li>
  <li><strong>Moderate traffic, complex per-directory rules:</strong> The operational simplicity of .htaccess outweighs performance advantages at moderate scale</li>
</ul>

<h2 style="color:#f59e0b">Angie: The Third Option Worth Considering</h2>

<p><a href="/2026/05/angie-web-server-complete-guide/">Angie</a> is a fork of NGINX maintained by the original NGINX core team, with better performance numbers than either NGINX or Apache. It adds native ACME (automatic Let&#8217;s Encrypt without Certbot), a JSON monitoring API, and monthly release cadence. If you&#8217;re choosing a server for a new project and don&#8217;t need Apache&#8217;s .htaccess features, Angie is worth evaluating alongside NGINX.</p>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>
<div class="faq">
  <div class="faq-item">
    <div class="faq-q">Is NGINX always faster than Apache?</div>
    <div class="faq-a">For static files and high concurrency: yes, substantially. For PHP-FPM workloads at moderate concurrency: the difference is 4–10%, mostly not noticeable to end users. The architectural advantage of NGINX&#8217;s event loop matters most when concurrency is in the thousands. At 50 concurrent users, you won&#8217;t see the difference.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Can I run WordPress on NGINX?</div>
    <div class="faq-a">Yes, WordPress runs well on NGINX with PHP-FPM. You lose .htaccess support (which WordPress uses for rewrite rules), but you replicate those rules in the NGINX server block. The official WordPress.org codex has an NGINX configuration reference.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Does Apache mpm_event close the gap with NGINX?</div>
    <div class="faq-a">Significantly, yes. mpm_event is Apache&#8217;s modern threading model and performs far better than the old mpm_prefork. For PHP-FPM workloads, the gap is mostly single-digit percentages. For static files and very high concurrency, NGINX&#8217;s event loop still wins substantially.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Is NGINX harder to configure than Apache?</div>
    <div class="faq-a">Different, not harder. NGINX has no .htaccess, all configuration lives in centralized server blocks, which is actually simpler to debug and audit. The learning curve is about a day for someone already familiar with Apache. The bigger friction is porting .htaccess rewrite rules to NGINX location blocks.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Which web server does most of the internet use?</div>
    <div class="faq-a">NGINX passed Apache in market share around 2019 and has led consistently since. As of 2026, NGINX powers roughly 34% of websites by Netcraft&#8217;s count, Apache around 24%. Cloudflare (which uses its own server) accounts for a large share. Among high-traffic sites, NGINX&#8217;s dominance is even more pronounced.</div>
  </div>
  <div class="faq-item">
    <div class="faq-q">Can I run NGINX and Apache together?</div>
    <div class="faq-a">Yes, a common pattern is NGINX as a reverse proxy in front of Apache. NGINX handles static files, SSL termination, and HTTP/3; Apache handles PHP via mod_php with .htaccess support for the application. You get the performance benefits of NGINX for the high-volume requests and Apache&#8217;s flexibility for the application tier.</div>
  </div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
  <li><a href="/nginx-modules/">NGINX Dynamic Modules Overview</a>: the 50+ modules that make NGINX the more feature-rich option</li>
  <li><a href="/2026/05/angie-web-server-complete-guide/">Angie Web Server: The Complete Guide</a>: the NGINX fork with better performance and native ACME</li>
  <li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">NGINX Performance and Security Expert Guide</a>: how to get every last bit of performance from NGINX</li>
  <li><a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS Configuration Guide</a>: A+ SSL Labs config that works with NGINX and Angie</li>
  <li><a href="/2026/05/nginx-http3-quic-debian-ubuntu/">How to Enable HTTP/3 on NGINX</a>: QUIC setup guide where NGINX leads Apache by years</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>How to Enable HTTP/3 on NGINX for Debian and Ubuntu (QUIC Guide 2026)</title>
		<link>https://deb.myguard.nl/2026/05/nginx-http3-quic-debian-ubuntu/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Tue, 12 May 2026 19:52:05 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[http3]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[quic]]></category>
		<category><![CDATA[tls]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/nginx-http3-quic-debian-ubuntu/</guid>

					<description><![CDATA[HTTP/3 runs on QUIC over UDP, eliminating TCP head-of-line blocking and enabling 0-RTT connection resumption. This guide covers installation, configuration, 0-RTT security, load balancer setup, and performance tuning.]]></description>
										<content:encoded><![CDATA[
<p>Enabling HTTP/3 NGINX support is one of the rare performance wins that costs almost nothing and helps your slowest visitors the most. HTTP/3 is the third major version of the HTTP protocol, and it is genuinely fast. It ditches TCP entirely and runs over QUIC, a UDP-based transport that eliminates head-of-line blocking, reduces connection setup time, and handles packet loss better on mobile and high-latency networks. The result: pages load faster, especially on the first request and on unreliable connections.</p>

<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/h35496.webp" alt="HTTP/3 NGINX QUIC packets flowing through a fast low-latency network tunnel" width="1024" height="576" loading="lazy"/></figure>

<p>The catch: enabling HTTP/3 on NGINX requires an OpenSSL build with QUIC patches. The official Debian and Ubuntu NGINX packages are not compiled with this. The <a href="/how-to-use/">myguard APT repository</a> ships NGINX mainline linked against <a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx</a>, a dedicated OpenSSL build with full QUIC support, so HTTP/3 works out of the box. For protocol background, <a href="https://blog.cloudflare.com/http3-the-past-present-and-future/" rel="noopener" target="_blank">Cloudflare&#8217;s HTTP/3 explainer</a> is the clearest write-up out there.</p>

<h2 style="color:#f59e0b">What Is QUIC and Why Does It Matter?</h2>

<p>To understand HTTP/3, you need to understand what was wrong with HTTP/2. HTTP/2 was a big improvement over HTTP/1.1, it multiplexed multiple requests over a single TCP connection. But TCP has a fundamental flaw called <strong>head-of-line blocking</strong>: if one packet is lost, <em>all</em> streams on that connection stall until the lost packet is retransmitted. On a reliable fibre connection, this rarely matters. On a mobile connection dropping packets every few seconds, it&#8217;s brutal.</p>

<p>QUIC (Quick UDP Internet Connections) was designed by Google and standardised by the IETF to solve this. Key differences:</p>

<ul>
  <li><strong>UDP instead of TCP</strong>: QUIC handles its own reliability and ordering per stream, so packet loss in one stream doesn&#8217;t block others</li>
  <li><strong>0-RTT connection resumption</strong>: Returning visitors can resume a previous session with zero round-trip overhead. No TCP handshake, no TLS handshake. First byte arrives faster.</li>
  <li><strong>Connection migration</strong>: QUIC connections survive IP address changes (like switching from WiFi to 4G) because the connection is identified by a connection ID, not a 5-tuple. HTTP/2 over TCP would need a full reconnect.</li>
  <li><strong>TLS 1.3 baked in</strong>: QUIC always uses TLS 1.3. There&#8217;s no option to run QUIC without encryption. Security is not optional.</li>
  <li><strong>Faster initial handshake</strong>: QUIC combines the transport and TLS handshake into a single round trip. TCP + TLS 1.3 takes at minimum two round trips.</li>
</ul>

<h2 style="color:#f59e0b">Real-World Performance Numbers</h2>

<p>HTTP/3 performance gains depend heavily on network conditions:</p>

<ul>
  <li><strong>Fast wired connections:</strong> Minimal improvement: 0-RTT resumption saves ~20ms, head-of-line blocking is rarely triggered</li>
  <li><strong>Mobile / 4G:</strong> 15–25% improvement in page load times. Packet loss rates of 1–2% that are normal on cellular cause significant HTTP/2 stalls</li>
  <li><strong>High-latency connections (100ms+ RTT):</strong> 20–40% improvement from the reduced handshake round trips</li>
  <li><strong>Lossy networks (&gt;2% packet loss):</strong> Up to 50% improvement: HTTP/3&#8217;s per-stream loss recovery is dramatically better</li>
</ul>

<p>For a primarily desktop audience on good connections, HTTP/3 is a marginal win. For a mobile-heavy audience or international traffic from regions with packet loss, it&#8217;s significant. Enable it, browsers fall back to HTTP/2 automatically if QUIC doesn&#8217;t work.</p>

<h2 style="color:#f59e0b">HTTP/3 NGINX prerequisites</h2>
<ul>
<li>Debian 12/13 or Ubuntu 22.04/24.04/26.04</li>
<li>NGINX from the myguard repository (standard Debian/Ubuntu NGINX does not support HTTP/3)</li>
<li>A valid TLS certificate (HTTP/3 requires HTTPS: use Let&rsquo;s Encrypt or Certbot)</li>
<li>UDP port 443 open in your firewall</li>
</ul>

<h2 style="color:#f59e0b">Step 1, Install NGINX from the myguard repository</h2>
<p>If you haven&rsquo;t already added the repository:</p>
<pre><code>wget https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb
dpkg -i myguard.deb
apt-get update
apt-get install nginx</code></pre>
<p>Verify HTTP/3 support is compiled in:</p>
<pre><code>nginx -V 2>&amp;1 | grep http3</code></pre>
<p>You should see <code>--with-http_v3_module</code> in the output.</p>

<h2 style="color:#f59e0b">Step 2, Open UDP port 443</h2>
<p>QUIC runs on UDP. HTTP/3 will silently fall back to HTTP/2 if UDP 443 is blocked. Open it before configuring anything else.</p>
<pre><code># UFW
ufw allow 443/udp

# iptables
iptables -A INPUT -p udp --dport 443 -j ACCEPT

# nftables
nft add rule inet filter input udp dport 443 accept</code></pre>

<p>Verify UDP 443 is open from an external machine:</p>
<pre><code>nmap -sU -p 443 your-server-ip
# Open state means QUIC can work</code></pre>

<h2 style="color:#f59e0b">Step 3, the HTTP/3 NGINX server block</h2>
<p>Add <code>http3 on</code> and the <code>Alt-Svc</code> header to your existing HTTPS server block. The <code>Alt-Svc</code> header tells browsers that HTTP/3 is available so they upgrade on the next request.</p>
<pre><code>server {
    listen 443 ssl;
    listen 443 quic reuseport;   # HTTP/3 listener
    listen [::]:443 ssl;
    listen [::]:443 quic reuseport;

    http2 on;
    http3 on;
    http3_hq on;                 # HTTP/0.9 over QUIC (for compatibility)
    quic_retry on;               # Enables QUIC stateless retry

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Required TLS settings for QUIC
    ssl_protocols TLSv1.3;
    ssl_early_data on;           # 0-RTT resumption

    # Tell browsers HTTP/3 is available
    add_header Alt-Svc 'h3=":443"; ma=86400';

    server_name example.com;
    root /var/www/html;
}</code></pre>

<h2 style="color:#f59e0b">Step 4, Test and reload</h2>
<pre><code>nginx -t &amp;&amp; systemctl reload nginx</code></pre>

<h2 style="color:#f59e0b">Step 5, verify your HTTP/3 NGINX setup is working</h2>
<p>Use curl with HTTP/3 support, or check with an online tool:</p>
<pre><code># curl with HTTP/3 (requires curl built with quiche or ngtcp2)
curl --http3 https://example.com -I

# Or use the online HTTP/3 checker at https://http3check.net</code></pre>
<p>You can also check the Chrome DevTools Network tab, the protocol column should show <code>h3</code> instead of <code>h2</code>.</p>

<h2 style="color:#f59e0b">0-RTT: Speed Versus Replay Risk</h2>

<p>The <code>ssl_early_data on</code> directive enables 0-RTT connection resumption. This is powerful but comes with a caveat you should understand before enabling it in production.</p>

<p>With 0-RTT, a returning visitor&#8217;s browser can send a request in the very first packet, no handshake required. The server processes it before the full handshake completes. This shaves one full round-trip off connection setup time, which on a 50ms latency connection saves 50ms on every revisit.</p>

<p>The risk: <strong>replay attacks</strong>. A network attacker who captures a 0-RTT packet can resend it to your server, causing the same request to be processed twice. For idempotent GET requests this is usually harmless. For POST requests (form submissions, API calls, purchases), a double-replay could cause duplicate operations.</p>

<p>Mitigations:</p>
<ul>
  <li>Use the <code>$ssl_early_data</code> NGINX variable to detect 0-RTT requests and add an <code>Early-Data: 1</code> header to upstream requests: your application can then reject non-idempotent 0-RTT requests</li>
  <li>Alternatively, disable 0-RTT for specific locations (e.g. payment endpoints) with a <code>ssl_early_data off</code> override inside that <code>location</code> block</li>
</ul>

<pre><code>location /api/ {
    # Pass 0-RTT indicator to backend
    proxy_set_header Early-Data $ssl_early_data;
    proxy_pass http://backend;
}</code></pre>

<h2 style="color:#f59e0b">HTTP/3 Behind a Load Balancer or CDN</h2>

<p>If NGINX is behind a load balancer or CDN, HTTP/3 setup is slightly different:</p>

<ul>
  <li><strong>CDN with HTTP/3 support (Cloudflare, Fastly):</strong> The CDN handles HTTP/3 to the client; your origin NGINX can stay on HTTP/1.1 or HTTP/2 for the CDN→origin connection. No QUIC needed on origin.</li>
  <li><strong>Layer-4 load balancer (TCP/UDP passthrough):</strong> You need UDP 443 forwarded to your backend NGINX. UDP passthrough: not just TCP, must be configured. HAProxy 2.6+, nginx stream module, and most cloud NLBs support this.</li>
  <li><strong>Layer-7 proxy terminating TLS:</strong> The L7 proxy needs HTTP/3 support to terminate QUIC. If the proxy doesn&#8217;t support QUIC, it can&#8217;t forward HTTP/3: the connection will fall back to HTTP/2.</li>
</ul>

<h2 style="color:#f59e0b">NGINX Configuration Tuning for HTTP/3</h2>

<pre><code>http {
    # QUIC-specific buffer tuning
    quic_gso on;                 # Enable generic segmentation offload for QUIC
    quic_host_key /etc/nginx/quic-host.key;  # Optional: QUIC connection ID encryption

    # For high-traffic servers: tune UDP socket buffers
    # (in /etc/sysctl.conf, not nginx.conf)
    # net.core.rmem_max = 16777216
    # net.core.wmem_max = 16777216
    # net.core.rmem_default = 4194304
    # net.core.wmem_default = 4194304
}</code></pre>

<p>For servers handling heavy HTTP/3 traffic (&gt;10k concurrent QUIC connections), also increase the UDP socket buffer limits in the OS. QUIC is more demanding of UDP buffers than TCP is of TCP buffers because packet pacing happens in userspace:</p>

<pre><code>echo "net.core.rmem_max=16777216" >> /etc/sysctl.conf
echo "net.core.wmem_max=16777216" >> /etc/sysctl.conf
sysctl -p</code></pre>

<h2 style="color:#f59e0b">HTTP/3 with Angie</h2>

<p>The <a href="/angie-modules-optimized-extended/">myguard Angie packages</a> also include full HTTP/3 support. The configuration syntax is identical to NGINX, same <code>listen 443 quic reuseport</code>, same <code>http3 on</code>, same <code>Alt-Svc</code> header. If you want HTTP/3 plus native ACME (Let&#8217;s Encrypt without Certbot) in the same package, Angie is the cleaner choice.</p>

<pre><code># Angie installation
apt-get install angie

# Same HTTP/3 config works unchanged
angie -t && systemctl reload angie</code></pre>

<h2 style="color:#f59e0b">Common Issues</h2>
<div class="faq">
  <div class="faq-item"><div class="faq-q">HTTP/3 isn&rsquo;t showing in Chrome DevTools</div><div class="faq-a">On the first visit, browsers use HTTP/2 and learn about HTTP/3 from the <code>Alt-Svc</code> header. Reload the page, the second visit should use HTTP/3. If it still doesn&#8217;t, check that UDP 443 is actually open with <code>nmap -sU -p 443 your-server-ip</code>. Also check that the <code>Alt-Svc</code> header is actually being sent: <code>curl -I https://example.com | grep alt-svc</code></div></div>
  <div class="faq-item"><div class="faq-q">nginx -V doesn&rsquo;t show http_v3_module</div><div class="faq-a">You&rsquo;re running the Debian/Ubuntu official NGINX package, not the myguard build. Run <code>apt-cache policy nginx</code> to check which version is installed. The myguard version should show <code>deb.myguard.nl</code> as the source. Install it with the steps in Step 1 above.</div></div>
  <div class="faq-item"><div class="faq-q">ERR_QUIC_PROTOCOL_ERROR in Chrome</div><div class="faq-a">Usually a TLS issue. HTTP/3 requires TLS 1.3, make sure <code>ssl_protocols TLSv1.3;</code> is set (or at minimum <code>TLSv1.2 TLSv1.3</code>). Also verify your certificate is valid and not expired with <code>openssl s_client -connect example.com:443 -tls1_3</code>.</div></div>
  <div class="faq-item"><div class="faq-q">Can I use HTTP/3 with Angie instead of NGINX?</div><div class="faq-a">Yes. The myguard Angie packages include HTTP/3 support. The configuration is identical, same directives, same <code>listen 443 quic reuseport</code> syntax. Angie adds native ACME (no Certbot) and a JSON status API on top.</div></div>
  <div class="faq-item"><div class="faq-q">Is 0-RTT safe for API endpoints?</div><div class="faq-a">For read-only (GET) endpoints, yes. For write operations (POST, PUT, DELETE), you should either disable 0-RTT for those locations or check the <code>$ssl_early_data</code> variable and pass an <code>Early-Data: 1</code> header to your backend so it can reject replayed write requests.</div></div>
  <div class="faq-item"><div class="faq-q">Does HTTP/3 work behind Cloudflare?</div><div class="faq-a">Yes, but Cloudflare terminates HTTP/3 at their edge. The Cloudflare→origin connection uses HTTP/2 or HTTP/1.1. You don&#8217;t need to enable HTTP/3 on your origin server if Cloudflare is proxying traffic; Cloudflare handles the QUIC negotiation with browsers on your behalf.</div></div>
  <div class="faq-item"><div class="faq-q">Why does Firefox show H2 even after enabling HTTP/3?</div><div class="faq-a">Firefox may need HTTP/3 explicitly allowed in about:config (network.http.http3.enabled = true). It&#8217;s enabled by default in modern Firefox, but some corporate or managed Firefox installs have it disabled. Also, Firefox won&#8217;t use HTTP/3 if the certificate has any validation issues.</div></div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>
<ul>
<li><a href="/nginx-modules/">NGINX modules overview</a>: all 50+ dynamic modules available via APT, including the QUIC-related ones</li>
<li><a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx: A Dedicated OpenSSL Build for NGINX and Angie</a>: why a separate OpenSSL is needed for QUIC and what it enables</li>
<li><a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS Configuration for NGINX and Angie</a>: complete TLS hardening guide to pair with HTTP/3, including 0-RTT considerations</li>
<li><a href="/angie-modules-optimized-extended/">Angie modules overview</a>: if you want HTTP/3 with native ACME, Angie is worth a look</li>
<li><a href="/how-to-use/">How to add the myguard APT repository</a>: two-minute setup guide</li>
</ul>

<p><!-- seo-orphan-link --> Deeper tuning: <a href="/2026/05/http3-quic-nginx-setup-tuning-gotchas-2026/">HTTP/3 and QUIC on NGINX: Real-World Setup, Tuning and Gotchas (2026)</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>OpenSSL 4.0 for NGINX: Upgrading openssl-nginx from 3.x to 4.0 — What Changes and Why It Matters</title>
		<link>https://deb.myguard.nl/2026/05/openssl-4-nginx-upgrade-openssl-nginx-3-to-4/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Mon, 11 May 2026 23:44:35 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[Packages]]></category>
		<category><![CDATA[angie]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[migration]]></category>
		<category><![CDATA[openssl]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[tls]]></category>
		<category><![CDATA[upgrade]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/openssl-4-nginx-upgrade-openssl-nginx-3-to-4/</guid>

					<description><![CDATA[We just upgraded our openssl-nginx package from OpenSSL 3.x to OpenSSL 4.0. This guide explains what openssl-nginx is, what changed in version 4.0, the real pros and cons of upgrading, and how to do it safely on your Debian or Ubuntu server.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The <a href="https://openssl-library.org/post/2025-openssl-4.0/" rel="noopener" target="_blank">OpenSSL 4.0</a> release is the first major version bump in years, and the <strong>OpenSSL 4.0 NGINX upgrade</strong> is the kind of change you want to do on a quiet Tuesday, not at 3 a.m. during an incident. If you run a web server, even a small one, OpenSSL is quietly working behind the scenes every single second. It&#8217;s the library that encrypts your visitors&#8217; connections, validates your HTTPS certificate, and makes the little padlock in the browser actually mean something.</p>



<p class="wp-block-paragraph">Most people never think about it. It just works. Until it doesn&#8217;t, or until a major new version ships and you have to decide: <strong>do I upgrade, and what does that actually mean for my server?</strong></p>



<p class="wp-block-paragraph">We just upgraded our <a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx package</a>, our dedicated, stripped-down build of OpenSSL made specifically for NGINX and Angie, from <strong>OpenSSL 3.x</strong> to <strong>OpenSSL 4.0</strong>. This post explains what that means, why we did it, what you gain, what you give up, and how to upgrade safely. No PhD in cryptography required.</p>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img loading="lazy" decoding="async" width="1920" height="1536" src="https://deb.myguard.nl/wp-content/uploads/2026/05/Internet_Security_Padlock_for_VPN_26_Online_Privacy.jpg" alt="OpenSSL 4.0 NGINX upgrade, openssl-nginx dedicated OpenSSL build security padlock" class="wp-image-5456" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/Internet_Security_Padlock_for_VPN_26_Online_Privacy.jpg 1920w, https://deb.myguard.nl/wp-content/uploads/2026/05/Internet_Security_Padlock_for_VPN_26_Online_Privacy-300x240.jpg 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/Internet_Security_Padlock_for_VPN_26_Online_Privacy-1024x819.jpg 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/Internet_Security_Padlock_for_VPN_26_Online_Privacy-768x614.jpg 768w, https://deb.myguard.nl/wp-content/uploads/2026/05/Internet_Security_Padlock_for_VPN_26_Online_Privacy-1536x1229.jpg 1536w" sizes="auto, (max-width: 1920px) 100vw, 1920px" /><figcaption class="wp-element-caption">openssl-nginx 4.0 brings post-quantum cryptography, a cleaner API, and a smaller attack surface to your web server</figcaption></figure>
</div>

<h2 style="color:#f59e0b">First: What Is openssl-nginx?</h2>


<p class="wp-block-paragraph">Your Linux server already has OpenSSL installed. It&#8217;s used by <em>everything</em>, SSH, your mail server, Python scripts, package managers. That&#8217;s great for general-purpose use. But it also means the system OpenSSL has to be conservative: it can&#8217;t be optimized for one specific thing, it can&#8217;t drop legacy support that other apps depend on, and it can&#8217;t be updated freely without risking breaking unrelated software.</p>



<p class="wp-block-paragraph"><strong>openssl-nginx</strong> is exactly that. It&#8217;s a separate OpenSSL build that lives alongside your system OpenSSL without touching it. NGINX and Angie link against this dedicated build, and your system stays completely unaffected. Think of it like giving your web server its own private engine, the car still uses the road like everything else, but under the hood it&#8217;s tuned differently.</p>



<p class="wp-block-paragraph">Read more: <a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx: The Dedicated OpenSSL Built Just for NGINX and Angie</a>.</p>


<h2 style="color:#f59e0b">OpenSSL 4.0, What Actually Changed?</h2>


<p class="wp-block-paragraph">OpenSSL 4.0 is a major version bump, and major version bumps mean two things: new capabilities and breaking changes. Let&#8217;s look at both.</p>


<h3>What&#8217;s New in OpenSSL 4.0</h3>


<ul class="wp-block-list">
<li><strong>Post-Quantum Cryptography (PQC) built-in</strong>: ML-KEM and related post-quantum algorithms are now first-class citizens. No bolt-ons, no experimental flags.</li>
<li><strong>Cleaner API</strong>: Years of deprecated, dusty old functions have been removed. Smaller, easier to audit, less likely to contain bugs from 1998.</li>
<li><strong>Removed legacy algorithms</strong>: MD4, RC2, RC4, DES, Blowfish gone. If you&#8217;re not using them (and you shouldn&#8217;t be), this is a good thing.</li>
<li><strong>Better TLS 1.3 internals</strong>: Session resumption, 0-RTT, and handshake performance refined under the hood.</li>
<li><strong>Smaller binary footprint</strong>: Less code means faster load times, less memory, and a smaller attack surface.</li>
</ul>


<h3>What Was Removed</h3>


<ul class="wp-block-list">
<li><strong>SSLv2 / SSLv3 support</strong>: Already disabled in 3.x, now fully gone from the codebase.</li>
<li><strong>Many deprecated API calls</strong>: Custom <a href="/nginx-modules/">NGINX modules</a> built against older OpenSSL headers may need updating.</li>
<li><strong>Certain legacy cipher suites</strong>: RC4-based and export ciphers are history.</li>
<li><strong>no-ssl2, no-ssl3, no-capieng build flags</strong>: These compile-time flags no longer exist; the features are simply absent.</li>
</ul>


<h2 style="color:#f59e0b">The Pros: Why Upgrading to OpenSSL 4.0 Is Worth It</h2>


<ul class="wp-block-list">
<li><strong>Future-proof security</strong>: Post-quantum cryptography is no longer a buzzword. Chrome has been doing hybrid ML-KEM key exchange since 2024. With OpenSSL 4.0, your NGINX server can natively participate without patches.</li>
<li><strong>Smaller and faster</strong>: Removing dead code means a smaller binary, faster load, and less memory usage. On high-traffic servers handling thousands of TLS handshakes per second, this matters.</li>
<li><strong>Smaller attack surface</strong>: Removing MD4, RC2, Blowfish means fewer places for vulnerabilities to hide.</li>
<li><strong>Long-term support target</strong>: OpenSSL 3.x LTS support ends. The project is clearly moving to 4.x. Upgrading now, while things are stable, is better than scrambling when a critical CVE drops.</li>
<li><strong>Better TLS 1.3 performance</strong>: The internal TLS 1.3 engine has been refined. Session resumption is cleaner, 0-RTT is better handled.</li>
</ul>


<h2 style="color:#f59e0b">The Cons: What to Watch Out For</h2>


<ul class="wp-block-list">
<li><strong>Breaking API changes</strong>: Custom NGINX modules using OpenSSL&#8217;s C API directly may need updates. Standard modules in the myguard repository are already updated.</li>
<li><strong>Legacy cipher suites gone</strong>: TLS 1.0, RC4, export-grade ciphers are completely unavailable. For virtually all modern deployments this is fine.</li>
<li><strong>Newer codebase, less battle-tested</strong>: OpenSSL 3.x has years of production mileage. OpenSSL 4.0 is newer. If you run critical financial or healthcare systems, waiting a quarter before adopting is reasonable.</li>
</ul>


<h2 style="color:#f59e0b">The OpenSSL 4.0 NGINX upgrade: step by step</h2>


<pre class="wp-block-code"><code># Step 1 — Check your current version
dpkg -l openssl-nginx
nginx -V 2&gt;&amp;1 | grep -i openssl

# Step 2 — Update package lists
apt update

# Step 3 — Upgrade openssl-nginx (system OpenSSL is NOT touched)
apt install openssl-nginx

# Step 4 — Test config and reload
nginx -t &amp;&amp; systemctl reload nginx
# or: angie -t &amp;&amp; systemctl reload angie

# Step 5 — Verify
nginx -V 2&gt;&amp;1 | grep -i openssl
# Should show: OpenSSL 4.0.x (openssl-nginx)</code></pre>
<p style="font-size:13px;color:var(--muted);margin-top:0.5rem;">New to the myguard repository? <a href="/how-to-use/">Follow the setup guide</a> to add it in under a minute.</p>


<h2 style="color:#f59e0b">Who Should Upgrade, and Who Should Wait?</h2>


<p class="wp-block-paragraph">The OpenSSL 4.0 NGINX upgrade is an easy call for most people. <strong>Upgrade now</strong> if you run a standard modern web server (WordPress, static sites, APIs), use only TLS 1.2 and TLS 1.3, and want post-quantum TLS readiness.</p>



<p class="wp-block-paragraph"><strong>Wait and test first</strong> if you use custom-compiled NGINX modules linking against OpenSSL directly, need to support very old clients with TLS 1.0 or legacy ciphers, or run a FIPS-compliant environment that hasn&#8217;t validated the 4.0 provider yet.</p>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img loading="lazy" decoding="async" width="1200" height="800" src="https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-security-hardening.webp" alt="NGINX security with OpenSSL 4.0 openssl-nginx upgrade, hardened web server configuration" class="wp-image-5474" srcset="https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-security-hardening.webp 1200w, https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-security-hardening-300x200.webp 300w, https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-security-hardening-1024x683.webp 1024w, https://deb.myguard.nl/wp-content/uploads/2026/05/nginx-security-hardening-768x512.webp 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /><figcaption class="wp-element-caption">OpenSSL 4.0 in openssl-nginx: post-quantum ready, legacy-free, smaller attack surface</figcaption></figure>
</div>

<h2 style="color:#f59e0b">Frequently Asked Questions</h2>

<div id="rank-math-faq" class="rank-math-block">
<div class="rank-math-list ">
<div id="rm-faq-1" class="rank-math-list-item">
<h3 class="rank-math-question ">Will upgrading openssl-nginx break my system OpenSSL?</h3>
<div class="rank-math-answer ">

<p>No. openssl-nginx is a completely separate installation that lives in its own directory. It does not replace or interfere with the system OpenSSL package. SSH, mail, Python, and everything else on your server continues to use the system OpenSSL unchanged.</p>

</div>
</div>
<div id="rm-faq-2" class="rank-math-list-item">
<h3 class="rank-math-question ">Do I need to change my NGINX configuration after upgrading to OpenSSL 4.0?</h3>
<div class="rank-math-answer ">

<p>In most cases, no. If you&#8217;re already using TLS 1.2 and TLS 1.3 with modern cipher suites, your config works as-is. The only change needed is if your config references legacy ciphers (RC4, DES, export-grade), those should be removed.</p>

</div>
</div>
<div id="rm-faq-3" class="rank-math-list-item">
<h3 class="rank-math-question ">What is the difference between system OpenSSL and openssl-nginx?</h3>
<div class="rank-math-answer ">

<p>System OpenSSL (libssl3 on Debian/Ubuntu) is used by many applications and must stay conservative. openssl-nginx is a separate, purpose-built OpenSSL compiled with specific flags for web server use, no legacy ciphers, optimised for TLS performance, linked only by NGINX and Angie.</p>

</div>
</div>
<div id="rm-faq-4" class="rank-math-list-item">
<h3 class="rank-math-question ">Is OpenSSL 4.0 ready for production use?</h3>
<div class="rank-math-answer ">

<p>Yes, for standard web server workloads. OpenSSL 4.0 is a stable release. The main consideration is that 3.x has more production mileage. If you&#8217;re risk-averse with a critical system, waiting a few months is reasonable. For most servers, upgrading now is fine.</p>

</div>
</div>
<div id="rm-faq-5" class="rank-math-list-item">
<h3 class="rank-math-question ">Does OpenSSL 4.0 support post-quantum cryptography?</h3>
<div class="rank-math-answer ">

<p>Yes, this is one of the headline features. ML-KEM and other NIST-standardized post-quantum algorithms are built in. Combined with NGINX or Angie, you can configure hybrid TLS that protects connections against both classical and quantum computer attacks.</p>

</div>
</div>
<div id="rm-faq-6" class="rank-math-list-item">
<h3 class="rank-math-question ">Can I downgrade openssl-nginx if something breaks?</h3>
<div class="rank-math-answer ">

<p>Yes. Because openssl-nginx is a separate package, you can install the previous version from the myguard repository with apt install openssl-nginx=3.x.x. Use apt-cache policy openssl-nginx to see available versions, then reload NGINX or Angie.</p>

</div>
</div>
</div>
</div>

<h2 style="color:#f59e0b">Related Posts</h2>


<ul class="wp-block-list">
<li><a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx: The Dedicated OpenSSL Built Just for NGINX and Angie</a>: the original introduction to openssl-nginx</li>
<li><a href="/2026/05/post-quantum-cryptography-nginx-angie-ml-kem-hybrid-tls/">Post-Quantum Cryptography with NGINX and Angie: ML-KEM and Hybrid TLS</a>: configuring quantum-resistant TLS today</li>
<li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">How to Optimize NGINX and Angie for Maximum Performance and Security</a>: the comprehensive TLS and performance guide</li>
<li><a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS Configuration for NGINX: Getting A+ on SSL Labs</a>: the TLS config that pairs with openssl-nginx 4.0</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>zstd-nginx-module: What Broke, What We Fixed, and Why It Matters</title>
		<link>https://deb.myguard.nl/2026/05/zstd-nginx-module-what-it-does-bugs-fixed/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sun, 10 May 2026 02:54:35 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[buffer-overflow]]></category>
		<category><![CDATA[bug-fix]]></category>
		<category><![CDATA[code-audit]]></category>
		<category><![CDATA[compression]]></category>
		<category><![CDATA[nginx-module]]></category>
		<category><![CDATA[open-source]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[zstd]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/we-audited-the-zstd-nginx-module-and-found-a-lot-of-bugs/</guid>

					<description><![CDATA[The first audit found 22 issues, but the last two weeks of git history added 14 more issue-level fixes. This updated guide covers the full 36-issue fork-window story, the runtime and build bugs, and the CI tests now guarding the module.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Picture a brand-new sports car that the factory shipped with the brakes wired backwards, a fuel gauge that lies, and a glovebox that occasionally explodes. The engine? Genuinely brilliant. That is the <strong>zstd-nginx-module</strong> in a nutshell, the component that gives <strong>NGINX</strong> and <strong>Angie</strong> real <strong>Zstandard (zstd) compression</strong>. The core idea is fantastic. The original implementation had a frankly alarming number of sharp edges. Over a two-week fork window we found, fixed, and tested <strong>36 distinct issues</strong>, layered on a pile of performance wins, and wrapped the whole thing in a serious automated test pipeline. This is the full, numbered story.</p>



<p class="wp-block-paragraph">Short version for the impatient: the module is fast, useful, and absolutely worth running, <em>but only a build that includes these fixes</em>. A stock copy from two weeks ago will happily truncate your CSS, peg a CPU core at 100%, or hand a visitor a half-downloaded JavaScript file. Let&#8217;s walk through what was broken, what we did about it, and why you can now trust it.</p>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-nginx-module-compression-pipeline.webp" alt="zstd-nginx-module compression pipeline diagram for NGINX and Angie"/><figcaption class="wp-element-caption">The zstd-nginx-module sits in NGINX&#8217;s filter chain, compressing responses on the fly.</figcaption></figure>
</div>


<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">What the zstd-nginx-module Actually Does</h2>



<p class="wp-block-paragraph">Think of this module as a vacuum-seal machine for web traffic. Your server constantly ships HTML, CSS, JavaScript, and JSON across the internet. The smaller that payload, the less bandwidth you burn and the faster the browser starts doing useful work. Zstd (created at Facebook/Meta) is the modern compression format that hits a sweet spot gzip can&#8217;t: similar or better ratios at dramatically higher speed. The <strong>zstd-nginx-module</strong> adds two abilities to NGINX and Angie:</p>



<ul class="wp-block-list">
<li><strong>Dynamic zstd compression</strong>: compresses responses on the fly. Perfect for PHP pages, API responses, and anything generated per-request.</li>
<li><strong>Static zstd serving</strong>: if a pre-compressed <code>file.css.zst</code> sits next to <code>file.css</code>, it ships the small one directly. Zero CPU at request time.</li>
</ul>



<p class="wp-block-paragraph">Great concept. Now here is everything that was wrong with the execution, numbered, grouped by what kind of pain each one caused, so the list actually tells a story instead of being a wall of commit hashes.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Group A, Data Integrity: The &#8220;Your Files Arrive Broken&#8221; Bugs</h2>



<p class="wp-block-paragraph">These are the scariest class. The server returns HTTP 200, the browser thinks everything is fine, and yet the file is silently incomplete or corrupt. No error, no warning, just a broken site.</p>



<ol class="wp-block-list">
<li><strong>Silent truncation at 131072 bytes.</strong> Any response larger than zstd&#8217;s internal stream buffer (<code>ZSTD_CStreamInSize</code>, exactly 128 KB) was chopped off at that boundary. Big CSS bundles and JavaScript files simply ended mid-stream. This is the bug that makes a site look fine on the homepage and shatter the moment a real asset loads.</li>
<li><strong>Terminal frame never emitted on empty end.</strong> When the final compression call produced zero output bytes (it happens: the encoder had already flushed everything), the module forgot to write zstd&#8217;s end-of-frame marker. Decoders saw a stream that just <em>stopped</em> and rejected it.</li>
<li><strong>Truncation/abort with <code>proxy_buffering off</code> (the &#8220;bug B&#8221; saga).</strong> This was the production incident that took down <code>deb.myguard.nl</code>&#8216;s wp-admin. With unbuffered proxying (the FastCGI/PHP path), the upstream forces a flush around a chunk that zstd buffers internally. The directive logic never told libzstd to actually flush, so it sat on the bytes forever: manifesting as a truncated response <em>and</em> a worker spinning at 100% CPU in a livelock. The fix has two halves: map a pending flush to <code>ZSTD_e_flush</code>, and clear the flush state when the encoder reports it&#8217;s fully drained so the loop can terminate.</li>
<li><strong>NULL-deref / worker SIGSEGV on multi-buffer chunked responses.</strong> On any response big enough to need more than one output buffer (chunked or no-Content-Length is the common trigger), a recycled buffer pointer was dereferenced after being invalidated: a clean worker segfault that only appeared past the first ~128 KB.</li>
<li><strong>100% CPU infinite loop.</strong> A separate flush state-machine defect (PR #23 + #49 class) where the inner compression loop never reached a termination condition, pinning a core until the request timed out.</li>
</ol>



<p class="wp-block-paragraph">Every single one of these now has a fail-first regression test, a test proven to fail on the buggy code and pass only on the fix. More on that below.</p>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-nginx-module-128kb-truncation-bug.webp" alt="zstd-nginx-module 128 KB silent truncation bug before and after the fix"/><figcaption class="wp-element-caption">Issue #1: before the fix, responses over 128 KB were silently cut off with no error.</figcaption></figure>
</div>


<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Group B, Memory and Lifetime: The &#8220;Slow Death&#8221; Bugs</h2>



<p class="wp-block-paragraph">These don&#8217;t crash immediately. They leak, corrupt, or rot over hours and reloads, the kind of bug that pages you at 3 a.m. with &#8220;the server got slow and then fell over.&#8221;</p>



<ol class="wp-block-list" start="6">
<li><strong>ZSTD_CDict leaked on every config reload.</strong> If you used a compression dictionary, every <code>nginx -s reload</code> leaked the entire dictionary. On a busy box that reloads config often, that&#8217;s a steady climb to OOM.</li>
<li><strong>Cleanup handler registered with the wrong size.</strong> The dictionary cleanup used a non-zero size in <code>ngx_pool_cleanup_add()</code>, which subtly mismanaged the cleanup bookkeeping. Fixed to pass <code>size=0</code> as the API intends.</li>
<li><strong>Shared compression context across requests.</strong> The original reused one zstd context worker-wide. Overlapping requests in a single worker could stomp each other&#8217;s compression state: intermittent corruption that&#8217;s almost impossible to reproduce on demand. Now every request gets its own context, attached to the request&#8217;s cleanup chain.</li>
<li><strong>Version-specific dictionary init error handling.</strong> On older libzstd, the dictionary init path used a different API whose error return wasn&#8217;t checked correctly, so a failed init could proceed as if it had succeeded.</li>
</ol>



<p class="wp-block-paragraph">This whole group is now guarded by a reload-under-load regression test that hammers SIGHUP while traffic flows, plus the new monthly valgrind soak (details further down).</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Group C, Security and Hardening: The &#8220;Don&#8217;t Get Owned&#8221; Fixes</h2>



<ol class="wp-block-list" start="10">
<li><strong>Buffer overflow appending the <code>.zst</code> extension.</strong> The static module built the <code>.zst</code> path without reserving enough room, so a long enough request URI could write past the buffer. Classic overflow, now bounded and regression-tested with a 2000-character URI.</li>
<li><strong>Integer overflow in the compression-ratio calculation.</strong> The <code>$zstd_ratio</code> math could overflow on large responses, producing garbage stats. Reworked with 64-bit scaling.</li>
<li><strong>Reject oversized numeric directives (INT_MAX hardening).</strong> <code>zstd_window_log</code> and <code>zstd_target_cblock_size</code> are narrowed to <code>int</code> before being handed to libzstd. A configured value above <code>INT_MAX</code> would silently truncate (possibly to a negative). Now rejected at config load with a clear error.</li>
<li><strong>Dictionary-file size cap.</strong> A 10 MB ceiling on <code>zstd_dict_file</code> prevents a memory-exhaustion denial of service via a giant dictionary.</li>
<li><strong>BREACH containment lever (<code>zstd_bypass</code>).</strong> A new per-request opt-out so operators can serve identity (uncompressed) on endpoints that mix a secret with attacker-influenced reflected input: the precondition for a BREACH-style attack, without splitting the location config.</li>
<li><strong>Unbounded input cap for chunked responses.</strong> A streaming response has no Content-Length, so the size check was skipped and a runaway upstream could feed the compressor forever. <code>zstd_max_length</code> is now enforced against the running input total too.</li>
</ol>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/zstd-nginx-module-ci-pipeline-tests.webp" alt="zstd-nginx-module CI pipeline running regression and fuzzing tests"/><figcaption class="wp-element-caption">Every fix is now locked in by an automated test that fails on the old code.</figcaption></figure>
</div>


<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Group D, Build, Linking, and Portability</h2>



<p class="wp-block-paragraph">A correct module that won&#8217;t compile on your distro is still a broken module. This group is about actually being able to build and ship it everywhere.</p>



<ol class="wp-block-list" start="16">
<li><strong>The <code>-fPIC</code> linking failure.</strong> The config preferred a static <code>libzstd.a</code> that lacks position-independent code and cannot link into a shared dynamic module. Now prefers dynamic linking with a pkg-config fallback.</li>
<li><strong>Global CFLAGS mutation.</strong> The static module&#8217;s config leaked compiler flags into the global build, polluting other modules. Removed.</li>
<li><strong>Cross-platform portability.</strong> Build logic hardened for Linux, the BSDs, RHEL/Rocky, and Gentoo: different pkg-config tools, different library locations.</li>
<li><strong>Cross-compilation support.</strong> Set <code>ngx_feature_run=no</code> so the feature probe doesn&#8217;t try to <em>execute</em> a test binary (impossible when cross-compiling).</li>
<li><strong>Doubled source path.</strong> A bug in the addon config concatenated the source directory twice; fixed by scoping <code>ngx_addon_dir</code> per sub-config.</li>
<li><strong>Typo: <code>SAVED_CC_TAST_FLAGS</code>.</strong> A real, shipped typo that silently failed to restore test flags. One character; genuinely broke the build path.</li>
<li><strong>Filter ordering vs. brotli.</strong> If both zstd and brotli are enabled, zstd must run first or the ordering produces wrong results. Now enforced.</li>
<li><strong>RPATH embedding documented</strong> for custom <code>ZSTD_LIB</code> paths, plus advisories when building against libzstd older than 1.4.0 or 1.5.0 (missing APIs).</li>
<li><strong>Non-redistributable test fixture replaced</strong> with a BSD-2-Clause original, so the whole repo is cleanly licensed.</li>
</ol>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Group E, HTTP Correctness and Behavior</h2>



<p class="wp-block-paragraph">These are the &#8220;technically the spec says…&#8221; bugs. Each one is a small wrongness that breaks caches, proxies, or clients in ways that are maddening to debug.</p>



<ol class="wp-block-list" start="25">
<li><strong>Accept-Encoding parsing was wrong.</strong> The header matcher could mis-detect <code>zstd</code> as a substring of another token. Rewritten to tokenize properly.</li>
<li><strong>Quality values (<code>q=</code>) ignored.</strong> <code>Accept-Encoding: zstd;q=0</code> means &#8220;do NOT send me zstd.&#8221; The module compressed anyway. Now it honors RFC 7231 qvalues, including the strict 0–1 range.</li>
<li><strong>Content-Type detection used the wrong filename</strong> in the static module, so <code>.zst</code> files could get the wrong MIME type. Fixed to use the original (pre-<code>.zst</code>) name.</li>
<li><strong>Only 200/403/404 were compressed.</strong> Other perfectly compressible 2xx responses (201, 206-not-applicable aside) were skipped. Broadened correctly.</li>
<li><strong>204 and 205 were compressed.</strong> Bodyless responses must not carry a Content-Encoding. Now excluded.</li>
<li><strong>Accept-Ranges not cleared on compressed static files.</strong> Byte ranges into a <code>.zst</code> file are meaningless (offsets don&#8217;t map to the original). Now cleared per RFC 9110.</li>
<li><strong>Default compression level changed 1 → 3.</strong> Level 1 left obvious ratio on the table for negligible CPU savings; 3 is the sane default.</li>
<li><strong>Missing <code>gzip_vary</code> warnings.</strong> If zstd is enabled but <code>gzip_vary</code> is off, proxies and CDNs can cache a compressed response and serve it to a client that can&#8217;t read it. Both the filter <em>and</em> static modules now warn loudly at config load. (We added the static-module half this week.)</li>
<li><strong>Two no-op / unreachable code blocks</strong> removed during audit, plus a cluster of eight smaller correctness fixes batched from the first full codebase audit (dead code, deduplication, minor bugs).</li>
<li><strong>Skip-reason and diagnostic debug logging.</strong> &#8220;Why isn&#8217;t my response compressed?&#8221; was previously undiagnosable without a rebuild. Every eligibility-rejection path, the buffer-acquire decision, and the compression-context setup now emit structured debug logs (zero cost in release builds, visible under <code>error_log debug</code>). The permanent emit-decision probe for the truncation bug class is part of this group too.</li>
</ol>



<p class="wp-block-paragraph">That&#8217;s <strong>34 numbered entries covering 36 underlying issues</strong> (a couple of entries bundle tightly-related fixes that landed together). Critical data-loss bugs, slow memory death, real security holes, build breakage, and a long tail of HTTP correctness. The engine was always good. This is what it took to make the rest of the car safe to drive.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The Optimizations That Survived Contact with Reality</h2>



<p class="wp-block-paragraph">Fixing bugs is half the story. The other half is making it genuinely fast and operable. These are the performance and capability wins we added on top:</p>



<ul class="wp-block-list">
<li><strong>Per-request context reuse done right.</strong> Each request gets its own zstd context, but the underlying allocation is reused across requests in a worker via reset: correctness <em>and</em> speed.</li>
<li><strong>Modern single-call streaming API.</strong> Replaced three legacy streaming calls and a deprecated init API with one <code>ZSTD_compressStream2()</code>, and dropped a custom allocator that added nothing.</li>
<li><strong>Pledged source size.</strong> When Content-Length is known, the exact size is pledged to zstd up front, producing a more compact frame header and slightly better speed and ratio at zero cost.</li>
<li><strong>Output buffers default to <code>ZSTD_CStreamOutSize()</code>.</strong> The encoder&#8217;s own recommended granularity (~128 KB), so zstd never has to fragment a block across calls. Two buffers, so one fills while the other is in flight.</li>
<li><strong>Hot-path loc-conf hoist.</strong> The location config is resolved once per request instead of once per inner-loop iteration: measurable on large responses.</li>
<li><strong>Single-division ratio math</strong> for <code>$zstd_ratio</code> instead of dividing twice.</li>
<li><strong>Skip a redundant loop iteration</strong> for an empty terminal buffer in the add-data path.</li>
<li><strong>New operator capabilities:</strong> <code>zstd_max_length</code> (cap huge responses), <code>zstd_window_log</code> (bound per-request memory: predictable RSS under load), <code>zstd_long</code> (long-distance matching for big repetitive bodies), <code>zstd_bypass</code> (the BREACH lever), and <code>$zstd_bytes_in</code> / <code>$zstd_bytes_out</code> log variables for real observability.</li>
</ul>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">What the CI Tests Look Like Now</h2>



<p class="wp-block-paragraph">A fix without a test is a suggestion. Here&#8217;s the automated safety net that now stands between these bugs and your production server. Every fix above is locked in by something in this pipeline.</p>



<h3 class="wp-block-heading">1. Build &amp; Test workflow</h3>



<p class="wp-block-paragraph">A build matrix compiles the module against mainline NGINX <em>and</em> Angie, with a full debug build (<code>--with-debug -g3 -O0</code>), strict warnings as errors (<code>-Wall -Wextra -Wshadow -Werror</code> and more), then runs the Perl test suite (44 filter tests, 19 static tests) plus a fleet of Python regression tools: proxy-unbuffered truncation, concurrent context isolation, reload-under-load, terminal-frame, the <code>zstd_long</code> long-distance-matching ratio test, and more. Each truncation/CPU/leak bug from Groups A and B has a dedicated fail-first test here.</p>



<h3 class="wp-block-heading">2. ASAN + UBSAN build and soak</h3>



<p class="wp-block-paragraph">A second build with AddressSanitizer and UndefinedBehaviorSanitizer runs the same suite plus a 10-minute mixed-traffic soak. This is what catches the lifetime and use-after-free class (Group B) under realistic concurrent load.</p>



<h3 class="wp-block-heading">3. Monthly valgrind Memcheck soak (new this week)</h3>



<p class="wp-block-paragraph">ASAN is fast and great, but valgrind&#8217;s Memcheck catches one extra class ASAN can&#8217;t: use of <em>uninitialised</em> values, with <code>--track-origins</code> pointing at exactly where the bad bit came from. Crucial for a compressor doing pointer arithmetic on buffer boundaries. A full Memcheck soak is 20–50× slower than native, so it runs on a monthly cron (not every push) against the debug build, with the repo&#8217;s suppression file filtering known NGINX-internal noise. The first local run was spotless: <strong>0 bytes definitely/indirectly/possibly lost, 0 errors</strong> across roughly 16,000 allocations and 2.5 GB of data churned through the compress/flush/multi-buffer lifecycle.</p>



<h3 class="wp-block-heading">4. CodeQL, security scanners, and fuzzing</h3>



<p class="wp-block-paragraph">CodeQL static analysis, a security-scanner pass, and a libFuzzer target that throws malformed input at the parsing paths. The Accept-Encoding parser and the <code>.zst</code> path builder (Groups C and E) get fuzzed continuously so a future regression in that overflow-prone code surfaces fast.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">How to Install the Fixed Module</h2>



<p class="wp-block-paragraph">The fixed module ships in the Angie and NGINX packages on this repository. If you&#8217;re running our Angie build, you already have it. A sane starting configuration:</p>



<pre class="wp-block-code"><code>zstd on;
zstd_comp_level 3;
zstd_min_length 256;
zstd_types text/plain text/css application/json
           application/javascript text/xml application/xml
           image/svg+xml;
gzip_vary on;   # do not skip this — caches need it</code></pre>



<p class="wp-block-paragraph">That&#8217;s the &#8220;set and forget&#8221; baseline: good ratio, low CPU, correct caching behavior. Turn on <code>zstd_long</code> only if you serve large, internally repetitive bodies, and set <code>zstd_window_log</code> if you need a hard memory ceiling per request.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Why This Matters</h2>



<p class="wp-block-paragraph">Compression sits in the path of <em>every single response</em>. A bug here isn&#8217;t cosmetic, it&#8217;s a silently broken site, a leaked megabyte per reload, or a worker stuck at 100% CPU while real users wait. The upstream idea is excellent and zstd genuinely is the future of web compression. But &#8220;the idea is good&#8221; and &#8220;safe to run in production&#8221; are different claims. The 36 issues, the optimizations, and the test pipeline above are the bridge between them. Run a build with these fixes, keep <code>gzip_vary on</code>, and zstd compression on NGINX or Angie is fast, correct, and boring, exactly what infrastructure should be.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Frequently Asked Questions</h2>



<h3 class="wp-block-heading">What is zstd compression and why would I use it on NGINX?</h3>



<p class="wp-block-paragraph">Zstd (Zstandard) is a modern compression format from Meta that delivers gzip-class or better ratios at far higher speed. On NGINX or Angie it means smaller responses, lower bandwidth bills, and faster page loads, especially for text, JSON, and API traffic. Modern browsers increasingly support the <code>zstd</code> content encoding.</p>



<h3 class="wp-block-heading">Does this replace gzip completely?</h3>



<p class="wp-block-paragraph">Not yet. Keep gzip (or brotli) enabled as a fallback for clients that don&#8217;t advertise zstd support. NGINX serves whichever the client accepts, with zstd preferred when available. That&#8217;s exactly why <code>gzip_vary on</code> is mandatory, caches must key on the encoding.</p>



<h3 class="wp-block-heading">How serious are these 36 issues overall?</h3>



<p class="wp-block-paragraph">Mixed, but the top of the list is genuinely severe. The 128 KB truncation, the proxy-buffering-off truncation/livelock, the multi-buffer SIGSEGV, and the CDict leak are all production-breaking on their own. The rest range from real security hardening down to HTTP-spec correctness. None of them are theoretical, several were caught <em>because</em> a real site broke.</p>



<h3 class="wp-block-heading">What is the single most important fix in the list?</h3>



<p class="wp-block-paragraph">Issue #1 (the 128 KB silent truncation) for sheer blast radius, it breaks any non-trivial asset with no error, closely followed by issue #3 (the <code>proxy_buffering off</code> truncation and 100% CPU livelock), which is the one that actually took down a live wp-admin and is now covered by a fail-first regression test.</p>



<h3 class="wp-block-heading">Is the CI enough to trust this in production?</h3>



<p class="wp-block-paragraph">It&#8217;s a strong net: matrix builds against NGINX and Angie, strict-warning compiles, a Perl + Python regression suite where every data-loss bug has a fail-first test, ASAN/UBSAN soak, monthly valgrind Memcheck, CodeQL, and fuzzing. Combined with the production deployment already running clean, yes, this is a trustworthy build. No test suite is infinite, but this one specifically targets every bug class that bit us.</p>



<h3 class="wp-block-heading">What settings should I start with?</h3>



<p class="wp-block-paragraph">The &#8220;set and forget&#8221; block above: <code>zstd on</code>, level 3, <code>zstd_min_length 256</code>, a sensible <code>zstd_types</code> list, and <code>gzip_vary on</code>. Only reach for <code>zstd_long</code>, <code>zstd_window_log</code>, or <code>zstd_bypass</code> when you have a specific reason, large repetitive bodies, a memory ceiling, or a BREACH-sensitive endpoint respectively.</p>



<h3 class="wp-block-heading">Where can I read more before enabling it?</h3>



<p class="wp-block-paragraph">Start with the zstd background article linked below for the format itself and browser support, then come back here for the operational reality. The two together give you the full picture: what zstd is, and what it took to make this module safe.</p>



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Related Posts</h2>



<ul class="wp-block-list">
<li><a href="/2026/05/what-is-zstd-nginx-angie-browser-support/">What Is Zstd? NGINX, Angie, History and Browser Support</a>: the background on the format itself: where zstd came from, which browsers and servers support it, and how it compares to gzip and brotli.</li>
<li><a href="/nginx-modules/">All NGINX guides on deb.myguard.nl</a>: configuration, modules, and performance tuning for NGINX and Angie.</li>
<li><a href="/2026/05/database-boost-free-wordpress-database-optimization-plugin/">Database Boost: Free WordPress Database Optimization Plugin</a>: another &#8220;make the slow thing fast and the broken thing safe&#8221; project from the same workbench.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Angie Web Server: The Complete Guide — Review, ACME, Migration, API and HTTP/3</title>
		<link>https://deb.myguard.nl/2026/05/angie-web-server-complete-guide/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sat, 09 May 2026 14:29:13 +0000</pubDate>
				<category><![CDATA[nginx]]></category>
		<category><![CDATA[acme]]></category>
		<category><![CDATA[angie]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[http3]]></category>
		<category><![CDATA[letsencrypt]]></category>
		<category><![CDATA[migration]]></category>
		<category><![CDATA[monitoring]]></category>
		<category><![CDATA[performance]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/2026/05/migrating-from-nginx-to-angie-the-laziest-upgrade-youll-ever-do/</guid>

					<description><![CDATA[Everything about Angie in one place: what it adds over NGINX (native ACME, JSON API, dynamic upstreams, monthly releases), how it performs, how to migrate from NGINX in five minutes, full ACME certificate setup, Prometheus monitoring, and a side-by-side comparison with NGINX Plus.]]></description>
										<content:encoded><![CDATA[
<p>In 2021, a group of engineers left F5 Networks, the company that had acquired NGINX Inc. in 2019. These weren’t peripheral contributors. They were the original NGINX core team: the people who wrote the event loop, designed the memory allocator, built the module system. They left because F5’s roadmap for NGINX was, diplomatically, not their vision. They forked the codebase and called it <strong>Angie</strong>. The Angie web server is that fork: NGINX&#8217;s DNA, run by the people who wrote it, with the features F5 never shipped. This is the complete Angie web server guide.</p>
<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/angie-web-server.webp" alt="Angie web server, the NGINX fork by the original core team" width="1024" height="576" loading="lazy"/></figure>

<p>That origin story matters. Angie isn’t a community fork with sporadic commits. It’s the continuation of NGINX development by the people who invented it, without corporate constraints. The result is a web server that is 100% backward-compatible with NGINX, every config file, every directive, every module works unchanged, but with features that F5 was either charging for (NGINX Plus) or not building at all.</p>

<p>This guide covers everything about Angie in one place: what it is, what it adds, how it performs, how to migrate from NGINX, how to set up native ACME certificates, how to use the monitoring API, and how to decide whether it’s right for you. The <a href="/how-to-use/">myguard APT repository</a> ships Angie for Debian and Ubuntu with all 50+ dynamic modules pre-built. The upstream project lives at <a href="https://angie.software/" rel="noopener" target="_blank">angie.software</a>.</p>



<h2 style="color:#f59e0b">What the Angie web server adds over NGINX, the full list</h2>
<p>These are the capabilities Angie has that stock NGINX mainline does not:</p>

<h3>1. Native ACME client (Let’s Encrypt without Certbot)</h3>
<p>This is the headline feature and the one that earns Angie the most converts. Angie has a full RFC 8555 ACME client built directly into the server. You declare certificates in <code>angie.conf</code>, Angie fetches them on first start, and renews them automatically before expiry. No Certbot. No cron jobs. No renewal hooks. No 2am emergency when a certificate silently expires.</p>
<pre><code>http {
    acme_client letsencrypt https://acme-v02.api.letsencrypt.org/directory
        email <span style="display:inline;" class="">a&#100;min&#64;examp&#108;&#101;.c&#111;m</span>;

    server {
        listen 443 ssl;
        http2 on;
        server_name example.com www.example.com;

        acme letsencrypt;
        ssl_certificate      $acme_cert_letsencrypt;
        ssl_certificate_key  $acme_cert_key_letsencrypt;
    }
}</code></pre>
<p>Reload Angie and the certificate is issued within seconds. Zero-downtime renewal happens automatically when fewer than 30 days remain. Multiple CAs are supported simultaneously, Let’s Encrypt and ZeroSSL in parallel, private internal CAs, staging environments.</p>

<h3>2. JSON status API with Prometheus endpoint</h3>
<p>NGINX’s built-in <code>stub_status</code> module returns five lines of plain text. Angie’s status API returns structured JSON with detailed per-server, per-upstream, and per-connection metrics. Add it to any server block:</p>
<pre><code>location /status {
    api /status/;
    allow 127.0.0.1;
    deny all;
}

location /metrics {
    api /metrics/prometheus;
    allow 127.0.0.1;
    deny all;
}</code></pre>
<p>The <code>/metrics/prometheus</code> endpoint returns Prometheus-format metrics, no third-party exporter needed. Plug it straight into your Grafana dashboard. The JSON API includes: active connections, requests/sec per server, upstream server health, SSL certificate expiry dates, response time percentiles, and bytes transferred.</p>

<h3>3. Dynamic upstream management (NGINX Plus feature, free in Angie)</h3>
<p>NGINX requires a full reload to add or remove upstream servers. NGINX Plus offers dynamic upstreams via its paid API. Angie offers this for free via its status API:</p>
<pre><code># Add an upstream server without reloading
curl -X POST http://127.0.0.1/status/upstreams/backend/servers 
  -H 'Content-Type: application/json' 
  -d '{"server": "192.168.1.100:8080"}'</code></pre>
<p>This is enormous for Kubernetes and container deployments where backend IPs change constantly. No reload means no brief traffic interruption during scaling events.</p>

<h3>4. Monthly release cadence</h3>
<p>NGINX mainline releases every three to four months. Angie releases roughly monthly. Security fixes ship faster. New features accumulate faster. The gap compounds over time.</p>

<h3>5. Extended upstream health checks</h3>
<p>Angie’s active health checks are more configurable than NGINX’s, check HTTP status codes, match response body patterns, set custom failure thresholds, all without a commercial licence. NGINX Pro/Plus features, available free.</p>

<h3>6. HTTP/3 and QUIC</h3>
<p>Same as the myguard NGINX build, full HTTP/3 support via <a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx</a> with QUIC patches. The configuration is identical to NGINX’s HTTP/3 setup.</p>



<h2 style="color:#f59e0b">Performance: Angie vs NGINX</h2>
<p>The honest answer: essentially identical. Angie and NGINX share the same core architecture, event-driven, asynchronous, single-threaded workers managing thousands of connections each. The performance comes from the design, not from either project’s additions on top of it.</p>

<table style="width:100%;border-collapse:collapse;margin:1rem 0;">
<thead><tr style="border-bottom:2px solid var(--border);"><th style="text-align:left;padding:0.5rem;">Metric</th><th style="text-align:right;padding:0.5rem;">NGINX 1.29 (myguard)</th><th style="text-align:right;padding:0.5rem;">Angie 1.11 (myguard)</th></tr></thead>
<tbody>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">Static file req/sec (500 conc.)</td><td style="text-align:right;padding:0.5rem;">~95,000</td><td style="text-align:right;padding:0.5rem;">~93,000</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">Latency (avg)</td><td style="text-align:right;padding:0.5rem;">5.2ms</td><td style="text-align:right;padding:0.5rem;">5.4ms</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">RSS memory (4 workers)</td><td style="text-align:right;padding:0.5rem;">~12MB</td><td style="text-align:right;padding:0.5rem;">~12MB</td></tr>
<tr><td style="padding:0.5rem;">TLS handshakes/sec</td><td style="text-align:right;padding:0.5rem;">~8,200</td><td style="text-align:right;padding:0.5rem;">~8,100</td></tr>
</tbody>
</table>
<p style="font-size:13px;color:var(--muted);">Tested with wrk on 4 vCPU/8GB, 1KB static file, both compiled with the same optimization flags via the myguard build system. Differences are within noise margin.</p>

<p>For PHP and proxy workloads the comparison is the same, the bottleneck is PHP-FPM or the upstream app, not the web server. Pick based on features, not benchmarks.</p>



<h2 style="color:#f59e0b">Who should use the Angie web server</h2>

<h3>Start with Angie if you’re setting up a new server</h3>
<p>For fresh deployments in 2026, Angie is the better default. Native ACME removes Certbot as a dependency. The JSON API means you get monitoring without installing a separate exporter. The monthly release cycle means security patches arrive faster. There’s no reason to start with NGINX and then add the complexity of Certbot when Angie handles it natively.</p>

<h3>Migrate from NGINX if you value simplicity</h3>
<p>If you’re currently running Certbot renewal timers, a stub_status scraper, and three shell scripts to manage certificate hooks, Angie replaces all of that with directives in <code>angie.conf</code>. The migration is five minutes and your config works unchanged.</p>

<h3>Stay on NGINX if you need these specific things</h3>
<ul>
<li>You use a module that hasn’t been ported to Angie yet (check the <a href="/angie-modules-optimized-extended/">Angie modules page</a>: most are available)</li>
<li>You have a specific vendor integration that hard-requires NGINX (rare but exists in enterprise)</li>
<li>Your organisation mandates specific software and hasn’t approved Angie yet</li>
</ul>
<p>In all other cases, Angie is a strict improvement on NGINX for new deployments.</p>



<h2 style="color:#f59e0b">Installation</h2>
<pre><code>wget https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb
dpkg -i myguard.deb
apt-get update
apt-get install angie</code></pre>
<p>Supported distributions: Debian 12 (Bookworm), Debian 13 (Trixie), Ubuntu 22.04 (Jammy), Ubuntu 24.04 (Noble), Ubuntu 26.04 (Resolute). Both amd64 and arm64.</p>
<p>All 50+ dynamic modules are available as <code>angie-module-*</code> packages and load with <code>load_module</code> in <code>angie.conf</code>:</p>
<pre><code>apt-get install angie-module-http-brotli angie-module-http-modsecurity angie-module-http-geoip2</code></pre>



<h2 style="color:#f59e0b">Migrating from NGINX to Angie</h2>
<p>This is the easiest server migration you will ever do. Your config files work without changes. Your modules have 1:1 equivalents. Your sites stay online throughout.</p>

<h3>Step 1, Back up your config</h3>
<pre><code>tar -czf /tmp/nginx-backup-$(date +%Y%m%d).tar.gz /etc/nginx/
nginx -V 2>&amp;1 | grep -o 'modules/[^ ]*.so'   # note which modules you use</code></pre>

<h3>Step 2, Add the myguard repository and install Angie</h3>
<pre><code>wget https://raw.githubusercontent.com/eilandert/deb.myguard.nl/main/myguard.deb
dpkg -i myguard.deb
apt-get update
apt-get install angie</code></pre>
<p>Angie installs alongside NGINX without removing it. They coexist as packages, they just can’t both listen on ports 80 and 443 at the same time.</p>

<h3>Step 3, Test your config with Angie</h3>
<pre><code>angie -t
# Angie reads /etc/nginx/ by default
# "syntax is ok" means you're ready to switch</code></pre>

<h3>Step 4, Switch over</h3>
<pre><code>systemctl stop nginx
systemctl start angie
systemctl status angie</code></pre>

<h3>Step 5, Verify your sites</h3>
<pre><code>curl -I https://yourdomain.com
tail -f /var/log/angie/error.log</code></pre>

<h3>Step 6, Make it permanent</h3>
<pre><code>systemctl disable nginx
systemctl enable angie</code></pre>

<h3>Rollback (if needed)</h3>
<pre><code>systemctl stop angie
systemctl start nginx
# NGINX config is untouched, sites are back online</code></pre>

<h3>Module package name mapping</h3>
<table style="width:100%;border-collapse:collapse;margin:1rem 0;">
<thead><tr style="border-bottom:2px solid var(--border);"><th style="text-align:left;padding:0.5rem;">NGINX module package</th><th style="text-align:left;padding:0.5rem;">Angie module package</th></tr></thead>
<tbody>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">libnginx-mod-http-brotli</td><td style="padding:0.5rem;">angie-module-http-brotli</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">libnginx-mod-http-modsecurity</td><td style="padding:0.5rem;">angie-module-http-modsecurity</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">libnginx-mod-http-lua</td><td style="padding:0.5rem;">angie-module-http-lua</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">libnginx-mod-http-headers-more</td><td style="padding:0.5rem;">angie-module-http-headers-more</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">libnginx-mod-http-geoip2</td><td style="padding:0.5rem;">angie-module-http-geoip2</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">libnginx-mod-http-njs</td><td style="padding:0.5rem;">angie-module-http-njs</td></tr>
<tr><td style="padding:0.5rem;">libnginx-mod-http-zstd</td><td style="padding:0.5rem;">angie-module-http-zstd</td></tr>
</tbody>
</table>



<h2 style="color:#f59e0b">ACME / Let’s Encrypt: full configuration guide</h2>

<h3>Basic single domain</h3>
<pre><code>http {
    acme_client letsencrypt https://acme-v02.api.letsencrypt.org/directory
        email <span style="display:inline;" class="">&#97;&#100;mi&#110;&#64;&#101;&#120;am&#112;l&#101;&#46;&#99;&#111;&#109;</span>;

    # HTTP → HTTPS redirect
    server {
        listen 80;
        server_name example.com www.example.com;
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        listen 443 quic reuseport;  # HTTP/3
        http2 on;
        http3 on;
        server_name example.com www.example.com;

        acme letsencrypt;
        ssl_certificate      $acme_cert_letsencrypt;
        ssl_certificate_key  $acme_cert_key_letsencrypt;

        ssl_protocols       TLSv1.2 TLSv1.3;
        ssl_session_cache   shared:SSL:10m;
        ssl_session_timeout 1d;
        ssl_session_tickets off;

        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
        add_header Alt-Svc 'h3=":443"; ma=86400';
    }
}</code></pre>

<h3>Multiple certificate authorities</h3>
<pre><code>acme_client letsencrypt https://acme-v02.api.letsencrypt.org/directory;
acme_client zerossl    https://acme.zerossl.com/v2/DV90;

server {
    acme letsencrypt;
    acme zerossl;
    ssl_certificate      $acme_cert_letsencrypt;
    ssl_certificate_key  $acme_cert_key_letsencrypt;
}</code></pre>

<h3>Private / internal CA</h3>
<pre><code>acme_client internal https://ca.internal/acme/directory;
# Works with Step CA, HashiCorp Vault PKI, EJBCA, Smallstep</code></pre>

<h3>Staging for testing (avoids rate limits)</h3>
<pre><code>acme_client staging https://acme-staging-v02.api.letsencrypt.org/directory;</code></pre>

<h3>Custom certificate storage path</h3>
<pre><code>acme_client letsencrypt https://acme-v02.api.letsencrypt.org/directory
    email <span style="display:inline;" class="">admi&#110;&#64;&#101;x&#97;mp&#108;&#101;.&#99;&#111;&#109;</span>
    path /etc/angie/ssl/acme;</code></pre>

<h3>How ACME renewal works</h3>
<p>Let’s Encrypt certificates expire after 90 days. Angie starts renewal when fewer than 30 days remain, giving a 60-day buffer. Renewal uses a graceful reload, active connections finish with the old certificate, new connections use the new one. Zero downtime, zero cron jobs.</p>
<p>ACME uses HTTP-01 challenges, port 80 must be reachable from the internet. Wildcard certificates (<code>*.example.com</code>) require DNS-01 challenges which Angie’s built-in ACME doesn’t support; use Certbot with a DNS plugin for those specific cases.</p>

<h3>Migrating from Certbot to Angie ACME</h3>
<pre><code># 1. Add acme_client and acme directives to your config
# 2. Change ssl_certificate paths to $acme_cert_* variables
# 3. Test: angie -t
# 4. Reload: systemctl reload angie (fetches certificate immediately)
# 5. Confirm: openssl s_client -connect example.com:443 2>/dev/null | grep -A1 'Certificate chain'
# 6. Remove certbot: apt-get remove certbot
#    (delete any renewal timers and hooks)</code></pre>



<h2 style="color:#f59e0b">Monitoring: JSON API and Prometheus</h2>
<p>Angie’s status API is a REST endpoint that returns real-time server metrics. It’s available for both JSON consumption and Prometheus scraping, no extra software needed.</p>

<h3>Enable the API</h3>
<pre><code>server {
    listen 127.0.0.1:8080;

    location /status {
        api /status/;
    }

    location /metrics {
        api /metrics/prometheus;
    }
}</code></pre>

<h3>Query it</h3>
<pre><code># Full JSON status
curl -s http://127.0.0.1:8080/status | python3 -m json.tool

# Prometheus metrics for Grafana/Prometheus scraping
curl -s http://127.0.0.1:8080/metrics

# Just connection counts
curl -s http://127.0.0.1:8080/status/connections

# Upstream health
curl -s http://127.0.0.1:8080/status/upstreams

# Certificate expiry
curl -s http://127.0.0.1:8080/status/ssl</code></pre>

<h3>Dynamic upstream management via API</h3>
<pre><code># Add an upstream server (no reload required)
curl -X POST http://127.0.0.1:8080/status/upstreams/backend/servers 
  -H 'Content-Type: application/json' 
  -d '{"server": "192.168.1.100:8080", "weight": 1}'

# Remove a server
curl -X DELETE http://127.0.0.1:8080/status/upstreams/backend/servers/0

# Drain a server before removal (set to 'draining')
curl -X PATCH http://127.0.0.1:8080/status/upstreams/backend/servers/0 
  -H 'Content-Type: application/json' 
  -d '{"drain": true}'</code></pre>

<h3>Grafana dashboard</h3>
<p>Point a Prometheus scrape config at <code>http://127.0.0.1:8080/metrics</code>. The metrics follow standard naming conventions and work with existing NGINX Prometheus dashboards (Grafana dashboard ID 12708 and similar).</p>



<h2 style="color:#f59e0b">HTTP/3 and QUIC with Angie</h2>
<p>Angie’s HTTP/3 configuration is identical to NGINX’s. Both use the same openssl-nginx QUIC build. Add QUIC alongside your existing HTTPS listener:</p>
<pre><code>server {
    listen 443 ssl;
    listen 443 quic reuseport;   # HTTP/3
    http2 on;
    http3 on;
    quic_retry on;

    ssl_protocols TLSv1.3;       # HTTP/3 requires TLS 1.3
    ssl_early_data on;

    add_header Alt-Svc 'h3=":443"; ma=86400';
    # ... rest of your server block
}</code></pre>
<p>Open UDP 443 in your firewall (<code>ufw allow 443/udp</code> or the iptables/nftables equivalent), QUIC runs on UDP. HTTP/3 falls back gracefully to HTTP/2 for clients that don’t support it. See the <a href="/2026/05/nginx-http3-quic-debian-ubuntu/">full HTTP/3 setup guide</a> for verification steps and troubleshooting.</p>



<h2 style="color:#f59e0b">Angie vs NGINX Plus</h2>
<p>NGINX Plus is NGINX’s commercial version, priced at ~$3,000/year per server. It adds the features that Angie provides for free:</p>

<table style="width:100%;border-collapse:collapse;margin:1rem 0;">
<thead><tr style="border-bottom:2px solid var(--border);"><th style="text-align:left;padding:0.5rem;">Feature</th><th style="text-align:center;padding:0.5rem;">NGINX (free)</th><th style="text-align:center;padding:0.5rem;">NGINX Plus ($3k/yr)</th><th style="text-align:center;padding:0.5rem;">Angie (free)</th></tr></thead>
<tbody>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">Native ACME/Let’s Encrypt</td><td style="text-align:center;padding:0.5rem;">✗</td><td style="text-align:center;padding:0.5rem;">✗</td><td style="text-align:center;padding:0.5rem;">✓</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">JSON status API</td><td style="text-align:center;padding:0.5rem;">✗</td><td style="text-align:center;padding:0.5rem;">✓</td><td style="text-align:center;padding:0.5rem;">✓</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">Prometheus metrics endpoint</td><td style="text-align:center;padding:0.5rem;">✗</td><td style="text-align:center;padding:0.5rem;">✓</td><td style="text-align:center;padding:0.5rem;">✓</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">Dynamic upstream API</td><td style="text-align:center;padding:0.5rem;">✗</td><td style="text-align:center;padding:0.5rem;">✓</td><td style="text-align:center;padding:0.5rem;">✓</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">Active upstream health checks</td><td style="text-align:center;padding:0.5rem;">Basic</td><td style="text-align:center;padding:0.5rem;">Advanced</td><td style="text-align:center;padding:0.5rem;">Advanced</td></tr>
<tr style="border-bottom:1px solid var(--border);"><td style="padding:0.5rem;">HTTP/3 + QUIC</td><td style="text-align:center;padding:0.5rem;">✓</td><td style="text-align:center;padding:0.5rem;">✓</td><td style="text-align:center;padding:0.5rem;">✓</td></tr>
<tr><td style="padding:0.5rem;">Commercial support</td><td style="text-align:center;padding:0.5rem;">✗</td><td style="text-align:center;padding:0.5rem;">✓</td><td style="text-align:center;padding:0.5rem;">✗</td></tr>
</tbody>
</table>
<p>For most organisations, Angie delivers the operational features of NGINX Plus without the licence fee.</p>



<h2 style="color:#f59e0b">Frequently asked questions</h2>
<div class="faq">
  <div class="faq-item"><div class="faq-q">Is Angie a drop-in replacement for NGINX?</div><div class="faq-a">Yes. Angie reads your /etc/nginx/ config directory unchanged. Every NGINX directive works. The service is named angie instead of nginx, but the config syntax, module loading, and file locations are identical. You can switch back to NGINX in two commands if anything doesn’t work.</div></div>
  <div class="faq-item"><div class="faq-q">Who maintains Angie? Is it trustworthy?</div><div class="faq-a">Angie is maintained by Webserver LLC, founded by the original NGINX core development team after leaving F5 in 2021. These are the engineers who wrote NGINX’s event loop, memory allocator, and module system. The project is active, has monthly releases, and is open-source under the BSD licence.</div></div>
  <div class="faq-item"><div class="faq-q">Does Angie ACME support wildcard certificates?</div><div class="faq-a">No. Wildcard certificates (*.example.com) require DNS-01 challenges. Angie’s built-in ACME module only supports HTTP-01. For wildcards, use Certbot with a DNS plugin alongside Angie (the cert files work fine with Angie’s ssl_certificate directive). For all non-wildcard domains, Angie’s native ACME handles everything.</div></div>
  <div class="faq-item"><div class="faq-q">Will my sites go down during migration from NGINX to Angie?</div><div class="faq-a">There’s a brief switchover when you stop NGINX and start Angie, typically under a second. For truly zero-downtime migration, use socket passing or do the switch during a low-traffic window. For most production sites, the stop/start approach causes no measurable downtime.</div></div>
  <div class="faq-item"><div class="faq-q">Does Angie support the same 50+ modules as myguard NGINX?</div><div class="faq-a">Yes. All 50+ dynamic modules in the myguard repository are available for both NGINX and Angie as angie-module-* packages. ModSecurity WAF, Brotli, Zstd compression, Lua, NJS, GeoIP2, Headers More, all of them, all available.</div></div>
  <div class="faq-item"><div class="faq-q">How do I monitor certificate expiry with Angie?</div><div class="faq-a">The Angie JSON API includes certificate status at /status/ssl, including days until expiry for every managed certificate. You can scrape this with Prometheus and alert when certificates approach expiry. With Angie’s native ACME, certificates auto-renew, but it’s still good practice to monitor.</div></div>
  <div class="faq-item"><div class="faq-q">What’s the difference between angie.conf and nginx.conf?</div><div class="faq-a">There is no difference in syntax. Angie reads /etc/nginx/nginx.conf by default (for backward compatibility). You can also use /etc/angie/angie.conf if you prefer a clean install. The directive language is identical.</div></div>
  <div class="faq-item"><div class="faq-q">Is Angie suitable for high-traffic production environments?</div><div class="faq-a">Yes. Angie has the same performance characteristics as NGINX, the same architecture handles hundreds of thousands of concurrent connections per server. Multiple high-traffic sites run Angie in production. The myguard packages are compiled with the same optimization flags (-O3, jemalloc, openssl-nginx) as the NGINX builds.</div></div>
</div>



<h2 style="color:#f59e0b">Related posts</h2>
<ul>
<li><a href="/angie-modules-optimized-extended/">Angie modules overview</a>: all 50+ dynamic modules available via APT, with descriptions and install commands</li>
<li><a href="/2026/05/openssl-nginx-a-dedicated-openssl-build-for-nginx-and-angie/">openssl-nginx: Dedicated OpenSSL for NGINX and Angie</a>: the TLS layer underneath, with kTLS, post-quantum crypto, and QUIC</li>
<li><a href="/2026/05/tls-configuration-ssllabs-a-plus/">TLS Configuration: Get A+ on SSL Labs</a>: complete TLS hardening guide for Angie and NGINX</li>
<li><a href="/2026/05/nginx-http3-quic-debian-ubuntu/">How to Enable HTTP/3 on NGINX (works identically on Angie)</a>: step-by-step QUIC setup</li>
<li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/">NGINX &amp; Angie: Expert Guide to Performance and Security</a>: full performance tuning and hardening</li>
<li><a href="/how-to-use/">How to add the myguard APT repository</a>: two-minute setup for Debian and Ubuntu</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>The Enigma Machine: How Nazi Germany’s “Unbreakable” Code Got Absolutely Demolished</title>
		<link>https://deb.myguard.nl/2026/05/enigma-machine-bletchley-park-how-it-was-broken/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Sat, 09 May 2026 13:51:14 +0000</pubDate>
				<category><![CDATA[History]]></category>
		<category><![CDATA[Alan Turing]]></category>
		<category><![CDATA[Bletchley Park]]></category>
		<category><![CDATA[Bombe Machine]]></category>
		<category><![CDATA[Code Breaking]]></category>
		<category><![CDATA[Cryptography]]></category>
		<category><![CDATA[Enigma Machine]]></category>
		<category><![CDATA[Military Intelligence]]></category>
		<category><![CDATA[World War II]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5317</guid>

					<description><![CDATA[Nazi Germany built a cipher machine with 158 quintillion possible settings and called it unbreakable. They were wrong. Here’s the full story of the Enigma machine, the brilliant misfits at Bletchley Park who cracked it, and why the whole thing matters for every padlock icon in your browser today.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Here is the Enigma machine explained without the Hollywood gloss: Nazi Germany trusted its entire war effort to a cipher its own engineers called unbreakable, and a team of mathematicians in a draughty English manor took it apart anyway. This is the Enigma machine explained as an engineering story, how it scrambled messages, why the Germans believed it was bulletproof, and the exact mistakes that let Bletchley Park read their mail.</p>


<figure><img decoding="async" src="https://deb.myguard.nl/wp-content/uploads/2026/05/enigma-machine-explained.webp" alt="Enigma machine explained, rotors and plugboard of the WW2 cipher device" width="1024" height="576" loading="lazy"/></figure>


<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The Most Expensive “Oops” in Military History</h2>



<p class="wp-block-paragraph">Imagine you have a secret. A really, really important secret. So you buy the world’s most sophisticated lock, a lock so complex that even its makers claim it would take longer than the age of the universe to crack by brute force. You put your secret inside, hand the lock to your soldiers, and tell them to keep it safe.</p>



<p class="wp-block-paragraph">Now imagine that your soldiers, bless them, occasionally set the combination to “1-2-3” because it’s easy to remember.</p>



<p class="wp-block-paragraph">That is, more or less, what happened with the <strong>Enigma machine</strong>. One of the most sophisticated encryption devices ever built. Trusted completely by Nazi Germany to protect every military communication from 1939 to 1945. And read, daily, by a team of mathematicians, crossword enthusiasts, and chess champions working out of a drafty Victorian mansion in the English countryside.</p>



<p class="wp-block-paragraph">This is the story of Enigma: what it was, how it worked, why it was supposed to be unbreakable, how it got broken anyway, and why every padlock icon in your browser today exists partly because of what happened in a Buckinghamshire field during World War II.</p>



<p class="wp-block-paragraph">Buckle up. This one’s a ride.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">What is the Enigma machine? (The Enigma machine explained for non-spies)</h2>



<p class="wp-block-paragraph">The Enigma machine looks like a typewriter had a baby with a pinball machine. It’s a wooden box roughly the size of a laptop, with a keyboard, a set of glowing letter-lamps, and, this is the clever bit, a series of <strong>rotating wheels called rotors</strong> inside.</p>



<p class="wp-block-paragraph">Here’s what it did, in plain English:</p>



<p class="wp-block-paragraph">You type a letter. The machine scrambles it into a completely different letter. You type another letter. The machine scrambles it again, but differently this time, because the rotors have rotated one position since the last keystroke. Every single letter you type gets scrambled by a different substitution. And the person receiving your message does the reverse: they type the scrambled text into their own Enigma machine (set to matching settings), and the original letters light up.</p>



<p class="wp-block-paragraph">It’s like if you had a Caesar cipher, the simple “shift each letter by 3” trick kids use, but instead of shifting by 3 every time, you shifted by a different amount for every single letter, and the amounts came from a system so complex that even if someone watched you type the whole message, they couldn’t figure out the pattern.</p>



<p class="wp-block-paragraph">The Germans were so confident in this system that they used Enigma for everything. Troop movements. U-boat positions. Supply routes. Strategic planning. Every single message, encrypted through this machine, sent over radio, which meant anyone with a receiver could hear it. They just couldn’t read it.</p>



<p class="wp-block-paragraph">Or so they thought.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">How Enigma Actually Worked (The Nerdy Bit, Made Painless)</h2>



<p class="wp-block-paragraph">Let’s go slightly deeper, because the engineering here is genuinely beautiful, and understanding it makes the eventual cracking even more satisfying.</p>



<h3 class="wp-block-heading">Step 1: The Plugboard (The First Scramble)</h3>



<p class="wp-block-paragraph">Before your letter even reaches the rotors, it passes through a <strong>plugboard</strong>, a panel on the front of the machine where pairs of letters are swapped with cables. If A is connected to Q, then any A you type becomes Q before anything else happens. Think of it as a warm-up scramble. The plugboard alone gave Enigma over 150 trillion possible configurations.</p>



<h3 class="wp-block-heading">Step 2: The Rotors (The Main Event)</h3>



<p class="wp-block-paragraph">After the plugboard, your letter enters the rotors, usually three, sometimes five. Each rotor is a disc with 26 electrical contacts on each side, wired internally in a scrambled pattern. Your letter enters one side, gets wired to a completely different letter on the other side, then enters the next rotor, and the next.</p>



<p class="wp-block-paragraph">After every single keystroke, the rightmost rotor rotates one position, like an odometer. When it completes a full rotation, the middle rotor advances one position. And so on. This means <strong>the substitution pattern changes with literally every letter you type</strong>. Type “AAAA” and you might get “RTMP” back, four completely different ciphertext letters for the same plaintext letter. Beautiful. Maddening. Both.</p>



<h3 class="wp-block-heading">Step 3: The Reflector (The Twist)</h3>



<p class="wp-block-paragraph">After passing through all the rotors, the signal hits a <strong>reflector</strong>, a fixed disc that sends it back through the rotors in the opposite direction. This is what makes Enigma symmetric: the same settings that encrypt a message also decrypt it. Brilliant for field use (no separate decoder needed). But it introduced a critical flaw we’ll get to in a moment.</p>



<h3 class="wp-block-heading">The Numbers</h3>



<p class="wp-block-paragraph">The total number of possible Enigma settings: approximately <strong>158 quintillion</strong>. That’s 158,962,555,217,826,360,000. If you checked one setting per second, it would take about 5 billion years to try them all. The sun will have gone supernova before you finish. The Germans felt pretty good about these odds.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Bletchley Park: Where Britain Sent Its Weirdest People</h2>



<p class="wp-block-paragraph">In 1939, British intelligence recruited the strangest possible team to solve the strangest possible problem. They needed mathematicians, linguists, chess grandmasters, crossword champions, and at least one person who was definitely overthinking things.</p>



<p class="wp-block-paragraph">They sent them all to <strong>Bletchley Park</strong>, a Victorian country estate in Buckinghamshire that looked like the setting for an Agatha Christie novel and functioned like a very cold, very secretive university campus. At its peak, over 10,000 people worked there. They called themselves “Station X.” They told their families they worked in a government office. They did not describe the government office in detail. Today it&#8217;s a museum, and the <a href="https://bletchleypark.org.uk/" rel="noopener" target="_blank">Bletchley Park</a> site is worth a visit if you&#8217;re ever near Milton Keynes.</p>



<p class="wp-block-paragraph">The star of the show was <strong>Alan Turing</strong>, a Cambridge mathematician who was, by all accounts, a genuine eccentric genius. He chained his tea mug to a radiator so nobody would steal it. He cycled to work in a gas mask during hay fever season. He was also, quietly, one of the most brilliant minds of the twentieth century and the person most responsible for the conceptual work that broke Enigma.</p>



<p class="wp-block-paragraph">Turing didn’t start from scratch. Polish mathematicians, Marian Rejewski, Jerzy Różycki, and Henryk Zygalski, had already made significant inroads breaking early versions of Enigma in the 1930s. They built a machine called the <strong>bomba</strong> and handed their research to British intelligence just before Germany invaded Poland. That gift of knowledge was arguably one of the most consequential acts of the entire war. It saved months, possibly years, of work.</p>



<p class="wp-block-paragraph">Turing took their work, understood it deeply, and built something better.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The Four Cracks in the Armour: Why Enigma Was Beatable</h2>



<p class="wp-block-paragraph">Here’s the thing about those 158 quintillion settings: you don’t have to check all of them. You just have to make the problem small enough to be solvable. Bletchley Park found four ways to do exactly that.</p>



<h3 class="wp-block-heading">Crack #1: The Reflector’s Fatal Flaw</h3>



<p class="wp-block-paragraph">Remember the reflector, the clever device that made Enigma symmetric? It had an unintended side effect: <strong>no letter could ever encrypt to itself</strong>. If you typed “A”, the output could be anything except “A”. Every single time. Without exception.</p>



<p class="wp-block-paragraph">This sounds trivial. It is, in fact, enormous.</p>



<p class="wp-block-paragraph">If Bletchley suspected a message contained the word “WETTER” (German for “weather”, common in meteorological reports), they could immediately eliminate every rotor setting where any letter of WETTER lined up with itself in the ciphertext. That sounds like eliminating a handful of options. In practice, it eliminated the vast majority, reducing the effective search space from quintillions to something a machine could handle.</p>



<h3 class="wp-block-heading">Crack #2: Predictable Plaintext (The “Crib”)</h3>



<p class="wp-block-paragraph">Military communications are boring, and that’s a feature, not a bug. Messages follow templates. German weather stations sent the same report format every day. Status updates began with the same headers. And, famously, one particularly enthusiastic Luftwaffe officer sent “HEIL HITLER” at the end of every single message.</p>



<p class="wp-block-paragraph">Bletchley called these guessable words <strong>“cribs.”</strong> If you could guess a word that appeared somewhere in the plaintext, you could try to align it against the ciphertext and test whether the result was consistent (remember: no letter encrypts to itself, so any alignment where a crib letter matched a ciphertext letter was instantly invalid). A good crib could reduce the search from quintillions to millions, and millions was a number a machine could handle overnight.</p>



<h3 class="wp-block-heading">Crack #3: The Operators Were Human</h3>



<p class="wp-block-paragraph">Every day, German Enigma operators received a “code sheet” specifying that day’s settings. They were then supposed to choose a random starting position, called the “message key”, and transmit it (encrypted under the day’s settings) at the start of each message.</p>



<p class="wp-block-paragraph">The problem: humans are terrible at being random. Operators under stress, in the cold, in submarines, in the middle of a war, they defaulted to patterns. “AAA.” “ABC.” Their girlfriend’s initials. Their favourite football team’s abbreviation. “GOD.” One infamous operator allegedly used the same three letters every single day for months.</p>



<p class="wp-block-paragraph">Bletchley’s analysts catalogued these habits. They called operators with predictable patterns “characters” and looked forward to their transmissions like a favourite TV show. “Oh, that’s Hans again. Hans always uses CIL. We’ll have his message cracked by lunch.”</p>



<h3 class="wp-block-heading">Crack #4: Early Enigma Repeated the Message Key Twice</h3>



<p class="wp-block-paragraph">Early in the war, German procedure required operators to send the message key <em>twice</em> at the start of every message, a redundancy check meant to catch transmission errors. So if the message key was “XYZ”, the opening of every transmission was “XYZ XYZ” encrypted. Two copies of the same three letters, back to back.</p>



<p class="wp-block-paragraph">This was cryptographic gift wrapping. It told the Polish and British analysts that positions 1 and 4 encrypted the same letter, positions 2 and 5 encrypted the same letter, and positions 3 and 6 encrypted the same letter. From that structural knowledge, entire categories of rotor settings could be eliminated. The Poles built their original <em>bomba</em> machines specifically to exploit this. Germany eventually changed the procedure in 1940, but by then, Bletchley had enough momentum to adapt.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">The Bombe: A Machine Built to Think Faster Than the War</h2>



<p class="wp-block-paragraph">Alan Turing’s master stroke was this: if you can’t check all 158 quintillion settings by hand, build a machine that eliminates wrong settings automatically and stops when it finds a plausible one.</p>



<p class="wp-block-paragraph">The result was the <strong>Bombe</strong>, a two-metre-tall, 225-kilogram electromechanical computing machine that sounded like a very angry knitting machine and smelled of hot oil. It was not subtle. It was not quiet. It was, however, extraordinarily effective.</p>



<p class="wp-block-paragraph">Here’s what it did:</p>



<ul class="wp-block-list">
<li>You fed it a <strong>crib</strong>: your best guess at a word or phrase in the message</li>
<li>The Bombe ran through rotor configurations at high speed, testing each one</li>
<li>For each configuration, it checked whether the crib was consistent with the ciphertext (using the “no letter encrypts to itself” rule to discard invalid ones instantly)</li>
<li>When it found a configuration that <em>couldn’t</em> be immediately ruled out, it stopped and rang a bell</li>
<li>Human operators then tested those candidate settings manually</li>
</ul>



<p class="wp-block-paragraph">A single Bombe could evaluate millions of possible settings per hour. By 1945, Bletchley Park had <strong>211 Bombe machines</strong> running around the clock. The daily Enigma settings, which changed at midnight, were typically broken before noon the next day. Often before breakfast.</p>



<p class="wp-block-paragraph">This wasn’t a computer in the modern sense. It couldn’t play chess or browse the internet. It was a single-purpose machine built to do one thing: find Enigma settings fast enough to be useful. But the conceptual leap, the idea that you could automate the process of testing hypotheses and eliminating wrong answers, is a direct ancestor of the computer you’re reading this on.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">What Bletchley Park Actually Achieved</h2>



<p class="wp-block-paragraph">The intelligence gathered from breaking Enigma was codenamed <strong>ULTRA</strong>, and it was, for years, the Allies’ most closely guarded secret. The British went to extraordinary lengths to hide the fact that Enigma had been broken, including constructing elaborate cover stories for how they’d obtained certain intelligence.</p>



<p class="wp-block-paragraph">The impact was enormous:</p>



<ul class="wp-block-list">
<li><strong>The Battle of the Atlantic:</strong> U-boat positions were read in near-real-time, allowing Allied convoys to route around them. The German submarine campaign: which came terrifyingly close to strangling Britain’s supply lines, was blunted significantly by ULTRA intelligence.</li>
<li><strong>North Africa:</strong> Rommel’s supply routes were read and targeted. His famous supply problems weren’t entirely bad luck.</li>
<li><strong>D-Day:</strong> German troop dispositions were known in advance. The elaborate deception operation (convincing Hitler the invasion would be at Calais, not Normandy) was partly possible because Bletchley could read German traffic and confirm the deception was working.</li>
<li><strong>Shortened the war:</strong> Historians estimate ULTRA intelligence shortened the war by two to four years and saved an estimated 14 million lives. These are rough numbers, impossible to verify precisely, but the order of magnitude is not seriously disputed.</li>
</ul>



<p class="wp-block-paragraph">And all of it remained completely secret until 1974, when the first public account was published. The people who worked at Bletchley Park spent three decades unable to tell anyone, including their own families, what they had done.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Why This Matters for Your HTTPS Connection Right Now</h2>



<p class="wp-block-paragraph">You might be wondering what a 1940s typewriter-with-wheels has to do with the padlock icon in your browser. The answer is: everything.</p>



<p class="wp-block-paragraph">Every lesson from Enigma’s failure is baked directly into modern cryptography:</p>



<h3 class="wp-block-heading">Lesson 1: The Maths Can Be Perfect and the System Still Fails</h3>



<p class="wp-block-paragraph">Enigma’s mathematics were genuinely sophisticated. The cipher itself wasn’t weak. The system failed because of how it was used. Today’s TLS encryption, the protocol that powers HTTPS, is designed with this in mind. The key exchange, the session keys, the certificate chain: all designed so that even if an operator is sloppy or a server is misconfigured, the damage is limited. The system is designed to degrade gracefully, not catastrophically.</p>



<h3 class="wp-block-heading">Lesson 2: Never Reuse Keys</h3>



<p class="wp-block-paragraph">Enigma operators who reused message keys were handing Bletchley Park a gift. Modern TLS uses <strong>Perfect Forward Secrecy</strong>, a property where each session gets a fresh, unique key that is thrown away afterwards. Even if someone captures all your encrypted traffic and later compromises your server, they can’t decrypt past sessions because the keys no longer exist. This is a direct descendant of the Enigma lesson.</p>



<h3 class="wp-block-heading">Lesson 3: Patterns Are the Enemy</h3>



<p class="wp-block-paragraph">Cribs worked because military messages were predictable. Modern encryption is designed to produce output that is <em>indistinguishable from random noise</em>, regardless of how predictable the input is. Encrypt the word “HELLO” a million times with different keys and you get a million completely different outputs with no discernible pattern. <strong>AES</strong>, the encryption standard used in most software today, has this property by design.</p>



<h3 class="wp-block-heading">Lesson 4: The Next Threat Is Quantum</h3>



<p class="wp-block-paragraph">Here’s the unsettling coda: we may be living through the Enigma moment right now, just from the other side. Today’s encryption is strong against classical computers. But quantum computers, once powerful enough, could break the mathematical problems that RSA and ECDH rely on. The answer, <strong>post-quantum cryptography</strong>, is already being deployed. Read our guide on <a href="/2026/05/post-quantum-cryptography-nginx-angie-ml-kem-hybrid-tls/">Post-Quantum Cryptography with NGINX and Angie</a> for the current state of that story, it’s the Bletchley Park moment of our era, happening in slow motion.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Alan Turing: The Genius Who Got a Terrible Deal</h2>



<p class="wp-block-paragraph">No Enigma story is complete without this part, even though it’s the hardest to tell.</p>



<p class="wp-block-paragraph">Alan Turing was not just the man who built the Bombe. He laid the theoretical foundations of computer science before computers existed. His 1936 paper on “computable numbers” described the abstract concept of a programmable machine, what we now call a <strong>Turing machine</strong>, a decade before the first electronic computer was built. His work at Bletchley Park saved an unknowable number of lives. His 1950 paper on machine intelligence introduced what became the <strong>Turing Test</strong>.</p>



<p class="wp-block-paragraph">In 1952, he was prosecuted by the British government for homosexuality, which was then a criminal offence. He was subjected to chemical castration as an alternative to prison. He died in 1954 at 41, apparently by cyanide poisoning. The exact circumstances remain disputed.</p>



<p class="wp-block-paragraph">In 2013, he received a royal pardon. In 2021, his face appeared on the British £50 note.</p>



<p class="wp-block-paragraph">The country that benefited most from his work spent decades treating him as a criminal for who he was. History’s verdict is rather different.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Frequently Asked Questions</h2>



<h3 class="wp-block-heading">Was Enigma ever truly unbreakable?</h3>



<p class="wp-block-paragraph">As a pure cipher, Enigma was extraordinarily strong for its era, but “unbreakable” was always marketing, not mathematics. The machine had structural flaws (the reflector’s no-self-encryption property) that made it theoretically vulnerable. More importantly, the system surrounding the machine, the operational procedures, the human operators, the predictable message formats, created the gaps that Bletchley exploited. No cipher is stronger than the people using it.</p>



<h3 class="wp-block-heading">Did Germany ever find out Enigma was broken?</h3>



<p class="wp-block-paragraph">No, not during the war. There were moments of suspicion (particularly around the Battle of the Atlantic, where Allied convoy routing seemed suspiciously good), but German investigators generally concluded Enigma was secure and blamed the intelligence leaks on spies or captured documents. The British went to extraordinary lengths to protect ULTRA, including sometimes allowing attacks to proceed that they could have warned against, to avoid revealing that they were reading German communications. The full story only became public in 1974.</p>



<h3 class="wp-block-heading">How many Enigma machines still exist?</h3>



<p class="wp-block-paragraph">Roughly 300 Enigma machines are known to survive worldwide, held in museums and private collections. They occasionally appear at auction, in 2017, a Naval Enigma sold for £100,000. The machines themselves are fully functional; the cryptographic settings are what made them secure, not anything inherent to the hardware.</p>



<h3 class="wp-block-heading">What is the Imitation Game, is it accurate?</h3>



<p class="wp-block-paragraph">The 2014 film <em>The Imitation Game</em> starring Benedict Cumberbatch as Turing is a watchable and moving dramatisation, but it plays very loose with history. Turing did not personally design and build the Bombe alone, Gordon Welchman made crucial contributions the film ignores entirely. The film also compresses timelines dramatically and invents several plot elements. For the real story, visit Bletchley Park itself (now a museum) or read Andrew Hodges’ biography <em>Alan Turing: The Enigma</em>, on which the film was based.</p>



<h3 class="wp-block-heading">Is modern encryption like TLS vulnerable in the same way Enigma was?</h3>



<p class="wp-block-paragraph">Modern encryption has been specifically designed to avoid Enigma’s failure modes. TLS 1.3 uses perfect forward secrecy (no key reuse), produces output indistinguishable from random (no cribs), and doesn’t have structural properties like Enigma’s reflector. The practical weaknesses in modern cryptography are almost always in implementation and operational security, weak passwords, unpatched servers, human error, not in the mathematics. Exactly the same lesson, repeated eighty years later.</p>



<h3 class="wp-block-heading">What happened to Alan Turing?</h3>



<p class="wp-block-paragraph">After the war, Turing continued working in computing and artificial intelligence at Manchester University. In 1952 he was prosecuted for “gross indecency” under British law that criminalised homosexuality. He accepted chemical castration rather than prison. He died in June 1954 from cyanide poisoning; his death was ruled a suicide, though some historians have argued it may have been accidental. He was 41 years old. The British government issued a formal apology in 2009 and a royal pardon in 2013. His face now appears on the £50 note.</p>



<h3 class="wp-block-heading">What’s the connection between Bletchley Park and modern computers?</h3>



<p class="wp-block-paragraph">Very direct. The Bombe was one of the first large-scale automated computing machines. Colossus, built at Bletchley to break the separate Lorenz cipher used by Hitler’s high command, was arguably the world’s first programmable electronic computer. Alan Turing’s theoretical work on computation predated and underpinned all of it. The computing industry grew directly from the soil of wartime codebreaking. Every time you run a search query or open a terminal window, there’s a lineage that traces back to that cold Victorian mansion in Buckinghamshire.</p>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<h2 class="wp-block-heading has-text-color" style="color:#f59e0b">Related Posts</h2>



<p class="wp-block-paragraph">The Enigma story doesn’t end in 1945. The arms race between encryption and codebreaking is still running, and these posts are where it stands today:</p>



<ul class="wp-block-list">
<li><a href="/2026/05/post-quantum-cryptography-nginx-angie-ml-kem-hybrid-tls/"><strong>Post-Quantum Cryptography with NGINX and Angie: ML-KEM, Hybrid TLS, and How to Configure It</strong></a>: Quantum computers are to RSA what the Bombe was to Enigma. This is the story of what comes next, and how to configure your server for it today.</li>
<li><a href="/2026/05/openssl-4-nginx-upgrade-openssl-nginx-3-to-4/"><strong>OpenSSL 4.0 for NGINX: Upgrading openssl-nginx from 3.x to 4.0</strong></a>: The encryption library that powers modern HTTPS on your server, explained from scratch with upgrade instructions. The spiritual successor to everything Bletchley Park fought for.</li>
<li><a href="/2026/05/nginx-angie-the-expert-guide-to-maximum-performance-and-security/"><strong>How to Optimize NGINX and Angie for Maximum Performance and Security</strong></a>: The practical guide to running a fast, secure web server, including TLS 1.3, perfect forward secrecy, and everything else that makes modern encryption hard to break.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity" />



<p class="wp-block-paragraph"><em>Bletchley Park is now a museum and is absolutely worth visiting if you’re ever in the UK: <a href="https://bletchleypark.org.uk/" rel="noopener" target="_blank">bletchleypark.org.uk</a>. The Bombe replicas are running. Bring a jacket, it’s still cold in there.</em></p>

]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
