
# 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.

```json title="config/policies.json"
{
  "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` <code className="text-green-600">&lt;string&gt;</code> - The name of your policy instance. This is used as a reference in your routes.
- `policyType` <code className="text-green-600">&lt;string&gt;</code> - The identifier of the policy. This is used by the Zuplo UI. Value should be `cdn-cache-control-outbound`.
- `handler.export` <code className="text-green-600">&lt;string&gt;</code> - The name of the exported type. Value should be `CdnCacheControlOutboundPolicy`.
- `handler.module` <code className="text-green-600">&lt;string&gt;</code> - The module containing the policy. Value should be `$import(@zuplo/runtime)`.
- `handler.options` <code className="text-green-600">&lt;object&gt;</code> - The options for this policy. [See Policy Options](#policy-options) below.

### Policy Options

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

- `cdn` **(required)** <code className="text-green-600">&lt;string&gt;</code> - 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` <code className="text-green-600">&lt;string&gt;</code> - 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` <code className="text-green-600">&lt;object&gt;</code> - 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)** <code className="text-green-600">&lt;integer&gt;</code> - Seconds the CDN may serve this response without revalidating.
  - `staleWhileRevalidate` <code className="text-green-600">&lt;integer&gt;</code> - 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` <code className="text-green-600">&lt;integer&gt;</code> - 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` <code className="text-green-600">&lt;object&gt;</code> - The `Cache-Control` sent to the browser or app. Separate from `edge` so the CDN can absorb load without clients holding stale data.
  - `maxAge` <code className="text-green-600">&lt;integer&gt;</code> - Seconds the client may reuse this response. Omit to send no `max-age`.
  - `visibility` <code className="text-green-600">&lt;string&gt;</code> - `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` <code className="text-green-600">&lt;boolean&gt;</code> - 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` <code className="text-green-600">&lt;boolean&gt;</code> - Adds `must-revalidate`, so the client may not serve the response once stale. Defaults to `false`.
  - `mode` <code className="text-green-600">&lt;string&gt;</code> - 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` <code className="text-green-600">&lt;string[]&gt;</code> - 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` <code className="text-green-600">&lt;string[]&gt;</code> - 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` <code className="text-green-600">&lt;boolean&gt;</code> - 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` <code className="text-green-600">&lt;object&gt;</code> - 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)** <code className="text-green-600">&lt;string&gt;</code> - Specifies the export that holds your cache config function, e.g. `default`, `cdnCacheConfig`. Defaults to `"default"`.
  - `module` **(required)** <code className="text-green-600">&lt;string&gt;</code> - 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:

```
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.

| `cdn`        | Edge TTL header                | Purge tag header | Tag separator |
| ------------ | ------------------------------ | ---------------- | ------------- |
| `akamai`     | `Edge-Control`                 | `Edge-Cache-Tag` | comma         |
| `fastly`     | `Surrogate-Control`            | `Surrogate-Key`  | space         |
| `cloudflare` | `Cloudflare-CDN-Cache-Control` | `Cache-Tag`      | comma         |
| `cloudfront` | `Cache-Control: s-maxage`      | none             | —             |
| `generic`    | `CDN-Cache-Control` (RFC 9213) | none             | —             |

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

## Example Configuration

```json
{
  "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:

```
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.

```json
{
  "export": "CdnCacheControlOutboundPolicy",
  "module": "$import(@zuplo/runtime)",
  "options": {
    "cdn": "cloudflare",
    "edge": { "maxAge": 300 },
    "client": { "visibility": "private", "maxAge": 0 }
  }
}
```

```
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

| Mode        | Behavior                                                                                                            |
| ----------- | ------------------------------------------------------------------------------------------------------------------- |
| `strictest` | **Default.** Emits the more conservative of the two — the lower `max-age`, and the union of restrictive directives. |
| `replace`   | Overwrites whatever the upstream sent.                                                                              |
| `preserve`  | Leaves 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:

| CDN        | Max tag length | Max tags | Max header size |
| ---------- | -------------- | -------- | --------------- |
| Akamai     | 128 chars      | 128      | 8192 bytes      |
| Fastly     | 1024 bytes     | —        | 16384 bytes     |
| Cloudflare | 1024 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.

```ts
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:

```ts
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:

| Return             | Effect                                                                                                                                                   |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `undefined`/`null` | Use the static options unchanged.                                                                                                                        |
| a config object    | Use 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".

```json
{
  "export": "CdnCacheControlOutboundPolicy",
  "module": "$import(@zuplo/runtime)",
  "options": {
    "cdn": "fastly",
    "cacheConfig": {
      "module": "$import(./modules/cdn-cache)",
      "export": "default"
    }
  }
}
```

```ts
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](https://zuplo.com/docs/policies/caching-inbound) 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](/articles/policies)
