Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

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.

Last updated on September 22, 2026

Was this page helpful?