Deployment & Operations
Deployment (Cloudflare Workers)
The site ships as two Cloudflare Workers built from the same website/ repo — a static public project that visitors hit, and an SSR staging project that only Sanity's Presentation tool iframes — and both deploy from GitHub main via Cloudflare Workers Builds.
This page covers the deploy surface: why Workers (not Pages), the two projects and their per-environment settings, the npx wrangler deploy flow and its config gotcha, the public deploy hook that rebuilds content, and what to update when a deploy URL changes. First-time infrastructure provisioning lives in Launch & operations; the BUILD_TARGET mechanics behind the split live in Two-build split.
Why Workers, not Pages
The site uses @astrojs/cloudflare (v13) with output: 'server' for the SSR build. The v13 adapter emits Workers-shaped output, not Pages-shaped:
- Workers shape:
dist/server/entry.mjs+dist/server/wrangler.json, with static assets indist/client/. - Pages shape (what we do not produce): a
_worker.js+_routes.jsonat thedist/root.
So the deploy target is Cloudflare Workers, deployed with npx wrangler deploy — never Cloudflare Pages.
SSR is required because draft preview reads the sanity-preview-perspective cookie at request time. Even the public project is hybrid, not fully static: /api/audit and /api/draft-mode/* keep export const prerender = false, so the public build still emits dist/server/ (with wrangler.json) — which is exactly what wrangler deploy needs. See Two-build split for how BUILD_TARGET toggles output between 'static' and 'server'.
// website/astro.config.mjs
output: process.env.BUILD_TARGET === 'static' ? 'static' : 'server',
adapter: cloudflare(),
A fully-static build breaks deploy
If the public build ever goes fully static (no on-demand route), the adapter stops emitting dist/server/wrangler.json, and npx wrangler deploy fails with "config path … does not exist". Keep /api/audit and /api/draft-mode/* as prerender = false so the build stays hybrid. Verify a static build with grep -rc cloudflare:workers dist/client (must be 0) and confirm both dist/server/ and dist/client/ exist.
The two projects
Both Workers are git-connected via Cloudflare Workers Builds on main. The only thing that differs between them is build-time env. The static public project bakes content into HTML at build; staging renders live per request so Presentation can show drafts.
| Setting | Public project | Staging project |
|---|---|---|
| Cloudflare Worker | whitetreedigital-public | whitetreedigital |
| Build command | npm run build:static | npm run build |
BUILD_TARGET | static | (unset → server) |
| Astro output | static (prerendered HTML) | server (SSR) |
PUBLIC_SITE_URL | https://whitetreedigital.com | https://staging.whitetreedigital.com |
PUBLIC_NOINDEX | (unset → indexed) | true (noindex meta + robots Disallow: /) |
| Domain | whitetreedigital.com | staging.whitetreedigital.com |
| Auto-deploy triggers | git push (code) + deploy hook (content) | git push (code) |
| Deploy command | npx wrangler deploy | npx wrangler deploy |
| Sanity at runtime | none — content pages are static files | every request |
build:static is just the server build with the switch flipped — cross-env BUILD_TARGET=static astro build (from website/package.json).
Both projects carry the same PUBLIC_* Sanity/HubSpot vars and the same two runtime secrets (SANITY_API_READ_TOKEN, PSI_API_KEY). The naming, env contract, and variable mirror are documented in Environment variables; the public-vs-staging rationale (resilience vs live editing) is in Two-build split.
The two Workers must not overwrite each other
The adapter derives the Worker name from website/package.json (whitetreedigital), which is the staging name. The public Worker's deploy must pin a distinct name — npx wrangler deploy --name whitetreedigital-public — so a public deploy doesn't land on top of staging. If public and staging appear to "fight" over one Worker, this --name override is missing.
Deploying with wrangler
Code pushes to main rebuild both Workers automatically through Cloudflare Workers Builds — staging gets the new code, and the public project gets the new code and picks up the latest published content as a side effect. No manual step is needed for a code change.
For a manual deploy (the fallback), build then deploy from the website root:
npm run build && npx wrangler deploy
Run wrangler from the website root, not dist/server
The build emits a deploy-redirect config at .wrangler/deploy/config.json that points wrangler at dist/server/wrangler.json. If you cd into dist/server and run wrangler deploy there, it sees both that generated config and the user config and fails with a dual-config error: "Found both a user configuration file … and a deploy configuration file". Always deploy from the website/ root.
Building clobbers the dev Vite cache
Running astro build (or npm run build:static) while astro dev is running corrupts the dev server's Vite cache. Stop the dev server before building, then restart it afterward. (astro check is safe to run alongside dev.)
The public deploy hook (content rebuilds)
Because content-only changes never push git, they never auto-rebuild the public site — publishing a Sanity document is instant in preview/staging but reaches whitetreedigital.com only on the next deploy. The mechanism that triggers that content rebuild is the public project's Workers Build deploy hook (a secret POST URL on the public Worker).
Three ways to fire it:
Studio's "Deploy" tool — the Deploy tab in the hosted Studio POSTs the hook. It reads
SANITY_STUDIO_DEPLOY_HOOK_URLfromstudio/.env.production(baked into the Studio bundle atnpx sanity deploy). This is deliberately manual and is distinct from Sanity's per-document Publish — Publish saves a document; Deploy rebuilds the whole public site.npm run deploy:publicfromwebsite/— a one-liner that POSTsDEPLOY_HOOK_URL(set it in your shell first):"deploy:public": "node -e \"fetch(process.env.DEPLOY_HOOK_URL,{method:'POST'}).then(r=>console.log('deploy hook:',r.status)).catch(e=>{console.error(e);process.exit(1)})\""The Cloudflare dashboard — the Retry/redeploy button on the public Worker.
Wiring Sanity document webhooks straight to the hook is deliberately avoided — it would rebuild on every Publish, which is wasteful during iterative editing. The everyday loop is: edit → Publish (instant to staging/preview) → Deploy (when a batch is ready to go live). The publish-vs-deploy distinction is the single most common point of confusion; see Visual editing and Two-build split for the full loop.
Never put a real deploy-hook URL in a doc or commit
The deploy hook and the read/PSI secrets are secret POST URLs and keys. Document the variable names only. Secrets (SANITY_API_READ_TOKEN, PSI_API_KEY) live in website/.dev.vars locally and as encrypted Secrets in the CF dashboard — never in .env, never committed.
When a deploy URL changes
Attaching a custom domain (or otherwise changing a project's public URL) requires three coordinated updates, or canonicals, CORS, or click-to-edit will break:
PUBLIC_SITE_URLin that project's CF dashboard env. It feeds sitemap, robots, canonical, andog:tags. Use the canonical origin with no trailing slash (src/lib/site.tsstrips one defensively, but fix the source — a trailing slash produced//canonicals).PUBLIC_* vars bake at build time
PUBLIC_*variables are inlined into the bundle at build time, not read at request time. After changing any of them in the CF dashboard, you must trigger a redeploy (push a commit or hit Deploy) for the new value to take effect.Sanity CORS origins — register the new URL as a credentialed origin, from
studio/:npx sanity cors add https://whitetreedigital.com --credentialsPresentation's staging URL — Presentation iframes the staging origin, not public. If the staging URL changes, set
SANITY_STUDIO_PREVIEW_URLinstudio/.env.productionand re-runnpx sanity deployfromstudio/. (The public URL only needs CORS if something browser-side ever reads Sanity from it — today nothing does, so the public site needs no CORS.) See Studio & singletons and Visual editing.
The full DNS-cutover procedure (domain attach, HubSpot email domain, Google Search Console) is in Launch & operations.
Auto-enabled adapter bindings
The @astrojs/cloudflare adapter auto-enables two Workers bindings that surface in the generated wrangler.json:
IMAGES— Cloudflare Images.SESSION— Sessions KV.
Neither is currently exercised by the app; they're tolerated as adapter defaults for now. No action is needed unless a future feature starts using them.
Where this lives
| Path | Role |
|---|---|
website/astro.config.mjs | BUILD_TARGET → output toggle; adapter: cloudflare() always on |
website/package.json | build, build:static, and deploy:public scripts |
website/CLAUDE.md | "Deploy (Cloudflare Workers — two projects)" — the authoritative table + URL-change checklist |
_TWO-BUILD-OPERATIONS.md | deployments-at-a-glance, the Deploy tool, deploy mechanics & triggers, troubleshooting |
_LAUNCH-RUNBOOK.md | CF env mirror, rate-limit rule, DNS cutover, rollback |
studio/components/DeployTool.tsx | the Studio "Deploy" button (POSTs the deploy hook) |
studio/sanity.config.ts | Presentation config, ALLOWED_ORIGINS, Deploy tool registration |
studio/.env.production | SANITY_STUDIO_PREVIEW_URL (→ staging) + SANITY_STUDIO_DEPLOY_HOOK_URL |
Related docs: Two-build split · Environment variables · Launch & operations · Visual editing.