Skip to content

Frozen env

A frozen env is your whole config resolved and validated once, at build or deploy time, and shipped inside the release. At runtime nothing resolves: no resolver runs, no .env file is read, and no varlock CLI or resolver credentials exist in the runtime. Types and sensitivity travel with the values.

Who does the freezing depends on what you deploy:

You deployFrozen byAutomatic?
Next.jsThe integration, in your build outputOn Vercel and Cloudflare (OpenNext). There is no setting for other hosts: a self-hosted next start resolves at boot, or boots from a frozen file via varlock run --frozen -- next start
AstroThe integration, in your build outputOn Vercel and Netlify. Elsewhere, set the build-output mode
Nuxt, SvelteKit, TanStack Start, plain Vite SSRThe integration, in your build outputNo. Set the build-output mode
Cloudflare Workersvarlock-wrangler deployYes
Anything with no integration (Elysia, Hono, Fastify, Express, a compiled binary)varlock freezeNo. This page

Why freeze at all? Resolvers need varlock and resolver credentials somewhere. On a platform that runs your app for you (Vercel, Netlify, Heroku, Railway, Render, Fly), that somewhere cannot be boot, so freezing is the only way to use them. There, run varlock freeze during the platform’s build (a Dockerfile RUN or a buildpack build script), never in a release or pre-deploy hook, which runs on a throwaway instance whose files never reach the artifact. Where you own the boot command (your own containers, VMs, Kubernetes), resolving at boot with varlock run works too. Freezing is still the better default: every replica runs the same values, rolling back the image rolls back config with it, a failed resolution stops the deploy instead of a replica, and the runtime carries no secret backend credentials. The background section explains why every major platform already works this way.

Whichever route, a rotated secret takes effect on the next build or deploy, not the next restart. See tradeoffs.

The framework integrations have done this for a long time. In build-output mode they resolve the whole graph during the build and inject it into the server-side output, and the runtime initializes from that instead of resolving.

  • Vite-based frameworks (Astro, Nuxt, SvelteKit, TanStack Start, plain Vite SSR): ssrInjectMode: 'resolved-env' is the frozen mode. Only Astro picks it on its own, when the adapter is @astrojs/vercel or @astrojs/netlify. Everything else defaults to resolving at boot (init-only, which expects varlock run, or auto-load), so set resolved-env explicitly.
  • Next.js: the config plugin bakes the resolved env into the build output when it detects Vercel or an OpenNext Cloudflare build, so the production server does not re-load .env files and nothing needs to be configured on the platform beyond the encryption key. It has no mode setting: on any other host next start resolves at boot through the CLI, and varlock run --frozen -- next start is the way to boot it from a varlock freeze file.
  • Cloudflare Workers are the odd one out: nothing goes into the bundle. varlock-wrangler deploy resolves at deploy time and uploads the result as a secret binding attached to that Worker version, which rolls back with it.

Everything this guide says about a frozen env applies to a build-output freeze: values are as of the build, the runtime needs no CLI, no .env files, and no resolver credentials, and rotating a secret means rebuilding. Two things are specific to the build-output route today:

  • The injected blob is plaintext by default. Enable @encryptInjectedEnv to encrypt it, with _VARLOCK_ENV_KEY set at both build time and runtime. The encrypted deployments guide covers key management per platform. Cloudflare needs nothing: varlock-wrangler deploy generates and stores the key for you.
  • Boot keys (@dynamic=boot) are a varlock freeze feature. A build-output freeze has no per-instance override step yet, so a value the platform must supply at process start should be read from process.env directly rather than declared in the schema.

If an integration covers you, the rest of this guide is background. The sections below are about varlock freeze.

