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:
- Mode —
always= 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.
Variable table
Section titled “Variable table”| Variable | Required | Mode | What reads it | Notes |
|---|---|---|---|---|
GITHUB_TOKEN | Yes | always | @drawnagency/github | Fine-grained Personal Access Token (or GitHub App token). Needs read/write access to the contents and metadata scopes of GITHUB_REPO. |
GITHUB_OWNER | Yes | always | @drawnagency/github | GitHub organisation or username that owns GITHUB_REPO. |
GITHUB_REPO | Yes | always | @drawnagency/github | Repository name (without the owner prefix) where site content is stored. |
SESSION_SECRET | Yes | always | @drawnagency/primitives (session cookie signing) + @drawnagency/core middleware | Random string, minimum 32 characters. Used to sign the session cookie in both password and Supabase modes. Generate with openssl rand -base64 32. |
AUTH_PROVIDER | No | always | Auth middleware | "password" (default) or "supabase". Selects the active auth adapter. |
ADMIN_PASSWORD | Yes (password mode) | password | Password adapter | Bcrypt 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_PASSWORD | Yes (password mode) | password | Password adapter | Bcrypt hash for editor accounts. Same escaping rules as ADMIN_PASSWORD. |
VIEWER_<NAME>_PASSWORD | No | password | Password adapter | Bcrypt hash for a named viewer audience (e.g. VIEWER_INTERNAL_PASSWORD). Add one per audience. |
VIEWER_<NAME>_COLOR | No | password | Password adapter | Hex colour for a named viewer audience in the editor UI (e.g. VIEWER_INTERNAL_COLOR=#10b981). |
SUPABASE_URL | Yes (supabase mode) | supabase | @drawnagency/auth-supabase | Project API URL, e.g. https://yourproject.supabase.co. |
SUPABASE_ANON_KEY | Yes (supabase mode) | supabase | @drawnagency/auth-supabase | Public anon key. Safe to expose to the browser. |
SUPABASE_SERVICE_ROLE_KEY | Yes (supabase mode, standalone) | supabase | @drawnagency/auth-supabase | Service-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_URL | Yes (platform mode) | platform | @drawnagency/primitives (platformBroker, isPlatformMode) + @drawnagency/auth-supabase + @drawnagency/github | Origin 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_KEY | Yes (platform mode) | platform | same as PLATFORM_API_URL | API 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_ID | Yes (platform mode) | platform | same as PLATFORM_API_URL | This 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_BUCKET | No | always | @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_TOKEN | No | cli | Supabase CLI | Personal access token for running supabase CLI commands (migrations). Not read at runtime. Obtain from the Supabase dashboard under Account → Tokens. |
SUPABASE_PROJECT_REF | No | cli | Supabase CLI | Project reference (the <ref> segment of https://supabase.com/dashboard/project/<ref>). Not read at runtime. |
SITE | Yes | supabase | @drawnagency/auth-supabase | Canonical 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.
| Variable | Required | What reads it | Notes |
|---|---|---|---|
MCP_PUBLIC_URL | Yes | apps/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_KID | Yes | apps/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_B64 | Yes | apps/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_JWK | Yes | apps/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_ORIGIN | Yes | apps/mcp (ADMIN_ORIGIN() getter) + @drawnagency/platform provisioner | Origin 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_SECRET | Yes | apps/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_SECRET | No | apps/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.
| Variable | Required | What reads it | Notes |
|---|---|---|---|
ANTHROPIC_API_KEY | Yes (for chat) | apps/admin/src/pages/api/site/llm.ts, llm-tools.ts | The 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_MODEL | No | apps/admin/src/pages/api/site/llm.ts, llm-tools.ts | Model id override, default claude-haiku-4-5. Pinned server-side — client sites cannot choose the model, on either endpoint. |
LLM_TOOLS_MODEL | No | apps/admin/src/pages/api/site/llm-tools.ts | Model 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_TOKENS | No | apps/admin/src/pages/api/site/llm-tools.ts | Output-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_THINKING | No | apps/admin/src/pages/api/site/llm-tools.ts | Set 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. |
Password hash escaping
Section titled “Password hash escaping”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.
import.meta.env → process.env fallback
Section titled “import.meta.env → process.env fallback”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.