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 in dist/client/.
  • Pages shape (what we do not produce): a _worker.js + _routes.json at the dist/ 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.

SettingPublic projectStaging project
Cloudflare Workerwhitetreedigital-publicwhitetreedigital
Build commandnpm run build:staticnpm run build
BUILD_TARGETstatic(unset → server)
Astro outputstatic (prerendered HTML)server (SSR)
PUBLIC_SITE_URLhttps://whitetreedigital.comhttps://staging.whitetreedigital.com
PUBLIC_NOINDEX(unset → indexed)true (noindex meta + robots Disallow: /)
Domainwhitetreedigital.comstaging.whitetreedigital.com
Auto-deploy triggersgit push (code) + deploy hook (content)git push (code)
Deploy commandnpx wrangler deploynpx wrangler deploy
Sanity at runtimenone — content pages are static filesevery 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_URL from studio/.env.production (baked into the Studio bundle at npx 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:public from website/ — a one-liner that POSTs DEPLOY_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:

  1. PUBLIC_SITE_URL in that project's CF dashboard env. It feeds sitemap, robots, canonical, and og: tags. Use the canonical origin with no trailing slash (src/lib/site.ts strips 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.

  2. Sanity CORS origins — register the new URL as a credentialed origin, from studio/:

    npx sanity cors add https://whitetreedigital.com --credentials
    
  3. Presentation's staging URL — Presentation iframes the staging origin, not public. If the staging URL changes, set SANITY_STUDIO_PREVIEW_URL in studio/.env.production and re-run npx sanity deploy from studio/. (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

PathRole
website/astro.config.mjsBUILD_TARGEToutput toggle; adapter: cloudflare() always on
website/package.jsonbuild, build:static, and deploy:public scripts
website/CLAUDE.md"Deploy (Cloudflare Workers — two projects)" — the authoritative table + URL-change checklist
_TWO-BUILD-OPERATIONS.mddeployments-at-a-glance, the Deploy tool, deploy mechanics & triggers, troubleshooting
_LAUNCH-RUNBOOK.mdCF env mirror, rate-limit rule, DNS cutover, rollback
studio/components/DeployTool.tsxthe Studio "Deploy" button (POSTs the deploy hook)
studio/sanity.config.tsPresentation config, ALLOWED_ORIGINS, Deploy tool registration
studio/.env.productionSANITY_STUDIO_PREVIEW_URL (→ staging) + SANITY_STUDIO_DEPLOY_HOOK_URL

Related docs: Two-build split · Environment variables · Launch & operations · Visual editing.

Previous
Blog & portfolio