The system reads ~60 environment variables. About half are public (NEXT_PUBLIC_*); the other
half are secrets (Clerk keys, MongoDB URI, WordPress webhook secret, AWS SES creds, …).
| Environment | Where secrets live |
|---|---|
| Local dev | .env.local and .env.secrets (both gitignored), plus the machine-local out-of-repo override at ~/.envs/.matters-ai/.env.<app>.prod (see load order below). |
| Preview deploys | Vercel project env vars, scoped to the "Preview" target. |
| Production | Vercel project env vars, scoped to the "Production" target. |
| CI | GitHub Actions workflow env: block — dummy values only (.github/workflows/ci.yml). Never real secrets. |
Next.js natively loads .env and .env.local. On top of that, each app's config loader pulls
in production secrets from a file kept outside the repo, on the same machine — so real
production values never sit inside the working tree. The order (first existing file wins for the
prod override; .env.secrets is always layered on top):
~/.envs/.matters-ai/.env.<app>.prod — the canonical store, resolved via os.homedir() so it
works identically on macOS, Linux, and Windows. <app> is the project name:
.env.web.prod — public website / anything consuming @matters/lib
(packages/lib/src/config/env.ts:26).env.memo.prod — memo (apps/memo/astro.config.mjs, apps/memo/drizzle.config.ts).env.partners.prod — partners portal (apps/partners/drizzle.config.ts)<repo>/.env.prod is loaded instead.<repo>/.env.secrets is then layered on top for any additional super-secrets.All of this runs server-side only (typeof window === "undefined") and is wrapped in a
try/catch that silently no-ops where Node's fs isn't available (e.g. the edge runtime — see the
proxy.ts note below).
The canonical Zod schema is at packages/lib/src/config/env.ts (re-exported via
@/lib/config/env). It validates at boot — missing required vars cause loud startup failures,
not silent runtime errors.
Never use process.env.X directly in app code. Always:
import { env } from "@/lib/config/env";
const url = env.WORDPRESS_URL; // typed, validated
const secret = env.WORDPRESS_WEBHOOK_SECRET;
The env object is a typed, parsed z.infer<typeof envSchema>. Direct process.env.X access
bypasses validation and gives you string | undefined.
The only exception is
proxy.tswhich runs in the edge runtime and currently readsFRASE_WEBHOOK_API_KEYdirectly. Edge runtime can't import the full env module because Zod's validator opens.env.secretsvia Node'sfs. Acceptable for that one variable; any further edge-runtime secrets should follow the same pattern with a clear comment.
CLERK_SECRET_KEY on both Vercel projects (matters-web + matters-workspace) at the
same time. There is a brief window where one app has the new key and the other has the
old; calls to clerkClient().users.* from the old key will start 401-ing.CLERK_WEBHOOK_SECRET) is rotated independently in Clerk → Webhooks → roll
secret. Update the corresponding Vercel env var.openssl rand -hex 32 (must be ≥32 chars).WORDPRESS_WEBHOOK_SECRET on matters-web Vercel project.MONGODB_CONNECTION_STRING on both Vercel projects.AWS_SES_ACCESS_KEY_ID + AWS_SES_SECRET_ACCESS_KEY on matters-workspace
(the public site doesn't send transactional email).SALESFORCE_WEB_TO_LEAD_URL, SALESFORCE_ORG_ID, etc. on both Vercel projects
(public marketing forms use the same Salesforce intake).If you accidentally commit a secret:
git filter-repo).security@matters.ai.Don't trust "the commit was reverted" — assume scrapers indexed the public branch within seconds. Rotation is the only safe response.
GitHub's secret scanning catches the obvious ones (AWS access keys, GitHub tokens). It does
NOT catch arbitrary 32-char strings like WORDPRESS_WEBHOOK_SECRET.
lib/downloads-config.ts and similar).packages/lib.