Skip to content
FrameworkTypeScript

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.

SvelteKitAt a glance

Env file that wins
.env.[mode].local

First 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

svelte.dev/docs/kit/environment-variables

Crunu command
crunu run --env web-app/production -- node buildRecipeCrunu has no dedicated integration here: this is the general setup.

Common pitfalls

  1. 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.
  2. A variable marked static is inlined during the build, so changing it means building again. Dynamic is the default.
  3. adapter-node does not load .env files in production. The built server sees only its own process environment.
  4. 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.
  5. $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:

PrecedenceFileLoaded in
1.env.[mode].localthat mode only
2.env.[mode]that mode only
3.env.localevery mode
4.envevery 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:

TypeScript
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. With public: true it's available from $app/env/public and as %sveltekit.env.NAME% in app.html.
  • static. Without it the variable is dynamic, read at runtime, so one build can run with different values in different places. With static: true it'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 stringsinlined into server code at build time; the build machine must hold the value
Publicread at runtime and sent to the browserinlined 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.

  1. Develop with crunu run --env web-app/development -- npm run dev after a one-time crunu login.
  2. 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.
  3. 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.

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.

  1. SvelteKit: Environment variables · checked October 30, 2026
  2. SvelteKit: $env/static/private (deprecated) · checked October 30, 2026
  3. SvelteKit: Migrating to SvelteKit v3 · checked October 30, 2026
  4. SvelteKit: Node servers (adapter-node) · checked October 30, 2026
  5. Vite: Env variables and modes · checked October 30, 2026
  6. @sveltejs/kit 3.1.1 on npm · checked October 30, 2026

Keep your .env values in one place

Free during early access. Values are encrypted on your devices, each environment has its own key, and crunu run hands them to your app without writing a file.