Getting Started
Environment variables
The White Tree Digital website has exactly two kinds of configuration, and the line between them is the whole story: build-time PUBLIC_* values that get inlined into the JavaScript bundle when Astro builds, and runtime secrets the Cloudflare Worker reads fresh on each request. The first kind is fine to ship to the browser; the second must never appear in a bundle, a commit, or a PUBLIC_ name.
This page lists every variable, what it's for, where it lives locally, where it mirrors in production, and the one constant — the GTM container ID — that is deliberately not a variable at all. Deploy mechanics (how these reach the Cloudflare Workers dashboard, the two-build split) are covered in Deployment and the Two-build split page; this page is about the variables themselves.
The two layers
There is no .env file that holds "all the config." Configuration is split by when it is read and who is allowed to see it:
| Layer | File (local) | Read in code via | Visible in browser? |
|---|---|---|---|
| Build-time public | website/.env | import.meta.env.PUBLIC_* | Yes — by design |
| Build-time switch | website/.env | import.meta.env.BUILD_TARGET / PUBLIC_NOINDEX | Inlined as a literal |
| Runtime secret | website/.dev.vars | import { env } from 'cloudflare:workers' | No — never |
Both files are gitignored. Astro inlines anything prefixed PUBLIC_ (and the build switches) directly into the output at build time, so those values are baked into static HTML/JS and reach every visitor's browser. Secrets are read by the running Worker at request time from cloudflare:workers env, so they stay server-side.
A secret in .env is a leaked secret
Anything in .env with a PUBLIC_ prefix is inlined into the client bundle and readable in DevTools. SANITY_API_READ_TOKEN and PSI_API_KEY are runtime secrets — they belong in website/.dev.vars (local) and as encrypted Secrets in the Cloudflare dashboard, never in .env, never PUBLIC_-prefixed, never committed. Both files are in .gitignore; keep them there.
Build-time public variables (.env)
These live in website/.env (copy website/.env.example and fill it) and are read anywhere with import.meta.env.PUBLIC_*. They are typed in website/src/env.d.ts.
| Variable | Purpose |
|---|---|
PUBLIC_SANITY_PROJECT_ID | Sanity project the site reads content from (8ftz0iuv). |
PUBLIC_SANITY_DATASET | Dataset name — production (the only dataset). |
PUBLIC_SANITY_STUDIO_URL | Where stega source-links resolve when clicked in the Presentation Tool — e.g. http://localhost:3333/production locally, https://whitetreedigital.sanity.studio/production in prod. |
PUBLIC_HUBSPOT_PORTAL_ID | HubSpot account ID the audit-tool lead form submits to. |
PUBLIC_HUBSPOT_AUDIT_FORM_ID | Form GUID for the /free-website-audit tool (one var per HubSpot form). |
PUBLIC_SITE_URL | Canonical origin, no trailing slash — feeds sitemap, robots, canonical and og: tags. |
The HubSpot IDs are intentionally public — the audit form's submit is still client-side, so the portal ID and form GUID ship in client JS by design (the HubSpot page covers the submit path). PUBLIC_SITE_URL is normalized so a stray trailing slash can't produce double-slash canonicals:
// website/src/lib/site.ts
export const siteUrl = (import.meta.env.PUBLIC_SITE_URL ?? '').replace(/\/+$/, '');
Missing HubSpot env = silently dropped leads
submitToHubspot in website/src/components/audit/hubspot.ts fails fast and loud when either ID is absent — without the guard a misconfigured build posts to /undefined/undefined and quietly drops every lead:
if (!import.meta.env.PUBLIC_HUBSPOT_PORTAL_ID || !import.meta.env.PUBLIC_HUBSPOT_AUDIT_FORM_ID) {
console.error('[hubspot] PUBLIC_HUBSPOT_PORTAL_ID / PUBLIC_HUBSPOT_AUDIT_FORM_ID not set — lead not submitted');
throw new Error('hubspot-env-missing');
}
Set both in .env and the CF Workers dashboard before relying on the audit form.
Build switches
Two more build-time values are not content IDs but toggles that pick which of the two deployments you're producing. They're inlined as literals so the unused branch is dead-code-eliminated.
| Variable | Purpose |
|---|---|
BUILD_TARGET | static selects the public prerendered build; unset (server) selects the staging SSR build. The whole two-build split hinges on this. |
PUBLIC_NOINDEX | true marks the build as no-index — adds the noindex meta tag and robots Disallow. Set on staging, unset on public. |
Both are documented inline in website/src/env.d.ts and consumed in website/src/layouts/Layout.astro:
// Layout.astro — staging mirror (or any workers.dev host) must not be indexed
const noindex =
import.meta.env.PUBLIC_NOINDEX === 'true' || Astro.url.hostname.endsWith('.workers.dev');
Because BUILD_TARGET is inlined to a literal, import.meta.env.BUILD_TARGET !== 'static' short-circuits to false in the static build, so the draft-cookie read and the VisualEditing overlay are eliminated entirely from the public bundle.
Runtime secrets (.dev.vars)
These live in website/.dev.vars locally (the Cloudflare adapter auto-loads .dev.vars in dev, mirroring CF Pages behavior) and are read at request time via the cloudflare:workers module. They are typed on the Env interface in website/src/env.d.ts.
| Variable | Purpose |
|---|---|
SANITY_API_READ_TOKEN | Authenticates draft-content reads and signs the preview-auth cookie. Required on staging; inert on public (no live draft route ships there). |
PSI_API_KEY | Google PageSpeed Insights key used server-side by the /api/audit route. |
The read pattern is consistent across the server routes — cloudflare:workers env first, with an import.meta.env fallback so it also resolves in contexts where the value comes through Astro's env:
// website/src/pages/api/audit.ts
import {env} from 'cloudflare:workers';
const psiKey = env.PSI_API_KEY ?? import.meta.env.PSI_API_KEY;
if (!psiKey) return json({error: 'config'}, 500);
The same shape appears in website/src/middleware.ts, website/src/pages/api/draft-mode/enable.ts, and website/src/lib/sanity-draft.ts for SANITY_API_READ_TOKEN.
Keep the cloudflare:workers import dynamic in sanity-draft.ts
getSanityClient in website/src/lib/sanity-draft.ts imports cloudflare:workers dynamically (const {env} = await import('cloudflare:workers')), not at module top level. A top-level import would drag the Workers runtime into every prerendered route's graph and leak cloudflare:workers into the static client bundle. After a static build, grep -rc cloudflare:workers dist/client must be 0. See the two-build split page.
The PSI key footgun
PSI_API_KEY is a recent migration: it used to be PUBLIC_PSI_API_KEY (client-side) and is now a server secret because the audit moved server-side to /api/audit. Two consequences from the move (tracked in TODO.md):
- In Google Cloud Console the key's Application restriction must be "None", not "Websites" (HTTP referrers). The server-side call sends no
Refererheader, so a referrer-restricted key returns403 "Requests from referer <empty> are blocked"— PSI silently returns nothing and every speed check degrades tounknown. Secrecy (it's a server secret now) is the protection; the old referrer rule only made sense when the key shipped in client JS. - Don't leave a stale
PUBLIC_PSI_API_KEYaround — nothing reads it anymore. The live read isenv.PSI_API_KEY.
The audit tool pages cover how the key feeds the checks.
Why GTM is hardcoded, not an env var
The Google Tag Manager container ID is GTM-5NDTZBHF, written directly into both the <script> and <noscript> snippets in website/src/layouts/Layout.astro. It is deliberately not an environment variable.
<!-- Layout.astro (noscript fallback) -->
<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-5NDTZBHF" ...></iframe>
The reasoning: the container ID is constant per site, and an env-driven value that silently fails to load on a misconfiguration is worse than a hardcoded constant — a missing analytics tag is invisible until you go looking for lost data. GTM is also the sole injection point for analytics on this site: GA4, the HubSpot tracking pixel, and consent tags are all configured inside the GTM container, not added as separate tags or env vars in code.
A leftover type declaration
website/src/env.d.ts still declares an optional PUBLIC_GTM_CONTAINER_ID?: string, but nothing reads it — the live code uses the hardcoded GTM-5NDTZBHF literal. The declaration is inert residue, not a wired var; don't reintroduce an env-driven GTM ID.
Where each variable lives in production
Local files have a production mirror. The full deploy procedure is in Deployment and the Launch operations runbook — the mirror map is summarized here. Values differ between the public and staging Workers (per the two-build split).
| Variable | Production location | Public | Staging |
|---|---|---|---|
BUILD_TARGET | CF build env | static | (unset → server) |
PUBLIC_SITE_URL | CF build env | canonical domain | staging domain |
PUBLIC_NOINDEX | CF build env | (unset) | true |
PUBLIC_SANITY_PROJECT_ID / PUBLIC_SANITY_DATASET | CF build env | 8ftz0iuv / production | same |
PUBLIC_SANITY_STUDIO_URL | CF build env | studio URL | studio URL |
PUBLIC_HUBSPOT_* | CF build env | same | same |
SANITY_API_READ_TOKEN | CF Secret (encrypted) | optional (inert) | required |
PSI_API_KEY | CF Secret (encrypted) | required | required |
The two PUBLIC_* build vars and the build switches go in the dashboard as plaintext variables; SANITY_API_READ_TOKEN and PSI_API_KEY go in as encrypted Secrets (never plaintext — they are real secrets). Studio-side variables (SANITY_STUDIO_PREVIEW_URL, the deploy-hook URL) live in studio/.env.production and are baked into the Studio bundle at deploy time — see Visual editing and Deployment.
Env changes only take effect on rebuild
Cloudflare bakes build-time PUBLIC_* values into the Worker at build, not at request time. After changing any PUBLIC_* var or build switch in the dashboard, trigger a re-deploy (push a commit or use the dashboard) — the running Worker keeps the old inlined value until it's rebuilt. Runtime Secrets (SANITY_API_READ_TOKEN, PSI_API_KEY) do apply on the next request, but only to the deployment they were set on.
Where this lives
website/.env— build-timePUBLIC_*values (gitignored).website/.env.example— the template to copy.website/.dev.vars— runtime secrets for local dev (gitignored).website/src/env.d.ts— types for both layers; documentsBUILD_TARGET/PUBLIC_NOINDEXand thecloudflare:workersEnvinterface.website/src/lib/site.ts— normalizesPUBLIC_SITE_URL.website/src/layouts/Layout.astro— the hardcodedGTM-5NDTZBHFID and thePUBLIC_NOINDEX/BUILD_TARGETreads.website/src/pages/api/audit.ts,website/src/pages/api/draft-mode/enable.ts,website/src/middleware.ts,website/src/lib/sanity-draft.ts— thecloudflare:workersenv reads for the secrets.website/src/components/audit/hubspot.ts— the fail-fast guard on the HubSpot public IDs.website/CLAUDE.md("Environment") and_TWO-BUILD-OPERATIONS.md(§6) — the authoritative prose and the per-deployment var table.TODO.md— the PSI-key migration checklist and restriction-must-be-"None" note.