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:

LayerFile (local)Read in code viaVisible in browser?
Build-time publicwebsite/.envimport.meta.env.PUBLIC_*Yes — by design
Build-time switchwebsite/.envimport.meta.env.BUILD_TARGET / PUBLIC_NOINDEXInlined as a literal
Runtime secretwebsite/.dev.varsimport { 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.

VariablePurpose
PUBLIC_SANITY_PROJECT_IDSanity project the site reads content from (8ftz0iuv).
PUBLIC_SANITY_DATASETDataset name — production (the only dataset).
PUBLIC_SANITY_STUDIO_URLWhere 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_IDHubSpot account ID the audit-tool lead form submits to.
PUBLIC_HUBSPOT_AUDIT_FORM_IDForm GUID for the /free-website-audit tool (one var per HubSpot form).
PUBLIC_SITE_URLCanonical 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.

VariablePurpose
BUILD_TARGETstatic selects the public prerendered build; unset (server) selects the staging SSR build. The whole two-build split hinges on this.
PUBLIC_NOINDEXtrue 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.

VariablePurpose
SANITY_API_READ_TOKENAuthenticates draft-content reads and signs the preview-auth cookie. Required on staging; inert on public (no live draft route ships there).
PSI_API_KEYGoogle 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 Referer header, so a referrer-restricted key returns 403 "Requests from referer <empty> are blocked" — PSI silently returns nothing and every speed check degrades to unknown. 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_KEY around — nothing reads it anymore. The live read is env.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).

VariableProduction locationPublicStaging
BUILD_TARGETCF build envstatic(unset → server)
PUBLIC_SITE_URLCF build envcanonical domainstaging domain
PUBLIC_NOINDEXCF build env(unset)true
PUBLIC_SANITY_PROJECT_ID / PUBLIC_SANITY_DATASETCF build env8ftz0iuv / productionsame
PUBLIC_SANITY_STUDIO_URLCF build envstudio URLstudio URL
PUBLIC_HUBSPOT_*CF build envsamesame
SANITY_API_READ_TOKENCF Secret (encrypted)optional (inert)required
PSI_API_KEYCF Secret (encrypted)requiredrequired

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-time PUBLIC_* 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; documents BUILD_TARGET / PUBLIC_NOINDEX and the cloudflare:workers Env interface.
  • website/src/lib/site.ts — normalizes PUBLIC_SITE_URL.
  • website/src/layouts/Layout.astro — the hardcoded GTM-5NDTZBHF ID and the PUBLIC_NOINDEX / BUILD_TARGET reads.
  • website/src/pages/api/audit.ts, website/src/pages/api/draft-mode/enable.ts, website/src/middleware.ts, website/src/lib/sanity-draft.ts — the cloudflare:workers env 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.
Previous
Development setup