Skip to content

Environment variable reference

This is the single reference for environment variables. There are three deployments, each with its own table: a client site (every provisioned portal’s Netlify site; locally .env, whose canonical source is the template’s .env.example), the admin app (admin.drawn.guide), and the MCP connector (mcp.drawn.guide). Environment variables covers the client-site setup prose (hash generation, escaping, platform vs standalone); this page is the table.

Columns (client site): 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); standalone = a self-hosted site holding its own provider credentials; build = read only during astro build / portal-build-pdfs, not at request time; cli = local migrations only.


VariableRequiredModeWhat reads itNotes
GITHUB_TOKENYes (standalone)always@drawnagency/githubFine-grained Personal Access Token (or GitHub App token). Needs read/write access to the contents and metadata scopes of GITHUB_REPO. In platform mode the site mints installation tokens through the broker instead.
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.
GITHUB_BRANCHNoalways@drawnagency/github (packages/github/src/index.ts)The published content branch the storage adapter reads from and that publish promotes the saved draft branch into. Default main; .env.example sets it explicitly. Not the draft branch — that is always saved.
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 node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" (as .env.example says; openssl rand -base64 32 is equivalent). Provisioner-set on new sites.
NETLIFY_WEBHOOK_SECRETYes (provisioned)always@drawnagency/core /api/webhooks/netlifySecret Netlify signs its deploy-event webhook with (JWS in x-webhook-signature); the route verifies it before recording build status. Generated and set by the provisioner (packages/platform/src/provisioner.ts), which also registers the webhooks — never hand-filled. Unset, the route acks and ignores every event (so a lagging secret cannot get the hook disabled) and the editor’s build status stays blank.
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). Deliberately absent on every provisioned site — platform mode replaces it (see below).
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’s required block — provisioner-pinned in Netlify’s environment variables. Not needed in password-only mode. For sites provisioned before this was added, set it manually in Netlify.
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; standalone with a bucket)platform, standalonesame as PLATFORM_API_URL, @drawnagency/assets-r2, /api/build-statusThis site’s id in the shared platform database, sent as x-site-id on every broker request and used to scope the shared buckets (sites/{siteId}/…) — so a standalone site with r2Assets()/r2Documents() needs it too.
R2_ACCOUNT_IDYes (standalone with a bucket)standalone@drawnagency/assets-r2Cloudflare account id that owns the bucket. Platform-mode sites never hold any R2_* variable — the admin app does.
R2_ACCESS_KEY_IDYes (standalone with a bucket)standalone@drawnagency/assets-r2R2 S3-API access key id.
R2_SECRET_ACCESS_KEYYes (standalone with a bucket)standalone@drawnagency/assets-r2R2 S3-API secret. Server-only. Scope the token to Object Read & Write on the one bucket.
R2_BUCKETYes (standalone, r2Assets())standalone@drawnagency/assets-r2 (r2Assets())Public assets bucket name (portal-assets) for self-hosted video over the git cap.
R2_PUBLIC_BASEYes (standalone, r2Assets())standalone@drawnagency/assets-r2 (r2Assets())Public origin serving the assets bucket, e.g. https://assets.drawn.guide. Use a custom domain, not r2.dev (rate-limited, no CDN caching).
R2_DOCS_BUCKETNostandalone@drawnagency/assets-r2 (r2Documents()) + @drawnagency/platform (admin broker, and the MCP connector’s upload_document)Name of the private R2 bucket holding document files (the PDF/HTML files behind document sections) over 2.5 MB. Reuses the R2_* credentials above — only the bucket name differs. There is deliberately no R2_DOCS_PUBLIC_BASE: 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. Unset ⇒ documents over 2.5 MB are refused in the editor with an actionable message; smaller ones still commit to the repository.
DOCUMENT_PDFNobuildportal-build-pdfs (packages/core/scripts/build-pdfs.mjs)Build-time PDF rendering switch: defaults on only when NETLIFY=true; 1 force-enables (local), 0 force-disables. Only meaningful with portalIntegration({ documentPdf: true }) — see Config reference.
DOCUMENT_PDF_DIRNoalways@drawnagency/core (lib/document-pdf/runtime.ts)Explicit directory holding the rendered document-pdfs/ + pdf-manifest.json; without it the /api/document-pdf route walks up from the bundle to find them. Set by the gate script and local runs only.
COMMIT_REF → BUILD_COMMITNobuild@drawnagency/core integration (define) → /api/build-statusCOMMIT_REF is Netlify’s built-in commit sha for the build; the integration inlines it as import.meta.env.BUILD_COMMIT, which /api/build-status compares against the deploy-status row so the editor can flip “Publishing” to “Published” for the commit that actually shipped. Never set either by hand.
SUPABASE_ACCOUNT_TOKENNocliSupabase CLIPersonal access token for running supabase CLI commands (migrations). Not read at runtime.
SUPABASE_PROJECT_REFNocliSupabase CLIProject reference (the <ref> segment of https://supabase.com/dashboard/project/<ref>). Not read at runtime.

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() and r2Documents() add no env vars in platform mode — the admin app holds the R2 credentials and brokers every operation; standalone sites need the R2_* rows above plus PORTAL_SITE_ID, since object keys are tenant-prefixed sites/{PORTAL_SITE_ID}/… in both modes. supabaseCollab() (live multi-editor collaboration, the collab config key) adds nothing either: 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) — where the env is missing it silently degrades to solo editing.


