CDN

Put a CDN in front of a site or bucket: your own edge nodes with GeoDNS, signed URLs and purge by API, plus Cloudflare and Bunny from the same screen.

In preview cdn version 0.1.0

Actions

37 actions, callable from the panel, the command palette and the API as POST /api/v1/a/<id>. Internal actions used between modules are not listed.

ActionWhat it doesRiskPreview
cdn.zone.create Create a pull or storage zone medium
cdn.zone.update Change a zone (origin, cache, security, TLS, suspend) medium
cdn.zone.delete Delete a zone: stop serving, drop its cache and certificate high
cdn.zone.list List zones (read) low No dry run
cdn.zone.get Get a zone with its hostnames, rules, settings and origin lockdown header (read) low No dry run
cdn.hostname.add Add a custom hostname (needs verification before it serves) medium
cdn.hostname.verify Check the CNAME or TXT record of a custom hostname and activate it medium No dry run
cdn.hostname.remove Remove a custom hostname medium
cdn.rule.set Replace the ordered cache rules of a zone medium
cdn.rule.preview Show which rule and settings a URL would get (read) low No dry run
cdn.purge Purge cached objects by URL, prefix, cache tag or everything low
cdn.purge.history Recent purges of a zone (read) low No dry run
cdn.token.rotate Add a new signing key; the old one keeps validating for the overlap medium
cdn.token.sign Generate a signed URL (read) low No dry run
cdn.token.snippet Code that signs URLs in your language (node, python, php, go, ruby, java, dotnet, curl) (read) low No dry run
cdn.stats.get Requests, bandwidth, hit ratio, status codes, top URLs of a zone (read) low No dry run
cdn.usage.get Bandwidth used this month against the account limit (read) low No dry run
cdn.api.describe How to call the CDN from your own code: endpoints, token, examples (read) low No dry run
cdn.edge.list Edge nodes with health, cache size and what GeoDNS answers with (read) low No dry run
cdn.edge.enable Make a node an edge: role, nginx stack, config, health, then GeoDNS high
cdn.edge.drain Stop answering with an edge (existing DNS answers expire by TTL) high
cdn.edge.undrain Answer with a drained edge again medium
cdn.edge.remove Remove the edge stack from a node critical
cdn.edge.health Probe edges now (read) low No dry run
cdn.region.get The region map (pops, countries, client networks) (read) low No dry run
cdn.region.set Replace the region map and re-steer GeoDNS medium
cdn.settings.get Global CDN settings (read) low No dry run
cdn.settings.set Change one global CDN setting medium
cdn.reconcile Re-apply edge configuration and GeoDNS from the stored state low
cdn.integration.create Connect Cloudflare or Bunny with an API token (vault-stored, masked) medium No dry run
cdn.integration.list Connected external CDNs (read) low No dry run
cdn.integration.test Validate the token and report its scope low No dry run
cdn.integration.delete Disconnect an external CDN medium
cdn.integration.zones Zones or pull zones at the provider, with the capability matrix (read) low No dry run
cdn.integration.purge Purge at the provider (URL, tag, prefix or everything where supported) low
cdn.integration.setting Change a cache or development-mode setting at the provider medium
cdn.integration.wire_dns Point a hostname at the provider (CNAME through dns, or the provider DNS for Cloudflare) medium

Permissions and limits

Permissions

  • cdn.zone.view View zones, hostnames and snippets
  • cdn.zone.manage Create and change zones, hostnames and signing keys
  • cdn.rule.manage Change cache rules
  • cdn.purge Purge the cache
  • cdn.stats.view View CDN statistics and usage
  • cdn.integration.manage Connect and change external CDN integrations
  • cdn.integration.view View external CDN integrations
  • cdn.edge.admin Manage the edge network, region map and settings
  • cdn.edge.view View the edge network and settings

Plan limits

  • cdn.zones CDN zones
  • cdn.hostnames_per_zone Custom hostnames per zone
  • cdn.bandwidth_gb CDN bandwidth per month
  • cdn.requests_m CDN requests per month (millions)
  • cdn.cache_gb Edge cache per zone
  • cdn.purges_per_hour Purges per hour
  • cdn.rules_per_zone Cache rules per zone
  • cdn.integrations External CDN integrations

Engineering notes

Generated from modules/cdn/docs.md at build d90e9e2. These are the notes the engineers keep next to the code: precise, technical, and honest about what is not done yet.

