Nothing Was Deployed, and Every Site Said 404

A note from the operator. I run Netrun Systems (https://netrunsystems.com). Our product Sigil is a multi-tenant CMS that hosts our own website and the sites of several small-business clients. Netrun's CTO agent, one of the AI seats that help me run the company, found and fixed the bug below during a routine morning health check, and I approved the deploy. We're writing it up because any site that renders pages on a server by calling its own API can fail this way, and the fix is small. Daniel Garza
The mystery
At 05:54 Pacific on Tuesday, September 29, our morning health check probed www.netrunsystems.com and got a 404. It got the same answer from the product pages, a blog post, and a client's site. Every one of them returned our renderer's "Page Not Found" template.
Two minutes later, the same URLs returned 200.
Nothing had been deployed. The API and the renderer had been running untouched for almost two days, and the database was healthy. Yet our site and our clients' sites had each briefly told the world they did not exist.
A bad deploy stays broken. A database outage shows up as a 500. A wrong route breaks one page. What produces a clean "not found" page on every tenant for a few minutes, then goes away?
The investigation
The renderer's logs answered it. Each 404 window lined up with a run of log lines reading API 429: the renderer had asked our own API for page data and been told it was making too many requests.
Next to those lines were requests from a vulnerability scanner. The paths were the usual ones: /.env, /wp-config.php, /.git/config, /.jenkins, and a range of .php probes. In the 48 hours of logs we checked, there were three bursts. Each one lined up with a window of 404s across every site.
But the scanner wasn't calling the API; it was requesting pages. And the API's rate limiter was keyed per client IP. So why would one scanner use up everyone's budget?
The "aha": the renderer is one client
Sigil renders pages on the server. When a visitor requests a page, the renderer looks up the site by hostname, fetches the page, theme, and navigation from the API, and returns HTML. A page view costs between one and four API reads.
The API sees those reads as coming from the renderer. The renderer's server-side calls didn't say which visitor they were for, so the rate limiter keyed them all on the one address it could see: the renderer's own internal IP.
As far as the rate limiter was concerned, every visitor to every site we host was one client. Their requests all drew on a single budget of a few hundred requests per window.
A scanner requesting nonsense paths still makes the renderer do work: each miss costs at least a hostname lookup and a page lookup against the API. A burst of probes on any one tenant used up the shared budget, and the API correctly started returning 429s to the renderer.
The last step was in our code. The renderer's fetch helper turned any non-OK response into null, and the page handler treated null as "this page doesn't exist." A rate-limit refusal became a 404 on every site until the window reset.
Each component behaved correctly on its own. The limiter was keyed per IP; the renderer funneled every visitor through one IP.
Three ways to fix it, and why two of them were wrong
Option 1: exempt the renderer from the limiter. This was the first suggestion in our own incident notes. It's one line of config and the 404s stop. But then anyone who can request a public page can push unlimited load through the renderer to the API and the database. You haven't fixed the scanner problem; you've removed the only thing slowing it down.
Option 2: trust requests from the internal Docker network. Only our own containers are on that network. But the edge proxy is on it too, and the edge proxy forwards public traffic to the API's public hostname. From the API's point of view, internet traffic also arrives from an internal address, so "trust the internal network" means trusting the internet. Container IPs also change on every restart, so an address allowlist would break without warning.
Option 3, the one we shipped: a signed internal header and a per-visitor bucket.
- The renderer sends two headers on its API calls: a shared internal token and the real visitor's IP.
- The API honors the visitor-IP header only when the token matches, compared in constant time. A token shorter than 32 characters counts as "not configured," so a short secret is refused rather than trusted.
- Without a valid token the header is ignored, so nobody on the internet can fake a visitor IP to dodge the limiter or get someone else rate-limited.
- Trusted reads go into a separate, larger page-render bucket, keyed per visitor. Everything else, including trusted writes, stays in the public bucket, keyed as before.
- The edge proxy strips both headers from requests to the API's public hostname.
The renderer calls the API from about fifty places. Instead of editing each one, we added a request-scoped context (Node's AsyncLocalStorage) and one fetch wrapper, scoped to the API's origin, so the token never goes to third-party hosts such as payment, video, or feed providers.
Now a scanner is rate-limited as itself. When it runs out of budget, it gets the errors, and everyone else keeps loading pages.
Here is the pattern in generic form. This is illustrative, not our production code:
import { timingSafeEqual } from "node:crypto";
import { isIP } from "node:net";
const TOKEN = process.env.INTERNAL_TOKEN ?? ""; // >= 32 random chars, from a secret store
// API side: the visitor header counts only when the token checks out.
export function rateLimitKey(h: Headers, method: string, edgeClientIp: string) {
const got = Buffer.from(h.get("x-internal-token") ?? ""), want = Buffer.from(TOKEN);
const trusted = TOKEN.length >= 32 && got.length === want.length && timingSafeEqual(got, want);
if (!trusted) return { bucket: "public", key: edgeClientIp };
const ip = h.get("x-visitor-ip")?.trim() ?? "";
const bucket = method === "GET" || method === "HEAD" ? "render" : "public";
return { bucket, key: isIP(ip) ? ip : "unknown-visitor" };
}
// Renderer side: only the API's own origin ever sees the token.
const API_ORIGIN = new URL(process.env.API_URL!).origin;
export function apiFetch(url: string, visitorIp: string, init: RequestInit = {}) {
if (new URL(url).origin !== API_ORIGIN) return fetch(url, init);
const headers = new Headers(init.headers);
headers.set("x-internal-token", TOKEN);
headers.set("x-visitor-ip", visitorIp);
return fetch(url, { ...init, headers });
}
A 404 is a claim; a 503 is an apology
Fixing the rate-limit keying stopped the outage. The other half of the bug was that, during the outage, our sites gave the wrong answer.
A 404 tells browsers, caches, and search engines that the page doesn't exist. Search engines generally drop pages that keep returning 404, and caches may store it. A 404 served because a dependency was briefly overloaded is false, and other systems act on it.
The honest answer is 503 Service Unavailable, which means the page exists and the server can't serve it right now. Now, when the API refuses or can't be reached (429, 502, 503, 504, or a network error), the renderer returns:
- 503 with a
Retry-Afterheader. It passes through the API's value, with a sensible default and a cap. Cache-Control: no-store, so no cache keeps the error page.- A small, self-contained "Temporarily unavailable" page marked
noindex.
A genuine 404 from the API still produces a 404, and a test checks that.
The same audit found two related problems. If the hostname lookup itself was refused, the renderer could fall back to a default site configuration; it now returns 503. And the theme, navigation, and HTML caches could store entries built during an outage, keeping a degraded page alive after recovery; they now skip those entries.
We believe the SEO impact of the original incident was small, because each window lasted minutes. We don't have crawl data showing whether a search engine fetched a page during one of those windows, and we're not going to guess.
Stop the noise at the edge
Requests for /.env should never reach a CMS renderer. Our edge proxy now answers a list of scanner paths with a 404 before they reach the application: dotfiles and VCS directories (.env, .git, .svn, .aws, .ssh), *.php/.asp/.jsp/.cgi, WordPress paths, phpmyadmin, cgi-bin, and a few framework admin endpoints. The block is applied to every tenant hostname.
The matcher was tested in both directions: 21 scanner paths returned 404, and 13 real routes returned 200. The real routes included /.well-known/security.txt and a page slug containing "wp," which a careless pattern would have blocked.
We also turned on a structured JSON access log at the edge, which redacts cookies and authorization headers by default. Before this, we could only see the scanner traffic through its effects in the renderer's logs.
The evidence
The timeline on September 29, Pacific time:
- 05:54: first failed probe.
- 06:02: root cause written to our ops log.
- 06:21: fix opened as a pull request with tests.
- 06:25: approval to deploy.
- 06:28–06:30: deployed.
- 06:33: verified.
Testing before the deploy:
- API tests went from 97 to 108, and renderer tests from 43 to 55.
- A new end-to-end test starts the real renderer server against a stub API. Three of its four tests fail on the old code. The fourth is the "missing page stays 404" guard, which passes on both.
- An off-host smoke test with deliberately tiny limits reproduced the original incident without the token: the scanner starved an innocent visitor. With the token, the scanner was cut off and the other visitor kept rendering. A forged visitor-IP header sent with a bad token was still rate-limited.
The deploy order was database backup, token, API, renderer, edge reload, each step reversible on its own. The API went first deliberately: an API that knows the token, paired with a renderer that doesn't yet send it, behaves exactly like the old system, so there was no unsafe midpoint.
After the deploy:
- Renderer
API 429lines: 0, against 3,375 in the six hours before. - Burst test: one client fired 90 concurrent requests, a mix of scanner paths and junk paths. During that burst, a second client with a different IP loaded three tenant sites 24 out of 24 times with a 200, at about 40 ms each.
- Edge block: the renderer's log showed no scanner paths. Requests for
/.envand/.git/configwere answered at the edge without reaching the application.
One thing is still open. The page-render budget was sized from the code's call pattern, not measured traffic; the new access log is how we'll measure it. Per-IP keying also means many people behind one office NAT share a bucket. We believe the render budget covers that case, but we'll confirm it from real data.
A checklist for any SSR + API stack
This applies to Next.js, Nuxt, Remix, SvelteKit, or a custom setup, whenever a server renders pages by calling an API that has a rate limiter.
- Ask what IP your API sees for server-side fetches. If it's the renderer's, every visitor shares one bucket, and any burst becomes everyone's outage.
- Forward the visitor's identity, but authenticate it. An untrusted visitor-IP header lets anyone bypass your limiter. Use a long random token, compare in constant time, and ignore the header without it.
- Don't trust "internal network." If your edge proxy shares a network with your renderer, a network-based trust rule also trusts the internet.
- Scope the token to your API's origin. Wrap fetch once and check the origin, so the secret never goes to a third party.
- Give rendering its own per-visitor bucket. A page view costs several API calls, so size that bucket separately from direct API clients.
- Map dependency failures to 503, not 404. Send
Retry-After,no-store, andnoindex. Keep real 404s as 404s, and test both cases. - Don't fall back to a default tenant or cache anything while degraded. Fallbacks and caches can keep the wrong answer around long after the outage ends.
- Block scanner paths at the edge, and test the block against real routes. A pattern that matches
wptoo loosely will eventually block a real page. - Turn on edge access logs before you need them. An incident you can only see from its side effects is hard to diagnose.
None of these steps is complicated. What made the bug hard to find is that a renderer in front of an API carries many visitors but looks like one client, and the rate limiter only ever saw the one.