varlock freeze resolves every value once and writes the result to an encrypted file. Your app boots from that file instead of re-resolving, and the file ships inside your deploy artifact so config and code travel together.

  1. Generate an encryption key, once

    Terminal window
    npm exec -- varlock generate-key

    Set the result as _VARLOCK_ENV_KEY in your deploy pipeline and in your runtime environment. It is a long-lived bootstrap value, so it does not need to change per release.

    Set _VARLOCK_USE_FROZEN_ENV=1 in the runtime environment next to it. A frozen file is only used when this is set, so a leftover file can never change what runs elsewhere, and a file that failed to make it into the deploy is an error rather than a silent fall back to normal resolution.

  2. Freeze at deploy time

    Run this wherever your .env files and resolver credentials are available, usually a CI job:

    Terminal window
    APP_ENV=production varlock freeze

    APP_ENV is whatever your schema’s @currentEnv reads, and the summary names the environment it froze, so check it. This writes .varlock-frozen-env in the current directory. Add it to your .gitignore yourself: varlock init does not, and varlock scan only reports an unencrypted one, so an encrypted file can still be committed by accident.

  3. Ship the file inside your deploy artifact

    It must be present at boot, in the app’s working directory. In Docker that means copying it into the image, not mounting it at runtime. Mounting it separately reintroduces the split this is meant to remove.

    The file is written with mode 0600, and COPY keeps the mode and the owner. If your runtime runs as a non-root user, copy it with COPY --chown=<user> or it will be unreadable at boot.

  4. Boot your app normally

    Terminal window
    bun server.js

    As long as your app imports varlock/auto-load (or you launch it with varlock run --frozen), varlock boots from the file. No varlock CLI, no .env files, and no resolver credentials are needed in the runtime image.

Dockerfile
FROM oven/bun:1 AS builder
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN bun build ./src/index.ts --target=bun --outdir dist
# .env files and resolver credentials exist only in this stage. Mount any resolver
# credential (a 1Password service account token, say) the same way as the key.
RUN --mount=type=secret,id=varlock_key \
_VARLOCK_ENV_KEY=$(cat /run/secrets/varlock_key) APP_ENV=production bunx varlock freeze
FROM oven/bun:1
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.varlock-frozen-env ./
ENV _VARLOCK_USE_FROZEN_ENV=1
CMD ["bun", "dist/index.js"]

_VARLOCK_ENV_KEY is supplied by the platform at runtime, not baked into the image. Your entrypoint imports varlock as usual:

src/index.ts
import 'varlock/auto-load';
import { ENV } from 'varlock/env';
import { Elysia } from 'elysia';
new Elysia()
.get('/', () => `hello from ${ENV.PUBLIC_APP_NAME}`)
.listen(ENV.PORT);

Types and sensitivity travel in the file, so ENV.PORT is still a number and log redaction still knows which values are secret. If your platform assigns PORT to each instance, mark it @dynamic=boot so the platform’s value wins over the frozen one.

A frozen file is used only when _VARLOCK_USE_FROZEN_ENV asks for it:

ValueBehavior
unsetNever read a frozen file, even when one is present. Resolve normally
1 / trueBoot from .varlock-frozen-env in the working directory. A missing file is an error
any pathBoot from the frozen file at that path, relative to the working directory unless absolute

A file that is asked for but missing or unusable (unreadable, encrypted with no key or the wrong key) is always an error, never a fall back to resolving at boot. Any unrecognized value is treated as a path, so _VARLOCK_USE_FROZEN_ENV=off looks for a file named off and fails (0 and false are accepted as unset).

varlock run --frozen and varlock load --frozen are the flag form, taking an optional path. varlock load --frozen, run where the file is (with the CLI available, e.g. bunx varlock load --frozen), shows exactly what the app boots with, and reads neither the schema nor any .env files.

Because the file is inert unless asked for, one left over from running varlock freeze locally changes nothing: dev servers, varlock run, and varlock load all keep resolving from your .env files. Delete it when you notice it, or leave it. It is gitignored either way.

FlagDescription
--out, -oOutput path (default .varlock-frozen-env), or - to write the payload to stdout
--path, -pEntry .env file or directory, repeatable
--allow-plaintextWrite unencrypted when _VARLOCK_ENV_KEY is not set
--clear-cacheClear the cache and re-resolve everything
--skip-cacheSkip the cache for this invocation