Spec docs/specs/cdn.md; handover docs/tasks/w8/w8-02-cdn.handover.md. Manifest edition: cdn (paid add-on key, see licensing below). Desired state lives in schema m_cdn (settings, edges, zones, purges, stats_hourly, integrations). Edges are only ever given the complete rendered configuration (cdngen.Render); apply is idempotent and cdn.reconcile re-applies everything from the database.

How a request flows

client -> GeoDNS (PowerDNS LUA record *.<base domain>) -> nearest healthy edge (nginx proxy_cache) -> origin (site URL or S3 bucket)
  • Edge node = a node with the cluster edge role and a row in m_cdn.edges. cdn.edge.enable adds the role through cluster.node.roles, installs nginx.org mainline nginx + nginx-module-njs on a node without nginx (the same build the web module installs); on a node that already has nginx it adds the njs package that matches it (nginx-module-njs for nginx.org's nginx, libnginx-mod-http-js for the distribution's nginx - the wrong one conflicts with the installed nginx in apt) (allow-listed, cdn.edge.install), refuses by name a http_port/https_port/status port another program already holds (before installing anything) and never leaves a restarting unit behind when a first start fails, writes a separate nginx instance (rc-cdn-edge.service, /etc/rc-cdn, cache /var/cache/rc-cdn/<zone>, logs /var/log/rc-cdn) so it never touches the web module's nginx (it can share a web node: pick other http_port/https_port), applies the config, requires /health to answer, and only then adds the node to DNS. A freshly installed distro nginx (default site on :80) is stopped and disabled.
  • Zone hostname <zone>.<base_domain> always serves; custom hostnames serve after cdn.hostname.verify (CNAME to the zone hostname or TXT _rc-cdn.<host> = token). A self-signed certificate is made per zone (stable across applies, re-made when the hostname set changes); cert_id uses the PEM the ssl module deployed on that edge node (/var/lib/respirecloud/ssl/certs/<id>), falling back to self-signed with a warning.
  • GeoDNS: one LUA A record *.<base domain> (TTL geodns_ttl, default 30 s) in the zone of the same name on the node that runs PowerDNS (dns_node, default the control node). cdn.geodns.apply switches enable-lua-records=yes and enable-lua-record-updates=yes on in its own drop-in rc-cdn.conf (without the second one pdns refuses every REPLACE of the record) and PATCHes the pdns API; a refused update puts the previous record back. A client in a pop's CIDRs (or countries, with geoip on and a GeoIP database configured in pdns) gets that pop's edges; all other clients get any healthy edge (pickclosest with GeoIP, random without). ifportup probes each edge on its own port and drops dead edges (edges on different ports are probed in one call per port and the answer is picked from the union; in that mixed-port form a port group whose edges are all down is still answered, because pdns cannot tell "all down" from "all up" for a selector all, so keep one port across edges in production); the per-pop answer is a list of lists (pop first, everybody else second) so a pop with all edges down falls back to the other pops. Draining edges are left out. Names are validated and the Lua is built from a closed grammar (no customer text reaches it).
  • Cache: key = <zone>|<decoded path>[?args]; request collapsing (proxy_cache_lock), background update, stale on error/timeout/5xx, per-zone cache directory (purge-all = delete the directory), X-Cache (HIT/MISS/EXPIRED/BYPASS/STALE) and X-Edge (node id) on every response. Rules are ordered (first match wins; path glob * within a segment, ** across, or extension list) and set TTL, browser TTL, bypass, query handling (ignore/all), ignoring origin Cache-Control, response headers; zone-level cookie bypass, gzip, CORS/security headers, allow/deny CIDRs, hotlink protection, HTTP->HTTPS redirect, origin failover (extra URLs are backup servers), origin host header, custom origin headers, origin timeout. cdn.rule.preview shows which rule a URL hits.
  • Purge (cdn.purge): url (exact path, also drops ?query variants), prefix, tag (origin Cache-Tag / Surrogate-Key), all. Done by the agent deleting the cache file (nginx treats a vanished file as a miss; exact URLs are found by the md5 of the key, prefix/tag by reading the key and headers stored in each cache file). Runs on every edge in parallel, returns per-edge counts and partial/failed_edges; rate limit = package limit cdn.purges_per_hour (default 600); each purge is logged with the actor (cdn.purge.history).
  • Signed URLs: ?expires=<unix>&token=<hex>[&ip=<ip>], token = HMAC-SHA256(secret, path\nexpires\nip), checked at the edge by an njs function (constant-time compare, keys from the zone's config) before the cache is consulted, so HITs are protected too and the token does not enter the cache key. Keys rotate with an overlap (cdn.token.rotate): the old key keeps validating until now + overlap_hours. cdn.token.sign signs a URL server-side; cdn.token.snippet returns signer code in node/python/php/go/ruby/java/dotnet/curl (verified against the Go implementation in tests and, for python, against a real edge in the lab). Signing secrets are shown once (create, rotate) and never in plans or zone reads.
  • Storage zones: origin = a bucket of the built-in S3 (storage). The edge reads anonymously, so the bucket must be public-read (a private bucket is refused with the reason); only GET/HEAD pass the edge; the cache key stays the public path. Writes go to the bucket with the storage module's keys, reads through the CDN; overwrite an object, then purge it. Private buckets with SigV4 origin requests are not built.
  • Origin lockdown: every zone has a random secret sent to the origin as X-RC-CDN-Secret; cdn.zone.get (zone managers only) returns it with a ready nginx snippet.
  • SSRF guard: customers cannot point a zone at loopback, link-local or private addresses (literal or resolved at create/update). Admins can (their own sites).
  • Stats and metering: cdn.stats.collect (1 min) reads each edge's access log from a saved offset into hourly rows (requests, bytes, hits/misses, status classes, origin bytes, TTFB, top URLs). cdn.stats.get ranges 1 h-90 d, CSV export; cdn.usage.get shows the month against cdn.bandwidth_gb. Events at 80/95/100 % (cdn.bandwidth.warning|exceeded), with setting overage_policy=block zones of that account return 403 until the next month. cdn.origin.failing|recovered from the 5xx share of origin fetches. Rows of a deleted zone are kept 30 days, others 90.
  • External CDNs (cdn.integration.*): Cloudflare and Bunny. Token in the vault (cdn/integration/<id>), row keeps ••••last4, test validates scope; zones lists provider zones and the capability matrix; purge (Cloudflare: url/prefix/tag/all; Bunny: url/prefix(wildcard url)/all), setting (allow-listed keys per provider), wire_dns (Cloudflare: provider DNS record; Bunny: CNAME through dns.record.set). An operation the provider lacks is refused with not_supported before any call; the same matrix comes back in list/zones so the UI can grey it out. Only an admin can set an endpoint override (the lab mock).
  • Customer API: every action is a REST call POST /api/v1/a/<action> with an API token that holds the permission (cdn.zone.view, cdn.purge, cdn.stats.view, cdn.zone.manage, cdn.rule.manage; subusers can be scoped to one zone); cdn.api.describe returns the base URL, auth, examples (curl/node/python/php), a deploy-script purge, and for storage zones how to read through the CDN and write to the bucket. ?dry_run=1 previews every mutation as a Plan with the real per-edge config diff (secrets redacted). Actions are also scriptable with respirecloudctl.

Licensing

Manifest edition: cdn (module-wide entitlement key, docs/tasks/w8/w8-04-store-licensing.handover.md). Without the entitlement the host answers not_entitled for every action and the module is "not included" in the Store. Degrades cleanly: the data plane does not depend on the control plane. Edges keep serving the last applied config (nginx), GeoDNS keeps answering, stats lines keep accumulating in the access log until the module is back. Spec says external-CDN integrations ship in the base: a module-wide key cannot split the module, see the handover (per-action edition or a second module id).

Verified in the lab (PROFILE=cluster, FAKENET=full, Ubuntu 24.04, nginx 1.24 + njs 0.8.2, pdns 4.8.3, SeaweedFS 4.48)

See the handover for commands and outputs: pull zone through two edges (MISS/HIT, per-edge cache), purge url/tag/prefix/all, signed URL (valid, tampered, expired, ip-bound, key rotation overlap, python-signed), storage zone (put via presigned URL, serve, overwrite + purge, private bucket refused, PUT refused), stale on origin failure, bypass rule, hotlink, gzip, HTTP->HTTPS redirect, origin lockdown header, GeoDNS per resolver view and failover, edge down/up detection by the scheduler, external CDN integrations against a mock API, stats and usage, dry-run plans.

Not done

imgproxy image optimisation, origin shield, soft purge, private-bucket (SigV4) origins, per-zone WAF/rate-limit toggles, country allow/deny, Brotli/zstd, HTTP/3, Fastly/CloudFront/KeyCDN/Gcore, bandwidth throttle mode, edge log download/stream to logs, auto-purge on web deploy, DB-IP lite download, certificate issuance for custom hostnames through ssl (self-signed until the contract below exists), p50/p95 TTFB (average only), UI.