Admin app variables (apps/admin deployment)

Section titled “Admin app variables (apps/admin deployment)”

Read by the admin app itself and by @drawnagency/platform, which admin bundles from source. apps/admin/astro.config.mjs declares most of these in Astro’s env.schema as required server secrets, so a missing one fails astro build rather than a request. The canonical local source is apps/admin/.env.example. The MCP connector deployment needs the platform subset of this table too (see the next section).

VariableRequiredWhat reads itNotes
SUPABASE_URLYes@drawnagency/platform (supabase.ts), admin authShared platform Supabase project URL.
SUPABASE_ANON_KEYYes@drawnagency/platform, admin authAnon key for the request-scoped client.
SUPABASE_SERVICE_ROLE_KEYYes@drawnagency/platform (getAdminClient())Service-role key for the platform database — user management, site rows, brokered auth admin for client sites. Server-only.
GITHUB_APP_IDYes@drawnagency/platform (github-app.ts)GitHub App id used to mint installation tokens for client repos (provisioning, brokered writes).
GITHUB_APP_PRIVATE_KEYYes@drawnagency/platform (github-app.ts)PEM private key of the GitHub App. Server-only.
GITHUB_APP_WEBHOOK_SECRETYes@drawnagency/platform (verifyWebhookSignature)Verifies GitHub App webhook deliveries.
GITHUB_APP_CLIENT_ID / GITHUB_APP_CLIENT_SECRETYesapps/admin (dashboard.astro, github/callback.astro)OAuth client for linking a platform user’s GitHub account from the dashboard.
NETLIFY_API_TOKENYes@drawnagency/platform (netlify-api.ts)Netlify personal access token: creates sites, sets env vars, registers webhooks, triggers builds, and answers the connector’s deploy-state broker call. Deliberately held only here — never on client sites or the connector.
NETLIFY_TEAM_SLUGYes@drawnagency/platform (netlify-api.ts)Netlify team new sites are created in.
TEMPLATE_OWNER / TEMPLATE_REPOYes@drawnagency/platform (provisioner.ts)The client-repo template (Drawn-Agency/client-brand-portal) new sites are generated from.
CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_IDYes@drawnagency/platform (cloudflare-api.ts)DNS records for <subdomain>.drawn.guide on provisioning and teardown.
SITEYesapps/admin (lib/site-admin.ts), @drawnagency/platform (provisioner.ts)The admin app’s own origin. Pin it to https://admin.drawn.guide; it is the fallback source of PLATFORM_API_URL for provisioned sites.
ADMIN_ORIGINNo (admin) / Yes (mcp)@drawnagency/platform (provisioner.ts)Preferred over SITE as the PLATFORM_API_URL written to a new site; required on the connector, where import.meta.env.SITE is unreliable inside the background function.
R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEYYes@drawnagency/platform (r2.ts)Cloudflare R2 credentials the admin app brokers on behalf of every platform-mode client site.
R2_BUCKET / R2_PUBLIC_BASEYes@drawnagency/platform (r2.ts)The shared public assets bucket (portal-assets) and its public origin.
R2_DOCS_BUCKETYes (documents)@drawnagency/platform (r2.ts)The shared private documents bucket (portal-documents). No public base, by design.
CONNECTOR_INVOKE_SECRETNoapps/admin (/api/connector/deploy-state)Shared with the MCP connector — see its row in the MCP table. Must match on both sites.
DEV_DRY_RUNNo@drawnagency/platform (provisioner.ts)true turns provisioning into a no-op for local development. Never set in production.
SEED_USER_EMAIL / SEED_GITHUB_ORGNoapps/admin/scripts/seed-dev.tsLocal pnpm --filter portal-admin seed:dev only; not read at runtime.

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.

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, documented in the admin table. 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.

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.