In a monorepo package that sets varlock.filter in its package.json, the frozen file holds only the items that filter selects. _VARLOCK_FILTER is not applied. Once frozen, the file is final: the filter is not applied again at boot, and a frozen file refuses to boot with _VARLOCK_FILTER set.

varlock freeze fails if _VARLOCK_ENV_KEY is not set. The file holds every resolved value, including secrets, and it is a portable standalone file: it gets copied between build stages, retained as a CI artifact, and read by anyone with access to your image layers or registry. That is a wider audience than the people who can reach your running container.

Encryption protects against exactly that: image layers, registry access, CI artifact retention, and accidental commits. It does not protect against someone who already has code execution in the running container, since the key is in the environment right next to the file. This is the same tradeoff described in encrypted deployments.

--allow-plaintext exists for schemas with nothing sensitive in them. It prints a warning, and there is no config setting for it, so it cannot be turned on once and forgotten.

This is the most important thing to understand before you use it.

A frozen file is a complete, already-validated snapshot, and it is authoritative. Environment variables set when the container starts do not override it:

Terminal window
# the frozen file has DATABASE_URL, so this is ignored
docker run -e DATABASE_URL=postgres://somewhere-else/db my-image

For a key that resolved to a value at freeze time, the frozen value wins. For a key that resolved to nothing, process.env is cleared to match, so process.env.KEY and ENV.KEY always agree. Nothing silently reads one resolution while something else reads another.

This is deliberate: freezing means the config was resolved and validated as a unit, and a value injected afterwards was part of neither. Accepting it would mean running on config that nothing validated.

If a value genuinely has to come from the container rather than the deploy, say so in the schema with @dynamic=boot (next section), so the frozen value becomes a default the container can override. Marking such a key @optional so that varlock freeze stops refusing to write is the wrong fix: it gets the freeze to succeed, but it permanently weakens the schema, so nothing enforces the value at boot either, and the frozen env then clears whatever the operator supplied. You end up with no value and no error.

Some values only exist once each instance starts: a PORT the platform assigns, pod or machine identity, or something an operator passes with docker run -e. Mark those @dynamic=boot:

.env.schema
# @type=port @required @dynamic=boot
PORT=3000
# @required @dynamic=boot
INSTANCE_ID=

A boot key is frozen like everything else, and whatever it resolves to at freeze time becomes its default. When the app starts, a value for that key in the process environment (set by the platform, docker run -e, and so on) overrides the default. A variable that is set but empty counts as set. The override is checked against what the freeze recorded about the key (its type and whether it is required), so a missing required INSTANCE_ID, or PORT=abc against @type=port, fails the boot. Every other key keeps its frozen value.

Because the frozen file records everything boot needs, booting still needs no varlock CLI, no .env.schema, and no resolver credentials, with or without boot keys. A required boot key with no value at freeze time is fine: it has no default, and the value has to be set at boot. Otherwise its default resolves and validates like any other value, so if it cannot resolve in CI, leave it empty in the schema.

The freeze summary lists each boot key and its default, and says when a default came from the build machine’s environment rather than your .env files (PORT (default 8080, from the build environment)), since that is easy to miss. varlock load --frozen next to the file shows the values the app boots with, boot values included.

A few rules keep a boot value checkable without the schema, and keep it from going stale:

  • Nothing may reference a boot key, not even another boot key. With PUBLIC_URL=http://localhost:${PORT}, the frozen PUBLIC_URL would keep the freeze-time PORT after the platform assigns a new one at boot. This is a schema error on every load, not just under freeze. Build such values in your app code at boot. A boot key can itself be built from non-boot values, since those never change after the freeze.
  • The type must be a built-in, non-composite type with plain settings (string, port, number, boolean, enum, url, and so on). A plugin-defined type, an array or record, a computed @type, or a setting like matches=regex(...) cannot be recorded, so it is a schema error.
  • It cannot be @internal, since internal items are never handed to the app.
  • Root decorators cannot reference it, and the @currentEnv item cannot be boot. Anything that decides how the schema loads or how varlock behaves (@currentEnv, @disable, @import, @cache, @redactLogs, plugin init) is settled before boot.
  • boot is per item and written literally. There is no root default, and a computed value is an error.

