# 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.

URL: https://crunu.com/environment-variables/sveltekit
Language: en
Updated: 2026-10-30

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](https://svelte.dev/docs/kit/environment-variables) on October 30, 2026, when `@sveltejs/kit` 3.1.1 was the current release on [npm](https://www.npmjs.com/package/@sveltejs/kit).

## 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     |

*Figure: Diagram of how SvelteKit loads environment variables. Env files are read in this order of priority, and the first one wins: .env.\[mode].local, .env.\[mode], .env.local, and .env. Some values are inlined at build time and the rest are read when the app starts. There is no public prefix: nothing reaches the browser unless your code sends it.*

Variables already present in the process environment outrank all four files, as in plain [Vite](https://crunu.com/environment-variables/vite).

Production is different, and this catches people who tested with `vite preview`. The [adapter-node documentation](https://svelte.dev/docs/kit/adapter-node) 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:

```ts
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](https://svelte.dev/docs/kit/migrating-to-sveltekit-3) marks all four as deprecated in favor of `$app/env/private` and `$app/env/public`, and the [reference page](https://svelte.dev/docs/kit/$env-static-private) 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.

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](https://crunu.com/environment-variables/netlify).

## Related reading

- [Vite](https://crunu.com/environment-variables/vite) for the file loader underneath.
- [Nuxt](https://crunu.com/environment-variables/nuxt) and [Astro](https://crunu.com/environment-variables/astro) solve the same public and private problem differently.
- [Runtime secret injection](https://crunu.com/learn/runtime-secret-injection) and [how to share .env files with your team](https://crunu.com/guides/share-env-files-with-a-team).
- [crunu run](https://crunu.com/docs/cli/run) and [service tokens](https://crunu.com/docs/concepts/service-tokens).

## FAQ

### 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

- [SvelteKit: Environment variables](https://svelte.dev/docs/kit/environment-variables) (accessed 2026-10-30)
- [SvelteKit: $env/static/private (deprecated)](https://svelte.dev/docs/kit/$env-static-private) (accessed 2026-10-30)
- [SvelteKit: Migrating to SvelteKit v3](https://svelte.dev/docs/kit/migrating-to-sveltekit-3) (accessed 2026-10-30)
- [SvelteKit: Node servers (adapter-node)](https://svelte.dev/docs/kit/adapter-node) (accessed 2026-10-30)
- [Vite: Env variables and modes](https://vite.dev/guide/env-and-mode) (accessed 2026-10-30)
- [@sveltejs/kit 3.1.1 on npm](https://www.npmjs.com/package/@sveltejs/kit) (accessed 2026-10-30)
