Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/frozen-env-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: minor
---

Add `varlock freeze` to resolve env values once at deploy time and write them to an encrypted file that ships inside your deploy artifact. Your app boots from that file instead of re-resolving, so config is pinned to the release and rolls back with it. Aimed at apps with no build step (Elysia, Hono, Fastify) on platforms where env vars can't be set atomically with a deploy.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ vite.config.ts.timestamp*
env.d.ts

smoke-tests/pnpm-lock.yaml
smoke-tests/bun.lock
framework-tests/.packed
framework-tests/.test-projects
.magent
Expand Down
162 changes: 162 additions & 0 deletions packages/varlock-website/src/content/docs/guides/frozen-env.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
---
title: Frozen env
description: Resolve env values once at deploy time and ship them inside your deploy artifact
---
import { Steps, Tabs, TabItem } from '@astrojs/starlight/components';
import ExecCommandWidget from '@/components/ExecCommandWidget.astro';

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

This is for apps where you control the boot command but have no build step that would inline values for you: Elysia, Hono, Fastify, or Express on Bun or Node, and distroless Docker images. If you use a framework integration (Next.js, Vite, Astro, Nuxt, Cloudflare Workers), you already get equivalent behavior and do not need this. See [encrypted deployments](/guides/encrypted-deployments/).

## The problem

Setting environment variables on a platform and shipping code are two separate operations. There is no way to make them one, which causes three things:

- **No atomic change.** A release that changes both code and config is two operations with a window in between, where one is live and the other is not.
- **No rollback.** Config lives in the platform's store, not in the release, so rolling back code leaves the new config in place.
- **No guarantee across replicas.** Values are re-resolved on every boot, so a replica that autoscales up at 3am can resolve differently from the one that booted at deploy time, with no signal that it happened.

A frozen env file makes config part of the deploy unit, so all three go away. It also means boot no longer depends on your secret backend being reachable, which removes a source of cold-start latency and rate limits when many replicas start at once.

## Setup

<Steps>

1. **Generate an encryption key, once**

<ExecCommandWidget command="varlock generate-key" showBinary={false} />

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.

2. **Freeze at deploy time**

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

```bash
varlock freeze --env production
```

This writes `.varlock-frozen-env` in the current directory. Add it to your `.gitignore`.

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.

4. **Boot your app normally**

```bash
bun server.js
```

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

</Steps>

## Example: Bun with Docker

```dockerfile title="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
# resolver credentials and .env files are only present in this stage
RUN --mount=type=secret,id=varlock_key \
_VARLOCK_ENV_KEY=$(cat /run/secrets/varlock_key) bunx varlock freeze --env production

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 ./
CMD ["bun", "dist/index.js"]
```

Your entrypoint imports varlock as usual:

