MRPH·LAB

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:

Caddyfile, the entire front door
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:

the CSP, emitted by the app per request
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:

nginx, the trust boundary
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:

app.js
// 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:

nginx, the WebSocket location
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:

nginx, coarse limits
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:

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.