# CDN

Luma can put a service behind Alibaba Cloud ESA (Edge Security Acceleration). One line in the manifest enables it; Luma then manages the ESA record and its rules for the life of the service. Services without `cdn` are unchanged and never touch Alibaba Cloud.

```text
User -> Alibaba Cloud ESA node -> (cache miss) -> CN Traefik -> service
```

## When to use it

- Public HTTP services on `cn-edge`, `external-edge` or `tailscale-relay`. Other exposures are rejected.
- Domains with an ICP filing use `scope: global`: one CNAME serves users in mainland China and abroad. `scope: overseas` needs no filing.
- DNS stays in Cloudflare. Luma writes a DNS-only CNAME to ESA; do not turn on the Cloudflare proxy for these names.

## Root domains (apex) {#root-domains-apex}

Serve the CDN on `www` and let the root domain redirect to it:

```yaml
name: web
image: ghcr.io/example/web:1.4.0
region: cn
exposure: cn-edge
domain: www.example.com
redirectFrom:
  hosts: [example.com]
  except: ["/api/*"]   # optional: served directly on example.com, never redirected
port: 3000
cdn: esa
```

- A root domain cannot be a CNAME, so Cloudflare must "flatten" it: Cloudflare resolves ESA itself and returns IP addresses. ESA then schedules every visitor by Cloudflare's location instead of the visitor's. Measured on itool.tech: a visitor in mainland China was served from US nodes and a page took 10 seconds. Luma therefore refuses `cdn` on the zone apex and explains this fix.
- `www.example.com` is a plain CNAME, so each visitor's own DNS resolver asks ESA and gets a nearby node.
- `example.com` stays a direct A record to the edge. GET and HEAD get a permanent redirect to `https://www.example.com` with the path and query kept; browsers cache it. Other methods reach the application unchanged, so payment callbacks and webhooks sent to the old address keep working.
- `except` lists paths served directly on the old host for every method. Use it for APIs other clients call with an `Authorization` header: browsers and most HTTP libraries drop that header when a redirect changes host, so the request would arrive unauthenticated. The short list form `redirectFrom: [example.com]` redirects every GET and HEAD.
- Point the application's canonical URL, sitemap and OAuth callbacks at `www`. Passkeys keep working when the WebAuthn RP ID stays the root domain.

## One-time setup

1. Buy an ESA plan in the Alibaba Cloud console. One plan serves one root domain (one ESA site). Luma never buys plans.
2. Create a RAM user with programmatic access only and attach this policy, which grants ESA and nothing else:

```json
{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "esa:*",
      "Resource": "*"
    }
  ]
}
```

3. Store the RAM user's AccessKey in Luma Control and check the setup:

```bash
luma secret set LUMA_ALIBABA_CLOUD_ACCESS_KEY_ID
luma secret set LUMA_ALIBABA_CLOUD_ACCESS_KEY_SECRET
luma cdn check
```

The variable names carry a `LUMA_` prefix so they never clash with Alibaba Cloud keys your applications use. `luma cdn check` without a domain verifies the key, the ESA plan, the Cloudflare zone and the ESA site.

## Enable it for a service

Add `cdn: esa` and deploy as usual:

```yaml
name: web
image: ghcr.io/example/web:1.4.0
region: cn
exposure: cn-edge
domain: www.example.com
port: 3000
cdn: esa
```

All fields:

```yaml
cdn:
  provider: esa                 # only esa today
  scope: global                 # global (default) | domestic | overseas
  purge: ["/", "/sitemap.xml"]  # omit: refresh the whole hostname; []: no refresh; "/blog/*": a directory
  cache:                        # paths that are the same for every visitor
    - path: /blog/*
      ttl: 300
    - path: /opengraph-image*
      ttl: 86400
```

List under `cache` only paths whose response never depends on who is signed in. A response the application marks `private` or `no-store` is still not cached.

## What a deploy does

