ZuploZuplo
LoginStart for Free
  • Documentation
  • API Reference

CDN Cache Control Policy

The CDN Cache Control policy sets the response headers that tell a CDN in front of your gateway how long it may cache a response, and which purge tags that response belongs to — separately from what you tell the browser.

With this policy, you'll benefit from:

  • Two audiences, two policies: let the CDN hold a response for 30 minutes while browsers hold it for 60 seconds, so a purge at the edge actually takes effect
  • CDN-only caching: cache an authenticated response at the edge without any browser or intermediary storing it
  • No dialect to learn: Akamai's Edge-Control and Edge-Cache-Tag, Fastly's Surrogate-Control and space-separated Surrogate-Key, Cloudflare's Cache-Tag — you configure intent and the policy writes the right headers
  • Purge tags with guard rails: tags are validated against each CDN's length, count and character limits, because an over-long tag header is silently truncated at the edge rather than rejected
  • Safe by default: an upstream no-store, private or Set-Cookie keeps its veto, and the default merge mode never loosens a policy your application deliberately tightened
  • Dynamic configuration: a function can return per-response TTLs and per-entity purge tags for anything that can't be expressed statically

Set cdn to the CDN actually in front of your gateway. It has no default, because every CDN reads a different header and a wrong guess produces a well-formed response with no edge caching at all.

Configuration

The configuration shows how to configure the policy in the 'policies.json' document.

