Skip to content

Add varlock freeze for deploy-time pinned env - #1049

Open
theoephraim wants to merge 4 commits into
mainfrom
varlock-freeze-command
Open

Add varlock freeze for deploy-time pinned env#1049
theoephraim wants to merge 4 commits into
mainfrom
varlock-freeze-command

Conversation

@theoephraim

@theoephraim theoephraim commented Aug 30, 2026

Copy link
Copy Markdown
Member

Adds varlock freeze: resolve every value once at deploy time, write an encrypted file that ships inside the deploy artifact, and boot from it instead of re-resolving.

Scoped deliberately to the standalone command plus its runtime loader. No changes to the vite or nextjs integrations — the broader reorientation of ssrInjectMode around this concept is follow-up work, and #1055 noted that freeze needs to land first so the breaking parts of that have somewhere to point.

Why

Setting env vars on a platform and shipping code are two separate operations. Config can't change atomically with code, rolling back code doesn't roll back config, and every boot re-resolves, so a replica that autoscales up at 3am can resolve differently from the one that booted at deploy time with no signal.

Framework integrations already solve this by injecting resolved env into build output, and varlock-wrangler deploy does it for Workers via versioned secret bindings. This generalizes that to apps with no build step (Elysia, Hono, Fastify on Bun/Node, distroless images) on platforms where varlock can't push secrets. It also means the runtime image needs no varlock CLI, no .env files, and no resolver credentials.

How it works

varlock generate-key                    # once, set as _VARLOCK_ENV_KEY in CI and at runtime
APP_ENV=production varlock freeze       # at deploy time, writes .varlock-frozen-env

Ship the file in your image and boot normally. varlock/auto-load and varlock run both pick it up. _VARLOCK_USE_FROZEN_ENV controls it: unset auto-discovers, 1/true requires it, 0/false disables, anything else is a required path.

Design notes

The seal is total. It wins over env supplied at boot, and process.env is kept in agreement with ENV, so nothing reads one resolution while something else reads another.

This is the opposite of injectedAtBuild from #1055, deliberately. That flag exists so an implicit bake preserves runtime env, because those users never opted into a seal and destroying their config was a surprise. Freezing is opt-in and its whole promise is a resolved-and-validated unit, so a value injected afterwards was part of neither. Frozen payloads therefore do not set injectedAtBuild, and tests pin this along with a control showing the same ambient value is honored as a normal override when no seal is present.

Fails closed. Existing blob reuse is an optimization that falls back to fresh resolution on any failed check. For a deploy pin that fallback is a correctness bug: it silently un-pins and re-resolves at boot, which is the behavior freezing eliminates. Only absence falls through; a file present but unusable always errors.

Authoritative, not verified. The basePath and source-fingerprint checks that gate automatic blob reuse compare against local .env files, which a frozen deploy by design does not carry. Takes precedence over an ambient __VARLOCK_ENV, and has its own flag so _VARLOCK_USE_INJECTED_ENV=0 doesn't disable it.

Encryption required unless --allow-plaintext. varlock-wrangler already goes to real trouble (a FIFO with a re-arming single-shot writer) to keep resolved secrets off disk, and a standalone frozen file travels further than a blob welded into a bundle: between build stages, into CI artifact retention, out through docker cp. Flag-only, no config setting, so it can't be turned on once and forgotten.

Override provenance cleared. overrideKeys describes overrides at the original invocation so consumers re-apply exactly those from their own env. Carrying that into a frozen file would make any schema key that happened to be set in CI a key the platform can override at runtime — a hole in the pin itself.

--env is refused when the schema sets @currentEnv. load ignores it silently; here it would bake the wrong environment's values into an artifact consumed without re-resolution, so nothing downstream gets another chance to catch it. Found while building the example, where it froze development values into a "production" artifact. The summary also names the environment it froze.

Known limitation

Freezing is all or nothing. There is no way to seal most of your config while leaving a few keys to the runtime, so a service with genuinely per-container config (docker run -e DATABASE_URL=...) should use varlock run or boot resolution instead. The guide says this plainly, including that marking such a key @optional to get past freeze's validation refusal is the wrong fix — it weakens the schema and the seal still clears the operator's value.

Closing that gap needs a schema-level way to say "this key is supplied at runtime", which is follow-up work.

Tradeoffs, documented

Rotating a secret takes effect on the next deploy, not the next restart. Rotating _VARLOCK_ENV_KEY breaks rollback to releases frozen under the old key. A frozen file in a project directory wins over edited .env files with no drift check. Values that expire shouldn't be frozen.

Testing

  • 32 unit tests, 14 end-to-end smoke tests
  • Verified against a real Elysia app in both deploy shapes (bun build --target=bun and --compile), booting from a directory containing only the output plus the artifact. Values, coerced types, and sensitivity survive the round trip, so ENV.PORT is still a number and log redaction still works.

An Elysia example app is ready for varlock-examples but not yet committed there.

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

bumpy-frog

The changes in this PR will be included in the next version bump.

