Dynamic sites
Sites extend the static Pages feature (src/server/pages/*) with dynamic Workers, per-site access control, branch previews and agent-friendly scratch deploys. Everything is served on a separate registrable domain, SITES_DOMAIN (for example gitforstatic.dev). Code lives in src/server/sites/*. The data model is in migrations/1300_sites.sql.
Research summary
| Primitive | What it gives us | Decision |
|---|---|---|
| Workers for Platforms dispatch namespaces | Each customer script is uploaded once through the API and invoked from a dispatch Worker with env.DISPATCHER.get(name). Outbound Workers and custom limits (cpuMs, subRequests) are set per call. Scripts are long-lived and versioned by Cloudflare. |
Supported later as a backend for very large sites. Uploading a script per deploy needs an account API token in the request path, and every preview would become a separate script to garbage-collect. |
Dynamic Worker Loader (worker_loaders binding) |
env.LOADER.get(id, () => code) builds an isolate from modules provided at runtime and caches it by id. env contains only what we pass, globalOutbound is either null (no network) or a Fetcher we control, and limits: { cpuMs, subRequests } caps each isolate. |
Chosen runtime. Deploys are immutable module sets in R2, the loader id is deploymentId:envVersion, so a rollback or env change is just a new id. Nothing outside the bundle needs to be uploaded. |
| Workers Builds and Previews with Artifacts | Cloudflare builds a Worker when an Artifacts repo is pushed, deploys the production branch, and gives every other branch an isolated preview. | Same model, implemented in-forge: the cf.artifacts.repo.pushed event already drives PushWorkflow, which now also calls deploySitesForPush. Building is left to CI (workflow-artifact source), so the forge never runs a bundler on untrusted input. |
| Cloudflare for SaaS custom hostnames | Custom hostnames on a SaaS zone whose fallback origin is this Worker, with TXT DCV certificates. | Reused from pages/domains.ts, plus a forge-issued TXT ownership proof (_gitfor-site.<host>). |
| Public Suffix List | Browsers treat each label below a PSL entry as its own site: no shared cookies, no document.domain merging, separate storage partitions. |
Submit gitforstatic.dev to the private section. See "PSL submission". |
Runtime and isolation model
- Static fast path. A deployment without
_worker.jsis served directly by the dispatcher from the manifest in KV and content-addressed objects in R2, exactly like Pages. No isolate is spun up. - Dynamic path. A deployment with
_worker.js(one module) or a_worker.js/folder (index.jsplus sibling.js,.mjs,.wasm,.jsonand.txtmodules) is a dynamic site. This is the Cloudflare Pages "advanced mode" convention, so existing projects work. Worker modules are never served as static files. - The dispatcher calls
env.LOADER.get(...)with:env: only the site's own variables and decrypted secrets, plusASSETS, a loopbackSiteAssetsentrypoint bound to the deployment (ctx.exports.SiteAssets({ props })). There are no D1, KV, R2, queue or secrets-store bindings.globalOutbound:ctx.exports.SiteOutbound({ props }). It stripscf-*/x-forwarded-*headers, doesn't follow redirects, and refuses the forge host, the sites domain, loopback, private, link-local and.internal/.localhosts, and non-HTTP(S) schemes. Scratch sites getglobalOutbound: null, sofetch()andconnect()throw.limits:cpuMsandsubRequestsfromSITE_LIMITS(claimed) orSCRATCH_LIMITS(scratch).
- User code only ever sees a sanitized request: the dispatcher strips its own access cookies and the OIDC state cookie, and adds
x-gitfor-userwhen the visitor signed in. AnySet-Cookiefrom user code that targets a reserved cookie name is dropped. - The dispatcher runs before user code for every request. Access control can't be bypassed by the site.
Domains
| Site | Host |
|---|---|
User or org site (repo named after the handle, or <handle>.<domain>) |
<handle>.<domain> |
| Project site | <repo>--<handle>.<domain> |
| Branch preview | <branch>--<repo>--<handle>.<domain> |
| User-site branch preview | <branch>--<handle>--<handle>.<domain> |
| Scratch site | <id>--scratch.<domain> (scratch is a reserved account name) |
Each label is slugged to [a-z0-9-], runs of - collapse to one, and labels over 63 characters are truncated with a hash suffix. A slug therefore never contains --, which makes -- an unambiguous separator. D1 enforces unique labels, so if two sources ever slug to the same label, the second deploy fails instead of taking over.
Why subdomains, not paths. Pages served every project and PR preview under one owner origin (PAGES-1). A path gives no isolation: localStorage, IndexedDB, service workers, non-HttpOnly cookies and same-origin fetch are all shared. One label per repo and per preview gives each its own origin. With the PSL entry, each also gets its own site (cookie and storage partition), so a--alice.gitforstatic.dev can't read or toss cookies for b--alice.gitforstatic.dev or bob.gitforstatic.dev. Previews of a fork branch never deploy, because they come from the base repo's branches and trusted runs only.
sitesDomain() refuses a domain that equals, contains or shares an approximate registrable domain with the forge host. The forge's session cookie is a host-only __Host- cookie on another registrable domain, so browsers never send it to a site, and site code can't read it. The e2e test checks this.
PSL submission
Open a pull request against publicsuffix/list adding this to the private domains section:
// gitfor sites : https://gitfor.dev
// Submitted by <maintainer> <email>
gitforstatic.dev
Requirements:
- Add a
_psl.gitforstatic.devTXT record containing the PR URL. - Keep the domain registered for at least two years.
- Explain that every subdomain holds independent user content.
Until the entry ships in browsers, isolation relies on two things. The dispatcher only uses host-only cookies (no Domain=) with the __Host- prefix on HTTPS. Separate origins also stop script access to storage. The remaining gap before PSL inclusion is cookie tossing: a site could set Domain=gitforstatic.dev cookies seen by its siblings. Reserved cookie names are ignored when they don't verify, because they are HMAC-signed and bound to the host.
Custom domains
setSiteDomain reuses pages/domains.ts and keeps every PAGES-2 fix:
- TXT DCV certificates.
- No host mapping until verified.
- Unverified claims expire after 72 h.
- Forge-related hosts (
isForgeRelatedHost) and hosts under the Pages or sites domain are refused.
On top of that, the site gets a random domain_token, and refreshSiteDomain only writes the KV mapping when the hostname and certificate are active and _gitfor-site.<host> TXT equals the token. The TXT record is resolved over DNS-over-HTTPS. A domain is unique across pages_sites and sites, and a stale unverified claim on either side (72 h) can be taken over from the other. Scratch sites can't add a domain.
Access control
The dispatcher enforces the mode stored in the site's KV metadata (sites:meta:<id>).
- public: no check.
- password: a scrypt hash (
N=16384, r=8, p=1). The form at/__gitfor/loginis rate-limited per site and IP throughAUTH_LIMITER. - gitfor: OIDC authorization-code flow with the forge as the IdP.
- The site host sets a random
statecookie and redirects to<forge>/sites/authorize?site&host&state. - The forge requires a session. It checks that
hostbelongs to the site and that the user is a repo reader (audience=readers) or an org member or collaborator with write access (audience=members). - The forge shows a consent screen ("continue as @user"), confirmed by a same-origin POST, so a site can't silently learn who a visitor is.
- The forge stores a single-use code in KV (60 s) and redirects to
https://<host>/__gitfor/callback. - The dispatcher consumes the code, checks host, site and state, and sets the site cookie. KV get-then-delete isn't atomic, so a code could be redeemed twice within 60 s. That is accepted, because redeeming one also needs the browser's host-bound, MAC'd state cookie. The forge session cookie never leaves the forge origin. The token exchange happens in-process, because the IdP and the relying party are the same Worker. Third-party RP discovery is a follow-up.
- The site host sets a random
- oidc: an external IdP.
- Flow: discovery, then authorization code with PKCE (S256),
stateandnonce. Theid_tokenis verified against JWKS (RS256 or ES256), with checks oniss,aud,expandnonce. The email must be verified and match the allow list (a@x.comor@x.com). - The issuer must be HTTPS on a public host.
- The client secret is encrypted with
SECRETS_KEY.
- Flow: discovery, then authorization code with PKCE (S256),
The access cookie is __Host-gitfor_site (gitfor_site on plain-HTTP dev):
- It is HttpOnly,
SameSite=Lax,Path=/, and has noDomain. - Its value is
v1.<siteId>.<exp>.<subject>.<hmac>, where the HMAC usesSITES_COOKIE_SECRET(falling back toSECRETS_KEY) over the host as well. A cookie copied to another host doesn't verify. - Changing the access mode or password bumps
access_version, which is in the MAC, so existing cookies are revoked. Site metadata is edge-cached in KV for 30 s, so revocation and disabling can take up to about 30 s per colo. nextredirects are resolved against a dummy origin and rejected unless they stay same-origin. Control characters and backslashes are refused.- Gated static responses are
Cache-Control: privatewithVary: Cookie.
Deploys
deploySitesForPush, called fromPushWorkflow:- The configured branch deploys to production.
- Any other branch of the same repo deploys a preview when previews are on. The cap is 5 previews per push and
deploysPerSitePerHourper site. - Open same-repo PRs from that branch get one comment with the preview link.
site_preview_commentsstores which PRs have one, so the comment isn't repeated.
deploySitesForRun: a successful CI run's artifact. It keeps the PAGES-1 guard:source_repo_id === repo_idanduntrusted === 0.SitesDeployWorkflowreusespages/collect.ts(bounded git walk, content-addressed upload, streaming tar extraction with size, count and path checks). It then splits worker modules from static files and writessites:manifest:<id>and the slot pointersites:slot:<label>.- Rollbacks re-point the production slot at an older
active/supersededdeployment. Manifests and objects are immutable. - Variables and secrets live in
site_env, encrypted withSECRETS_KEY(at most 64 entries, 4 KiB each). Secrets are write-only in the UI. Every change bumpsenv_version, which changes the loader isolate id.
Lifecycle and reconciliation
The dispatcher reads only KV, so D1 changes must be mirrored:
deleteRepositorycallsteardownSiteForRepobefore the cascade. It deletes every label slot, the meta, the custom-host mapping and the SaaS hostname.syncSiteForReporuns after repo settings changes, and on repo transfer.syncSitesForOwnerruns after an account rename. They do two things:- Re-derive the label. Previews are dropped and rebuilt on the next push. If the new label is taken, the site is disabled rather than left serving under a stale namespace.
- Force sign-in (
gitfor) when a repo with a public site becomes private.
- Deleting a branch removes its preview.
reconcileSites, run by cron:- Walks repo sites in batches through the same sync.
- Re-checks a few custom domains per run (ordered by
domain_checked_at, which never touches the claim's staleness clock), so a lapsed hostname or TXT proof is unmapped automatically. - Deletes KV meta whose D1 row is gone. A pointer whose site no longer exists may be taken over by a new site. Preview slots of such a site are left as inert pointers. Nothing serves them without meta.
- Deploys that fail with an unexpected error are marked
failedby the Workflow, so they don't hold their label.
Scratch (agent) deploys
POST https://<SITES_DOMAIN>/__scratch with a .tar.gz body returns:
{
"url": "https://k3x9…--scratch.gitforstatic.dev/",
"expiresAt": 0,
"claimToken": "gitfor_claim_…",
"claimUrl": "https://forge/sites/claim?token=…"
}
The endpoint is on the sites apex, not the forge, so it has no ambient forge authority.
Caps:
- Bodies up to 10 MiB, 500 files and 25 MiB unpacked.
- 5 scratch sites per IP per day, and the
HEAVY_LIMITERper IP. - Requests go through
GIT_LIMITERper site (120 per minute). - Workers run with
cpuMs: 10,subRequests: 0andglobalOutbound: null. - No custom domains and no password or OIDC gating while unclaimed.
POST /__gitfor/reporton any site records an abuse report. Reporter IPs are stored as an HMAC. 3 reports from distinct IPs disable a scratch site, and an admin can disable any site.
Claiming: a signed-in user opens claimUrl, and a same-origin POST confirms (isSameOriginRequest in src/server.ts covers it). the token hash is compared, and the site moves to the user. Its label becomes <id>--<handle>, the TTL is cleared, and claimed limits apply. The claim token is single-use. The cron sweep (sweepScratchSites) deletes expired, unclaimed sites and their KV entries. Content-addressed R2 objects are shared and left for a future GC, like Pages.
A git-push scratch flow (an anonymous repo per push) is a follow-up. It would reuse the same claim and TTL columns with repo_id set.
Billing model
| Tier | What you get |
|---|---|
| Scratch (anonymous) | Free and time-boxed. Live for 24 h with the hard caps above. |
| Claimed, free | Static sites free within the Pages limits. Dynamic sites include a monthly allowance (for example 100k requests and 1M CPU-ms per account). |
| Usage-based | Beyond the allowance, the account pays per million requests, per CPU-ms and per GB egress. That mirrors Workers Standard pricing plus margin. Custom domains are per hostname per month, mirroring Cloudflare for SaaS. |
Metering points are in place: the dispatcher knows the site, account and deployment for every request. A Workers Analytics Engine writer per request is the planned meter. It is not wired yet, and limits are enforced by caps for now.
Local development and e2e
Chrome resolves *.localhost to loopback. The sites domain is therefore gitforstatic.localhost, and loopback forges skip the registrable-domain check because localhost has no eTLD+1. The shared dev server on :3000 runs master, so a branch is tested on its own port.
Add this to .dev.vars:
SITES_DOMAIN=gitforstatic.localhost
PUBLIC_URL=http://localhost:3317
SCRATCH_SITES_PER_IP_PER_DAY=1000
Then run:
bun run db:migrate && bunx vite dev --port 3317
bun test src/server/sites
SITES_E2E_URL=http://localhost:3317 bun run e2e/sites.e2e.ts
SITES_E2E_URL=http://localhost:3317 bun run e2e/sites-browser.e2e.ts
e2e/sites.e2e.ts sends a Host header to the forge's address, so it needs no hosts-file entries. It checks the following:
- Two scratch deploys get distinct origins.
- Static assets are served through
env.ASSETS, and_worker.jsis never served. - Dispatcher and forge cookies are stripped before user code.
- Scratch workers get no bindings or network.
- Reserved
Set-Cookies are dropped, and site cookies are host-only. - Abuse reports are recorded.
- Claiming requires a session.
- Unknown hosts 404.
e2e/sites-browser.e2e.ts drives headless Chrome over CDP with a throwaway profile (CHROME_PATH overrides the binary). It checks the following:
- A cookie set on the forge (including
Domain=localhost) is not visible to a site, over HTTP or from script. - A cookie set by one site is not visible to another.
- A new user can claim a scratch site, which moves to
<id>--<user>, and the old host stops serving. - The claimed site's outbound fetch to the forge is refused.
For tools that don't resolve *.localhost, add hosts entries such as 127.0.0.1 <label>.gitforstatic.localhost.
Security checklist against docs/security-triage/ci-pages.md
- PAGES-1: previews only come from same-repo branches and trusted runs, and each preview has its own origin.
- PAGES-2: TXT DCV, mapping written only after verification (plus the forge TXT token), 72 h claim expiry, forge-related hosts refused, mapping removed when the hostname is no longer active.
- CI-*: sites never get job tokens or secrets from CI. Workflow artifacts are read from
job_artifactsjoined on the run's repo.