Outside of freezing, @dynamic=boot behaves like @dynamic: the value is never inlined at build time, and every value is resolved at boot anyway.

If you find yourself marking more than a handful of keys boot, freezing is the wrong tool for that service. Use varlock run and resolve everything at boot.

Carrying the payload in an env var instead

Section titled “Carrying the payload in an env var instead”

Some platforms take environment variables but give you no way to get a file into the deploy unit: a Compose file that pulls an image tag it does not rebuild, an ECS task definition, Heroku config vars. --out - writes the same payload to stdout so you can carry it in one variable.

Terminal window
# at deploy time, where your .env files and resolver credentials are
export __VARLOCK_ENV=$(APP_ENV=production varlock freeze --out -)

Set three things in the runtime environment:

VariableValue
__VARLOCK_ENVthe payload
_VARLOCK_USE_INJECTED_ENV1
_VARLOCK_ENV_KEYthe same key you froze with

_VARLOCK_USE_INJECTED_ENV=1 is required, not optional. Without it varlock checks the payload against the .env files it was resolved from, which a deploy does not carry, and falls back to running the CLI instead.

compose.yaml
services:
app:
environment:
__VARLOCK_ENV: ${__VARLOCK_ENV:?run `varlock freeze --out -` first}
_VARLOCK_USE_INJECTED_ENV: "1"
_VARLOCK_ENV_KEY: ${_VARLOCK_ENV_KEY:?}

Your app boots exactly as it does with a file: import 'varlock/auto-load' and read ENV.

The summary goes to stderr in this mode, so stdout carries the payload and nothing else. Everything else is identical to writing a file: the same resolution, the same encryption, the same refusal to emit a payload built from a failed resolution.

This is a weaker guarantee than a file, and it is worth being clear about why. The payload lives in platform config rather than inside the release, so it does not roll back when you roll back code, and it is a second operation on deploy rather than part of the artifact. That is the split a frozen file exists to close, and the env var transport reopens part of it.

What you still get over a set of individual environment variables: it resolved and validated once, as a unit; types and sensitivity survive, so ENV.PORT is a number and log redaction still knows what is secret; and the runtime needs no .env files, no resolver credentials, and no varlock CLI.

Prefer a file whenever you can ship one. Reach for --out - when you cannot.

Every route can ship the frozen env encrypted with AES-256-GCM, decrypted at runtime with _VARLOCK_ENV_KEY from the process environment. The key is never written into the artifact. What differs is the default and where the key has to be present:

RouteEncrypted by default?How to enableKey must be present at
Build output (Vite-based, Next.js)No. Plaintext JSON in the server bundle, with a build-time warning on Vercel@encryptInjectedEnv in the schemaBuild and runtime
Cloudflare WorkersYesNothing to do. varlock-wrangler deploy generates the key and uploads it as a secretHandled by the deploy
varlock freeze (file or --out -)Yes. The command refuses to run without a key unless you pass --allow-plaintextSet _VARLOCK_ENV_KEY before running freezeFreeze and runtime

A plaintext build-output blob is only readable by whoever can read your server bundle, which is why it is the default there. A varlock freeze file travels further (between build stages, into CI artifact retention, out through docker cp), which is why encryption is required there. Either way, encryption protects the artifact at rest and in transit, not against someone with code execution in the running process, where the key sits next to the data. See encrypted deployments for key management on each platform.

These apply to every route, not just varlock freeze.

Rotating a secret takes effect on your next build or deploy, not on the next restart. This is the entire point, but it inverts what most secret managers do, so everyone touching the deploy should know it. If your incident response plan is “rotate the credential and bounce the service”, freezing changes that plan. To pick up a rotated value, rebuild (integrations) or re-run varlock freeze, and redeploy.

