Environment
How settings and secret keys work in zap.ts: one schema per app, your values in .env.local, and one check that tells you what is missing.
An environment variable is a setting your app reads when it starts. A database address or an API key is an environment variable.
zap.ts manages them with varlock. Varlock reads a file that lists the variables, checks their values, and writes the TypeScript types for you.
One schema per app
A .env.schema file declares variables. It says the name, the type, whether
the value is needed, and whether the value is safe to send to the browser.
The schema holds no real secret. The values in it are safe defaults for your own computer.
| Schema | Holds | Read by |
|---|---|---|
.env.schema |
The shared keys: Better Auth, Google, Stripe, OpenAI, mail, PostHog, Sentry, R2_* |
packages/* |
apps/web/.env.schema |
The shared keys, plus API_URL |
apps/web |
apps/admin/.env.schema |
The shared keys | apps/admin |
apps/marketing/.env.schema |
SITE_URL, APP_URL, plus the PostHog and Sentry keys |
apps/marketing |
apps/api/.env.schema |
CORS_ORIGINS, API_URL, plus SENTRY_* and the keys it needs |
apps/api |
packages/database/.env.schema |
DATABASE_URL |
The db:* scripts |
How an app borrows a variable
A variable has one owner. Other schemas borrow it with @import().
apps/web/.env.schema takes everything from the root schema:
# @import(../../.env.schema)
apps/marketing/.env.schema takes only six variables, by name:
# @import(../../.env.schema, pick=[POSTHOG_KEY, POSTHOG_HOST, SENTRY_DSN, SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN])
You can also pick by prefix. A * at the end takes every name that starts
that way. apps/api/.env.schema uses this:
# @import(../../.env.schema, pick=[SENTRY_*, BETTER_AUTH_*, GOOGLE_*, STRIPE_*])
SENTRY_* takes SENTRY_DSN, SENTRY_ORG, SENTRY_PROJECT and
SENTRY_AUTH_TOKEN in one line.
This way a key is declared once. When you change its type or its default, every app that borrows it changes too.
Putting in your own values
Real values go in a file named .env.local.
Place that file next to the schema that declares the variable. So
DATABASE_URL goes in packages/database/.env.local, because that is where
the schema declares it.
# packages/database/.env.local
DATABASE_URL=postgresql://user:password@your-host:5432/your-database
.env.local files are never saved to git. Your keys stay on your computer.
You do not need one to start. The defaults in the schema point at the services
that docker compose up -d runs for you.
Checking what your app will read
Run one of these to see how every value resolves:
pnpm run env:web
pnpm run env:admin
pnpm run env:marketing
pnpm run env:api
pnpm run env:packages
The first four print every variable of that app, its value, and the file the
value came from. So you can see whether your .env.local was picked up.
pnpm run env:packages is different. It does not print a list. It writes the
TypeScript types again for the shared schema, so your editor knows the new
variable.
When a value is missing
A variable marked @required must have a value.
If one is missing, the build stops. It does not start half broken. The error names the variable, so you know which line to fix.
The same check runs when the dev server starts.
Secrets stay secret
Each variable says whether it is sensitive. In zap.ts, sensitive is the default.
A sensitive value never goes into the code the browser downloads. Varlock leaves it out of the bundle.
A sensitive value is also hidden in logs. If your code prints one by mistake, you see a redacted placeholder, not the key.
A variable that is safe to show is marked @sensitive=false. POSTHOG_HOST
and SITE_URL are two examples. They are public addresses, so there is no
harm.
Builds in CI
CI is the server that builds your code after you push it. It has no secret keys, and it should not need any.
So apps/web and apps/admin each hold a file named .env.ci. It contains
placeholder values, one for every required variable.
Varlock loads that file when the VARLOCK_ENV variable is set to ci:
VARLOCK_ENV=ci pnpm run build
The build then passes its checks with no real key anywhere.
Values on a deploy
A Cloudflare deploy runs through varlock-wrangler. It reads your schema and
sends the values to Cloudflare for you.
Values that are not sensitive go up as Cloudflare vars. Sensitive values go up as Cloudflare secrets.
A Vercel deploy runs through varlock run, which resolves the same values for
the build but sends nothing anywhere. You put the values in Vercel yourself,
with vercel env add or in the project settings. Your schema stays the source
of truth for what an app needs; on Vercel it is not the source of truth for
what the host holds. See Deployment.
Reading a variable in your code
You do not read process.env directly. You import a typed object instead.
The Environment page covers that part: the env
object and the requireEnv helper.
Related
- Environment package — reading values in code
- Quickstart — the first run, with no keys
- Local development — the services the defaults point at
- Database — where
DATABASE_URLis used - API —
CORS_ORIGINSandAPI_URL