Skip to content

Environment variable reference

All environment variables for a client site are set in .env (local dev) or the Netlify site environment (production). The canonical source is .env.example in the repository root.

Columns:

  • Modealways = required regardless of auth provider; password = password-only auth; supabase = Supabase auth; platform = platform mode (provisioned client sites brokering privileged ops through the admin app); cli = local migrations only, not needed at runtime.

VariableRequiredModeWhat reads itNotes
GITHUB_TOKENYesalways@drawnagency/githubFine-grained Personal Access Token (or GitHub App token). Needs read/write access to the contents and metadata scopes of GITHUB_REPO.
GITHUB_OWNERYesalways@drawnagency/githubGitHub organisation or username that owns GITHUB_REPO.
GITHUB_REPOYesalways@drawnagency/githubRepository name (without the owner prefix) where site content is stored.
SESSION_SECRETYesalways@drawnagency/primitives (session cookie signing) + @drawnagency/core middlewareRandom string, minimum 32 characters. Used to sign the session cookie in both password and Supabase modes. Generate with openssl rand -base64 32.
AUTH_PROVIDERNoalwaysAuth middleware"password" (default) or "supabase". Selects the active auth adapter.
ADMIN_PASSWORDYes (password mode)passwordPassword adapterBcrypt hash for the site-owner account. Escape every $ as \$ in .env (Vite’s dotenv-expand interprets bare $ as variable references and silently corrupts the hash).
EDITOR_PASSWORDYes (password mode)passwordPassword adapterBcrypt hash for editor accounts. Same escaping rules as ADMIN_PASSWORD.
VIEWER_<NAME>_PASSWORDNopasswordPassword adapterBcrypt hash for a named viewer audience (e.g. VIEWER_INTERNAL_PASSWORD). Add one per audience.
VIEWER_<NAME>_COLORNopasswordPassword adapterHex colour for a named viewer audience in the editor UI (e.g. VIEWER_INTERNAL_COLOR=#10b981).
SUPABASE_URLYes (supabase mode)supabase@drawnagency/auth-supabaseProject API URL, e.g. https://yourproject.supabase.co.
SUPABASE_ANON_KEYYes (supabase mode)supabase@drawnagency/auth-supabasePublic anon key. Safe to expose to the browser.
SUPABASE_SERVICE_ROLE_KEYYes (supabase mode, standalone)supabase@drawnagency/auth-supabaseService-role key. Server-only — never expose to the client. Used for admin operations (invite, delete, role assignment). Not needed in platform mode (see below). Video asset storage no longer uses this — see the R2 variables below.
PLATFORM_API_URLYes (platform mode)platform@drawnagency/primitives (platformBroker, isPlatformMode) + @drawnagency/auth-supabase + @drawnagency/githubOrigin of the admin app’s broker API, e.g. https://admin.drawn.guide. Set together with PLATFORM_API_KEY (and PORTAL_SITE_ID) to switch privileged ops — auth admin, video asset storage, GitHub App writes, the chatbot broker, deploy-status — to route through the admin app instead of holding a service-role key on the client site. Provisioner-set on newly-created sites; not usually hand-filled.
PLATFORM_API_KEYYes (platform mode)platformsame as PLATFORM_API_URLAPI key the admin app uses to authorize this site’s broker requests (sent as x-api-key). Server-only — never expose to the client.
PORTAL_SITE_IDYes (platform mode)platformsame as PLATFORM_API_URLThis site’s id in the shared platform database, sent as x-site-id on every broker request and used to scope the shared portal-assets bucket (sites/{siteId}/…).
R2_DOCS_BUCKETNoalways@drawnagency/assets-r2 (r2Documents()) + @drawnagency/platform (admin broker, and the MCP connector’s upload_document)Two readers. On a client site: standalone mode only — name of the private R2 bucket holding document files (the PDF/HTML files behind document sections). On the MCP connector deployment it is read again (with R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY, via r2DocsConfigFromEnv()) so upload_document can presign a PUT for files over 2.5 MB; there it is optional, and unset simply means the connector is git-tier-only (see MCP server variables). Reuses R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / PORTAL_SITE_ID — only the bucket name differs from the assets bucket. There is deliberately no R2_DOCS_PUBLIC_BASE counterpart: the bucket has no public domain, and documents are only reachable through short-lived presigned URLs issued by the site’s auth-gated /api/document-file/ route. In platform mode the admin app holds this variable and brokers every operation, so a client site needs nothing extra. Unset ⇒ documents over 2.5 MB are refused in the editor with an actionable message; smaller ones still commit to the repository.
SUPABASE_ACCOUNT_TOKENNocliSupabase CLIPersonal access token for running supabase CLI commands (migrations). Not read at runtime. Obtain from the Supabase dashboard under Account → Tokens.
SUPABASE_PROJECT_REFNocliSupabase CLIProject reference (the <ref> segment of https://supabase.com/dashboard/project/<ref>). Not read at runtime.
SITEYessupabase@drawnagency/auth-supabaseCanonical origin of the deployed site, e.g. https://acme.drawn.guide. Used by Supabase auth to construct absolute URLs in invite and password-reset emails. Not in .env.example — provisioner-pinned in Netlify’s environment variables. Only read by @drawnagency/auth-supabase; not needed in password-only mode. For sites provisioned before this was added, set it manually in Netlify’s environment variables.

A Supabase-auth site needs either the platform trio (PLATFORM_API_URL + PLATFORM_API_KEY + PORTAL_SITE_ID — the provisioned default) or SUPABASE_SERVICE_ROLE_KEY (standalone) — not both. r2Assets() (large self-hosted video storage) adds no env vars in platform mode — the admin app holds the R2 credentials and brokers every operation, so a provisioned client site needs nothing extra. Standalone sites need the five R2_* variables listed under Assets (video storage), plus PORTAL_SITE_ID — object keys are tenant-prefixed sites/{PORTAL_SITE_ID}/… in both modes. r2Documents() (the private documents bucket behind PDF/HTML document sections) splits the same way — nothing extra in platform mode, and one extra standalone variable, R2_DOCS_BUCKET. supabaseCollab() (live multi-editor collaboration, the collab config key) is the same story: presence and section locks run over Supabase Realtime with the browser’s existing SUPABASE_URL / SUPABASE_ANON_KEY, and the server-side saved-change broadcast reuses the service-role key (standalone) or the platform trio (platform mode) — no new env vars, and where the env is missing it silently degrades to solo editing.


MCP server variables (apps/mcp deployment)

Section titled “MCP server variables (apps/mcp deployment)”

These are read only by the remote MCP connector deployment (apps/mcp), not by client sites — they have no client-site Mode and are set in the MCP Netlify site’s own environment. The six in the table below are required — apps/mcp/src/env.ts’s assertRequiredEnv() checks for them at cold start (logs the missing names) and on every GET /healthz (returns 503 with the missing names, never values, when any are unset). CONNECTOR_INVOKE_SECRET is deliberately not in that set — every consumer degrades without it, so it is safe to add later and to roll out to the two sites in either order.

Four platform-side variables are validated alongside them even though they are not connector-specific: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY. Without the Supabase pair getAdminClient() throws inside verifyBearer, and without the GitHub App pair getInstallationToken() throws in the site resolver — so a deploy missing any of them serves errors on every request. They are in the required set so that failure shows up as a red /healthz naming the variable, instead of a green health check in front of a server returning opaque 500s.

Beyond those ten, the MCP deployment also needs the remaining platform-side variables read transitively through @drawnagency/platform (NETLIFY_API_TOKEN/NETLIFY_TEAM_SLUG, CLOUDFLARE_API_TOKEN/CLOUDFLARE_ZONE_ID, and TEMPLATE_OWNER/TEMPLATE_REPO) — the same set the admin app’s provisioning path uses. These are unvalidated because they gate provisioning only: without them create_site fails, but reads and content writes keep working. Four more are optional: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY and R2_DOCS_BUCKET enable upload_document’s bucket tier (documents over 2.5 MB, presigned straight into the platform’s private documents bucket). Unset, the connector runs git-tier-only and refuses larger files with a typed message — a degrade, not a fault, which is why they are outside the required set. See MCP connector internals.

VariableRequiredWhat reads itNotes
MCP_PUBLIC_URLYesapps/mcp (MCP_PUBLIC_URL() getter, plus the derived MCP_RESOURCE_URL()/MCP_HOST())Public origin of the deployed MCP connector, e.g. https://mcp.drawn.guide. Used as the OAuth issuer and JWT iss; MCP_RESOURCE_URL() (+ "/mcp") is the JWT aud and must byte-match the protected-resource metadata’s resource.
MCP_JWT_KIDYesapps/mcp (MCP_JWT_KID() getter; oauth/keys.ts)Key ID stamped into the header (kid) of every signed access/txn/consent token and published in the JWKS response, so the verifier can select the matching public key.
MCP_JWT_PRIVATE_KEY_B64Yesapps/mcp (MCP_JWT_PRIVATE_KEY_B64() getter; oauth/keys.ts)Base64-encoded PKCS8 PEM of the ES256 private signing key used to sign access tokens and the short-lived OAuth txn/consent tokens. Server-only — never expose to the client.
MCP_JWT_PUBLIC_JWKYesapps/mcp (MCP_JWT_PUBLIC_JWK() getter; oauth/keys.ts, oauth/metadata.ts)JSON-stringified public JWK matching the private key above. Served at /.well-known/jwks.json and used locally to verify tokens. Must parse as valid JSON — an empty/unset value throws inside token verification and JWKS serving, which is exactly what cold-start//healthz validation catches before it happens.
ADMIN_ORIGINYesapps/mcp (ADMIN_ORIGIN() getter) + @drawnagency/platform provisionerOrigin of the admin/platform app, e.g. https://admin.drawn.guide. The provisioner sources PLATFORM_API_URL from env("ADMIN_ORIGIN") (falling back to import.meta.env.SITE); it is the source of truth inside the provision-background Netlify function, where import.meta.env.SITE is unreliable.
PROVISION_INVOKE_SECRETYesapps/mcp (PROVISION_INVOKE_SECRET() getter)HMAC-SHA256 shared secret. create_site signs the fire-and-forget provisioning POST with it; the provision-background background function verifies the x-provision-signature header (timing-safe) and rejects any unsigned or mismatched request. Must be a high-entropy random secret of at least 32 characters — generate with openssl rand -base64 32. This is enforced fail-closed at both ends: if the secret is unset or shorter than 32 chars, create_site returns a config error (no site row is created) and provision-background refuses every request with a 500, because node:crypto accepts an empty key as a valid HMAC key and a blank secret would let any caller forge a signature. Server-only — never expose to the client.
CONNECTOR_INVOKE_SECRETNoapps/mcp (CONNECTOR_INVOKE_SECRET() getter; lib/live-deploy-state.ts) + apps/admin (/api/connector/deploy-state)HMAC-SHA256 shared secret for connector→admin broker calls. Today one caller: get_build_status asks admin for the site’s LIVE Netlify deploy state, because admin holds NETLIFY_API_TOKEN and the connector deliberately does not (it is reachable by every authorized teammate, and that token can modify sites). Same 32-character minimum and timing-safe verification as PROVISION_INVOKE_SECRET, and must be a different value — sharing one would mean a leak of this read-only secret also unlocks provisioning. Optional by design: unset on either side, the endpoint answers 503, the connector logs and falls back to the stored deploy_status row, and nothing else changes — so it can be rolled out to the two sites in either order. Set the SAME value on the mcp and admin Netlify sites. Server-only.

Chatbot broker variables (apps/admin deployment)

Section titled “Chatbot broker variables (apps/admin deployment)”

Read only by the admin app’s /api/site/llm (plain chat) and /api/site/llm-tools (tool-use) endpoints — the platform-held LLM access for the on-site brand chatbot and for tool-calling consumers. Client sites never hold these.

VariableRequiredWhat reads itNotes
ANTHROPIC_API_KEYYes (for chat)apps/admin/src/pages/api/site/llm.ts, llm-tools.tsThe platform’s Anthropic API key. Server-only — never expose to the client or set in client-site environments. Unset ⇒ the endpoints return 503 not_configured and chat is unavailable everywhere.
CHATBOT_MODELNoapps/admin/src/pages/api/site/llm.ts, llm-tools.tsModel id override, default claude-haiku-4-5. Pinned server-side — client sites cannot choose the model, on either endpoint.
LLM_TOOLS_MODELNoapps/admin/src/pages/api/site/llm-tools.tsModel id for the tool-use endpoint only, letting it diverge from brand chat. Falls back to CHATBOT_MODEL, then claude-haiku-4-5.
LLM_TOOLS_MAX_TOKENSNoapps/admin/src/pages/api/site/llm-tools.tsOutput-token ceiling for the tool-use endpoint only, default 16384, clamped to 32000. The plain /api/site/llm endpoint stays at 1024 regardless.
LLM_TOOLS_THINKINGNoapps/admin/src/pages/api/site/llm-tools.tsSet to omit to drop the thinking key from the upstream request. The endpoint otherwise pins thinking: {"type":"disabled"}, because thinking is on by default on Claude Opus 5 / Sonnet 5 and its tokens are billed against the same max_tokens as the tool call. Required when LLM_TOOLS_MODEL names an always-on model (Claude Fable 5, Claude Mythos 5), which reject "disabled" with a 400 on every request.

Bcrypt hashes contain $ characters. Vite processes .env with dotenv-expand, which interprets $name as a variable reference and strips it, silently corrupting the hash. Always escape every $ with a backslash:

# bcryptjs output:
# $2b$10$n1JHs0z5qYC.ISZGabc...
# Write in .env as:
ADMIN_PASSWORD=\$2b\$10\$n1JHs0z5qYC.ISZGabc...

Quoting the value does not prevent expansion. Only \$ works.


Custom env vars (everything except Vite builtins like PROD, DEV, SSR) are not injected into import.meta.env in the Netlify Functions SSR runtime. All @drawnagency/* packages that read env vars use this pattern:

const value =
import.meta.env?.[key] ??
(typeof process !== "undefined" ? process.env?.[key] : undefined) ??
"";

The typeof process guard is required because portal.config.mjs and its imports can be loaded in the browser during editor hydration, where process is undefined. If you add a new env var read in any @drawnagency/* package, always include both the import.meta.env lookup and the guarded process.env fallback.