1. **Prepare CDN**, before DNS changes. The first time for a Luma zone, Luma creates the ESA site for the zone with CNAME access, proves ownership with the `_esaauth` TXT record in Cloudflare and applies the site-wide settings below. For each service it creates a proxied ESA record pointing at the edge, updates it when the edge address changed, and reconciles the rules for its `cache` paths. It then writes the `_acme-challenge` CNAME that delegates certificate validation to ESA and waits, up to 15 minutes, until ESA has issued a free certificate that covers the domain and its edge completes a TLS handshake for it. A failure here, including a certificate that is still pending, stops the deploy before DNS changes; deploy again later. ESA renews the certificate through the same delegation.
2. **Sync DNS** points the domain at the ESA CNAME, which already serves HTTPS. Switching between A and CNAME replaces the record in place.
3. The Nomad rollout runs as before. `redirectFrom` hosts move to the edge right after it, once their redirect routes are live; a DNS failure for them is a warning. Hosts dropped from `redirectFrom` get their DNS record removed.
4. The public route probe connects to the edge directly with the domain as SNI, so a CDN error cannot trigger a recovery of a healthy application.
5. **Purge CDN** refreshes the cache after the new version is live: the whole hostname by default, or the `purge` paths. A refresh failure is a warning, never a failed deploy; run `luma cdn purge` to retry. A newly created record is not refreshed because it has no cache yet.

Site-wide settings, shared by every Luma domain on the ESA site:

- **Origin**: always fetch over HTTPS with the request's `Host`, and verify Traefik's certificate. ESA redirects plain HTTP to HTTPS at the edge.
- **Cache**: one site-wide rule makes every path eligible for caching and honours the application's cache headers, HTML included; a response without cache headers is not cached. ESA's global cache setting alone only covers static file extensions, so HTML would stay uncached.
- Gzip, Brotli and Zstd compression, HTTP/2 and HTTP/3 to visitors, HTTP/2 to the origin, WebSocket, and the `ali-real-client-ip` header with the real client address.

Each `cache` path gets one rule for its domain: honour the application's headers, and without them cache for `ttl` seconds. Path rules run after the site-wide rule, so their TTL applies, and `cache` paths cannot overlap. Cache rules count against a per-site quota shared by every application on the root domain: 5 on Free, 10 on Basic, 50 on Standard. The site-wide rule takes one. A deploy that would exceed it fails at Prepare CDN with that explanation.

ESA defaults cover the rest: TLS 1.0 off, basic DDoS protection and the managed WAF rules. Luma leaves paid options such as smart routing off.

## Cache as much as possible

Without its own configuration ESA caches only static files: HTML and paths without an extension are never cached, whatever the application returns. Luma's site-wide cache rule makes the application's headers count on every path, so caching more comes down to two levers:

- Return cache headers from the application:

```text
/_next/static/*, /assets/*.<hash>.js   Cache-Control: public, max-age=31536000, immutable
HTML pages                              Cache-Control: public, max-age=0, s-maxage=60
API, signed-in pages                    Cache-Control: private, no-store
```

- Or list the paths that are the same for every visitor under `cdn.cache`, without changing the application.

Audit a domain to see where you stand. The check is read-only: it fetches the home page, the `purge` and `cache` paths, a few assets referenced by the home page, `/sitemap.xml` and `/robots.txt`, twice each, anonymously:

```bash
luma cdn check www.example.com
luma cdn check www.example.com --path /pricing --format json
```

- **DANGER**: a cacheable response that sets a cookie, or an account-like path marked public. Fix these first; the command exits non-zero. The `acw_tc`, `cdn_sec_tc` and `acw_sc__v2` cookies are added by the ESA edge with a new value on every response and are never cached, so they are not counted.
- **IMPROVE**: a page without cache headers, a page revalidated on every request, a static file without cache headers, or large text sent uncompressed.
- **OK**: cacheable (and served from the edge when the second request hit), or explicitly not cached.

Files with a content hash in the name never need a refresh: a new version has a new URL. The default refresh after a deploy covers the whole hostname and only fetches changed resources again. ESA limits hostname and directory refreshes to about 100 per day per plan; URL refreshes allow far more. Applications that deploy many times a day should list fixed URLs in `purge`.

## Operations

```bash
luma cdn status
luma cdn purge www.example.com
luma cdn purge www.example.com / /pricing
luma cdn remove www.example.com
```

- `status` lists each domain with its site, CNAME, last refresh and last error.
- `purge` refreshes the whole hostname, or the given paths.
- `remove` deletes an ESA record and its `cache` rules once DNS no longer points at it.

To turn ESA off for a service, remove `cdn` from the manifest and deploy. DNS points at the edge again and the ESA record stays, so clients with a cached DNS answer keep working; delete it later with `luma cdn remove`. Removing the application deletes its ESA record and rules. The ESA site stays.
