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.

PhaseFocusOwner
A — Unbreak the funnelPublish /contact, fill HubSpot env IDs + properties + workflow, fill siteSettings, purge placeholder content, legal pages, trust-signal decisionStudio + HubSpot dashboard
B — SEO before DNSTitle/meta fallback chain, Sanity-driven sitemap, canonical-origin fix, og-default.png, branded 404 + 503-on-outage, noindex the workers.dev host, JSON-LDCode
C — PerformanceCut the 2.1 MB lead-magnet island, replace the 3 MB CTA background, edge-cache middleware, prefetch, font prune, gate AnimDebugCode
D — HardeningRate-limit + origin-check /api/audit, HubSpot submit robustness, draft-cookie HMAC, stegaClean gaps, hydration fix, section-drift testCode
E — Studio editor liftsRequired-when-embed validation, reserved-slug block, orderings/previews, desk cleanupStudio schema
F — DocsLaunch runbook, audit-funnel docs, .env reconciliation, tunables backfill, README rewritesDocs

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):

  • /contact published 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.
  • /seed plus the three dummy posts unpublished; /privacy live 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.tsx so 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_KEY is an encrypted Secret in the Cloudflare dashboard.
  • Local npm run build succeeds — stop the dev server first — and dist/client/_astro/ shows the WebsiteAudit island at tens of KB, not MB.
  • The Studio has been redeployed (npx sanity deploy from studio/) 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:

NameTypeNotes
PUBLIC_SANITY_PROJECT_IDPlaintext8ftz0iuv
PUBLIC_SANITY_DATASETPlaintextproduction
PUBLIC_SANITY_STUDIO_URLPlaintextwhere stega edit-links resolve
PUBLIC_HUBSPOT_PORTAL_IDPlaintextthe HubSpot portal ID
PUBLIC_HUBSPOT_AUDIT_FORM_IDPlaintextthe audit form GUID
PUBLIC_SITE_URLPlaintexthttps://whitetreedigital.comno trailing slash
SANITY_API_READ_TOKENSecretread token; also signs the draft-preview cookie
PSI_API_KEYSecretthe PageSpeed Insights key
PUBLIC_PSI_API_KEYdelete — 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/audit AND Method equals POST.
  • Rate: 3 requests per 10 seconds per 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:

  1. Attach the domain — worker → Settings → Domains & Routes → Add → Custom domain → whitetreedigital.com. Add www. too, or create a Redirect Rule www → apex (308). Cloudflare provisions DNS + cert automatically if the zone is on Cloudflare.
  2. Set PUBLIC_SITE_URL to https://whitetreedigital.com (if not already) → redeploy so it bakes in.
  3. Sanity CORS, from studio/:
    npx sanity cors add https://whitetreedigital.com --credentials
    
  4. Studio preview URL — edit studio/.env.production so SANITY_STUDIO_PREVIEW_URL points at the live origin, then npx sanity deploy. Also add the new origin to ALLOWED_ORIGINS in studio/sanity.config.ts (it's env-derived, but the explicit list keeps localhost Studio working).
  5. 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.
  6. 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.com loads; view-source: head shows the correct <title>, a canonical of https://whitetreedigital.com/... (no //), and an og:image that 200s.
  • /robots.txt on the canonical domain returns Allow + the sitemap; the *.workers.dev host returns Disallow: / and its pages carry noindex.
  • /sitemap.xml lists 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-audit creates a contact with the audit_* 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 on workers.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:

FailureRecovery
Bad deployWorker → Deployments → ⋮ on the previous good build → Rollback (instant, no rebuild).
Bad contentStudio → open the doc → ⋮ → Review changes → restore a prior revision (every publish is versioned).
Domain emergencyRemove the custom domain from the worker — workers.dev keeps serving; pointing DNS back can wait.
Edge-cache poisoningDashboard → 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/hubspotutk cookie 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.

  1. Create the /contact page — the launch blocker. Every CTA points at /contact, which currently 404s as a blank page. Create a Page (Title Contact, slug contact), 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.
  2. 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.
  3. Fix the homepage. It has an open draft — review before publishing. Replace the literal THIS SECTION NEEDS MOBILE OPTIMIZATION heading 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).
  4. Purge test content. Unpublish the SEED kitchen-sink page (keep as draft), discard the TEST draft, and unpublish the three dummy posts (Post 1/Post 2/Post 3).
  5. Create the /privacy page. Add a Page (slug privacy) 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.)
  6. 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.
  7. Quick decisions. Leave /portfolio unlinked 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 doesSaves a document draft → published in the datasetRebuilds the whole public site from current published content
ScopeOne documentEverything
SpeedInstant~1–3 minute rebuild
Shows in preview?Instantlyn/a (preview is always live)
Shows on whitetreedigital.com?No — not until you DeployYes, 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 / service doc, 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 / siteSettings singletons → PublishDeploy. 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
    
    Invoke-RestMethod -Method Post -Uri "<hook-url>"   # PowerShell
    
    …or the "Retry/redeploy" button in the Cloudflare dashboard.

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

SymptomCause / fix
Published a change, public site unchangedWorking as designed — click Deploy. Publish ≠ Deploy.
New page 404s on public but works in previewThe public build hasn't rebuilt since the page was published. Deploy.
getStaticPaths() ignored WARN on the staging buildExpected — 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 otherThe public deploy command is missing --name whitetreedigital-public.
cloudflare:workers shows up in dist/client after a static buildSomeone 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 GooglePUBLIC_NOINDEX=true is missing on the staging Worker, or the build is stale.
Presentation iframe blank / won't loadPreview 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 brokenA 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 buildastro 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 hubspotutk cookie sets. See HubSpot & lead capture.
  • Pages/about and /contact are content-only (create + publish a page doc); /work (case-study index + detail) route files don't exist yet, blocked on the caseStudy vs portfolioPost direction 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

FileRole
_LAUNCH-RUNBOOK.mdThe 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.mdThe phased roadmap (A–F), decisions, and per-phase verification.
_LAUNCH-CONTENT.mdThe Studio content checklist (Phase A) — contact page, site settings, homepage, purge, privacy, services.
_TWO-BUILD-OPERATIONS.mdPublish-vs-Deploy mental model, everyday how-tos, deploy mechanics, troubleshooting.
TODO.mdLiving checklist of unshipped items — HubSpot setup, pages, analytics/consent, deployment.
_HUBSPOT-SETUP.mdCompanion: HubSpot forms, the six audit_* properties, and the report-email workflow.
website/src/pages/api/audit.tsThe audit route — in-code per-IP throttle the WAF rule backstops.
website/src/components/audit/WebsiteAudit.tsxThe audit island; thank-you copy to soften pre-workflow.
studio/components/DeployTool.tsxThe Studio Deploy button that fires the public deploy hook.
Previous
Deployment (Cloudflare)