SvelteKit env variables: $env modules and $app/env
SvelteKit 3 declares env variables in src/env.ts and reads them through $app/env. Public, private, static and dynamic explained, plus the legacy $env modules.
Part of the guide
SvelteKitAt a glance
- Env file that wins
.env.[mode].localFirst of 4 .env files, highest priority first. See the load order
- Public prefix
- None
No names are copied into browser code by a prefix.
- Values applied
- Build and runtime
Some values are fixed at build time, the rest are read when the app runs.
- Built-in secret store
- None: the environment of the platform the adapter runs on
- Crunu command
crunu run --env web-app/production -- node buildRecipeCrunu has no dedicated integration here: this is the general setup.
Common pitfalls
- In SvelteKit 3 the $env/static and $env/dynamic modules are deprecated and will be removed in SvelteKit 4. Declare variables in src/env.ts.
- A variable marked static is inlined during the build, so changing it means building again. Dynamic is the default.
- adapter-node does not load .env files in production. The built server sees only its own process environment.
- An invalid value stops the app from starting or building, so a CI build needs every required variable or a schema that knows it is building.
- $app/env/private cannot be imported into browser code. A value the browser needs must be declared with public set to true.
On this page
SvelteKit 3 reads environment variables that you declare in src/env.ts with defineEnvVars, validates them, and serves them through two modules: $app/env/private for server code and $app/env/public for values the browser may see. Variables are private and dynamic by default, which means they're read from the environment when the app runs. Mark one static and it's inlined during the build instead. During development and builds, the values come from Vite's .env files and the process environment. In production with adapter-node, only the real process environment counts. The four $env/* modules from SvelteKit 2 still work but are deprecated. Everything here was checked against the SvelteKit environment variables page on October 30, 2026, when @sveltejs/kit 3.1.1 was the current release on npm.
Where values come from
During vite dev and vite build, SvelteKit asks Vite to load the env files for the current mode from the directory set by env.dir, which is the project root by default. That gives the usual Vite order:
| Precedence | File | Loaded in |
|---|---|---|
| 1 | .env.[mode].local | that mode only |
| 2 | .env.[mode] | that mode only |
| 3 | .env.local | every mode |
| 4 | .env | every mode |
Variables already present in the process environment outrank all four files, as in plain Vite.
Production is different, and this catches people who tested with vite preview. The adapter-node documentation states that in production .env files are not loaded automatically. The built server in build/ reads only the environment it was started with. The adapter suggests node --env-file=.env build if you want a file, and it reads its own settings, such as PORT, HOST and BODY_SIZE_LIMIT, from the same environment.
Declaring variables in src/env.ts
SvelteKit 3 asks you to list every variable your app reads. The declaration is a plain object passed to defineEnvVars, which exists only to give you type checking:
import { defineEnvVars } from '@sveltejs/kit/env';
export const variables = defineEnvVars({
DATABASE_URL: {},
GOOGLE_ANALYTICS_ID: { public: true },
SHOW_DEBUG_OVERLAY: { public: true, static: true }
});Each entry can carry four settings:
public. Without it the variable is private and can be imported only from$app/env/private, which SvelteKit refuses to bundle into browser code. Withpublic: trueit's available from$app/env/publicand as%sveltekit.env.NAME%inapp.html.static. Without it the variable is dynamic, read at runtime, so one build can run with different values in different places. Withstatic: trueit's inlined into the code during the build, which allows dead-code elimination but freezes the value.schema. A Standard Schema validator from a library such as Zod or Valibot, or a function that returns the value or throws. An invalid value stops the app from starting, or the build from finishing.description. Text your editor shows when you hover the variable.
The list is also documentation. A new teammate opens one file and sees every variable the app needs, which ones are public, and what form each must take.
Building without production secrets
Validation runs during the build, because SvelteKit analyzes your app by running it. That's where pipelines usually trip: the build machine doesn't have the production database password, and a required DATABASE_URL fails the build. SvelteKit's answer is the building flag from $app/env, which is true during the build and during prerendering. A schema can make a variable optional while building and required when the server starts.
Prerendered pages deserve a second look. Their HTML is produced at build time, so any variable they read is read at build time too, whatever its static setting.
The deprecated $env modules
Projects that started on SvelteKit 2 import from four modules: $env/static/private, $env/static/public, $env/dynamic/private and $env/dynamic/public. The migration guide marks all four as deprecated in favor of $app/env/private and $app/env/public, and the reference page says they will be removed in SvelteKit 4. The old $app/environment module was renamed to $app/env at the same time.
The concepts line up. A $env/static/* import corresponds to a declared variable with static: true. A $env/dynamic/* import becomes a declared variable with the default dynamic behavior. Instead of the PUBLIC_ name prefix, public variables are marked with public: true. Moving over is a good moment to delete variables nobody reads anymore, since the declaration forces you to name each one.
Deciding public, private, static or dynamic
Every declared variable answers two questions, and getting either wrong has a different cost:
| Dynamic (default) | Static | |
|---|---|---|
| Private (default) | read on the server at runtime; the usual home for passwords, API keys and connection strings | inlined into server code at build time; the build machine must hold the value |
| Public | read at runtime and sent to the browser | inlined into browser code; the value is in your published JavaScript |
Start from private and dynamic, and move a variable only when you have a reason. Public is right for values the page needs and anyone may see, such as an analytics ID. Static is right for flags that should remove code from the bundle when off, like the debug overlay example in the SvelteKit docs. A private static variable is rarely what you want, because it puts a secret on whatever machine runs the build.
Run SvelteKit with values from Crunu
crunu run decrypts a Crunu environment in memory and starts your command with its variables in the process environment. For SvelteKit that covers all three phases: Vite gives the process environment top priority during dev and build, and the adapter-node server reads nothing else in production. No .env file has to exist on the server.
- Develop with
crunu run --env web-app/development -- npm run devafter a one-timecrunu login. - Build in CI with a service token on that step only:
exec crunu run --env web-app/build -- npm run build. The environment needs only static and build-time values, not runtime secrets. - On a Linux server with the agent, start the output with
crunu run --env web-app/production --restart on-change -- node build.
The second step is where Crunu's per-environment keys help. A web-app/build environment that holds only what the build needs means the CI token never receives the production database password. Dynamic private variables arrive only at step three, on the server, where the agent delivers them from memory.
--restart on-change restarts the Node server when a value changes, which is enough for dynamic variables. Static variables were inlined, so changing one in Crunu needs a new build. The adapter's own variables, such as PORT, can live in the same Crunu environment as your app's variables.
Other adapters
On platforms with their own adapters, the platform supplies the runtime environment, and the adapter maps it into SvelteKit. Values for those environments live in the platform's settings. Crunu has no built-in sync into them. For a static build with adapter-static, every page is prerendered, so all values are build-time values and crunu run belongs on the build command, as in the Netlify recipe.
Related reading
- Vite for the file loader underneath.
- Nuxt and Astro solve the same public and private problem differently.
- Runtime secret injection and how to share .env files with your team.
- crunu run and service tokens.
Frequently asked questions
Something else? Write to support@crunu.com
Is $env/static/private gone in SvelteKit 3?
It still works but is deprecated, together with the other three $env modules, and it will be removed in SvelteKit 4. New code declares variables with defineEnvVars in src/env.ts and imports them from $app/env/private or $app/env/public.
How do I give the built Node server its variables?
adapter-node doesn't read .env files in production. Set real environment variables, run node --env-file=.env build, or start it under crunu run so the values come from Crunu without a file.
Why does my CI build fail with a missing secret?
SvelteKit validates declared variables and fails the build when one is invalid. Either provide the value in CI or make the schema optional while building, using the building flag from $app/env.
Do I still need the PUBLIC_ prefix?
Not with explicit variables in SvelteKit 3. Public variables are marked with public set to true instead of a name prefix. The prefix applies only to the deprecated $env modules.
Sources
Every source this page relies on, with the date we last checked it.
- SvelteKit: Environment variables · checked October 30, 2026
- SvelteKit: $env/static/private (deprecated) · checked October 30, 2026
- SvelteKit: Migrating to SvelteKit v3 · checked October 30, 2026
- SvelteKit: Node servers (adapter-node) · checked October 30, 2026
- Vite: Env variables and modes · checked October 30, 2026
- @sveltejs/kit 3.1.1 on npm · checked October 30, 2026