minor Minor releases

  • @varlock/native-helper-darwin 1.18.0 → 1.19.0
  • @varlock/native-helper-linux-arm64 1.18.0 → 1.19.0
  • @varlock/native-helper-linux-x64 1.18.0 → 1.19.0
  • @varlock/native-helper-win32-x64 1.18.0 → 1.19.0
  • varlock 1.18.0 → 1.19.0

Bump files in this PR

Click here if you want to add another bump file to this PR


This comment is maintained by bumpy.

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle size

⚠️ grows the bundle by 40.8 KB (+0.9%)

Metric main This PR Δ
Total dist 4324.4 KB 4365.2 KB +40.8 KB (+0.9%)
JS 1656.3 KB 1671.6 KB +15.3 KB (+0.9%)
Sourcemaps 2568.7 KB 2594.2 KB +25.5 KB (+1.0%)
Type defs 99.4 KB 99.4 KB
Other 0.0 KB 0.0 KB

dist/ only; native binaries are versioned separately and not counted here.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 30, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
varlock-website 605e44c Commit Preview URL

Branch Preview URL
Sep 01 2026, 09:27 PM

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Frozen env reuse currently breaks blob-only injection, can hang startup on a non-regular artifact, and can leave plaintext artifacts readable by other users when overwriting an existing file.

Reviewed changes in the initial review of varlock freeze, its runtime reuse paths, filesystem behavior, tests, and documentation.

  • Freeze command: Resolves and serializes the complete graph, clears override provenance, and writes an encrypted artifact unless plaintext is explicitly allowed.
  • Runtime consumption: Adds auto-discovery and explicit frozen-file modes to varlock/auto-load and varlock run, ahead of ambient blob reuse.
  • Failure policy: Makes present but unusable frozen artifacts fail closed and rejects fresh-resolution flags while a frozen artifact is active.
  • Coverage and docs: Adds unit and smoke coverage plus the frozen-env and Elysia deployment guides and CLI reference updates.

🚨 Blob-only injection omits the frozen graph

When reuseDecision.source === 'frozen-file', there is normally no ambient __VARLOCK_ENV, but run.command.ts:259 forwards process.env.__VARLOCK_ENV rather than the selected graph. I reproduced this with the built CLI: varlock run --inject blob -- node -e ... launched with both process.env.__VARLOCK_ENV and a frozen individual value as undefined; if an unrelated ambient blob exists, this path can forward stale data instead.

Technical details
# Forward the graph selected by frozen reuse

## Affected sites
- `packages/varlock/src/cli/commands/run.command.ts:254-268` - the generic reuse branch assumes every reused graph came from `process.env.__VARLOCK_ENV`.
- `packages/varlock/src/lib/injected-env-reuse.ts:172-178` - frozen reuse already returns the authoritative plaintext graph as `blobJson` and identifies its source.

## Required outcome
- `--inject blob` and the blob half of the default `all` mode must inject the frozen graph, never an absent or stale ambient graph.
- Preserve encryption when `_VARLOCK_ENV_KEY` is available.
- Add an end-to-end assertion on the child process's actual `__VARLOCK_ENV`; the current smoke test is masked because child auto-load rereads the file from the same directory.