Rotating _VARLOCK_ENV_KEY breaks rollback. Releases frozen under the old key cannot be decrypted with the new one, which is a problem precisely when you want to roll back. Keep the key stable, or plan to re-freeze and redeploy the releases you want to keep rollable.

A frozen file is used as-is, with no drift checking. When _VARLOCK_USE_FROZEN_ENV is set, the file wins even after you edit your .env files or schema. A frozen file is final: keys added to the schema since the freeze are not part of it until you re-freeze.

Values that expire are frozen too. If your schema resolves a short-lived credential (an OIDC-exchanged token, an STS credential), freezing captures it at build or deploy time and it will expire while the release is still running. Resolvers never run at boot, not even for boot keys, so freezing is the wrong tool for a service that needs fresh credentials. Use varlock run for it where you own the boot command, or have the platform supply the value in the environment at boot (and, under varlock freeze, mark the key @dynamic=boot).

None of this applies to local development. Resolve from your .env files on every run, which is what varlock does by default.

The mechanism differs per platform, but the property is the same: the config a running instance sees was fixed when the release was created, not when the instance started.

Vercel applies env vars to a deployment when it is built. A change “[is] not applied to previous deployments, they only apply to new deployments.” To change config you redeploy.

Heroku makes config vars part of the release object, alongside the slug or image and the add-ons. Setting one creates a new release and restarts your dynos, and heroku rollback “copies the compiled slug or OCI image and config vars” of the release you roll back to. Code and config move together in both directions.

AWS Lambda freezes both when you publish a version: “Lambda creates an immutable snapshot of your function’s code and configuration.” Environment variables are on the list of changes that qualify a function for a new version.

Cloud Run puts env vars in the revision, which is immutable once created. Cloudflare Workers attach secrets to a Worker version, which is what varlock-wrangler deploy uploads.

That is not five independent designs arriving at the same place by accident. Config and code are one artifact because a release that changes both is otherwise two operations that can each succeed alone. What it buys you:

  • Changes are atomic. Otherwise there is a window where one is live and the other is not, and you find out which combination your app tolerates.
  • Rollbacks actually roll back. If config lives in a store the release does not reference, rolling back code leaves the new config in place. You roll back to a known-good version and it is not the version you tested.
  • Every replica agrees. People notice this one last and care about it most, because it only shows up under load.

A versioned release fixes atomicity and rollback. Replica agreement has a second failure mode that survives even on a platform that versions its config, and it is the one worth understanding.

If your config is a reference to a secret rather than a value, the reference is resolved when the instance starts. Two instances of the same release, started at different times, can see different values.

Google says this directly in the Cloud Run docs. Secrets exposed as env vars are fetched “prior to starting the instance”, and because “environment variables are resolved at instance startup time”, Google recommends “that you pin the secret to a particular version instead of using latest.”

Kubernetes has the same property in a sharper form. A running Pod never sees a ConfigMap or Secret change, because the kubelet reads the data when it launches the container. A Pod created afterwards reads the current value. So a rotation at 2am leaves you with a Deployment whose old Pods hold the old value and whose new Pods hold the new one, indefinitely, with nothing reporting the split.

Now add autoscaling. You scale out at 3am. The new replica resolves config fresh, picks up a value that changed since the deploy, and starts serving on it. Nothing failed, nothing logged, and the only signal is that some fraction of your traffic behaves differently depending on which instance answers. When you scale back in, it disappears.

A varlock schema that resolves from a secret backend has exactly this property if you let varlock resolve at boot. varlock run and varlock/auto-load resolve on every process start when they have .env files and resolver credentials to work from. That is the right behavior in development, where you want an edited .env file to take effect on restart. In production it means your config is only as stable as the last time something restarted.

Boot resolution costs two other things under scale-out. Every new instance waits on your secret backend before it can serve, which is cold-start latency paid per instance. And a scale-out event sends a burst of identical requests at that backend, which is where rate limits find you.