TX·08 · EDGE / REVERSE PROXY · LOGGED 2026·08 · 14 MIN
Hardening the edge: TLS was the easy part
TX·07 ended with the reverse proxy holding the only published port on the box, which makes the proxy the entire perimeter. So I spent a weekend making the door worth the title, and the surprise ran opposite to instinct: TLS, the part everyone tunes hardest, needed almost nothing except deletions. The parts that actually bit were a forwarding header I was trusting without realizing it, and a config line pointing at a service that shut down a year ago. Here's the edge done properly: proxy choice, TLS without the folklore, headers with one owner each, and the seam where two HTTP parsers have to agree.
One door, one owner
Three candidates for the door: nginx, Caddy, Traefik. Traefik eliminated itself fast, it's built for dynamic container fleets with labels and discovery, ships a new minor at a pace that expires old ones within months, and everything it's good at is over-scoped for one static backend. The real decision was nginx versus Caddy, and it came down to two structural facts rather than features. First, maintenance: Caddy's automatic HTTPS means certificates are obtained, renewed, and rotated by the binary itself, no Certbot, no timer, no cron logic to rot, and its TLS defaults are current without me writing a single cipher line. Second, the bug classes: nginx is C, and its 2026 patch notes read like a C changelog, buffer overflow, use-after-free, memory disclosure; Caddy is Go, where the bugs skew toward logic and normalization mistakes rather than the wormable memory-corruption tail. That's a judgment call, not a guarantee, Go has logic bugs too. But for an operator who will not read every changelog, secure-by-default plus memory-safe is the combination that fails least badly. If you already run nginx fluently, it remains fully defensible; the delta is maintenance ergonomics, not security ceiling, and the nginx configs below cover that branch.
Then the shape question, and a correction to my own last article. TX·07's compose file ran Caddy as a container, because when everything is a container the proxy follows along. I've moved it to the host, and the reasoning is TX·07's own: a containerized proxy that wants dynamic discovery gets wired to the Docker socket, which is the exact crown-jewels mistake that article warns about, and even without discovery, the certificate store ends up in a volume you must remember exists. On the host, the privileged 443 bind and the ACME state live in the one place my hardening and my SIEM already watch, the backend container binds only to localhost, and the mental model has one moving part. The cost is one package patched outside the container lifecycle. I'll pay that for a simpler perimeter. The whole edge now looks like this:
game.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
log {
output file /var/log/caddy/access.json
format json
}
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), camera=(), microphone=()"
-Server
}
}
That block is TLS with automatic renewal, HTTP/2 and HTTP/3, WebSocket upgrades handled correctly, structured JSON logs, and the static security headers, in under twenty lines. Before moving on, the standing disclaimer this layer needs: the proxy is not a WAF, not an IPS, and not authorization. "The proxy handles security" is a cargo-cult sentence. It terminates TLS and normalizes HTTP; it has no idea what your business logic permits, and it does nothing about an attacker holding valid credentials or an app-layer bug behind it.
TLS in 2026: delete more than you add
The posture that's actually current: serve TLS 1.3 and 1.2, nothing older, which current builds already do by default. TLS 1.3 has a small fixed set of forward-secret AEAD suites and there is nothing in it to tune; your cipher list only ever governed the 1.2 leg, and the maintained answer there is the Mozilla Intermediate profile, ECDHE with AES-GCM or ChaCha20, no CBC, no static RSA, and server cipher preference off so clients pick what their hardware runs fastest. This year's profile update is telling about the direction: Mozilla deleted the legacy "Old" profile outright and added a post-quantum hybrid key exchange to the recommendations. The work is deletions. Same lesson as the SSH crypto section in TX·05: the defaults are maintained by professionals, and the pasted snippet is maintained by nobody.
The change that actually demands operational attention is lifetime.
Let's Encrypt's opt-in profile has been issuing 45-day certificates
since May, the default drops to 64 days next February, and the
industry ballot behind it steps every public CA down to 47-day
maximums by 2029. The consequence is blunt: any hardcoded
renew-every-60-days logic breaks, and manual renewal stops being a
thing a human can reliably do. The renewal path has to be automated
and watched, either Caddy doing it natively or a current Certbot
that speaks ACME Renewal Information. Two smaller calls round out
the section. Keep 0-RTT early data off, it's replayable by design,
acceptable for idempotent first requests and wrong for an API where
the first bytes might mutate something; Caddy leaves it off, nginx
spells it ssl_early_data off. And send HSTS with a
two-year max-age and includeSubDomains, but skip the
preload directive: the preload list ships compiled into browser
binaries, and its own site warns that "Removal tends to be slow and
painful for those sites." A one-way door protecting only the
first-ever visit is a bad trade for a solo operation. Last
cargo-cult flag while I'm here: the SSL Labs A+ is a regression
check, not an objective. Re-test after changes; don't badge-hunt.
And remember what all of it buys: transit security only. A perfect
TLS grade on a vulnerable app is a locked door on a house with
open windows.
Headers are browser law, with one owner each
Security headers are instructions to browsers, which means two
things people skip: they protect browser clients only, a curl
script or native game client ignores every one of them, and each
header needs exactly one layer that owns it, or the proxy and the
app drift into sending conflicting copies. My rule after this
round: the proxy owns every static header, because those survive
app redeploys and framework swaps, and the app owns exactly one,
the CSP, because a modern CSP carries a per-request nonce the
proxy can't generate. Ownership is commented in both configs, and
verification is curl -I against production, checking
what the browser actually receives rather than what each layer
intended.
The CSP itself is where the folklore is thickest. Allowlist
policies, the long lists of permitted domains, are both a
maintenance treadmill and bypassable in practice, which is why
current MDN and web.dev guidance has converged on the nonce
pattern: a cryptographically random value minted per request,
stamped on your own script tags, with strict-dynamic
propagating trust to what those scripts load:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'self';
frame-ancestors 'self';
connect-src 'self' wss://game.example.com
The https: and 'unsafe-inline' entries
look alarming and aren't: CSP3 browsers ignore them when a nonce
and strict-dynamic are present, they exist purely as
fallback for old engines. The line that matters for this stack is
connect-src: browsers check WebSocket connections
against it, and a wss:// origin that isn't named (or
covered by 'self' on the same origin) fails the
handshake with a console error users never report. That's the
header-layer sibling of the origin checks from
TX·04, same connection, gated
from the other side. Roll the CSP out in report-only, read a few
days of violation reports, then enforce; report-only forever is a
staging step promoted to a lifestyle, and it protects nothing.
The rest is quick. frame-ancestors is the modern
clickjacking control, with X-Frame-Options: DENY kept
only as legacy fallback. Referrer-Policy:
strict-origin-when-cross-origin and a Permissions-Policy
disabling the device features a game doesn't use are two cheap
lines. The cross-origin isolation pair, COOP and COEP, has a
precise trigger: set it only if the client needs
SharedArrayBuffer, threaded WebAssembly territory,
because COEP breaks every cross-origin resource that doesn't opt
in, and that's pure damage if nothing needs the isolation. Digital
Descent doesn't yet, so it's correctly absent. And strip the dead
weight: X-XSS-Protection controlled a browser auditor
removed years ago and could itself introduce bugs,
Expect-CT is obsolete, HPKP should never appear
anywhere, and X-Powered-By is free reconnaissance for
scanners, which TX·01 already had Express suppressing.
The seam: two parsers, one truth
A reverse proxy means two HTTP parsers read every request, the proxy's and Node's, and request smuggling is the class of attack that lives in their disagreements about where a request ends. I'm keeping this section conceptual on purpose, the defensive takeaways don't need packet diagrams. Node's parser has real history here: a 2022 fix for header fields terminated with a bare carriage return, and a 2025 CVE where a malformed terminator sequence let requests slip past proxy-based access controls, fixed by moving to a strictly CRLF-enforcing parser generation. The pattern across all of them: the fix was a parser upgrade, not a config setting. Which makes the primary defense embarrassingly familiar from three articles running: keep Node on the current LTS, because that removes the disagreement at its source. Behind it, let the proxy do what current proxies do, parse strictly, reject ambiguous length headers, and forward a normalized request. Keep the proxy-to-backend hop plain HTTP/1.1 with no cleartext HTTP/2 upgrade, protocol translation is where desync risk re-enters. And wire the detection side: clusters of 400s from one source, especially with conflicting length headers, are somebody probing the seam, and that signal feeds the log section below.
The header that breaks three things at once
This is the section that earned its own heading, because the
failure is silent and triple. Anyone on the internet can send
X-Forwarded-For: whatever in a request to your edge.
If any layer trusts that value blindly, three systems degrade at
once with no error message anywhere: rate limiting keys on an
attacker-chosen IP, so a different spoofed value per request never
trips a limit; fail2ban-style banning reads the wrong address out
of the logs and bans nothing, or bans innocents; and every
enrichment and correlation the TX·06
pipeline does attributes activity to the wrong source, poisoning
detections downstream. The rule that fixes all three: trust a
forwarding header only when your own infrastructure wrote it.
For this topology, host proxy in front of a localhost backend, there's exactly one trusted hop, and the config is short. Caddy does the right thing by default here, overwriting rather than appending what the client sent. On the nginx branch, the load-bearing choice is which variable feeds the header:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# the classic mistake, blindly relaying the client-supplied header:
# proxy_set_header X-Forwarded-For $http_x_forwarded_for; # never this
And the app side has its own trap. Express derives
req.ip from the forwarding chain according to its
trust proxy setting, and the value everyone pastes is
the one that reopens the hole:
// trust exactly one hop: the proxy on this machine.
app.set("trust proxy", "loopback");
// never this on an internet-facing app: it trusts the leftmost
// X-Forwarded-For value, i.e. whatever the client typed.
// app.set("trust proxy", true);
Proxying the sockets
WebSockets cross the proxy too, and the failure modes are specific
because Upgrade and Connection are
hop-by-hop headers that proxies strip by default. Caddy's
reverse_proxy handles the upgrade dance automatically,
one of the ergonomic wins that decided the door section. The nginx
branch needs it spelled out, and one timeout raised from a default
that silently kills idle connections at sixty seconds:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1; # 1.0 cannot carry an upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s; # default 60s drops idle sockets
proxy_connect_timeout 5s;
}
The read timeout wants a deliberate value, long enough that
heartbeat-quiet connections survive, not so long that dead
connections linger as ghosts; an hour pairs sensibly with
application-level ping/pong from TX·04. Missing
proxy_http_version 1.1 shows up as mysterious 400s on
the handshake, and a dead backend shows up as 502s, both worth
recognizing on sight.
Rate limits at the right layer
The edge and the app split abuse control by what each can see. The proxy sees addresses and volumes, so it gets the coarse, cheap controls: request-rate limits, concurrent-connection caps, body size, and slow-client timeouts that end Slowloris-style dribbles. The app sees identity, so it keeps the semantic limits TX·01 and TX·04 already built, per-account login attempts, per-user message rates, per-endpoint costs. The nginx spelling of the edge half:
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=ws_conn:10m;
server {
limit_req_status 429; # not the default 503; be honest with clients
limit_conn_status 429;
client_max_body_size 1m;
client_header_timeout 10s;
client_body_timeout 10s;
location /api/ { limit_req zone=api burst=40 nodelay; }
location /login { limit_req zone=login burst=3; }
location /ws { limit_conn ws_conn 10; }
}
The honesty clause for per-IP limits: carrier-grade NAT puts thousands of innocent users behind one IPv4 address, and a mobile attacker rotates addresses for free, so per-IP thresholds at the edge are a blunt instrument for stopping egregious floods, not a fairness mechanism. Set them generously, treat IPv6 subscribers as their /64 rather than a single address, and let precision live at the identity layer where it belongs. The connection cap matters most for the sockets: ten simultaneous WebSockets per address is plenty for humans and starves the trivial connection-exhaustion script. None of this stops a distributed botnet spreading load thin or an authenticated user abusing logic, the first needs scrubbing capacity a zero-dollar stack doesn't have, and the second is the app's job.
The edge feeds the SOC
Every request to the box now crosses one door, which makes the
door's log the highest-density telemetry the TX·06 pipeline
ingests. Structured JSON, so the SIEM parses fields instead of
regexes: Caddy does it by default, nginx via
log_format ... escape=json. The field set stays lean,
timestamp, the trusted client IP from the section above, method,
path, status, bytes, request time, user agent, TLS version and
cipher; cookies and full header dumps cost storage and risk
logging secrets for no detection value. On top of it, five edge
detections joined the SOC's roster, all of them cheap: scanner
spray, one source producing 404 bursts across many paths or
touching honeytoken paths like /.env and
/wp-login.php; 401 and 429 bursts against the login
route; TLS handshake anomaly spikes, deprecated-protocol attempts
showing up in the version field; upgrade abuse, failed-handshake
bursts or one address holding an abnormal socket count; and the
malformed-400 clusters from the seam section. Five rules, all
keyed on fields the log already carries, and the alert budget from
TX·06 still holds: a few actionable pages a day, or the pager gets
muted and the whole edifice is decorative.
Last piece, the one that outages are actually made of: certificate
expiry. With lifetimes headed to 45 days, renewal is a background
process that must never silently die, so it gets a dead man's
switch: an external check that alerts when the served certificate
is inside fourteen days of expiry, which only ever fires if
automation already failed quietly. A one-line
openssl x509 -checkend in a timer does the same job
from a second machine. The monitoring principle is the same as
everywhere else in this series: the simple check that keeps
running beats the elaborate one that rots.
The checklist
The edge, compressed into what I now hold the front door to:
- One proxy on the host owns 443; the backend binds to localhost only; outside-in nmap confirms nothing else answers
- Renewal fully automated (Caddy, or Certbot with ARI) and watched by an external expiry alert at fourteen days
- TLS 1.3 + 1.2 on the Intermediate profile or the proxy's own defaults; stapling and other dead OCSP directives deleted; 0-RTT off
- HSTS at two years with
includeSubDomains; no preload until the commitment is proven in months - Forwarding-header trust chain: proxy writes
X-Forwarded-Foritself, app trusts loopback only, and the forged-header curl test passes from outside - CSP with per-request nonce and
strict-dynamic,connect-srcnaming thewss://origin, enforcing within days of report-only, never parked there - One owner per header: proxy holds the static set, app emits the CSP, ownership commented in both configs,
curl -Iverifies reality - Dead headers stripped:
X-XSS-Protection,Expect-CT,X-Powered-By, verboseServer - COOP/COEP deferred until
SharedArrayBufferactually arrives, then rolled out report-only first - Node pinned to current LTS as the primary smuggling defense; proxy-to-backend stays plain HTTP/1.1, no h2c
- WebSocket proxying explicit on nginx (upgrade headers, hour-scale read timeout) or delegated to Caddy's defaults
- Edge limits return 429, sized generously for CGNAT; connection caps per address; semantic limits stay in the app on identity
- JSON access logs shipped off-box; the five edge detections live in the TX·06 pipeline within its alert budget
The pattern I keep re-learning, one article at a time: the glamorous knobs were already correct, and the wins were deleting dead config, distrusting a header, and watching the renewal that watches itself. Skills-wise this is the unglamorous middle of every SRE and application security posting, TLS and PKI hygiene, certificate lifecycle automation, edge log analysis into a SIEM, and it maps straight onto the operations domains of the certs on my ladder. The door is now solid, the SOC watches it, and the next question is the obvious one: who gets a key. That's TX·09, authentication for the game's account system, and the door metaphor is about to get literal.