SvelteKit environment variables
SvelteKit gives you four environment modules: $env/static/private, $env/dynamic/private, $env/static/public, and $env/dynamic/public. The naming is precise and the distinctions matter. This guide covers what each module does, when to use which, and how to manage the actual env surface of a multi-tenant SaaS starter — which ships on better-sqlite3 (Node), so its env is SQLite paths and tunables rather than Workerd bindings.
The four $env modules
SvelteKit divides environment variables along two axes: visibility (public vs. private) and resolution timing (static vs. dynamic).
// Private — server only, never reaches the browser
import { DATA_DIR } from '$env/static/private';
import { DATA_DIR } from '$env/dynamic/private';
// Public — available on both server and client
// (illustrative; the starter ships no PUBLIC_ vars — it's pure server-side)
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { PUBLIC_SITE_NAME } from '$env/dynamic/public';Static modules ($env/static/*) are resolved at build time. Vite replaces every import with a literal string — the value is baked into the JavaScript output. This means the env var must be present when you run vite build, and changing it requires a rebuild.
Dynamic modules ($env/dynamic/*) read the real process environment at request time. The import resolves to a runtime lookup, not a compile-time constant. This is essential on serverless platforms where the environment is not available during the build step. The starter kit itself reads env via process.env directly in a few server modules (see below), so it isn't sensitive to this distinction, but the four modules matter for app code that wants typed env access.
Public vs. private
Only the $env/static/public and $env/dynamic/public variables reach the browser. SvelteKit enforces this: importing $env/dynamic/private in a +page.svelte will cause a build error. Public variables are useful for client-visible configuration — site name, analytics IDs, feature flags that the UI needs to read directly.
Private variables stay server-side. Database paths, API keys, and any value that would compromise your system if exposed belong in private env. In the kit, the only private env values are runtime tunables like DATA_DIR — session tokens are random per-session (hashed in the DB) and password salts are embedded in each stored hash, so there is no site-wide secret to manage. The enforcement is structural, not just convention — SvelteKit's compiler actively prevents private values from leaking into client bundles.
Static vs. dynamic: which to choose
The default recommendation: use dynamic for anything server-side in a SaaS starter. Static env is convenient for values that are truly constant across every build — a rare scenario when you're deploying to multiple environments or tenants. Dynamic env gives you the flexibility to change values without rebuilding.
// In a +page.server.ts load function:
import { DATA_DIR } from '$env/dynamic/private';
export function load() {
// DATA_DIR is read from process.env at request time
return { dbReady: Boolean(DATA_DIR) };
}Static env has one real advantage: the bundler can dead-code-eliminate branches that depend on a known value. If a static env var is empty, the import resolves to an empty string and the compiler can optimize away unreachable code. This rarely matters in practice for server-side modules.
The starter kit's actual env surface
The shipped starter's env surface is small and SQLite-first. These are the only env vars the kit reads, all with sane defaults read from process.env (see src/lib/server/db/index.ts, src/lib/server/ratelimit.ts, and src/lib/server/billing/mock.ts):
# Where the SQLite database file lives (the kit opens ./data/app.db)
DATA_DIR=./data
# Seat limit for the MockBillingAdapter (default 3)
MOCK_PLAN_SEATS=3
# Failed auth attempts allowed per sliding window (default 5)
AUTH_FAILED_ATTEMPTS=5
# Sliding window for those attempts, in ms (default 900000 = 15 min)
AUTH_WINDOW_MS=900000DATA_DIR points at the folder where getDb() creates app.db (default ./data). MOCK_PLAN_SEATS caps how many members can join an org before the billing adapter blocks the next seat. AUTH_FAILED_ATTEMPTS and AUTH_WINDOW_MS tune the auth rate limiter — with the current defaults, after 5 failed attempts in a rolling 15-minute window the login/signup routes return fail(429, ...).
There is no DATABASE_URL, IP_SALT, or SESSION_SECRET in the kit — sessions are DB-backed (a random 32-byte hex token, only its SHA-256 hash stored), so no site-wide signing secret needs configuring. Brand names like BRAND_NAME/BRAND_EMAIL belong to the separate Astro SITE build (site/src/config.ts), not to the SvelteKit kit.
Local development: .env files
For local development with npm run dev, SvelteKit reads .env files automatically. The standard .env and .env.local are loaded by Vite, with .env.local taking precedence. These files should be in .gitignore — they contain secrets and are not for version control.
# .env (gitignored) — the starter's real vars, with non-default values
DATA_DIR=./data
MOCK_PLAN_SEATS=10
AUTH_FAILED_ATTEMPTS=5
AUTH_WINDOW_MS=900000SvelteKit also supports .env.development and .env.production for environment-specific overrides, following Vite's loading order. A .env.example (committed, no values) documents the expected shape for other developers.
Site deployment (Cloudflare Pages/Workers) — not the kit
The kit itself cannot run on Cloudflare Workers: it uses better-sqlite3, which needs a real filesystem, so its SQLite deployment is a Node process (or a platform with a durable disk). What follows is a note for a separate deployment — the Astro SITE that documents/sells the kit — if you host that on Cloudflare Pages/Workers. For local development with wrangler dev, secrets go in .dev.vars (gitignored, read by wrangler but never bundled); non-secret vars go in wrangler.toml under [vars]; production secrets are set via the Cloudflare API:
# .dev.vars (gitignored — Cloudflare-local secrets for a Workers-based site)
SOME_API_KEY=example-key
ANOTHER_SECRET=example-secret# Set a secret for a Pages deployment
wrangler pages secret put SOME_API_KEY --project-name my-site
# Set a secret for a Worker
wrangler secret put ANOTHER_SECRET --name my-workerThe key distinction: if it would be a problem if it appeared in a code review or a public repo, it belongs in wrangler secret or .dev.vars, not in [vars]. This whole section applies to a Workers-hosted site, not to the SvelteKit starter kit's Node/SQLite runtime.
Declaring and typing env vars the SvelteKit way
SvelteKit does not support declaring env vars in svelte.config.js — there is no env: { PUBLIC: [...], PRIVATE: [...] } option. Instead, you declare variables in .env / .env.local, prefix public ones with PUBLIC_, and import them explicitly from the $env modules. Public vars go in .env with the PUBLIC_ prefix (for example PUBLIC_SITE_NAME=...); everything else stays server-side and is read via $env/static/private or $env/dynamic/private. The prefix + import location is the declaration.
# .env — public vars use the PUBLIC_ prefix
PUBLIC_SITE_NAME=VerdantStack
# server-only (private) values
DATA_DIR=./data
AUTH_WINDOW_MS=900000Public values are imported via $env/static/public or $env/dynamic/public; private values via $env/static/private or $env/dynamic/private. SvelteKit enforces the boundary: importing a private module in client code fails the build.
Validating env vars at startup — recommended, not shipped
SvelteKit does not validate env vars at runtime — a missing value usually surfaces as a confusing error at request time. A recommended pattern is a small typed module that centralizes access and validates required values, imported once at startup. The starter does not ship one (its env vars all have safe defaults), but this is a clean way to add it:
// src/lib/server/env.ts — recommended pattern, not shipped in the starter.
import { env } from '$env/dynamic/private';
export const config = {
dataDir: env.DATA_DIR ?? './data',
maxAttempts: Number(env.AUTH_FAILED_ATTEMPTS) || 5,
windowMs: Number(env.AUTH_WINDOW_MS) || 900000,
};
if (config.maxAttempts < 1 || config.maxAttempts > 100) {
throw new Error(`AUTH_FAILED_ATTEMPTS must be between 1 and 100, got: ${config.maxAttempts}`);
}Import this module once at the top of your server startup path. If any required env var is missing or out of range, the error surfaces immediately rather than at the first request — a much shorter feedback loop when deploying to a new environment.
Generating and rotating secrets you add
The shipped kit has no operator-supplied site-wide secret to generate or rotate: session tokens are random 32-byte hex per session (only the SHA-256 hash is stored), and scrypt password salts are embedded in each stored hash. If you add your own secret — e.g. a signing key for an API token or an external webhook — it should be random and high-entropy. A dictionary phrase or a short string is not a secret:
# Generate a 32-byte base64-encoded secret
openssl rand -base64 32
# Or using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"Rotate secrets you add periodically and during a low-traffic window. Note that rotating any salt you use to hash stored values invalidates the old values — with the kit's DB-backed sessions this only matters for secrets you introduce, since the kit's session tokens don't depend on a rotating salt.
Common mistakes
- Using static env on serverless platforms.
$env/static/privateis resolved at build time. On serverless platforms whereprocess.envisn't available during the build, static imports resolve to empty strings — use dynamic modules there. The kit itself runs on Node with better-sqlite3, so this is not a constraint for the kit's runtime. - Putting secrets in PUBLIC_ variables. There is no runtime guard — the value ends up in the client JavaScript bundle. Anyone can read it with DevTools. If a variable contains a secret, it must be private.
- Committing .env or .dev.vars to version control. These files should be in
.gitignore. Use.env.exampleor.env.template(without values) to document the expected shape. - Confusing per-request tenant config with deployment config. Env vars are per-deployment, not per-request. If different tenants need different data locations or API keys at runtime, that belongs in a database lookup keyed by tenant ID, not in env.
- Assuming env vars are validated. SvelteKit does not validate or type-check env vars at runtime. If a required value like
DATA_DIRis missing, you'll find out at request time. Add startup validation for critical env vars (see the validation section above).
Production checklist
- Private env vars never reach the browser. If you import from $env/dynamic/private in a +page.server.ts load function, the value stays server-side. Import the same module in a +page.svelte and the build fails.
- Static vs. dynamic is a build-time vs. runtime distinction. $env/static/* is substituted at build — the value is baked into the JavaScript bundle. $env/dynamic/* reads the real process environment at request time, which matters on serverless platforms where the env is not available during `vite build`.
- Public env vars are the only ones that reach the client. Prefix with PUBLIC_ and SvelteKit exposes them to both server and browser. Never put secrets in a PUBLIC_ variable — there is no guardrail, just convention.
- The kit runs better-sqlite3 (Node) and needs a real filesystem, so you cannot run it on Cloudflare Workers. If you deploy an Astro/Cloudflare SITE (a separate build from the kit), wrangler.toml [vars] and wrangler secret put / .dev.vars are its env mechanisms — those notes below are site-deployment concerns, not kit behavior.
- The starter kit's env surface is small and SQLite-focused: DATA_DIR (default ./data), MOCK_PLAN_SEATS (default 3), AUTH_FAILED_ATTEMPTS (default 5), and AUTH_WINDOW_MS (default 900000 = 15 min). BRAND_NAME/BRAND_EMAIL are the Astro SITE's build-time brand config (site/src/config.ts), not the kit's.
- Session material in the kit is random 32-byte hex generated at runtime per token (see src/lib/server/auth.ts) — there is no IP_SALT or SESSION_SECRET to configure. Nothing user-facing depends on an operator-supplied secret.
- AUTH_FAILED_ATTEMPTS and AUTH_WINDOW_MS are tunables, not secrets. They can live in env for easy adjustment per environment, or be hardcoded with sane defaults and overridden via env when a specific deployment needs different thresholds.
- Use SvelteKit's private dynamic env for anything that varies per deployment. Static env is for values that are truly constant across all builds — a rare situation in multi-tenant SaaS.
- Audit your env surface before launch: list every env var, classify it as public/private and static/dynamic, confirm secrets are in the right store, and verify no sensitive value leaks into client bundles.
Related guides: Multi-tenant auth architecture · Hashed session tokens · Rate limiting login
Related reading
- Multi-tenant Authentication — application-layer tenancy patterns
- Rate Limiting Login — env-tunable rate limiter config
- Drizzle ORM Migrations — schema-first database management
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll respond within 48 hours.