Deployment & Operations
Launch & operations
Launching White Tree Digital is a content-and-configuration job, not an architecture job — the code is done, so going live is a runbook you work top-to-bottom: clear the pre-flight gates, mirror env vars into Cloudflare, cut DNS over, verify for fifteen minutes, then watch for a week.
This page is the operator's map. It covers the launch runbook (gates → cutover → verify → rollback → week-one watch), the phased launch roadmap at a glance, the content launch checklist, and the everyday how-tos for running the site once it's live. For the underlying two-deployment mechanism see Two-build split; for the Cloudflare project setup itself see Deployment.
Names, never values
Everything below references env-var and hook names and their purpose. Real keys, tokens, and deploy-hook URLs never appear here and never belong in .env — secrets are encrypted Secrets in the Cloudflare dashboard and live locally only in gitignored .dev.vars. See Environment variables and Security.
The launch roadmap at a glance
The production launch was scoped from a multi-agent audit plus a manual browser pass: the codebase is in good shape, and the blockers are overwhelmingly content + configuration. The funnel starts broken end-to-end (every CTA 404s, the only working lead submit posts to a malformed URL), so the roadmap is ordered to unbreak that first.
| Phase | Focus | Owner |
|---|---|---|
| A — Unbreak the funnel | Publish /contact, fill HubSpot env IDs + properties + workflow, fill siteSettings, purge placeholder content, legal pages, trust-signal decision | Studio + HubSpot dashboard |
| B — SEO before DNS | Title/meta fallback chain, Sanity-driven sitemap, canonical-origin fix, og-default.png, branded 404 + 503-on-outage, noindex the workers.dev host, JSON-LD | Code |
| C — Performance | Cut the 2.1 MB lead-magnet island, replace the 3 MB CTA background, edge-cache middleware, prefetch, font prune, gate AnimDebug | Code |
| D — Hardening | Rate-limit + origin-check /api/audit, HubSpot submit robustness, draft-cookie HMAC, stegaClean gaps, hydration fix, section-drift test | Code |
| E — Studio editor lifts | Required-when-embed validation, reserved-slug block, orderings/previews, desk cleanup | Studio schema |
| F — Docs | Launch runbook, audit-funnel docs, .env reconciliation, tunables backfill, README rewrites | Docs |
Phases B–F have largely landed and are reflected across the codebase (see the TODO.md status boxes). What remains is mostly Phase A — the human content and HubSpot dashboard work — captured in the content checklist below.
Pre-flight gates (all must be true before DNS)
These are the hard gates from the launch runbook. Nothing in this list is optional; DNS cutover happens only when all of them are green.
Content (driven from the Studio content checklist):
/contactpublished and renders the HubSpot form; header / hero / pricing CTAs all land on it.- Site settings published: nav (including Free Website Audit), footer, contact email, Default SEO.
- No placeholder copy on the homepage; founder photo live.
/seedplus the three dummy posts unpublished;/privacylive and in footer legal links.- Service pages: CTA sections filled, empty proof-sections hidden, drafts resolved.
HubSpot (driven from the HubSpot setup doc):
- An audit-tool test submit creates a contact with the six
audit_*properties populated. - The workflow fires (internal notification at minimum); the contact-page form submit works.
- If launching with personal-reply follow-up, soften the "report in a few minutes" promise in
website/src/components/audit/WebsiteAudit.tsxso the thank-you copy isn't a promise nothing keeps yet.
Code / config:
- The PSI key's application restriction is None in Google Cloud Console, and
PSI_API_KEYis an encrypted Secret in the Cloudflare dashboard. - Local
npm run buildsucceeds — stop the dev server first — anddist/client/_astro/shows the WebsiteAudit island at tens of KB, not MB. - The Studio has been redeployed (
npx sanity deployfromstudio/) so the hosted Studio carries the new validations, and Presentation preview is confirmed working.
Never run astro build while astro dev is up
Running astro build clobbers the running dev server's Vite cache — it 404s the dev server's gsap dependency and breaks all client scripts. Stop astro dev before building, and restart it afterward. (astro check is safe.)
PSI key restriction must be None
The audit now calls PageSpeed Insights server-side from /api/audit, so no browser Referer is sent. A referrer-restricted key returns 403 "Requests from referer <empty> are blocked", PSI silently returns nothing, and all the speed checks degrade to unknown. Set Application restrictions to None in Google Cloud Console — for a server secret, secrecy is the protection, not the referrer rule. Confirm a real POST to /api/audit takes 10–40s (a near-instant ~1s response means PSI is still 403ing).
Cloudflare env mirror
In the Cloudflare dashboard: Workers & Pages → your worker → Settings → Variables and Secrets. The target state for the public worker:
| Name | Type | Notes |
|---|---|---|
PUBLIC_SANITY_PROJECT_ID | Plaintext | 8ftz0iuv |
PUBLIC_SANITY_DATASET | Plaintext | production |
PUBLIC_SANITY_STUDIO_URL | Plaintext | where stega edit-links resolve |
PUBLIC_HUBSPOT_PORTAL_ID | Plaintext | the HubSpot portal ID |
PUBLIC_HUBSPOT_AUDIT_FORM_ID | Plaintext | the audit form GUID |
PUBLIC_SITE_URL | Plaintext | https://whitetreedigital.com — no trailing slash |
SANITY_API_READ_TOKEN | Secret | read token; also signs the draft-preview cookie |
PSI_API_KEY | Secret | the PageSpeed Insights key |
PUBLIC_PSI_API_KEY | — | delete — legacy plaintext, nothing reads it |
PUBLIC_SITE_URL must have no trailing slash
The old …workers.dev/ value (with the slash) produced double-slash canonicals like https://…com//foo. The code now strips a trailing slash defensively (src/lib/site.ts), but fix the source value too. PUBLIC_* vars bake at build time — after any change in this panel, trigger a redeploy (push a commit, or hit Deploy in the dashboard) or nothing changes.
WAF rate-limit rule for /api/audit
The audit route has an in-code, per-isolate throttle, but isolates reset — so the Cloudflare WAF rule is the real backstop. The in-code limiter lives in website/src/pages/api/audit.ts:
// Per-IP sliding window. Each audit triggers a 30–60s PageSpeed Insights run
// against a quota'd key — without a throttle, a trivial script can exhaust
// the quota in minutes ... Per-isolate state, so it's a bar-raiser, not a
// guarantee; the backstop is a Cloudflare rate-limiting rule on this route.
const RATE_WINDOW_MS = 10 * 60_000;
const RATE_MAX_PER_WINDOW = 5;
Create the backstop in Security → WAF → Rate limiting rules → Create:
- If incoming requests match:
URI Path equals /api/auditANDMethod equals POST. - Rate:
3requests per10 secondsper IP (the free-plan period is 10s; this kills burst scripts while the in-code limiter handles slow drips). - Action: Block for the maximum duration available.
DNS cutover (the launch)
A Cloudflare custom domain maps a hostname to exactly one Worker, so the cutover is a hand-off, performed in order:
- Attach the domain — worker → Settings → Domains & Routes → Add → Custom domain →
whitetreedigital.com. Addwww.too, or create a Redirect Rule www → apex (308). Cloudflare provisions DNS + cert automatically if the zone is on Cloudflare. - Set
PUBLIC_SITE_URLtohttps://whitetreedigital.com(if not already) → redeploy so it bakes in. - Sanity CORS, from
studio/:npx sanity cors add https://whitetreedigital.com --credentials - Studio preview URL — edit
studio/.env.productionsoSANITY_STUDIO_PREVIEW_URLpoints at the live origin, thennpx sanity deploy. Also add the new origin toALLOWED_ORIGINSinstudio/sanity.config.ts(it's env-derived, but the explicit list keeps localhost Studio working). - HubSpot email sending domain — HubSpot → Settings → Content → Email → Connect domain →
whitetreedigital.com, then add the DKIM/SPF CNAMEs to Cloudflare DNS. This unblocks the automated report email; once verified, upgrade the workflow from internal-notification to the customer-facing report email. - Google Search Console — add the domain property (DNS TXT verification) and submit
https://whitetreedigital.com/sitemap.xml.
Presentation points at staging, not public
SANITY_STUDIO_PREVIEW_URL must point at the staging SSR origin, not the public site — only the SSR build can read draft cookies and emit the stega tags click-to-edit needs. The public site never reads Sanity from the browser and needs no CORS for preview. See Two-build split and Visual editing.
Post-cutover verification (15 minutes)
Walk this immediately after the domain attaches:
https://whitetreedigital.comloads;view-source:head shows the correct<title>, a canonical ofhttps://whitetreedigital.com/...(no//), and anog:imagethat 200s./robots.txton the canonical domain returnsAllow+ the sitemap; the*.workers.devhost returnsDisallow: /and its pages carrynoindex./sitemap.xmllists all published pages and no unpublished/test slugs.- An unknown URL returns the branded 404 with an HTTP 404 status.
- A real audit on
/free-website-auditcreates a contact with theaudit_*properties in HubSpot, and the follow-up fires. - A contact-form submit produces a notification.
- Edge cache is live: a second anonymous load of
/has visibly faster TTFB. (The Cache API is a no-op onworkers.dev— it only works on the custom domain.) - Presentation against prod: hosted Studio → Presentation → edit a field → it streams into the iframe.
- Sharing the homepage URL in a private Slack/iMessage shows the OG card.
- Mobile spot-check on a real phone: homepage,
/contact, and the audit tool.
The SEO floor that this verifies — title fallback chain, dynamic sitemap/robots, 503-on-outage, branded 404 — is documented in SEO & structured data.
Rollback
Every step of the launch is reversible:
| Failure | Recovery |
|---|---|
| Bad deploy | Worker → Deployments → ⋮ on the previous good build → Rollback (instant, no rebuild). |
| Bad content | Studio → open the doc → ⋮ → Review changes → restore a prior revision (every publish is versioned). |
| Domain emergency | Remove the custom domain from the worker — workers.dev keeps serving; pointing DNS back can wait. |
| Edge-cache poisoning | Dashboard → Caching → Purge Everything (preferred), or push any deploy. |
Week-one watch list
For the first week after launch, keep an eye on:
- Google Cloud Console → PSI quota — 25k/day on the free tier; the rate limit should keep usage trivial.
- HubSpot → Contacts — leads arriving with attribution. The
hutk/hubspotutkcookie requires the GTM tracking tag; verify in incognito. - Search Console → Coverage — pages discovered, no unexpected
noindex. - Cloudflare worker analytics — error rate and CPU time; the edge cache should keep request counts to Sanity low.
- Sanity 503 spikes — content routes return
503 + Retry-After(not 404) on a fetch failure so blips don't drop pages from Google's index. They self-heal; repeated ones are worth a look.
Outages return 503, not 404
A Sanity CDN blip must never get pages dropped from the search index, so every content route returns 503 + Retry-After (not 404) when the fetch fails. Real not-founds render the branded 404.astro. Don't "fix" a route to 404-on-error.
Content launch checklist (Phase A)
All of this happens in Studio — at http://localhost:3333 or the hosted Studio — best done from the Presentation tab so every edit shows live. Doc names match the dataset exactly. The HubSpot Form B embed snippet is a prerequisite for step 1; everything else is independent.
- Create the
/contactpage — the launch blocker. Every CTA points at/contact, which currently 404s as a blank page. Create a Page (TitleContact, slugcontact), add a CTA section, fill heading/body and a primary CTA, then on the Layout tab turn on Two-column layout and Use form embed, and paste the Form B embed into Form embed code. Add SEO, then Publish. - Fill Site settings. Populate Primary nav (Services, Free Website Audit, Blog, Contact), footer columns + description + legal links, contact email, tagline, and Default SEO (meta title, description, 1200×630 OG image). The Phase-B fallback chain makes every page fall back to Default SEO, so fill it now. Publish.
- Fix the homepage. It has an open draft — review before publishing. Replace the literal
THIS SECTION NEEDS MOBILE OPTIMIZATIONheading and the[need a result-driven cta message here]CTA body, upload a real founder portrait (the live site shows a gray "EG" placeholder), change every "Learn more" button to action-led copy, fill SEO (the homepage currently ships no title tag), then Publish (this also resolves the draft). - Purge test content. Unpublish the
SEEDkitchen-sink page (keep as draft), discard theTESTdraft, and unpublish the three dummy posts (Post 1/Post 2/Post 3). - Create the
/privacypage. Add a Page (slugprivacy) with a rich-text section covering what's collected, why, where it's processed (HubSpot, Google Analytics), retention, and a removal-request contact. Publish, then link it in Site settings → Footer → Legal links. (The audit form asserts agreement to a policy page — it can't 404.) - Service pages — founder-story substitution. For each of the three published services (each with a stale draft): hide empty reference sections (Styles tab → Hide section: ON), fill the empty bottom CTA section (label
Book a strategy call, URL/contact), add an About or Receipts section where the page reads thin, set SEO, and publish or discard each draft. - Quick decisions. Leave
/portfoliounlinked for launch (its index singleton is missing). Fill blog-index SEO. Optionally tweak the header CTA label.
Done when: /contact renders the form and every CTA lands on it; the header shows nav and /free-website-audit is reachable; no placeholder text on the homepage; /seed and the dummy posts are unpublished; /privacy is live and footer-linked; every service page ends in a filled CTA with empty proof-sections hidden; no stale drafts remain; SEO meta is filled on settings, homepage, and services.
Never delete a Sanity singleton
For siteSettings, globalStyles, homepage, blogIndex, portfolioIndex, and the like, never run sanity documents delete — it permanently tombstones the ID and breaks initialValue recovery. To clean up test content, Unpublish (keeping the draft) or discard a draft; the singleton bootstrap path is createOrReplace, not delete. See Studio & singletons.
Everyday operations
Once live, the day-to-day rests on one mental model: Publish ≠ Deploy.
Publish vs Deploy
| Publish (Sanity, per-document) | Deploy (the Studio "Deploy" tab) | |
|---|---|---|
| What it does | Saves a document draft → published in the dataset | Rebuilds the whole public site from current published content |
| Scope | One document | Everything |
| Speed | Instant | ~1–3 minute rebuild |
| Shows in preview? | Instantly | n/a (preview is always live) |
Shows on whitetreedigital.com? | No — not until you Deploy | Yes, when the build finishes |
The public site is static prerendered HTML with Sanity out of the request path (that's why a Sanity outage can't take it down), so it only changes when you Deploy. Publishing is instant in the staging preview and in the dataset, but the public site reflects nothing until a Deploy (or a code push) rebuilds it. This is the single most common point of confusion. Full mechanics in Two-build split.
How-tos
- Make content live — edit in Studio → Publish the doc(s) → click Deploy. (Editing/publishing alone updates the preview and the dataset; Deploy is what reaches the public.)
- Add a new page — create a Page doc with a unique slug, fill its sections, Publish (visible instantly in Presentation), then Deploy — a brand-new page only appears publicly after a Deploy bakes it into a static file. See Routing & pages.
- Add a blog post / portfolio piece / service — create + publish the
post/portfolioPost/servicedoc, then Deploy. Their slug lists are enumerated at build time, so again: instant in preview, public after Deploy. - Change theme colors / settings / header / footer — edit the
globalStyles/siteSettingssingletons → Publish → Deploy. Theme and chrome bake into the public build like everything else. See Brand tokens & theme. - Ship a code change — push to
main. Both Workers rebuild automatically (staging gets the new code; public gets the new code and picks up the latest published content). No Deploy click needed. - Fire a public deploy without Studio — the Deploy button is just a POST to a Cloudflare deploy hook. Equivalents:
npm run deploy:public # from website/, with DEPLOY_HOOK_URL set in your shell
…or the "Retry/redeploy" button in the Cloudflare dashboard.Invoke-RestMethod -Method Post -Uri "<hook-url>" # PowerShell
The Deploy hook is a secret name, not a value
The Studio Deploy tool reads SANITY_STUDIO_DEPLOY_HOOK_URL from studio/.env.production, baked in at npx sanity deploy. If it's blank, the tool shows a "not configured" notice. The hook URL is a secret POST endpoint — treat it as a name; never paste the value into docs or .env. It's deliberately manual (not wired to document webhooks) so editing doesn't rebuild on every Publish.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Published a change, public site unchanged | Working as designed — click Deploy. Publish ≠ Deploy. |
| New page 404s on public but works in preview | The public build hasn't rebuilt since the page was published. Deploy. |
getStaticPaths() ignored WARN on the staging build | Expected — those routes are dynamic in the server build; the warning is informational. |
wrangler deploy fails: "config path … does not exist" | The build went fully static, so dist/server/ wasn't emitted. Keep /api/* as prerender = false. |
| Both deployments overwrite each other | The public deploy command is missing --name whitetreedigital-public. |
cloudflare:workers shows up in dist/client after a static build | Someone reintroduced a top-level import {env} from 'cloudflare:workers' in sanity-draft.ts. Keep it dynamic; verify with grep -rc cloudflare:workers dist/client (must be 0). |
| Staging is being indexed by Google | PUBLIC_NOINDEX=true is missing on the staging Worker, or the build is stale. |
| Presentation iframe blank / won't load | Preview URL or CORS misconfigured, or Cloudflare Access was put in front of staging (incompatible — its login sends X-Frame-Options: DENY). |
| "Failed to decode stega" floods / click-to-edit broken | A Sanity string used in logic wasn't stegaClean()'d. |
| Deploy button says "not configured" | SANITY_STUDIO_DEPLOY_HOOK_URL is blank in studio/.env.production; set it and npx sanity deploy. |
Local astro dev breaks after a build | astro build clobbered the dev server's Vite cache — restart astro dev. |
Stega breaks string equality in draft mode
In draft preview, Sanity string fields carry invisible zero-width characters, so variant === 'sidebar' silently fails. Any Sanity string that drives logic — equality, switch, regex, an object/array index key — must be passed through stegaClean() first. The bug is invisible in production and only surfaces in Presentation. See Visual editing.
urlFor throws on asset-less images
urlFor() throws when a Sanity image has no resolvable asset, and an unhandled throw in a section's frontmatter blanks the whole page silently. Guard on img?.asset (not on the object) — a cleared image can leave a ghost {_type:'image', alt:'…'} that's truthy but asset-less. This matters at launch when editors are filling and clearing image fields. See Images & fonts.
Open items at launch
A few tracked items remain in TODO.md and the roadmap:
- HubSpot dashboard work — the report-email template + form-submission workflow (send email, enroll in nurture) still needs building and testing; confirm the HubSpot tracking tag fires via GTM and the
hubspotutkcookie sets. See HubSpot & lead capture. - Pages —
/aboutand/contactare content-only (create + publish apagedoc);/work(case-study index + detail) route files don't exist yet, blocked on thecaseStudyvsportfolioPostdirection decision. - Analytics & consent — GA4 + HubSpot tracking still need configuring inside GTM (dashboard work, not code), and the cookie-consent posture (US-only vs EU-aware) is undecided.
- Pre-launch Lighthouse — verify against the budgets (page weight < 2 MB, hero < 500 KB, ≤ 80 requests, ≤ 5 third-party scripts). See Performance budgets.
- Marketing placement — decide where to link the audit tool from; it intentionally isn't auto-linked yet, but at least one nav item must point at it (the highest-intent lead path).
Where this lives
| File | Role |
|---|---|
_LAUNCH-RUNBOOK.md | The single go-live procedure: pre-flight gates, CF env mirror, WAF rate-limit rule, DNS cutover, 15-min verification, rollback, week-one watch. |
_PRODUCTION LAUNCH ROADMAP.md | The phased roadmap (A–F), decisions, and per-phase verification. |
_LAUNCH-CONTENT.md | The Studio content checklist (Phase A) — contact page, site settings, homepage, purge, privacy, services. |
_TWO-BUILD-OPERATIONS.md | Publish-vs-Deploy mental model, everyday how-tos, deploy mechanics, troubleshooting. |
TODO.md | Living checklist of unshipped items — HubSpot setup, pages, analytics/consent, deployment. |
_HUBSPOT-SETUP.md | Companion: HubSpot forms, the six audit_* properties, and the report-email workflow. |
website/src/pages/api/audit.ts | The audit route — in-code per-IP throttle the WAF rule backstops. |
website/src/components/audit/WebsiteAudit.tsx | The audit island; thank-you copy to soften pre-workflow. |
studio/components/DeployTool.tsx | The Studio Deploy button that fires the public deploy hook. |