Code
{ "name": "my-cdn-cache-control-outbound-policy", "policyType": "cdn-cache-control-outbound", "handler": { "export": "CdnCacheControlOutboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "cacheConfig": { "export": "default", "module": "$import(./modules/my-module)" }, "cdn": "akamai", "client": { "maxAge": 60, "mode": "strictest", "mustRevalidate": false, "noCache": false, "visibility": "public" }, "edge": { "maxAge": 1800, "staleIfError": 86400, "staleWhileRevalidate": 600 }, "respectUpstream": true, "strategy": "targeted", "tags": ["catalog", "cities"], "vary": ["accept-language"] } } }

Policy Configuration

  • name <string> - The name of your policy instance. This is used as a reference in your routes.
  • policyType <string> - The identifier of the policy. This is used by the Zuplo UI. Value should be cdn-cache-control-outbound.
  • handler.export <string> - The name of the exported type. Value should be CdnCacheControlOutboundPolicy.
  • handler.module <string> - The module containing the policy. Value should be $import(@zuplo/runtime).
  • handler.options <object> - The options for this policy. See Policy Options below.

Policy Options

The options for this policy are specified below. All properties are optional unless specifically marked as required.

  • cdn (required) <string> - The CDN in front of this gateway. Required, with no default — each CDN reads a different header for shared-cache TTL and purge tags, and a wrong guess fails silently. Use generic for an RFC 9213 CDN that is not listed. Allowed values are akamai, fastly, cloudflare, cloudfront, generic.
  • strategy <string> - How the edge TTL is carried. targeted uses the CDN's own header (Edge-Control, Surrogate-Control, CDN-Cache-Control) and is the only way to cache at the edge without also caching in the browser. s-maxage folds the edge TTL into Cache-Control instead, for a property that honors origin Cache-Control but not the targeted header. Defaults to targeted, except on a CDN that reads no targeted header — CloudFront defaults to s-maxage, which is the only thing it supports. Allowed values are targeted, s-maxage. Defaults to "targeted".
  • edge <object> - How long the CDN may serve this response. These directives are sent only to the CDN, never to the browser (unless strategy is s-maxage).
    • maxAge (required) <integer> - Seconds the CDN may serve this response without revalidating.
    • staleWhileRevalidate <integer> - Seconds the CDN may keep serving a stale response while it revalidates in the background. Not expressible in Akamai's Edge-Control — configure it in Property Manager, or use strategy: s-maxage.
    • staleIfError <integer> - Seconds the CDN may serve a stale response when the gateway returns an error. Not expressible in Akamai's Edge-Control — configure it in Property Manager, or use strategy: s-maxage.
  • client <object> - The Cache-Control sent to the browser or app. Separate from edge so the CDN can absorb load without clients holding stale data.
    • maxAge <integer> - Seconds the client may reuse this response. Omit to send no max-age.
    • visibility <string> - public allows any cache to store the response, private restricts it to the client's own cache, no-store forbids client caching entirely. With strategy: targeted a private/no-store client policy still permits edge caching, because the CDN reads its own header. Allowed values are public, private, no-store.
    • noCache <boolean> - Adds no-cache, so the client must revalidate before reusing the response. Distinct from visibility: no-store, which forbids storing it at all. Defaults to false.
    • mustRevalidate <boolean> - Adds must-revalidate, so the client may not serve the response once stale. Defaults to false.
    • mode <string> - How to combine the configured client policy with a Cache-Control the upstream already sent. strictest (default) emits the more conservative of the two — the lower max-age and the union of restrictive directives. replace overwrites whatever upstream sent. preserve leaves the upstream header untouched and only adds the CDN headers. Allowed values are strictest, replace, preserve. Defaults to "strictest".
  • tags <string[]> - Purge tags, rendered in the target CDN's format: comma-joined Edge-Cache-Tag (Akamai), space-joined Surrogate-Key (Fastly), comma-joined Cache-Tag (Cloudflare). CloudFront has no tag purge and rejects this option. Tags are validated against the CDN's length and character limits, because an over-long tag header is silently truncated rather than rejected. Defaults to [].
  • vary <string[]> - Request headers that vary this response, unioned with any Vary the upstream already sent and emitted as Vary. The union is deliberate: replacing an upstream Vary: Authorization would stop a shared cache keying on it and let it serve one user's response to another. Off by default: Akamai skips caching any response carrying Vary until Cache ID Modification is configured, so enabling this can silently disable edge caching entirely. Defaults to [].
  • respectUpstream <boolean> - When true (default), an upstream Cache-Control of no-store/no-cache/private, or a Set-Cookie on the response, suppresses all edge headers and purge tags. This keeps the application's ability to mark a response uncacheable. Set to false to override it — on Akamai this emits Edge-Control: !no-store. Defaults to true.
  • cacheConfig <object> - A function that overrides the cache configuration per response, for rules that cannot be expressed statically (per-entity purge tags, a TTL that depends on a response header, or skipping an empty result set). Returning nothing (undefined or null) falls back to the edge, client and tags options, so those stay the enforced default and the function only handles exceptions. A returned config **replaces** those options wholesale rather than being merged directive by directive, so include every directive the response needs. Return { cache: false } to skip caching a response entirely — note that also sends Cache-Control: no-store to the client unless client.mode is preserve. The function receives the live response: it must not consume the body, and should call response.clone() if it needs to read it.
    • export (required) <string> - Specifies the export that holds your cache config function, e.g. default, cdnCacheConfig. Defaults to "default".
    • module (required) <string> - Specifies the module that holds your cache config function, in the format $import(./modules/my-module). Defaults to "".

Using the Policy

The CDN Cache Control policy sets the response headers a CDN in front of your gateway reads to decide how long it may cache a response and which purge tags that response belongs to. It keeps that policy separate from the Cache-Control you send to the browser.

Why the two are separate

A single Cache-Control header has to serve two audiences with different needs. If you write Cache-Control: public, max-age=1800 so your CDN holds a response for 30 minutes, you have also told every browser and mobile app to hold it for 30 minutes. When the underlying data changes you can purge the edge in seconds — but no purge reaches those clients, and they keep serving stale data until the TTL runs out.

This policy writes the edge TTL into a header only the CDN reads, so the two can differ:

Code
Cache-Control: public, max-age=60 Edge-Control: cache-maxage=30m Edge-Cache-Tag: catalog,cities

The CDN absorbs the traffic for half an hour, clients re-check every minute, and a purge by the cities tag takes effect immediately for everyone.

Choosing the CDN

cdn is required and has no default. Each CDN reads a different header, so a default would silently produce a well-formed response with no edge caching for anyone using a different one.

cdnEdge TTL headerPurge tag headerTag separator
akamaiEdge-ControlEdge-Cache-Tagcomma
fastlySurrogate-ControlSurrogate-Keyspace
cloudflareCloudflare-CDN-Cache-ControlCache-Tagcomma
cloudfrontCache-Control: s-maxagenone—
genericCDN-Cache-Control (RFC 9213)none—

Use generic for a CDN that implements RFC 9213 targeted cache control but is not listed above.

Example Configuration

Code
{ "export": "CdnCacheControlOutboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "cdn": "akamai", "edge": { "maxAge": 1800 }, "client": { "visibility": "public", "maxAge": 60 }, "tags": ["catalog", "cities"] } }

On Fastly the same options emit space-separated surrogate keys, which is the format Fastly's purge API expects:

Code
Cache-Control: public, max-age=60 Surrogate-Control: max-age=1800 Surrogate-Key: catalog cities

CDN-only caching

The most valuable configuration is one a single Cache-Control cannot express: the response is identical for every caller but requires an Authorization header, so you want the CDN to absorb the load while no browser stores it.

Code
{ "export": "CdnCacheControlOutboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "cdn": "cloudflare", "edge": { "maxAge": 300 }, "client": { "visibility": "private", "maxAge": 0 } } }
Code
Cache-Control: private, max-age=0 Cloudflare-CDN-Cache-Control: max-age=300

This works because a CDN that honors a targeted cache header ignores Cache-Control entirely for its own decision — it never sees the private.

edge

How long the CDN may serve the response.

  • maxAge — seconds the CDN may serve the response without revalidating.
  • staleWhileRevalidate — seconds the CDN may keep serving a stale response while it revalidates in the background.
  • staleIfError — seconds the CDN may serve a stale response when the gateway returns an error. This is often the highest-value directive for an API: it keeps reads working through a backend outage.

Akamai's Edge-Control header cannot express the two stale-* windows. If you need them on Akamai, either configure stale serving in Property Manager or set strategy to s-maxage. The policy rejects the combination rather than dropping the directives silently.

client

The Cache-Control sent to the browser or app.

  • maxAge — seconds the client may reuse the response.
  • visibility — public, private, or no-store.
  • noCache — adds no-cache, so the client revalidates before reusing the response. Distinct from visibility: no-store, which forbids storing it at all.
  • mustRevalidate — adds must-revalidate, so the client may not serve the response once stale.
  • mode — how to combine this with a Cache-Control the upstream already sent.

client.mode

ModeBehavior
strictestDefault. Emits the more conservative of the two — the lower max-age, and the union of restrictive directives.
replaceOverwrites whatever the upstream sent.
preserveLeaves the upstream Cache-Control untouched and only adds the CDN headers.

strictest is the default because loosening a cache directive is a data disclosure risk, not only a performance one. If your application returns Cache-Control: private for a per-user response and the policy widened that to public, a shared cache could serve one user's data to the next. Use replace when you intend the gateway to be the authority on client caching.

strategy

How the edge TTL reaches the CDN.

  • targeted (default) — the CDN's own header. Required for CDN-only caching.
  • s-maxage — folds the edge TTL into Cache-Control as s-maxage, for a property that honors origin Cache-Control but not the targeted header.

On CloudFront the default is s-maxage, since it reads no targeted header and there is nothing to choose between. You only need to set strategy explicitly when you want s-maxage on a CDN that does read a targeted header — for example an Akamai property that honors origin Cache-Control but has not enabled Edge-Control. Setting strategy: "targeted" on CloudFront is a build-time error, because that is a contradiction rather than an omission.

Either way, the policy removes any targeted header — or purge-tag header — your backend already set that the configured CDN reads, unless the policy is writing that header itself. A targeted header outranks Cache-Control, so an upstream Surrogate-Control: max-age=99999 reaching Fastly would silently beat the TTL configured here — particularly under s-maxage, where the policy's own TTL is in Cache-Control. Only the configured vendor's headers are touched: on Cloudflare that means both Cloudflare-CDN-Cache-Control and CDN-Cache-Control, while on CloudFront (which reads neither) nothing is removed.

That includes the tag header when you configure no tags: a backend-set Surrogate-Key was never checked against the vendor's limits, so it is cleared rather than passed through unvalidated.

If your backend is the one that should own the edge policy, do not apply this policy to that route. There is no configuration that lets a backend-set targeted header through: edge is required unless cacheConfig supplies it at runtime, and the vendor's headers are stripped on every path — including when a response is opted out with cache: false, which removes them and writes nothing in their place. client.mode: "preserve" preserves the client Cache-Control only; it has no bearing on the edge headers.

tags

Purge tags, rendered in the target CDN's format. Tags let you invalidate a slice of the edge cache — every response tagged cities after a city sync — without waiting for the TTL or purging everything.

Tags are validated against the CDN's limits before they are emitted:

CDNMax tag lengthMax tagsMax header size
Akamai128 chars1288192 bytes
Fastly1024 bytes—16384 bytes
Cloudflare1024 chars—16384 bytes

This validation matters because the failure mode is silent. Fastly ignores the key it is parsing and every key after it once a limit is hit, so an over-long tag makes later tags quietly unpurgeable. Akamai additionally rejects spaces, commas, colons and brackets in tag values.

A tag that violates a limit is dropped and logged as a warning rather than failing the response — a 200 should not become a 500 over a purge tag — except in static configuration, where it is a configuration error you can fix before deploying.

CloudFront has no purge-by-tag support; use path invalidation instead.

respectUpstream

When true (the default), an upstream Cache-Control of no-store, no-cache or private, or a Set-Cookie on the response, suppresses every edge header and purge tag. Your application keeps the ability to mark a response uncacheable, and that keeps working even if the policy is applied broadly.

Set it to false to override the upstream. On Akamai this emits Edge-Control: !no-store, the vendor's own directive for caching at the edge despite a no-store.

vary

Request headers that vary the response, emitted as Vary. This is off by default for a reason specific to Akamai: Akamai skips caching any response carrying a Vary header until Cache ID Modification is configured in Property Manager. Turning this on can therefore disable edge caching entirely while the response still looks correct. The policy logs a warning when vary is set together with cdn: akamai.

The names you configure are unioned with any Vary the upstream already sent, deduplicated case-insensitively; Vary: * from either side absorbs the rest. Unlike the edge headers, Vary is still emitted when an upstream veto or cache: false suppresses edge caching — a private, max-age=300 response is uncacheable at the edge but still cached by the browser, and it needs the variant key just as much. The policy never narrows an upstream Vary, because doing so silently is unsafe: if the origin sent Vary: Authorization and you configure vary: ["accept-language"], replacing it would stop a shared cache keying on Authorization and let it serve one user's authorized response to another. To deliberately drop a wasteful upstream Vary — Vary: User-Agent shreds hit rates — remove it in a separate outbound policy before this one runs.

If your response genuinely varies by a request header, configure the cache key at the CDN — Cache ID Modification on Akamai, Cache Keys on Cloudflare — rather than relying on Vary.

cacheConfig

For rules that cannot be expressed statically, a function can return the cache configuration per response.

The static options stay the enforced default: a function that returns nothing falls back to them. So you configure the policy once for the route, and the function only has to handle the exceptions.

Code
export default function cdnCache(response: Response) { // An empty result set is usually a transient upstream problem. if (response.headers.get("x-result-count") === "0") { return { cache: false }; } // Everything else: fall back to the configured edge/client/tags. return undefined; }

When the function does return a config, that config replaces the static options rather than being merged into them field by field — so a returned { edge: { maxAge: 60 } } drops a statically configured staleIfError and the static tags. Return the complete policy for the responses you override.

Reading the response body

The function receives the live response, not a copy. Do not consume the body — the policy still has to forward it, and reading it first makes that impossible. If you need to inspect the payload, clone it:

Code
const body = await response.clone().json();

Cloning is left to you rather than done for every response: clone() tees the stream, and a branch nobody reads keeps the whole body in memory. Most rules can avoid the body entirely — a response header such as x-result-count is cheaper than parsing JSON on every cacheable response.

If the body is consumed without cloning, the policy fails the request with a message naming the cause, rather than letting the response go out truncated.

The three return values:

ReturnEffect
undefined/nullUse the static options unchanged.
a config objectUse it in place of the static options.
{ cache: false }Emit no edge headers or tags, and send Cache-Control: no-store to the client — except under client.mode: "preserve", which is a trap; see below.

{ cache: false } is a positive statement that the response must not be cached anywhere, so it disables client caching as well as edge caching.

Under client.mode: "preserve" it does not, and this is a sharp edge. The policy suppresses its own edge headers but leaves the upstream Cache-Control alone — and that header is exactly what a CDN falls back to once the targeted header is gone. An upstream Cache-Control: public, max-age=300 therefore keeps the response cacheable at the edge for five minutes despite the cache: false. On CloudFront, which reads no targeted header at all, cache: false under preserve changes nothing whatsoever.

To make the edge skip a response while clients still cache it normally, return { edge: { maxAge: 0 } } with client.mode: "preserve". That states a zero edge TTL in the CDN's own dialect — Surrogate-Control: max-age=0 on Fastly, s-maxage=0 appended to the client's own directives on CloudFront — rather than relying on the absence of a header to mean "do not cache".

Code
{ "export": "CdnCacheControlOutboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "cdn": "fastly", "cacheConfig": { "module": "$import(./modules/cdn-cache)", "export": "default" } } }
Code
import { CdnCacheControlConfig, ZuploContext, ZuploRequest, } from "@zuplo/runtime"; export default function cdnCache( response: Response, request: ZuploRequest, context: ZuploContext, ): CdnCacheControlConfig { // An empty result set is usually a transient upstream problem — don't cache it. if (response.headers.get("x-result-count") === "0") { return { cache: false }; } const cityId = new URL(request.url).searchParams.get("cityId"); return { edge: { maxAge: 1800, staleIfError: 86400 }, client: { maxAge: 60, visibility: "public" }, tags: ["cities", `city-${cityId}`], }; }

The function returns intent, not headers: the policy renders it into whichever dialect cdn names, so switching CDNs is a one-line configuration change rather than a rewrite. Per-entity tags like city-42 are the main thing that cannot be expressed statically, and they are what makes fine-grained purging possible.

Note that { cache: false } and undefined are not the same: cache: false suppresses caching for that response, while undefined falls back to the static options.

Using this with the Caching policy

This policy sets headers for a CDN outside Zuplo. The Caching policy is a separate, gateway-side response cache.

Do not use the two on the same route. They are alternative places to cache, not layers that stack, and combining them gives you neither policy reliably.

The Caching policy serves a hit from an inbound policy, which short-circuits the pipeline before outbound policies run — so this policy does not execute on a gateway cache hit. The CDN instead receives the copy the Caching policy stored, which it sanitizes on the way in: several CDN headers are removed and Cache-Control is overwritten with its own s-maxage (60s by default). Cache misses behave correctly, so the problem is invisible until traffic warms the cache.

Pick one:

  • Cache at the CDN — use this policy alone. Purges are global and fast, and the edge/client split works as configured.
  • Cache at the gateway — use the Caching policy alone, and let it own the Cache-Control it sends.

Best Practices

  1. Make the client TTL much shorter than the edge TTL. The edge is what you can purge; clients are not. A short client max-age with a long edge.maxAge gives you both offload and control.
  2. Set staleIfError on read endpoints where CDN support allows it. Serving slightly stale data through a backend outage is almost always better than serving an error.
  3. Tag by entity, not just by route. city-42 lets you purge one city; cities alone forces you to purge them all.
  4. Leave respectUpstream on so the application retains the final say.
  5. Verify at the edge, not just at the gateway. curl -I against your Zuplo URL shows the headers; whether the CDN honors them depends on its configuration (Akamai's "Honor origin Cache-Control", Cloudflare's Origin Cache Control, a Cache Rule that makes the path cacheable at all).

Limitations

  • Purge tags are emitted, not purged. Invalidation is an API call to your CDN from your own tooling.
  • Edge cache-key configuration is not expressible in response headers and remains CDN configuration.
  • CloudFront supports neither a targeted cache header nor purge tags.

Read more about how policies work

Edit this page
Last modified on August 4, 2026
On this page
  • Configuration
    • Policy Configuration
    • Policy Options
  • Using the Policy
  • Why the two are separate
  • Choosing the CDN
  • Example Configuration
  • CDN-only caching
  • edge
  • client
    • client.mode
  • strategy
  • tags
  • respectUpstream
  • vary
  • cacheConfig
    • Reading the response body
  • Using this with the Caching policy
  • Best Practices
  • Limitations
JSON
JSON
JSON
TypeScript
TypeScript
JSON
TypeScript