Module F-15 — Reverse Proxies, API Gateways, and CDNs
What this module covers: The layer in front of everything, and the three distinct things people call "the edge." This module separates reverse proxy, API gateway, and CDN by the job each does, then covers what belongs at the edge and what must never move there, TLS termination and who holds the certificate, Cache-Control semantics precisely (including the four directives that solve real problems and the two that don't do what their names suggest), conditional requests, CDN cache keys and the misconfiguration that leaks one user's data to another, purging strategies and why versioned URLs beat purging, origin shielding and request collapsing, and the protections a proxy gives you that have nothing to do with caching.
Three Different Things
Reverse proxy
API gateway
CDN
Examples
nginx, HAProxy, Envoy, Caddy
Kong, Apigee, AWS API Gateway, Envoy
Cloudflare, Fastly, CloudFront, Akamai
Primary job
Route, terminate TLS, buffer
Enforce per-consumer policy
Cache content near users
Location
Your infrastructure
Your infrastructure
Hundreds of PoPs worldwide
Typically knows about
Backends
Consumers, keys, quotas
Cache keys, TTLs, geography
They overlap heavily in practice — a CDN does reverse-proxy work, Envoy can be all three — but the jobs are distinct, and conflating them is how policy ends up in the wrong place.
A reverse proxy sits in front of your application servers: routes by host and path, terminates TLS, buffers requests and responses, adds and strips headers, compresses. It's infrastructure plumbing, and it protects your app servers in ways covered at the end of this module.
An API gateway enforces policy about consumers: authentication and API keys, per-client rate limits and quotas (Module P-18), request/response transformation, usage metering, versioned routing. The distinguishing feature is that it knows who is calling, not just what they're asking for.
A CDN is a geographically distributed cache. Its value is proximity — a cache hit at a PoP 10ms from the user never reaches your infrastructure at all. And as Module F-5 established, terminating TLS 10ms away instead of 180ms away is a large latency win even for content the CDN can't cache.
Analogy: a large office building's front desk, its security barrier, and the newsagent in the lobby. The front desk takes every visitor, checks where they are going and sends them to the right lift — it does not care who you are, only where you belong; that is the reverse proxy. The barrier knows your pass, your access level and how many times you have badged in today, and turns you away by policy; that is the API gateway. The newsagent stocks the same paper everyone wants so nobody has to travel to the printer for it — and stocks it in the lobby of every building in the city; that is the CDN. Conflating them is how the barrier ends up deciding routing, or the newsagent ends up handing one visitor a parcel addressed to another, which is exactly the cache-key failure later in this module.
What Belongs at the Edge
The edge is the right home for cross-cutting concerns that every request needs and that don't require your domain data:
Cheap authentication checks — verifying a signature with a local key
And the rule for what must not move there:
Business logic must not live in the gateway.
This is worth being firm about, because gateway products actively encourage it — most support request transformation, conditional routing, and scripting. The reasons it's a bad trade:
It's logic outside your repository, often outside version control, usually untested, and invisible to anyone reading the codebase. A rule in a gateway console is a rule nobody knows exists.
It can't be tested locally. Behaviour that only exists in deployed configuration is behaviour you find out about in production.
It becomes a shared mutable bottleneck. Every team's special case accumulates in one config, and eventually nobody will change it.
It's the wrong deployment unit. A logic change now means a gateway config change, with a different review process and a different rollback story.
The line is: the edge decides whether a request proceeds; the application decides what the request means. An edge check that rejects a request with an invalid signature is fine. An edge rule computing a discount is a maintenance liability with a long tail.
Why this matters in production: the failure mode is diagnostic. A request behaves in a way the code cannot explain, because the explanation is in a config file in a vendor console, written by someone who has since left. Time to resolution on those is measured in days.
TLS Termination: Where, and Who Holds the Key
Where you terminate TLS determines who can read the traffic and how much handshake cost the user pays.
Terminate at the CDN edge (the common choice): the expensive handshake happens close to the user, and the CDN holds a warm pooled connection to your origin. The CDN can therefore see the plaintext — which is what lets it cache, compress, and apply a WAF, and is also exactly what some compliance regimes forbid.
Terminate at your own load balancer: you keep control of certificates and plaintext; users pay the full-distance handshake.
End-to-end encryption with re-encryption at each hop: the edge decrypts, processes, and re-encrypts to your origin. This is the normal production configuration, and the origin certificate should be validated rather than skipped — an unauthenticated origin connection is a real gap, and "we'll fix the cert later" survives for years.
mTLS to the origin: the origin only accepts connections presenting a client certificate the CDN holds. This closes the hole where someone who discovers your origin IP bypasses the CDN entirely — including its WAF and rate limits. Worth doing, and routinely skipped.
Cache-Control, Precisely
Most caching bugs at this layer are misunderstood directives. The ones that matter:
http
max-age=60 — browsers may reuse this for 60 seconds.
s-maxage=3600 — shared caches (CDN, proxy) may reuse it for an hour. Overrides max-age for them. This split is the most useful pair here: short in the browser so users see updates promptly, long at the CDN so your origin is protected.
public / private — private means only the end user's browser may store it, never a shared cache. Every personalised response needs private (or no-store), and forgetting it is one route to the data-leak scenario below.
no-store — do not store this anywhere, at all. What you want for genuinely sensitive responses.
no-cache — not "don't cache." It means "you may store it, but revalidate before reuse." The name is a historical accident and it misleads people constantly.
must-revalidate — once stale, do not serve without checking the origin.
stale-while-revalidate=86400 — serve the stale copy immediately and refresh in the background. This is the directive that gives you cache latency with near-fresh data and no request paying the miss cost. If you take one thing from this section, take this one.
stale-if-error=604800 — if the origin is failing, keep serving the stale copy. This is an availability feature disguised as a caching directive: with a week's grace, a CDN can keep serving your site through an origin outage. Cheap, and criminally under-used.
immutable — this content will never change at this URL. Correct for hashed asset filenames, and it stops browsers from revalidating on reload.
Conditional requests are the other half. With ETag or Last-Modified, a cache can ask "has this changed?" and receive a 304 Not Modified with no body:
http
Note carefully what this saves and what it doesn't: it saves bandwidth, not the round trip. The client still pays full latency to ask. For a large asset over a slow link that's a big win; for a small JSON response over a high-RTT link, a max-age that avoids asking at all is far better. Choose based on which resource you're conserving.
Cache Keys at the CDN: The Configuration That Leaks Data
A CDN decides what counts as "the same request." By default that's usually method + host + path + query string. Everything else — cookies, headers, auth — is ignored unless you say otherwise.
Which produces the single most serious misconfiguration at this layer:
text
That's a real class of incident, not a thought experiment. The defences, and you want more than one:
Mark every personalised response private or no-store, at the application, as the default for authenticated routes rather than an annotation people remember to add.
Include the auth dimension in the cache key where you do want to cache per-user, and be aware the hit rate will be per-user.
Use Vary correctly (Vary: Authorization, Accept-Encoding, Accept-Language) — but know that Vary: Cookie is usually catastrophic for hit rate, because every distinct cookie value is a separate entry.
Normalise the cache key deliberately. Strip tracking parameters (utm_*, fbclid) that don't change the response, or they fragment your cache into thousands of identical copies. Sort the remaining parameters so ?a=1&b=2 and ?b=2&a=1 are one entry.
The general rule from Module F-12 applies with higher stakes here, because the blast radius is every user: the key must include everything that varies the response and nothing that doesn't.
Purging, and Why Versioned URLs Are Better
When content changes before its TTL expires, you purge.
Hard purge — delete the entry. The next request is a miss and hits the origin. Simple, and it exposes you to a stampede if the object is hot.
Soft purge — mark stale rather than deleting, so stale-while-revalidate behaviour applies: users keep getting instant responses while one background request refreshes. Strictly better for hot content.
Surrogate-key (tag) purge — tag objects on the way out (Surrogate-Key: product-42 category-shoes) and purge by tag. One product update can invalidate every page that included it, without enumerating URLs. This is the mechanism behind tag-based invalidation generally (Module P-13), and it's what makes aggressive edge caching of dynamic pages viable.
Two operational realities: purge propagation is not instant (usually seconds, across hundreds of PoPs, and it can be slower), and purge APIs are rate limited. A design that purges on every write will hit those limits and then fail silently, which is the worst kind of caching bug.
Which is why, wherever you can:
Change the URL instead of purging it.
/assets/app.a3f5c1.js with max-age=31536000, immutable never needs purging — a new build produces a new filename, and the old one can stay cached forever. Deploys become atomic (no window where new HTML references old assets), rollback is trivial, and there is no invalidation logic to be wrong. The same idea works for data: a version segment in the cache key (Module F-12) invalidates everything at once by simply not being asked for again.
Origin Shield, Request Collapsing, and the Stampede
A CDN with 300 PoPs has a problem your single cache doesn't: 300 independent caches means 300 independent misses for the same object.
text
Two features handle this, and they are worth switching on explicitly rather than assuming:
Tiered caching / origin shield — PoPs fetch from a designated parent PoP rather than the origin, collapsing the fan-in and also giving the origin a warmer, closer, pooled connection.
Request collapsing (coalescing) — when many concurrent requests for the same object arrive at a PoP during a miss, only one goes to the origin and the rest wait for that response. This is the edge's answer to the thundering herd, and it's the same mechanism Module P-12 builds in the application tier.
Without both, a popular object's expiry is a synchronised spike at your origin — which is Module F-12's warning about hit-ratio dependency, arriving through the CDN.
What a Proxy Gives You Besides Caching
Worth listing, because these justify a proxy even for entirely dynamic, uncacheable traffic:
TLS termination near the user, and HTTP/2 and HTTP/3 support without touching your application (Modules F-5, F-6).
Request and response buffering. The proxy reads the full request before passing it on, so a slow client occupies a cheap proxy connection instead of an expensive application worker. This is the direct defence against slowloris-style attacks, and on a thread-per-request server it's the difference between resilience and trivial exhaustion.
DDoS absorption. A large CDN has capacity you cannot buy for one origin, and volumetric attacks stop at the edge.
WAF and bot management. Imperfect, and still valuable as a layer.
Compression and image optimisation — Brotli, WebP/AVIF conversion, resizing on the fly. Real bandwidth savings without application changes, which matters directly to Module F-3's egress arithmetic.
Hiding origin topology. Clients see the edge. Your origin IPs aren't public — which only holds if you also enforce origin access with mTLS or an allowlist, per the TLS section above.
Where This Shows Up in Your Stack
Next.js:revalidate maps onto s-maxage, and revalidateTag maps onto surrogate-key purging. Understanding the header semantics is what lets you reason about why a page is stale — and which cache answered.
PostgreSQL: the CDN is the cheapest protection your database has. A 99% edge hit rate means 1% of read traffic reaches the origin at all — the highest-leverage version of Module F-12's hit-ratio arithmetic.
Node.js: put a proxy in front of it. Node is single-threaded per process, so a slow client holding a connection while your process waits on it is exactly the resource you can least afford to spend; the proxy absorbs that.
API keys and rate limits: these belong at the gateway, where they're enforced before any application capacity is consumed. Rate limiting inside your application still costs you the request (Module P-18).
Summary
Three distinct jobs: reverse proxy (route, terminate, buffer), API gateway (per-consumer policy — it knows who is calling), CDN (geographically distributed cache).
Cross-cutting, data-independent concerns belong at the edge. Business logic does not — it becomes untested, untestable, invisible configuration that no code review will catch.
The edge decides whether a request proceeds; the application decides what it means.
TLS termination location decides who reads the plaintext and how much handshake the user pays. Validate origin certificates, and use mTLS or an allowlist so nobody can bypass the edge by finding your origin IP.
Know the directives:s-maxage for shared caches, private/no-store for personalised data, no-cache means revalidate (not "don't cache"), stale-while-revalidate for free freshness, and stale-if-error as an availability feature that serves your site through an origin outage.
Conditional requests save bandwidth, not round trips. For small responses over high-RTT links, not asking at all beats a cheap 304.
A CDN cache key that ignores the auth dimension serves one user's data to another. Default authenticated responses to private, use Vary deliberately, and normalise away tracking parameters.
Prefer soft and tag-based purges over hard purges, and know that purge propagation isn't instant and purge APIs are rate limited.
Change the URL instead of purging it — hashed filenames with immutable need no invalidation logic at all.
Enable origin shielding and request collapsing, or a hot object's expiry becomes 300 simultaneous origin requests.
A proxy earns its place even for uncacheable traffic: buffering against slow clients, DDoS absorption, WAF, compression, and protocol support.
The next module is the Foundation capstone: Design a URL Shortener — one complete single-region system that uses every piece of Phase 1, from the estimation to the cache hierarchy to the redirect hot path.
Knowledge Check
GET /api/me returns the authenticated user's profile. It sets no Cache-Control header, and the CDN's cache key is host + path. What happens, and what's the layered fix?
A team wants aggressive edge caching but needs product pages to update promptly after an edit. They implement a hard purge of each affected URL on every product write. What problems does the module identify?
A team argues a CDN is pointless for their fully dynamic, personalised API since almost nothing is cacheable. What does the module say?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
GET /api/products/42
If-None-Match:"a3f5c1"HTTP/1.1304Not ModifiedETag:"a3f5c1"
1. GET /api/me returns the authenticated user's profile.
2. The response has no `private` and no `no-store`, so it looks cacheable.
3. The CDN's cache key is host + path — it ignores the `Authorization` header.
4. Alice requests /api/me. Her profile is cached.
5. Bob requests /api/me. The CDN serves Alice's profile.
Without shielding: hot object expires → 300 PoPs each miss → 300 origin requests
With origin shield: hot object expires → 300 PoPs miss → 1 designated PoP
fetches from origin → 1 origin request