Add varlock freeze for deploy-time pinned env - #1049
Conversation
|
The changes in this PR will be included in the next version bump.
|
📦 Bundle size
dist/ only; native binaries are versioned separately and not counted here. |
Deploying with
|
| 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 |
There was a problem hiding this comment.
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-loadandvarlock 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(); |
There was a problem hiding this comment.
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 }); |
There was a problem hiding this comment.
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.
varlock
@varlock/native-helper-darwin
@varlock/native-helper-linux-arm64
@varlock/native-helper-linux-x64
@varlock/native-helper-win32-x64
commit: |
…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).
b9afe6d to
605e44c
Compare
There was a problem hiding this comment.
ℹ️ 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-loadpath. - Corrected filter guidance: Removed the nonexistent
freeze --filterremedy and now points users to disabling either filtering or frozen reuse.
azure/gpt-5.6-sol | 𝕏



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
ssrInjectModearound 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 deploydoes 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.envfiles, and no resolver credentials.How it works
Ship the file in your image and boot normally.
varlock/auto-loadandvarlock runboth pick it up._VARLOCK_USE_FROZEN_ENVcontrols it: unset auto-discovers,1/truerequires it,0/falsedisables, anything else is a required path.Design notes
The seal is total. It wins over env supplied at boot, and
process.envis kept in agreement withENV, so nothing reads one resolution while something else reads another.This is the opposite of
injectedAtBuildfrom #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 setinjectedAtBuild, 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
basePathand source-fingerprint checks that gate automatic blob reuse compare against local.envfiles, 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=0doesn't disable it.Encryption required unless
--allow-plaintext.varlock-wrangleralready 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 throughdocker cp. Flag-only, no config setting, so it can't be turned on once and forgotten.Override provenance cleared.
overrideKeysdescribes 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.--envis refused when the schema sets@currentEnv.loadignores 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 usevarlock runor boot resolution instead. The guide says this plainly, including that marking such a key@optionalto 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_KEYbreaks rollback to releases frozen under the old key. A frozen file in a project directory wins over edited.envfiles with no drift check. Values that expire shouldn't be frozen.Testing
bun build --target=bunand--compile), booting from a directory containing only the output plus the artifact. Values, coerced types, and sensitivity survive the round trip, soENV.PORTis still a number and log redaction still works.An Elysia example app is ready for varlock-examples but not yet committed there.