```ts title="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.

## Controlling the file

Varlock uses `.varlock-frozen-env` in the working directory automatically when it is present. `_VARLOCK_USE_FROZEN_ENV` changes that:

| Value | Behavior |
| --- | --- |
| unset | Use `.varlock-frozen-env` if present, otherwise resolve normally |
| `1` / `true` | Require `.varlock-frozen-env`. A missing file is an error |
| `0` / `false` | Never use a frozen env file |
| any path | Require a frozen env file at that path, relative to the working directory unless absolute |

Setting `_VARLOCK_USE_FROZEN_ENV=1` in production is worth doing. Without it, a pipeline that failed to produce the file, or an image that failed to copy it, boots and resolves normally instead, and you have no signal that the pin is not in effect.

Note that any unrecognized value is treated as a path, so `_VARLOCK_USE_FROZEN_ENV=off` looks for a file named `off` and fails. Only `0` and `false` disable it.

### Freeze options

| Flag | Description |
| --- | --- |
| `--out`, `-o` | Output path (default `.varlock-frozen-env`) |
| `--env` | Environment to resolve for (e.g. `production`) |
| `--path`, `-p` | Entry .env file or directory, repeatable |
| `--allow-plaintext` | Write unencrypted when `_VARLOCK_ENV_KEY` is not set |
| `--clear-cache` | Clear the cache and re-resolve everything |
| `--skip-cache` | Skip the cache for this invocation |

## Encryption is required

`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](/guides/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.

## The frozen file wins over runtime env

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:

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

:::caution[Config that genuinely varies per container]
Freezing is all or nothing: there is currently no way to seal most of your config while leaving a few keys to the runtime. If some values must come from the container rather than the deploy (a per-container `DATABASE_URL`, say), freezing is not the right tool for that service yet. Use [`varlock run`](/reference/cli/load-and-run/#run), or let the app resolve at boot, so every value is validated where it is supplied.

In particular, 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 seal then clears whatever the operator supplied. You end up with no value and no error.
:::

## Tradeoffs

**Rotating a secret takes effect on your next deploy, not on the next restart.** This is the entire point of freezing, but it inverts what most secret managers do, so everyone touching the deploy should know it. To pick up a rotated value, 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.** If one is sitting in your project directory during local development, it wins over your .env files even after you edit them. Delete it, or set `_VARLOCK_USE_FROZEN_ENV=0`.

**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 deploy time and it will expire while the deploy is still running. Resolve those at boot instead of freezing them.

## Related approaches

| Approach | When it fits |
| --- | --- |
| `varlock freeze` | You control the boot command, have no build step, and want config pinned to the release |
| [`varlock run`](/reference/cli-commands/#run) | The CLI, .env files, and resolver credentials are all available in the runtime environment |
| [`ssrInjectMode: 'resolved-env'`](/guides/encrypted-deployments/) | You use a framework integration that injects the resolved env into build output |
| [`varlock-wrangler deploy`](/integrations/cloudflare/) | Cloudflare Workers, where the resolved env is uploaded as a versioned secret binding |
143 changes: 143 additions & 0 deletions packages/varlock-website/src/content/docs/integrations/elysia.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
---
title: Elysia
description: How to use varlock with an Elysia server on Bun, including deploy-time frozen env
---
import { Steps, Tabs, TabItem } from "@astrojs/starlight/components";
import ExecCommandWidget from '@/components/ExecCommandWidget.astro';
import InstallJsDepsWidget from '@/components/InstallJsDepsWidget.astro';

[Elysia](https://elysiajs.com) needs no integration package. Import `varlock/auto-load` and read values through the typed `ENV` proxy, the same as any other [JavaScript project](/integrations/javascript/).

The part worth reading is [deploying](#deploying). Elysia has no build step that inlines env values, so unlike Next.js or Vite there is nothing to bake your config into. `varlock freeze` fills that gap.

Check out the [Elysia example project](https://github.com/dmno-dev/varlock-examples/tree/main/integrations/elysia) for a working reference.

---

## Setup

<Steps>

1. **Install varlock**
<InstallJsDepsWidget packages="varlock" />

1. **Run `varlock init` to set up your `.env.schema`**

<ExecCommandWidget command="varlock init" showBinary={false} />

1. **Turn off Bun's own `.env` loading**

Bun loads `.env` files itself based on `NODE_ENV`/`BUN_ENV`, which feeds values into varlock behind its back. Let varlock own env loading:

```toml title="bunfig.toml"
env = false
```

See the [Bun integration docs](/integrations/bun/) for the other ways to disable it, including for compiled binaries.

1. **Import `varlock/auto-load` first in your entrypoint**

```ts title="src/server.ts" ins={1,2}
import 'varlock/auto-load';
import { ENV } from 'varlock/env';
import { Elysia } from 'elysia';

new Elysia()
.get('/', () => ({ message: ENV.PUBLIC_MESSAGE }))
.listen(ENV.PORT);
```

The import must come first, so env is loaded and validated before anything else runs. If you would rather keep it out of app code, drop the import and launch with `varlock run -- bun src/server.ts` instead.

</Steps>

## Leak prevention

Because varlock knows which items are `@sensitive`, both protections work in Elysia with no extra setup:

```ts
.get('/log-demo', () => {
console.log('SOME_API_KEY =', ENV.SOME_API_KEY); // logged redacted
return 'ok';
})

.get('/leak-demo', () => {
return { oops: ENV.SOME_API_KEY }; // blocked, request fails with a 500
})
```

Elysia builds responses with the standard `Response` API, which varlock patches, so a sensitive value returned from a handler is caught before it reaches the client. See [leak prevention](/guides/secrets/#leak-prevention).

## Deploying

Locally, varlock resolves your `.env` files on every boot. In a deploy that is usually the wrong default: it means config lives in your platform's settings rather than in the release, so it can't change atomically with your code and doesn't roll back with it. It also makes every boot depend on your secret backend being reachable.

[`varlock freeze`](/guides/frozen-env/) resolves everything once at deploy time and writes an encrypted file that ships inside your deploy artifact. Your app boots from that file.

<Steps>

1. **Generate a key, once**

<ExecCommandWidget command="varlock generate-key" showBinary={false} />

Set the result as `_VARLOCK_ENV_KEY` in your deploy pipeline and your runtime environment.

1. **Bundle and freeze at deploy time**

```bash
bun build ./src/server.ts --target=bun --outdir dist
APP_ENV=production varlock freeze
```

Run `freeze` where your `.env` files and secret backends are reachable, usually a CI job. It writes `.varlock-frozen-env` and prints which environment it captured.

1. **Ship the frozen file with your code**

```dockerfile title="Dockerfile"
FROM oven/bun:1
WORKDIR /app
COPY dist/server.js bunfig.toml ./
COPY .varlock-frozen-env ./
CMD ["bun", "server.js"]
```

Copy it into the image rather than mounting it at runtime. Mounting it separately puts config back outside the release, which is the thing this avoids.

1. **Boot normally**

```bash
bun server.js
```

</Steps>

That image needs nothing else: no `.env` files, no `node_modules`, and no varlock CLI. Values, coerced types, and sensitivity all travel inside the frozen file, so `ENV.PORT` is still a number and log redaction still works.

Set `_VARLOCK_USE_FROZEN_ENV=1` in production so a missing frozen file is a hard error instead of a silent fall back to normal resolution.

:::caution[Values are pinned once frozen]
Rotating a secret takes effect on your next deploy, not on the next restart. Re-run `varlock freeze` and redeploy. See [tradeoffs](/guides/frozen-env/#tradeoffs) for the full list, including how key rotation interacts with rollback.
:::

### Standalone binaries

`bun build --compile` works the same way. The binary reads `.varlock-frozen-env` from its working directory, so ship the two together:

```bash
bun build ./src/server.ts --compile --outfile server --no-compile-autoload-dotenv
APP_ENV=production varlock freeze
```

`--no-compile-autoload-dotenv` stops the compiled binary from doing Bun's own `.env` loading, matching the `bunfig.toml` setting above.

### Choosing an environment

If your schema sets [`@currentEnv`](/reference/root-decorators/#currentenv), the `--env` flag does not apply, and `varlock freeze` errors rather than silently capturing the wrong environment. Set the flag item instead:

```bash
APP_ENV=production varlock freeze
```

### Other options

`freeze` is not the only way to deploy. If the varlock CLI, your `.env` files, and your resolver credentials are all available in the runtime environment, `varlock run -- bun server.js` works too and re-resolves on every boot. See [related approaches](/guides/frozen-env/#related-approaches) for the comparison.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Project commands
description: CLI reference for init, scan, install-plugin, flatten, telemetry, and help
description: CLI reference for init, scan, install-plugin, flatten, freeze, telemetry, and help
---

import ExecCommandWidget from "@/components/ExecCommandWidget.astro";
Expand Down Expand Up @@ -171,6 +171,48 @@ The output directory is a generated artifact: add it to `.gitignore`, and rerun

<div>

## `varlock freeze` ||freeze||

Resolves every value once and writes the result to an encrypted file, so your app can boot from those exact values without re-resolving. Run it at deploy time, and ship the file inside your deploy artifact so config and code travel and roll back as one unit. See [the frozen env guide](/guides/frozen-env/) for the full workflow.

This is aimed at apps where you control the boot command but have no build step that would inline values (Elysia, Hono, Fastify, Express on Bun or Node, distroless images). Framework integrations already do the equivalent, so you do not need this with Next.js, Vite, Astro, Nuxt, or Cloudflare Workers.

At boot, varlock uses `.varlock-frozen-env` automatically when it is present in the working directory. Nothing else is needed in the runtime image: no varlock CLI, no `.env` files, and no resolver credentials. `_VARLOCK_ENV_KEY` must be set in the runtime environment so the file can be decrypted. See [`_VARLOCK_USE_FROZEN_ENV`](/reference/reserved-variables/#_varlock_use_frozen_env) to require the file or point at a different path.

Encryption is required: `freeze` errors when `_VARLOCK_ENV_KEY` is not set, since the file holds every resolved value and travels through image layers, registries, and CI artifacts. `--allow-plaintext` opts out for schemas with nothing sensitive in them.

Values are pinned once frozen. Rotating a secret takes effect on your next deploy, not on the next restart.

```bash
varlock freeze [options]
```

**Options:**
- `--out <path>`, `-o`: Output file path, relative to the current directory unless absolute (default `.varlock-frozen-env`)
- `--env <env>`: Environment to resolve for (e.g. `production`). Overridden by [`@currentEnv`](/reference/root-decorators/#currentenv) if the schema sets it
- `--path <path>`, `-p`: Entry `.env` file or directory, can be passed multiple times
- `--allow-plaintext`: Write the file unencrypted when `_VARLOCK_ENV_KEY` is not set
- `--clear-cache`: Clear the cache and re-resolve all values
- `--skip-cache`: Skip the cache entirely for this invocation

**Examples:**
```bash
# Write .varlock-frozen-env in the current directory
varlock freeze

# Resolve for a specific environment
varlock freeze --env production

# Custom output location, bypassing the cache
varlock freeze --out dist/env.frozen --skip-cache
```

The output file is a generated artifact holding resolved values: add it to `.gitignore`, and rerun `freeze` whenever your env values change.

</div>

<div>

## `varlock telemetry` ||telemetry||

Opts in/out of anonymous usage analytics. This command creates/updates a configuration file at `$XDG_CONFIG_HOME/varlock/config.json` (defaults to `~/.config/varlock/config.json`) saving your preference.
Expand Down
Loading
Loading