let rawContents: string;
try {
rawContents = fs.readFileSync(filePath, 'utf8').trim();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

existsSync does not establish that this is a regular file, so readFileSync blocks indefinitely when .varlock-frozen-env is a FIFO with no writer. I reproduced this by placing a FIFO at the default path, after which application startup hung until an external timeout; reject non-regular artifacts with statSync(...).isFile() before reading them.

try {
fs.mkdirSync(path.dirname(outPath), { recursive: true });
// 0600 so the resolved values aren't readable by other users on a shared build machine
fs.writeFileSync(outPath, `${contents}\n`, { mode: 0o600 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mode option is only applied when a file is created, so overwriting an existing permissive artifact does not enforce 0600. I reproduced this by freezing plaintext over a 0644 file, which remained 0644 with all resolved secrets; the write must explicitly enforce permissions on replacement, ideally via a 0600 temporary file and atomic rename.

Technical details
# Enforce private permissions on every write

## Affected sites
- `packages/varlock/src/cli/commands/freeze.command.ts:78-83` - direct overwrite preserves the target inode's existing mode.

## Required outcome
- A successful freeze leaves the artifact at `0600` even when the output path already exists.
- Avoid exposing a truncated or partially written artifact if the process is interrupted during replacement.
- Add coverage that starts with a permissive existing output file and verifies both its final content and mode.

@pkg-pr-new

pkg-pr-new Bot commented Aug 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

varlock

npm i https://pkg.pr.new/varlock@1049

@varlock/native-helper-darwin

npm i https://pkg.pr.new/@varlock/native-helper-darwin@1049

@varlock/native-helper-linux-arm64

npm i https://pkg.pr.new/@varlock/native-helper-linux-arm64@1049

@varlock/native-helper-linux-x64

npm i https://pkg.pr.new/@varlock/native-helper-linux-x64@1049

@varlock/native-helper-win32-x64

npm i https://pkg.pr.new/@varlock/native-helper-win32-x64@1049

commit: 605e44c

theoephraim added a commit that referenced this pull request Sep 1, 2026
…napshot (#1055)

When a server boots from the env blob baked into build output (e.g. a
Next.js standalone container where the varlock CLI is unreachable),
initVarlockEnv treated the blob like a fresh resolution. Items that
resolved to undefined at build time triggered the stale-echo cleanup
from #1038, DELETING the corresponding runtime-provided values from
process.env. A schema item like `REDIS_URL=` would actively clear a
`docker run -e REDIS_URL=...` value at boot, silently breaking the
service. Shipped only in varlock@1.17.1.

That cleanup assumes a resolution happened in this process (a genuine
ambient value would have acted as an override and resolved to it),
which is false for a blob resolved on a build machine.

The injection preludes (nextjs webpack + turbopack, vite resolved-env
SSR entry) now bake an `injectedAtBuild: true` flag INSIDE the
serialized payload, before encryption where applicable, and
initVarlockEnv skips the cleanup for a flagged blob. Provenance lives
in the payload so it travels with the blob to child processes and
through encryption round-trips, and can never outlive it: any fresh
resolution produces an unflagged blob, so no marker clearing is needed
anywhere.

Otherwise unchanged: baked values stay authoritative for ENV, and
runtime values are still not applied to a baked snapshot (they cannot
be validated or coerced against it). Making that contract explicit and
enforceable belongs with `varlock freeze` (#1049).
Resolves every value once and writes an encrypted file that ships inside the
deploy artifact. The app boots from that file instead of re-resolving, so
config is part of the release: it changes atomically with code and rolls back
with it.

Aimed at apps with no build step to inline values (Elysia, Hono, Fastify on
Bun/Node, distroless images) on platforms where env vars can't be set
atomically with a deploy. Also removes the need for the CLI, .env files, or
resolver credentials in the runtime image.

- `.varlock-frozen-env` is picked up automatically when present, controlled by
  `_VARLOCK_USE_FROZEN_ENV` (require it, point at a path, or disable it)
- A file that is present but unusable is always an error, never a silent
  fallback to boot-time resolution
- Takes precedence over an ambient `__VARLOCK_ENV` blob, and is authoritative:
  the directory/drift checks that gate blob reuse compare against local .env
  files, which a frozen deploy does not carry
- Encryption is required unless `--allow-plaintext` is passed
- Override provenance is cleared, so a key that happened to be set in CI can't
  become a hole the platform overrides at runtime
- Elysia integration guide, covering setup, leak prevention through Elysia's
  Response-based handlers, and the freeze-based deploy flow for both
  `bun build` bundles and `--compile` binaries
- End-to-end smoke tests: freeze, then boot from the artifact in a directory
  with no .env files, asserting values, coerced types, and sensitivity survive
  the round trip, and that a broken file fails closed even where resolution
  would otherwise succeed

`varlock freeze` now refuses `--env` when the schema sets `@currentEnv`, rather
than inheriting `load`'s silent-fallback behavior. There, a wrong `--env` just
prints wrong output; here it bakes the wrong environment's values into a deploy
artifact that is consumed without re-resolution, so nothing downstream gets
another chance to catch it. The summary also names the environment it froze.
The error told users to "freeze with --filter instead", but `varlock freeze`
has no --filter and ignores _VARLOCK_FILTER, so the suggested remedy could
never work.

That asymmetry is deliberate: a partial seal would leave keys outside the
filter neither sealed nor validated, which is the split-validation state
freezing exists to prevent. So the remedy is to drop one or the other, and
the message now says that.
A frozen file is authoritative: it wins over env supplied at boot, and
process.env is kept in agreement with ENV. That is the most surprising thing
about the feature and the guide never said it, which is a problem because it
is the exact shape that took production down in #1055 (`docker run -e
REDIS_URL=...` against an image whose snapshot lacked the key).

The behavior is correct and deliberate. `injectedAtBuild` exists so an
*implicit* bake preserves runtime env, because those users never asked for a
seal. Freezing is opt-in and its whole promise is a validated unit, so a value
injected afterwards was part of neither resolution.

Tests pin both halves plus a control showing the same ambient value is honored
as a normal override when no seal is present, so a later change can't quietly
give freeze the baked-snapshot semantics.

Also states plainly that freezing is all or nothing today: if some values must
come from the container, freeze is not the right tool for that service yet, and
marking the key @optional to get past the validation refusal is the wrong fix
(it weakens the schema and the seal still clears the operator's value).
@theoephraim
theoephraim force-pushed the varlock-freeze-command branch from b9afe6d to 605e44c Compare September 1, 2026 21:24

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in this delta. The prior review's unresolved findings still determine merge readiness.

Reviewed changes since the prior Pullfrog review, covering the clarified total-seal contract, its end-to-end coverage, and the corrected filter conflict guidance.

  • Documented total sealing: Explained that frozen values override runtime env and that keys frozen as unset are removed from process.env.
  • Pinned seal behavior: Added smoke coverage for set and unset ambient values plus a non-frozen control, all through the real auto-load path.
  • Corrected filter guidance: Removed the nonexistent freeze --filter remedy and now points users to disabling either filtering or frozen reuse.

Pullfrog  | Fix it ➔View workflow run | Using azure/gpt-5.6-sol𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant