This is the full developer documentation for Varlock # Installation > How to install and set up Varlock in your project There are two ways to install Varlock: 1. Install as a `package.json` dependency in JavaScript/TypeScript projects (the `varlock` CLI package) 2. Install as a standalone binary If you prefer to let your AI agent install Varlock for you, you can skip these instructions and build a prompt for your agent: [Open Prompt Builder](/prompt-builder/) ### Docs MCP [Section titled “Docs MCP”](#docs-mcp) There is also a Docs MCP server that exposes a search tool. See more details [here](/guides/mcp/docs-mcp/). ## As a JavaScript/TypeScript dependency [Section titled “As a JavaScript/TypeScript dependency”](#as-a-javascripttypescript-dependency) Requires: * Node.js version 22 or higher ### Installation [Section titled “Installation”](#installation) To install `varlock` in your project, run: * npm ```bash npx varlock init ``` * pnpm ```bash pnpm dlx varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx varlock init ``` * yarn ```bash yarn dlx varlock init ``` This will install `varlock` as a dependency and scan your project for `.env` files and create a `.env.schema` file in the root of your project. Depending on your project configuration, it will optionally: * Remove your existing `.env.example` file * Add decorators to your `.env.schema` file to specify the type of each environment variable For AI agent workflows, use non-interactive mode instead: * npm ```bash npx varlock init --agent ``` * pnpm ```bash pnpm dlx varlock init --agent ``` * bun ```bash bunx varlock init --agent ``` * vlt ```bash vlx varlock init --agent ``` * yarn ```bash yarn dlx varlock init --agent ``` ## As a standalone binary [Section titled “As a standalone binary”](#as-a-standalone-binary) To install `varlock` CLI as a binary, run: ```bash # Install via homebrew brew install dmno-dev/tap/varlock # OR via cURL curl -sSfL https://varlock.dev/install.sh | sh -s ``` Then run the setup wizard to help you get started: ```bash varlock init ``` Antivirus false positives If Microsoft Defender or another scanner flags a `varlock-local-encrypt` binary on any platform, see the [local encryption guide, antivirus false positives](/guides/local-encryption/#antivirus-false-positives) for verification and recovery steps. **Installation script usage** ```plaintext Usage: install.sh [options] install varlock binary Options: --dir directory to install varlock to (defaults to $XDG_CONFIG_HOME/varlock/bin, else ~/.varlock/bin if it exists, else ~/.config/varlock/bin) --reinstall reinstall even if already installed (default: false) --version version of varlock to install (defaults to latest) --force-no-brew force install without homebrew even when detected (default: false) --skip-win-exe on WSL, skip installing the Windows encryption helper (varlock-local-encrypt.exe) (default: false) ``` ### Verifying a release [Section titled “Verifying a release”](#verifying-a-release) Both install paths already check integrity for you: the homebrew formula pins a sha256 per archive, and `install.sh` verifies the downloaded archive against the release’s `checksums.txt` before installing. If you are pinning varlock yourself (a container image, a provisioning script, or a tool that installs varlock on a user’s behalf), download the archive from the release tag and verify it the same way: ```bash VERSION=1.14.0 BASE="https://github.com/dmno-dev/varlock/releases/download/varlock@${VERSION}" ARCHIVE="varlock-linux-x64.tar.gz" curl -sSfLO "${BASE}/${ARCHIVE}" curl -sSfLO "${BASE}/checksums.txt" grep " ${ARCHIVE}\$" checksums.txt | sha256sum -c - # on macOS, which has no sha256sum: grep " ${ARCHIVE}\$" checksums.txt | shasum -a 256 -c - ``` `checksums.txt` is signed with [cosign](https://docs.sigstore.dev/) keyless signing, so you can also confirm it came from varlock’s release workflow rather than from someone with write access to the repo’s releases. The signature covers `checksums.txt`, which in turn covers every archive by hash: ```bash curl -sSfLO "${BASE}/checksums.txt.cosign.bundle" cosign verify-blob checksums.txt \ --bundle checksums.txt.cosign.bundle \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp '^https://github\.com/dmno-dev/varlock/\.github/workflows/(release|binary-release)\.yaml@refs/heads/main$' ``` Both workflow identities are accepted because binaries are normally cut by `release.yaml` alongside the npm publish, and re-cut by `binary-release.yaml` when an already-published version needs new archives. Note Signing was added after varlock 1.14.0, so older releases have `checksums.txt` but no bundle. If a release has no `checksums.txt.cosign.bundle` asset, it predates signing and only the checksum step applies. ## Varlock skill [Section titled “Varlock skill”](#varlock-skill) Then install the Varlock agent skill: * skills ```bash npx skills add dmno-dev/varlock ``` * GitHub CLI Requires GitHub CLI v2.90+. ```bash gh skill install dmno-dev/varlock varlock ``` See the [AI Tools guide](/guides/ai-tools/#install-the-varlock-skill) for update commands and agent-specific options. ## Editor and shell tooling [Section titled “Editor and shell tooling”](#editor-and-shell-tooling) Varlock ships with first-party tooling that makes authoring `.env.schema` files much easier. Set these up right after installing the CLI. ### VS Code extension [Section titled “VS Code extension”](#vs-code-extension) Install the [**@env-spec VS Code extension**](/env-spec/vs-code-ext/) from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=varlock.env-spec-language) or [Open VSX](https://open-vsx.org/extension/varlock/env-spec-language) (for Cursor, Windsurf, and other VS Code forks). It provides syntax highlighting, decorator and `@type` IntelliSense, `$KEY` reference completion, and inline validation while you edit `.env.schema` and other `@env-spec` files. ### Shell completion [Section titled “Shell completion”](#shell-completion) Enable tab completion for varlock commands and flags in bash, zsh, or fish. See the [Shell completion guide](/guides/shell-completion/) for one-time setup. Usually, adding `eval "$(varlock complete)"` to your shell profile is enough. # Introduction > Introduction to Varlock, the AI-safe env var toolkit for validating, securing, and sharing your environment variables Varlock is a universal configuration/secrets/environment variable management tool built on top of the [@env-spec](/env-spec/overview/) specification. It helps you manage, validate, and secure your environment configuration, with type-safe environment variables, multi-environment management, secure secret handling, and leak prevention. While it is written in TypeScript, it is language and framework agnostic, and meant to be used in any project that needs configuration at build or boot time, usually passed in via environment variables. ## Features [Section titled “Features”](#features) Varlock provides: * **[AI-Safe Config](/guides/ai-tools/)** - Your `.env.schema` gives AI agents full context on your config without ever exposing secret values. Prevent leaks to AI servers, and scan for leaked secrets with `varlock scan` * **[Security](/guides/secrets/)** - Automatic log redaction for sensitive values, leak detection in bundled code and server responses, and proactive scanning via `varlock scan` * **[Validation & Type Safety](/reference/data-types/)** - Validation with clear error messages, plus automatic type generation for IntelliSense support * **[Secure Secrets](/guides/secrets/)** - Built-in [device-local encryption](/guides/local-encryption/) with hardware-backed security (Secure Enclave, TPM), plus provider [plugins](/plugins/overview/) (e.g., [1Password](/plugins/1password/), [AWS](/plugins/aws-secrets/), [HashiCorp Vault](/plugins/hashicorp-vault/)) or any CLI tool using [exec()](/reference/functions/#exec) * **[Multi-Environment Management](/guides/environments/)** - Flexible environment handling with support for environment-specific files, local overrides, and value composition * **[Value Composition](/reference/functions/)** - Compose values together using functions, references, and external data sources * **[Framework Integrations](/integrations/overview/)** - Official integrations for Next.js, Vite, Astro, and more, plus support for any language via `varlock run` * **[Replacement for dotenv](/guides/migrate-from-dotenv/)** - Can be used as a direct replacement for `dotenv` in most projects with minimal code changes ## AI tooling [Section titled “AI tooling”](#ai-tooling) Varlock is built for AI-assisted development: your `.env.schema` gives agents schema context without exposing secret values. See the [AI Tools guide](/guides/ai-tools/) for `varlock run` with coding CLIs, the maintainer skill, [Docs MCP](/guides/mcp/docs-mcp/), and [LLMs.txt](https://varlock.dev/llms.txt). ## Next Steps [Section titled “Next Steps”](#next-steps) Ready to get started? Check out the [Installation](/getting-started/installation/) guide to set up Varlock in your project, or build a tailored setup prompt for your AI tool: [Open Prompt Builder](/prompt-builder/) # Migration > An overview if you have existing .env files and want to migrate to Varlock ## Loading env vars using Varlock [Section titled “Loading env vars using Varlock”](#loading-env-vars-using-varlock) ### Migration from dotenv (Node.js) [Section titled “Migration from dotenv (Node.js)”](#migration-from-dotenv-nodejs) In a [Node.js](/integrations/javascript/) app if you are already calling `dotenv/config`, you can replace it with `varlock/auto-load`. index.js ```diff -import 'dotenv/config'; +import 'varlock/auto-load'; ``` In some cases where `dotenv` is being called deep under the hood by another dependency, you may instead want to swap it in as a dependency override. See our [migrate from dotenv](/guides/migrate-from-dotenv/) guide for more information. ### Within a framework [Section titled “Within a framework”](#within-a-framework) We must replace the framework’s existing `.env` logic with Varlock. Our [framework integrations](/integrations/overview/) handle most of the work for you. After [installation](/getting-started/installation/), follow the instructions in the relevant integration guide to set up Varlock in your project. Usually this involves adding a new plugin to the existing build system or the framework config file. ### Minimal setup [Section titled “Minimal setup”](#minimal-setup) In some cases, a code-level integration may be challenging or impossible. In this case you can use [`varlock run`](/reference/cli/load-and-run/#run) to boot your application with env vars injected from Varlock. For example `varlock run -- your-app`. Sometimes you may need to use this alongside a deeper integration, for example to feed env vars into external tools or additional scripts. ## Using `varlock/env` [Section titled “Using varlock/env”](#using-varlockenv) If you’re currently using `import.meta.env` or `process.env`, your code will still work after switching to Varlock. However, we recommend using varlock’s `ENV` object for better type-safety and an improved developer experience. index.js ```diff // Before (import.meta.env) -console.log(import.meta.env.SOMEVAR); // After (ENV) import { ENV } from 'varlock/env'; +console.log(ENV.SOMEVAR); ``` ![IntelliSense for ENV variables](/_astro/intellisense.l7SBUQg3_jY8ez.png) See our [integrations](/integrations/overview/) section for more information. # Usage > How to use Varlock in your project ## Basics [Section titled “Basics”](#basics) The basic workflow for using Varlock is to: 1. Run [`varlock init`](/reference/cli/project/#init) to set up your `.env.schema` file 2. Run [`varlock load`](/reference/cli/load-and-run/#load) to debug and refine your .env file(s) 3. Use Varlock to load, validate, and inject env vars into your application, either: * Use an [existing framework / tool integration](/integrations/overview/) that automatically calls Varlock under the hood (*recommended*) * Use `import 'varlock/auto-load'` in a backend JavaScript/TypeScript project * Boot your command via [`varlock run`](/reference/cli/load-and-run/#run)\ (*necessary for non-JS/TS projects, or feeding env vars to external tools*) ## CLI Commands [Section titled “CLI Commands”](#cli-commands) ### `varlock load` [Section titled “varlock load”](#varlock-load) * npm ```bash npm exec -- varlock load ``` * pnpm ```bash pnpm exec -- varlock load ``` * bun ```bash bunx varlock load ``` * vlt ```bash vlx -- varlock load ``` * yarn ```bash yarn exec -- varlock load ``` * standalone binary ```bash varlock load ``` Validates your environment variables according to your `.env.schema` and associated `.env.*` files, and prints the results. Useful for debugging locally, and in CI to print out a summary of env vars, also when you’re authoring your `.env.schema` file and want immediate feedback. Tip Our [integrations](/integrations/overview) all use `varlock load` under the hood, so you’ll get the same developer experience, but typically they will only let you know if there are errors, rather than the full summary. See the [`varlock load` CLI Reference](/reference/cli/load-and-run/#load) for more information. #### Understanding `varlock load` output [Section titled “Understanding varlock load output”](#understanding-varlock-load-output) By default, `varlock load` prints a human-readable summary to **stdout**. Schema and validation errors go to **stderr** first; if loading succeeds, you will see a `-- Resolved config --` header followed by one block per item. For example, given a schema like this: .env.schema ```env-spec # @defaultRequired=false # @defaultSensitive=false # --- # @required SERVICE_NAME=payments-api LOG_LEVEL=info # @type=number PORT="8080" # @type=boolean FEATURE_FLAG_BETA="true" # @required APP_ENV=development # @sensitive @required DATABASE_URL=postgres://user:pass@localhost:5432/app # @sensitive SESSION_SECRET=cache(randomHex(32), ttl="1h") # @sensitive @required STRIPE_SECRET_KEY= ``` `varlock load` prints (with `STRIPE_SECRET_KEY` set in `.env`, `APP_ENV` overridden in the shell, and `SESSION_SECRET` served from cache on a second run): ```ansi -- Resolved config -- ✅ SERVICE_NAME* └ "payments-api" ✅ LOG_LEVEL └ "info" ✅ PORT └ 8080 < coerced from "8080" ✅ FEATURE_FLAG_BETA └ true < coerced from "true" ✅ APP_ENV* └ "production" 🟡 process.env ✅ DATABASE_URL* 🔐sensitive └ po▒▒▒▒▒ ✅ SESSION_SECRET 🔐sensitive └ df▒▒▒▒▒ 📦 2m ago ✅ STRIPE_SECRET_KEY* 🔐sensitive └ sk▒▒▒▒▒ ``` Each item line shows: * A status icon (`✅` valid, or an error/warning icon if something failed) * The key name, with `*` if [`@required`](/reference/item-decorators/#required) * `🔐 sensitive` when the item is marked [`@sensitive`](/reference/item-decorators/#sensitive); values are redacted in the summary * The resolved value (quoted strings, colored booleans/numbers) * Inline hints such as `📦` (cached), `🟡 process.env` (overridden by the shell), or `< coerced from ...` when a value was coerced When validation fails, only failing items are shown unless you pass [`--show-all`](/reference/cli/load-and-run/#load). Use [`--format json`](/reference/cli/load-and-run/#load) or [`--format json-full`](/reference/cli/load-and-run/#load) for machine-readable output. For example, in CI or when piping to other tools. For example, with `STRIPE_SECRET_KEY` left empty: ```ansi 🚨🚨🚨 Configuration is currently invalid 🚨🚨🚨 ❓ STRIPE_SECRET_KEY* 🔐sensitive └ undefined - Value is required but is currently empty 💥 Resolved config/env did not pass validation 💥 ``` ### `varlock run` [Section titled “varlock run”](#varlock-run) * npm ```bash npm exec -- varlock run -- ``` * pnpm ```bash pnpm exec -- varlock run -- ``` * bun ```bash bunx varlock run -- ``` * vlt ```bash vlx -- varlock run -- ``` * yarn ```bash yarn exec -- varlock run -- ``` * standalone binary ```bash varlock run -- ``` Executes a command in a child process, injecting your resolved and validated environment variables. This is useful when a code-level integration is not possible. For example, if you’re using a database migration tool, you can use `varlock run` to run the migration tool with the correct environment variables. Or if you’re using a non-js/ts language, you can use `varlock run` to run a command and inject validated environment variables. See the [`varlock run` CLI Reference](/reference/cli/load-and-run/#run) for more information. ### `varlock encrypt` [Section titled “varlock encrypt”](#varlock-encrypt) * npm ```bash npm exec -- varlock encrypt --file .env.local ``` * pnpm ```bash pnpm exec -- varlock encrypt --file .env.local ``` * bun ```bash bunx varlock encrypt --file .env.local ``` * vlt ```bash vlx -- varlock encrypt --file .env.local ``` * yarn ```bash yarn exec -- varlock encrypt --file .env.local ``` * standalone binary ```bash varlock encrypt --file .env.local ``` Encrypts sensitive values using device-local encryption. Use `--file` to encrypt all `@sensitive` plaintext values in a `.env` file in-place, or run without arguments for interactive single-value encryption. Encrypted values are stored as `varlock("local:")` and are automatically decrypted during `varlock load` or `varlock run`. See the [`varlock encrypt` CLI Reference](/reference/cli/encryption/#encrypt) and the [Local encryption guide](/guides/local-encryption/) for more information. ### `varlock reveal` [Section titled “varlock reveal”](#varlock-reveal) * npm ```bash npm exec -- varlock reveal ``` * pnpm ```bash pnpm exec -- varlock reveal ``` * bun ```bash bunx varlock reveal ``` * vlt ```bash vlx -- varlock reveal ``` * yarn ```bash yarn exec -- varlock reveal ``` * standalone binary ```bash varlock reveal ``` Securely view or copy decrypted values of `@sensitive` environment variables. Values are shown in an alternate screen buffer to prevent scrollback capture. See the [`varlock reveal` CLI Reference](/reference/cli/encryption/#reveal) for more information. # Wrapping up > How to get your project ready for production and collaboration ## Next steps with your schema [Section titled “Next steps with your schema”](#next-steps-with-your-schema) With a more flexible env var toolkit, after an initial migration, you may be tempted to take advantage of Varlock’s features to improve your developer experience and security posture. * Move more configuration constants out of application code and into your `.env` files * Reduce the number of env-style checks in your code, favouring individual flags, with a default value set based on the current env * Add deeper validation, more thorough comments, and additional docs links to each env var within your schema * Compose values together to keep your configuration DRY * Use [imports](/guides/import/) to share common configuration across a monorepo, or to break up a large `.env.schema` * Reduce secret sprawl, by loading secrets from a single source of truth, instead of injecting them from your CI/hosting platform ## Repo setup [Section titled “Repo setup”](#repo-setup) ### `.gitignore` [Section titled “.gitignore”](#gitignore) Depending on your setup you will want to update your `.gitignore` to *not* ignore your `.env.schema` file and any other `.env.xxx` files that can now be safely committed to your repo if they don’t contain secrets (which they shouldn’t). If using [generated types](/reference/root-decorators/#code-generation), we also recommend that you ignore the generated file (usually `env.d.ts` in TypeScript) since it is regenerated automatically from your schema (on `varlock load`/`run`), like other build artifacts. .gitignore ```diff # Include .env.schema, .env. file # exclude local overrides .env.* .env.local .env.*.local # Exclude generated env types file env.d.ts ``` Tip Depending on the [AI Tools](/guides/ai-tools/) you use, you may need to add a similar rule to allow `.env.schema` to be modified. For example, in `.cursorignore`. ### Monorepos [Section titled “Monorepos”](#monorepos) Consider how you can reuse and modularize your schema if you have a monorepo or multi-service setup. Use [`@import()`](/guides/import/) to share config across packages, and see the [Monorepos](/guides/monorepos/) guide for layout patterns, Turborepo setup, incremental adoption, CI, and container builds. ## Deployment [Section titled “Deployment”](#deployment) ### CI/CD platforms [Section titled “CI/CD platforms”](#cicd-platforms) It may be useful to validate your schema in CI/CD pipelines, especially if you want to validate configurations that you don’t have access to locally (e.g. Staging or Production). You can do this manually by running `varlock load` in your pipeline. And if you’re using GitHub Actions, you can use the [Varlock GitHub Action](/integrations/github-action/) to validate your schema automatically. Tip Having a well architected multi-environment setup is key to healthy CI/CD workflows. See the [Environments](/guides/environments/) guide for more information. ### Production deployments [Section titled “Production deployments”](#production-deployments) Because varlock supports loading environment variables from the environment itself or via a [function](/reference/functions/) in your `.env.schema`, there are a few different approaches. If you’re already using your deployment platform’s environment variable management, you may not need to do anything to benefit from varlock’s validation and security features. If you have a multi-environment setup, you may need to set your environment flag (the item referenced by [`@currentEnv`](/reference/root-decorators/#currentenv), e.g. `APP_ENV`) to the correct environment. ```bash APP_ENV=production varlock run -- your-production-command ``` If you’re not using your deployment platform’s environment variable management, you may consider using one of our [plugins](/plugins/overview/) to securely load environment variables from a secret storage system such as [1Password](/plugins/1password/). For **container** deployments (Docker, Docker Compose, Kubernetes), see the [Docker guide](/integrations/docker/). # Cache and codegen > CLI reference for varlock cache and codegen ## `varlock cache` [Section titled “varlock cache”](#cache) Manage the encrypted **disk cache** used by [`cache()`](/reference/functions/#cache) and plugin authors when cache mode is set to `disk`. When run in a TTY, opens an interactive browser; otherwise prints a status summary. ```bash varlock cache [status|clear] [options] ``` **Sub-commands:** * `status`: print a non-interactive cache status summary (location, file size, entry counts by group) * `clear`: remove cache entries. Requires `--yes` for non-interactive use. **Options:** * `--plugin `: When clearing, only remove entries for a specific plugin * `--yes` / `-y`: Skip confirmation prompts (required when clearing without a TTY) **Examples:** ```bash # Interactive cache browser (or status summary in CI) varlock cache # Print a non-interactive status summary varlock cache status # Clear all cache entries (prompts to confirm) varlock cache clear # Clear all cache entries without confirming (e.g. CI) varlock cache clear --yes # Clear cache for a specific plugin only varlock cache clear --plugin 1password --yes ``` Note When global cache mode is `memory` (or disabled), this command does not show per-process in-memory entries. See the [Caching guide](/guides/caching/) for cache mode strategy and troubleshooting. ## `varlock codegen` [Section titled “varlock codegen”](#codegen) Runs the [code-generation decorators](/reference/root-decorators/#code-generation) in your schema: typed env accessors like `@generateTsTypes` (type declarations) and `@generatePythonEnv` (a loadable module), plus any generators contributed by plugins. Uses only your schema definitions, so output is deterministic regardless of which environment is active. Keys (and decorators) that come only from value files like `.env` or `.env.local` are ignored. Only items declared in your `.env.schema` (or imported into it) are included. When run directly, the command reports any keys found only in a plain `.env` so you can move them into your schema if they belong there. Add a per-language decorator for each output file you want: `@generateTsTypes`, `@generatePythonEnv`, `@generateRustEnv`, `@generateGoEnv`, `@generatePhpEnv`, `@generateJavaEnv`, or `@generateCsharpEnv`. Each generates its own file. Each decorator can also take a `filter=` arg to scope its output to a subset of items, using the same selectors as the [`--filter` CLI flag](/reference/cli-commands/#filtering-items); see the [code generation reference](/reference/root-decorators/#code-generation). This command is particularly useful when you have set `auto=false` on a generator decorator to disable automatic generation during `varlock load` or `varlock run`. Note `varlock typegen` is a deprecated alias for `varlock codegen`. It still works but prints a warning. ```bash varlock codegen [options] ``` **Options:** * [Common options](/reference/cli-commands/#common-options): `--path` / `-p` **Examples:** ```bash # Generate using the default schema varlock codegen # Generate from a specific .env file varlock codegen --path .env.prod # Generate from multiple directories varlock codegen -p ./envs -p ./overrides ``` # Encryption commands > CLI reference for encrypt, reveal, lock, audit, and generate-key ## `varlock encrypt` [Section titled “varlock encrypt”](#encrypt) Encrypts sensitive values using device-local encryption. Encrypted values are stored in `.env` files using the `varlock()` resolver function and are automatically decrypted at load time. On macOS, encryption is hardware-backed via the Secure Enclave (with Touch ID / biometric authentication). On Windows, keys are TPM-sealed when a TPM is available (Windows Hello gates interactive decrypts). On Linux, TPM2 and/or Secret Service is used. A pure-JavaScript file-based fallback is available on all platforms. See the [local encryption guide](/guides/local-encryption/) for platform details. On Windows, existing DPAPI keys are automatically upgraded to TPM sealing on the next decrypt. ```bash varlock encrypt [options] ``` **Options:** * `--file`: Path to a `.env` file; encrypts all sensitive plaintext values in-place **Examples:** ```bash # Interactive mode: encrypt a single value (prompts with hidden input) varlock encrypt # Pipe a value via stdin (keeps secrets out of shell history) printf '%s' "$SECRET" | varlock encrypt varlock encrypt < secret.txt # Encrypt all sensitive plaintext values in a .env file varlock encrypt --file .env.local ``` In single-value mode, you’ll either be prompted to enter a value (hidden input) or the value will be read from stdin when piped. The encrypted output is printed for you to copy into your `.env.local` file: ```plaintext SOME_SENSITIVE_KEY=varlock("local:") ``` Tip Piping via stdin avoids exposing secrets in your shell history. Prefer `printf '%s'` over `echo` to avoid a trailing newline (varlock strips a single trailing newline either way). In file mode, varlock loads the env graph, identifies `@sensitive` items with plaintext values, and lets you select which to encrypt in-place. Tip Use `varlock encrypt --file .env.local` after adding new secrets to quickly encrypt them all at once. Alternative: prompt mode Instead of encrypting values ahead of time, you can use `varlock(prompt)` as a placeholder in your `.env` files. On first load, varlock will prompt you to enter the secret and automatically replace the placeholder with the encrypted value. See the [`varlock()` function reference](/reference/functions/#varlock) for details. ## `varlock reveal` [Section titled “varlock reveal”](#reveal) Securely view or copy the value of a `@sensitive` environment variable. The value is displayed in an alternate terminal screen buffer so it doesn’t persist in your scrollback history. 🔒 Usually sensitive values are redacted, so this is needed to actually view the value without exposing it in plaintext on disk or in your terminal history. ```bash varlock reveal [VAR_NAME] [options] ``` **Options:** * `--copy`: Copy the value to clipboard instead of displaying (auto-clears after 10s) * [Common options](/reference/cli-commands/#common-options): `--path` / `-p`, `--env` **Examples:** ```bash # Interactive picker to browse and reveal sensitive values varlock reveal # Reveal a specific variable varlock reveal MY_SECRET # Copy a value to clipboard (auto-clears after 10s) varlock reveal MY_SECRET --copy ``` Note Non-sensitive values are not shown by `varlock reveal`. Use [`varlock printenv`](/reference/cli/load-and-run/#printenv) for non-sensitive values, or to inject a sensitive value into a command. Clipboard support on Linux `varlock reveal --copy` uses your system clipboard command (`xclip` or `xsel`) on Linux. Install one of them if copy mode is unavailable. ## `varlock lock` [Section titled “varlock lock”](#lock) Locks the encryption daemon, requiring biometric authentication (e.g., Touch ID) for the next decrypt operation. This invalidates the current biometric session cache. ```bash varlock lock ``` This command only has an effect when using a biometric-enabled encryption backend (macOS Secure Enclave, Windows Hello, or Linux with polkit/PAM biometric setup). On other backends, it will display a message and exit. Tip Use `varlock lock` when stepping away from your machine to ensure the next person to decrypt a secret must authenticate biometrically. ## `varlock audit` [Section titled “varlock audit”](#audit) Scans your source code for environment variable references and compares them against keys defined in your schema. This command reports two drift categories: * **Missing in schema**: key is used in code but not declared in schema * **Unused in schema**: key is declared in schema but not referenced in code Pure execution-environment plumbing, meaning variables that reflect *where/how* the process runs (e.g. `PATH`, `HOME`, `SHELL`, `NODE_OPTIONS`, `npm_*`), is read from `process.env` in normal code but is never part of your schema, so it is **not** reported as missing. Semantically meaningful variables your app or CI may depend on (e.g. `NODE_ENV`, `CI`, GitHub Actions vars) are still reported, so you can decide whether to declare them or suppress them with [`@auditIgnore`](/reference/item-decorators/#auditignore). Exit codes: * `0` when schema and code are in sync * `1` when drift is detected ```bash varlock audit [paths...] [options] ``` **Positional arguments:** * `[paths...]`: Optional list of directories to scan. When provided, only these directories are scanned instead of the auto-detected scan root. Naming a directory that your exclusions cover is an error, since the two instructions contradict each other. **Options:** * [Common options](/reference/cli-commands/#common-options): `--path` / `-p` (here it sets the **schema** entry point, single path only) * `--ignore` / `-i`: Directory to exclude from code scanning (can be specified multiple times). Accepts the same forms as [`@auditIgnorePaths()`](/reference/root-decorators/#auditignorepaths), which it is merged with: a bare name matches wherever it appears, while `./`, `../`, `~/` and absolute entries are paths to one specific directory. **Examples:** ```bash # Audit current project varlock audit # Audit using a specific .env file as schema entry point varlock audit --path .env.prod # Audit using a directory as schema entry point varlock audit --path ./config # Only scan specific directories varlock audit ./src ./lib # Exclude every directory named fixtures varlock audit --ignore fixtures # Exclude one specific directory, as a path from the scan root varlock audit --ignore ./apps/docs # Exclude multiple directories varlock audit -i fixtures -i generated ``` Note When `--path` points to a directory, code scanning is scoped to that directory tree. When it points to a file, scanning is scoped to that file’s parent directory. Monorepos Code scanning does not descend into nested projects. Any subdirectory that contains its own `package.json` or `.env.schema` is treated as a separate package and skipped. This keeps a parent package’s audit from picking up env var references that belong to child packages, and works even in a fresh monorepo where the child packages haven’t run `varlock init` yet. Run `varlock audit` inside each package to audit it against its own schema. Suppressing false positives * Use [`@auditIgnore`](/reference/item-decorators/#auditignore) on individual schema items that are only consumed by external tools and won’t appear in your application code. * Use [`@auditIgnorePaths()`](/reference/root-decorators/#auditignorepaths) to exclude directories (e.g., vendored code, generated files) from the code scan. ### What gets scanned [Section titled “What gets scanned”](#what-gets-scanned) Files with these extensions: `.js`, `.mjs`, `.cjs`, `.jsx`, `.ts`, `.mts`, `.cts`, `.tsx`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.py`, `.go`, `.rb`, `.php`, `.rs`, `.java`, `.cs`. Anything else is skipped, as are files over 1MB. `.git`, `node_modules`, `dist`, `build`, `.next`, `vendor` and `.venv` are always skipped, on top of anything you exclude yourself. Within those files the scan recognizes the conventional ways of reading env vars: `process.env.KEY`, `import.meta.env.KEY`, `ENV.KEY` and destructuring for JS-likes, plus `os.environ["KEY"]` / `os.getenv()` and the equivalents in Go, Ruby, PHP, Rust, Java and C#. Code inside comments and inside unrelated string literals is ignored. Keys the scan doesn’t find If your code reaches env vars through a wrapper instead, such as `configService.get('KEY')`, those references are invisible to the scan and the keys get reported as unused. Add [`@auditExtraPatterns()`](/reference/root-decorators/#auditextrapatterns) to teach it your own access pattern, and pass `fileTypes=[...]` on the same call to cover file types the scan skips by default, such as Terraform or Helm values. ## `varlock generate-key` [Section titled “varlock generate-key”](#generate-key) Generates a random 256-bit encryption key for use with `_VARLOCK_ENV_KEY`. This key is used to encrypt the resolved env blob that gets baked into your build output on certain frameworks/platforms. ```bash varlock generate-key varlock generate-key --plain ``` **Flags:** * `--plain`: Print only the key (no surrounding help text). Useful for piping into platform CLIs, e.g. `varlock generate-key --plain | vercel env add _VARLOCK_ENV_KEY production --sensitive`. See the [encrypted deployments guide](/guides/encrypted-deployments/) and the [Next.js](/integrations/nextjs/#encrypting-the-env-blob) / [Vite](/integrations/vite/#encrypting-the-env-blob) integration docs for setup instructions. # Load and run > CLI reference for varlock load, run, printenv, and explain ## `varlock load` [Section titled “varlock load”](#load) Loads and validates environment variables according to your .env files, and prints the results. Default prints a nicely formatted, colorized summary of the results, but can also print out machine-readable formats. Useful for debugging locally, and in CI to print out a summary of env vars. ```bash varlock load [options] ``` **Options:** * `--format`: Format of output \[pretty|json|env|shell|json-full] * `--agent`: Agent-safe mode: defaults to JSON output and redacts sensitive values. Not compatible with `--format env` or `--format shell`. * `--compact`: Compact output (json-full: no indentation, env/shell: skip undefined values) * `--show-all`: Shows all items, not just failing ones, when validation is failing * `--include-internal`: Include [`@internal`](/reference/item-decorators/#internal) items in `--format json-full` output. Excluded by default (`json-full` is commonly consumed programmatically, e.g. by framework integrations, not just for local human inspection) - pass this for local debugging of a secret-zero credential. * [Common options](/reference/cli-commands/#common-options): `--env`, `--path` / `-p`, `--clear-cache`, `--skip-cache`, `--filter` **Examples:** ```bash # Load and validate environment variables varlock load # Load and validate for a specific environment (when not using @currentEnv in .env.schema) varlock load --env production # Output validation results in JSON format varlock load --format json # Output full serialized graph (including errors/configErrors fields) varlock load --format json-full # Compact output varlock load --format json-full --compact # Output as shell export statements (useful for direnv / eval) eval "$(varlock load --format shell)" # When validation is failing, will show all items, rather than just failing ones varlock load --show-all # Load from a specific .env file varlock load --path .env.prod # Load from a specific directory varlock load --path ./config/ # Load from multiple directories (later paths take higher precedence) varlock load -p ./envs -p ./overrides # Agent-safe JSON output with sensitive values redacted varlock load --agent # Only show STRIPE_* keys, excluding one varlock load --filter="STRIPE_*,!STRIPE_DEBUG_KEY" # Only show items marked @sensitive varlock load --filter="@sensitive" # Only show items tagged with @tag(billing) varlock load --filter="#billing" ``` **Exit codes:** * `0` when all config is valid * non-zero when validation fails (missing required values, failed coercion, or resolver errors) ### Output formats (for scripts & agents) [Section titled “Output formats (for scripts & agents)”](#output-formats-for-scripts--agents) * `--format json`: a flat `{ "KEY": value }` map of resolved values on stdout * `--agent`: the same flat map, but `@sensitive` values are redacted (e.g. `"su▒▒▒▒▒"`), so it is safe to print in logs or agent transcripts. Implies JSON output; not compatible with `--format env`/`shell`. Combine with `--agent --format json-full` to get the redacted full graph. * `--format json-full`: the full serialized graph: top-level `basePath`, `sources`, `config` (per-item metadata including resolved value, validation state, and sensitivity), `settings`. Use this when you need per-item validation/error detail rather than just values. ⚠️ This includes **raw resolved secret values**, so add `--agent` (`--agent --format json-full`) to redact them before logging or feeding to an agent. [`@internal`](/reference/item-decorators/#internal) items are excluded unless you pass `--include-internal`. * `--format env` / `--format shell`: dotenv lines / shell `export` statements with **raw** values. Never pipe these somewhere that gets logged when secrets are involved. Items that resolve to undefined are emitted as `KEY=` lines in env format (which round-trip to undefined when re-read by varlock), but are skipped in shell format, since `export KEY=` would set an empty string instead. Set [`@injectUndefinedAsEmpty`](/reference/root-decorators/#injectundefinedasempty) to get empty-string exports for them. When emitting machine-readable output, add `--summary-stderr` (or `--summary-file`) to get a human-readable, redacted summary on **stderr** while keeping clean JSON on **stdout**. This is handy for agents and CI. Caution Setting `@currentEnv` in your `.env.schema` will override the `--env` flag. ## `varlock run` [Section titled “varlock run”](#run) Executes a command in a child process, injecting your resolved and validated environment variables from your .env files. This is useful when a code-level integration is not possible. ```bash varlock run -- ``` **Exit codes:** `varlock run` exits with the child command’s exit code, so it is transparent in scripts and CI. If varlock itself fails before the command starts (e.g. invalid config or a failed resolver), it exits non-zero without running the command. **Options:** * `--redact-stdout` / `--no-redact-stdout`: Override automatic stdout/stderr redaction. `--redact-stdout` forces redaction of piped/redirected output (e.g., to override `@redactLogs=false`) and errors if output is attached to an interactive terminal, where redaction is not possible without breaking TTY behavior. `--no-redact-stdout` disables redaction entirely. * `--inject ` / `-i`: Control what gets injected into the child process environment: `all` (default: individual vars plus the `__VARLOCK_ENV` serialized config graph blob), `vars` (individual vars only, no blob), or `blob` (only the `__VARLOCK_ENV` blob, no individual vars) * `--include-internal`: Pass [`@internal`](/reference/item-decorators/#internal) items through to the child process. By default they are stripped from the child env, even if set in the ambient environment, so a secret-zero token never reaches your app. Use this for a *nested* `varlock run` whose own resolution needs the internal value. * [Common options](/reference/cli-commands/#common-options): `--path` / `-p`, `--clear-cache`, `--skip-cache`, `--filter` **Examples:** ```bash varlock run -- node app.js # Run a Node.js application varlock run -- python script.py # Run a Python script # Use a specific .env file as entry point varlock run --path .env.prod -- node app.js # Use a specific directory as entry point varlock run --path ./config/ -- node app.js # Use multiple directories as entry points varlock run -p ./envs -p ./overrides -- node app.js # Only inject STRIPE_* keys, excluding one (also strips them from the __VARLOCK_ENV blob) varlock run --filter="STRIPE_*,!STRIPE_DEBUG_KEY" -- node app.js ``` Shell expansion of env vars in commands Because of the way that shell expansion works, you may need to use use `sh -c` to properly expand environment variables in your command *after* varlock has injected them. ```bash varlock run -- echo $MY_VAR # ❌ will not work varlock run -- sh -c 'echo $MY_VAR' # ✅ will work ``` Interactive tools and TTY detection `varlock run` automatically detects where each output stream is going: * **Interactive terminal (TTY)**: output passes straight through, preserving raw TTY behavior. Interactive tools like `psql` and `claude` work with no flags needed. A human at the terminal already has access to the secrets, so redaction adds little there. * **Piped or redirected output** (CI logs, files, `| tee`, etc.): output is piped through a redaction filter, since that’s where leaked secrets would persist. Detection is per stream. For example `varlock run -- node app.js | tee log.txt` redacts stdout (piped) while stderr (still attached to your terminal) passes through. To override the auto-detection: ```bash # force-disable redaction even when output is piped/redirected varlock run --no-redact-stdout -- node app.js | tee log.txt # force redaction of piped output even when @redactLogs=false is set varlock run --redact-stdout -- node app.js > log.txt ``` Redacting a TTY-attached stream is not possible without piping it (which would break interactive tools), so `--redact-stdout` errors if output is attached to an interactive terminal rather than silently degrading the experience. The same override can be set via the [`_VARLOCK_REDACT_STDOUT`](/reference/reserved-variables/#_varlock_redact_stdout) environment variable (`true`/`1` to force on, `false`/`0` to force off), which is handy when you can’t easily change the command being run, for example in a wrapper script or CI config. The CLI flag always takes precedence over the env var. Preventing sensitive value exposure via env inspection By default, `varlock run` injects a `__VARLOCK_ENV` variable containing the full serialized config graph (including resolved sensitive values) into the child process environment. This is used by framework integrations to obtain sensitivity metadata without re-invoking the CLI. For long-lived processes, interactive shells, or any workflow where subprocesses may inspect their environment (e.g., `env`, `printenv`, or an LLM-driven agent), use `--inject vars` to inject only the individual vars and omit this blob: ```bash varlock run --inject vars -- bash varlock run --inject vars -- claude ``` Note: When the `__VARLOCK_ENV` blob is omitted, framework integrations that rely on it will need to fall back to re-invoking the CLI. `__VARLOCK_RUN=1` is always set regardless of this flag. Signals and exit codes `varlock run` stays resident while your command runs, but it gets out of the way of process termination: * Terminating signals (`SIGTERM`, `SIGINT`, `SIGHUP`, `SIGQUIT`) are **forwarded** to the child so it can run its own shutdown handlers, rather than being killed abruptly. When no terminal is attached (containers, CI, background/agent runs), the child runs in its own process group and the signal is forwarded to the whole group, so grandchildren are terminated too. * The child’s exit status is **propagated faithfully**: a normal exit code passes through unchanged, and a child terminated by signal N exits with `128+N` (e.g. `143` for `SIGTERM`), matching shell conventions. This makes `varlock run` safe to use as a container `ENTRYPOINT` (including PID 1), so `docker stop` / pod termination reaches your app gracefully instead of waiting out the grace period and being `SIGKILL`ed. By default varlock forwards and then waits indefinitely for the child. It does **not** impose its own kill deadline, leaving that decision to the orchestrator or operator (a forwarded signal isn’t always terminal, since many programs use `SIGHUP` to reload). If you want varlock to escalate to `SIGKILL` when the child hasn’t exited within a grace period, set the `_VARLOCK_FORCE_KILL_TIMEOUT_MS` environment variable to a number of milliseconds. Because the parent stays alive, container memory is roughly **CLI process + child process**. For the leanest footprint when you only need env injection at boot (no long-lived parent), resolve once and replace the shell with your app: `eval "$(varlock load --format shell --compact)" && exec node dist/server.js`. ## `varlock printenv` [Section titled “varlock printenv”](#printenv) Resolves and prints the value of a single environment variable to stdout. Only the requested item and its transitive dependencies are resolved, making this faster than loading the full graph. This is useful within larger shell commands where you need to embed a single resolved env var value. **Exit codes:** `0` when the value resolves; non-zero when no variable name is given, the variable is not in the schema, or it fails to resolve. ```bash varlock printenv [options] ``` **Positional arguments:** * ``: The variable to print. Required unless `--template` is used; printenv errors if both are omitted, or if both are given. **Options:** * `--template` / `-t`: Print a rendered string template instead of a single value. `{{KEY}}` placeholders are replaced with resolved values, and a template can reference multiple variables. * `--escape `: Escape substituted values for the given language (only valid with `--template`). Currently supports `json`, which escapes each value for embedding inside a JSON string literal. Only the value’s content is escaped (no quotes are added), so placeholders used as bare values (like numbers) pass through unchanged. * [Common options](/reference/cli-commands/#common-options): `--path` / `-p`, `--clear-cache`, `--skip-cache` **Examples:** ```bash # Print the resolved value of MY_VAR varlock printenv MY_VAR # Use a specific .env file as entry point varlock printenv --path .env.prod MY_VAR # Use multiple directories as entry points varlock printenv -p ./envs -p ./overrides MY_VAR # Embed in a shell command using subshell expansion sh -c 'some-tool --token $(varlock printenv MY_TOKEN)' # Render a template referencing one or more variables varlock printenv --template '{"Authorization": "Bearer {{MY_TOKEN}}"}' --escape json ``` Why not use `varlock run -- echo $MY_VAR`? Shell expansion happens *before* varlock runs, so `$MY_VAR` is substituted by the shell with whatever value it already has (likely empty). `varlock printenv` avoids this by printing the value directly to stdout, letting you capture it with `$(...)` *after* varlock has resolved it. ```bash varlock run -- echo $MY_VAR # ❌ shell expands $MY_VAR before varlock runs varlock printenv MY_VAR # ✅ varlock resolves and prints the value sh -c 'echo $(varlock printenv MY_VAR)' # ✅ embed in a larger command ``` ## `varlock explain` [Section titled “varlock explain”](#explain) Shows detailed information about how a single config item is resolved: all of its definitions, sources, overrides, and the final resolved value. This is the go-to command for debugging *why* a value is what it is (and an agent-friendly way to inspect resolution without dumping every value). Sensitive values are redacted in the output. **Exit codes:** `0` when the item is found and explained; non-zero when no key is given or the key is not in the schema. ```bash varlock explain [options] ``` **Positional arguments:** * ``: The config item to explain. Required; explain errors if omitted. **Options:** * [Common options](/reference/cli-commands/#common-options): `--env`, `--path` / `-p` **Examples:** ```bash # Explain how a value is resolved varlock explain DATABASE_URL # Explain in the context of a specific environment varlock explain --env production API_KEY ``` # Project commands > CLI reference for init, scan, install-plugin, flatten, telemetry, and help ## `varlock init` [Section titled “varlock init”](#init) Starts an interactive onboarding process to help you get started. Will help create your `.env.schema` and install varlock as a dependency if necessary. ```bash varlock init [options] ``` **Options:** * `--agent`: Run non-interactively for agent/automation workflows. Skips confirmation prompts and uses deterministic defaults for schema generation. **Examples:** ```bash # Interactive setup wizard varlock init # Non-interactive setup for AI agents varlock init --agent ``` Tip Install the Varlock agent skill with `npx skills add dmno-dev/varlock` or `gh skill install dmno-dev/varlock varlock`. See the [AI Tools guide](/guides/ai-tools/#install-the-varlock-skill) for more. ## `varlock scan` [Section titled “varlock scan”](#scan) Scans your project files for sensitive config values that should not appear in plaintext. Loads your varlock config, resolves all `@sensitive` values, then checks files for any occurrences of those values. This is especially useful as a **pre-commit git hook** to prevent accidentally committing secrets into version control, and for **scanning build output** to ensure no secrets leaked into files that will be published or deployed. **Exit codes:** `0` when no plaintext secrets are found; `1` when a leaked value is detected. This makes it usable directly as a pre-commit hook or CI gate. ```bash varlock scan [paths...] [options] ``` **Positional arguments:** * `[paths...]`: Optional list of file paths, directories, or glob patterns to scan. When provided, only these targets are scanned: git filtering (`--staged`, `--include-ignored`) is bypassed and build-output directories that are normally skipped (such as `dist`, `.next`, `build`) are included. **Options:** * `--staged`: Only scan staged git files (ignored when explicit paths are provided) * `--include-ignored`: Include git-ignored files in the scan (ignored when explicit paths are provided) * `--install-hook`: Set up `varlock scan` as a git pre-commit hook * [Common options](/reference/cli-commands/#common-options): `--path` / `-p` (here it sets the **schema** entry point used to resolve sensitive values) **Examples:** ```bash # Scan all non-gitignored files in the current directory varlock scan # Only scan staged git files varlock scan --staged # Scan all files, including gitignored ones varlock scan --include-ignored # Scan a specific build output directory (e.g. to check for leaked secrets before publishing) varlock scan ./dist # Scan multiple directories varlock scan ./dist ./public # Scan files matching a glob pattern varlock scan './dist/**/*.js' # Use a specific .env file as the schema entry point varlock scan --path .env.prod # Use multiple schema entry points varlock scan -p ./envs -p ./overrides # Set up as a git pre-commit hook varlock scan --install-hook ``` Git pre-commit hook The easiest way to set up scanning as a pre-commit hook is to run: ```bash varlock scan --install-hook ``` This will detect if you are using a hook manager (like husky or lefthook) and provide the appropriate setup instructions. Otherwise, it will create a `.git/hooks/pre-commit` hook for you automatically. If varlock is installed as a project dependency, the hook command will be automatically prefixed with your package manager (e.g., `npx varlock scan`). You can also set it up manually. See the [Secrets guide](/guides/secrets/#scanning-files-for-leaked-secrets) for more details. ## `varlock install-plugin` [Section titled “varlock install-plugin”](#install-plugin) Pre-downloads a plugin from npm into the local varlock plugin cache so it is available without an interactive confirmation prompt. This is mainly for the **standalone binary** in CI or other non-interactive environments. When varlock runs as a package.json dependency, plugins resolve through your normal node\_modules instead. The plugin must be specified with an exact version (`name@version`). ```bash varlock install-plugin ``` **Positional arguments:** * ``: The plugin to install, with an exact version (e.g. `my-plugin@1.2.3`). **Examples:** ```bash # Install a plugin at an exact version varlock install-plugin my-plugin@1.2.3 # Scoped package varlock install-plugin @my-scope/my-plugin@2.0.0 ``` ## `varlock flatten` [Section titled “varlock flatten”](#flatten) Copies every env file reachable via [`@import()`](/guides/import/) into one self-contained directory and rewrites the `@import` paths. Use it when only part of a monorepo is available at runtime, most commonly the final stage of a Docker build. See [the Docker guide](/integrations/docker/#monorepos-and-partial-build-context) for the full workflow. By default, values are never resolved and plugins are never executed: this is a purely structural transform, safe to run in CI without secrets. (`--vendor-plugins` copies plugin code into the output, but still never resolves values.) Details of the transform: * Files imported from outside the package are mirrored under `.env-imports/` inside the output directory. Their layout is preserved relative to the deepest directory that contains the package and everything being copied, so relative imports between copied files keep working. Any import path varlock can resolve is flattened: relative, `/`-rooted absolute, and `~/` home-directory. There is no repo or workspace boundary. Windows-native paths are [not supported as import paths anywhere in varlock](/guides/import/#import-source-types), so flatten leaves those untouched and warns. * Imports that are conditionally disabled (via `enabled=`) are still copied, with the condition preserved, since a different environment may enable them at runtime. * `.env.local` and `.env.[env].local` files are skipped by default (use `--include-local` to include them). * `@plugin()` declarations in copied files get their versions pinned to whatever is currently installed, so varlock can [auto-install them](/guides/plugins/) where the original package’s `node_modules` is not available. Plugins declared via a local path are copied into the output. * With `--vendor-plugins`, npm `@plugin()` packages are copied into `.env-plugins/` (from your `node_modules`, downloading only any that are not installed) and the declarations are rewritten to local paths, so the output resolves with no runtime install. This is the way to run plugins in shell-less, offline, or distroless images. See [plugins in containers](/integrations/docker/#distroless-and-shell-less-base-images). * Imports that do not exist on disk still get their paths rewritten, but nothing is copied. You get a warning unless the import is marked `allowMissing=true`. * Copied files that are not part of the project are flagged with a warning: ones from outside the git repo, and gitignored ones. They are still copied, but they will not be present for anyone else running the same build. Files that git tracks are never flagged, so force-added files (the common `.env*` ignored plus `git add -f .env.schema` setup) stay quiet. The gitignore check needs the `git` binary and is skipped if it is unavailable. ```bash varlock flatten [options] ``` **Options:** * `--out-dir `: Output directory, relative to the current directory (default `.env-flat`) * `--include-local`: Include `.env.local` / `.env.*.local` files (excluded by default) * `--vendor-plugins`: Copy npm plugins into the output so no runtime install is needed. Uses the copy in your `node_modules`, downloading only any that are not installed **Examples:** ```bash # Flatten env files from the current directory into .env-flat/ varlock flatten # Custom output location varlock flatten --out-dir dist/env # Also vendor plugins for a self-contained, distroless-ready output varlock flatten --vendor-plugins ``` The output directory is a generated artifact: add it to `.gitignore`, and rerun `flatten` whenever your env files change. ## `varlock telemetry` [Section titled “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. ```bash varlock telemetry disable varlock telemetry enable ``` Note You can also temporarily opt out by setting the `VARLOCK_TELEMETRY_DISABLED` or `DO_NOT_TRACK` environment variable. See the [Telemetry guide](/guides/telemetry/) for more information about our analytics and privacy practices. ## `varlock help` [Section titled “varlock help”](#help) Displays general help information, alias for `varlock --help` ```bash varlock help ``` For help about specific commands, use: ```bash varlock subcommand --help ``` # Proxy command > CLI reference for varlock proxy ## `varlock proxy` [Section titled “varlock proxy”](#proxy) Manages [credential proxy](/guides/proxy/) sessions: running an untrusted child process so it only sees placeholder secrets while real values are injected at the network boundary. Route secrets by adding [`@proxy(domain=...)`](/reference/item-decorators/#proxy) to the items you want to protect. ```bash varlock proxy [options] ``` Every subcommand operates on a [session](/guides/proxy/running/#sessions). Target one with `--session `, or let it auto-resolve: `run` attaches to the daemon for the current directory (else starts its own), and the other subcommands use the single active session (asking for `--session` if more than one is running). **Subcommands:** * `run -- `: Start a proxy, run `` through it, and tear down on exit. Attaches to a running `proxy start` session for this directory if one exists; otherwise runs self-contained. With `--url` it instead runs through a proxy on **another machine** (a broker started with `--expose`), reached over the built-in WebSocket tunnel, self-wiring the placeholder env and CA certs from the broker. See the [E2B guide](/sandboxes/e2b/). * `start`: Start a long-lived proxy session that owns the terminal (a live request log appears here). Stop with `Ctrl+C`. * `rules`: Print a static summary of the effective `@proxy` configuration: the rules (host/path/method, block) and each secret’s mode (proxied / placeholder / passthrough / omit), without starting a proxy. * `env`: Print the proxy + CA environment for a session, to source into another shell (`eval "$(varlock proxy env)"`). Add `--full` to emit the whole child-view env a proxied agent runs with (placeholders for secrets, real values for non-secrets), with `--proxy-url` / `--cert-dir` to repoint it for a remote sandbox. * `token`: Print a session’s data-plane token, for handing to `proxy run --url`. Its own verb because the token is a credential: it is never included in `status` or `env` output, and the startup banner withholds it unless stdout is a terminal. * `status`: List active proxy sessions. * `audit`: Print a session’s request audit log (no secret values). * `reload`: Re-resolve the schema and swap a running proxy’s live policy without restarting, after an intentional schema edit. Must be run from a **trusted terminal**: a reload requested from inside the proxied agent is refused and logged. Requires the proxy’s reload posture to allow it (see `--allow-reload` / [`@proxyConfig={reload=...}`](/reference/root-decorators/#proxyconfig)); otherwise restart the proxy to apply schema changes. * `stop`: Stop a session. **Options:** * `--session `: Target a specific session by id. * `--new`: For `run`, force a fresh proxy instead of attaching to a running one. * `--port `: For `start`/`run` (when it starts a proxy), bind a fixed loopback port instead of a random one, so you can point tools at a known `HTTP_PROXY` before the proxy starts. Refuses to start if the port is already in use. * `--cert-dir `: For `start`/`run` (when it starts a proxy), write the CA cert (`ca-cert.pem` + `combined-ca.pem`) into a known directory instead of a temp one, so tools can trust it at a fixed path. Created if missing; only the cert files are removed on stop. * `--persist-ca`: For `start`/`run`, keep the CA in `--cert-dir` (including `ca-key.pem`, mode 0600) and reuse it on the next start, so a restart does not invalidate clients that already trust it. Requires `--cert-dir`. Intended for long-lived brokers: the CA private key normally never touches disk, so only use this where the proxy runs alone (not alongside the agent it proxies). A persisted CA is valid for 10 years (effectively for the life of the broker), since any expiry would break agents still running when it hits; to retire one, delete the cert directory and restart. * `--expose` (optionally `--expose=`): For `start`/`run`, make the proxy reachable from another machine: it binds off-loopback (bare `--expose` = `0.0.0.0`; `--expose=` picks an interface) and serves the built-in WebSocket tunnel for clients behind HTTP-only ingress. Mints a per-session data-plane token (pin it with `VARLOCK_PROXY_TOKEN`) that off-loopback clients must present; loopback clients stay exempt and the control endpoint stays loopback-only. * `--url ` + `--token `: For `run`, target a proxy on another machine (a broker started with `--expose`) over the tunnel, instead of a local session. Prefer passing the token as `VARLOCK_PROXY_TOKEN` rather than `--token`, so it stays out of process listings and shell history. The local-proxy flags (`--sandbox`, `--port`, `--cert-dir`, `--persist-ca`, `--expose`) don’t apply with `--url`. * `--token` also pins `start`’s minted token to a known value (via `VARLOCK_PROXY_TOKEN`), so an orchestrator can hand the same token to the broker and its agents. * `--all`: For `status`/`stop`, include all sessions (`status` also shows ended sessions). * `--allow-reload` / `--no-allow-reload`: For `start`/`run`, override the reload posture. `--allow-reload` forces `manual` (human-applied from a trusted terminal; agent-context reloads refused), `--no-allow-reload` forces `off`. Otherwise [`@proxyConfig={reload=...}`](/reference/root-decorators/#proxyconfig) applies, defaulting to `auto` (manual for an interactive `proxy start`, off for headless or one-shot `proxy run`). On a shared uid this is a bar-raiser, not a hard boundary, so prefer a sandbox. * `--inject `: For `run`, control what is injected into the child env (default `all`). * `--redact-stdout` / `--no-redact-stdout`: For `run`, override the automatic per-stream redaction. By default output is redacted only when piped or redirected; streams attached to an interactive terminal pass through as a raw TTY, so interactive tools like `claude` work. `--no-redact-stdout` disables redaction entirely; `--redact-stdout` forces it for piped output (and errors on a TTY). Also settable via `_VARLOCK_REDACT_STDOUT`. * `--watch`: For `status`, continuously refresh. * `--format `: Output format for `audit` (text/json) and `env` (shell/json). **Examples:** ```bash # Run an agent through the proxy varlock proxy run -- claude # Daemon in one terminal, attach from another varlock proxy start varlock proxy run -- node agent.js # Inspect activity varlock proxy status varlock proxy audit --format json # Reach a broker proxy from another machine / a remote sandbox (see the E2B guide) varlock proxy start --expose varlock proxy token # read the minted token to hand to clients VARLOCK_PROXY_TOKEN=… varlock proxy run --url wss://8080-abc.e2b.app -- claude ``` See the [credential proxy guide](/guides/proxy/) for the full workflow. # About @env-spec > Understanding the env-spec specification and how varlock implements it @env-spec is a DSL that extends normal `.env` syntax. It allows adding structured metadata using `@decorator` style comments (similar to [JSDoc](https://jsdoc.app/)) and a syntax for setting values via explicit function calls. This lets us express a declarative schema of our environment variables in a familiar format, not tied to any specific programming language or framework. ### A short example: [Section titled “A short example:”](#a-short-example) .env.schema ```env-spec # Stripe secret api key # @required @sensitive @type=string(startsWith="sk_") # @docs(https://docs.stripe.com/keys) STRIPE_SECRET_KEY=varlock(local:abc123...) ``` ### Why is this useful? [Section titled “Why is this useful?”](#why-is-this-useful) Loading a schema file full of structured metadata gives us: * additional validation, coercion, type-safety for your env vars * extra guard-rails around handling of `@sensitive` data * more flexible loading logic without hand-rolled application code or config files * a place to store default values, clearly differentiated from placeholders This schema information is most valuable when it is **shared across team members** and machines. So in most cases, this means creating a git-committed `.env.schema` file, instead of the familiar `.env.example` file used by many projects. The difference is that now the schema can be used on an ongoing basis, instead of just once to create an untracked local copy. Building on this, you could use additional files which set values. They could add additional items or override properties of existing ones. Whether you want to use a single git-ignored `.env` file, or apply a cascade of environment-specific files (e.g., `.env`, `.env.local`, `.env.test`, etc) is up to you. However the new ability to use function calls to safely decrypt data, or load values from external sources, means you’ll likely be tempted to use git-committed `.env` files much more. An env-spec enabled tool would load all env files appropriately, merging together both schema and values, as well as additional values read from the shell/process. Then the schema would be applied which could transform and fill values, for example decrypting or fetching from an external source, as well as applying coercion and validation. Backwards compatibility This is designed to be mostly backwards compatible with traditional .env files. However, as there is no standard .env spec and various tools have different rules and features, we made some decisions to try to standardize things. Our tools may support additional compatibility flags if users want to opt in/out of specific behaviours that match other legacy tools. The extended feature set means an env-spec enabled parser will successfully parse env files that other tools may not. ### What is included in env-spec? [Section titled “What is included in env-spec?”](#what-is-included-in-env-spec) This package defines a parser and related tools for parsing an @env-spec enabled .env file. It does not provide anything past this parsing step, such as actually loading environment variables. ### Why did we create this? [Section titled “Why did we create this?”](#why-did-we-create-this) We previously created DMNO and saw immense value in this schema-driven approach to configuration. With env-spec, we wanted to provide a standard that could benefit anyone who uses .env files (and even those who don’t!). There’s an incredible ecosystem of libraries and tools that have adopted .env, and we want to make it easier for everyone to benefit from additional guardrails, with as little upfront work as possible. We’ve also seen the explosion of AI-assisted coding tools which means that users are even more likely to leak sensitive configuration items, like API keys. If we can help to improve the security posture for these users, then hopefully that improves things for everyone. How can I help? If you’re a maintainer, author, contributor, or an opinionated user of tools that rely on .env files, please read through our RFC. We are not trying to build in a vacuum and we want your input. We’d also love your feedback on varlock which is built on top of @env-spec since it provides (we hope!) a solid reference implementation. *If this resonates with you, please reach out. We welcome your feedback and we welcome additional contributors.* *** # @env-spec Reference > Reference docs and details for @env-spec Tip In this spec, we don’t make any assumptions about the meaning of specific decorators, or function calls. This document just deals with how the syntax itself is parsed and structured. ## Config Items [Section titled “Config Items”](#config-items) Config items define individual env vars. * Each has a key, an optional value, and optional attached comments * Keys must start with `[a-ZA-Z_]`, followed by any of `[a-ZA-Z0-9_]` — ✅ `SOME_ITEM`, ❌ `BAD-KEY`, ❌ `2BAD_KEY` * Setting no value is allowed and will be treated as `undefined` — `UNDEF_VAR=` * note that no value (`ITEM=`) is treated slightly differently than an explicit `ITEM=undefined` when combining multiple definitions * An explicit empty string is allowed — `EMPTY_STRING_VAR=""` * Single-line values may be wrapped in quotes or not, and will follow the common value-handling rules (see below) * Multi-line string values may be wrapped in either `( ' | " | """ | ``` )` - but we **strongly** recommend using triple backticks only for consistency ````env-spec NO_VALUE= EXPLICIT_UNDEFINED=undefined EMPTY_STRING="" UNQUOTED=asdf QUOTED="asdf" FUNCTION_CALL=fn(foo, "bar") MULTILINE_STRING=``` multiple lines ``` ```` ## Comments and @decorators [Section titled “Comments and @decorators”](#comments-and-decorators) Comments in env-spec (like traditional .env files) start with a `#`. Unlike traditional .env files, comments may contain additional metadata by using `@decorators`, which may be attached to specific config items, sections, or the entire document. * Comments can be either on their own line, or at the end of a line after something else * Leading whitespace after the `#` is optional, but a single space is recommended * If a comment line starts with a @decorator, it will be considered a *decorator comment line* * Otherwise it is a *regular comment line* and any contained @decorators will be ignored * A decorator comment line may contain multiple decorators * A decorator comment line may end with an additional comment, in which decorators will be ignored * A post-value comment may also contain decorators, but is not recommended ```env-spec # ❌ leading space makes this invalid # this is a regular comment line # @dec2 @dec2=foo # this is a decorator comment line FOO=val # this is a post-value comment BAR=val # @dec # post-value comments may also contain decorators # regular comment lines @ignore contained @decorators # @dec # as are @decorators within an extra comment after a decorator comment line BAZ= # @dec # this is @ignored too ``` ### Decorators [Section titled “Decorators”](#decorators) Decorators are used within comments to attach structured data to specific config items, or within a standalone comment block to alter a group of items or the entire document and loading process. * Each decorator has a name and optional value (`@name=value`) or is a bare function call `@func()` * The two forms carry a meaning: **bare function calls `@func(...)` may be used multiple times** (their args accumulate, e.g. multiple `@docs(...)` links), while **decorators with a value `@name=value` are single-use** * A decorator value may itself be a function call (`@name=func(args)`), an object literal (`@name={key=value}`), or an array literal (`@name=[a, b, c]`). These are all single-use values, not bare function calls * Using the name only is equivalent to setting the value to true — `@required` === `@required=true` * Multiple decorators may be specified on the same line * Decorator values will be parsed using the common value-handling rules (see below) .env.schema ```env-spec # @willBeTrue @willBeFalse=false @explicitTrue=true @undef=undefined @trueString="true" # @int=123 @float=123.456 @willBeString=123.456.789 # @doubleQuoted="with spaces" @singleQuote='hi' @backTickQuote=`hi` # @unquoted=this-works-too @withNewline="new\nline" # @funcCallNoArgs=func() @dec=funcCallArray(val1, "val2") @dec=funcCallObj(k1=v1, k2="v2") # @objectLiteral={k1=v1, k2="v2"} @arrayLiteral=[a, b, c] # standalone object/array values # @anotherOne # and some comments, this @decorator is ignored # this is a comment and this @decorator is ignored ``` ### Dividers [Section titled “Dividers”](#dividers) A divider is a comment that serves as a separator, like a `
` in HTML. * A comment starting with `---` or `===` is considered a divider — `# ---`, `# ===` * A *single* leading whitespace is optional (but recommended — `# ---`, `#---` ) * Anything after that is ignored and valid — `# --- some info`, `# ------------` ```env-spec # a divider can be used to visually separate sections # --- ITEM1= ITEM2= # --- another divider --- ITEM3= ``` ### Config Item Comments [Section titled “Config Item Comments”](#config-item-comments) Comment lines directly preceding an item will be attached to that item, along with the decorators contained within. * A blank line or a divider will break the above comments from being attached to the item below * Both decorator and regular comment lines may be interspersed * Standalone comments only count as decorator comments when the comment content starts with `@` * Post-value comments may also contain decorators, but should be used sparingly ```env-spec # these comments are attached to ITEM1 below # @dec1 @dec2 # meaning these decorators will affect the item # additional comments can be interspersed with decorators ITEM1= # @dec3 # and a post-value comment can be used too # not attached due to blank line # also not attached due to divider # --- ITEM2= ``` ### Comment blocks & document header [Section titled “Comment blocks & document header”](#comment-blocks--document-header) A comment block is a group of continuous comments that is not attached to a specific config item. * The comment block is ended by an empty line, a divider, or the end of the file * All comment blocks before the first config item in a document are considered part of the same *document header* until one of those endings appears * Decorators from this header can be used to configure all contained elements, or the loading process itself * We recommend ending the header with a divider for clarity, but `# ---` is optional ```env-spec # this is the document header and usually contains root decorators # which affect default settings and the behavior of the tool that will be parsing this file # @dec1 @dec2 # --- # this is another comment block # and is not attached to an item # this comment is attached to the item below ITEM1= ``` ## Common rules [Section titled “Common rules”](#common-rules) ### Value handling [Section titled “Value handling”](#value-handling) Values are interpreted similarly for config item values, decorator values, and values within function call arguments. Values may be wrapped in quotes or not, but handling varies slightly: #### Unquoted values [Section titled “Unquoted values”](#unquoted-values) * Will coerce `true`, `false`, `undefined` — `@foo=false` * Will coerce numeric values — `@int=123 @float=123.456` * if the number is too large, would lose precision, or would change formatting, it will remain a string * May be interpreted as a function call (see below) * Otherwise will be treated as a string * May not contain other characters depending on the context: * config item values - may not contain `#` * decorator values - may not contain `[ #]` * function call arg values - may not contain `[),]` #### Quoted values [Section titled “Quoted values”](#quoted-values) * A value in quotes is *always* treated as a string — `@d1="with spaces"`, `@trueString="true"`, `@numStr="123"` * All quote styles ``[`'"]`` are ok — ``@dq="c" @bt=`b` @sq='a'`` * Escaped quotes matching the wrapping quote style are ok — `@ok="escaped\"quote"` * Single quote wrapped strings do not support [expansion (see below)](#expansion) * In `"` or `` ` `` wrapped values, the string `\n` will be converted to an actual newline * Multi-line strings may be wrapped in `(```|"""|"|')` * only available for config item values, not decorators or within function args ### Regex literals [Section titled “Regex literals”](#regex-literals) Regex literals use JavaScript-style `/pattern/flags` syntax and can be used anywhere a value is expected, for example as function arguments or decorator option values. * The pattern is delimited by `/` characters * Forward slashes within the pattern must be escaped as `\/` * Optional flags (`g`, `i`, `m`, `s`, `u`, `y`) may follow the closing `/` ```env-spec ITEM=fn(/^dev.*/i, dev, "production", prod) # @type=string(matches=/^sk-[a-zA-Z0-9]+$/) API_KEY= ``` ### Function calls [Section titled “Function calls”](#function-calls) Function calls may be used for item values `ITEM=fn()`, decorator values `# @dec=fn()`, and bare decorator functions `# @func()`. In each case, much of the handling is the same. * a value must not be wrapped in quotes to be interpreted as a function call * function names must start with a letter, and can then contain letters, numbers, and underscores `/[a-ZA-Z][a-ZA-Z0-9_]*/` * you can pass no args, a single arg, or multiple args * you may also pass key value pairs at the end of the list * each value will be interpreted using common value-handling rules (see above) ```env-spec NO_ARGS=fn() SINGLE_ARG=fn(asdf) MULTIPLE_ARGS=fn(one, "two", three, 123.456) KEY_VALUE_ARGS=fn(key1=v1, key2="v2", key3=true) MIXED_ARGS=fn(item1, item2, key1=v1, key2="v2", key3=true) NOT_FN_CALL="fn()" # treated as string ``` ### Object & array literals [Section titled “Object & array literals”](#object--array-literals) Standalone objects and arrays use distinct brackets, keeping them unambiguous from function calls (which use `()`): * `{ key=value, ... }` is an **object**, where keys use `=` (as elsewhere in the spec), not `:` * `[ value, ... ]` is an **array** * they may be nested, and used as decorator values, function-call arguments, or standalone config item values * they are parsed using the common value-handling rules, and may span multiple lines (see [Multi-line literals](#multi-line-literals) below) * as a config item value, a literal implies the item’s type (or pair it with an explicit `@type=array(...)` / `@type=record(...)` for element validation - see [array](/reference/data-types/#array) and [record](/reference/data-types/#record)) * an item value starting with `{`/`[` that does not parse as a literal (e.g. a JSON string using `:` pairs) still falls back to a plain string ```env-spec # @dec={ key1=v1, key2="v2", nested={ a=1 } } # @dec=[a, b, c] # @dec=fn(opts={ retries=3 }, tags=[x, y]) ITEM= # @type=array(email) ALLOWED_EMAILS=[admin@example.com, support@example.com] # @type=record(url) ENDPOINTS={api=https://api.example.com, docs=https://docs.example.com} ``` This is why per-item options are written `@sensitive={preventLeaks=false}` rather than `@sensitive(...)`. The latter (a bare function call) is reserved for repeatable decorators. #### When to reach for a literal (and when not to) [Section titled “When to reach for a literal (and when not to)”](#when-to-reach-for-a-literal-and-when-not-to) Object/array literals earn their place only where there is **no function-call `()` already carrying the structure**. In practice that means two situations: 1. **The value of a non-function decorator**, e.g. `@sensitive={preventLeaks=false}`. These decorators can’t take bare `()` args (that syntax is reserved for repeatable decorators), so an object value is the only way to attach options. 2. **A single argument that is itself a nested object or list**, sitting alongside other args, e.g. `fn(retry={count=3, backoff=2})`. Do **not** wrap a function’s (or function-decorator’s) own named arguments in a literal. The parentheses already *are* the options bag. The following are correct as written, and the literal-wrapped versions are redundant: | Prefer | Avoid | | ----------------------------------------- | ------------------------------------------- | | `@type=string(minLength=3, maxLength=50)` | `@type=string({minLength=3, maxLength=50})` | | `@generateTsTypes(path=env.d.ts)` | `@generateTsTypes({path=env.d.ts})` | | `cache(fn, ttl=1h, key=k)` | `cache(fn, {ttl=1h, key=k})` | Also prefer the existing positional/comma forms for: * **ordered pairs**: `ifs(cond1, val1, cond2, val2, default)` and `remap($VAR, match1, result1, ...)` read like a spreadsheet `IFS()`/lookup and stay flat. Crucially, `remap` match keys may be **regexes or non-identifier strings**, which cannot be object keys (keys must match `/[a-zA-Z][a-zA-Z0-9_]*/`), so an object would be strictly less expressive. * **value lists**: `@type=enum(dev, staging, prod)` already reads as a list; `enum([...])` only adds brackets. #### Multi-line literals [Section titled “Multi-line literals”](#multi-line-literals) Object and array literals may span multiple lines, which is handy for long key lists (e.g. picking many keys to import). The continuation convention matches function calls and depends on where the literal lives: * **Inside a decorator** (which lives in a `#` comment), every continuation line must start with `#`: ```env-spec # @import(./.env.shared, pick=[ # DATABASE_URL, # REDIS_URL, # STRIPE_KEY, # ]) DATABASE_URL= ``` ```env-spec # @sensitive={ # preventLeaks=false, # } SECRET= ``` * **On an item value line**, where the literal is nested in a function call, plain newlines are used (no `#`): ```env-spec ALLOWED=fn([ dev, staging, prod, ]) ``` A trailing comma is allowed. If a `#`-prefixed continuation is omitted in a decorator, the bracket simply falls back to a plain string value and the following lines remain independent config items. They are never silently absorbed into the literal. Within a multi-line literal (or function call), `#` introduces a comment that runs to the end of the line, so entries can be commented out or annotated. This applies the same way to multi-line function-call arguments. ```env-spec # @import(./.env.shared, pick=[ # DATABASE_URL, # # REDIS_URL, ← commented out, skipped # STRIPE_KEY, # primary payment key # ]) DATABASE_URL= ``` ### String expansion [Section titled “String expansion”](#expansion) While the parser itself does not include any implemention of specific functions, it does handle *expansion* of strings - and it uses several function calls under the hood to do so. This means a few basic function calls, while not implemented, have specific inherent meaning and must be implemented similarly across all tools that support this spec. Expansion can be used within item values, decorator values, and function call arguments. *Note that single quote wrapped strings are NOT expanded.* * `$ITEM_NAME` -> `ref(ITEM_NAME)` * `${ITEM_NAME}` -> `ref(ITEM_NAME)` * `pre${ITEM_NAME}post` -> `concat("pre", ref(ITEM_NAME), "post")` * `${ITEM_NAME:-defaultval}` -> `fallback(ref(ITEM_NAME), "defaultval")` * `${ITEM_NAME-defaultval}` -> `fallback(ref(ITEM_NAME), "defaultval")` * `$(my-cli arg --arg2)` -> `exec("my-cli arg --arg2")` Keep it simple We recommend using expansion only for simple refs `$ITEM`/`${ITEM}` and skipping the rest. * Use bracketed version within a larger string - `fn("${ENV}_db")` * Skip the brackets otherwise - `fn($ENV)` The rest is implemented to match other popular tools, but we do not recommend using them, as intent can be more clearly expressed using function calls directly. # @env-spec VS Code extension > Syntax highlighting and tooling for @env-spec enabled .env files The @env-spec VS Code and Open VSX extensions provide language support for @env-spec enabled .env files. ## Installation [Section titled “Installation”](#installation) The extension is available on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=varlock.env-spec-language) and [Open VSX Registry](https://open-vsx.org/extension/varlock/env-spec-language) for those who use VS Code forks like Cursor and Windsurf. ## Features [Section titled “Features”](#features) * Syntax highlighting * IntelliSense for decorators, `@type` values, type options, resolver functions, and `$KEY` references * Enum value completion for item values below `@type=enum(...)` * Inline validation for invalid enum values, incompatible decorators, and obvious static `@type` mismatches * Hover info for common @decorators * Comment continuation - automatically continue comment blocks when you hit enter within one ## IntelliSense and diagnostics [Section titled “IntelliSense and diagnostics”](#intellisense-and-diagnostics) ### Decorators and built-in types [Section titled “Decorators and built-in types”](#decorators-and-built-in-types) The extension suggests decorators and built-in `@type=` values directly inside comment blocks. ![Decorator and type completion](/env-spec-vscode/autocomplete.gif) ### Type option completions [Section titled “Type option completions”](#type-option-completions) Built-in types surface context-aware option completions like `email(normalize=...)`, `ip(version=..., normalize=...)`, and `url(prependHttps=...)`. ![Type option completion](/env-spec-vscode/types.gif) ### Email-specific option completions [Section titled “Email-specific option completions”](#email-specific-option-completions) Type-specific completions also work for focused cases like `email(normalize=...)`, with boolean choice values suggested inline. ![Email option completion](/env-spec-vscode/email.gif) ### Enum value completions [Section titled “Enum value completions”](#enum-value-completions) When an item is declared as `@type=enum(...)`, the allowed values are suggested directly on the item value line below. ![Enum value completion](/env-spec-vscode/enum.gif) ### Variable references [Section titled “Variable references”](#variable-references) Typing `$` inside values and decorator expressions suggests config keys from the current file. ![Key reference completion](/env-spec-vscode/key.gif) ### Prefix-aware completions [Section titled “Prefix-aware completions”](#prefix-aware-completions) Decorator and validation workflows also support prefix-related configuration scenarios while editing schema comments. ![Prefix-related completion or validation](/env-spec-vscode/prefix.gif) ### Invalid decorator combinations [Section titled “Invalid decorator combinations”](#invalid-decorator-combinations) Autocomplete filters out incompatible decorators like `@required` and `@optional`, and inline diagnostics catch invalid combinations if they still appear in the file. ![Incompatible decorator diagnostics](/env-spec-vscode/exclusive_options.gif) ### Inline validation [Section titled “Inline validation”](#inline-validation) The extension also highlights obvious static validation issues, such as invalid enum values or incorrect `prependHttps` URL usage. ![Inline validation](/env-spec-vscode/prepend_https.gif) ## How to use this extension [Section titled “How to use this extension”](#how-to-use-this-extension) The new @env-spec language mode should be enabled automatically for any .env and .env.\* files, but you can always set it via the Language Mode selector in the bottom right of your editor. # AI Tools > Your schema gives AI agents full context, while your secrets never touch AI servers Your `.env.schema` gives AI agents full context on your configuration (variable names, types, validation rules, descriptions), while your secret values never leave your machine or touch AI servers. This solves two problems with AI-assisted development: 1. **Secret exposure**: AI tools read your project files, including `.env` files. With varlock, secrets are never stored in plain text. They’re fetched at runtime from secure providers. 2. **AI-generated leaks**: AI agents may hardcode secrets or log sensitive values in generated code. `varlock scan` catches these leaks before they’re committed, and runtime protection redacts secrets from logs and responses. ## Securely inject secrets into AI CLI tools [Section titled “Securely inject secrets into AI CLI tools”](#securely-inject-secrets-into-ai-cli-tools) Many AI coding assistants offer CLI tools that require API keys and other secrets. Instead of storing these secrets in plain text `.env` or `.json` files or exposing them in your shell history, use `varlock` to inject them securely at runtime. This applies both to config that might be required to bootstrap the tool itself, as well as things like [MCP servers](/guides/mcp/) that require API keys. ### 1. Install varlock [Section titled “1. Install varlock”](#1-install-varlock) If you haven’t already, [install varlock](/getting-started/installation/) on your system. ### 2. Create an environment schema [Section titled “2. Create an environment schema”](#2-create-an-environment-schema) Define your API keys and secrets in your `.env.schema` file. Mark sensitive values appropriately: .env.schema ```env-spec # @plugin(@varlock/1password-plugin) # @initOp(allowAppAuth=true) # --- # @sensitive @required OPENAI_API_KEY=op(op://api-local/openai/api-key) # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) # @sensitive @required KIMI_API_KEY=op(op://api-local/kimi/api-key) # @sensitive @required GOOGLE_API_KEY=op(op://api-local/google/api-key) ``` Tip You can use any secrets provider you want, but we like 1Password since it uses biometric authentication for local access. All the examples in this guide use the [1Password plugin](/plugins/1password/) (`op()`) to show more real world examples. See that page for plugin setup and auth options. Store the actual secret values in your preferred [secret provider](/plugins/overview/). The examples above use the [1Password plugin](/plugins/1password/)’s `op()` resolver, but any provider plugin works the same way ([AWS Secrets Manager](/plugins/aws-secrets/), [and more](/plugins/overview/)). If you’d rather not depend on an external provider, keep encrypted values in a gitignored `.env.local` using [device-local encryption](/guides/local-encryption/). See also the [Secrets guide](/guides/secrets/#loading-secrets-from-external-sources). ### 3. Run your tool via `varlock run` [Section titled “3. Run your tool via varlock run”](#3-run-your-tool-via-varlock-run) Execute your AI CLI tool through `varlock` to securely inject environment variables: ```bash varlock run -- ``` See the [tool-specific guides](#ai-cli-tool-guides) below for setup details. ## AI CLI tool guides [Section titled “AI CLI tool guides”](#ai-cli-tool-guides) Popular AI coding CLIs differ in which env vars they need and how they authenticate. Varlock supports two patterns: * **Project setup**: define secrets in the repo’s `.env.schema` (steps 1-2 above) and run `varlock run -- ` from the project directory. * **Personal setup**: keep a schema in your home directory (for example `~/.env.claude`) and pass it with [`-p`](/reference/cli/load-and-run/#run) when launching a tool from any directory (usually using an alias). Varlock does not auto-load a global schema (that would make two developers’ machines behave differently for the same repo). [Aider ](/guides/ai-tools/aider/)Git-aware terminal pair programmer with provider API keys [Antigravity CLI ](/guides/ai-tools/antigravity/)Run agy with validated env via varlock [Claude Code ](/guides/ai-tools/claude/)Inject ANTHROPIC\_API\_KEY with varlock run [Codex ](/guides/ai-tools/codex/)Inject OPENAI\_API\_KEY / CODEX\_API\_KEY for Codex CLI [Crush ](/guides/ai-tools/crush/)Charm's multi-provider terminal coding agent [Goose ](/guides/ai-tools/goose/)Block's MCP-native open-source agent [Kimi ](/guides/ai-tools/kimi/)Inject KIMI\_API\_KEY into Kimi Code CLI [Opencode ](/guides/ai-tools/opencode/)Provider-agnostic CLI with env-referenced API keys *** ## Install the Varlock skill [Section titled “Install the Varlock skill”](#install-the-varlock-skill) Give your AI agent project-scoped instructions for working with Varlock: security rules, schema checklists, and CLI guidance. Install using one of: * skills Install with the [skills CLI](https://github.com/vercel-labs/skills) (recommended): ```bash npx skills add dmno-dev/varlock ``` The CLI detects your installed agents and installs to the correct skills directory (Cursor, Claude Code, Codex, Copilot, OpenCode, and [50+ others](https://github.com/vercel-labs/skills#supported-agents)). Target a specific agent with `-a`: ```bash npx skills add dmno-dev/varlock -a cursor -a claude-code -y ``` Update later with: ```bash npx skills update varlock ``` * GitHub CLI Install with [`gh skill`](https://github.blog/changelog/2026-04-16-manage-agent-skills-with-github-cli/) (requires GitHub CLI v2.90+): ```bash gh skill install dmno-dev/varlock varlock ``` Target a specific agent and scope: ```bash gh skill install dmno-dev/varlock varlock --agent cursor --scope project ``` Update later with: ```bash gh skill update varlock ``` ## Agent mode [Section titled “Agent mode”](#agent-mode) Varlock provides a non-interactive `--agent` flag for `init` and `load`, designed for AI coding assistants running commands on your behalf. ### `varlock init --agent` [Section titled “varlock init --agent”](#varlock-init---agent) Use this when an AI agent is setting up Varlock in a project: ```bash varlock init --agent ``` In agent mode, init skips interactive confirmation prompts, uses deterministic defaults when multiple `.env.example` files are found, and prints schema review guidance for the agent to follow. ### `varlock load --agent` [Section titled “varlock load --agent”](#varlock-load---agent) Use this to validate resolved config without exposing secret values in logs or agent transcripts: ```bash varlock load --agent ``` Agent mode defaults to JSON output and redacts values marked `@sensitive`. It is not compatible with `--format env` or `--format shell` (which would expose raw values). Drop the `--agent` flag when you need human-readable output to show the user directly. ### Reading varlock output programmatically [Section titled “Reading varlock output programmatically”](#reading-varlock-output-programmatically) When an agent needs to consume varlock output rather than show it to a user, prefer machine-readable formats and branch on exit codes: * **`varlock load --agent`**: flat `{ "KEY": value }` JSON on stdout with `@sensitive` values redacted. Safe to keep in a transcript. * **`varlock load --agent --format json-full`**: the full serialized graph (sources, per-item validation state, sensitivity) with sensitive values redacted, for when you need to reason about *why* something is invalid, not just the values. Always keep `--agent` here: plain `--format json-full` emits **raw secret values** and must not go into an agent transcript. * **Exit codes**: `varlock load` exits non-zero when config is invalid, `varlock run` forwards the child command’s exit code, and `varlock scan` exits `1` when it finds a leaked secret. Check the code instead of parsing prose. * **stdout vs stderr**: pass `--summary-stderr` (or `--summary-file`) so the redacted human summary goes to stderr while stdout stays clean JSON for parsing. See the [CLI commands reference](/reference/cli/load-and-run/#load) for the full output-format and exit-code details. Tip Use the [Prompt Builder](/prompt-builder/) to generate a tailored setup prompt for your stack and AI tool. ## Allowing schema files for AI tools [Section titled “Allowing schema files for AI tools”](#allowing-schema-files-for-ai-tools) Most AI tools ignore `.env.*` files by default. To ensure your AI tool can access your environment schema, add the following to your `.gitignore`: ```txt !.env.schema ``` If you use a tool with its own ignore file, check that tool’s documentation to see how it handles ignore files and make sure `.env.schema` is allowed. ## Custom instructions and rules [Section titled “Custom instructions and rules”](#custom-instructions-and-rules) The [Varlock agent skill](#install-the-varlock-skill) is the recommended way to give agents structured guidance for schema work and CLI usage. You can also provide broader context with our `llms.txt` files. Start with [varlock.dev/llms.txt](https://varlock.dev/llms.txt), which summarizes what varlock is, when to use it, and links to topic-specific bundles (getting started and CLI reference, AI tools and MCP, integrations, plugins). The [full docs](https://varlock.dev/llms-full.txt) are also available as a single file. In Cursor, this is accomplished via ‘Add New Custom Docs’. ### Machine-readable discovery [Section titled “Machine-readable discovery”](#machine-readable-discovery) Agents and tools can find everything above without scraping HTML: * `https://varlock.dev/llms.txt`: docs index and “when to use” guidance * `https://varlock.dev/.well-known/ai-catalog.json`: Agentic Resource Discovery manifest listing the docs MCP server, llms.txt files, skills, and the CLI * `https://varlock.dev/.well-known/agent-skills/index.json`: Agent Skills index with `SKILL.md` files and digests * `https://varlock.dev/.well-known/mcp/server-card.json`: MCP server card for the [Docs MCP](/guides/mcp/docs-mcp/) (also at `/.well-known/mcp.json`) * Any docs page returns markdown when requested with an `Accept: text/markdown` header If your tool supports custom rules, you can use our own varlock [Cursor rule file from this repo](https://github.com/dmno-dev/varlock/blob/main/.cursor/rules/varlock.mdc) as a starting point to create your own that is most suited to your workflow. ## Scan for leaked secrets [Section titled “Scan for leaked secrets”](#scan-for-leaked-secrets) AI agents can sometimes hardcode secret values or leak them into generated code. Use `varlock scan` to detect leaked secrets in your codebase: ```bash # Scan the current directory for leaked secret values varlock scan # Scan specific paths varlock scan ./src ./config ``` You can also set up `varlock scan` as a git pre-commit hook to automatically catch leaks before they’re committed: ```bash # Add to your .git/hooks/pre-commit or use a hook manager like husky/lefthook varlock scan --staged ``` This is especially useful when working with AI coding tools. The scan command compares your resolved secret values against your codebase to find any that may have been accidentally included in plain text. ## Varlock Docs MCP [Section titled “Varlock Docs MCP”](#varlock-docs-mcp) We also have a docs MCP server that allows you to search the Varlock docs. See more details [here](/guides/mcp/docs-mcp/). # Aider > Inject LLM API keys into Aider with varlock run [Aider](https://aider.chat/) is an open-source AI pair programmer for the terminal. It maps your repo, edits files, and commits changes. Use varlock to inject provider API keys at runtime so they never sit in a plain text `.env` or shell history. For install, schema setup, and shared patterns, see the [AI Tools overview](/guides/ai-tools/). **Launch command:** `aider` **Environment variables** (see [API keys](https://aider.chat/docs/config/api-keys.html)): * `ANTHROPIC_API_KEY` for Anthropic models * `OPENAI_API_KEY` for OpenAI models * Other providers use their usual env names (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, and so on) ## In a project [Section titled “In a project”](#in-a-project) Add to `.env.schema`: ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) # @sensitive OPENAI_API_KEY=op(op://api-local/openai/api-key) ``` Tip You can use any secrets provider you want, but we like 1Password since it uses biometric authentication for local access. All the examples in this guide use the [1Password plugin](/plugins/1password/) (`op()`) to show more real world examples. See that page for plugin setup and auth options. ```bash varlock run -- aider # or pin a model varlock run -- aider --model sonnet ``` ## From any directory [Section titled “From any directory”](#from-any-directory) Personal schema at `~/.env.aider`: \~/.env.aider ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) # @sensitive OPENAI_API_KEY=op(op://api-local/openai/api-key) ``` ```bash varlock run -p ~/.env.aider -- aider ``` ## Shell alias (optional) [Section titled “Shell alias (optional)”](#shell-alias-optional) ```bash alias vaider='varlock run -p ~/.env.aider --no-redact-stdout -- aider' ``` `--no-redact-stdout` keeps Aider’s own terminal output unredacted while secrets stay out of your shell history. See the [Aider docs](https://aider.chat/docs/) for models, install, and config file options. # Antigravity CLI > Run Antigravity CLI (agy) with varlock for validated env [Antigravity CLI](https://antigravity.google/docs/cli-getting-started) is Google’s agent-first terminal experience, the successor to [Gemini CLI](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/), which has been phased out. For install, schema setup, and shared patterns, see the [AI Tools overview](/guides/ai-tools/). **Launch command:** `agy` **Authentication:** Antigravity signs in via Google OAuth on first launch (credentials live in your system keyring). ```bash varlock run -- agy ``` ## From any directory [Section titled “From any directory”](#from-any-directory) Personal schema at `~/.env.antigravity` for project-agnostic defaults: \~/.env.antigravity ```env-spec # @type=enum(development, staging, production) APP_ENV=development ``` ```bash varlock run -p ~/.env.antigravity -- agy ``` ## Shell alias (optional) [Section titled “Shell alias (optional)”](#shell-alias-optional) ```bash alias vagy='varlock run -p ~/.env.antigravity --no-redact-stdout -- agy' ``` See the [Antigravity CLI getting started guide](https://antigravity.google/docs/cli-getting-started) and [installation and auth docs](https://antigravity.google/docs/cli-overview) for OAuth, enterprise, and headless setup. # Claude Code > Inject ANTHROPIC_API_KEY into Claude Code with varlock run [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) is Anthropic’s CLI for AI-assisted coding. Use varlock to inject `ANTHROPIC_API_KEY` at runtime so the key never sits in a plain text `.env` or shell history. For install, schema setup, and shared patterns, see the [AI Tools overview](/guides/ai-tools/). **Environment variable:** `ANTHROPIC_API_KEY` (see [supported env variables](https://docs.claude.com/en/docs/claude-code/settings#environment-variables)). Use the API key, not `CLAUDE_CODE_OAUTH_TOKEN`: that (from `claude setup-token`) only works for headless `claude -p` requests, not the interactive TUI. ## In a project [Section titled “In a project”](#in-a-project) Add to `.env.schema`: ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) ``` Tip You can use any secrets provider you want, but we like 1Password since it uses biometric authentication for local access. All the examples in this guide use the [1Password plugin](/plugins/1password/) (`op()`) to show more real world examples. See that page for plugin setup and auth options. ```bash varlock run -- claude ``` ## From any directory [Section titled “From any directory”](#from-any-directory) Personal schema at `~/.env.claude`: \~/.env.claude ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) ``` ```bash varlock run -p ~/.env.claude -- claude ``` ## Shell alias (optional) [Section titled “Shell alias (optional)”](#shell-alias-optional) ```bash alias vclaude='varlock run -p ~/.env.claude --no-redact-stdout -- claude' ``` `--no-redact-stdout` keeps Claude’s own terminal output unredacted while secrets stay out of your shell history. ## MCP server secrets [Section titled “MCP server secrets”](#mcp-server-secrets) MCP server configs (`.mcp.json`, `~/.claude.json`) support `${VAR}` expansion, but the variables are read from Claude Code’s own process environment. That works if you launch `claude` via `varlock run`, but not for the desktop app, which is not launched from your shell. Instead, let varlock resolve secrets at the point where Claude Code uses them. ### Remote servers: `headersHelper` [Section titled “Remote servers: headersHelper”](#remote-servers-headershelper) For `http` (and `ws`) servers, Claude Code’s [`headersHelper`](https://code.claude.com/docs/en/mcp) runs a command that prints a JSON object of headers. It runs when Claude Code connects to the server (once per session that uses it, not per request), and re-runs automatically if a call returns 401 or 403. Use `varlock printenv --template` to emit the headers JSON: .mcp.json ```json { "mcpServers": { "my-api": { "type": "http", "url": "https://mcp.example.com", "headersHelper": "varlock printenv --template '{\"Authorization\": \"Bearer {{MY_MCP_TOKEN}}\"}' --escape json" } } } ``` The helper runs from the session’s working directory, so varlock picks up the project’s `.env.schema` automatically. For user-scope servers, or to be independent of the working directory, add `-p /absolute/path/to/project` (or `-p ~/.env.mcp` for a personal schema file). For the desktop app, also use an absolute path to the varlock binary. The [standalone binary](/getting-started/installation/#as-a-standalone-binary) is the safest choice there: the npm-installed CLI needs `node` on `PATH`, which the desktop app may not have. Claude Code gives the helper 10 seconds to run. A warm varlock cache resolves well within that; a cold resolve that prompts for biometric auth may not, so keep caching enabled for schemas used this way. ### Local stdio servers: wrap the command [Section titled “Local stdio servers: wrap the command”](#local-stdio-servers-wrap-the-command) For stdio servers, wrap the server command with `varlock run` in the server entry itself. This works no matter how Claude Code was launched, since each MCP server is a child process it spawns: .mcp.json ```json { "mcpServers": { "my-local-server": { "command": "varlock", "args": ["run", "-p", "/absolute/path/to/project", "--filter", "MY_SERVER_*", "--inject", "vars", "--", "npx", "-y", "some-mcp-server"] } } } ``` * `--filter` injects only the vars that server needs, so each server gets least-privilege access * `--inject vars` keeps the `__VARLOCK_ENV` blob out of a third-party server’s environment * The server’s stdout is a pipe, so [redaction](/guides/secrets/#cli-log-redaction) applies to sensitive values in its output; add `--no-redact-stdout` if that interferes with your server # Codex > Inject OpenAI API keys into Codex CLI with varlock run [Codex CLI](https://developers.openai.com/codex/) is OpenAI’s coding agent for the terminal. Use varlock to inject API keys at runtime so they never sit in a plain text `.env` or shell history. For install, schema setup, and shared patterns, see the [AI Tools overview](/guides/ai-tools/). **Authentication:** Codex supports [ChatGPT sign-in](https://developers.openai.com/codex/auth) (subscription) and API key auth (usage-based). Interactive ChatGPT login is fine for local work. Prefer an API key (via varlock) for headless/`codex exec` workflows. **Environment variables** (see [Codex environment variables](https://developers.openai.com/codex/environment-variables)): * `CODEX_API_KEY`: API key for a single non-interactive `codex exec` run * `OPENAI_API_KEY`: commonly used for API key login and custom [model providers](https://developers.openai.com/codex/config-advanced) that set `env_key` ## In a project [Section titled “In a project”](#in-a-project) Add to `.env.schema`: ```env-spec # @sensitive @required OPENAI_API_KEY=op(op://api-local/openai/api-key) # @sensitive @required CODEX_API_KEY=op(op://api-local/openai/api-key) ``` Tip You can use any secrets provider you want, but we like 1Password since it uses biometric authentication for local access. All the examples in this guide use the [1Password plugin](/plugins/1password/) (`op()`) to show more real world examples. See that page for plugin setup and auth options. For interactive Codex, use varlock to pass the key into the one-time login command. Codex stores the resulting credentials for later sessions: ```bash varlock run -- sh -c 'printenv OPENAI_API_KEY | codex login --with-api-key' codex ``` For a single non-interactive run, inject `CODEX_API_KEY` directly: ```bash varlock run -- codex exec "summarize the recent git changes" ``` ## From any directory [Section titled “From any directory”](#from-any-directory) Personal schema at `~/.env.codex`: \~/.env.codex ```env-spec # @sensitive @required OPENAI_API_KEY=op(op://api-local/openai/api-key) # @sensitive @required CODEX_API_KEY=op(op://api-local/openai/api-key) ``` ```bash varlock run -p ~/.env.codex -- sh -c 'printenv OPENAI_API_KEY | codex login --with-api-key' codex varlock run -p ~/.env.codex -- codex exec "summarize the recent git changes" ``` Tip OpenAI recommends scoping `CODEX_API_KEY` to individual `codex exec` runs rather than exporting it job-wide in CI that runs untrusted repository code. `varlock run` keeps the key in the child process environment for that invocation only. # Crush > Inject provider API keys into Crush with varlock run [Crush](https://github.com/charmbracelet/crush) is Charm’s terminal coding agent. It supports many model providers and reads API keys from the environment. Use varlock to inject those keys at runtime so they never sit in a plain text `.env` or shell history. For install, schema setup, and shared patterns, see the [AI Tools overview](/guides/ai-tools/). **Launch command:** `crush` **Environment variables** (from the [Crush README](https://github.com/charmbracelet/crush?tab=readme-ov-file#api-keys)): * `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, and many other provider keys * `HYPER_API_KEY` for Charm Hyper (Crush’s built-in provider) You can also paste a key in the TUI model picker (`ctrl+l`). Prefer env injection so the key is not typed into the UI or stored in project config. ## In a project [Section titled “In a project”](#in-a-project) Add to `.env.schema`: ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) # @sensitive OPENAI_API_KEY=op(op://api-local/openai/api-key) # @sensitive OPENROUTER_API_KEY=op(op://api-local/openrouter/api-key) ``` Tip You can use any secrets provider you want, but we like 1Password since it uses biometric authentication for local access. All the examples in this guide use the [1Password plugin](/plugins/1password/) (`op()`) to show more real world examples. See that page for plugin setup and auth options. ```bash varlock run -- crush ``` ## From any directory [Section titled “From any directory”](#from-any-directory) Personal schema at `~/.env.crush`: \~/.env.crush ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) # @sensitive OPENAI_API_KEY=op(op://api-local/openai/api-key) ``` ```bash varlock run -p ~/.env.crush -- crush ``` ## Shell alias (optional) [Section titled “Shell alias (optional)”](#shell-alias-optional) ```bash alias vcrush='varlock run -p ~/.env.crush --no-redact-stdout -- crush' ``` `--no-redact-stdout` keeps Crush’s own terminal output unredacted while secrets stay out of your shell history. Tip Treat project `crush.json` as trusted code. Review it before running Crush in a directory you did not author. # Goose > Inject LLM API keys into Goose with varlock run [Goose](https://goose-docs.ai/) is Block’s open-source, extensible AI agent for the terminal (and desktop). It is MCP-native and works with many LLM providers. Use varlock to inject provider API keys at runtime so they never sit in a plain text `.env` or shell history. For install, schema setup, and shared patterns, see the [AI Tools overview](/guides/ai-tools/). **Launch commands:** `goose` (interactive), `goose session`, `goose run` (see [CLI commands](https://goose-docs.ai/docs/guides/goose-cli-commands/)) **Environment variables** (see [environment variables](https://goose-docs.ai/docs/guides/environment-variables/) and [providers](https://goose-docs.ai/docs/getting-started/providers/)): * Provider keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `GOOGLE_API_KEY` * Optional: `GOOSE_PROVIDER` and `GOOSE_MODEL` to select provider/model without `goose configure` You can also store credentials via `goose configure`. Env injection is a better fit for shared machines, CI, and keeping secrets out of goose’s local secret store. ## In a project [Section titled “In a project”](#in-a-project) Add to `.env.schema`: ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) # @sensitive OPENAI_API_KEY=op(op://api-local/openai/api-key) # @type=string GOOSE_PROVIDER=anthropic # @type=string GOOSE_MODEL=claude-sonnet-4-5-20250929 ``` Tip You can use any secrets provider you want, but we like 1Password since it uses biometric authentication for local access. All the examples in this guide use the [1Password plugin](/plugins/1password/) (`op()`) to show more real world examples. See that page for plugin setup and auth options. ```bash varlock run -- goose # one-shot task varlock run -- goose run -t "summarize recent git changes" ``` ## From any directory [Section titled “From any directory”](#from-any-directory) Personal schema at `~/.env.goose`: \~/.env.goose ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) # @type=string GOOSE_PROVIDER=anthropic # @type=string GOOSE_MODEL=claude-sonnet-4-5-20250929 ``` ```bash varlock run -p ~/.env.goose -- goose ``` ## Shell alias (optional) [Section titled “Shell alias (optional)”](#shell-alias-optional) ```bash alias vgoose='varlock run -p ~/.env.goose --no-redact-stdout -- goose' ``` `--no-redact-stdout` keeps Goose’s own terminal output unredacted while secrets stay out of your shell history. See the [Goose docs](https://goose-docs.ai/) for install, extensions/MCP, and recipes. Pair with the [MCP guide](/guides/mcp/) when wiring secret-backed MCP servers. # Kimi > Inject KIMI_API_KEY into Kimi Code CLI with varlock run [Kimi Code CLI](https://www.kimi.com/code/docs/en/) is Moonshot AI’s terminal coding agent (`kimi`). Use varlock to inject `KIMI_API_KEY` at runtime so the key never sits in a plain text `.env` or shell history. For install, schema setup, and shared patterns, see the [AI Tools overview](/guides/ai-tools/). **Launch command:** `kimi` **Authentication:** You can sign in interactively with `/login` (Kimi Code OAuth or a platform API key), or inject a key via the environment. See [environment variables](https://moonshotai.github.io/kimi-cli/en/configuration/env-vars.html). **Environment variable:** `KIMI_API_KEY` (optional: `KIMI_BASE_URL` if you use a custom endpoint) ## In a project [Section titled “In a project”](#in-a-project) Add to `.env.schema`: ```env-spec # @sensitive @required KIMI_API_KEY=op(op://api-local/kimi/api-key) ``` Tip You can use any secrets provider you want, but we like 1Password since it uses biometric authentication for local access. All the examples in this guide use the [1Password plugin](/plugins/1password/) (`op()`) to show more real world examples. See that page for plugin setup and auth options. ```bash varlock run -- kimi ``` ## From any directory [Section titled “From any directory”](#from-any-directory) Personal schema at `~/.env.kimi`: \~/.env.kimi ```env-spec # @sensitive @required KIMI_API_KEY=op(op://api-local/kimi/api-key) ``` ```bash varlock run -p ~/.env.kimi -- kimi ``` ## Shell alias (optional) [Section titled “Shell alias (optional)”](#shell-alias-optional) ```bash alias vkimi='varlock run -p ~/.env.kimi --no-redact-stdout -- kimi' ``` `--no-redact-stdout` keeps Kimi’s own terminal output unredacted while secrets stay out of your shell history. See the [Kimi Code getting started guide](https://www.kimi.com/help/kimi-code/cli-getting-started) for install options and login flows. # Opencode > Inject API keys into Opencode with varlock run [Opencode](https://opencode.ai/) is a provider-agnostic AI coding assistant that works in your terminal. Use varlock to inject provider API keys at runtime. For install, schema setup, and shared patterns, see the [AI Tools overview](/guides/ai-tools/). **Environment variables:** * `ANTHROPIC_API_KEY` for Claude models * `OPENAI_API_KEY` for OpenAI models * `OPENCODE_CONFIG` path to custom config file (optional) ## Auth configuration [Section titled “Auth configuration”](#auth-configuration) Run `opencode auth login` once. When prompted for an API key, paste an env reference instead: `{"env:ANTHROPIC_API_KEY"}` Your config file (`~/.local/share/opencode/auth.json`) should look like: \~/.local/share/opencode/auth.json ```json { "anthropic": { "type": "api", "key": "{env:ANTHROPIC_API_KEY}" } } ``` ## In a project [Section titled “In a project”](#in-a-project) Add to `.env.schema`: ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) # @sensitive OPENAI_API_KEY=op(op://api-local/openai/api-key) ``` Tip You can use any secrets provider you want, but we like 1Password since it uses biometric authentication for local access. All the examples in this guide use the [1Password plugin](/plugins/1password/) (`op()`) to show more real world examples. See that page for plugin setup and auth options. ```bash varlock run -- opencode # or with a specific model varlock run -- opencode --model claude-3-5-sonnet ``` ## From any directory [Section titled “From any directory”](#from-any-directory) Personal schema at `~/.env.opencode`: \~/.env.opencode ```env-spec # @sensitive @required ANTHROPIC_API_KEY=op(op://api-local/anthropic/api-key) # @sensitive OPENAI_API_KEY=op(op://api-local/openai/api-key) ``` ```bash varlock run -p ~/.env.opencode -- opencode # or with a specific model varlock run -p ~/.env.opencode -- opencode --model claude-3-5-sonnet ``` ## Shell alias (optional) [Section titled “Shell alias (optional)”](#shell-alias-optional) ```bash alias vopencode='varlock run -p ~/.env.opencode --no-redact-stdout -- opencode' ``` See the [Opencode docs](https://opencode.ai/docs/) for more information. # Caching > How varlock caching works, how to choose cache mode, and how to debug cache behavior Varlock has a built-in caching system, secured by [local encryption](/guides/local-encryption/). Rather than caching environment variable values, it is a general key-value store, usable by plugins to cache arbitrary internal requests, or by an explicit [`cache()`](/reference/functions/#cache) function to cache any resolver. ## Plugin cache behavior [Section titled “Plugin cache behavior”](#plugin-cache-behavior) Plugins have access to cache helpers that can be used to avoid repeated network round trips, or to hold short-lived tokens. They will usually expose params (like `cacheTtl`) to let the user opt into caching. This still respects global cache mode and CLI flags. Each plugin can decide how to cache things and it will often depend on the shape of the external API. `cacheTtl` can be set dynamically, for example `cacheTtl=if(forEnv(dev), "1h")` to cache only in development. Setting it to `false` (or an empty string) disables caching for that plugin instance. ## Common `cache()` use case: stable generated values [Section titled “Common cache() use case: stable generated values”](#common-cache-use-case-stable-generated-values) The most common use case for `cache()` is to hold [generated random values](/reference/functions/#random-value-generators) stable across restarts for local development. .env.local ```env-spec # Good for local-only generated values INSTANCE_ID=cache(randomUuid(), ttl=1d) # cache for 1 day TRACKING_ID=cache(randomUuid(), ttl=forever) # cache until manually cleared SESSION_SECRET=cache(randomHex(32)) # default is ttl=forever ``` This allows a new environment to generate values, while keeping them stable. ## TTL duration format (shared everywhere) [Section titled “TTL duration format (shared everywhere)”](#ttl-duration-format-shared-everywhere) Both plugins and `cache()` use the same duration format and parser for TTLs - values like `"30s"`, `"5m"`, `"1h"`, `"500ms"`, or `"2days"` - based on our [`duration` data type](/reference/data-types/#duration). The format is a number followed by a unit (`ms`, `s`, `m`, `h`, `d`, `w`), with long forms and plurals also accepted. For example, minute accepts any of `m`, `min`, `mins`, `minute`, `minutes`. Bare numbers are interpreted as milliseconds, and must be plain decimals (no hex or exponent notation). Use the keyword `forever` to mean “cache until manually cleared”. A TTL of `0` is rejected as ambiguous - use `forever` instead, or set `cacheTtl=false` to disable plugin caching. * `@initOp(..., cacheTtl=30m)` * `cache(someFunc(), ttl=1h)` * `cache(someFunc(), ttl=forever)` ## Cache modes [Section titled “Cache modes”](#cache-modes) Caching is mostly useful for local development, where there may be many invocations of varlock and strong [local encryption](/guides/local-encryption/) (e.g., biometric-protected keys) is available. The default mode (`auto`) picks the most secure persistent option available: 1. **disk** encrypted via local encryption - when a native encryption backend is available and not running in CI 2. **disk** encrypted with `_VARLOCK_CACHE_KEY` - when that env var is set (see below) 3. in-process **memory** caching otherwise (CI without a key, or the basic file-based encryption fallback) In memory mode, entries still avoid repeated work within a single invocation, but are not persisted across invocations. To explicitly set the cache mode (or disable caching entirely), use the [`@cache`](/reference/root-decorators/#cache) root decorator. Forcing `@cache=disk` in CI or while using the file-based encryption fallback is allowed, but emits a warning - with the file backend, the decryption key lives on the same disk as the cache, so encryption is obfuscation-only. ## Disk caching in CI with `_VARLOCK_CACHE_KEY` [Section titled “Disk caching in CI with \_VARLOCK\_CACHE\_KEY”](#disk-caching-in-ci-with-_varlock_cache_key) Memory mode doesn’t help when a CI job runs varlock in several separate processes. If the `_VARLOCK_CACHE_KEY` env var is set (a 256-bit hex key, typically provided as a CI secret), `auto` mode uses a **disk** cache encrypted with that key instead of falling back to memory. The key only ever lives in the environment - never on the runner’s disk - so the persisted cache file is genuinely encrypted. This is deliberately a separate variable from `_VARLOCK_ENV_KEY` (used for [encrypted deployments](/guides/encrypted-deployments/)) - that one is auto-generated and ephemeral in some flows, which would silently defeat caching. The key format is the same though, so you can generate one with `varlock generate-key`. Each key gets its own cache file (named by key fingerprint), so rotating the key naturally invalidates the old cache. `varlock cache` also operates on the active key’s cache file when `_VARLOCK_CACHE_KEY` is set. ## Disk cache location and security [Section titled “Disk cache location and security”](#disk-cache-location-and-security) The disk cache lives at `~/.config/varlock/cache/`, with one file per encryption key. A few things to keep in mind: * Only entry **values** are encrypted. Cache keys are stored in plaintext and include file paths, item names, and resolver source text. * The disk cache is per-OS-user and shared across all projects. This is intentional - projects often share config - but it means any env file you load can read and write it, so treat untrusted repos accordingly. * With the file-based encryption fallback, the decryption key sits on the same disk as the cache, so encryption is obfuscation-only (see warning above). ## Concurrent runs [Section titled “Concurrent runs”](#concurrent-runs) When several varlock processes start at once (a turbo or nx pipeline, or a monorepo dev script), they coordinate through the disk cache so a value is only fetched once. The first process to need a given value fetches it while the others wait, then read the result from the cache. This is what keeps ten parallel tasks from triggering ten API calls, or ten biometric prompts, for the same secret. Coordination uses per-value lock directories under `~/.config/varlock/cache/`. If a process is killed while holding one (Ctrl+C at a password or biometric prompt, for example), the leftover lock is detected and reclaimed by the next run, so this is normally invisible. A lock can outlive its owner in one case: a cache directory shared between machines through a synced home directory, where varlock cannot tell whether the owning process is still running. `varlock cache clear --yes` removes lock directories along with entries. ## Per-invocation CLI controls [Section titled “Per-invocation CLI controls”](#per-invocation-cli-controls) [`varlock load`](/reference/cli/load-and-run/#load), [`varlock run`](/reference/cli/load-and-run/#run), and [`varlock printenv`](/reference/cli/load-and-run/#printenv) support: * `--skip-cache`: disables cache reads and writes for that invocation (overrides `@cache`) * `--clear-cache`: clears the active cache store before resolving values If both are used, the cache is cleared first, then reads and writes are skipped for the rest of the run. ## Inspecting and clearing cache [Section titled “Inspecting and clearing cache”](#inspecting-and-clearing-cache) Use [`varlock cache`](/reference/cli/cache-and-codegen/#cache) to inspect and clear the **disk cache**: ```bash varlock cache status varlock cache clear --yes varlock cache clear --plugin 1password --yes ``` Notes: * This command manages disk cache entries only. * In `memory` mode, entries are process-local and are not visible via `varlock cache`. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### ”I changed a resolver but still see old values” [Section titled “”I changed a resolver but still see old values””](#i-changed-a-resolver-but-still-see-old-values) * If you used a custom `key`, that key may intentionally pin the cache entry. * Run with `--clear-cache` or clear entries via `varlock cache clear`. ### ”Timed out waiting for cache lock” [Section titled “”Timed out waiting for cache lock””](#timed-out-waiting-for-cache-lock) A lock directory is being held and could not be reclaimed. Check whether another varlock process is genuinely running and still working. If none is, clear the locks: ```bash varlock cache clear --yes ``` This is expected to be rare. Locks left behind by an interrupted run are reclaimed automatically on the next run. ### ”`varlock cache` shows nothing, but caching seems active” [Section titled “”varlock cache shows nothing, but caching seems active””](#varlock-cache-shows-nothing-but-caching-seems-active) * You are likely in `memory` mode (for example in CI or file-backend fallback mode). ### ”I want no cache artifacts on disk” [Section titled “”I want no cache artifacts on disk””](#i-want-no-cache-artifacts-on-disk) * Use `@cache=memory` (or `@cache=disabled`) and avoid `disk` mode. # Code generation > Generate types and other code from your env schema, and extend it with plugins Your `.env.schema` is the single source of truth for your environment variables: their names, types, and metadata. Varlock can turn that schema into **generated code**: strongly-typed definitions for your language, and, via plugins, anything else you can derive from a schema. Generation is driven by per-language root decorators in your schema. Each one writes its own output file: .env.schema ```env-spec # @generateTsTypes(path=./env.d.ts) # @generatePythonEnv(path=./env_types.py) ``` Output is regenerated automatically on [`varlock load`](/reference/cli/load-and-run/#load) and [`varlock run`](/reference/cli/load-and-run/#run), or explicitly with [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen). It’s derived only from non-environment-specific schema definitions, so the result is deterministic regardless of which environment is active. Tip `varlock init` detects your project’s primary language and adds the matching decorator for you. ## TypeScript [Section titled “TypeScript”](#typescript) [`@generateTsTypes`](/reference/root-decorators/#generatetstypes) is the default for JavaScript/TypeScript projects. It makes `import { ENV } from 'varlock/env'` fully typed, and augments `process.env` / `import.meta.env`: .env.schema ```env-spec # @generateTsTypes(path=./env.d.ts) ``` You can control what it emits: * **`exposeEnv`**: where the coerced `ENV` object lives: `global` (default, augments `varlock/env`), `local` (export a package-local `ENV` from the generated file, ideal for monorepos), or `none`. * **`processEnv`** / **`importMetaEnv`**: `strict` (only defined keys), `loose` (also allow extra keys), or `none` (don’t augment). See the [`@generateTsTypes` reference](/reference/root-decorators/#generatetstypes) for details. In monorepos where multiple packages have different schemas, prefer `exposeEnv=local` to avoid global augmentation clashes. See [Typed `ENV` across packages](/guides/monorepos/#typed-env-across-packages). ## Other languages [Section titled “Other languages”](#other-languages) Varlock ships generators for several languages. Each emits a **self-contained, idiomatic module**: a typed value object, a loader that reads the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob, and a `SENSITIVE_KEYS` constant (so you can build your own redaction or leak-scanning). No hand-rolled parsing required: | Language | Decorator | Loader | Guide | | -------- | -------------------- | ------------- | ------------------------------- | | Python | `@generatePythonEnv` | `load_env()` | [Python](/integrations/python/) | | Rust | `@generateRustEnv` | `env::load()` | [Rust](/integrations/rust/) | | Go | `@generateGoEnv` | `env.Load()` | [Go](/integrations/go/) | | PHP | `@generatePhpEnv` | `Env::load()` | [PHP](/integrations/php/) | | Java | `@generateJavaEnv` | `Env.load()` | [Java](/integrations/java/) | | C# | `@generateCsharpEnv` | `Env.Load()` | [C#](/integrations/csharp/) | For a language without a generated module, see [Other languages](/integrations/other-languages/). ## Disabling automatic generation [Section titled “Disabling automatic generation”](#disabling-automatic-generation) Set `auto=false` on any generator to skip it during `load`/`run`, then regenerate on demand with [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen), useful for CI or a dedicated build step: .env.schema ```env-spec # @generateTsTypes(path=./env.d.ts, auto=false) ``` ## Extending with plugins [Section titled “Extending with plugins”](#extending-with-plugins) Code generation is contributed through a registry, and [plugins](/guides/plugins/) can register their own generators using the same mechanism the built-in ones use. A generator is a root decorator name plus a `generate` function that receives the resolved schema and returns file contents: my-plugin.ts ```ts import { plugin, type CodeGeneratorDef } from 'varlock/plugin-lib'; const generateZodSchema: CodeGeneratorDef = { decoratorName: 'generateZodSchema', generate: ({ fields, outputPath }) => { // `fields` is the language-agnostic resolved schema (keys, coerced/raw types, // required/sensitive flags, docs). Return the source to write to `outputPath`. const lines = fields.map((f) => ` ${f.key}: z.string(),`); return `import { z } from 'zod';\n\nexport const envSchema = z.object({\n${lines.join('\n')}\n});\n`; }, }; plugin.registerCodeGenerator(generateZodSchema); ``` Once registered, users trigger it from their schema like any other generator: .env.schema ```env-spec # @plugin(my-plugin) # @generateZodSchema(path=./env.schema.ts) ``` The `decoratorName` must start with `generate` (e.g. `generateZodSchema`), a shared convention that keeps code-generation decorators recognizable and separate from behavior decorators. Generators also get the common `path`, `auto`, and `executeWhenImported` args automatically, so your `generate` function only handles turning the schema into code. Plugins that register custom data types can also declare `coercedType` (what their `coerce()` function outputs: `'string'`, `'int'`, `'number'`, `'boolean'`, `'object'`, or `{ enum: [...] }`) so generated code types those fields correctly in every language: my-plugin.ts ```ts plugin.registerDataType({ name: 'retryCount', coercedType: 'int', coerce: (val) => parseInt(val, 10), }); ``` Data types that don’t declare a `coercedType` are treated as strings. # Static vs Dynamic Vars > How to control build-time replacement vs runtime resolution, and how to use dynamic+public values safely Most build tools and web frameworks have a concept of “public” env vars, based on a name prefix (e.g., `NEXT_PUBLIC_*`/`VITE_*`/`PUBLIC_*`). Public values are made available in the client by inlining them at build time, while non-public values are resolved at boot time, and are not available in the client. While it works for most situations, this setup merges 2 distinct concepts that varlock decouples to give you more flexibility: * **sensitive** vs **public** - is this safe to expose to clients * **static** vs **dynamic** - can this be bundled at build time By manually marking items as `@dynamic`/`@static`, you get two new possibilities: * `@dynamic`+`@public` - a non-sensitive value that is never bundled at build time. This is useful for values that change without a client rebuild. * `@static`+`@sensitive` - a sensitive value that is inlined into server bundles. Very rarely needed, but can affect code folding for deep optimization. You probably don’t need to think about this If you are building a totally static site, or you host on a platform that rebuilds and redeploys on every env change (Vercel, Netlify, Cloudflare Workers Builds, etc) the notion of a “dynamic” value that changes at boot time doesn’t exist. Every change to a public value triggers a rebuild, so we can skip the dynamic machinery entirely. Examples of when you might need this: * running your builds separate from your deploys (e.g. a CI/CD pipeline that builds once and deploys to multiple environments) * building a single docker image to run in different situations One important nuance: dynamic values are still resolved when varlock loads, at server boot, deploy, or `varlock run`, not on every access. `ENV.KEY` returns that resolved value. Dynamic means the value is not frozen into build output; it does not mean it is re-resolved per request. ## Defaults and decorators [Section titled “Defaults and decorators”](#defaults-and-decorators) By default, static/dynamic behavior follows sensitivity, like what you are likely used to. You can also adjust the default with the [`@defaultDynamic`](/reference/root-decorators/#defaultdynamic) root decorator, and override per item with the [`@dynamic`](/reference/item-decorators/#dynamic) and [`@static`](/reference/item-decorators/#static) item decorators. .env.schema ```env-spec # @public SOMETHING_STATIC=foo # static by default # @public @dynamic SET_AT_BOOT= # explicitly dynamic # @sensitive SECRET_BAR=somePluginFn() # dynamic by default ``` ## Loading dynamic vars in the client [Section titled “Loading dynamic vars in the client”](#loading-dynamic-vars-in-the-client) Marking a public value `@dynamic` is cheap: it is one decorator, and server-side code keeps reading `ENV.KEY` with no other changes. Use it freely whenever a public value should stay out of client bundles or stay runtime-resolved on the server. Because dynamic+public items cannot be bundled into client-side code, if you need them available on the client, we must load them somehow after build time. We can either: * load them from a server endpoint * inject them into static files *at boot time* The endpoint approach works anywhere you have a running server. Boot-time injection has no runtime fetch at all, so it works for fully static sites, but it only works when you control the boot command (e.g. your own Docker image, VM, or server process); on managed platforms where you cannot hook the serve step, use the endpoint approach. If browser code does not read the value, none of this machinery is needed: `@dynamic` alone does the job. Sensitive values are unaffected either way: they default to dynamic for secrecy, and are never available in the client. ### Runtime helpers [Section titled “Runtime helpers”](#runtime-helpers) The endpoint approach revolves around two helpers, both exported from `varlock/env`. (Boot-time injection does not need them, though it plays nice with them.) #### `getPublicDynamicEnv(keys?)` [Section titled “getPublicDynamicEnv(keys?)”](#getpublicdynamicenvkeys) Server-side. Returns the current public+dynamic values as a plain object, ready to serve from an endpoint. The values are whatever the server process booted with (see the note on boot-time resolution above). * `keys` (optional `string[]`): limit the payload to a subset, e.g. `getPublicDynamicEnv(['MY_FLAG'])`. Keys that are not declared public+dynamic are always excluded. #### `loadPublicDynamicEnv(opts?)` [Section titled “loadPublicDynamicEnv(opts?)”](#loadpublicdynamicenvopts) Client-side. Fetches the endpoint, hydrates the shared `ENV` store, and returns the payload; once it resolves, `ENV.KEY` reads work as usual. Concurrent calls share a single request, and after a successful load it will not refetch unless forced. Options: * `endpoint` (default `/__varlock/public-env`): URL to fetch. Must be absolute outside the browser (e.g. native apps). * `force`: refetch even if values are already hydrated. * `fetch`: custom fetch implementation (defaults to the global `fetch`). * `requestInit`: extra request options, merged over the defaults (`GET`, `cache: 'no-store'`, `credentials: 'same-origin'`). The response must be a JSON object of key/value pairs, and hydration is filtered to the declared public+dynamic keys. If you deliver values through some other transport, `setPublicDynamicEnv(values)` hydrates the store directly, and `clearPublicDynamicEnv()` resets it. ### Loading from a server endpoint [Section titled “Loading from a server endpoint”](#loading-from-a-server-endpoint) Each recipe is the same shape: a small server route returning `getPublicDynamicEnv()`, and a one-time `loadPublicDynamicEnv()` call before browser code reads `ENV.KEY`. * Next.js app/\_\_varlock/public-env/route.ts ```ts import { getPublicDynamicEnv } from 'varlock/env'; export const dynamic = 'force-dynamic'; export async function GET() { return Response.json(getPublicDynamicEnv(), { headers: { 'cache-control': 'no-store' }, }); } ``` app/example/client-widget.tsx ```tsx 'use client'; import { useEffect } from 'react'; import { ENV, loadPublicDynamicEnv } from 'varlock/env'; export function ClientWidget() { useEffect(() => { void loadPublicDynamicEnv(); }, []); return

{ENV.NEXT_PUBLIC_RUNTIME_FLAG}

; } ``` In the browser, reading a dynamic+public key before `loadPublicDynamicEnv()` completes returns `undefined`; gate rendering on the load completing or handle the initial state. Avoid putting the loading call in your root layout unless every route needs it, so pages that do not use dynamic+public values can still be prerendered. * Astro The Astro integration auto-injects the endpoint at `/__varlock/public-env` when public+dynamic keys exist (configurable; see [route injection options](/integrations/astro/#route-injection-options)). Load it in the browser: src/components/MyClientThing.astro ```astro --- --- ``` * SvelteKit src/routes/\_\_varlock/public-env/+server.ts ```ts import { getPublicDynamicEnv } from 'varlock/env'; export const GET = async () => new Response(JSON.stringify(getPublicDynamicEnv()), { headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', }, }); ``` src/routes/+page.svelte ```svelte

{runtimeFlag}

``` Keep dynamic-key usage out of routes that must be prerendered. * TanStack Start src/routes/api/public-env.ts ```ts import { createServerFileRoute } from '@tanstack/react-start/server'; import { getPublicDynamicEnv } from 'varlock/env'; export const ServerRoute = createServerFileRoute('/api/public-env').methods({ GET: async () => Response.json(getPublicDynamicEnv(), { headers: { 'cache-control': 'no-store' }, }), }); ``` src/components/client-widget.tsx ```tsx import { useEffect } from 'react'; import { ENV, loadPublicDynamicEnv } from 'varlock/env'; export function ClientWidget() { useEffect(() => { void loadPublicDynamicEnv({ endpoint: '/api/public-env' }); }, []); return

{ENV.PUBLIC_RUNTIME_FLAG}

; } ``` * Expo Expose a server route and hydrate over the network (native fetch needs an absolute URL): app/api/public-env+api.ts ```ts import { getPublicDynamicEnv } from 'varlock/env'; export function GET() { return Response.json(getPublicDynamicEnv()); } ``` native code ```ts import { ENV, loadPublicDynamicEnv } from 'varlock/env'; await loadPublicDynamicEnv({ endpoint: 'https://myapp.example.com/api/public-env' }); console.log(ENV.PUBLIC_RUNTIME_FLAG); ``` If you only need a subset of keys, return `getPublicDynamicEnv(['KEY_A', 'KEY_B'])` from your endpoint. ### Boot-time injection [Section titled “Boot-time injection”](#boot-time-injection) This approach only applies when you control the boot command of whatever serves your files (your own Docker image, VM, or server process); a common pattern for static SPAs in Docker. The idea: skip the endpoint and fetch entirely by extracting the public+dynamic values with the `--filter` selector language and injecting them into the served files once, at boot. The runtime picks up `globalThis.__varlockPublicDynamicEnv` automatically at init, so no app code changes are needed, and any existing `loadPublicDynamicEnv()` call short-circuits since values are already hydrated. We can’t provide a one-size-fits-all recipe for this, because it depends on your framework and server setup. But the general idea is: 1. Make sure a placeholder comment ends up in your HTML entry file (or a common script/entry point). dist/index.html ```diff ... + ... ``` 2. Replace it at boot. Plain POSIX shell is enough (no node needed in the container, and shell parameter expansion splices the JSON in literally, avoiding sed’s value-escaping pitfalls): docker-entrypoint.sh ```sh #!/bin/sh set -e # resolve only the public+dynamic subset from the container env, then escape `<` # as `<` so a value containing `` cannot break out of the tag # (a fixed substitution, so none of sed's usual value-splicing pitfalls apply) PUBLIC_ENV_JSON="$(varlock load --filter '@dynamic,!@sensitive' --format json | sed 's/' HTML_FILE=dist/index.html html="$(cat "$HTML_FILE")" case "$html" in *"$MARKER"*) ;; *) echo "varlock env placeholder not found in $HTML_FILE" >&2; exit 1 ;; esac printf '%s\n' "${html%%"$MARKER"*}${html#*"$MARKER"}" > "$HTML_FILE" exec "$@" ``` 3. Wire it up as your image’s entrypoint, keeping your normal serve command as the CMD (the script’s `exec "$@"` hands off to it after injecting): Dockerfile ```dockerfile COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh ENTRYPOINT ["/docker-entrypoint.sh"] # keep whatever serve command your image already uses, e.g.: CMD ["nginx", "-g", "daemon off;"] ``` On the official nginx image you can skip the ENTRYPOINT override entirely: drop the script into `/docker-entrypoint.d/` (without the final `exec "$@"` line) and the stock entrypoint runs it automatically before starting nginx. The replacement step itself can be accomplished many ways: this shell snippet, a small node script, `envsubst`, or whatever templating your setup already has. The only contract is that `globalThis.__varlockPublicDynamicEnv` is set to the JSON object before your bundle loads. Whatever tool you use, escape the JSON for HTML before embedding it in a `` cannot break out of the tag or inject markup. The `sed` step above escapes `<` as its `\u003C` unicode escape, which is spec-valid JSON that parses back to `<`; a node script can serialize with the equivalent escape. If the entrypoint never runs, the placeholder is just an HTML comment, so nothing breaks; the values are simply not injected. The marker is consumed on injection, so the guard also prevents double-injection on container restarts with a persisted file. The `--filter '@dynamic,!@sensitive'` selection also scopes resolution and validation, so a container that only serves static assets does not need the rest of your schema to be resolvable at boot. As always, only public values belong in served HTML; the filter guarantees sensitive values are excluded. ## Prerender/build guardrails [Section titled “Prerender/build guardrails”](#prerenderbuild-guardrails) Baking a public+dynamic value into prerendered output defeats its purpose: you marked it `@dynamic` because the build-time value should not be frozen. Varlock catches this per framework: * **Next.js**: accessing a public+dynamic key during server rendering marks the route dynamic (it will not be statically prerendered), including access from nested components. * **Vite-based frameworks** (Astro, SvelteKit, etc.): accessing a public+dynamic key while a build/prerender is running throws an error. Set `_VARLOCK_DYNAMIC_BUILD_ACCESS_MODE=warn` to downgrade it to a warning (e.g. while migrating an existing app). Sensitive values are not subject to this guard. Reading a sensitive value server-side during a static build (e.g. using an API key to fetch data while generating pages) is fine; leak detection separately errors if the value itself ends up in the built output. One caveat on freshness: `@dynamic` guarantees a value is never inlined into client bundles or prerendered output. How fresh the *server-side* value is depends on how your deployment delivers env: platforms where varlock resolves at server boot (Node servers, Next.js) re-read on each deploy/boot, while modes that bake a resolved blob into the server artifact at build time (e.g. Vite `ssrInjectMode: 'resolved-env'` on serverless adapters) serve values as of the build. If you need per-boot freshness on those platforms, deliver the value through the platform’s runtime env instead. ## Filtering by static/dynamic [Section titled “Filtering by static/dynamic”](#filtering-by-staticdynamic) The [`--filter` selector language](/reference/cli-commands/#filtering-items) supports a `@dynamic` selector (negate it for static items), so you can scope a load or a generated file to one side of the split: ```bash varlock load --filter="!@dynamic" # only build-time-inlineable items varlock load --filter="@dynamic" # only runtime-resolved items varlock run --filter="@dynamic" -- node app # inject only runtime values ``` .env.schema ```env-spec # generate a module covering only runtime public values # @generateTsTypes(path=public-runtime-env.d.ts, filter="@dynamic,!@sensitive") ``` Like all filters, `@dynamic` filters scope **resolution and validation**, not just output: varlock resolves each item’s decorator metadata first (cheap), then only resolves and validates the items the filter selects. So a build-time load can skip runtime-only vars entirely, including their `@required` checks and any value resolvers they use: ```bash # runtime-only vars (e.g. platform-injected at runtime) don't exist yet at build # time - exclude them so their @required checks don't fail the build varlock load --filter='!@dynamic' ``` ## Guidance [Section titled “Guidance”](#guidance) * The default (`@defaultDynamic=inferFromSensitive`) keeps today’s behavior: sensitive stays out of bundles, public gets inlined. You only need decorators when you want something different. * Mark intentional runtime public values with `@dynamic`. * Keep dynamic+public loading scoped to only the parts of your app that need it. * Prefer one app-level endpoint/payload shape per app unless you have a strong reason to split. # Encrypted deployments > Encrypt the env blob in your build output so secrets are never stored in plaintext When deploying SSR applications to **serverless platforms** (like Vercel or Netlify) that don’t give you control over how the application boots, varlock injects the fully resolved env data into your server-side build output (`ssrInjectMode: 'resolved-env'`) so it’s available at runtime without needing the CLI or filesystem access. Cloudflare Workers is different: `varlock-wrangler deploy` uploads the blob as a runtime secret binding instead of baking it into the build output, so it never appears in a build artifact or sourcemap in the first place. See the [Cloudflare Workers](#cloudflare-workers) section below. By default, this blob is **plaintext JSON**. Since it only appears in server-side code, it’s generally safe: your secrets are not exposed to the client. However, encrypting the blob adds protection against secrets leaking via **sourcemaps** that may be uploaded to error tracking services. If you deploy to Vercel with `ssrInjectMode: 'resolved-env'` and haven’t enabled encryption, varlock warns you about this at build time. ## Enabling encryption [Section titled “Enabling encryption”](#enabling-encryption) Add the [`@encryptInjectedEnv`](/reference/root-decorators/#encryptinjectedenv) root decorator to your `.env.schema`: .env.schema ```env-spec # @encryptInjectedEnv # --- SECRET_KEY= # @sensitive ``` You can also enable it conditionally for specific environments: .env.schema ```env-spec # @encryptInjectedEnv=forEnv(prod) # --- SECRET_KEY= # @sensitive ``` ## How it works [Section titled “How it works”](#how-it-works) 1. At **build time**, varlock encrypts the serialized env graph with **AES-256-GCM** before injecting it 2. The encrypted blob is prefixed with `varlock:v1:`, so you can verify encryption by inspecting your build output 3. At **runtime**, `initVarlockEnv()` detects the encrypted prefix and decrypts using `_VARLOCK_ENV_KEY` from the runtime environment 4. The key is **never baked into the build**. It must always come from the runtime environment Decryption uses `node:crypto` where available; edge runtimes without it (e.g. Vercel Edge middleware) automatically fall back to asynchronous Web Crypto, with request handling gated until the env is ready. ## Key management [Section titled “Key management”](#key-management) The encryption key (`_VARLOCK_ENV_KEY`) is managed differently depending on your platform: ### Local development [Section titled “Local development”](#local-development) Nothing to set up. When `@encryptInjectedEnv` is enabled, the Vite plugin mints a temporary key in an early dev-server config hook. Frameworks that run the SSR dev runtime in a separate worker or process (Nitro and TanStack Start, Nuxt, vitest) spawn it later with a copy of the environment, so they inherit the key and the blob is encrypted in dev the same way it is in production. The key lives only in memory for that dev session and is never created for builds. If you export `_VARLOCK_ENV_KEY` yourself before starting the dev server, that key is used instead. `vite preview` never mints a key: it serves a finished build, so it needs the same real key the build used. On Cloudflare targets, the dev blob is always plaintext: the workerd dev runtime reads `process.env` from bindings, so no host env var can reach it. Dev output is never deployed, so this is safe. If you use another runtime that cannot see host env vars, scope the decorator to production with `@encryptInjectedEnv=forEnv(prod)`. ### Cloudflare Workers [Section titled “Cloudflare Workers”](#cloudflare-workers) No setup needed. `varlock-wrangler deploy` **auto-generates a key** and uploads it alongside your encrypted env as a Cloudflare secret binding. The key persists across deploys. ### Vercel, Netlify, and other platforms [Section titled “Vercel, Netlify, and other platforms”](#vercel-netlify-and-other-platforms) You must set `_VARLOCK_ENV_KEY` as an environment variable on your platform. The key must be available at both **build time** (for encryption) and **runtime** (for decryption). It must be a real environment variable in both places: it’s a reserved `_VARLOCK_*` name that varlock never populates from `.env` files, and it can’t be baked in via vite’s `define` (the value has to come from the actual process environment at runtime, not a string substituted at build time). 1. **Generate and set the key** * Vercel ```bash # set one key for preview varlock generate-key --plain | vercel env add _VARLOCK_ENV_KEY preview --sensitive # set one key for production varlock generate-key --plain | vercel env add _VARLOCK_ENV_KEY production --sensitive ``` You can use the same key for both `preview` and `production`, but using separate keys is usually safer because it isolates environments and reduces blast radius if one key is exposed. * Netlify ```bash netlify env:set _VARLOCK_ENV_KEY $(varlock generate-key --plain) --secret ``` * Other platforms Generate a key and set it as a secret/env var on your platform: * npm ```bash npm exec -- varlock generate-key ``` * pnpm ```bash pnpm exec -- varlock generate-key ``` * bun ```bash bunx varlock generate-key ``` * vlt ```bash vlx -- varlock generate-key ``` * yarn ```bash yarn exec -- varlock generate-key ``` * npm ```bash npm exec -- varlock generate-key ``` * pnpm ```bash pnpm exec -- varlock generate-key ``` * bun ```bash bunx varlock generate-key ``` * vlt ```bash vlx -- varlock generate-key ``` * yarn ```bash yarn exec -- varlock generate-key ``` 2. **Deploy your app** The build will fail with a clear error if `@encryptInjectedEnv` is enabled but `_VARLOCK_ENV_KEY` is not set. ## Supported integrations [Section titled “Supported integrations”](#supported-integrations) | Integration | How it works | | --------------------------------------------------- | ---------------------------------------------------------------------- | | **[Next.js](/integrations/nextjs/)** | Encrypts the env blob injected into the webpack/turbopack build output | | **[Vite](/integrations/vite/)** | Encrypts the env blob when using `ssrInjectMode: 'resolved-env'` | | **[Astro](/integrations/astro/)** | Uses the Vite integration; encryption applies to SSR adapter builds | | **[SvelteKit](/integrations/sveltekit/)** | Uses the Vite integration; encryption applies to SSR builds | | **[TanStack Start](/integrations/tanstack-start/)** | Uses the Vite integration; encryption applies to SSR builds | | **[Cloudflare Workers](/integrations/cloudflare/)** | Encrypts the `__VARLOCK_ENV` secret binding uploaded at deploy time | | **[Expo](/integrations/expo/)** | Encrypts the env blob used by the Metro bundler for server routes | ## Verifying encryption [Section titled “Verifying encryption”](#verifying-encryption) After building with encryption enabled, you can inspect your build output to confirm the blob is encrypted: ```bash # Look for the encrypted prefix in build output grep -r 'varlock:v1:' dist/ .next/server/ 2>/dev/null ``` If encryption is working, you’ll see `varlock:v1:` followed by a base64-encoded ciphertext instead of raw JSON. ## Alternative: manual key without the decorator [Section titled “Alternative: manual key without the decorator”](#alternative-manual-key-without-the-decorator) You can also enable encryption without the `@encryptInjectedEnv` decorator by setting `_VARLOCK_ENV_KEY` in your environment. When present, varlock will encrypt the blob, but it won’t enforce the key’s presence or auto-generate it. # Environments > Best practices for managing multiple environments with varlock One of the main benefits of using environment variables is the ability to boot your application with configuration intended for different environments (e.g., development, preview, staging, production, test). You may use both [functions](/reference/functions/) and/or environment-specific `.env` files (e.g., `.env.production`) to alter configuration accordingly in a declarative way. Plus the additional guardrails provided by `varlock` also make this much safer no matter where values come from. environment-specific files are optional While many have traditionally shied away from using environment-specific `.env` files due to fear of committing sensitive values, the ability to set values using [plugins](/guides/plugins/) makes it easier to securely, and collaboratively, manage these values. ### Process overrides [Section titled “Process overrides”](#process-overrides) `varlock` will always treat environment variables passed into the process with the most precedence. Generally, we recommend moving as much configuration as possible into your `.env` files, but there are cases where you may want to override specific values at runtime, either from the environment itself, or by prepending them to your command (e.g., `APP_ENV=prod pnpm run build`). At the very least, you’ll often need to inject an environment flag (e.g., `APP_ENV`) and a *secret-zero* which allows access to the rest of your secrets in deployed environments. For local workflows, consider [device-local encryption](/guides/local-encryption/) to secure local secret-zero handling. That said, as a first step to adopting `varlock`, you could rely entirely on process overrides to inject all config values, but still benefit from having a clear schema with validation applied to them. ### Loading environment-specific `.env` files [Section titled “Loading environment-specific .env files”](#loading-environment-specific-env-files) Any environment-specific files (e.g., `.env.development`) will automatically be loaded if they match the value of the *current environment* as set by the [`@currentEnv`](/reference/root-decorators/#currentenv) root decorator in your `.env.schema` file. The referenced flag item can be defined in that same file or brought in by [`@import`](/guides/import/). The files are applied with a specific precedence (increasing): * `.env.schema` - your schema file, which can also contain default values * `.env` - ⚠️ **not recommended**, will be loaded but better to use others * `.env.local` - local overrides *(gitignored)* * `.env.[currentEnv]` - environment-specific values * `.env.[currentEnv].local` - environment-specific local overrides *(gitignored)* Auto-detect with `VARLOCK_ENV` Instead of managing your own environment flag, you can use the built-in `$VARLOCK_ENV` variable which auto-detects the environment from your CI/deploy platform. See the [builtin variables reference](/reference/builtin-variables/) for details. .env.schema ```env-spec # @currentEnv=$VARLOCK_ENV ``` For example, consider the following `.env.schema`: .env.schema ```env-spec # @currentEnv=$APP_ENV # --- # @type=enum(development, test, staging, production) APP_ENV=development ``` Your environment flag key is set to `APP_ENV`, which has a default value of `development` - meaning that `.env.development` and `.env.development.local` will be loaded if they exist. To tell `varlock` to load `.env.staging` instead, you must set `APP_ENV` to `staging` - usually using an override passed into the process. For example: ```bash APP_ENV=staging varlock run -- node my-test-script.js ``` Loading `.env.local` in `test` environment Some tools ([dotenv-flow](https://github.com/kerimdzhanov/dotenv-flow), [Next.js](https://nextjs.org/docs/pages/guides/environment-variables#test-environment-variables), etc) make a special exception to skip loading `.env.local` if the current environment is `test`. Others tools ([Vite](https://vite.dev/config/#env-variables)) do not have any special handling. We chose to follow Vite’s lead, and instead provide a way to explicitly opt-in to that behavior: .env.local ```env-spec # @disable=forEnv(test) ``` Next.js precedence order Unlike Varlock (which matches Vite and dotenv-flow), [Next.js](https://nextjs.org/docs/pages/guides/environment-variables#environment-variable-load-order) swaps the order of precedence for `.env.local` vs `.env.[currentEnv]`. ## Advanced logic using functions [Section titled “Advanced logic using functions”](#advanced-logic-using-functions) On some platforms, you may not have full control over a build or boot command or the env vars passed into them. In this case, we can use functions to transform other env vars provided by the platform into the environment flag value we want. We can use [`remap()`](/reference/functions#remap) to transform a value according to a lookup, along with [regex literals](/reference/functions/#regex-like-strings) if we need to match a pattern instead of an exact value. For example, on the Cloudflare Workers CI platform, we get the current branch name injected as `WORKERS_CI_BRANCH`, which we can use to determine which environment to load: .env.schema ```env-spec # @currentEnv=$APP_ENV # --- # set to current branch name when build is running on Cloudflare CI, empty otherwise WORKERS_CI_BRANCH= # @type=enum(development, preview, production, test) APP_ENV=remap($WORKERS_CI_BRANCH, "main", production, /.*/, preview, undefined, development) ``` You’ll notice that `test` is one of the possible enum values, but it is not listed in the remap. When running tests, you would just explicitly set `APP_ENV` when invoking your command. ```bash APP_ENV=test varlock run -- your-test-command # or if your command is loading varlock internally APP_ENV=test your-test-command ``` or you could run a production style build locally `APP_ENV=production varlock run -- your-build-command` Tip You can also use the [`forEnv()` helper](/reference/functions/#forenv) to dynamically set whether configuration items are required or optional based on the current environment. ## Setting a *default* environment flag [Section titled “Setting a default environment flag”](#setting-a-default-environment-flag) You can set the default environment flag directly when running CLI commands using the `--env` flag: ```bash varlock load --env production ``` This is only useful if you do not want to create a new env var for your env flag, and you are only using varlock via CLI commands. Mostly it is used internally by some integrations to match existing default behavior, and should not be used otherwise. Caution If `@currentEnv` is used, this will be ignored! ## Using `currentEnv` in Turborepo [Section titled “Using currentEnv in Turborepo”](#using-currentenv-in-turborepo) Turborepo users should be aware of a common pitfall when using `varlock`’s `@currentEnv` in monorepos managed by Turborepo, especially since Turborepo v2.0+ now enables **Strict Environment Mode** by default. ### The Problem [Section titled “The Problem”](#the-problem) Turborepo, when running tasks, filters the environment variables available to each task. By default in Strict Mode, **only** variables listed in the `env` or `globalEnv` keys in your `turbo.json` are passed to your scripts. This means that if your environment flag set by `@currentEnv` (e.g., `APP_ENV`) is not explicitly listed, it will not be available to your process, even if you set it in your shell or CI environment. This can cause `varlock` to load the wrong environment, or fail to load the correct `.env.[currentEnv]` file. ### Solution: Add your environment flag to turbo.json [Section titled “Solution: Add your environment flag to turbo.json”](#solution-add-your-environment-flag-to-turbojson) To ensure your environment flag variable is always available to your scripts, add it to the `env` or `globalEnv` section of your `turbo.json`: turbo.json ```json { "globalEnv": ["APP_ENV"], "tasks": { "build": { "env": ["APP_ENV"] }, "dev": { "env": ["APP_ENV"] } } } ``` * Use `globalEnv` if the variable should be available to all tasks. * Use `env` under a specific task if only needed for that task. Now when you run the following: ```bash APP_ENV=production turbo run build ``` it will load the correct `.env.production` file because the override for `APP_ENV` is passed correctly to `turbo` and in turn to `varlock`. Tip Substitute whatever your environment flag is for `APP_ENV` in the above example. We’re *only* passing `APP_ENV` because that is the variable you are most likely going to want to override in your scripts. If there are other variables you want to pass to your scripts, they will need to be explicitly added as well. ### Setting the Environment Flag [Section titled “Setting the Environment Flag”](#setting-the-environment-flag) When running locally, or on a platform you control, you can set the env flag explicitly as an environment variable. However on some cloud platforms, there is a lot of magic happening, and the ability to set environment variables per branch is limited. In these cases you can use functions to transform env vars injected by the platform, like a current branch name, into the value you need. #### Local/Custom Scripts [Section titled “Local/Custom Scripts”](#localcustom-scripts) You can set the env var explicitly when you run a command, but often you will set it in `package.json` scripts: package.json ```json "scripts": { "build:preview": "APP_ENV=preview next build", "start:preview": "APP_ENV=preview next start", "build:prod": "APP_ENV=production next build", "start:prod": "APP_ENV=production next start", "test": "APP_ENV=test jest" } ``` #### Vercel [Section titled “Vercel”](#vercel) You can use the injected `VERCEL_ENV` variable to match their concept of environment types, while adding your own additional options. .env.schema ```env-spec # @currentEnv=$APP_ENV # --- # @type=enum(development, preview, production) VERCEL_ENV= # @type=enum(development, preview, production, test) APP_ENV=fallback($VERCEL_ENV, development) ``` For more granular environments, use the branch name in `VERCEL_GIT_COMMIT_REF` (see Cloudflare example below). #### Cloudflare Workers Build [Section titled “Cloudflare Workers Build”](#cloudflare-workers-build) Use the branch name in `WORKERS_CI_BRANCH` to determine the environment: .env.schema ```env-spec # @currentEnv=$APP_ENV # --- WORKERS_CI_BRANCH= # @type=enum(development, preview, production, test) APP_ENV=remap($WORKERS_CI_BRANCH, "main", production, /.*/, preview, undefined, development) ``` # Imports > Learn how to use the @import decorator to share environment variables across files and services The [`@import()` root decorator](/reference/root-decorators/#import) allows you to import schema and/or values from other sources (currently just `.env` files), making it easy to share config across services within a monorepo, split up large schemas, or reuse pre-defined schemas. Multiple `@import()` calls may be used, and an imported source may itself import more sources. **Basic examples:** ```env-spec # @import(./.env.imported) # import specific file # @import(./env-dir/) # import directory # @import(./.env.partial, pick=[K1, K2]) # import specific keys # @import(~/.env.shared) # import from home directory ``` ## Import source types [Section titled “Import source types”](#import-source-types) The first argument to `@import()` specifies where to look for file(s) to import. Currently only local file imports are supported, but we plan to support importing over http in a style similar to Deno’s http imports. For now, all imported files must be `.env` files (and may contain @env-spec decorators), but in the future, we may also support other formats (e.g., JSON, YAML, etc.) or even JS/TS files. Paths on Windows Import paths are written the same way on every platform. Use forward slashes, even on Windows: `@import(../shared/.env.schema)` works there, `@import(..\shared\.env.schema)` does not. Absolute Windows paths (`C:\...`, `C:/...`, and UNC paths like `\\server\share`) are not supported at all. Use a path relative to the importing file, or `~/` for a file in your home directory. The same applies to local paths passed to [`@plugin()`](/guides/plugins/). ### Single file [Section titled “Single file”](#single-file) * Path must begin with `./`, `../`, `/`, or `~/` * Imported file name must be begin with `.env.` ```env-spec # @import(./.env.common) ``` Home directory (`~`) You can import files from your home directory using the `~/` prefix. This is useful for sharing personal configuration across multiple projects without placing files inside any specific repo. ```env-spec # @import(~/.env.shared) ``` ### Directory [Section titled “Directory”](#directory) * Path must begin with `./`, `../`, `/`, or `~/` * Path must end with a trailing `/` * Multiple `.env.*` files will be detected and loaded, based on the current environment flag, similar to what happens in the current directory (see [environments guide](/guides/environments#loading-environment-specific-env-files)) * The environment flag value will be inherited, unless another `@currentEnv` is defined within the directory’s `.env.schema` ```env-spec # @import(../shared-config-dir/) ``` File vs directory: which should I import? * **Import a single file** when you want exactly that file’s contents: a shared `.env.common`, or specific keys from another schema via `pick`. * **Import a directory** (trailing `/`) when you want everything that directory would load on its own: its `.env.schema` **plus** sibling `.env`, `.env.local`, and environment-specific files, resolved by the current environment flag. This distinction matters when pulling in a root or sibling package. Importing its `.env.schema` file alone brings only the schema; importing the package’s **directory** also brings its values (including `.env.local` and `.env.[currentEnv]`). For the common “import the repo root” case in a monorepo, use the directory form (`# @import(../../)`). ## Partial imports [Section titled “Partial imports”](#partial-imports) By default, all items will be imported, but you can restrict which keys are brought in: * `pick=[...]` imports only an allowlist of keys * `omit=[...]` imports everything except a denylist of keys * You can’t use both in one import, and both accept simple globs (`*`, `?`) * If there is a chain of imports, an item is only imported if every ancestor import includes it (filters intersect) ```env-spec # @import(./.env.imported, pick=[KEY1, KEY2]) # allowlist # @import(./.env.imported, pick=[API_*]) # globs work too # @import(./.env.imported, omit=[SECRET]) # denylist ``` Positional keys are deprecated Older docs and configs list keys as positional args (`@import(./.env.imported, KEY1, KEY2)`). This still works but is deprecated and will warn. Use `pick=[KEY1, KEY2]` instead. ## Conditional imports [Section titled “Conditional imports”](#conditional-imports) While you can use the [`@disable`](/reference/root-decorators/#disable) root decorator to disable a file *from within that file*, you can also use the `enabled` parameter of the `@import()` decorator to conditionally load the file. The `enabled` parameter accepts any expression that evaluates to a boolean. It can be combined with partial imports to only import specific keys from a file. **Example:** .env.schema ```env-spec # Combine with partial imports # @import(./.env.features, pick=[FEATURE_X], enabled=eq($ENABLE_X, "true")) # --- ENABLE_X=true ``` ## Optional imports [Section titled “Optional imports”](#optional-imports) By default, `@import()` will cause a loading error if the specified file or directory does not exist. You can use the `allowMissing` parameter to make an import optional - if the file or directory doesn’t exist, it will be silently skipped without causing an error. The `allowMissing` parameter accepts a boolean value (defaults to `false`). It can be combined with partial imports and the `enabled` parameter. **Example:** .env.schema ```env-spec # Import if exists, skip if not # @import(./.env.local, allowMissing=true) ``` **Combine with other parameters:** .env.schema ```env-spec # Optional partial import with conditional loading # @import(./.env.features, pick=[FEATURE_X], enabled=true, allowMissing=true) ``` ## Import precedence and merging multiple sources [Section titled “Import precedence and merging multiple sources”](#import-precedence-and-merging-multiple-sources) Varlock is designed to load multiple definitions for a single item and merge them together. The common case would be taking schema info from `.env.schema` and overriding a value from another source (e.g., `.env.local`, `.env.production`, etc.), but there are many cases where root decorators, item decorators, and descriptions may be merged as well. To do this, we usually walk our data sources in decreasing order of precedence, until we find something defined for the value/decorator/etc we are evaluating. **Precedence rules are:** * Imported files are processed in order, with later imports overriding previous imports * Definitions and root decorators in the importing file override those in files it imports * For a directory, the precedence order is `.env.schema` < `.env` < `.env.local` < `.env.{currentEnv}` < `.env.{currentEnv}.local` For example, given a `.env.local` and a `.env.schema` that imports 2 files: .env.schema ```env-spec # @import(./.env.import1) # @import(./.env.import2) ``` The precedence order would be `.env.import1` < `.env.import2` < `.env.schema` < `.env.local`. Meaning if there was a value for `ITEM` in all 4 files, the final value used would be the one from `.env.local`. ## More details [Section titled “More details”](#more-details) * Root decorators that affect individual items (e.g., `@defaultRequired`) affect only the items that are defined in the file, not those in imported files * An item with no value at all (e.g., `ITEM=`) will be skipped when looking for a value / function to use, but its presence can be used to add other decorators/description to the item * If an imported file is marked with [`@disable`](/reference/root-decorators/#disable), it and any files it imports are skipped entirely * Importing the same source from multiple places (a “diamond”) is fine: it is loaded once and reused ## When things go wrong [Section titled “When things go wrong”](#when-things-go-wrong) ### `environment flag "..." must be defined within this schema` [Section titled “environment flag "..." must be defined within this schema”](#environment-flag--must-be-defined-within-this-schema) If you use [`@currentEnv`](/reference/root-decorators/#currentenv) to point at a variable (e.g. `# @currentEnv=$DEPLOY_ENV`), that item must either be defined in the same file or brought in by `@import`. A partial import must include the flag in `pick=[...]` (or not omit it). This commonly appears in monorepos when a sub-package imports shared keys from a parent schema: .env.schema (sub-package) ```env-spec # @currentEnv=$DEPLOY_ENV # @import(../../../, pick=[DEPLOY_ENV, AWS_REGION]) # --- MY_SERVICE_URL=... ``` That works: `DEPLOY_ENV` arrives via the import, and varlock uses it to load `.env.` files after this file’s imports finish. Two consequences of that ordering: * Declare the import that provides the flag before other imports. A directory imported earlier cannot select its `.env.` files yet, and an earlier import using `enabled=forEnv(...)` has no environment to check. Both cases fail with an error asking you to reorder. * `@import(enabled=...)` and `@disable` conditions in this file cannot depend on values set only in its `.env.` files, because those load after the conditions are evaluated. If you need that, define the flag locally and leave it out of the `pick` list. If the pick list omits the flag, `varlock load` fails with: ```txt environment flag "DEPLOY_ENV" is not defined in this file and was not provided by any @import ``` A file with `@currentEnv` and no imports at all still fails with `must be defined within this schema or imported via @import`. **Fixes:** * Add the flag to the import filter: `pick=[DEPLOY_ENV, ...]` * Define the env flag locally in the file that uses `@currentEnv`, and leave it out of the `pick` list so the imported default cannot conflict with the locally resolved value * Move `@currentEnv` to the shared schema where the flag is already defined. It carries through any import of that file or directory, including a partial import, as long as the flag passes the `pick`/`omit` filter ### Circular imports [Section titled “Circular imports”](#circular-imports) A circular chain of imports (e.g. `a` imports `b` which imports `a`) is not allowed. Varlock detects the cycle and fails with an explicit error showing the chain: ```txt Circular import detected: a/.env.schema -> b/.env.schema -> a/.env.schema ``` # Local encryption > Use varlock() to secure local untracked secrets with cross-platform encryption backends Varlock includes a built-in [`varlock()` function](/reference/functions/#varlock) that lets you secure local untracked secrets (typically in git-ignored env files like `.env.local`). This allows you to keep **everything out of plaintext** - even temporary local overrides, or a “secret-zero” which is needed by some plugins to load the rest of your sensitive data. Sensitive values will be stored *encrypted*, with the key linked to your local device, and requiring no extra configuration. The encryption mechanism varies per platform, but as it is tied to your device, **these values are not meant to be shared or committed to git**. .env.local ```env-spec PLAINTEXT=shh-im-secret # 🚨 danger SECURED=varlock(local:abc123...) # ✅ secured at rest ``` Keychain also available on macOS For mac users, you can also use [keychain()](/plugins/macos-keychain/). The experience will be similar, but encrypted values are stored in the system keychain. ## Quick start - existing secrets [Section titled “Quick start - existing secrets”](#quick-start---existing-secrets) You likely already have some plaintext secrets in a `.env.local` file. If not you can create one, and add some. Ensure those items are marked as [`@sensitive`](/reference/item-decorators/#sensitive) in your schema. Then you can use [`varlock encrypt`](/reference/cli/encryption/#encrypt) to encrypt them in-place: 1. Run `varlock encrypt --file .env.local` to encrypt them in-place 2. Sensitive plaintext values are replaced with `varlock("local:<***encrypted***>")` 3. Decryption happens automatically during `varlock load` / `varlock run` Only plaintext values of `@sensitive` items are encrypted, so you may run it multiple times. ## Using `varlock(prompt)` resolver [Section titled “Using varlock(prompt) resolver”](#using-varlockprompt-resolver) When you need to edit a value or add a new sensitive item, just set the value to `varlock(prompt)` and run `varlock load`. You will be prompted for the new value in a secure input prompt, and the encrypted value will be written back to the file automatically. .env.local ```env-spec EXISTING_ITEM=varlock(local:abc123...) NEW_ITEM=varlock(prompt) # will prompt you for new value ``` ## Using `varlock encrypt` CLI [Section titled “Using varlock encrypt CLI”](#using-varlock-encrypt-cli) You can also call the [`varlock encrypt`](/reference/cli/encryption/#encrypt) CLI command to get a secure prompt to encrypt a single value. It will spit out an item you can copy/paste into your file: $ varlock encrypt ```text ◇ Enter the value you want to encrypt │ ▪▪▪▪▪▪▪▪ Copy this into your .env.local file and rename the key appropriately: SOME_SENSITIVE_KEY=varlock("local:ABC123...") ``` #### `--file` option [Section titled “--file option”](#--file-option) As outlined above, you can also run `varlock encrypt --file .env.local` to encrypt all sensitive plaintext values in a file in-place. This is a great way to quickly encrypt many secrets at once. ## Core commands [Section titled “Core commands”](#core-commands) Use [`varlock encrypt`](/reference/cli/encryption/#encrypt) to create encrypted payloads: ```bash # Interactive: encrypt a single value varlock encrypt # Batch: encrypt all sensitive plaintext values in .env.local varlock encrypt --file .env.local ``` Use [`varlock reveal`](/reference/cli/encryption/#reveal) to inspect decrypted values safely: ```bash varlock reveal # interactive - select and reveal varlock reveal API_KEY # securely reveal specific item varlock reveal API_KEY --copy # copy to clipboard ``` Use [`varlock lock`](/reference/cli/encryption/#lock) to invalidate biometric session cache when stepping away: ```bash varlock lock ``` ## Backend selection overview [Section titled “Backend selection overview”](#backend-selection-overview) Varlock chooses the best available backend automatically: | Platform | Backend | Key Storage | Biometric | | ------------- | -------------------------- | -------------------------------- | ------------------------------------ | | macOS | Secure Enclave | Hardware Secure Enclave | Touch ID | | Windows | NCrypt TPM + Windows Hello | TPM (when available), else DPAPI | Windows Hello (face/fingerprint/PIN) | | Linux | TPM2 / Secret Service | TPM2 and/or system key store | Yes (when configured via polkit/PAM) | | All platforms | File-based fallback | `~/.varlock/` directory | No | If native capabilities are unavailable, varlock falls back to file-based local encryption. With npm installs, the native helper arrives through per-platform optional dependencies (`@varlock/native-helper-*`), so only your platform’s binary is downloaded. The one exception: Linux installs also receive the Windows helper, since WSL uses it for Windows Hello and TPM support. If your install skips optional dependencies (for example `--no-optional` / `--omit=optional`), the native helper is not installed and varlock uses the file-based fallback, with a warning printed when local encryption is used. **Verify encryption backend (macOS/Linux)** ```bash varlock-local-encrypt status ``` **Verify encryption backend (Windows/WSL)** ```bash varlock-local-encrypt.exe status ``` ## Unattended decryption (headless servers & CI) [Section titled “Unattended decryption (headless servers & CI)”](#unattended-decryption-headless-servers--ci) Local encryption isn’t just for laptops. On a headless host with a TPM (a home server, a CI runner, a container host), varlock seals the key to the machine’s TPM and decrypts automatically at load with **no prompt**. That makes it a good way to store a “secret-zero” (like a 1Password service account token) that bootstraps the rest of your secrets: .env.schema ```env-spec # @type=opServiceAccountToken @sensitive @internal OP_TOKEN=varlock("local:...") ``` On Linux this requires `tpm2-tools` and a TPM (see [Linux setup](#linux) below). The sealed value survives reboots and is bound to that specific machine. It can’t be copied to another host. This unattended path applies to backends whose key is unsealed to the host: Linux (TPM2) and Windows (NCrypt/TPM). **macOS is different:** Secure Enclave keys never leave the enclave, and every decrypt requires user presence (Touch ID or password), so there is no unattended decrypt on macOS. A headless Mac or CI runner falls back to file-based encryption instead. ### What this protects (and what it doesn’t) [Section titled “What this protects (and what it doesn’t)”](#what-this-protects-and-what-it-doesnt) Hardware-backed encryption protects your secrets **at rest**: a stolen disk, a leaked backup, or an env file committed by mistake can’t be decrypted off the machine. It does **not** protect against an attacker who is already running code as your user on that machine. They can ask the TPM to unseal the value exactly like varlock does. This is the same trade-off as tools like [`systemd-creds`](https://www.freedesktop.org/software/systemd/man/latest/systemd-creds.html), and it’s inherent to unattended decryption: if no human is present to approve, anything running as you can decrypt. If you want a presence gate (fingerprint / password / YubiKey via polkit + PAM) on every decrypt, register the policy: ```bash sudo varlock-local-encrypt setup --linux-biometrics ``` Once configured, varlock’s normal decrypt flow requires user presence, so everyday loads are no longer unattended. Enable this only on interactive machines, not headless servers. Treat this gate as a **consent prompt**, not an at-rest boundary. On Linux the TPM unseal isn’t bound to polkit, so (as noted above) other code already running as your user can still unseal directly. It means “a human approved this load,” not “only a human can ever decrypt.” ## Platform details & setup [Section titled “Platform details & setup”](#platform-details--setup) ### macOS [Section titled “macOS”](#macos) * Native Swift helper (Secure Enclave integration) * Uses system-native secure input / auth prompts * Includes a menu bar applet flow for native interactions * Hardware-backed key protection via Secure Enclave with biometric auth where supported ✅ No additional install/setup steps required ### Windows [Section titled “Windows”](#windows) * Native helper with **TPM-sealed** key protection when a TPM 2.0 chip is available (via NCrypt / Platform Crypto Provider) * Falls back to **DPAPI** (user-session-scoped) when TPM sealing is unavailable * **Windows Hello** gates interactive decrypts (fingerprint/face/PIN), separate from at-rest protection, same as before * Windows native and **WSL** workflows are both supported (WSL uses the Windows `varlock-local-encrypt.exe` via `--via-daemon`; Hello + TPM behavior is identical) * Automated daemon startup/installation behavior is built in for biometric session flows * WSL decrypt flows use a native bridge to the Windows daemon path * The Windows helper is only launched when varlock actually needs it: decrypting or encrypting a value, or writing to the encrypted disk cache. Loading a schema with no encrypted values never invokes it, so it adds no startup cost on WSL ✅ No additional install/setup steps required Tip On WSL, the standalone `install.sh` installs the Windows encryption helper (`varlock-local-encrypt.exe`) alongside the Linux `varlock-local-encrypt` binary by default. Pass `--skip-win-exe` to install only the Linux binary. On regular Linux and macOS, only the native binary is installed. #### Upgrading existing keys to TPM [Section titled “Upgrading existing keys to TPM”](#upgrading-existing-keys-to-tpm) New keys automatically use TPM sealing when available. Existing **DPAPI** keys are **auto-upgraded to TPM sealing on the next successful decrypt** (public key unchanged, no re-encryption of `.env` values). To upgrade without decrypting, use `varlock-local-encrypt rewrap-key --key-id varlock-default`. ### Linux [Section titled “Linux”](#linux) * Native Linux helper when available * User-presence verification via polkit/PAM (can support fingerprint/face/password depending system setup) Common packages/tools: * `tpm2-tools` (plus distro TPM2 libs such as `tpm2-tss`) * `polkit` for user-presence authorization flows * `xclip` or `xsel` for `varlock reveal --copy` Example installs: ```bash # Debian/Ubuntu sudo apt-get update sudo apt-get install -y tpm2-tools tpm2-tss policykit-1 xclip ``` ```bash # Fedora/RHEL variants sudo dnf install -y tpm2-tools tpm2-tss polkit xclip ``` If biometric/user-presence prompts are unavailable on Linux, complete policy setup (native helper command): ```bash sudo varlock-local-encrypt setup --linux-biometrics ``` ## Antivirus false positives [Section titled “Antivirus false positives”](#antivirus-false-positives) `varlock-local-encrypt` is Varlock’s official local-encryption helper, installed with the npm package and the standalone CLI. Microsoft Defender sometimes flags it as `Trojan:Win32/Wacatac.C!ml`, `Trojan:Script/Wacatac.C!ml`, or similar. These are generic machine-learning detections that fire on small, new, unsigned executables. The binary is safe when obtained from official Varlock releases. The npm package installs the helper through per-platform optional dependencies (`@varlock/native-helper-darwin`, `-linux-x64`, `-linux-arm64`, `-win32-x64`), so you only download the binary for your own platform. One exception: the Windows helper also installs on Linux, because WSL uses it for Windows Hello and TPM support. So a scanner on Linux may still see a Windows `.exe`, but a detection against one platform’s binary can no longer block installs on the others. **Verify the download.** Each [GitHub release](https://github.com/dmno-dev/varlock/releases) includes `SHA256SUMS.txt` with hashes for all native helpers. In npm installs the helper lives under `node_modules/@varlock/native-helper-/`; compare it against the matching `native-bins//...` line for your release version: ```powershell # Windows PowerShell Get-FileHash varlock-local-encrypt.exe -Algorithm SHA256 ``` ```bash # macOS / Linux shasum -a 256 varlock-local-encrypt ``` **If a file was quarantined.** On Windows, restore it from Windows Security → Protection history, or add an exclusion for your project directory. On macOS and Linux, restore it through your endpoint security console. Reinstalling from npm or the official release also works once the exclusion is in place. **Reporting.** If you hit a detection on a current release, [file an issue](https://github.com/dmno-dev/varlock/issues) with the file path and the detection name. You can also [submit the file to Microsoft](https://www.microsoft.com/en-us/wdsi/filesubmission) as a false positive, which is what gets the definition corrected for everyone. **What we do to prevent this.** Windows binaries are Authenticode-signed via Azure Artifact Signing, macOS binaries are Developer ID signed and notarized, and no binary is compressed with an executable packer (packers such as UPX are a well-known trigger for these detections). # MCP > Use varlock with local and remote MCP servers, plus the Varlock Docs MCP The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) enables AI agents to connect to external data sources and tools. When using MCP, you often need to handle sensitive configuration like API keys, database credentials, and authentication tokens. `varlock` provides a secure way to manage these secrets without exposing them in your configuration files or to AI agents. This guide covers three scenarios: * **Local MCP servers** using stdio transport with `varlock run` * **Remote MCP servers** using varlock’s Node.js integration * **Third-party MCP servers** using varlock to load secrets and pass them to the server ## Guides in this section [Section titled “Guides in this section”](#guides-in-this-section) * [Local MCP servers (stdio)](/guides/mcp/local/): wrap stdio MCP servers with `varlock run` * [Remote MCP servers](/guides/mcp/remote/): HTTP/SSE MCP with validated env * [Docs MCP](/guides/mcp/docs-mcp/): search varlock documentation from your agent ## Security Best Practices [Section titled “Security Best Practices”](#security-best-practices) ### 1. Never Store Secrets in Plain Text [Section titled “1. Never Store Secrets in Plain Text”](#1-never-store-secrets-in-plain-text) Always use external secret management such as 1Password or the built-in env var management in your deployment platform. ```env-spec # ❌ Never do this API_KEY=sk_live_1234567890abcdef # ✅ Use external secret management API_KEY=op(op://devTest/myVault/api-key) ``` ### 2. Use Environment-Specific Schemas [Section titled “2. Use Environment-Specific Schemas”](#2-use-environment-specific-schemas) Create separate schema files for different environments. See the [environments guide](/guides/environments) for detailed information on managing multiple environments with varlock. .env.schema ```env-spec # @defaultSensitive=true # @currentEnv=$APP_ENV # --- # env flag is used to determine which environment to load # default is development # @type=enum(development, staging, test, production) APP_ENV=development # Common configuration DATABASE_URL= API_KEY= ``` .env.development ```env-spec DATABASE_URL=postgresql://localhost:5432/dev_db API_KEY=op(op://devTest/myVault/dev-api-key) ``` .env.production ```env-spec DATABASE_URL=op(op://prodTest/prodVault/prod-database-url) API_KEY=op(op://prodTest/prodVault/prod-api-key) ``` ### 3. Validate Sensitive Data [Section titled “3. Validate Sensitive Data”](#3-validate-sensitive-data) Use varlock’s validation features to ensure data integrity: .env.schema ```env-spec # @type=string(startsWith="sk_", minLength=20) API_KEY= # @type=url DATABASE_URL= ``` ### 4. Monitor and Log Securely [Section titled “4. Monitor and Log Securely”](#4-monitor-and-log-securely) Use varlock’s redaction features to prevent sensitive data from appearing in logs: ```typescript import 'varlock/auto-load'; import { ENV } from 'varlock/env'; // Sensitive values are automatically redacted in logs console.log('API Key:', ENV.API_KEY); // Shows: [xx▒▒▒▒▒] console.log('Database URL:', ENV.DATABASE_URL); // Shows: [xx▒▒▒▒▒] ``` Note Redaction is on by default, see [root decorators - redactLogs](/reference/root-decorators/#redactlogs) for more information. ## Next Steps [Section titled “Next Steps”](#next-steps) * Learn more about [varlock’s environment specification](/env-spec/overview) * Explore [available data types](/reference/data-types) for validation * Check out [function reference](/reference/functions) for external integrations * Read about [secrets management](/guides/secrets) best practices # Docs MCP > Search and read varlock documentation from AI agents via MCP ## Docs MCP [Section titled “Docs MCP”](#docs-mcp) We also have a MCP server that allows you to search the Varlock docs. Yes, this getting a bit meta. The MCP server is available at: * (Streamable HTTP) * (Server-Sent Events) Clients that auto-discover MCP servers can read the server card at [`/.well-known/mcp/server-card.json`](https://varlock.dev/.well-known/mcp/server-card.json) (also served at `/.well-known/mcp.json`). No authentication is required. See below for tool-specific setup instructions. * Cursor Click below to install the MCP server in Cursor: [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=varlock-docs-mcp\&config=eyJjb21tYW5kIjoibnB4IG1jcC1yZW1vdGUgaHR0cHM6Ly9kb2NzLm1jcC52YXJsb2NrLmRldi9tY3AifQ%3D%3D) Or add the following to your `.cursor/mcp-servers.json` file: \~/.cursor/mcp.json ```json { "mcpServers": { "varlock-docs-mcp": { "command": "npx", "args": ["mcp-remote", "https://docs.mcp.varlock.dev/mcp"] } } } ``` * Claude To add a server in Claude Code, run the following command. Use `--scope user` so the server is available in **all** Claude Code sessions; without it, the install applies to the current project only. ```bash claude mcp add --scope user --transport http varlock-docs-mcp https://docs.mcp.varlock.dev/mcp ``` See [Claude’s documentation](https://docs.claude.com/en/docs/claude-code/mcp#option-1%3A-add-a-remote-http-server) for more information. * Codex To add the Varlock docs MCP server in Codex, run: ```bash codex mcp add varlock-docs-mcp --url https://docs.mcp.varlock.dev/mcp ``` * opencode To add a remote MCP server in Opencode, add the following to your `opencode.json` file: opencode.json ```json { "$schema": "https://opencode.ai/config.json", "mcp": { "varlock-docs-mcp": { "type": "remote", "url": "https://docs.mcp.varlock.dev/mcp", "enabled": true, } } } ``` See [Opencode’s documentation](https://opencode.ai/docs/mcp-servers/#remote) for more information. * VS Code To add a remote MCP server in VS Code, add the following to your `.vscode/mcp.json` file: .vscode/mcp.json ```json { "servers": { "varlock-docs-mcp": { "type": "http", "url": "https://docs.mcp.varlock.dev/mcp" } } } ``` See [VS Code’s documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers) for more information. # Local MCP servers > Run local stdio MCP servers with varlock for validated, redacted secrets ## Local MCP Servers with stdio [Section titled “Local MCP Servers with stdio”](#local-mcp-servers-with-stdio) For local development and testing, MCP servers often use stdio transport for communication with clients. This is perfect for using `varlock run` to securely load environment variables before starting your server. ### Server Setup [Section titled “Server Setup”](#server-setup) Create a `.env.schema` file for your MCP server: .env.schema ```env-spec # @plugin(@varlock/1password-plugin) # @initOp(allowAppAuth=true) # @defaultSensitive=true # @defaultRequired=true # --- # Database connection for MCP server # @type=url DATABASE_URL= # API key for external service # @type=string(startsWith="sk_") EXTERNAL_API_KEY= # Authentication secret # @type=string(minLength=32) AUTH_SECRET= # Server configuration # @sensitive=false # @type=number(min=1024, max=65535) SERVER_PORT=3000 # @sensitive=false # @type=enum(debug, info, warn, error) LOG_LEVEL=info ``` Create your local `.env.local` file with values from your 1Password vault: .env.local ```env-spec DATABASE_URL=op(op://devTest/myVault/database-url) EXTERNAL_API_KEY=op(op://devTest/myVault/external-api-key) AUTH_SECRET=op(op://devTest/myVault/auth-secret) LOG_LEVEL=debug ``` Note We’re using 1Password as an example here, but you can use any secret management tool you prefer as long as it has a CLI to load values. Update your MCP server’s `package.json` to use `varlock run`: package.json ```json { "name": "my-mcp-server", "scripts": { "start": "varlock run -- node server.js", "dev": "varlock run -- node --watch server.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^0.4.0" } } ``` ### Docker (local) [Section titled “Docker (local)”](#docker-local) For containerized local development, create a Dockerfile that uses varlock: Dockerfile ```dockerfile FROM node:22-alpine # Install varlock RUN npm install -g @varlock/cli WORKDIR /app # Copy package files COPY package*.json ./ COPY pnpm-lock.yaml ./ # Install dependencies RUN npm install -g pnpm && pnpm install # Copy application files COPY . . # Build the application RUN pnpm build # Use varlock run to start the server CMD ["varlock", "run", "--", "node", "dist/server.js"] ``` Build and run your Docker container: ```bash # Build the image docker build -t my-mcp-server:latest . # Run the container (for testing) docker run --rm -it my-mcp-server:latest ``` ### Client Configuration [Section titled “Client Configuration”](#client-configuration) * Cursor Create a Cursor configuration file to connect to your local MCP server: \~/.cursor/mcp-servers.json ```json { "mcpServers": { "my-local-server": { "command": "npm", "args": ["start"], "cwd": "/path/to/your/mcp-server", "env": { "NODE_ENV": "development" } } } } ``` For local MCP servers running in Docker: In this case an off-the-shelf MCP server is used, so we need to use `varlock run` to load the `GITHUB_TOKEN` environment variable and pass it to the server. \~/.cursor/mcp-servers.json ```json { "mcpServers": { "github": { "command": "varlock", "args": [ "run", "--", "docker", "run", "--rm", "-i", "ghcr.io/github/github-mcp-server:latest" ], "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } } } } ``` And the corresponding `.env.schema` file would look something like this: .env.schema ```env-spec # @defaultSensitive=true # @defaultRequired=true # --- # GitHub token # @type=string(startsWith="ghp_") GITHUB_TOKEN=op(op://devTest/myVault/github-token) ``` * Claude Desktop For Claude Desktop, create a configuration file: \~/.config/claude/desktop\_config.json ```json { "mcpServers": { "my-local-server": { "command": "npm", "args": ["start"], "cwd": "/path/to/your/mcp-server" } } } ``` For local MCP servers running in Docker: In this case an off-the-shelf MCP server is used, so we need to use `varlock run` to load the `GITHUB_TOKEN` environment variable and pass it to the server. \~/.config/claude/desktop\_config.json ```json { "mcpServers": { "github": { "command": "varlock", "args": [ "run", "--", "docker", "run", "--rm", "-i", "ghcr.io/github/github-mcp-server:latest" ], "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } } } } ``` * Custom Client Here’s an example of a custom MCP client that uses varlock for its own configuration: client.ts ```typescript import 'varlock/auto-load'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { spawn } from 'child_process'; import { ENV } from 'varlock/env'; const client = new Client( { name: 'my-mcp-client', version: '1.0.0' }, { capabilities: { tools: {} } } ); // Start the server process with varlock const serverProcess = spawn('pnpm', ['start'], { cwd: ENV.MCP_SERVER_PATH, stdio: ['pipe', 'pipe', 'pipe'] }); const transport = new StdioClientTransport(serverProcess.stdin, serverProcess.stdout); await client.connect(transport); // Use the client to interact with your MCP server const result = await client.callTool({ name: 'my-tool', arguments: {} }); ``` For third-party MCP servers that require API keys: third-party-client.ts ```typescript import 'varlock/auto-load'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { spawn } from 'child_process'; import { ENV } from 'varlock/env'; async function connectToOpenAIServer() { const client = new Client( { name: 'openai-mcp-client', version: '1.0.0' }, { capabilities: { tools: {} } } ); const serverProcess = spawn('npx', [ '@modelcontextprotocol/server-openai', '--api-key', ENV.OPENAI_API_KEY ], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, OPENAI_API_KEY: ENV.OPENAI_API_KEY } }); const transport = new StdioClientTransport(serverProcess.stdin, serverProcess.stdout); await client.connect(transport); return client; } async function connectToGitHubServer() { const client = new Client( { name: 'github-mcp-client', version: '1.0.0' }, { capabilities: { tools: {} } } ); const serverProcess = spawn('npx', [ '@modelcontextprotocol/server-github', '--token', ENV.GITHUB_TOKEN ], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, GITHUB_TOKEN: ENV.GITHUB_TOKEN } }); const transport = new StdioClientTransport(serverProcess.stdin, serverProcess.stdout); await client.connect(transport); return client; } ``` # Remote MCP servers > Deploy and configure remote MCP servers with varlock-validated environment ## Remote MCP Servers [Section titled “Remote MCP Servers”](#remote-mcp-servers) For production deployments, you’ll want to run MCP servers as standalone processes with varlock integrated directly into the server code. Note Code is for example purposes only. Server implementations will vary depending on the MCP server you’re using. See the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) for more information. ### Server Implementation [Section titled “Server Implementation”](#server-implementation) server.ts ```typescript import 'varlock/auto-load'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ENV } from 'varlock/env'; async function main() { const server = new Server( { name: 'my-mcp-server', version: '1.0.0' }, { capabilities: { tools: {} } } ); // Register tools with access to secure configuration server.setRequestHandler('tools/call', async (request) => { const { name, arguments: args } = request.params; switch (name) { case 'query-database': // Use secure database connection from config return await queryDatabase(ENV.DATABASE_URL, args); case 'call-external-api': // Use secure API key from config return await callExternalAPI(ENV.EXTERNAL_API_KEY, args); default: throw new Error(`Unknown tool: ${name}`); } }); const transport = new StdioServerTransport(process.stdin, process.stdout); await server.connect(transport); } async function queryDatabase(databaseUrl: string, args: any) { // Implementation using secure database URL console.log('Querying database with secure connection'); return { result: 'database query result' }; } async function callExternalAPI(apiKey: string, args: any) { // Implementation using secure API key console.log('Calling external API with secure key'); return { result: 'api call result' }; } main().catch(console.error); ``` ### Production Deployment [Section titled “Production Deployment”](#production-deployment) For production, create environment-specific schema files. See the [environments guide](/guides/environments) for detailed information on managing multiple environments with varlock. .env.schema ```env-spec # @defaultSensitive=true # @defaultRequired=true # @currentEnv=$APP_ENV # --- # env flag is used to determine which environment to load # default is development # @type=enum(development, staging, test, production) APP_ENV=development # Database connection # @type=url DATABASE_URL= # External API credentials # @type=string(startsWith="sk_") EXTERNAL_API_KEY= # Authentication # @type=string(minLength=32) AUTH_SECRET= # Server settings # @sensitive=false # @type=number(min=1024, max=65535) SERVER_PORT=3000 # @sensitive=false # @type=enum(debug, info, warn, error) LOG_LEVEL=info ``` .env.production ```env-spec DATABASE_URL=op(op://prodTest/prodVault/prod-database-url) EXTERNAL_API_KEY=op(op://prodTest/prodVault/prod-external-api-key) AUTH_SECRET=op(op://prodTest/prodVault/prod-auth-secret) SERVER_PORT=3000 LOG_LEVEL=warn ``` Then in the command to start the server, you can use the `varlock run` command to load the environment variables with the correct `currentEnv` environment override. ```bash APP_ENV=production varlock run -- node server.js ``` # Migrate from dotenv > How to migrate from dotenv (CLI and npm package) to varlock ## Why migrate from dotenv? [Section titled “Why migrate from dotenv?”](#why-migrate-from-dotenv) * **Validation**: Catch misconfigurations early with schema-driven validation. * **Security**: Redact secrets and prevent accidental leaks. * **Type-safety**: Generate types automatically for your config. * **External secrets**: Load secrets from providers like 1Password, AWS, and more. *** ## Migrating from dotenvx CLI [Section titled “Migrating from dotenvx CLI”](#migrating-from-dotenvx-cli) If you use `dotenvx` via the CLI, you can switch to `varlock run`: ```bash # Before (dotenv CLI) dotenvx run -- node app.js # env specific dotenvx run -f .env.staging -- node app.js # install varlock brew install dmno-dev/tap/varlock # After (varlock CLI) varlock run -- node app.js # To specify an environment, set your env flag (see your .env.schema) APP_ENV=staging varlock run -- node app.js ``` Note You can use multiple `.env` files (see the [Environments guide](/guides/environments/)). *** ## Migrating from dotenv npm package [Section titled “Migrating from dotenv npm package”](#migrating-from-dotenv-npm-package) Initialize your project with `varlock init` to install `varlock` and generate a `.env.schema` from any existing `.env` files. * npm ```bash npx varlock init ``` * pnpm ```bash pnpm dlx varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx varlock init ``` * yarn ```bash yarn dlx varlock init ``` Then to use `varlock` in your code, you can replace `dotenv/config` with `varlock/auto-load`: index.js ```diff // Before (dotenv) import 'dotenv/config'; import 'varlock/auto-load'; ``` Finally, you can remove `dotenv` from your dependencies: * npm ```bash npm uninstall dotenv ``` * pnpm ```bash pnpm remove dotenv ``` * bun ```bash bun remove dotenv ``` * yarn ```bash yarn remove dotenv ``` * vlt ```bash vlt uninstall dotenv ``` Unset items are not injected as empty strings With varlock, a schema item with no value set (`MY_VAR=`) resolves to undefined and is left out of `process.env` entirely, so `process.env.MY_VAR ?? 'fallback'` works as expected. Some dotenv-style setups instead end up with empty strings for unset vars. If your code relies on that, set [`@injectUndefinedAsEmpty`](/reference/root-decorators/#injectundefinedasempty) in your `.env.schema`. ## Using overrides [Section titled “Using overrides”](#using-overrides) If `dotenv` is being used under the hood of one of your dependencies, you can use `overrides` to swap in `varlock` instead. * npm See [NPM overrides docs](https://docs.npmjs.com/cli/v9/configuring-npm/package-json#overrides) package.json ```diff { +"overrides": { +"other-dep": { +"dotenv": "npm:varlock" + } + } } ``` * yarn See [yarn resolutions docs](https://yarnpkg.com/configuration/manifest#resolutions) package.json ```diff { +"resolutions": { +"**/dotenv": "npm:varlock" + }, } ``` **In a monorepo, this override must be done in the monorepo’s root package.json file!** * pnpm * pnpm version 10+ See [pnpm v10 overrides docs](https://pnpm.io/settings#overrides) pnpm-workspace.yaml ```diff +overrides: +"dotenv": "npm:varlock" ``` **This must be set in `pnpm-workspace.yaml`, which lives at the root of your repo, regardless of whether you are using a monorepo or not.** * pnpm version 9 ### pnpm version 9 [Section titled “pnpm version 9”](#pnpm-version-9) See [pnpm v9 overrides docs](https://pnpm.io/9.x/package_json#pnpmoverrides) package.json ```diff { +"pnpm": { +"overrides": { +"dotenv": "npm:varlock" + } + } } ``` **In a monorepo, this override must be done in the monorepo’s root package.json file!** * pnpm version 10+ See [pnpm v10 overrides docs](https://pnpm.io/settings#overrides) pnpm-workspace.yaml ```diff +overrides: +"dotenv": "npm:varlock" ``` **This must be set in `pnpm-workspace.yaml`, which lives at the root of your repo, regardless of whether you are using a monorepo or not.** * pnpm version 9 ### pnpm version 9 [Section titled “pnpm version 9”](#pnpm-version-9) See [pnpm v9 overrides docs](https://pnpm.io/9.x/package_json#pnpmoverrides) package.json ```diff { +"pnpm": { +"overrides": { +"dotenv": "npm:varlock" + } + } } ``` **In a monorepo, this override must be done in the monorepo’s root package.json file!** *** ## Further reading [Section titled “Further reading”](#further-reading) * [Environments guide](/guides/environments) * [Schema guide](/guides/schema) * [Reference: CLI commands](/reference/cli-commands) * [Reference: Item decorators](/reference/item-decorators) # Monorepos > Patterns for using varlock in monorepos and multi-service repositories Monorepos often mix shared platform config with app-specific settings. Varlock supports both per-package schemas and shared root config, with [`@import()`](/reference/root-decorators/#import) to compose them. This guide walks through common layout choices, incremental adoption, CI, and containers. ## Schema layout [Section titled “Schema layout”](#schema-layout) The recommended pattern is **one `.env.schema` per project**. Each app or service owns a schema next to its code and imports whatever shared config it needs from the repo root or sibling packages. Avoid funneling every variable into a single central env directory; keep each project’s config with the project. ### One schema per project (recommended) [Section titled “One schema per project (recommended)”](#one-schema-per-project-recommended) Give each app or service its own `.env.schema`. This keeps config next to the code that uses it, lets you adopt varlock one package at a time, and works well even when services are mostly independent. ```text .env.schema # shared root config (imported by apps that need it) packages/ web/.env.schema api/.env.schema ``` Varlock resolves env files from each package’s **current working directory**. It does not walk up to a parent directory automatically. Use [`@import()`](/guides/import/) when a package needs config defined elsewhere. Packages with no env vars A library or utility package that doesn’t read any environment variables doesn’t need a `.env.schema` at all. Only add one where a package actually loads config. ### Sharing config from the root or siblings [Section titled “Sharing config from the root or siblings”](#sharing-config-from-the-root-or-siblings) Put cross-cutting config (database URLs used by many services, shared feature flags, platform identifiers) in a schema at the repo root, then **import the root directory** from each project that needs it: packages/web/.env.schema ```env-spec # Import only the shared keys this app needs # @import(../../, pick=[DATABASE_URL, REDIS_URL, SENTRY_*]) # @import(../api/, pick=[API_PUBLIC_URL]) # --- # App-specific config # @type=url @required PUBLIC_SITE_URL= ``` By default every key in the imported source is brought in, but importing only the keys an app actually needs is usually the cleaner default. Use `pick=[...]` (allowlist) or `omit=[...]` (denylist) so each schema declares exactly what it depends on. Importing the **directory** (`../../`) rather than the schema file (`../../.env.schema`) means all files like `.env.local` and [environment-specific](/guides/environments/) are loaded too, not just the schema, following the same precedence as the current directory. Imported definitions merge with the importing schema, and the importing file wins. See the [Imports guide](/guides/import/) for partial imports, precedence, and conditional loading. Use `@import()` to pull in a root or shared schema without copying keys, to split a large schema into focused files (e.g. `.env.database`, `.env.features`), or to share a directory of common env files across services (`# @import(../../shared-config/)`). You can also **import directly from a sibling package**, which lets you keep a config item with its logical owner instead of hoisting everything to the root. Define the item once in the package that owns it, then `pick` just what you need, like the `../api/` import in the example above. Avoid circular imports Imports must flow in one direction. If `web` imports `api` and `api` also imports `web` (directly or through a chain), varlock detects the cycle and loading fails with an explicit error naming the chain (e.g. `Circular import detected: web/.env.schema -> api/.env.schema -> web/.env.schema`). Keep each shared item with a single owner and import one way; if two packages genuinely need each other’s values, hoist those keys into a common file (e.g. the repo root) that both import. This also applies between the root and a package. Pick one direction: either the root schema imports package directories, or packages import the root schema. If the root imports `./packages/web/` and `packages/web/.env.schema` imports `../../.env.schema` back, that is a cycle and fails the same way when varlock is run from the root, even though it works when run from inside `packages/web`. Shared environment flag If several projects share an environment flag like `APP_ENV`, define it in the shared schema and import it. The simplest option is to put `@currentEnv=$APP_ENV` in the shared schema as well: it carries through any `@import` of that file or directory, including a partial import, as long as `APP_ENV` passes the `pick`/`omit` filter. Alternatively, a package can point its own `@currentEnv` at the imported key: packages/web/.env.schema ```env-spec # @currentEnv=$APP_ENV # @import(../../.env.schema, pick=[APP_ENV, DATABASE_URL]) # @import(../api/, pick=[API_PUBLIC_URL]) # --- ``` Declare the import that provides the flag before any other imports. Until the flag is known, varlock cannot select `.env.[currentEnv]` files, so a directory imported earlier would be loaded without them, and an earlier import using `enabled=forEnv(...)` has no environment to check. Also note that when the flag arrives via import, this package’s own `.env.[currentEnv]` files load after its imports finish, so `@import(enabled=...)` and `@disable` conditions in this schema cannot depend on values set only in those files. If you need that, define the flag locally instead and leave it out of the `pick` list, so the imported default cannot conflict with the locally resolved value. For per-service environment variations, see [Loading environment-specific `.env` files](/guides/environments#loading-environment-specific-env-files). ### Pointing varlock at the right directory [Section titled “Pointing varlock at the right directory”](#pointing-varlock-at-the-right-directory) In most cases you don’t need anything here. A `.env.schema` in each package, loaded from its working directory, is the right default. Reach for the options below only when a package’s env files live somewhere other than its cwd. When an app’s env files live outside its default cwd (or you need multiple roots), set `varlock.loadPath` in that package’s `package.json`: apps/web/package.json ```json { "varlock": { "loadPath": "./" } } ``` You can also pass an explicit path on the CLI with `--path` (or `-p`), useful in CI or scripts that run from the monorepo root: * npm ```bash npm exec -- varlock load --path apps/web/ ``` * pnpm ```bash pnpm exec -- varlock load --path apps/web/ ``` * bun ```bash bunx varlock load --path apps/web/ ``` * vlt ```bash vlx -- varlock load --path apps/web/ ``` * yarn ```bash yarn exec -- varlock load --path apps/web/ ``` For loading from multiple directories in one package, see [Loading from multiple directories](/integrations/vite/#loading-from-multiple-directories) in the Vite integration docs (the same `loadPath` array works for all commands). ## Next.js apps [Section titled “Next.js apps”](#nextjs-apps) Next.js requires a workspace-root `@next/env` override and has extra footguns when only some apps use varlock. See [Next.js in a monorepo](/integrations/nextjs/#monorepos) for package-manager overrides, incremental adoption, and troubleshooting, then return here for shared schema layout and CI patterns. ## Turborepo and strict env mode [Section titled “Turborepo and strict env mode”](#turborepo-and-strict-env-mode) Turborepo v2+ enables **Strict Environment Mode** by default. Tasks only receive env vars that are listed in `turbo.json`, so any var varlock reads from the ambient environment (not just your `@currentEnv` flag) must be declared there, or it won’t reach the task. This includes: * your environment flag (e.g. `APP_ENV`), or varlock may load the wrong `.env.[currentEnv]` file * the **CI-detection vars** that built-ins like [`VARLOCK_ENV`](/reference/builtin-variables/#varlock_env) rely on (`CI`, `VERCEL`, `WORKERS_CI_BRANCH`, etc.) * any var passed in at the root `turbo` invocation, or otherwise present in the environment, that a `VARLOCK_*` setting or a resolver in your schema depends on Declare these in `globalEnv` (or per-task `env`) in `turbo.json`. Full explanation and examples are in [Using `currentEnv` in Turborepo](/guides/environments#using-currentenv-in-turborepo). ## Incremental adoption [Section titled “Incremental adoption”](#incremental-adoption) You do not need to migrate every package at once. A practical order: 1. **Pick one app**: run `varlock init` in that package and commit its `.env.schema`. 2. **Wire the integration** for that stack only (Next.js plugin, Vite plugin, or `varlock run` for scripts). 3. **Expand shared config**: extract common keys to a root or shared schema and `@import()` them from apps as you go. 4. **Leave untouched apps alone**: packages without a `.env.schema` should keep using their existing env loading until you migrate them. CLI-only services Services that do not need a framework integration can start with [`varlock run`](/reference/cli/load-and-run/#run) and a local `.env.schema` without changing root dependency overrides. ## Typed `ENV` across packages [Section titled “Typed ENV across packages”](#typed-env-across-packages) By default, [`@generateTsTypes`](/reference/root-decorators/#generatetstypes) globally augments the `varlock/env` module so `import { ENV } from 'varlock/env'` is typed. There is only one `varlock/env` module in a TypeScript program, so when several packages each generate types with **different** schemas, their augmentations merge, so a package can end up seeing keys it never declared, or hit conflicting types. To keep each package’s `ENV` isolated, set `exposeEnv=local`. It emits a package-local file that re-exports the runtime `ENV` typed to *that* package’s schema, with no global augmentation. The `process.env` / `import.meta.env` augmentations default off too, so nothing from this package merges into the global type scope: packages/api/.env.schema ```env-spec # @generateTsTypes(path=./env.ts, exposeEnv=local) ``` Then import from the generated file instead of `varlock/env`: packages/api/src/server.ts ```ts import { ENV } from '../env'; // typed to this package's schema only ``` Runtime behavior is identical (it’s the same underlying proxy); only the types are scoped locally. The output path must be a `.ts` file (not `.d.ts`) since it contains a runtime re-export. ## Mixed stacks (JavaScript and other languages) [Section titled “Mixed stacks (JavaScript and other languages)”](#mixed-stacks-javascript-and-other-languages) Monorepos often combine Node apps with Python, Go, or other runtimes. Varlock’s CLI works the same everywhere: load and validate from a schema, then exec your command with the resolved env injected. * npm ```bash npm exec -- varlock run -- python manage.py runserver ``` * pnpm ```bash pnpm exec -- varlock run -- python manage.py runserver ``` * bun ```bash bunx varlock run -- python manage.py runserver ``` * vlt ```bash vlx -- varlock run -- python manage.py runserver ``` * yarn ```bash yarn exec -- varlock run -- python manage.py runserver ``` Run the command from the service directory (or pass `--path` to its env folder). Each language can keep its own `.env.schema`; there is no requirement to share a single root file. For more detail, see [Other languages](/integrations/other-languages/). ## Container builds [Section titled “Container builds”](#container-builds) Container images often copy a single app directory, not the whole monorepo. If your schema uses `@import()` to pull files from sibling packages or the repo root, those paths must exist in the build context, otherwise the load graph fails at build time. The [Docker guide](/integrations/docker/) covers the official image, multi-stage copies of the varlock binary, and running `varlock run` as your container entrypoint. Run [`varlock flatten`](/reference/cli/project/#flatten) while the full monorepo is available (e.g. in the builder stage of a multi-stage build) to collapse the import graph into a self-contained directory that travels with the package. See the [Docker guide](/integrations/docker/#monorepos-and-partial-build-context) for the full workflow. # OIDC Workload Identity > Authenticate with secret providers using OIDC tokens from your deployment platform, with no long-lived credentials Many deployment platforms issue short-lived [OIDC](https://openid.net/developers/how-connect-works/) tokens that your application can exchange for temporary credentials with secret providers. This eliminates the need to store long-lived API keys or service account credentials in your deployment environment: no “secret zero” needed to fetch the rest of your secrets. Varlock supports OIDC workload identity federation across multiple plugins and platforms. In most cases, the OIDC token is **auto-detected** from your deployment platform, so you just need to configure the provider side. ## How it works [Section titled “How it works”](#how-it-works) ```plaintext 1. Your app starts on a deployment platform (Vercel, GitHub Actions, etc.) 2. Varlock auto-detects the platform and requests an OIDC token 3. The token is exchanged with your secret provider for temporary credentials 4. Secrets are fetched using the temporary credentials (valid 15 min – 1 hour) ``` No long-lived secrets are stored anywhere in the deployment environment. ## Supported plugins [Section titled “Supported plugins”](#supported-plugins) | Plugin | OIDC mechanism | Setup guide | | -------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------- | | [AWS Secrets](/plugins/aws-secrets/) | STS `AssumeRoleWithWebIdentity` | [OIDC setup](/plugins/aws-secrets/#oidc-authentication) | | [Azure Key Vault](/plugins/azure-key-vault/) | Federated credential (JWT assertion) | [OIDC setup](/plugins/azure-key-vault/#oidc-federated-credentials) | | [Google Secret Manager](/plugins/google-secret-manager/) | Workload Identity Federation | [OIDC setup](/plugins/google-secret-manager/#oidc-workload-identity-federation) | | [HashiCorp Vault](/plugins/hashicorp-vault/) | JWT/OIDC auth method | [OIDC setup](/plugins/hashicorp-vault/#jwtoidc-authentication) | | [Infisical](/plugins/infisical/) | OIDC machine identity | [OIDC setup](/plugins/infisical/#oidc-authentication) | | [Akeyless](/plugins/akeyless/) | OIDC access type | [OIDC setup](/plugins/akeyless/#oidc-authentication) | ## Supported platforms [Section titled “Supported platforms”](#supported-platforms) Varlock auto-detects OIDC tokens from these deployment platforms: | Platform | How it works | Token source | Setup | | ------------------ | --------------------------------------------------- | --------------------------- | -------------------------- | | **Vercel** | Token available in builds and serverless functions | `VERCEL_OIDC_TOKEN` env var | [details](#vercel) | | **GitHub Actions** | Requires `permissions: id-token: write` in workflow | Token request API | [details](#github-actions) | | **GitLab CI** | Requires `id_tokens` in `.gitlab-ci.yml` | `CI_JOB_JWT_V2` env var | [details](#gitlab-ci) | | **Fly.io** | Available to all Fly Machines | Internal API endpoint | [details](#flyio) | | **GCP Cloud Run** | Available via metadata server | Metadata endpoint | [details](#gcp-cloud-run) | For platforms not listed above, you can pass an explicit OIDC token using the `oidcToken` parameter on each plugin. See [Custom / other platforms](#custom--other-platforms). ### Vercel [Section titled “Vercel”](#vercel) OIDC tokens are available automatically in Vercel builds and serverless functions. No additional configuration is needed on the Vercel side. To use with a secret provider, you need to configure the provider to trust Vercel’s OIDC issuer: * **Issuer URL:** `https://oidc.vercel.com` * **Audience:** Varies by provider (see plugin-specific docs) See [Vercel OIDC documentation](https://vercel.com/docs/oidc) for details. ### GitHub Actions [Section titled “GitHub Actions”](#github-actions) Add `id-token: write` permission to your workflow: ```yaml jobs: deploy: permissions: id-token: write contents: read steps: - uses: actions/checkout@v7 # ... your build/deploy steps ``` * **Issuer URL:** `https://token.actions.githubusercontent.com` * **Subject claim:** `repo:/:ref:refs/heads/` (varies by trigger) See [GitHub Actions OIDC documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) for details. ### GitLab CI [Section titled “GitLab CI”](#gitlab-ci) Add `id_tokens` to your `.gitlab-ci.yml`: ```yaml job_name: id_tokens: SIGSTORE_ID_TOKEN: aud: https://your-provider-audience script: - # your build/deploy steps ``` * **Issuer URL:** `https://gitlab.com` (or your self-hosted instance) See [GitLab CI OIDC documentation](https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html) for details. ### Fly.io [Section titled “Fly.io”](#flyio) OIDC tokens are available automatically to all Fly Machines. No additional configuration needed. * **Issuer URL:** `https://oidc.fly.io` See [Fly.io OIDC documentation](https://fly.io/docs/security/openid-connect/) for details. ### GCP Cloud Run [Section titled “GCP Cloud Run”](#gcp-cloud-run) Identity tokens are available via the GCP metadata server for services with a service account. * **Issuer URL:** `https://accounts.google.com` ### Custom / other platforms [Section titled “Custom / other platforms”](#custom--other-platforms) For platforms that aren’t auto-detected, pass an explicit OIDC JWT token using the `oidcToken` parameter on any supported plugin: ```env-spec # @initAws(region=us-east-1, oidcRoleArn="arn:aws:iam::123:role/my-role", oidcToken=$MY_OIDC_TOKEN) ``` ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Token not detected [Section titled “Token not detected”](#token-not-detected) * **Vercel:** Ensure `VERCEL_OIDC_TOKEN` is available (check Vercel project settings) * **GitHub Actions:** Add `permissions: id-token: write` to your workflow/job * **GitLab CI:** Configure `id_tokens` in your job definition * **Other platforms:** Pass an explicit token via `oidcToken=...` ### Token exchange failed [Section titled “Token exchange failed”](#token-exchange-failed) * Verify the OIDC issuer URL matches exactly what you configured on the provider * Check that the audience claim matches what the provider expects * Ensure the subject/claims conditions match your deployment context ### Permission denied after successful auth [Section titled “Permission denied after successful auth”](#permission-denied-after-successful-auth) * The OIDC exchange succeeded, but the resulting identity doesn’t have the right permissions * Check role/policy assignments on the provider side # Plugins > Using plugins with varlock Plugins allow extending the functionality of Varlock. Specifically they may introduce new [root decorators](/reference/root-decorators/), [item decorators](/reference/item-decorators/), [data types](/reference/data-types/), and [resolver functions](/reference/functions/). 1Password plugin example ```env-spec # @plugin(@varlock/1password-plugin) # load + install plugin # @initOp(token=$OP_TOKEN, allowAppAuth=true) # init via custom root decorator # --- # @type=opServiceAccountToken # custom data type OP_TOKEN= # @sensitive XYZ_API_KEY=op(op://api-prod/xyz/api-key) # custom resolver function ``` This enables use cases like: * loading values from cloud providers or locally running services * adding domain-specific validation/coercion logic via custom data types * generating values dynamically via custom resolver functions Plugins are authored in TypeScript and can be loaded via local files, or from package registries like npm. Varlock will handle downloading and caching plugins automatically. ## Plugin installation [Section titled “Plugin installation”](#installation) Plugins are loaded using the `@plugin()` root decorator with an npm package name. How you specify the version depends on whether you are in a JavaScript project or using the standalone binary. ### JavaScript projects [Section titled “JavaScript projects”](#javascript-projects) In a JavaScript project (where a `package.json` file is present), you must install the plugin as a dependency in your project and load it **without** a version specifier. Varlock will use the version installed in your `node_modules` directory. .env.schema ```env-spec # @plugin(@varlock/a-plugin) # uses the locally installed version ``` You can optionally add a version specifier to validate that the installed version satisfies a [semver range](https://devhints.io/semver), but the local version is always what gets loaded. If the installed version doesn’t match the specified range, an error will be thrown. .env.schema ```env-spec # @plugin(@varlock/a-plugin@^2.3.4) # validates installed version is >=2.3.4 <3.0.0 ``` ### Standalone binary [Section titled “Standalone binary”](#standalone-binary) When using the standalone binary (no `package.json` present), you must specify a **fixed version number** (e.g., `1.2.3`). Semver ranges are not supported in this mode. Varlock will automatically download and cache the plugin from npm. .env.schema ```env-spec # @plugin(@varlock/a-plugin@1.2.3) # downloads and caches v1.2.3 from npm ``` Third-party plugin confirmation When downloading a third-party (non-`@varlock/*`) plugin for the first time, Varlock will prompt you to confirm before downloading. Once confirmed and cached, subsequent runs will not re-prompt. You can also install the plugin via your `package.json` to skip the prompt entirely. ### Local plugins [Section titled “Local plugins”](#local-plugins) You can point `@plugin()` at a local path instead of an npm package, either a single file or a package directory: .env.schema ```env-spec # @plugin(./my-plugin.js) # single file # @plugin(../shared/plugin/) # package directory (trailing slash) ``` Local plugins must be **self-contained**: a single built file, or a package that bundles its dependencies. Varlock loads a plugin from its file directly and does not install anything for it, so a plugin that relies on separate `node_modules` dependencies will fail to load once it is moved away from where those dependencies live (for example in a compiled binary, or in the output of [`varlock flatten`](/reference/cli/project/#flatten)). If you author a plugin with dependencies, bundle it into a single file first. The official `@varlock/*` plugins are each a single bundled file. ## Plugin scope [Section titled “Plugin scope”](#plugin-scope) Plugins are loaded globally, and the additional functionality they provide will be available in all `.env` files in your project. Only a single `@plugin()` decorator is needed to load the plugin, even if multiple files use its functionality. If a plugin is loaded in multiple files, no error will be thrown, as long as they all use the same version. Note that plugins will not be loaded from an inactive file - for example an environment-specific file that does not match the current environment, or one that uses the [`@disable` root decorator](/reference/root-decorators/#disable). No specific namespacing or prefixes are enforced, and any naming conflicts will trigger an error, but plugins will use specific names to avoid conflicts. ## Initialization [Section titled “Initialization”](#initialization) Plugins are initialized using custom root decorators that they introduce. In some cases, no specific initialization is needed, and in others, you may need to initialize multiple instances of a plugin with different options, referred to by some identifier. How (or if) a plugin needs to be initialized depends on the specific plugin and can depend on the the external service’s data/auth model. A plugin initialization root decorator is used to set IDs, toggle features, and wire up auth. Note that sensitive data should be passed in via references to config items within your schema. Plugin initialization example ```env-spec # @initOp(account=acmeco, token=$OP_TOKEN, allowAppAuth=forEnv(dev)) # --- # @type=opServiceAccountToken @sensitive OP_TOKEN= ``` ### Multiple plugin instances [Section titled “Multiple plugin instances”](#multiple-plugin-instances) In secret storage tools, you should segment your data to follow the [*principle of least privilege*](https://en.wikipedia.org/wiki/Principle_of_least_privilege), so that different environments/services/devs only have access to the minimal secrets they need. At the very least, this usually means splitting your extra sensitive prod secrets from everything else, but it can be as fine-grained as needed. We cannot always assume that you won’t need access to multiple segments at the same time. In these cases, a plugin may be designed to be initialized multiple times with some kind of id parameter. Resolver functions and decorators can then accept an additional parameter to specify which instance to use. .env.schema ```env-spec # @plugin(@varlock/1password-plugin) # @initOp(id=dev, token=$OP_TOKEN_DEV, allowAppAuth=forEnv(dev)) # @initOp(id=prod, token=$OP_TOKEN_PROD, allowAppAuth=false); # --- # @type=opServiceAccountToken @sensitive OP_TOKEN_DEV= # @type=opServiceAccountToken @sensitive OP_TOKEN_PROD= XYZ_API_KEY=op(dev, op://api-creds-dev/xyz/api-key) ``` .env.production ```env-spec XYZ_API_KEY=op(prod, op://api-creds-prod/xyz/api-key) ``` *While the 1Password plugin can be set up using a single instance (using a higher scoped service account for prod) you might want to use multiple instances if you want to make sure you don’t accidentally access prod secrets while working locally.* ## Usage [Section titled “Usage”](#usage) Once installed, all decorators, data types, and resolver functions provided by the plugin will be available for use within your `.env` files. These are available globally, and ordering is not important. Some decorators or resolver functions may require the plugin to be initialized and will throw an error if not set up properly. Please refer to the specific plugin’s documentation for details on usage. ## Plugin caching [Section titled “Plugin caching”](#plugin-caching) Plugins can use varlock’s built-in [cache](/guides/caching/) to avoid repeated API calls or hold short-lived tokens. Caching is opt-in - it is only active when the user enables it (typically via a `cacheTtl` init option) and the global cache mode allows it. Plugin authors get a scoped cache accessor on the plugin instance: ```ts // primary API - atomic get-or-set const value = await plugin.cache.getOrSet('some-key', '1h', () => fetchFromApi()); // lower-level helpers await plugin.cache.get('some-key'); await plugin.cache.set('some-key', value, '1h'); plugin.cache.delete('some-key'); ``` Keys are automatically prefixed with `plugin:{name}:`, so plugins cannot collide with each other’s cache entries. # Credential proxy > Broker credentialed HTTP for AI agents without putting secrets in the child env The credential proxy lets you run an AI agent (or any untrusted component) so that it **never sees real secrets**. Instead it receives *placeholders* and real values are swapped in at the network boundary. This is useful any time a process you don’t fully trust needs to *use* a secret without being allowed to *read* it: coding agents, MCP servers, third-party CLIs, or scripts. This pattern is known as a **credential broker**: a trusted component holds the real credentials and swaps them in at the network boundary, so a compromised or prompt-injected agent never holds a secret it can leak. It is increasingly recommended for agent security, for example by the [SANS Institute](https://www.sans.org/blog/your-ai-agent-easily-confused-deputy-why-cloud-security-needs-credential-broker) and in [Anthropic’s managed-agents architecture](https://www.anthropic.com/engineering/managed-agents). Most other credential brokers require you to store secrets in their tool, or work only with a limited subset of secure storage locations (system keychain, 1Password). Our proxy works with the existing varlock suite - so you can use any of our [plugins](/plugins/overview/), and still take advantage of the rest of varlock’s features. The agent only ever holds a placeholder. The proxy swaps in the real secret on the wire toward a verified upstream, and scrubs it back out of the response. Preview The credential proxy is an early preview, and its API is not yet stable: while in preview its flags, decorators, and behavior may change (including breaking changes) in minor releases before it’s finalized in a future major, so pin your varlock version if you depend on it. Its core protection (placeholder isolation + verified-identity wire injection) is solid, but on its own it runs as the same user as the agent, so it raises the bar rather than being a hard boundary. Pair it with an OS sandbox or container and it becomes a real one. See [Sandboxing](/guides/proxy/sandboxing/), and read [Limitations](#limitations) before relying on it as a security boundary. .env.schema ```diff # @sensitive +# @proxy(domain="api.stripe.com") @placeholder=sk_test_00000000000000000000000000 STRIPE_SECRET_KEY=yourPreferredPlugin() # from a plugin or built-in encryption — never a plaintext secret ``` ```bash varlock proxy run -- claude ``` The agent launched above sees `STRIPE_SECRET_KEY=sk_test_0000…`, a placeholder shaped like a real key. When it makes a request to `api.stripe.com`, the proxy swaps the placeholder for the real key on the wire. If the agent prints the variable, exfiltrates its env, or sends it anywhere else, all it has is a useless placeholder. The `@placeholder` keeps the placeholder valid-looking so the Stripe SDK’s client-side key-format check passes. See [Placeholders](/guides/proxy/rules/#placeholders) for when you need it. ## How it works [Section titled “How it works”](#how-it-works) 1. You mark which secrets are proxied with [`@proxy(domain=...)`](/reference/item-decorators/#proxy). 2. The child process is launched with **placeholders** in place of those secrets, plus the standard proxy/CA environment variables pointing at a local proxy. 3. The proxy intercepts HTTPS traffic. For a request that matches a rule, it verifies the upstream’s real TLS identity, then substitutes the placeholder for the real secret **only on that connection**. 4. Responses are scrubbed so real values can’t leak back into the child’s output, and every request is recorded to an [audit log](/guides/proxy/running/#auditing). Secrets and the proxy’s signing key live only in memory on your machine; they are never written to disk and never handed to the child. ## Quick start [Section titled “Quick start”](#quick-start) This assumes you already have varlock [installed](/getting-started/installation/) and a working `.env.schema` whose secrets resolve (committed encrypted values, a [plugin](/plugins/overview/), or env values you can load). #### 1. Route a secret through the proxy [Section titled “1. Route a secret through the proxy”](#1-route-a-secret-through-the-proxy) Mark the item [`@sensitive`](/reference/item-decorators/#sensitive) and add [`@proxy(domain=...)`](/reference/item-decorators/#proxy) **to the item** you want to protect. The `@proxy` tells the proxy to inject the item’s real value into requests to that host: .env.schema ```env-spec # @sensitive # @proxy(domain="api.stripe.com") # @placeholder=sk_test_00000000000000000000000000 STRIPE_SECRET_KEY=yourPreferredPlugin() # @sensitive # @proxy(domain="api.github.com") GITHUB_TOKEN=yourPreferredPlugin() ``` `@proxy` already implies [`@sensitive`](/reference/item-decorators/#sensitive) (and varlock treats items as sensitive by default), so the `@sensitive` line is optional. We show it explicitly here because being clear about what is a secret is good practice. The `@placeholder` on `STRIPE_SECRET_KEY` makes the placeholder the agent sees look like a real key, so SDK key-format checks pass. Leave it off (like `GITHUB_TOKEN` above) and the item gets a generic `vlk_placeholder_…`, which varlock warns about because it can fail a client-side key-format check (see [Placeholders](/guides/proxy/rules/#placeholders)). That is all the proxy needs: there is no separate “enable” step. It runs in **permissive** mode by default (hosts that don’t match a rule pass through untouched). Add [`@proxyConfig={egress="strict"}`](/guides/proxy/rules/#egress-modes) to your schema header only when you want to block everything that isn’t explicitly routed. #### 2. Check your config (optional) [Section titled “2. Check your config (optional)”](#2-check-your-config-optional) ```bash varlock proxy rules # prints the effective @proxy rules + per-secret mode, no proxy started ``` #### 3. Run your agent through it [Section titled “3. Run your agent through it”](#3-run-your-agent-through-it) ```bash varlock proxy run -- claude # or any command: varlock proxy run -- node agent.js varlock proxy run -- python tool.py ``` That’s it. The child inherits everything it needs (proxy address + CA trust) automatically. Using the proxy with Claude Code Wire Claude Code’s credential as [`ANTHROPIC_API_KEY`](/guides/ai-tools/claude/) (an `sk-ant-api…` key). `CLAUDE_CODE_OAUTH_TOKEN` (a subscription token from `claude setup-token`) only works for headless `claude -p` requests: the interactive TUI rejects a static token, so through the proxy it fails with a `401` regardless of your egress setting. Put `@proxy` on the item, not in the header `@proxy(domain=...)` on a **config item** routes *that item’s* secret to the domain. The same decorator in the **header** creates a *detached* policy rule for a domain with **no** secret injection (useful for `block` rules). A common mistake is to write `@proxy` in the header and expect a separate item to be injected, but it won’t be. See [Routing rules](/guides/proxy/rules/#routing-rules). ## More in this section [Section titled “More in this section”](#more-in-this-section) * [Routing rules](/guides/proxy/rules/): match domains, paths, methods; placeholders and egress modes * [Running modes](/guides/proxy/running/): one-shot, daemon, sessions, auditing, sandboxing ## Limitations [Section titled “Limitations”](#limitations) The proxy is a local, same-machine tool. Know what it does and doesn’t cover before relying on it: * **Same host, same user.** The proxy runs as you, and so does the agent. It stops the agent from *trivially reading* a secret it’s *using*, but on its own it is **not a sandbox**: it doesn’t isolate the filesystem, other processes, or memory. A determined agent can spawn a process outside the proxy’s view (e.g. reparented via `setsid`) and resolve secrets directly from their source; the in-tree guards (placeholder isolation, the schema-fingerprint check) raise the bar but are not a hard boundary. Add [`--sandbox`](/guides/proxy/sandboxing/#built-in-sandbox) (or a container/VM; see [Sandboxing](/guides/proxy/sandboxing/)) for a real isolation boundary. * **Response scrubbing is best-effort.** Responses are scrubbed back to placeholders only for **small, uncompressed text** bodies. **Compressed (gzip/br) or large (>2 MB) response bodies are passed through unscrubbed**: the proxy can’t scan into them. This only matters if an upstream *reflects your secret back in its response* (uncommon); the primary protection (the agent only ever holds a placeholder) is unaffected. Scrubbing is also scoped to the values a matching rule sends to that host, so a secret bound to a different route is left alone rather than scanned for everywhere. * **Injection requires a verified public-TLS host.** Secrets are injected only onto connections whose TLS identity is verified against public CAs. The proxy refuses to inject over cleartext `http://` or into a self-signed/local upstream, by design. * **Default egress is not containment.** `egress=permissive` (the default) does not restrict where the agent can connect; it just never injects to unmatched hosts. Use `egress=strict` (and ideally a sandbox) if you want to constrain egress. * **Hot-reload is human-applied.** `proxy reload` (or pressing `r` in an interactive `proxy start`) re-resolves the schema and swaps the live policy; a reload requested from inside the proxied agent is refused and logged so the agent can’t self-approve a schema edit. Availability follows [`@proxyConfig={reload=...}`](/reference/root-decorators/#proxyconfig) (default `auto`: on for an interactive daemon, off for headless or one-shot runs). But the agent-refusal is a self-reported signal, not authentication: an agent that strips its proxy markers (or runs out-of-tree) can still trigger a reload on a shared uid. A real out-of-band approver is planned to close this; until then, prefer a sandbox. * **Only proxy-aware clients are covered.** The child is pointed at the proxy via standard `HTTP(S)_PROXY` / CA env vars. A client that ignores those (or pins its own CA) bypasses the proxy entirely; it just won’t get a working secret. See [client compatibility](/guides/proxy/running/#client-compatibility) for the status of common clients. * **TLS interception is HTTP/1.1 only.** Normal clients negotiate down via ALPN and work fine, but protocols that require HTTP/2 (gRPC) or a connection upgrade (WebSockets) are unsupported on hosts with a `@proxy` rule. Hosts without a rule tunnel through untouched. # Proxy routing rules > Configure @proxy routing rules, placeholders, and what the agent sees ## Routing rules [Section titled “Routing rules”](#routing-rules) A `@proxy(...)` rule supports more than just a domain: | Option | Meaning | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `domain` | **(required)** Host to match: a single host or an array list. Supports globs, e.g. `*.example.com`. | | `path` | Restrict to matching URL paths (glob), e.g. `path="/v1/**"`. | | `method` | Restrict to one or more HTTP methods, e.g. `method=[GET, POST]`. | | `block` | `block=true` denies matching requests outright (fail closed). | | `keys` | Array of additional item names to inject for this rule, e.g. `keys=[STRIPE_KEY, WEBHOOK_SECRET]`. | | `substituteIn` | Where the secret may be substituted: `header` (default), `header:`, `query`, `query:`, `body:`, e.g. `substituteIn=[header, "body:client_secret"]` (see [Substitution surface](#substitution-surface)). | | `rules` | Array of per-path/method policy refinements that share this rule’s `domain` (see [Grouping rules for one domain](#grouping-rules-for-one-domain)). | `domain` and `method` take either a single value or an **array literal** for lists: .env.schema ```env-spec # @proxyConfig={egress="strict"} # Block a dangerous endpoint entirely (detached rule, no injection): # @proxy(domain="api.stripe.com", path="/v1/refunds/**", method=[POST, DELETE], block=true) # --- # Match either host, any method: # @sensitive # @proxy(domain=[api.stripe.com, api.stripe-test.com]) STRIPE_SECRET_KEY=yourPreferredPlugin() ``` ### Grouping rules for one domain [Section titled “Grouping rules for one domain”](#grouping-rules-for-one-domain) When one host needs several path/method policies, write the `domain` once and list the refinements under `rules`: .env.schema ```env-spec # @proxyConfig={egress="strict"} # --- # @sensitive # @proxy(domain="api.stripe.com", rules=[ # {path="/v1/refunds/**", method=[POST, DELETE], block=true}, # {path="/v1/payouts/**", block=true}, # ]) STRIPE_SECRET_KEY=yourPreferredPlugin() ``` This injects `STRIPE_SECRET_KEY` across `api.stripe.com` and blocks refunds and payouts. The parent `@proxy(...)` still controls injection (where the secret goes); each `rules` entry is a policy-only refinement that inherits the `domain` and injects nothing on its own, so [precedence](#routing-rules) (block over allow) does the rest. An entry may set `path`, `method`, `block`, and `substituteIn`, but not `domain` or `keys` (those stay on the parent). ### Attached vs detached rules [Section titled “Attached vs detached rules”](#attached-vs-detached-rules) * **Attached rule**: `@proxy` on an item. Injects that item’s secret into matching requests. (Attach extra items with the `keys` array: `@proxy(domain="api.x.com", keys=[OTHER_KEY])`.) * **Detached rule**: `@proxy` in the header. A policy-only rule (match or `block`) for a domain. It injects nothing on its own, but can inject named items with `keys=[...]`. ### Egress modes [Section titled “Egress modes”](#egress-modes) The [`@proxyConfig={egress=...}`](/reference/root-decorators/#proxyconfig) header decorator controls what happens to requests that **don’t** match any rule. It’s the only reason to write `@proxyConfig` at all: the proxy works without it, and `egress` defaults to `permissive`. * `permissive` *(default, no decorator needed)*: an unmatched request passes through untouched (no injection, no blocking). Good for getting started. * `strict`: add `@proxyConfig={egress="strict"}` to the header so only requests that match an allow (`@proxy`) rule are allowed; everything else is blocked — **including a request to a host that has `@proxy` rules but none matching this path/method**. This is the recommended posture once your rules are dialed in, since it prevents the agent from reaching arbitrary hosts (or arbitrary endpoints on a routed host). A matching `block` rule **always wins over an allow rule**, in either egress mode — so a `block=true` rule denies a request even if a broader `@proxy` allow rule also matches it. (To allow only a subset of a host and deny the rest, use `strict` egress with a specific allow rule, rather than a broad `block` with a narrow allow.) Helpful failures instead of mystery 401s Even in `permissive` mode, if a request carries a placeholder that **no rule injects on that route** (e.g. the agent hits `/v2/…` but your rule matched `/v1/…`), the proxy blocks it with a message naming the item and the rule gap — rather than forwarding the placeholder and letting the upstream reject it with a confusing `401`. So a mismatched path tells you *the proxy rule is the problem*, not “check your credentials.” ### Substitution surface [Section titled “Substitution surface”](#substitution-surface) Matching a rule decides **which host** a secret may go to. `substituteIn` decides **where inside the request** the placeholder is swapped for the real value, and each place you name is worth one swap. Without that, a prompt-injected agent could put the placeholder somewhere the real value then leaks: the classic case is asking a mail API on an allowed host to send an email whose body contains it. By default a secret is only substituted into request **headers** (any header), which covers most APIs. Targets can be as broad or as specific as you want: | Target | Allows substitution in | | ---------------------- | -------------------------------------------------------------------------------------- | | `header` | any request header value (the default) | | `header:authorization` | only the named header (case-insensitive) | | `path` | anywhere in the URL path, for APIs that carry a token in the path (`/v1/{token}/data`) | | `query` | anywhere in the query string | | `query:api_key` | only the named query parameter’s value | | `body:client_secret` | only the value at that body path (see below) | | `body:*` | anywhere in the body (escape hatch for unparseable bodies, see below) | Pin as tightly as the API allows: `header:authorization` keeps the secret out of every other header (some providers forward custom ones onward), and a body path pins it to one field. The bare `header` default excludes headers that are never a legitimate secret and are common forward/log sinks: `cookie`, `host`, `x-forwarded-*`, `forwarded`, `via`, `referer`, `origin`, and `user-agent`. If an API really authenticates through one, name it explicitly (`substituteIn=[header:cookie]`) and the explicit target wins. **Placeholders outside your targets are left alone.** An occurrence in a part of the request no target covers (the body, under the header-only default) is **skipped**: those bytes are never rewritten, and the request is forwarded with the literal placeholder, which is inert. This is routine with agents, which quote their own env var into the conversation transcript they send with every call. Every item with a skipped placeholder gets one `skipped-placeholder` [audit event](/guides/proxy/running/#auditing) per request, naming the item and the parts of the request its placeholder turned up in, so probing stays visible. **Body substitution always requires a path.** `substituteIn=[body]` is a schema error, deliberately: “anywhere in the body” is the easiest surface to exfiltrate from, and a placeholder placed once in the wrong field would pass any count check. A path is a dotted path into a JSON body (`client_secret`, `data.token`, `items[0].key`) or a field name in an `application/x-www-form-urlencoded` body. The content type selects the parser, and a body that can’t be parsed as declared fails closed. .env.schema ```env-spec # OAuth token exchange carries the secret in a form field: # @proxy(domain="api.example.com", path="/oauth/token", substituteIn=[header, "body:client_secret"]) CLIENT_SECRET=yourPreferredPlugin() ``` For a body varlock can’t parse into a path (XML/SOAP, protobuf, plain text, a signed blob), `body:*` allows the placeholder anywhere in it. That reopens the surface a path exists to close, so scope the rule tightly with `path` and `method`, and don’t use it on an endpoint that echoes, forwards, or stores body content. **One substitution per target.** A second occurrence at the *same* target is blocked, since the proxy can’t tell the real use from an exfiltration copy. Skipped occurrences belong to no target, so they never count against it. Note that the bare `header` target is a single target covering every header, so the default allows the secret in one header, not one per header. An API that carries it in two places just names both, which tightens the rule rather than loosening it: .env.schema ```env-spec # One substitution in the auth header, one in the body's signature field: # @proxy(domain="api.example.com", substituteIn=["header:authorization", "body:signature"]) SIGNING_KEY=yourPreferredPlugin() ``` Two things still fail closed, and both return a `403` naming the item, where the placeholder was found, and how to adjust the rule: * **A repeat at one target**, as above. * **An occurrence off the named spot inside a targeted body or query.** With a `body:` or `query:` target, substitution is one find-and-replace across that whole surface, so a stray occurrence elsewhere in it can’t be skipped without rewriting the body. ### Request transforms [Section titled “Request transforms”](#request-transforms) Some APIs never receive the secret at all. Instead, every request carries an HMAC signature computed *with* it: crypto exchange APIs (Coinbase, Kraken), and many webhook and partner APIs that require an `HMAC-SHA256` of the request body in a signature header. Substitution can’t cover these, since the secret never appears in the request, so there is no placeholder to swap. The `transform=` option on a `@proxy` rule makes the proxy compute the credential at the wire instead. This is a stronger boundary than substitution: a substituted credential is a string the child *could* have held, but here the child cannot produce a valid request even in principle, because it never holds the underlying secret. Signing is the main case, but a transform is any scheme that computes what the request carries: `http-basic` below composes a header instead of signing. .env.schema ```env-spec # @proxy(domain="api.exchange.com", transform={ # scheme="hmac-sha256", # stringToSign="{timestamp}{method}{pathWithQuery}{body}", # signatureHeader="X-ACCESS-SIGN", # timestampHeader="X-ACCESS-TIMESTAMP", # keyId=$EXCHANGE_API_KEY, keyHeader="X-ACCESS-KEY", # encoding="hex", # }) EXCHANGE_API_SECRET=yourPreferredPlugin() # @sensitive EXCHANGE_API_KEY=yourPreferredPlugin() ``` The signature is computed over the **final outbound request**, after placeholder substitution, so it covers exactly the bytes the upstream receives. Any signature or timestamp headers the child sent are overwritten (an SDK configured with placeholder credentials produces a garbage signature; the proxy replaces it with a valid one). Each scheme accepts its own option set. `scheme` is required on every transform: `hmac-sha256`, `hmac-sha512`, and `http-basic` are built in, and plugins add more (e.g. `aws-sigv4`, see below). Options for the `hmac-*` schemes: | Option | Meaning | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `stringToSign` | **(required)** Template for the signed message. Fields: `{timestamp}` `{method}` `{path}` `{pathWithQuery}` `{query}` `{host}` `{body}`. | | `signatureHeader` | **(required)** Header the signature is written to. | | `secretKey` | Reference to the item whose value is the HMAC key, e.g. `secretKey=$PARTNER_SECRET`. Defaults to the decorated item on an attached rule; **required** on a detached rule. | | `keyId` / `keyHeader` | Optional companion item reference (an API key id, e.g. `keyId=$EXCHANGE_API_KEY`) and the header it is written to. Set together. | | `timestampHeader` | Header the signing timestamp is written to. | | `encoding` | Signature output encoding: `base64` *(default)* or `hex`. | | `keyEncoding` | How the secret decodes into key bytes: `raw` *(default)*, `base64`, or `hex`. Some APIs (Coinbase Prime) issue base64-encoded secrets. | | `timestampFormat` | `unix-seconds` *(default)*, `unix-millis`, `unix-nanos`, or `rfc3339`. | A few properties worth knowing: * **The transform credential is consumed, not substituted.** The child never holds it: it only ever sees a placeholder, and the proxy applies the real value itself. `substituteIn` therefore doesn’t apply to it, and if the placeholder shows up anywhere in any request the request is blocked, since there is no legitimate reason for the child to send it. The `keyId` item is wire-visible (it is an API key id, not a secret key), so it substitutes normally like a `keys=` entry. * **Whether the secret itself reaches the API depends on the scheme.** With `hmac-*` and `aws-sigv4` only a derived signature travels, so the secret never leaves the proxy. `http-basic` is different: Basic auth carries the credentials themselves, base64-encoded, so they do reach the API. In both cases the guarantee for the agent is the same, and it is the one that matters here: it cannot produce a valid request itself, because it never holds the credential. * **The transform rides its rule’s match.** A transform applies when a rule carrying `transform=` matches the request. For a domain with several rules, put the transform on one broad `domain=`-only rule and keep `path`/`method`/`block`/`approval` refinements on separate rules; two *different* transform configs matching the same request is a schema misconfiguration and the request fails closed. * **TLS only.** Like injection, transforms refuse cleartext connections: a credential over plain http is readable in transit, and a signature over it is trivially replayable. * If the transform-carrying rule requires `approval`, the transform runs only after the approval gate passes, mirroring how approval-gated injection works. #### HTTP Basic auth [Section titled “HTTP Basic auth”](#http-basic-auth) Basic auth defeats plain substitution on its own: the child sends `Authorization: Basic base64(user:placeholder)`, and since the placeholder is base64-encoded, it never appears as a substring the proxy could swap. The built-in `http-basic` scheme has the proxy write the header itself, with the real values. The scheme has two options, `username` and `password`, one per side of the credential pair. Each references the item holding that side’s value, which the proxy resolves when it applies the transform. On an attached rule **the decorated item fills whichever side you leave unset**, and if you set neither it is the userid, since a single-credential Basic API almost always sends the token as the userid with an empty password (`curl -u "token:"`). .env.schema ```env-spec # token as the userid, empty password # @proxy(domain="api.stripe.com", transform={scheme="http-basic"}) STRIPE_SECRET_KEY=yourPreferredPlugin() # username given, so the item is the password # @proxy(domain="registry.example.com", transform={scheme="http-basic", username=$REGISTRY_USER}) REGISTRY_PASSWORD=yourPreferredPlugin() # @sensitive=false REGISTRY_USER=ci-bot ``` A detached rule names both sides itself: .env.schema ```env-spec # @proxy(domain="api.twilio.com", transform={ # scheme="http-basic", username=$TWILIO_ACCOUNT_SID, password=$TWILIO_AUTH_TOKEN, # }) # --- # @sensitive TWILIO_ACCOUNT_SID=yourPreferredPlugin() # @sensitive TWILIO_AUTH_TOKEN=yourPreferredPlugin() ``` Both sides are references, never inline values, including fixed ones: GitHub’s `TOKEN:x-oauth-basic` pairing is an item holding `x-oauth-basic` referenced as the `password`. That keeps one rule for every case (either side secret, both secret, or one fixed), makes each side environment-overridable, and means a username holding sensitive data gets the same protections as the password. Every referenced item is a consumed credential: managed, so the child only ever sees a placeholder, never substituted, and leak-guarded. A rule needs at least one side, so a detached rule naming neither is a schema error. Because Basic auth carries the credentials in the request (base64-encoded, not hashed), the proxy also treats the encoded `Authorization` value as a secret in responses: an endpoint that reflects the header cannot hand the child something it could decode. Response scrubbing covers the sensitive values a rule sends to that upstream, and this scheme adds the encoded form on top, since only the scheme can produce it. Anything computed, like a fixed prefix, is composed in the item rather than in the rule: .env.schema ```env-spec # @sensitive API_PASSWORD=prefix-${RAW_SECRET} ``` #### AWS SigV4 (plugin) [Section titled “AWS SigV4 (plugin)”](#aws-sigv4-plugin) The `aws-sigv4` scheme re-signs AWS SDK requests and ships as a separate plugin, [`@varlock/aws-sigv4-plugin`](/plugins/aws-sigv4/), so core varlock carries no AWS dependencies. AWS never receives the secret access key either; every request carries a signature computed from it. Configure the SDK in the child with the **placeholder** credentials (varlock’s proxied env does this automatically) and point it at the proxy; the SDK signs normally, and the proxy strips the placeholder signature and re-signs with the real keys. .env.schema ```env-spec # @plugin(@varlock/aws-sigv4-plugin) # --- # @proxy(domain="*.amazonaws.com", transform={ # scheme="aws-sigv4", keyId=$AWS_ACCESS_KEY_ID, # allowedServices=[bedrock, s3], # }) AWS_SECRET_ACCESS_KEY=somePlugin() # @sensitive AWS_ACCESS_KEY_ID=somePlugin() ``` The region and service need no configuration: they are parsed from the inbound request’s credential scope, so one rule covers every AWS service and region the client talks to. The same scheme covers S3-compatible services that authenticate with SigV4 (Cloudflare R2, MinIO, Backblaze B2, and others); point the rule’s `domain` at their endpoint. See the [plugin docs](/plugins/aws-sigv4/) for all options and limitations. #### Plugin-provided schemes [Section titled “Plugin-provided schemes”](#plugin-provided-schemes) Transform schemes are registered through the [plugin system](/guides/plugins/): a plugin declares the scheme’s options (which are validated exactly like the built-in ones, including which options name credential items) and provides the function that runs in the proxy per matching request. This keeps provider-specific code and dependencies out of core. Custom or venue-specific schemes that the generic template can’t express (nested hashing, custom canonicalization) are built the same way, including as local, unpublished plugins (`@plugin(./my-transform-plugin)`). ## Controlling what the agent sees [Section titled “Controlling what the agent sees”](#controlling-what-the-agent-sees) By default, varlock applies **least privilege** to the proxied child: * A `@proxy(domain=...)` item → the agent sees a **placeholder**; the real value is injected at the wire. * A [`@sensitive`](/reference/item-decorators/#sensitive) item with **no** proxy policy → the agent sees a **placeholder** too (it just isn’t injected anywhere). The real value never reaches the child. * Non-sensitive items → passed through normally. varlock treats items as sensitive by default Unless an item is marked [`@sensitive=false`](/reference/item-decorators/#sensitive) (shorthand: `@public`), or made non-sensitive by a [`@type`](/reference/item-decorators/#type) / `@defaultSensitive` rule, varlock considers it sensitive, so it becomes a **placeholder** in the proxied child. If your agent needs to read a non-secret config value (an API base URL, a feature flag), mark it `@sensitive=false` so it passes through with its real value. Because every sensitive item resolves to a placeholder inside a proxied session, an agent can’t **trivially** recover a secret by re-running `varlock load` / `varlock printenv` from within the proxied session; it gets the same placeholder back, not the real value. (A determined agent on the same machine can still escape this; see [Limitations](/guides/proxy/#limitations) and pair the proxy with a sandbox for a real boundary.) To override the default for an item, use the value form of `@proxy`: .env.schema ```env-spec # @sensitive # @proxy=passthrough # inject the REAL value into the child (escape hatch) LEGACY_TOKEN=yourPreferredPlugin() # @proxy=omit # withhold entirely: absent from the child env, and # resolves to "unset" (not the real value) if re-resolved UNUSED_SECRET=yourPreferredPlugin() ``` `@proxy=passthrough` and `@proxy=omit` are the **value form** of the decorator and cannot be combined with the function form (`@proxy(...)`) on the same item. ### Placeholders [Section titled “Placeholders”](#placeholders) You don’t have to define placeholders. If you don’t set one, varlock generates a placeholder for every proxied item automatically. Its exact value usually doesn’t matter, because the proxy injects the real secret on the wire regardless of what the placeholder looks like. It matters only when the client checks the key’s format **locally, before sending** — typically an SDK (for example the OpenAI or Stripe client asserting an `sk-` / `sk_` prefix when you construct it). A raw HTTP client (curl, `fetch`, and most tools) accepts any placeholder, so the generated one is fine. The placeholder the agent sees is chosen in priority order: 1. An explicit [`@placeholder`](/reference/item-decorators/#placeholder) value (always wins). 2. A valid-and-unique value derived from the item’s [`@type`](/reference/item-decorators/#type): e.g. `@type=url` → `https://vlk-placeholder-…invalid/`, `@type=email` / `uuid` / `md5` likewise, and `@type=string(startsWith=sk-, isLength=20)` yields an `sk-`-shaped placeholder. 3. A generic fallback (`vlk_placeholder__…`). For a `@proxy`-routed item varlock **warns** about this one, since it’s the case that can fail an SDK’s format check; if your client doesn’t validate the format, it’s harmless. Every placeholder is unique per item, so two different secrets can never collide on the wire. If an SDK rejects the generic placeholder, add an `@placeholder` or a typed format so it looks valid to the client: .env.schema ```env-spec # @sensitive # @proxy(domain="api.stripe.com") # @placeholder=sk_test_00000000000000000000000000 STRIPE_SECRET_KEY=yourPreferredPlugin() ``` ## Reference [Section titled “Reference”](#reference) * [`@proxyConfig`](/reference/root-decorators/#proxyconfig) and [`@proxy`](/reference/root-decorators/#proxy) root decorators * [`@proxy`](/reference/item-decorators/#proxy) item decorator * [`varlock proxy`](/reference/cli/proxy/#proxy) CLI commands * [Sandboxing](/guides/proxy/sandboxing/) * [AI Tools guide](/guides/ai-tools/) # Running the proxy > One-shot and daemon modes, sessions, wiring, auditing, and sandboxing ## Running modes [Section titled “Running modes”](#running-modes) There are a few ways to drive the proxy, trading convenience for interactivity. ### One-shot: `proxy run` [Section titled “One-shot: proxy run”](#one-shot-proxy-run) ```bash varlock proxy run -- ``` Starts a proxy, runs the command through it, and tears down when the command exits. The single most common way to use the feature. If a `proxy start` daemon is already running for this directory, `proxy run` **attaches** to it (so its terminal owns the live request log); otherwise it runs a self-contained proxy. Pass `--new` to force a fresh, separate proxy. ### Daemon + attach: `proxy start` [Section titled “Daemon + attach: proxy start”](#daemon--attach-proxy-start) ```bash # Terminal 1: owns the proxy and its live request log varlock proxy start # Terminal 2: attaches automatically varlock proxy run -- claude ``` `proxy start` is a long-lived session that owns its terminal, where the live per-request log appears. Attach to it from another terminal (or shell) so the agent’s stdio doesn’t compete with the daemon’s output. An attaching `proxy run` **adopts the running session’s env**: it fetches the child view (placeholders, non-secret values, omitted keys) directly from the daemon instead of resolving anything itself. That means no second unlock prompt, and the session’s own overrides and env selection apply (attaching means “run me in that session’s env”). Your shell’s ambient values for schema-managed keys are ignored in favor of the session’s; everything else (`PATH`, etc.) passes through as usual. ### Manual env: `proxy start` + `proxy env` [Section titled “Manual env: proxy start + proxy env”](#manual-env-proxy-start--proxy-env) ```bash varlock proxy start # in one terminal eval "$(varlock proxy env)" # in another shell node agent.js # now routed through the proxy ``` `proxy env` prints the proxy + CA environment for an existing session so you can source it into any shell or tool. ### Remote: `proxy start --expose` + `proxy run --url` [Section titled “Remote: proxy start --expose + proxy run --url”](#remote-proxy-start---expose--proxy-run---url) The workload does not have to be on the same machine. `proxy start --expose` makes the proxy reachable off-loopback and serves a built-in WebSocket tunnel, gated by a per-session data-plane token, and `proxy run --url wss://` runs a command through that broker from anywhere, self-wiring the placeholder env and CA certs over the tunnel. The token is a credential: pin it with `VARLOCK_PROXY_TOKEN` or read it back with `varlock proxy token`, and prefer passing it to clients as an env var rather than a `--token` argument. This is how cloud sandboxes reach a broker; see the [E2B](/sandboxes/e2b/) and [Fly.io](/sandboxes/flyio/) guides for full recipes and the [proxy CLI reference](/reference/cli/proxy/) for the flags. When the guest’s only egress is an explicit HTTP proxy (a corporate proxy, or a sandbox gateway like [Docker Sandboxes](/sandboxes/docker-sandboxes/)), `proxy run --url` dials the tunnel through it automatically: it honors `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` and `NO_PROXY` (a loopback or `NO_PROXY`-matched broker still dials direct). Only a plaintext `http://` `CONNECT` proxy is supported (an `https://` proxy, meaning TLS to the proxy itself, and SOCKS are not); a `wss://` broker still works through it, since the tunnel’s own TLS runs end to end inside the `CONNECT`. That end-to-end TLS also means a proxy that terminates TLS only ever sees the encrypted tunnel. ## Sessions [Section titled “Sessions”](#sessions) Every `varlock proxy` command operates on a **session**: one running proxy with its own short id (printed by `proxy start`, listed by `proxy status`). You target a session in one of two ways: * **By id:** pass `--session ` to any command (`proxy env --session abc12`, `proxy stop --session abc12`, etc.). * **Auto-discovered:** with no `--session`, the command resolves one for you: * `proxy run` attaches to the running `proxy start` daemon **for the current directory** (a session whose directory is this one or a parent of it). If there isn’t one it starts its own proxy; pass `--new` to always start a fresh, separate one. * `env` / `status` / `audit` / `reload` / `stop` use the **single active session**. If more than one is running they ask you to pass `--session `; commands run from inside a proxied child target that child’s own session automatically. Sessions are durable records: they persist after the proxy stops (visible with `proxy status --all`) so their [audit log](#auditing) stays available. ## How the child is wired [Section titled “How the child is wired”](#how-the-child-is-wired) `proxy run` (and `proxy env`) inject a standard set of environment variables into the child so common HTTP clients trust the proxy with no manual setup: * **Proxy address:** `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` (and lowercase), with `NO_PROXY` excluding localhost. * **CA trust:** `NODE_EXTRA_CA_CERTS` (Node.js), `REQUESTS_CA_BUNDLE` (Python `requests`), `CURL_CA_BUNDLE` (curl), `GIT_SSL_CAINFO` (git), `CARGO_HTTP_CAINFO` (cargo), `DENO_CERT` (Deno), and `SSL_CERT_FILE` (OpenSSL-based tools). * **Runtime opt-ins:** `NODE_USE_ENV_PROXY=1`, so Node’s built-in `fetch` (Node 24+) routes through the proxy. Without it, `fetch` ignores the proxy env vars entirely and sends requests (with placeholder values) directly to the upstream. On older Node versions the flag is ignored and `fetch` still bypasses the proxy; use an env-proxy-aware client (axios, got) or Node 24+. The proxy URL carries no credentials, and the proxy never asks clients for proxy authentication (no `407` challenges). Session scoping is enforced by the proxy itself, so clients with limited proxy-auth support (like Python’s `urllib`) work with the plain `HTTPS_PROXY` url. The proxy uses an **ephemeral, in-memory certificate authority** generated per run; only its public certificate is written to a temp file for the child to trust. The CA private key and all per-host certificates never leave memory. (The one exception is [`--persist-ca`](#pinning-the-port-and-ca-location), an opt-in for long-lived brokers.) **Stdout/stderr redaction** is decided per stream: a stream attached to an interactive terminal passes through raw (so TUIs like `claude` render correctly), while a piped or redirected stream is scrubbed of sensitive values, including the real values the proxy injects at the wire, in case an upstream response echoes one back. Override with `--redact-stdout` / `--no-redact-stdout`. ### Client compatibility [Section titled “Client compatibility”](#client-compatibility) The proxy works with any client that honors the standard proxy and CA env vars above. Status of common clients: | Client | Works? | Notes | | ------------------------------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | curl, git, wget | yes | `--http2` is fine: ALPN negotiates down to HTTP/1.1 | | Python: python.org, Homebrew, pyenv, `uv` builds | yes | urllib, requests, and httpx all verified, including python 3.13+ strict verification | | Python: macOS system (`/usr/bin/python3`) | partial | proxied, but ignores `SSL_CERT_FILE` so urllib fails TLS verification; see workarounds below | | Node built-in `fetch` (Node 24+) | yes | via the injected `NODE_USE_ENV_PROXY=1` | | Node built-in `fetch` (Node < 24) | no | silently bypasses the proxy and sends placeholders directly upstream; use axios/got or Node 24+ | | Node axios, got | yes | implement env-proxy support themselves | | Bun `fetch` | yes | | | Deno | yes | via the injected `DENO_CERT` | | Go programs (incl. `gh`, `terraform`, `kubectl`) | Linux yes, macOS no | on macOS Go verifies against the system keychain and rejects the proxy CA | | .NET | Linux yes, macOS no | same system-keychain issue as Go | | Ruby | build-dependent | OpenSSL-linked builds work; macOS system Ruby has the same `SSL_CERT_FILE` issue as system Python | | Rust (reqwest etc.) | build-dependent | `native-tls`/OpenSSL backends honor `SSL_CERT_FILE`; `rustls` platform-verifier or bundled-roots builds reject the proxy CA | | JVM tools (java, gradle, maven) | no | the JVM ignores both the proxy and CA env vars on every platform; needs `-Dhttps.proxyHost`/`-Dhttps.proxyPort` and a custom truststore | | gRPC clients | no on matched hosts | gRPC requires HTTP/2 and the proxy’s TLS interception is HTTP/1.1 only; hosts without a `@proxy` rule tunnel through untouched | | WebSockets (`wss://`) | no on matched hosts | upgrades through the interception path are unsupported; hosts without a `@proxy` rule tunnel through untouched | Workarounds * **macOS system Python:** use `requests` (reads `REQUESTS_CA_BUNDLE`), or pass the bundle explicitly: `ssl.create_default_context(cafile=os.environ["SSL_CERT_FILE"])`. Pythons from python.org, Homebrew, pyenv, or `uv` need nothing. * **macOS system-keychain clients (Go, .NET, Rust platform verifiers):** these honor `HTTPS_PROXY` but verify TLS against the system keychain, which does not contain the proxy’s ephemeral CA. There is no env-var fix; run those tools on Linux (e.g. [`--sandbox=docker`](/guides/proxy/sandboxing/), where they read `SSL_CERT_FILE`) or outside the proxy. * **GUI apps and browsers** generally ignore these env vars entirely; the target here is CLI tools and agents. ### Pinning the port and CA location [Section titled “Pinning the port and CA location”](#pinning-the-port-and-ca-location) By default the proxy binds a random loopback port and writes its CA cert to a fresh temp dir. Both are discoverable after startup with `proxy env` / `proxy status`. When something needs to be wired up **before** the proxy starts (a config that points at a fixed `HTTP_PROXY`, or a tool told to trust a CA at a known path), pin them: ```bash varlock proxy start --port 8080 --cert-dir ./.varlock-proxy ``` `--port ` fixes the loopback port, so `HTTP_PROXY` is `http://127.0.0.1:`. If that port is already in use the proxy refuses to start instead of falling back to a random one. `--cert-dir ` writes `ca-cert.pem` (and `combined-ca.pem`, the CA plus system roots) into ``, creating it if missing. The CA itself is ephemeral and regenerated on each start; on stop, only the cert files varlock wrote are removed, never the directory itself. `--persist-ca` (with `--cert-dir`) changes that: the CA is kept, private key included, and reused on the next start, so a restart does not invalidate clients that already trust it. This matters for a long-lived [broker](/sandboxes/overview/#topologies) serving remote sandboxes, where a restart would otherwise break every running agent. It writes `ca-key.pem` (mode 0600), which the proxy otherwise never does, so use it only where the proxy runs alone rather than alongside the agent it proxies. A persisted CA lasts 10 years, effectively the life of the broker: an expiry would break agents still running when it came around, and it would buy nothing, since nothing checks revocation for this CA and only clients that fetched it from this broker trust it. To retire one, delete the cert directory and restart. These flags apply only when **starting** a proxy. They also work on `proxy run` when it starts its own proxy (with `--new`, or when no daemon is running for the directory). On a `proxy run` that attaches to an existing daemon they are rejected, since that daemon already fixed the port and cert location. ## Auditing [Section titled “Auditing”](#auditing) Every request through the proxy is appended to a per-session, secrets-free audit log (host, method, path, a request hash, the matched rule, the decision, and which key names were injected, never any values). A [skipped](/guides/proxy/rules/#substitution-surface) placeholder adds one `skipped-placeholder` line per item, naming the key and the parts of the request its placeholder appeared in. ```bash varlock proxy audit # current/most-recent session varlock proxy audit --session # a specific session varlock proxy audit --format json # machine-readable ``` Sessions are kept as durable records after they end. List them with: ```bash varlock proxy status # active sessions varlock proxy status --all # include ended sessions varlock proxy status --watch # live updates ``` ## Editing the schema while a session is running [Section titled “Editing the schema while a session is running”](#editing-the-schema-while-a-session-is-running) For safety, a proxied command refuses to run if your env schema has changed since the proxy session started. This prevents an agent from editing the schema mid-session (downgrading `@sensitive`, or adding a new item that resolves a secret) to recover real values. After an intentional edit, **restart the proxy** from a trusted (non-proxied) shell so it re-resolves the new schema: ```bash varlock proxy stop --all varlock proxy start # or: varlock proxy run -- ``` A one-shot `varlock proxy run` already re-reads the schema on its next invocation, so just re-run it. A running `varlock proxy start` also **warns in its live log** when it detects your env schema changed on disk (any of the loaded `.env*` files, not just `.env.schema`). It keeps serving the previous policy (it does not block your agent’s in-flight requests just because a file changed), so reload or restart to apply the change, and if you did not make the edit, a proxied process may have. Hot-reload A running proxy can hot-reload your schema without a restart: run `varlock proxy reload` from a **trusted terminal** and the proxy re-resolves and validates the schema in its own context, then swaps the live policy (a broken edit is refused and the error reported back to the caller). In an interactive `varlock proxy start`, you can also just press **`r`** in its terminal (then **`y`** to confirm) to reload in place. A reload requested from **inside** the proxied agent is refused (so the agent can’t self-approve its own schema edit) and the attempt is surfaced in the `proxy start` log. Whether reload is available is set by [`@proxyConfig={reload=...}`](/reference/root-decorators/#proxyconfig) (`off` / `manual` / `auto`, default `auto`), and can be overridden per run with `--allow-reload` / `--no-allow-reload`. `auto` is conservative: it enables `manual` reload only for an interactive `varlock proxy start` (a human is there to apply it and watch the log) and stays `off` for headless runs or a one-shot `varlock proxy run`. On a shared uid the agent-refusal is a bar-raiser, not a hard boundary (a marker-stripping agent evades it), so `auto` only widens toward agent-triggered reload once containment (a sandbox) or a real out-of-band approver makes it safe. ## Sandboxing [Section titled “Sandboxing”](#sandboxing) On its own the proxy raises the bar but is not a hard boundary: a determined same-uid agent can still reach a secret’s source. Close that with `--sandbox` so the child’s only network path is the proxy: ```bash varlock proxy run --sandbox -- claude ``` Bare `--sandbox` is a built-in macOS jail (loopback-only egress + credential-path and keychain denials). Use `--sandbox=docker` (or `=podman`) for a container whose only egress is the host proxy. Details, flags, and third-party recipes: [Sandboxing](/guides/proxy/sandboxing/). # Sandboxing > Run AI agents in a sandbox while varlock proxy brokers credentialed requests The [credential proxy](/guides/proxy/) stops an agent from *trivially reading* secrets it needs to *use*. On its own it still runs as you on the same machine, so it raises the bar rather than drawing a hard boundary. Pair it with an OS sandbox and you get both: the sandbox contains the agent; varlock holds the real credentials and injects them only on verified upstream connections. Each recipe wires the sandbox to the proxy in one of two ways. Most source the proxy’s environment after it starts (`eval "$(varlock proxy env)"`), so the default random port and temp CA dir are fine. A few tools need the proxy address or CA path in a config file **before** the proxy starts; for those, pin them up front with [`--port` / `--cert-dir`](/guides/proxy/running/#pinning-the-port-and-ca-location). Each recipe below uses whichever fits. ## Why sandbox + proxy [Section titled “Why sandbox + proxy”](#why-sandbox--proxy) On its own the proxy’s in-tree guards (placeholder isolation, the schema-fingerprint check) raise the bar but are **not a hard boundary**: a determined agent can spawn a process outside the proxy’s view (for example reparented via `setsid`) and reach a secret’s source directly. Wrapping the agent in an OS sandbox closes this. It removes the *capability* to reach a resolution source or egress off-loopback, so evading process detection buys nothing. | Layer | Job | | ------------------------------ | ------------------------------------------------------------------------------------------- | | **Sandbox** | Isolate filesystem, processes, and (where configured) network from the rest of your machine | | **Credential proxy** (varlock) | Give the agent placeholders; swap in real secrets only toward allowlisted hosts | A sandbox without a credential broker often still puts real keys in the guest (env files, keychain pinholes, mounted config). A proxy without a sandbox leaves a determined agent on your uid with paths around the proxy. Use both. ## Built-in sandbox [Section titled “Built-in sandbox”](#built-in-sandbox) Start here when you want isolation without installing a third-party sandbox. Add `--sandbox` to a self-owned `proxy run` (it does not apply when attaching to an existing `proxy start` session). ### macOS: `--sandbox` [Section titled “macOS: --sandbox”](#macos---sandbox) ```bash varlock proxy run --sandbox -- claude ``` Runs the child inside a minimal, built-in **credential + egress jail** (macOS `sandbox-exec`, no install). The jail keeps the agent, varlock, and integrations working while denying exactly the escape routes: * **Egress**: only loopback is reachable, so the proxy on `127.0.0.1` becomes the child’s *sole* path to the network. A process that scrubs its `HTTPS_PROXY` / `HTTP_PROXY` env to bypass the proxy simply cannot connect. * **Credential material**: reads of the user varlock dir (the encryption key, plugin/credential cache, and the proxy session/reload channel) are denied, and so are unix-socket connections into it. The daemon that decrypts local secrets listens on a socket under that dir; a file-read deny does not gate a socket connection, so that connection is blocked separately, otherwise a child could reach the warm daemon and have it decrypt for them. * **The macOS keychain**: `mach-lookup` to the keychain daemons (`securityd` / `SecurityServer`) is denied, so the agent can’t read keychain secrets directly (via `security` or `SecItemCopyMatching`). TLS trust evaluation (`trustd`) is left allowed, so HTTPS still works. * **Warm credential agents**: `mach-lookup` to the 1Password helper (and similar) is denied, so the agent cannot drive a warm `op` session directly. It’s **opt-in** by design (a jail that’s too tight silently breaks an agent, so we don’t force it on). Bare `--sandbox` is macOS-only today; on Linux (or for stronger isolation) use `--sandbox=docker` below. ### Container: `--sandbox=docker` [Section titled “Container: --sandbox=docker”](#container---sandboxdocker) ```bash varlock proxy run --sandbox=docker --sandbox-image -- claude ``` Runs the child in a container (`docker`, or `--sandbox=podman`) whose **only** network egress is the proxy, while your secrets and the resolution machinery stay on the host. varlock wires this up for you: * The agent container runs on an **`--internal` network**: it has no route to the internet at all, so raw egress fails closed. * A tiny **forwarder** container (a `socat` byte-relay holding no secrets) sits on that network as `varlock-proxy` and bridges to the host proxy. The agent’s `HTTPS_PROXY` points at it, so every request goes host proxy → policy check → wire injection. * Your cwd is mounted as the working directory and the proxy’s public CA is mounted read-only so the container trusts the MITM. Secrets never enter any container: the host proxy resolves them (keeping your encryption key, keychain/enclave, and warm `op` session on the host) and injects the real value onto the wire; the container only ever holds placeholders. Both networks and the forwarder are torn down when the command exits. `--sandbox-image` is required. It must be an image that contains your command (for example a devcontainer image with `claude` installed); varlock can’t know your toolchain. The first run pulls a small `socat` image for the forwarder. Why not “run the proxy on the host and point the container at it”? An `--internal` docker network can’t reach the host at all (that’s what makes it a real egress boundary), so the proxy has to be reachable *as a peer on that network*. The forwarder is the minimal way to do that without moving secret custody off the host. ## Third-party sandboxes [Section titled “Third-party sandboxes”](#third-party-sandboxes) The [sandbox recipes](/sandboxes/overview/) cover open-source, easy-to-install sandboxes that do **not** ship their own credential broker, so varlock stays the wire injector. Prefer [built-in `--sandbox`](#built-in-sandbox) when it fits; use these when you already run that tool, need Windows, or want a different isolation model. | Sandbox | Isolation | Platforms | | ---------------------------------------------- | ----------------------------------------- | --------------------- | | [Minimal](/sandboxes/minimal/) | Task / microVM or process sandbox | macOS, Linux | | [smolvm](/sandboxes/smolvm/) | microVM (libkrun) | macOS, Linux, Windows | | [Fence](/sandboxes/fence/) | Seatbelt / bubblewrap + domain allowlist | macOS, Linux | | [yolobox](/sandboxes/yolobox/) | Container (home dir stays on the host) | macOS, Linux | | [Agent Safehouse](/sandboxes/agent-safehouse/) | Deny-first Seatbelt profiles | macOS | | [bubblewrap](/sandboxes/bubblewrap/) | Linux namespaces (DIY) | Linux | | [MXC](/sandboxes/mxc/) | Windows AppContainer (`processcontainer`) | Windows 11 | Tools that already broker credentials at the network boundary (Docker Sandboxes, microsandbox, Anthropic `srt` credential `mask`, and similar) are out of scope here: use those on their own, or wait for varlock host bridging if you want varlock as the broker inside a microVM that cannot reach host loopback. ### Shared setup [Section titled “Shared setup”](#shared-setup) 1. Varlock [installed](/getting-started/installation/) with a working `.env.schema` whose secrets resolve (plugin, encrypted local values, or similar). 2. Familiarity with the [credential proxy](/guides/proxy/) quick start: mark items with [`@proxy(domain=...)`](/reference/item-decorators/#proxy). 3. The sandbox CLI for the guide you follow (install commands are inline on each page). .env.schema ```env-spec # @proxyConfig={egress="strict"} # --- # @sensitive # @proxy(domain="api.anthropic.com") # @placeholder=sk-ant-api03-000000000000000000000000 ANTHROPIC_API_KEY=yourPreferredPlugin() ``` Use `@placeholder` when the agent or SDK checks key shape at startup. See [Placeholders](/guides/proxy/rules/#placeholders) and the [Claude Code note](/guides/proxy/#quick-start) on the proxy guide. Wire Claude through [`ANTHROPIC_API_KEY`](/guides/ai-tools/claude/), not a subscription OAuth token. For recipes that pass env into an external sandbox (rather than `proxy run --sandbox`), start a durable proxy session on the host first: ```bash varlock proxy start ``` By default this binds a random loopback port and writes the CA cert to a temp dir; recipes that source `eval "$(varlock proxy env)"` after startup pick both up automatically. When a tool’s config file has to name the proxy address or CA path before the proxy starts (Fence’s `upstreamProxy`, MXC’s `network.proxy`, or a container that needs the CA at a mountable path), pin them instead: ```bash varlock proxy start --port 54321 --cert-dir ./.varlock-proxy ``` `--port` fails closed if the port is busy, and `--cert-dir` is created if missing (only the cert files are removed on stop). Details: [Pinning the port and CA location](/guides/proxy/running/#pinning-the-port-and-ca-location). ## What’s next [Section titled “What’s next”](#whats-next) For Apple `container`, microsandbox, or a full VM, run the agent inside it with egress routed through the proxy on a host-reachable address. That is the VM-grade tier and covers the filesystem and process table too. MicroVM-style sandboxes that cannot reach host loopback (and products that already run their own credential proxy) need varlock reachable on the guest data plane. Until that bridging exists, prefer [built-in `--sandbox`](#built-in-sandbox), the process / Seatbelt / AppContainer recipes above, or container tools only when you have confirmed a host-gateway path to the proxy. ## Reference [Section titled “Reference”](#reference) * [Credential proxy](/guides/proxy/) * [`varlock proxy`](/reference/cli/proxy/#proxy) CLI * [`@proxy`](/reference/item-decorators/#proxy) / [`@proxyConfig`](/reference/root-decorators/#proxyconfig) * [Minimal sandboxing](https://docs.minimal.dev/concepts/sandboxing) * [Fence](https://github.com/fencesandbox/fence) / [yolobox](https://yolobox.dev/) / [Agent Safehouse](https://agent-safehouse.dev/) / [bubblewrap](https://github.com/containers/bubblewrap) / [MXC](https://github.com/microsoft/mxc) * [AI Tools](/guides/ai-tools/) # Schema > Using the schema to manage your environment variables One of the core features of Varlock is its schema-driven approach to environment variables, which is best when shared with your team and committed to version control. We recommend creating a new `.env.schema` file to hold schema info set by [config item decorators](/reference/item-decorators/), non-sensitive default values, and [root decorators](/reference/root-decorators/) to specify global settings that affect the `varlock` CLI itself. This schema should include all of the environment variables that your application depends on, along with comments and documentation about them, and decorators which affect coercion, validation, and generated types / documentation. The more complete your schema is, the more validation and coercion `varlock` can perform, and the more it can help you catch errors earlier in your development cycle. Note Running [`varlock init`](/reference/cli/project/#init) will attempt to convert an existing `.env.example` file into a `.env.schema` file. It must be reviewed, but it should be a good starting point. ## Root Decorators [Section titled “Root Decorators”](#root-decorators) The *header* section of a `.env` file is any comment block(s) at the beginning of the file, before the first config item. Within this header, you can use [root decorators](/reference/root-decorators/) to specify global settings and default behavior for the config items defined in that file. The ones that set item defaults (`@defaultRequired`, `@defaultSensitive`, `@defaultDynamic`) do not carry into other files, including imported files and higher-precedence files like `.env.local`. .env.schema ```env-spec # This is the header, and may contain root decorators # @currentEnv=$APP_ENV # @defaultSensitive=false @defaultRequired=false # @generateTsTypes(path=env.d.ts) # --- # This is a config item comment block and may contain decorators which affect only the item # @required @type=enum(dev, test, staging, prod) APP_ENV=dev ``` More details: * [Root decorators reference](/reference/root-decorators/) ## Config Items [Section titled “Config Items”](#config-items) Config items are the environment variables that your application depends on. Like normal `.env` syntax, each item is a key-value pair of the form `KEY=value`. The key is the name of the environment variable, and a value may be specified or not. While simply enumerating all of them in your `.env.schema` is useful (like a `.env.example` file), [@env-spec](/env-spec/overview/) allows us to attach additional comments and [item decorators](/reference/item-decorators/), making our schema much more capable. ### Item Values [Section titled “Item Values”](#item-values) Values may be static, or set using [functions](/reference/functions/), which can facilitate loading values from external sources without exposing any sensitive values. **Quote rules:** * Static values can be wrapped in quotes or not — all quotes styles (`` ` ``, `"`, `'`) are supported * Values wrapped in single quotes do not support [expansion](#ref-expansion) * Single line values may not contain newlines, but `\n` will be converted to an actual newline except in single quotes * Multiline values can be wrapped in ` ``` `, `"""`. Also supported is `"` and `'` but not recommended. * Unquoted values will be parsed as a number/boolean/undefined where possible (`ITEM=foo` -> `"foo"`, while `ITEM=true` -> `true`), however data-types may further coerce values * No value (undefined) and empty string ("") are distinct * `ITEM=` sets no value at all, so in a higher-precedence file it does not override a value from a lower-precedence one. Use `ITEM=""` to override with an empty string * this holds through injection too: an item that resolves to undefined is left out of `process.env` entirely (so `process.env.MY_VAR ?? 'fallback'` works), while an explicit `""` is injected as an empty string. See [`@injectUndefinedAsEmpty`](/reference/root-decorators/#injectundefinedasempty) if you want dotenv-style empty-string injection instead. .env.schema ```env-spec NO_VALUE= # will resolve to undefined EMPTY_STRING_VALUE="" # will resolve to empty string STATIC_VALUE_UNQUOTED=quotes are optional # but are recommended! STATIC_VALUE_QUOTED="#hashtag" # and are necessary in some cases BOOLEAN_VALUE=true NUMERIC_VALUE=123.456 FUNCTION_VALUE=op(op://api-config/item/credential) EXPANSION_VALUE=${OTHER_VAR}-suffix MULTILINE_VALUE=""" multiple lines """ ``` ### Item comments [Section titled “Item comments”](#item-comments) Comments are used to attach additional documentation and metadata to config items using [item decorators](/reference/item-decorators). This additional metadata is used by varlock to perform validation, coercion, and generate types / documentation. Multiple comment lines *directly* preceding an item will be attached to that item. A blank line or a divider (e.g., `# ---`) breaks a comment block and detaches it from the following config item. Any comment block before the first item is still part of the document header until it is ended by a blank line, a divider, or the end of the file. Comment lines can either contain regular comments or [item decorators](/reference/item-decorators). Standalone comments only count as decorator comments when the comment content starts with `@`. ```env-spec # description of item can be multiple lines # this @decorator will be ignored because the line does not start with @ # @sensitive=false @required # decorator lines can end with a comment # @type=string(startsWith=pk-) # multiple lines of decorators are allowed SERVICE_X_PUBLISHABLE_KEY=pk-abc123 ``` More details: * [Item decorators reference](/reference/item-decorators) * [@type data types reference](/reference/data-types) * [Functions reference](/reference/functions) ## Resolver Functions [Section titled “Resolver Functions”](#resolver-functions) You may use [resolver functions](/reference/functions/) instead of static values within both config items and decorator values. Functions may be composed together to create more complex value resolution logic. ```env-spec # @required=forEnv(prod) API_DOMAIN=if(eq(ref(APP_ENV), prod), api.myapp.com, staging-api.myapp.com) ``` ### Referencing other values [Section titled “Referencing other values”](#ref-expansion) Within values and function args, you often need to reference other env vars within your schema. You may use [`ref()`](/reference/functions/#ref) but we support *expansion* syntax (like many other .env tools) for convenience. Both `$ITEM` and `${ITEM}` are equivalent to `ref(ITEM)`. We recommend using the bracket version only when used within a larger string. ```env-spec WITH_BRACKETS=op(op://${OP_VAULT_NAME}/service/api-key) NO_BRACKETS=fallback($OTHERVAR, foo) ``` Read more about string expansion in the [@env-spec reference](/env-spec/reference/#expansion). ## Decorator details [Section titled “Decorator details”](#decorator-details) ### Functions vs single use [Section titled “Functions vs single use”](#functions-vs-single-use) Most decorators take a single value (e.g., `@sensitive`, `@currentEnv`) and may be used only once per item (or file in the case of a root decorator). Some decorators however, are function calls (e.g., `@docs()`, `@import()`) and may be called multiple times. ```env-spec # @sensitive=true # @docs(https://xyzapi.com/docs/auth) # @docs(https://xyzapi.com/manage-api-keys) XYZ_API_KEY= ``` ### Value resolution [Section titled “Value resolution”](#value-resolution) Values passed to decorators will be resolved, meaning if a decorator is expecting a boolean, either a static `true`/`false` or a [resolver function](/reference/functions) that resolves to a boolean may be used. ```env-spec # @required=false NEVER_REQUIRED= # @required=forEnv(prod) # resolves to true/false depending on the current environment REQUIRED_FOR_PROD= ``` # Secrets management > Best practices for managing secrets and sensitive environment variables with varlock Varlock uses the term *sensitive* to describe any value that should not be exposed to the outside world. This includes secret API keys, passwords, and other generally sensitive information. Instead of relying on prefixes (e.g., `NEXT_PUBLIC_`) to know which items may be “public”, varlock relies on `@decorators` to mark sensitive items explicitly. Because we understand which values are sensitive, we can apply extra security guardrails to keep them safe at every step of the SDLC. No more plaintext secrets! One of varlock’s main goals is to help you keep *every secret out of plaintext*. This reduces the risk of leaks via supply chain attacks, accidental commits to version control, LLM access, and other common pitfalls. ## Identifying `@sensitive` items [Section titled “Identifying @sensitive items”](#identifying-sensitive-items) Whether each item is sensitive or not is controlled by the [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive) root decorator and the [`@sensitive`](/reference/item-decorators/#sensitive) item decorator. For example: .env.schema ```env-spec # @defaultSensitive=false # --- # not sensitive by default (because of the root decorator) NON_SECRET_FOO= # @sensitive # explicitly marking this item as sensitive SECRET_FOO= ``` These decorators are typically used only in your `.env.schema` file, but may be used in any file, which can be useful to override sensitivity in a specific environment. ## Values varlock can’t protect [Section titled “Values varlock can’t protect”](#values-varlock-cant-protect) Marking an item `@sensitive` is a claim that varlock will keep the value out of your output. Redaction makes good on that by replacing the value wherever it appears, which only works for a string long enough not to occur in ordinary text. Some values can’t be protected that way at all, so varlock reports them rather than letting the decorator promise something it isn’t delivering. | The value | What happens | | ---------------------------------------------------------------- | ---------------------------------------------------- | | under 12 characters | warning | | under 3 characters | error if you wrote `@sensitive`, otherwise a warning | | a boolean | error if you wrote `@sensitive`, otherwise a warning | | a number | error if you wrote `@sensitive`, otherwise a warning | | an array or object with non-string elements | error if you wrote `@sensitive`, otherwise a warning | | the [`@currentEnv`](/reference/root-decorators/#currentenv) item | error if you wrote `@sensitive`, otherwise a warning | | a secret embedded in a non-sensitive item’s value | warning, on the non-sensitive item | **Short values.** Redaction has no token boundary, so a sensitive `acmeco` rewrites every `acmeco` in your logs and proxied responses, including text that was never a secret. A value that common isn’t meaningfully protected by redacting it either. Set `@sensitive=false` when it isn’t really a secret, like an org slug or an account name. Some secrets are short by nature, like a one-time code or a PIN, and can’t be lengthened; `@sensitive={allowShortValue=true}` records that you read the collision risk and accepted it. Under 3 characters the collision is a certainty rather than a risk, so the acknowledgement doesn’t apply. **Booleans and numbers.** A boolean holds one bit, so there’s no secret in it to protect, while redacting it would rewrite every `true`/`false` you print. A number is never redacted at all, because redaction only replaces strings. For a numeric secret, make it a string with `@type=string` or by quoting the value (`PIN="007123"`), which also preserves the leading zeros and the precision past 2^53 that a number drops. **Composite values** are measured per element, since each element is registered for redaction on its own. That means an `array(number)` follows the same rule as a bare number, and a long array with a one-character element is still a one-character match. **The `@currentEnv` item** drives conditional imports and `forEnv()`, and gets echoed by most tools in your stack. It’s a mode name, not a secret, and redacting it rewrites that name everywhere it appears. **A secret inside a non-sensitive value** is reported on the non-sensitive item, since marking that item sensitive is usually the fix. Two things can be wrong: a long secret embedded in a public value is carried into logs, generated types, and client bundles where none of its protections apply; a short one will be redacted out of the containing value everywhere that value legitimately appears. ### Which ones fail the load [Section titled “Which ones fail the load”](#which-ones-fail-the-load) Nothing an item inherited from `@defaultSensitive` can fail your load. Every rule above is an error only when you wrote `@sensitive` on the item yourself, and a warning when `@defaultSensitive=true` swept it in: that default is what a hand-written schema inherits, so it picks up every port, feature flag, region code and env name in the file, and failing those would be a bigger break than the problem warrants. Either way the fix is the same, `@sensitive=false` (or `@public`). Builtin `VARLOCK_*` variables are exempt from all of it. None of this changes an item’s sensitivity for you. Making a value public would also make it [`@static`](/reference/item-decorators/#static) and eligible to be inlined into a build, so varlock reports the problem and leaves the decision to you. ## Local encryption via `varlock()` [Section titled “Local encryption via varlock()”](#local-encryption) For local git-ignored secrets (typically overrides in `.env.local`), use the [`varlock()` function](/reference/functions/#varlock) to store encrypted values on disk. Note that the encryption key used is tied to your device, so these encrypted values are not meant to be shared or committed to git. .env.local ```env-spec PLAINTEXT=shh-im-secret # 🚨 danger SECURED=varlock(local:abc123...) # ✅ secured at rest NEW_ITEM=varlock(prompt) # will prompt for new value ``` See the [local encryption guide](/guides/local-encryption/) for full setup, platform-specific details, and related CLI commands like [`varlock encrypt`](/reference/cli/encryption/#encrypt), [`varlock reveal`](/reference/cli/encryption/#reveal), and [`varlock lock`](/reference/cli/encryption/#lock). On macOS, you can also use the built-in [keychain()](/plugins/macos-keychain/) plugin, which provides a similar experience but stores encrypted values in the system keychain. ## Loading secrets from external sources [Section titled “Loading secrets from external sources”](#loading-secrets-from-external-sources) ### Using plugins (recommended) [Section titled “Using plugins (recommended)”](#using-plugins-recommended) `varlock` provides official plugins for popular secret management platforms, giving you a type-safe way to fetch secrets directly in your `.env` files. Available plugins include: * [1Password](/plugins/1password/) * [AWS Secrets Manager & Parameter Store](/plugins/aws-secrets/) * [Azure Key Vault](/plugins/azure-key-vault/) * [Bitwarden](/plugins/bitwarden/) * [Google Secret Manager](/plugins/google-secret-manager/) * [HashiCorp Vault](/plugins/hashicorp-vault/) * [Infisical](/plugins/infisical/) See the [plugins overview](/plugins/overview/) for the complete list. Plugins are able to register new decorators and resolver functions that declaratively fetch secrets: ```env-spec # Install and initialize the 1Password plugin # @plugin(@varlock/1password-plugin) # @initOp(token=$OP_TOKEN, allowAppAuth=forEnv(dev)) # --- # Load secrets using the op() resolver function # @sensitive @required MY_SECRET=op(op://my-vault/item-name/field-name) ``` Benefits of using plugins: * Declarative secret references safe to check into version control * Built-in validation and type safety applied to fetched values * Built-in authentication handling * Better error messages and debugging * Platform-specific features (e.g., biometric unlock for 1Password) See each plugin’s documentation for detailed setup instructions. ### Using exec() as a fallback [Section titled “Using exec() as a fallback”](#using-exec-as-a-fallback) For cases where a plugin doesn’t exist or you need custom logic, `varlock` supports fetching secrets via CLI commands using `exec()` function syntax. ```env-spec # A secret fetched via a custom CLI / script # @sensitive @required MY_SECRET=exec(`./scripts/fetch-secret.sh prod/api-credential`) ``` This approach works with any CLI tool, ensuring no secrets are left in plaintext on your system, even if they are gitignored. If a [plugin](/plugins/overview/) already exists for your provider (1Password, AWS, Vault, …), prefer it over `exec()`. Plugin resolvers like `op()` handle auth and caching for you. ### Bulk injection with `@setValuesBulk()` [Section titled “Bulk injection with @setValuesBulk()”](#bulk-injection-with-setvaluesbulk) For some secret management platforms, you may already be setting key names that match your environment variable names - in which case, wiring up each value can feel like a lot of boilerplate. In cases like this, you can set many values at once using the [`@setValuesBulk()`](/reference/root-decorators/#setvaluesbulk) root decorator. For example, using 1Password, you could store a .env style blob within a text field, or you could fetch values from their new environments tool. .env.schema ```env-spec # fetch a dotenv style blob within a text field # @setValuesBulk(op("op://vault/item/field")) # # load values in a 1Password environment # @setValuesBulk(opLoadEnvironment(your-environment-id), createMissing=true) # # load all secrets from an Infisical project environment # @setValuesBulk(infisicalBulk()) # # load Infisical secrets filtered by path or tag # @setValuesBulk(infisicalBulk(path="/database", tag="backend")) # # load all key/value pairs from a HashiCorp Vault path # @setValuesBulk(vaultSecret("secret/myapp/config", raw=true)) # # inject only specific keys (pick allowlist), or everything except some (omit denylist) # both accept simple globs like API_* # @setValuesBulk(opLoadEnvironment(your-environment-id), pick=[API_KEY, DB_*]) # @setValuesBulk(infisicalBulk(), omit=[LEGACY_TOKEN]) ``` The bulk values are injected at the precedence level of the file containing the decorator, so `.env.local` and `process.env` will still override them as expected. See the [reference docs](/reference/root-decorators/#setvaluesbulk) for full details. ## Security enhancements [Section titled “Security enhancements”](#security-enhancements) Unlike other tools where you have to rely on pattern matching to detect *sensitive-looking* data, `varlock` knows exactly which values are sensitive, and can take extra precautions to protect them. ### CLI log redaction [Section titled “CLI log redaction”](#cli-log-redaction) The varlock CLI itself redacts sensitive values in its own output. Commands like [`varlock explain`](/reference/cli/load-and-run/#explain) will display resolved values with partial masking (e.g., `my▒▒▒▒▒`) so you can verify configuration without exposing secrets in your terminal scrollback. When you need to see the actual plaintext value, use [`varlock reveal`](/reference/cli/encryption/#reveal) which provides a secure viewing experience: ```bash varlock reveal # interactive picker to select a secret varlock reveal MY_SECRET # reveal a specific variable varlock reveal MY_SECRET --copy # copy to clipboard (auto-clears after 10s) ``` The `reveal` command displays values in an **alternate screen buffer**. When you press any key to dismiss, the value disappears from your terminal entirely and won’t be visible in scrollback history. With `--copy`, the value is copied to your clipboard and automatically cleared after 10 seconds. #### Redaction in `varlock run` [Section titled “Redaction in varlock run”](#redaction-in-varlock-run) When the output of [`varlock run`](/reference/cli/load-and-run/#run) is piped or redirected (CI logs, files, `| tee`, etc.), the child process’s stdout/stderr is piped through the same redaction engine, so any sensitive values that end up in your application’s output will be masked automatically before they persist anywhere. When output is attached to an interactive terminal, it passes straight through instead. Piping would break interactive tools (e.g., `psql`, `claude`) that rely on TTY detection, and a human at the terminal already has access to the secrets. `--no-redact-stdout` disables redaction entirely. `--redact-stdout` forces it on piped output, which is how you override `@redactLogs=false`; it errors when output is attached to an interactive terminal, where redaction would break TTY behavior. ```bash varlock run --no-redact-stdout -- node app.js | tee log.txt # force-disable redaction for piped output varlock run --redact-stdout -- node app.js > log.txt # force redaction despite @redactLogs=false ``` The same override can be set with the [`_VARLOCK_REDACT_STDOUT`](/reference/reserved-variables/#_varlock_redact_stdout) environment variable (`true`/`1` or `false`/`0`) when you can’t easily change the command, e.g. in a wrapper script or CI config. The CLI flag takes precedence over the env var. ### Runtime log redaction [Section titled “Runtime log redaction”](#runtime-log-redaction) *Only available in JavaScript/Node.js projects using varlock’s runtime integrations.* When using `varlock/auto-load` or a [framework integration](/integrations/overview/), varlock automatically patches global `console` methods (`log`, `warn`, `error`, `debug`, `info`, `trace`) to redact any sensitive values before they reach stdout/stderr. Sensitive values are replaced with a partially masked string using the `▒` character. For example, `my-secret-value` becomes `my▒▒▒▒▒`. ```js console.log(process.env.SECRET_KEY); // outputs "my▒▒▒▒▒" instead of "my-secret-value" ``` This works by intercepting Node’s console internals, with an additional layer that wraps the console methods themselves to handle environments where `console.log` has been patched by the platform (e.g., AWS Lambda) or where those internals do not exist at all (edge runtimes like Cloudflare Workers and Vercel Edge). Redaction covers whatever you pass to the console methods: strings, arrays, plain objects, and `Error` objects (including messages, stack traces, and anything nested inside). Varlock logs a redacted copy rather than modifying what you passed in. If you need to intentionally reveal a secret in logs (for example during debugging), you can use the `revealSensitiveConfig` helper: ```js import { revealSensitiveConfig } from 'varlock/env'; console.log(revealSensitiveConfig(process.env.SECRET_KEY)); // outputs the actual value ``` To disable runtime log redaction, set the [`@redactLogs`](/reference/root-decorators/#redactlogs) root decorator to `false`. ### Leak prevention [Section titled “Leak prevention”](#leak-prevention) *Only available in JavaScript/Node.js projects using varlock’s runtime integrations.* Varlock scans outgoing HTTP responses at runtime to detect if any sensitive values are being accidentally sent to clients. If a leak is detected, varlock throws an error with a detailed diagnostic message including the config item key and where the leak was detected. The rejected body is never delivered and the request fails rather than hanging, so look to your server log for the diagnostic. This works by patching: * **Node.js `ServerResponse`**: intercepts `write()` and `end()` calls, scanning text and JSON response bodies, including compressed ones * **Global `Response` constructor**: intercepts the `Response` class used in edge runtimes (e.g., Cloudflare Workers), scanning bodies passed to the constructor and `Response.json()` Streamed responses are scanned across chunk boundaries, so a sensitive value that gets split between two writes is still detected. When the end of a chunk looks like the start of a sensitive value, that trailing text is held back briefly (up to 15ms, or until the next chunk arrives) so the full value can be caught before any of it is sent. Our [framework integrations](/integrations/overview/) automatically apply the appropriate patches for your environment. For example, a Next.js integration will scan both server-rendered pages and API route responses. To disable leak prevention, set the [`@preventLeaks`](/reference/root-decorators/#preventleaks) root decorator to `false`. Caution There is a potential performance impact for both `@preventLeaks` and `@redactLogs` when enabled, as they must inspect console output and response bodies. We feel they are beneficial enough to have on by default, but you can always opt out if needed. ## Scanning files for leaked secrets [Section titled “Scanning files for leaked secrets”](#scanning-files-for-leaked-secrets) The [`varlock scan` command](/reference/cli/project/#scan) checks your project files for any plaintext occurrences of your `@sensitive` values. It loads your varlock config, resolves all sensitive values, and then searches through files to detect leaks. ```bash varlock scan ``` This is intended to be used as a pre-commit git hook to prevent accidentally committing secrets into version control. If no sensitive values are found in plaintext, it exits successfully. If any are detected, it reports the file, line number, and which secret was found, then exits with a non-zero status code. It can also be used to scan build output as an extra step to prevent accidentally bundling secrets into client-facing code. Our [drop-in integrations](/integrations/overview/) usually do this automatically, but this can be useful in some scenarios. ### Scanning modes [Section titled “Scanning modes”](#scanning-modes) * `varlock scan` - default mode, scans all files except gitignored ones * `varlock scan --include-ignored` - scans all files including gitignored ones * `varlock scan --staged` - scans only the files you have staged for commit * `varlock scan path1 path2/**/*.js` - scans specific files #### Automatic setup [Section titled “Automatic setup”](#automatic-setup) The easiest way to set this up is: ```bash varlock scan --install-hook ``` This will detect if you use a hook manager (like [husky](https://typicode.github.io/husky/) or [lefthook](https://github.com/evilmartians/lefthook)) and provide appropriate instructions. If no hook manager is detected, it will create a `.git/hooks/pre-commit` script for you. Note If varlock is installed as a project dependency (rather than a standalone binary), the generated hook command will automatically be prefixed with your package manager’s exec command (e.g., `npx varlock scan` or `bunx varlock scan`). #### Manual setup [Section titled “Manual setup”](#manual-setup) If you prefer to set it up yourself, add the following to your pre-commit hook: **Plain git hook** (`.git/hooks/pre-commit`): ```bash #!/bin/sh varlock scan ``` Make sure the hook file is executable: ```bash chmod +x .git/hooks/pre-commit ``` **With husky** (`.husky/pre-commit`): ```bash varlock scan ``` **With lefthook** (`lefthook.yml`): ```yaml pre-commit: commands: varlock-scan: run: varlock scan ``` Tip If you already have an existing pre-commit hook, just add `varlock scan` as an additional line in the script. It will exit with a non-zero code if any secrets are found, which will abort the commit. # Shell completion > Enable tab completion for the varlock CLI in bash, zsh, fish, and PowerShell The varlock CLI supports tab completion for subcommands and flags. There are two ways to get it, depending on how varlock is installed: * **`varlock` on your PATH** (standalone binary or global npm install): install varlock’s own completion script. See [Setup](#setup) below. * **varlock as a local project dependency** (run via `pnpm exec`, `npm exec`, `bun x`, scripts, etc.): completion comes from your package manager’s completion instead. See [Local project installs](#local-project-installs). ## Setup [Section titled “Setup”](#setup) Use this when `varlock` is on your PATH, installed via the [standalone binary](/getting-started/installation/#as-a-standalone-binary) (Homebrew or cURL) or a global npm install (`npm install -g varlock`). Generate a completion script with `varlock complete `, then install it using the instructions below for your shell. After upgrading varlock, re-run `varlock complete ` if new commands or flags were added. * Zsh Zsh completion files must be named with a leading underscore: ```bash mkdir -p ~/.zsh/completions echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc echo 'autoload -U compinit && compinit' >> ~/.zshrc varlock complete zsh > ~/.zsh/completions/_varlock source ~/.zshrc ``` * Bash ```bash mkdir -p ~/.local/share/bash-completion/completions varlock complete bash > ~/.local/share/bash-completion/completions/varlock source ~/.bashrc ``` * Fish ```bash mkdir -p ~/.config/fish/completions varlock complete fish > ~/.config/fish/completions/varlock.fish ``` Fish loads completions automatically. Restart your shell or open a new terminal to pick them up. * PowerShell ```powershell varlock complete powershell >> $PROFILE . $PROFILE ``` ## Local project installs [Section titled “Local project installs”](#local-project-installs) When varlock is only a local dependency (not on your PATH), you run it through your package manager: `pnpm exec varlock …`, `npm exec varlock …`, `bun x varlock …`, or a `package.json` script. In that case completion comes from **your package manager’s** completion, which delegates to varlock automatically. You don’t install anything varlock-specific; varlock already speaks the completion protocol that package-manager completion uses. Set up completion for your package manager once, using [`@bomb.sh/tab`](https://bomb.sh/docs/tab/#package-manager-completions): ```bash npm install -g @bomb.sh/tab # zsh (swap in npm / yarn / bun and bash / fish / powershell as needed) echo 'source <(tab pnpm zsh)' >> ~/.zshrc source ~/.zshrc ``` Now `pnpm exec varlock `, `pnpm dlx varlock `, and bare `pnpm varlock ` complete varlock’s subcommands and flags. The same works for `npm exec`, `yarn`, and `bun x`. `npx` / `bunx` aren’t covered Completion is registered against the package-manager binary (`npm`, `pnpm`, `yarn`, `bun`). `npx` and `bunx` are separate commands with no completion of their own, so `npx varlock ` / `bunx varlock ` won’t complete. Use `npm exec varlock` / `bun x varlock` instead. ## What gets completed [Section titled “What gets completed”](#what-gets-completed) Tab completion covers varlock **subcommands** and **static flags** (including enum choices like `load --format`). Dynamic values, such as env var names from your schema, are not completed yet. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) If completions stop working after an upgrade, regenerate the script: * npm ```bash npm exec -- varlock complete zsh > ~/.zsh/completions/_varlock ``` * pnpm ```bash pnpm exec -- varlock complete zsh > ~/.zsh/completions/_varlock ``` * bun ```bash bunx varlock complete zsh > ~/.zsh/completions/_varlock ``` * vlt ```bash vlx -- varlock complete zsh > ~/.zsh/completions/_varlock ``` * yarn ```bash yarn exec -- varlock complete zsh > ~/.zsh/completions/_varlock ``` * standalone binary ```bash varlock complete zsh > ~/.zsh/completions/_varlock ``` Replace `zsh` and the output path with your shell and install location. # Telemetry > Learn about varlock's anonymous usage analytics and how to opt out The `varlock` CLI collects **anonymous telemetry data** about usage to help us understand how the tool is being used and to make it better. Participation is optional, and you may opt-out at any time. ## What We Collect [Section titled “What We Collect”](#what-we-collect) We track general usage information, and the environment in which `varlock` is being used. Specifically we collect *anonymous* information about: * Which varlock command is being invoked * Which framework integration invoked the CLI (if any), such as Vite, Next.js, or Astro: official `@varlock/*` package name and version only * Which plugins are installed in the project: `@varlock/*` plugin names and versions are sent as-is; third-party plugin names are hashed (SHA-256) and versions are omitted * Anonymous, non-sensitive usage attributes that official `@varlock/*` plugins report about how they are used (for example which authentication mode is active, or whether a feature is enabled): booleans, short enum strings, and counts only; collected for official plugins only and strictly sanitized (never secret values, item references, hostnames, or paths) * Anonymous schema feature signals when a project is loaded: for example root settings like `@redactLogs`, cache mode, safe resolver/decorator identifiers in use, and data source type counts (not config keys, values, file paths, or error messages) * Whether the env graph was loaded (`graph_loaded`) and a coarse error category when the run failed (`error_code`: e.g. `parse_error`, `plugin_error`, `schema_error`, `validation_error`, `resolution_error`, `load_error`): no error messages or file paths * Version information for varlock and Node.js * JavaScript package manager in use (if detectable from lockfiles or runtime env): `npm`, `pnpm`, `yarn`, `bun`, or `deno` only * General system/machine information * Anonymous user + project ID * A random per-run ID, so the events from a single command can be grouped together. It is generated fresh in memory each time the CLI starts, is never written to disk, and cannot be used to link one run to another **We will never collect any of your config files or environment variables.** ## How to Opt Out [Section titled “How to Opt Out”](#how-to-opt-out) You can opt out of analytics in three ways: ### Using the CLI [Section titled “Using the CLI”](#using-the-cli) Run the following command to permanently opt out: * npm ```bash npm exec -- varlock telemetry disable ``` * pnpm ```bash pnpm exec -- varlock telemetry disable ``` * bun ```bash bunx varlock telemetry disable ``` * vlt ```bash vlx -- varlock telemetry disable ``` * yarn ```bash yarn exec -- varlock telemetry disable ``` * standalone binary ```bash varlock telemetry disable ``` This will create/update a configuration file saving your preference at `$XDG_CONFIG_HOME/varlock/config.json` (defaults to `~/.config/varlock/config.json`). *You may re-enable telemetry by running `varlock telemetry enable`* ### Using an Environment Variable [Section titled “Using an Environment Variable”](#using-an-environment-variable) You can also opt out temporarily by setting the `VARLOCK_TELEMETRY_DISABLED` environment variable: ```bash export VARLOCK_TELEMETRY_DISABLED=true ``` This could be set in a specific terminal session, while running a specific command, in a Dockerfile, or in a CI/CD pipeline. varlock also honors `DO_NOT_TRACK`, a convention shared by many command line tools such as Homebrew, Deno and Turborepo. The convention is described at [donottrack.sh](https://donottrack.sh/). Setting it opts you out of every tool that supports it, so you do not need to remember a separate variable for each one: ```bash export DO_NOT_TRACK=1 ``` ### With a project config file [Section titled “With a project config file”](#with-a-project-config-file) You can also opt out at the project level by creating a `.varlock/config.json` file in your project root with the following content: my-app/.varlock/config.json ```json { "telemetryDisabled": true } ``` ## Privacy [Section titled “Privacy”](#privacy) * All analytics data is completely anonymous * No personal or sensitive information is collected * Data is only used to improve the product * You can opt out at any time * Analytics are handled by [PostHog](https://posthog.com/), a privacy-friendly analytics platform ## Data Usage [Section titled “Data Usage”](#data-usage) The anonymous usage data helps us: * Understand which features are most used * Identify areas for improvement * Make informed decisions about future development * Prioritize bug fixes and new features If you have any questions about our analytics or privacy practices, please [start a discussion](https://github.com/dmno-dev/varlock/discussions) on GitHub. # Astro > How to integrate varlock with Astro for secure, type-safe environment management [![](https://img.shields.io/npm/v/@varlock/astro-integration?label=%40varlock%2Fastro-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/astro-integration) While Astro has [`astro:env`](https://docs.astro.build/en/guides/environment-variables/) to help with environment variables, we think Varlock has more to offer: * Your `.env.schema` is not tied to JavaScript, and is a better place to store this schema info versus your `astro.config.*` file * Facilitates loading and composing multiple `.env` files * You can use validated env vars right away within your `astro.config.*` file * Facilitates setting values and handling multiple environments, not just setting defaults * More data types and options available * Leak detection, log redaction, and more security guardrails To integrate varlock into an Astro application, you must use our [`@varlock/astro-integration`](https://npmx.dev/package/@varlock/astro-integration) package, which is an [Astro integration](https://docs.astro.build/en/guides/integrations-guide/). ## Setup [Section titled “Setup”](#setup) Requirements * Node.js v22 or higher * Astro v4 or higher 1. **Install varlock and the Astro integration package** * npm ```bash npm install @varlock/astro-integration varlock ``` * pnpm ```bash pnpm add @varlock/astro-integration varlock ``` * bun ```bash bun add @varlock/astro-integration varlock ``` * yarn ```bash yarn add @varlock/astro-integration varlock ``` * vlt ```bash vlt install @varlock/astro-integration varlock ``` 2. **Run `varlock init` to set up your `.env.schema` file** This will guide you through setting up your `.env.schema` file, based on your existing `.env` file(s). Make sure to review it carefully. * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Enable the Astro integration** You must add our `varlockAstroIntegration` to your `astro.config.*` file: astro.config.ts ```diff import { defineConfig } from 'astro/config'; +import varlockAstroIntegration from '@varlock/astro-integration'; export default defineConfig({ integrations: [varlockAstroIntegration(), otherIntegration()], }); ``` *** ## Accessing environment variables [Section titled “Accessing environment variables”](#accessing-environment-variables) You can continue to use `import.meta.env.SOMEVAR` as usual, but we recommend using varlock’s imported `ENV` object for better type-safety and improved developer experience: example.ts ```ts import { ENV } from 'varlock/env'; console.log(import.meta.env.SOMEVAR); // 🆗 still works console.log(ENV.SOMEVAR); // ✨ recommended ``` #### Why use `ENV` instead of `import.meta.env`? [Section titled “Why use ENV instead of import.meta.env?”](#why-use-env-instead-of-importmetaenv) * Non-string values (e.g., number, boolean) are properly typed and coerced * All non-sensitive items are replaced at build time (not just `VITE_` prefixed ones) * Better error messages for invalid or unavailable keys * Enables future DX improvements and tighter control over what is bundled ### Within `astro.config.*` [Section titled “Within astro.config.\*”](#within-astroconfig) It’s often useful to be able to access env vars in your Astro config. Without varlock, it’s a bit awkward, but varlock makes it easy. In fact it’s already available: import varlock’s `ENV` object and reference env vars via `ENV.SOME_ITEM` like you do everywhere else. astro.config.ts ```diff import { defineConfig } from 'astro/config'; import varlockAstroIntegration from '@varlock/astro-integration'; +import { ENV } from 'varlock/env'; +doSomethingWithEnvVar(ENV.FOO); export default defineConfig({ /* ... */ }); ``` TypeScript config If you find you are not getting type completion on `ENV`, you may need to add your generated type files (usually `env.d.ts`) to your `tsconfig.json`’s `include` array. ### Within other scripts [Section titled “Within other scripts”](#within-other-scripts) Even in a static front-end project, you may have other scripts in your project that rely on sensitive config. You can use [`varlock run`](/reference/cli/load-and-run/#run) to inject resolved config into other scripts as regular env vars. * npm ```bash npm exec -- varlock run -- node ./script.js ``` * pnpm ```bash pnpm exec -- varlock run -- node ./script.js ``` * bun ```bash bunx varlock run -- node ./script.js ``` * vlt ```bash vlx -- varlock run -- node ./script.js ``` * yarn ```bash yarn exec -- varlock run -- node ./script.js ``` ### Type-safety and IntelliSense [Section titled “Type-safety and IntelliSense”](#type-safety-and-intellisense) To enable type-safety and IntelliSense for your env vars, enable the [`@generateTsTypes` root decorator](/reference/root-decorators/#generatetstypes) in your `.env.schema`. Note that if your schema was created using `varlock init`, it will include this by default. .env.schema ```diff +# @generateTsTypes(path='env.d.ts') # --- # your config items... ``` *** ## Managing multiple environments [Section titled “Managing multiple environments”](#managing-multiple-environments) Varlock can load multiple *environment-specific* `.env` files (e.g., `.env.development`, `.env.preview`, `.env.production`) by using the [`@currentEnv` root decorator](/reference/root-decorators/#currentenv). **This is different than Astro/Vite’s default behaviour, which relies on it’s own [`MODE` flag](https://vite.dev/guide/env-and-mode.html#modes).** Usually this env var will be defaulted to something like `development` in your `.env.schema` file, and you can override it by overriding the value when running commands - for example `APP_ENV=production vite build`. For a JavaScript based project, this will often be done in your `package.json` scripts. package.json ```json { "scripts": { "dev": "astro dev", "build": "APP_ENV=production astro build", "preview": "APP_ENV=production vite preview", } } ``` In some cases, you could also set the current environment value based on other vars already injected by your CI platform, like the current branch name. See the [environments guide](/guides/environments) for more information. ## Managing sensitive config values [Section titled “Managing sensitive config values”](#managing-sensitive-config-values) Astro uses the `PUBLIC_` prefix to determine which env vars are public (bundled for the browser). Varlock decouples the concept of being *sensitive* from key names, and instead you control this with the [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive) root decorator and the [`@sensitive`](/reference/item-decorators/#sensitive) item decorator. See the [secrets guide](/guides/secrets) for more information. Set a default and explicitly mark items: .env.schema ```diff +# @defaultSensitive=false # --- NON_SECRET_FOO= # sensitive by default # @sensitive SECRET_FOO= ``` Or if you’d like to continue using Astro’s prefix behavior: .env.schema ```diff +# @defaultSensitive=inferFromPrefix('PUBLIC_') # --- FOO= # sensitive PUBLIC_FOO= # non-sensitive, due to prefix ``` Bundling behavior All non-sensitive items are bundled at build time via `ENV`, while `import.meta.env` replacements continue to only include `PUBLIC_`-prefixed items. ### Leak Detection [Section titled “Leak Detection”](#leak-detection) This integration will automatically inject a new middleware that scans outgoing http responses for any sensitive values. Encrypting the env blob When deploying SSR Astro apps to serverless platforms, varlock injects the resolved env into your server-side build output as plaintext JSON. This is generally safe since it only appears in server code, but you can encrypt it for extra protection (e.g., against sourcemap leaks). See the [encrypted deployments guide](/guides/encrypted-deployments/) for setup instructions. *** ## Deploying to Cloudflare Workers [Section titled “Deploying to Cloudflare Workers”](#deploying-to-cloudflare-workers) If you deploy your SSR Astro app to Cloudflare Workers via [`@astrojs/cloudflare`](https://docs.astro.build/en/guides/integrations-guide/cloudflare/), install [`@varlock/cloudflare-integration`](/integrations/cloudflare/) alongside the Astro integration. The Astro integration **auto-detects** the Cloudflare adapter and wires up env injection into the worker. There’s no extra Vite or adapter config to write, and you keep using `astro dev` as normal. Static and prerendered pages work too: they are rendered at build time with your resolved env, so a fully static site (`output: 'static'`) needs no vars or secrets uploaded to Cloudflare at all. 1. **Install the Cloudflare integration** * npm ```bash npm install @varlock/cloudflare-integration ``` * pnpm ```bash pnpm add @varlock/cloudflare-integration ``` * bun ```bash bun add @varlock/cloudflare-integration ``` * yarn ```bash yarn add @varlock/cloudflare-integration ``` * vlt ```bash vlt install @varlock/cloudflare-integration ``` 2. **Use the Cloudflare adapter as usual** No varlock-specific Cloudflare setup is needed. Use the standard adapter alongside `varlockAstroIntegration()`: astro.config.ts ```ts import { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare'; import varlockAstroIntegration from '@varlock/astro-integration'; export default defineConfig({ output: 'server', adapter: cloudflare(), integrations: [varlockAstroIntegration()], }); ``` 3. **Deploy with `varlock-wrangler`** Use [`varlock-wrangler`](/integrations/cloudflare/#varlock-wrangler-cli) so your resolved env is uploaded as Cloudflare vars and secrets at deploy time: package.json ```json { "scripts": { "dev": "astro dev", "build": "astro build", "deploy": "astro build && varlock-wrangler deploy" } } ``` Requires `nodejs_compat` Varlock relies on a handful of `node:*` built-ins at runtime, so your worker must enable Cloudflare’s Node.js compatibility layer. Add `nodejs_compat` to `compatibility_flags` in your `wrangler.json` / `wrangler.toml`. Remove `.dev.vars` Varlock manages env injection automatically. Delete any `.dev.vars` file in your project root, or the dev server will error. See the [Cloudflare Workers integration docs](/integrations/cloudflare/) for full details on how dev injection, deployment, CI/CD, and `varlock-wrangler` work. ## Dynamic+public config [Section titled “Dynamic+public config”](#dynamicpublic-config) Use [`@dynamic`](/reference/item-decorators/#dynamic) to keep selected public values runtime-resolved; server-rendered code keeps reading `ENV.KEY` with no extra setup. See the [Static vs Dynamic Config guide](/guides/dynamic-config/) for the full model. .env.schema ```env-spec PUBLIC_STATIC_FLAG=enabled # @public PUBLIC_RUNTIME_FLAG=enabled # @public @dynamic ``` ### Client-side usage [Section titled “Client-side usage”](#client-side-usage) When the schema contains any public+dynamic keys, this integration automatically injects a route at `/__varlock/public-env` (SSR builds only) that serves them to the browser. If browser code needs one of these values, load it with `loadPublicDynamicEnv()` before reading `ENV.KEY`; this is rarely needed, but when it is, see [Loading dynamic vars in the client](/guides/dynamic-config/#loading-dynamic-vars-in-the-client). Static output With `output: 'static'`, the endpoint is not injected automatically during builds (it is still available in `astro dev`). If you use static output with an adapter (so non-prerendered routes are allowed), opt in explicitly with `publicDynamicEndpoint: true`. Without an adapter there is no server to serve runtime public values from. ### Route injection options [Section titled “Route injection options”](#route-injection-options) You can control endpoint injection: astro.config.ts ```ts import { defineConfig } from 'astro/config'; import varlockAstroIntegration from '@varlock/astro-integration'; export default defineConfig({ integrations: [ varlockAstroIntegration({ publicDynamicEndpoint: true, // auto by default // or: { path: '/my-public-env' } // or: false (disable auto endpoint) }), ], }); ``` ## Reference [Section titled “Reference”](#reference) * [Root decorators reference](/reference/root-decorators) * [Item decorators reference](/reference/item-decorators) * [Functions reference](/reference/functions) * [Astro’s environment variable docs](https://docs.astro.build/en/guides/environment-variables/) # Bun > How to integrate Varlock with a Bun-powered JavaScript project For the most part, Varlock just works with Bun the same way it works with Node.js, and other JavaScript integrations work the same way. ### Conflicts with Bun’s .env loading [Section titled “Conflicts with Bun’s .env loading”](#conflicts-with-buns-env-loading) Bun does its own automatic loading of `.env` files, based on the current value of `NODE_ENV` (or `BUN_ENV`), which it defaults to `development` if not set. This causes problems when bun decides to load `.env.development` and passes those env vars into varlock. The best way to fix this is to [disable bun’s automatic loading of `.env` files](https://bun.com/docs/runtime/environment-variables#disabling-automatic-env-loading) in your `bunfig.toml` file: bunfig.toml ```toml env = false ``` You may also use the `--no-env-file` CLI flag when invoking scripts with `bun`/`bunx`. Note that if you are building a standalone executable using `bun build`, you can use the `--no-compile-autoload-dotenv` flag to disable this behavior in the final executable. ### Compiled executables [Section titled “Compiled executables”](#compiled-executables) `bun build --compile` bundles the `varlock/auto-load` runtime code into your executable, but it does not bundle the separate Varlock CLI that resolves your config. If the executable resolves config when it starts, your deployment must also include the installed `varlock` package and its dependencies. The executable looks for `node_modules/.bin/varlock` in its directory and each parent directory. For example, this monorepo layout keeps the CLI available to the compiled server: ```text apps/server/dist/server apps/server/node_modules/.bin/varlock ``` If your deployment platform installs or prunes dependencies, declare `varlock` as a production dependency in a package whose `node_modules` directory is deployed with the executable. You can also launch the executable through the CLI. This resolves the config before the application starts, so the bundled `varlock/auto-load` code reuses the injected environment instead of looking for the CLI again: ```sh varlock run -- ./dist/server ``` If your deployment otherwise contains only the compiled executable, install the [standalone Varlock binary](/getting-started/installation/#as-a-standalone-binary) in the deployment environment and launch the application through `varlock run`. ### Using a preload script (optional) [Section titled “Using a preload script (optional)”](#using-a-preload-script-optional) One option we have with bun is to use a [preload script](https://bun.com/docs/runtime/bunfig#preload), configured in `bunfig.toml`. If you do this, you will no longer have to use `bun run varlock run -- yourscript` or use `import 'varlock/auto-load'` in your code! bunfig.toml ```toml preload = ["varlock/auto-load"] ``` Do not use preload with framework integrations Note that you should not do this if using a framework integration, as those integrations watch your `.env` files to trigger live-reloading. # Cloudflare Workers > How to integrate varlock with Cloudflare Workers and Wrangler for secure, type-safe environment management [![](https://img.shields.io/npm/v/@varlock/cloudflare-integration?label=%40varlock%2Fcloudflare-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/cloudflare-integration) Varlock manages environment variables in Cloudflare Workers with validation, type safety, and security features that go beyond Cloudflare’s built-in environment variable and secrets handling. Our integration relies on using `varlock-wrangler`, a thin wrapper around `wrangler` that automatically resolves and uploads your env vars as Cloudflare secrets and vars during deployment, and injects them into miniflare during local dev. Requires `nodejs_compat` Varlock relies on a handful of `node:*` built-ins at runtime, so your worker must opt into Cloudflare’s Node.js compatibility layer. Add `nodejs_compat` to your `compatibility_flags` in `wrangler.toml` / `wrangler.json`. Without it, the worker will fail to start with “Cannot resolve ‘node:…’” errors. wrangler.json ```json { "compatibility_flags": ["nodejs_compat"] } ``` ## Choosing an approach [Section titled “Choosing an approach”](#choosing-an-approach) All paths use the same `.env.schema` and `varlock-wrangler deploy` for production uploads. The difference is how env reaches your worker during **local dev** and **build**: | Approach | Best when | Dev / build | | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | **[`varlock-wrangler`](#approach-1-using-varlock-wrangler)** | Plain Workers, minimal tooling | `varlock-wrangler dev` injects env via FIFO and watches `.env` files; `varlock-wrangler deploy` uploads vars + secrets | | **[Vite plugin](#approach-2-with-the-vite-plugin)** | Already using [`@cloudflare/vite-plugin`](https://developers.cloudflare.com/workers/vite-plugin/) | `vite dev` injects into miniflare automatically; `vite build` + `varlock-wrangler deploy` | | **[Astro adapter](/integrations/astro/#deploying-to-cloudflare-workers)** | Astro with [`@astrojs/cloudflare`](https://docs.astro.build/en/guides/integrations-guide/cloudflare/) | `@varlock/astro-integration` auto-detects the adapter; `astro dev` works as-is; `astro build` + `varlock-wrangler deploy` | | **[SvelteKit](/integrations/sveltekit/#setup-for-cloudflare-workers)** | SvelteKit with [`@sveltejs/adapter-cloudflare`](https://svelte.dev/docs/kit/adapter-cloudflare) | `@varlock/vite-integration` auto-detects the adapter; `vite dev` via an SSR entry loader; `vite build` + `varlock-wrangler deploy` | Using SvelteKit? Follow the SvelteKit guide instead This page covers plain Workers and the `@cloudflare/vite-plugin` flow, which **does not support SvelteKit** ([workers-sdk#8922](https://github.com/cloudflare/workers-sdk/issues/8922)). For SvelteKit with `@sveltejs/adapter-cloudflare`, set up varlock using the **[SvelteKit → Cloudflare Workers guide](/integrations/sveltekit/#setup-for-cloudflare-workers)**. You use the standard `varlockVitePlugin` (which auto-detects the adapter), **not** `varlockCloudflareVitePlugin`. You’ll still install `@varlock/cloudflare-integration` (for `varlock-wrangler`) and deploy the same way described below. ## Approach 1: Using `varlock-wrangler` [Section titled “Approach 1: Using varlock-wrangler”](#approach-1-using-varlock-wrangler) Replace your `wrangler` commands with `varlock-wrangler` and initialize varlock in your worker code with a single import. It’s a thin wrapper that wires up your env vars correctly and passes everything else through unchanged. ### Setup [Section titled “Setup”](#setup) 1. **Install packages** * npm ```bash npm install @varlock/cloudflare-integration varlock ``` * pnpm ```bash pnpm add @varlock/cloudflare-integration varlock ``` * bun ```bash bun add @varlock/cloudflare-integration varlock ``` * yarn ```bash yarn add @varlock/cloudflare-integration varlock ``` * vlt ```bash vlt install @varlock/cloudflare-integration varlock ``` 2. **Run `varlock init` to set up your `.env.schema` file** This will guide you through setting up your `.env.schema` file, based on your existing `.env` file(s). Make sure to review it carefully. * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Add the varlock init import to your worker entry point** This initializes varlock’s `ENV` proxy and applies console redaction and response leak detection (unless disabled): src/index.ts ```diff +import '@varlock/cloudflare-integration/init'; +import { ENV } from 'varlock/env'; export default { async fetch(request, env, ctx) { +// both work: + console.log(ENV.MY_VAR); // varlock (recommended) + console.log(env.MY_VAR); // native Cloudflare return new Response('Hello!'); }, }; ``` 4. **Update your package.json scripts to use `varlock-wrangler`** Use `varlock-wrangler` as a drop-in replacement for `wrangler`: package.json ```json { "scripts": { "dev": "varlock-wrangler dev", "deploy": "varlock-wrangler deploy", "types": "varlock-wrangler types", } } ``` If you deploy via [Cloudflare Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) instead of a local/CI script, override the deploy command in your Cloudflare dashboard under **Settings → Build → Deploy command** (see [Workers Builds](#cloudflare-workers-builds-cicd) below). ### How it works [Section titled “How it works”](#how-it-works) * **In dev**: `varlock-wrangler dev` resolves your env and injects it into `wrangler` using `--env-file` with a named pipe (unix/mac) or temp file (windows). It also watches your `.env` files and automatically restarts wrangler when they change, something `wrangler dev` doesn’t do. * **In production**: `varlock-wrangler deploy` (and `varlock-wrangler versions upload`) attaches non-sensitive values as Cloudflare vars (via `--var`) and sensitive values as Cloudflare secrets via `--secrets-file`. The worker reads them at runtime via `import { env } from 'cloudflare:workers'`. *** ## Approach 2: With the Vite plugin [Section titled “Approach 2: With the Vite plugin”](#approach-2-with-the-vite-plugin) If you’re building with Vite, `varlockCloudflareVitePlugin` wraps the [Cloudflare Workers Vite plugin](https://developers.cloudflare.com/workers/vite-plugin/) and adds env var + varlock init injection. No extra init import needed, and both `ENV.MY_VAR` and native `env.MY_VAR` work in dev and production. For SvelteKit on Cloudflare, use the standard [`varlockVitePlugin`](/integrations/sveltekit/#setup-for-cloudflare-workers) instead. It auto-detects the Cloudflare adapter and wires up the SSR entry loader. ### Setup [Section titled “Setup”](#setup-1) 1. **Install packages** * npm ```bash npm install @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` * pnpm ```bash pnpm add @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` * bun ```bash bun add @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` * yarn ```bash yarn add @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` * vlt ```bash vlt install @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` `@cloudflare/vite-plugin` is an (optional) peer dependency. This plugin wraps it, so you need it installed in the same project. SvelteKit users should omit it (see the [SvelteKit callout](#choosing-an-approach) at the top of this page). 2. **Run `varlock init` to set up your `.env.schema` file** * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Update your Vite config** Replace the `cloudflare()` plugin with `varlockCloudflareVitePlugin()`. It is a thin wrapper of the Cloudflare vite plugin and passes through all config: vite.config.ts ```diff import { defineConfig } from 'vite'; -import { cloudflare } from '@cloudflare/vite-plugin'; +import { varlockCloudflareVitePlugin } from '@varlock/cloudflare-integration'; export default defineConfig({ plugins: [ -cloudflare(), +varlockCloudflareVitePlugin(), // other plugins ... ], }); ``` Any options you were passing to `cloudflare()` can be passed directly to `varlockCloudflareVitePlugin()` at the top level. 4. **Deploy with `varlock-wrangler`** Use `varlock-wrangler deploy` instead of `wrangler deploy` in your deploy script: package.json ```json { "scripts": { "dev": "vite dev", "build": "vite build", "deploy": "npm run build && varlock-wrangler deploy" } } ``` If you deploy via [Cloudflare Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) instead of a local/CI script, override the deploy command in your Cloudflare dashboard under **Settings → Build → Deploy command** (see [Workers Builds](#cloudflare-workers-builds-cicd) below). Remove `.dev.vars` If you have a `.dev.vars` file in your project root, **delete it**. Varlock manages env injection automatically. A `.dev.vars` file will conflict and the plugin will throw an error. This also applies to `.dev.vars.` files. ### How it works [Section titled “How it works”](#how-it-works-1) * **In dev**: Resolved vars are automatically injected into miniflare’s bindings. * **In production**: Non-sensitive values are statically replaced at build time by vite. `varlock-wrangler deploy` sets non-sensitive values as Cloudflare vars and sensitive values as secrets. ### Upgrading from `@varlock/vite-integration` [Section titled “Upgrading from @varlock/vite-integration”](#upgrading-from-varlockvite-integration) If you were previously using `varlockVitePlugin()` from `@varlock/vite-integration` with Cloudflare Workers: 1. Install `@varlock/cloudflare-integration` (you can remove `@varlock/vite-integration`, it’s included) 2. In `vite.config.ts`, replace both `varlockVitePlugin()` and `cloudflare()` with `varlockCloudflareVitePlugin()` 3. Replace `wrangler deploy` with `varlock-wrangler deploy` in your deploy script vite.config.ts ```diff -import { varlockVitePlugin } from '@varlock/vite-integration'; -import { cloudflare } from '@cloudflare/vite-plugin'; +import { varlockCloudflareVitePlugin } from '@varlock/cloudflare-integration'; export default defineConfig({ plugins: [ -varlockVitePlugin(), -cloudflare(), +varlockCloudflareVitePlugin(), ], }); ``` Your secrets will no longer be bundled into the JS artifact. They’ll be stored in Cloudflare’s secret management instead. *** ## `varlock-wrangler` CLI [Section titled “varlock-wrangler CLI”](#varlock-wrangler-cli) `varlock-wrangler` is a thin wrapper around `wrangler`. It enhances the commands below; everything else (`tail`, `secret list`, etc.) is passed through unchanged, so you can use `varlock-wrangler` everywhere or just for these commands. ### `varlock-wrangler dev` [Section titled “varlock-wrangler dev”](#varlock-wrangler-dev) Wraps `wrangler dev` with automatic env injection. Resolves your environment variables, injects them into miniflare, and watches your `.env` files to restart wrangler when they change. ### `varlock-wrangler deploy` / `versions upload` [Section titled “varlock-wrangler deploy / versions upload”](#varlock-wrangler-deploy--versions-upload) Resolves your environment variables from `.env.schema` + `.env` files, then uploads non-sensitive values as Cloudflare vars (`--var`), sensitive values as Cloudflare secrets (`--secrets-file`), and includes a `__VARLOCK_ENV` secret containing the full resolved env graph for the varlock runtime. Secure secret handling On Unix, secrets are passed to wrangler commands via a named pipe (FIFO) and never written to disk. On Windows, a temporary file is used and immediately deleted after the deploy completes. Varlock owns your env vars `varlock-wrangler deploy` replaces **all** Cloudflare vars and secrets with the ones defined in your `.env.schema`. Any vars or secrets set manually via the Cloudflare dashboard or `wrangler secret put` that aren’t in your schema will be removed on the next deploy. If you need additional bindings (KV, D1, etc.), configure them in your `wrangler.toml` as usual. Only plain vars/secrets are affected. ### `varlock-wrangler types` [Section titled “varlock-wrangler types”](#varlock-wrangler-types) Generates Cloudflare Worker types (the `Env` interface) including all varlock-managed environment variables, so your `env.MY_VAR` access is properly typed alongside other Cloudflare bindings (KV, D1, etc.). *** ## Type-safety and IntelliSense [Section titled “Type-safety and IntelliSense”](#type-safety-and-intellisense) To enable type-safety and IntelliSense for `ENV`, enable the [`@generateTsTypes` root decorator](/reference/root-decorators/#generatetstypes) in your `.env.schema`. If your schema was created using `varlock init`, it will include this by default. .env.schema ```diff +# @generateTsTypes(path='env.d.ts') # --- # your config items... ``` Not seeing types on `ENV`? If `ENV` stays untyped after the file is generated, TypeScript probably isn’t loading it. Worker templates scaffolded by `npm create cloudflare` typically use an explicit `include` list that won’t pick up a generated `env.d.ts` at the project root. Add it to the `include` array of the tsconfig that covers the code reading `ENV`: tsconfig.json ```diff { "include": [ +"env.d.ts", "worker-configuration.d.ts", "src/**/*.ts" ] } ``` Some framework templates split their TypeScript setup into multiple referenced configs (for example `tsconfig.app.json` and `tsconfig.worker.json`). In that layout, editing the root config has no effect; add `env.d.ts` to whichever config owns the source files where you use `ENV` (or both). This types the `ENV` object. For the native `env` parameter in your fetch handler, use [`varlock-wrangler types`](#varlock-wrangler-types) to generate an `Env` interface that includes your varlock-managed vars alongside your other Cloudflare bindings. ### `process.env` is typed by wrangler, not varlock [Section titled “process.env is typed by wrangler, not varlock”](#processenv-is-typed-by-wrangler-not-varlock) `wrangler types` writes its own `NodeJS.ProcessEnv` augmentation into `worker-configuration.d.ts`, covering every var and secret it knows about. TypeScript merges all declarations of that interface and requires every shared key to be identical across them, so varlock’s augmentation and wrangler’s cannot both apply: a single optional item (`FOO?: string` against wrangler’s `FOO: string`) fails with `TS2320`, as do enums and booleans, which varlock types as literal unions. When varlock sees an existing `NodeJS.ProcessEnv` declaration next to your schema, it defers and skips its own, noting why at the top of the generated file. Nothing is lost if you generate types with [`varlock-wrangler types`](#varlock-wrangler-types): it feeds your schema’s keys to wrangler, so all of them are typed on `process.env` as strings. Use `ENV` where you want the coerced types (enums, booleans, numbers) and the docs from your schema. Setting `processEnv=strict` on [`@generateTsTypes`](/reference/root-decorators/#generatetstypes) opts back in, but only do that if nothing else is declaring `NodeJS.ProcessEnv`: with wrangler’s block present, the two conflict and TypeScript reports `TS2320`. With `skipLibCheck: true` the conflict is silent Most Worker templates enable `skipLibCheck`, which suppresses `TS2320` in `.d.ts` files entirely. The two declarations still clash, and whichever file TypeScript reaches first wins, so a key can silently take wrangler’s literal type from `wrangler.jsonc` instead of your schema’s. Deferring avoids that as well. *** ## Log redaction and leak prevention [Section titled “Log redaction and leak prevention”](#log-redaction-and-leak-prevention) Both integration approaches automatically enable **log redaction** (sensitive values are masked in console output) and **response leak detection** (responses are scanned for accidentally exposed secrets). These are enabled by default and can be disabled in your `.env.schema` using root decorators: .env.schema ```env-spec # @redactLogs=false # @preventLeaks=false ``` For more details, see the [root decorators reference](/reference/root-decorators/). *** ## Managing Multiple Environments [Section titled “Managing Multiple Environments”](#managing-multiple-environments) Varlock can load multiple *environment-specific* `.env` files (e.g., `.env.development`, `.env.preview`, `.env.production`) by using the [`@currentEnv` root decorator](/reference/root-decorators/#currentenv). The simplest approach is to use the built-in [`VARLOCK_ENV`](/reference/builtin-variables/#varlock_env) variable, which auto-detects the deployment environment on most CI platforms, including [Cloudflare Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) (via `WORKERS_CI_BRANCH`), Vercel, Netlify, and others. Locally it resolves to `development`, and during tests to `test`. .env.schema ```env-spec # @currentEnv=$VARLOCK_ENV # --- ``` If you need more control, for example mapping specific branches to specific environments, you can define your own env-selection var instead: .env.schema ```env-spec # @currentEnv=$APP_ENV # --- WORKERS_CI_BRANCH= # @type=enum(development, preview, production, test) APP_ENV=remap($WORKERS_CI_BRANCH, "main", production, /.*/, preview, undefined, development) ``` For more information, see the [environments guide](/guides/environments). *** ## Dynamic+public config [Section titled “Dynamic+public config”](#dynamicpublic-config) On Workers, [`@dynamic`](/reference/item-decorators/#dynamic) usually changes nothing in practice. Worker values are fixed at deploy time (`varlock-wrangler deploy` resolves your env and uploads it), and client assets are typically rebuilt in the same pipeline, so a static public value and a dynamic one change at exactly the same moment. If you build and deploy together, you can ignore this section; there is no downside to leaving `@dynamic` markers in a schema shared with other platforms (worker-side code reads `ENV.KEY` the same either way). Where it does matter is when build and deploy are decoupled: for example, building client assets once and deploying that same build to several environments, where deploy-time resolution gives each environment different values. For that setup, use your framework’s recipe from the [guide](/guides/dynamic-config/#loading-from-a-server-endpoint) (most apps on Workers run a framework like Astro or SvelteKit); on a plain worker, add a route in your fetch handler that returns `getPublicDynamicEnv()`. Either way the endpoint serves whatever the worker was deployed with, not per-request-fresh values. ## Cloudflare Workers Builds (CI/CD) [Section titled “Cloudflare Workers Builds (CI/CD)”](#cloudflare-workers-builds-cicd) If you deploy via [Cloudflare Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), two pieces of setup are required in the dashboard, and neither can be configured from a file in the repo: 1. **Override the Deploy command** Under **Settings → Build → Deploy command**, replace the default `npx wrangler deploy` with: ```sh npx varlock-wrangler deploy ``` Without this override, Cloudflare runs stock `wrangler deploy`, which skips varlock’s env resolution and leaves your worker without its resolved vars/secrets at runtime. 2. **Set any *secret-zero* vars under Build variables** Any env vars that varlock itself needs *during load* (for example a 1Password service account token or a GCP key) must be set under **Settings → Build → Variables and Secrets**. These are made available to the build step, where `varlock-wrangler deploy` resolves your full env graph and uploads the result to the worker runtime as regular vars/secrets. Vars that only your worker needs at runtime (not varlock itself) don’t need to be set here. They’ll be resolved by varlock and set automatically by `varlock-wrangler deploy`. *** ## Deploying from your own CI [Section titled “Deploying from your own CI”](#deploying-from-your-own-ci) Deploying from **your own CI** (GitHub Actions, GitLab CI, a self-hosted runner) instead of [Cloudflare Workers Builds](#cloudflare-workers-builds-cicd) works the same way, and isn’t really Cloudflare-specific. The same *secret-zero* rule applies: any var varlock needs to resolve your secrets (a 1Password service account token, a GCP key, etc.) must be present in the job environment before `varlock-wrangler deploy` runs. .github/workflows/deploy-worker.yml ```yaml jobs: deploy: permissions: id-token: write # required for OIDC-backed plugins contents: read steps: - uses: actions/checkout@v7 - run: npm ci - run: npm run build - run: npx varlock-wrangler deploy env: # Only vars varlock needs to *resolve* secrets, not every worker var OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} ``` For supported providers, varlock can authenticate with **short-lived OIDC tokens** from your CI instead of long-lived keys. See the [OIDC workload identity guide](/guides/oidc/). Run `varlock load` in an earlier step to fail fast on schema errors without exposing values. *** ## Multi-worker and auxiliary Workers [Section titled “Multi-worker and auxiliary Workers”](#multi-worker-and-auxiliary-workers) Cloudflare supports running [multiple Workers in one dev session](https://developers.cloudflare.com/workers/development-testing/multi-workers/): a primary Worker plus **auxiliary Workers** reachable via service bindings. The Vite plugin exposes this through [`auxiliaryWorkers`](https://developers.cloudflare.com/workers/development-testing/multi-workers/#vite-plugin-with-auxiliary-workers) in `vite.config.ts`. ### Local dev and preview [Section titled “Local dev and preview”](#local-dev-and-preview) `varlockCloudflareVitePlugin` supports `auxiliaryWorkers` during `vite dev` and `vite preview`. Pass them through as you would to `cloudflare()`: vite.config.ts ```ts import { defineConfig } from 'vite'; import { varlockCloudflareVitePlugin } from '@varlock/cloudflare-integration'; export default defineConfig({ plugins: [ varlockCloudflareVitePlugin({ auxiliaryWorkers: [ { configPath: './workers/jobs/wrangler.jsonc' }, ], }), ], }); ``` Every Worker in the session (the entry Worker and each auxiliary Worker) gets the same resolved env: the `__VARLOCK_ENV` binding plus a binding per env var, so `ENV` and the native `env` object both work inside each Worker. There is one shared `.env.schema` for the project, so all Workers see all vars. Per-worker filtering is not supported yet. Deploys are still one Worker at a time Varlock resolves and uploads env vars for **one Wrangler config per deploy**. `varlock-wrangler deploy` injects vars/secrets into the worker named by the target `wrangler.toml` / `wrangler.json`. It does not automatically propagate env to auxiliary Workers or sibling configs passed to `wrangler dev -c ./a.json -c ./b.json`. For multi-worker repos today: * Deploy each Worker separately with `varlock-wrangler deploy -c `, or * Share a single `.env.schema` and run deploy once per worker script, or * Use [`varlock run`](/reference/cli/load-and-run/#run) to inject resolved env when invoking a non-wrangler deploy tool (see [non-wrangler deploy tools](#non-wrangler-deploy-tools-alchemysstpulumi) below). If this blocks your setup, join [Discord](https://chat.dmno.dev) and share your wrangler layout and which Workers need which vars. *** ## When things go wrong [Section titled “When things go wrong”](#when-things-go-wrong) ### `Cannot resolve 'node:...'` at worker startup [Section titled “Cannot resolve 'node:...' at worker startup”](#cannot-resolve-node-at-worker-startup) Your worker is missing the Node.js compatibility layer. Add `nodejs_compat` to `compatibility_flags`; see the **`nodejs_compat` caution** near the top of this page. ### `.dev.vars` conflicts with the Vite plugin [Section titled “.dev.vars conflicts with the Vite plugin”](#devvars-conflicts-with-the-vite-plugin) Delete `.dev.vars` and `.dev.vars.` files when using the Vite plugin or `varlock-wrangler dev`. Varlock owns env injection; leftover Wrangler env files cause hard errors. ### `Property 'SOMEVAR' does not exist on type 'TypedEnvSchema'` [Section titled “Property 'SOMEVAR' does not exist on type 'TypedEnvSchema'”](#property-somevar-does-not-exist-on-type-typedenvschema) The generated types file is not being loaded by TypeScript. Make sure the [`@generateTsTypes` root decorator](/reference/root-decorators/#generatetstypes) is enabled in your `.env.schema`, and that the generated file (usually `env.d.ts`) is listed in the `include` array of the tsconfig that covers the code where the error appears. See [Type-safety and IntelliSense](#type-safety-and-intellisense) above. ### Vars or secrets missing after deploy [Section titled “Vars or secrets missing after deploy”](#vars-or-secrets-missing-after-deploy) * Confirm you deploy with `varlock-wrangler deploy`, not stock `wrangler deploy`, especially on [Workers Builds](#cloudflare-workers-builds-cicd) * Check that secret-zero CI vars are set for any plugin-backed items varlock must resolve at deploy time * Remember that `varlock-wrangler deploy` **replaces all** Cloudflare vars and secrets with schema-defined ones. Manually added dashboard secrets not in your schema are removed ### Non-wrangler deploy tools (Alchemy, SST, Pulumi) [Section titled “Non-wrangler deploy tools (Alchemy, SST, Pulumi)”](#non-wrangler-deploy-tools-alchemy-sst-pulumi) If you deploy Workers with **IaC tools other than wrangler** ([Alchemy](https://alchemy.run/), SST, Pulumi, Terraform, etc.), you can still use varlock for **fail-fast validation and secret resolution** via [`varlock run`](/reference/cli/load-and-run/#run), which injects resolved values into `process.env` for the deploy tool to read. What you **cannot** get today without `varlock-wrangler`: the in-worker runtime layer (`@varlock/cloudflare-integration/init`) that provides console redaction and response leak detection. That layer reads a `__VARLOCK_ENV` binding that only `varlock-wrangler` produces. A public API to build `__VARLOCK_ENV` bindings for non-wrangler deploy tools is planned but not available yet. Until then, teams on Alchemy/SST/Pulumi typically rely on varlock for validation + env injection at deploy time and skip the in-worker runtime protections. *** ## Varlock vs Cloudflare’s built-in env management [Section titled “Varlock vs Cloudflare’s built-in env management”](#varlock-vs-cloudflares-built-in-env-management) Cloudflare Workers have built-in support for [vars](https://developers.cloudflare.com/workers/configuration/environment-variables/) and [secrets](https://developers.cloudflare.com/workers/configuration/secrets/), but managing them across environments quickly becomes painful. Here’s what Varlock adds: * **Single source of truth**: Instead of scattering config across the Cloudflare dashboard, `wrangler.toml`, `.dev.vars`, and your code, everything is defined in one `.env.schema` file that works across local dev, preview, and production. * **Validation before deploy**: Cloudflare only has basic required var validation. Varlock catches missing vars, wrong types, and invalid values *before* your worker starts, not when it crashes at runtime. * **Pull secrets from vaults**: Instead of manually running `wrangler secret put` or copy-pasting values in the dashboard, pull secrets directly from [1Password](/plugins/1password/), [AWS Secrets Manager](/plugins/aws-secrets/), [HashiCorp Vault](/plugins/hashicorp-vault/), and [more](/plugins/overview/). * **Log redaction & leak prevention**: Cloudflare doesn’t prevent you from accidentally `console.log`-ing a secret or including one in a response body. Varlock automatically redacts sensitive values in logs and scans outgoing responses for leaked secrets. * **Simpler multi-environment setup**: Wrangler’s `[env.staging]` / `[env.production]` blocks duplicate config and don’t support `.env` file overrides. Varlock uses familiar `.env.production` / `.env.preview` files with a single schema, and auto-detects the current environment in CI. * **Local dev that matches production**: `.dev.vars` is completely disconnected from your production secrets. With Varlock, local dev resolves from the same schema and same secret sources, so there’s no drift between environments. * **AI-safe**: Your `.env.schema` gives AI coding tools full context on your config (names, types, descriptions) without ever exposing secret values. # C# > Use varlock with C# via a generated, typed env class To use varlock with C#, [install the standalone binary](/getting-started/installation/#as-a-standalone-binary) and run your app under [`varlock run`](/reference/cli/load-and-run/#run). It loads and validates your env, then injects it into the process. Add [`@generateCsharpEnv`](/reference/root-decorators/#generatecsharpenv) to your schema to generate a small, self-contained class: a typed `Env` with init-only properties, a static `Load()` that parses the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob, and a `SensitiveKeys` set. .env.schema ```env-spec # @generateCsharpEnv(path=Env.cs) ``` The file is regenerated automatically on [`varlock load`](/reference/cli/load-and-run/#load) and [`varlock run`](/reference/cli/load-and-run/#run), or explicitly with [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen). Requires **.NET 6+** (uses `System.Text.Json` from the BCL; no extra NuGet package). Optional `namespace=` and `class=` set the C# namespace and class name (default: no namespace, class `Env`). Property names are PascalCase (`DB_HOST` → `DbHost`). ## Reading values [Section titled “Reading values”](#reading-values) Call `Load()` once, then read properties off the object. Required fields are the plain type, optional ones are nullable (`long?`, `bool?`, `string?`): ```csharp var e = Env.Load(); // call once, hold or pass `e` around var host = e.DbHost; // string (required field) if (e.DbPort is long port) // long? (optional field) { Console.WriteLine($"{host}:{port}"); } ``` ```bash varlock run -- dotnet run ``` `Load()` throws a clear error if `__VARLOCK_ENV` is missing (e.g. you forgot `varlock run`) or if a required key is absent. ## Sharing one instance [Section titled “Sharing one instance”](#sharing-one-instance) Every `Load()` call re-parses the blob. To avoid reloading, register the loaded instance in DI or expose it from a static holder: ```csharp // Program.cs / startup builder.Services.AddSingleton(Env.Load()); // elsewhere, inject Env public class MyService(Env env) { public void Run() => Console.WriteLine(env.DbPort); } ``` No DI? A tiny static holder works: ```csharp public static class Config { public static Env Values { get; } = Env.Load(); } // elsewhere: Config.Values.DbPort (long?) ``` Note Every key is also injected as a plain environment variable, so you can read any of them with `Environment.GetEnvironmentVariable` (raw strings) if you don’t need the typed module. Keys whose names aren’t valid identifiers (they contain `.` or `-`) can’t be properties, so read those from the env var directly. See [Other languages](/integrations/other-languages/) for the shared details on the injected blob, raw env vars, and encryption. # direnv > Load validated environment variables into your shell with direnv and varlock [direnv](https://direnv.net/) is a shell extension that automatically loads and unloads environment variables when you enter and leave a directory. By combining it with varlock, you can get validated, schema-driven environment variables loaded directly into your shell session. Consider a deeper integration For projects where you control the codebase, using one of varlock’s [framework integrations](/integrations/overview/) or the [JavaScript / Node.js integration](/integrations/javascript/) is usually a better fit than direnv. Those integrations wire validation, type safety, and leak protection directly into your build and runtime, rather than relying on shell-level injection. You can also use [`varlock run`](/reference/cli/load-and-run/#run) to inject env vars into any process without needing direnv. direnv is most useful when you need env vars available in your shell session itself, or when working with tools that cannot be launched via `varlock run`. ## How it works [Section titled “How it works”](#how-it-works) direnv works by executing a `.envrc` file in your project directory and capturing any exported variables into the current shell. Varlock’s `--format shell` flag outputs your resolved env vars as `export KEY=VALUE` lines that direnv can capture via `eval`. .envrc ```bash eval "$(varlock load --format shell)" ``` When you `cd` into your project, direnv runs this command and exports all your validated environment variables into the shell. ## Setup [Section titled “Setup”](#setup) 1. **Install direnv** Follow the [official direnv installation guide](https://direnv.net/docs/installation.html) for your platform and shell, then hook it into your shell profile. For example, for bash: ```bash echo 'eval "$(direnv hook bash)"' >> ~/.bashrc ``` 2. **Create a `.envrc` file** in your project root .envrc ```bash watch_file .env .env.* eval "$(varlock load --format shell)" ``` The `watch_file` line tells direnv to re-evaluate whenever any of your `.env` files change. Note that **new files added after the initial load will not be watched until you run `direnv reload`**, but this is rarely a concern since adding new env files is uncommon. Imported files outside the project directory If your `.env` files [import](/guides/import/) other files from outside the current directory, those external files will **not** be watched automatically. You would need to add additional `watch_file` entries for them, or run `direnv reload` manually after changing them. 3. **Allow the `.envrc` file** ```bash direnv allow ``` ## How it looks [Section titled “How it looks”](#how-it-looks) Once set up, when you `cd` into your project directory, direnv automatically loads your varlock-validated environment: ```bash $ cd my-project direnv: loading .envrc direnv: export +API_URL +DB_PASS +PORT ``` And when you leave the directory, those variables are automatically unloaded. ## Skip undefined values [Section titled “Skip undefined values”](#skip-undefined-values) By default, varlock outputs an empty assignment for undefined optional variables (e.g. `OPTIONAL_VAR=""`). If you prefer to skip undefined values entirely, use the `--compact` flag: .envrc ```bash watch_file .env .env.* eval "$(varlock load --format shell --compact)" ``` ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) direnv strict mode Some shells or direnv configurations run `.envrc` files in strict mode (`set -euo pipefail`). If `varlock load` exits with a non-zero code (e.g. due to a validation error), direnv will halt and show an error. This is intentional: it prevents your shell from loading a broken environment. Loading errors If your environment fails validation, run `varlock load` directly in your terminal to see the full colorized output and error details before direnv loads it. # Docker > Using varlock with Docker containers for CI validation, runtime injection, multi-stage builds, and monorepo workarounds Varlock works well in containerized workflows, but **how** you use it depends on what you need at each stage of your pipeline. This guide covers the official [GHCR image](https://github.com/dmno-dev/varlock/pkgs/container/varlock), patterns for installing varlock yourself, and the pitfalls to avoid when secrets meet Docker layers. ## Choosing an approach [Section titled “Choosing an approach”](#choosing-an-approach) Before adding varlock to a Dockerfile, decide which role it plays: | Approach | When to use | varlock in production image? | | ---------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | **CI-only validation** | Validate schema and config in pipelines; runtime env comes from the platform | No. Run `varlock load` in CI only | | **Runtime injection** | Long-running containers (APIs, workers) that resolve secrets at boot via [plugins](/guides/plugins/) | Yes. Use `varlock run` as the entrypoint | | **Build-time** | SSR apps where a [framework integration](/integrations/overview/) injects resolved env into build output | Sometimes. Only in a **builder** stage, not the final runtime image | * CI-only validation Use varlock in your pipeline to catch schema errors before deploy. Your production container does not need the CLI. The hosting platform (or orchestrator) injects env vars, and varlock has already validated the config graph in CI. .github/workflows/ci.yml ```yaml - name: Validate environment schema run: | docker run --rm \ -v ${{ github.workspace }}:/work \ -w /work \ -e PWD=/work \ ghcr.io/dmno-dev/varlock:1.0.2 load ``` See also the [GitHub Action](/integrations/github-action/) for a native Actions integration. * Runtime injection Use when your container must resolve secrets at boot, for example via a [1Password service account token](/plugins/1password/), AWS IAM, or [OIDC workload identity](/guides/oidc/). The final image runs your app through `varlock run`: Dockerfile (runtime stage excerpt) ```dockerfile ENTRYPOINT ["varlock", "run", "--"] CMD ["node", "dist/server.js"] ``` Secrets are fetched when the container starts, not when the image is built. `varlock run` stays resident while your app runs (signal forwarding), so the container pays for two processes. That is usually fine once the CLI is a few tens of MiB. If you are on a very tight memory limit and only need env at boot, prefer resolve-then-`exec` so a single Node process remains: `eval "$(varlock load --format shell --compact)" && exec node dist/server.js`. * Build-time Use when a [framework integration](/integrations/overview/) (Next.js, Vite SSR, Cloudflare Workers, etc.) resolves and injects env during `build`. Varlock belongs in the **builder** stage; the runtime image receives env via the platform or an encrypted blob, not via the CLI. For serverless SSR, prefer [encrypted deployments](/guides/encrypted-deployments/) over baking resolved values into image layers. ### When not to put varlock in your production image [Section titled “When not to put varlock in your production image”](#when-not-to-put-varlock-in-your-production-image) Don’t ship the CLI unless you need it at runtime Skip varlock in the final production image when: * **Serverless / edge**: integrations inject env at build or deploy time; the runtime has no filesystem or CLI access anyway * **Platform-managed env**: your host already injects env vars and you only want schema validation in CI * **Build-time-only resolution**: secrets are resolved during `docker build` and would end up in a layer (see [best practices](#best-practices) below) Adding varlock to every production image increases size and attack surface without benefit if secrets are already handled elsewhere. ## Official Docker image [Section titled “Official Docker image”](#official-docker-image) The image is published to GitHub Container Registry and built from the official release binaries on Alpine Linux: * **Image:** `ghcr.io/dmno-dev/varlock` * **Binary path:** `/usr/local/bin/varlock` * **Default workdir:** `/work` * **Entrypoint:** `varlock` (pass subcommands as arguments) ```bash # Pull a pinned version (recommended) docker pull ghcr.io/dmno-dev/varlock:1.6.0 # Run help docker run --rm ghcr.io/dmno-dev/varlock:1.6.0 --help # Validate schema in the current directory docker run --rm \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ ghcr.io/dmno-dev/varlock:1.6.0 load ``` ### Available tags [Section titled “Available tags”](#available-tags) * `ghcr.io/dmno-dev/varlock:latest`: latest stable release * `ghcr.io/dmno-dev/varlock:1.6.0`: specific version (replace with the version you pin in CI/production) Pin image tags Use an explicit version tag in CI and production Dockerfiles. `latest` is convenient for local experiments but can change without notice. ## Best practices [Section titled “Best practices”](#best-practices) ### Never bake resolved secrets into image layers [Section titled “Never bake resolved secrets into image layers”](#never-bake-resolved-secrets-into-image-layers) Anything written during `RUN` is persisted in the image history. Avoid: Anti-pattern: secrets in layers ```dockerfile # BAD: resolved values become part of the image RUN varlock load > /app/.env.production RUN varlock run -- node scripts/build.js # if build embeds secrets in output ARG OP_SERVICE_ACCOUNT_TOKEN # build args are also stored in metadata ``` Instead: * Pass **secret-zero** credentials at **runtime** via environment variables or orchestrator secrets * Use **multi-stage builds** so builder-stage artifacts containing secrets are discarded * For SSR, use [@encryptInjectedEnv](/guides/encrypted-deployments/) so build output holds ciphertext, not plaintext ### Use the `/work` mount and `PWD=/work` pattern [Section titled “Use the /work mount and PWD=/work pattern”](#use-the-work-mount-and-pwdwork-pattern) The official image sets `WORKDIR /work`. When bind-mounting your project, mirror that layout and set `PWD` explicitly. Varlock uses the process working directory to resolve relative `@import()` paths and `.env` files: ```bash docker run --rm \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ ghcr.io/dmno-dev/varlock:1.6.0 load ``` If your app lives in a subdirectory, mount that directory to `/work` (or adjust `-w` and `PWD` together). ### Pin versions [Section titled “Pin versions”](#pin-versions) Pin the GHCR image tag **and** any plugin versions in `.env.schema` when using the standalone binary (see [plugins in containers](#plugins-in-containers)). ## Multi-stage builds [Section titled “Multi-stage builds”](#multi-stage-builds) Copy the varlock binary from the official image into your application image. No need to install Node or curl in the final stage: Dockerfile ```dockerfile FROM ghcr.io/dmno-dev/varlock:1.6.0 AS varlock FROM node:22-alpine AS builder COPY --from=varlock /usr/local/bin/varlock /usr/local/bin/varlock WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile COPY . . # Validate schema during build (no secrets written to disk) RUN varlock load RUN pnpm build FROM node:22-alpine AS runner WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/.env.schema ./.env.schema # Runtime: resolve secrets when the container starts COPY --from=varlock /usr/local/bin/varlock /usr/local/bin/varlock ENTRYPOINT ["varlock", "run", "--"] CMD ["node", "dist/server.js"] ``` Alpine runtime deps The official image includes `libstdc++` and `ca-certificates` required by the Bun-compiled binary. If you copy the binary into a minimal Alpine-based app image, add the same packages: ```dockerfile RUN apk add --no-cache ca-certificates libstdc++ ``` You can also use `COPY --from=ghcr.io/dmno-dev/varlock:1.6.0` directly without a named stage: ```dockerfile COPY --from=ghcr.io/dmno-dev/varlock:1.6.0 /usr/local/bin/varlock /usr/local/bin/varlock ``` ## Runtime: entrypoint and Docker Compose [Section titled “Runtime: entrypoint and Docker Compose”](#runtime-entrypoint-and-docker-compose) For containers that resolve secrets at boot, set varlock as the entrypoint and pass your process after `--`: Dockerfile ```dockerfile ENTRYPOINT ["varlock", "run", "--"] CMD ["node", "dist/server.js"] ``` Inject the environment flag and secret-zero credentials via your orchestrator, not via `docker build`: ```bash docker run --rm \ -e APP_ENV=production \ -e OP_SERVICE_ACCOUNT_TOKEN \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ my-app:latest ``` ### Docker Compose [Section titled “Docker Compose”](#docker-compose) docker-compose.yml ```yaml services: app: build: . environment: APP_ENV: production OP_SERVICE_ACCOUNT_TOKEN: ${OP_SERVICE_ACCOUNT_TOKEN} entrypoint: ["varlock", "run", "--"] command: ["node", "dist/server.js"] volumes: - .:/work working_dir: /work ``` Compose secrets Use [Docker Compose secrets](https://docs.docker.com/compose/how-tos/use-secrets/) or an `.env` file excluded from git for tokens like `OP_SERVICE_ACCOUNT_TOKEN`. Never commit secret-zero values into `docker-compose.yml`. ## Monorepos and partial build context [Section titled “Monorepos and partial build context”](#monorepos-and-partial-build-context) In a monorepo, `.env.schema` often uses [`@import()`](/guides/import/) to pull shared config from other packages. For Docker builds, prefer importing **specific files** (optionally with `pick`) over whole directories. A [directory import](/guides/import/#directory) also pulls sibling `.env.local` / `.env.[currentEnv]` files, which you generally don’t want baked into an image and may not have copied into the build context: apps/web/.env.schema ```env-spec # @import(../../shared/.env.common) # @import(../api/.env.schema, pick=[DATABASE_URL, REDIS_URL]) ``` When Docker’s build context is limited to `apps/web/`, those imported files are **not** in the context, so `varlock load` fails with missing file errors. ### `varlock flatten` [Section titled “varlock flatten”](#varlock-flatten) [`varlock flatten`](/reference/cli/project/#flatten) collapses the whole import graph into one self-contained directory (default `.env-flat/`) and rewrites the `@import` paths, so the package’s env files no longer reach outside the package: ```bash cd apps/web && varlock flatten ``` The output directory contains rewritten copies of the package’s own env files at its root, plus everything they import mirrored under `.env-imports/`. It is position-independent: copy its **contents** over your app directory (replacing the originals, whose imports still point outside) and `varlock load` / `varlock run` work with nothing else present. The most common flow runs `flatten` in the **builder stage** of a multi-stage build, while the full monorepo is still around, then overlays the output in the final stage: Dockerfile: multi-stage with flatten ```dockerfile # builder stage has the full monorepo FROM node:22-slim AS builder WORKDIR /repo COPY . . RUN npm install RUN cd apps/web && npx varlock flatten RUN cd apps/web && npm run build FROM node:22-slim COPY --from=ghcr.io/dmno-dev/varlock:1.6.0 /usr/local/bin/varlock /usr/local/bin/varlock WORKDIR /app COPY --from=builder /repo/apps/web /app # overlay the flattened env files over the originals COPY --from=builder /repo/apps/web/.env-flat/ /app/ ENTRYPOINT ["varlock", "run", "--"] CMD ["node", "dist/server.js"] ``` You can also run `flatten` on the host or in CI before `docker build`, when the build context is limited to the package directory. It never resolves values or executes plugins, so it needs no secrets wherever it runs. Add the output directory to `.gitignore` (and make sure `.dockerignore` does not exclude it). A few behaviors worth knowing (see the [command reference](/reference/cli/project/#flatten) for the full list): * `.env.local` / `.env.[env].local` files are **skipped by default**, so machine-local secrets don’t end up in image layers. Use `--include-local` if you really want them. * Conditionally-enabled imports (`enabled=`) are copied too, with their conditions preserved, since production may enable imports your build machine doesn’t. * `@plugin()` declarations in copied files get pinned to the installed version, so the standalone binary can [auto-install them](/guides/plugins/) in the container without the sibling package’s `node_modules`. ### Alternative: copy imported files explicitly [Section titled “Alternative: copy imported files explicitly”](#alternative-copy-imported-files-explicitly) If you prefer not to add a build step, copy every file the import graph needs: Dockerfile: monorepo env files ```dockerfile # Build from repo root: docker build -f apps/web/Dockerfile . ARG SERVICE_DIR=web COPY .env.schema ./ COPY shared/.env.common ./shared/ COPY apps/${SERVICE_DIR}/.env.schema ./apps/${SERVICE_DIR}/ COPY apps/${SERVICE_DIR}/.env.production ./apps/${SERVICE_DIR}/ WORKDIR /app/apps/${SERVICE_DIR} RUN varlock load ``` Alternatively, set the build context to the **monorepo root** and use `docker build -f apps/web/Dockerfile .` so all import paths resolve naturally, at the cost of a larger context and slower builds. ## Plugins in containers [Section titled “Plugins in containers”](#plugins-in-containers) Containers commonly authenticate to secret providers in two ways: 1. **Service account tokens**: pass a long-lived token (e.g. `OP_SERVICE_ACCOUNT_TOKEN`) as a runtime environment variable 2. **OIDC workload identity**: exchange a short-lived platform token for temporary credentials; no secret-zero in the image For OIDC on Fly.io, GCP Cloud Run, GitHub Actions, and other platforms, see the [OIDC workload identity guide](/guides/oidc/). When using the **standalone binary** (copied from GHCR or a release tarball), plugins must specify a **fixed version** in `@plugin()`. Semver ranges require a local `node_modules` install: .env.schema ```env-spec # @plugin(@varlock/1password-plugin@1.2.3) # @initOp(token=$OP_SERVICE_ACCOUNT_TOKEN) # --- OP_SERVICE_ACCOUNT_TOKEN= # @sensitive DATABASE_URL=op(op://prod/db/connection-string) # @sensitive ``` In **Node-based images** where varlock is an npm dependency, install plugins in `package.json` and reference them without a version (or with a range for validation only). See the [Plugins guide](/guides/plugins/#installation) for details. ### Distroless and shell-less base images [Section titled “Distroless and shell-less base images”](#distroless-and-shell-less-base-images) Auto-install works in minimal base images (Google distroless, `scratch`-based, etc.) that have no shell and no `tar` binary. The standalone binary downloads and extracts pinned plugin versions itself, so official `@varlock/*` plugins resolve at runtime with no extra setup. Runtime auto-install still has two limits: **third-party (non-`@varlock`) plugins** need a one-time trust confirmation (an interactive terminal), and any auto-install needs **network access to npm** at container start. For a fully self-contained image that has neither, vendor the plugins at build time. #### Vendor plugins with `flatten --vendor-plugins` (recommended) [Section titled “Vendor plugins with flatten --vendor-plugins (recommended)”](#vendor-plugins-with-flatten---vendor-plugins-recommended) If you already use [`varlock flatten`](/reference/cli/project/#flatten) for a monorepo build, add `--vendor-plugins`. It copies each `@plugin()` package into the output (`.env-plugins/`) and rewrites the declarations to local paths. Plugins already in your `node_modules` are copied directly (no network); anything not installed is downloaded. The flattened directory then resolves with **no runtime npm fetch, no shell, and no trust prompt**: Dockerfile (excerpt) ```dockerfile # builder stage (has the full monorepo + network) RUN cd packages/api && varlock flatten --vendor-plugins # final distroless stage COPY --from=builder /repo/packages/api/.env-flat/ /app/ ``` This is the most complete option: it covers third-party plugins and offline runtimes, which runtime auto-install cannot. #### Or pre-cache with `install-plugin` [Section titled “Or pre-cache with install-plugin”](#or-pre-cache-with-install-plugin) Without flatten, pre-cache plugins in a stage that has a shell and copy the cache across. Pin the cache location with `XDG_CONFIG_HOME` so both stages agree: Dockerfile (excerpt) ```dockerfile ENV XDG_CONFIG_HOME=/varlock-config # builder stage: pre-cache the plugin into $XDG_CONFIG_HOME/varlock/plugins-cache RUN varlock install-plugin my-plugin@1.2.3 # final stage: reuse the populated cache (set the same XDG_CONFIG_HOME here too) COPY --from=builder /varlock-config /varlock-config ``` Plugin cache in CI/containers Ephemeral containers often run in memory cache mode. If your job invokes varlock multiple times and plugin fetches are slow, set [`_VARLOCK_CACHE_KEY`](/guides/caching/#disk-caching-in-ci-with-_varlock_cache_key) as a CI secret. See the [Caching guide](/guides/caching/) for details. ## Without the official image [Section titled “Without the official image”](#without-the-official-image) If you cannot pull from GHCR (air-gapped registry policy, custom base images, etc.), install varlock by another method: * npm (Node image) Best when your image already uses Node 22+ and varlock is a project dependency: Dockerfile ```dockerfile FROM node:22-alpine AS builder WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile COPY . . RUN pnpm exec varlock load RUN pnpm build ``` Install `varlock` and any plugins in `package.json`. The npm package includes the CLI. * install.sh Download a release binary during build (similar to local [installation](/getting-started/installation/#as-a-standalone-binary)): Dockerfile ```dockerfile FROM alpine:3.19 RUN apk add --no-cache curl ca-certificates libstdc++ \ && curl -sSfL https://varlock.dev/install.sh | sh -s -- --version=1.6.0 --dir=/usr/local/bin WORKDIR /work ENTRYPOINT ["/usr/local/bin/varlock"] ``` Pass `--version=x.y.z` for reproducible builds. For multi-arch images, prefer the release tarball tab, since `install.sh` picks the host architecture at install time. * Release tarball Matches how the [official Dockerfile](https://github.com/dmno-dev/varlock/blob/main/Dockerfile) is built. Download a versioned binary from GitHub releases: Dockerfile ```dockerfile FROM alpine:3.19 AS varlock-builder ARG VARLOCK_VERSION=1.6.0 ARG TARGETARCH RUN apk add --no-cache curl tar \ && ARCH="$([ "$TARGETARCH" = "arm64" ] && echo linux-musl-arm64 || echo linux-musl-x64)" \ && curl -L -o varlock.tar.gz \ "https://github.com/dmno-dev/varlock/releases/download/varlock@${VARLOCK_VERSION}/varlock-${ARCH}.tar.gz" \ && tar -xzf varlock.tar.gz \ && chmod +x varlock FROM alpine:3.19 RUN apk add --no-cache ca-certificates libstdc++ COPY --from=varlock-builder /varlock /usr/local/bin/varlock WORKDIR /work ENTRYPOINT ["/usr/local/bin/varlock"] ``` Build with Buildx for multi-platform support (`TARGETARCH` is set automatically). ## Building the image locally [Section titled “Building the image locally”](#building-the-image-locally) The repository root [Dockerfile](https://github.com/dmno-dev/varlock/blob/main/Dockerfile) builds the GHCR image. To build locally: ```bash # Pin a specific varlock version docker build --build-arg VARLOCK_VERSION=1.6.0 -t varlock:local . # Build using the latest GitHub release docker build --build-arg VARLOCK_VERSION=latest -t varlock:local . ``` ## When things go wrong [Section titled “When things go wrong”](#when-things-go-wrong) ### Permission denied on mounted volumes [Section titled “Permission denied on mounted volumes”](#permission-denied-on-mounted-volumes) Bind mounts inherit host ownership. If varlock cannot read `.env.schema` or write cache files: ```bash docker run --rm \ -u "$(id -u):$(id -g)" \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ ghcr.io/dmno-dev/varlock:1.6.0 load ``` Or adjust host file permissions so the container user can read your env files. ### Network / plugin connectivity [Section titled “Network / plugin connectivity”](#network--plugin-connectivity) Plugins that call external APIs (1Password, AWS, Azure, Vault, etc.) need outbound network access from the container. If fetches fail with connection errors: * Confirm the container has DNS and egress to the provider’s endpoints * For local CLI tools (e.g. 1Password desktop app), use `--network host` only in local dev, not in production * Check that secret-zero tokens are passed at **runtime**, not only at build time ```bash # Local dev only: host network for desktop CLI integrations docker run --rm --network host \ -v "$(pwd):/work" \ -w /work \ -e PWD=/work \ ghcr.io/dmno-dev/varlock:1.6.0 load ``` For cloud deployments, prefer [service account tokens](/guides/plugins/) or [OIDC](/guides/oidc/) over desktop CLI integrations. ### Missing `@import` files [Section titled “Missing @import files”](#missing-import-files) See [Monorepos and partial build context](#monorepos-and-partial-build-context). The error usually lists a path outside your Docker build context. Run [`varlock flatten`](/reference/cli/project/#flatten) while the full monorepo is available, add explicit `COPY` instructions, or widen the context to the monorepo root. ### Wrong environment loaded [Section titled “Wrong environment loaded”](#wrong-environment-loaded) If `.env.production` is not loading, verify the environment flag is set in the container environment: ```bash docker run --rm -e APP_ENV=production -e PWD=/work -w /work ... ``` See the [Environments guide](/guides/environments/) for how `@currentEnv` selects files. # Expo / React Native > How to integrate varlock with Expo and React Native for secure, type-safe environment management [![](https://img.shields.io/npm/v/@varlock/expo-integration?label=%40varlock%2Fexpo-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/expo-integration) Expo and [React Native CLI](https://github.com/react-native-community/cli) projects both use the [Metro bundler](https://metrobundler.dev/), which has its own approach to environment variables. Varlock integrates via a **Babel plugin** that replaces `ENV.xxx` references with their resolved values at compile time, and a **Metro config wrapper** that initializes the `ENV` proxy at runtime. Both stacks use the same [`@varlock/expo-integration`](https://npmx.dev/package/@varlock/expo-integration) package. Only the Babel preset and Metro base config differ between Expo and React Native CLI projects. This integration does a few things: * Loads and validates your `.env` files using varlock at bundle time * Replaces `ENV.xxx` references to non-sensitive config items with their literal values at compile time * Sensitive values are **never** inlined into your bundle. In Expo projects they are accessible at runtime only in [Expo Router API routes](https://docs.expo.dev/router/reference/api-routes/) (`+api` files) * Patches the global console to redact sensitive values from logs No CLI command wrapping is needed. Env loads when Metro/Babel start. Run `expo start`, `npx react-native start`, or your usual build commands as normal. ## Setup [Section titled “Setup”](#setup) * Expo Requirements * Node.js v22 or higher * Expo SDK v50 or higher * React Native CLI Requirements * Node.js v22 or higher * React Native v0.72 or higher (ships `@react-native/metro-config`) 1. **Install varlock and the integration package** * npm ```bash npm install @varlock/expo-integration varlock ``` * pnpm ```bash pnpm add @varlock/expo-integration varlock ``` * bun ```bash bun add @varlock/expo-integration varlock ``` * yarn ```bash yarn add @varlock/expo-integration varlock ``` * vlt ```bash vlt install @varlock/expo-integration varlock ``` 2. **Run `varlock init` to set up your `.env.schema` file** This will guide you through setting up your `.env.schema` file, based on your existing `.env` file(s). Make sure to review it carefully. * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Add the Babel plugin to your `babel.config.js`** * Expo babel.config.js ```diff module.exports = { presets: ['babel-preset-expo'], + plugins: [ +require('@varlock/expo-integration/babel-plugin'), + ], }; ``` * React Native CLI babel.config.js ```diff module.exports = { presets: ['module:@react-native/babel-preset'], + plugins: [ +require('@varlock/expo-integration/babel-plugin'), + ], }; ``` 4. **Wrap your Metro config** Wrap your Metro config with `withVarlockMetroConfig`. This automatically configures Metro to resolve varlock’s subpath exports (e.g. `varlock/env`) and initializes the `ENV` proxy in the main Metro process. * Expo metro.config.js ```diff const { getDefaultConfig } = require('expo/metro-config'); +const { withVarlockMetroConfig } = require('@varlock/expo-integration/metro-config'); const config = getDefaultConfig(__dirname); -module.exports = config; +module.exports = withVarlockMetroConfig(config); ``` * React Native CLI metro.config.js ```diff const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const { withVarlockMetroConfig } = require('@varlock/expo-integration/metro-config'); const config = mergeConfig(getDefaultConfig(__dirname), {}); -module.exports = config; +module.exports = withVarlockMetroConfig(config); ``` What does this do? * Installs a custom resolver so that `import { ENV } from 'varlock/env'` works (Metro doesn’t support `package.json` `"exports"` subpaths by default). * Initializes the varlock `ENV` proxy in the main Metro process. Metro forks worker processes for Babel transforms; the wrapper ensures the environment is initialized in the main process (needed for Expo Router `+api` server routes and dev-time console redaction). *** ## Accessing environment variables [Section titled “Accessing environment variables”](#accessing-environment-variables) Rather than using `process.env.SOMEVAR` directly, use varlock’s `ENV` object for better type-safety: example.ts ```ts import { ENV } from 'varlock/env'; // Non-sensitive values are inlined at bundle time: const apiUrl = ENV.API_URL; // ✨ recommended, replaced at compile time // process.env still works, but loses type-safety and compile-time replacement: const legacyUrl = process.env.API_URL; // 🆗 still works ``` #### Why use `ENV` instead of `process.env`? [Section titled “Why use ENV instead of process.env?”](#why-use-env-instead-of-processenv) * Non-string values (e.g., number, boolean) are properly typed and coerced * Non-sensitive items are replaced with their literal values at bundle time (smaller bundle, no runtime lookup) * Sensitive items are never embedded in the bundle * Better error messages for invalid or unavailable keys ### How compile-time replacement works [Section titled “How compile-time replacement works”](#how-compile-time-replacement-works) When Metro compiles your project, the Babel plugin traverses the AST and replaces `ENV.xxx` member expressions: your-code.ts (before bundling) ```ts import { ENV } from 'varlock/env'; const url = ENV.API_URL; const port = ENV.PORT; const debug = ENV.DEBUG; ``` compiled output (after bundling) ```ts const url = "https://api.example.com"; const port = 3000; const debug = false; ``` Sensitive items (marked with `@sensitive` in your `.env.schema`) are **never** replaced. They remain as `ENV.xxx` references. In Expo Router `+api` server routes they resolve at runtime via the proxy; in all other code they throw at runtime. ### Type-safety and IntelliSense [Section titled “Type-safety and IntelliSense”](#type-safety-and-intellisense) To enable type-safety and IntelliSense for your env vars, enable the [`@generateTsTypes` root decorator](/reference/root-decorators/#generatetstypes) in your `.env.schema`. Note that if your schema was created using `varlock init`, it will include this by default. .env.schema ```diff +# @generateTsTypes(path='env.d.ts') # --- # your config items... ``` TypeScript config If you find you are not getting type completion on `ENV`, you may need to add your generated type files (usually `env.d.ts`) to your `tsconfig.json`’s `include` array. ## Managing multiple environments [Section titled “Managing multiple environments”](#managing-multiple-environments) Varlock can load multiple *environment-specific* `.env` files (e.g., `.env.development`, `.env.preview`, `.env.production`) by using the [`@currentEnv` root decorator](/reference/root-decorators/#currentenv). Usually this env var will be defaulted to something like `development` in your `.env.schema` file, and you can override it when running commands. * Expo For Expo projects, set it in your `package.json` scripts or via [EAS environment variables](https://docs.expo.dev/eas/environment-variables/): package.json ```json { "scripts": { "start": "expo start", "build:staging": "APP_ENV=staging eas build", "build:production": "APP_ENV=production eas build", } } ``` * React Native CLI For React Native CLI projects, set it in your `package.json` scripts: package.json ```json { "scripts": { "start": "react-native start", "start:staging": "APP_ENV=staging react-native start", "android:staging": "APP_ENV=staging react-native run-android", "ios:staging": "APP_ENV=staging react-native run-ios", } } ``` See the [environments guide](/guides/environments) for more information. ## Managing sensitive config values [Section titled “Managing sensitive config values”](#managing-sensitive-config-values) Sensitive values (marked with `@sensitive`) are **never** statically inlined into your bundle, regardless of any prefix conventions. You control this with the [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive) root decorator and the [`@sensitive`](/reference/item-decorators/#sensitive) item decorator. See the [secrets guide](/guides/secrets) for more information. Set a default and explicitly mark items: .env.schema ```diff +# @defaultSensitive=false # --- NON_SECRET_FOO= # non-sensitive - will be inlined into bundle # @sensitive SECRET_KEY= # sensitive - will NEVER be inlined, runtime only ``` Bundle security Non-sensitive items are inlined into your JavaScript bundle. Anyone who extracts your app’s bundle can read these values. **Never** mark credentials, tokens, or other secrets as non-sensitive. ### Sensitive values in server routes (Expo only) [Section titled “Sensitive values in server routes (Expo only)”](#sensitive-values-in-server-routes-expo-only) Expo Router only [Expo Router API routes](https://docs.expo.dev/router/reference/api-routes/) (`+api` files) are an **Expo-only** feature. React Native CLI apps have no server-side JS runtime, so skip to the next section. If you use Expo Router API routes (`+api` files), sensitive values **are** accessible at runtime via the `ENV` proxy. These files run server-side in the Metro process where `withVarlockMetroConfig` has initialized the environment. app/secret+api.ts ```ts import { ENV } from 'varlock/env'; export function GET() { // ✅ Sensitive values work in +api server routes const key = ENV.SECRET_KEY; return Response.json({ authorized: !!key }); } ``` ### Sensitive values in native code [Section titled “Sensitive values in native code”](#sensitive-values-in-native-code) In native app code (anything that isn’t an Expo Router `+api` server route), sensitive values are **not available**. React Native apps run entirely on the device, so there is no server to keep secrets safe. Accessing a sensitive value in native code will **throw at runtime**. The Babel plugin also emits a **build-time warning** when it detects a sensitive `ENV.xxx` reference in a non-server file, helping you catch these issues early. This applies to **all** React Native code, including React Native CLI projects where every screen and component is client-side. src/screens/Home.tsx ```ts import { ENV } from 'varlock/env'; const url = ENV.API_URL; // ✅ non-sensitive, inlined at build time const key = ENV.SECRET_KEY; // ❌ throws at runtime, build-time warning ``` Expo pages are universal Unlike Next.js Server Components, Expo Router page components run on **both** the server (SSR) and the client (hydration). This means you cannot safely access sensitive values in page components. Use `+api` server routes instead. *** ## Static vs dynamic values [Section titled “Static vs dynamic values”](#static-vs-dynamic-values) The babel plugin inlines **static** values into your app code at build time. By default that means every non-sensitive value; mark a public value [`@dynamic`](/reference/item-decorators/#dynamic) to opt it out of inlining. For the shared model and tradeoffs, see the [Static vs Dynamic Config guide](/guides/dynamic-config/). Values that are not inlined (sensitive values, and anything `@dynamic`) are only directly accessible in **server routes** (`+api` files). Referencing one from native code logs a build-time warning, and reading it at runtime in native will fail. If native code needs a runtime-fresh public value, expose it from a server route and hydrate over the network; see the [Expo recipe in the guide](/guides/dynamic-config/#loading-from-a-server-endpoint). ## Reference [Section titled “Reference”](#reference) * [Root decorators reference](/reference/root-decorators) * [Item decorators reference](/reference/item-decorators) * [Functions reference](/reference/functions) * [Expo environment variables docs](https://docs.expo.dev/guides/environment-variables/) * [Expo Router API routes](https://docs.expo.dev/router/reference/api-routes/) * [React Native Metro docs](https://reactnative.dev/docs/metro) * [React Native Community CLI](https://github.com/react-native-community/cli) * [Metro bundler docs](https://metrobundler.dev/) # GitHub Actions > Use Varlock in GitHub Actions to securely load and validate environment variables [![GitHub Actions Marketplace](https://img.shields.io/badge/GitHub%20Actions-Marketplace-blue?logo=github)](https://github.com/marketplace/actions/varlock-environment-loader) The Varlock GitHub Action loads and validates environment variables in your GitHub Actions workflows. It automatically detects and loads your `.env.schema` file and all relevant `.env.*` files, validates all environment variables against your schema, and exports them as either environment variables or a JSON blob for use in subsequent steps. ## Features [Section titled “Features”](#features) * 🔒 **Schema Validation**: Validates all environment variables against your `.env.schema` file * 🚀 **Auto-installation**: Automatically installs varlock if not present * 🔍 **Smart Detection**: Automatically loads `.env` and relevant `.env.*` files * 🛡️ **Security**: Handles sensitive values as GitHub secrets * 📊 **Flexible Output**: Export as environment variables or JSON blob .env.schema not required While you are encouraged to create a `.env.schema` file while using varlock, you can still use this GitHub Action without one. If you do not have a `.env.schema` file, the action will only load `.env`, since we won’t know what to use as your [environment flag](/guides/environments), and therefore which other `.env.*` files to load. ## Setup [Section titled “Setup”](#setup) 1. **Create or update your `.env.schema` file** Make sure you have a `.env.schema` file in your repository that defines your environment variables and their validation rules. .env.schema ```env-spec # @currentEnv=$APP_ENV # @defaultSensitive=false @defaultRequired=false # @generateTsTypes(path='env.d.ts') # --- # Environment flag # @type=enum(development, staging, production) APP_ENV=development # Database configuration # @type=url @required DATABASE_URL= # API configuration # @type=string(startsWith=sk-) @sensitive API_KEY= # Feature flags # @type=boolean ENABLE_FEATURE_X=false ``` 2. **Add the action to your workflow** .github/workflows/deploy.yml ```yaml name: Deploy Application on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v7 - name: Load environment variables uses: dmno-dev/varlock-action@v1.0.1 ``` ## Inputs [Section titled “Inputs”](#inputs) | Input | Description | Required | Default | | ------------------- | ---------------------------------------------- | -------- | ------- | | `working-directory` | Directory containing `.env.schema` files | No | `.` | | `show-summary` | Show a summary of loaded environment variables | No | `true` | | `fail-on-error` | Fail the action if validation errors are found | No | `true` | | `output-format` | Output format: `env` or `json` | No | `env` | ## Outputs [Section titled “Outputs”](#outputs) | Output | Description | | ------------- | ---------------------------------------------------------------------------------------------- | | `summary` | Summary of loaded environment variables using `varlock load` | | `error-count` | Number of validation errors found | | `json-env` | JSON blob containing all environment variables (only available when `output-format` is `json`) | ## Usage Examples [Section titled “Usage Examples”](#usage-examples) ### Basic Environment Variable Loading [Section titled “Basic Environment Variable Loading”](#basic-environment-variable-loading) This example loads environment variables and exports them for use in subsequent steps: .github/workflows/basic.yml ```yaml name: Basic Environment Loading on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v7 - name: Load environment variables uses: dmno-dev/varlock-action@v1.0.1 - name: Use environment variables run: | echo "Database URL: $DATABASE_URL" echo "API Key: $API_KEY" echo "Environment: $APP_ENV" ``` ### JSON Output Format [Section titled “JSON Output Format”](#json-output-format) Use JSON output when you need to reuse environment variables in multi-job workflows or pass them to other tools: .github/workflows/json-output.yml ```yaml name: JSON Output Example on: push: branches: [main] jobs: load-env: runs-on: ubuntu-latest outputs: env-vars: ${{ steps.varlock.outputs.json-env }} steps: - name: Checkout code uses: actions/checkout@v7 - name: Load environment variables as JSON id: varlock uses: dmno-dev/varlock-action@v1.0.1 with: show-summary: false output-format: 'json' build: needs: load-env runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v7 - name: Process environment variables run: | # Access the JSON blob from the previous job echo '${{ needs.load-env.outputs.env-vars }}' > env-vars.json # Use jq to process the JSON echo "Database URL: $(jq -r '.DATABASE_URL' env-vars.json)" echo "API Key: $(jq -r '.API_KEY' env-vars.json)" - name: Build application run: | # Use environment variables from JSON in build process DATABASE_URL=$(jq -r '.DATABASE_URL' env-vars.json) API_KEY=$(jq -r '.API_KEY' env-vars.json) echo "Building with DATABASE_URL: $DATABASE_URL" echo "Building with API_KEY: $API_KEY" # Your build logic here deploy: needs: build runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v7 - name: Deploy with environment variables run: | # Access the same environment variables from the first job echo '${{ needs.load-env.outputs.env-vars }}' > env-vars.json # Use environment variables in deployment DATABASE_URL=$(jq -r '.DATABASE_URL' env-vars.json) API_KEY=$(jq -r '.API_KEY' env-vars.json) echo "Deploying with DATABASE_URL: $DATABASE_URL" echo "Deploying with API_KEY: $API_KEY" # Your deployment logic here ``` ### Multi-Environment Workflows [Section titled “Multi-Environment Workflows”](#multi-environment-workflows) Handle different environments based on branch or deployment context: .github/workflows/multi-env.yml ```yaml name: Multi-Environment Deployment on: push: branches: [main, staging, develop] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v7 - name: Load environment variables uses: dmno-dev/varlock-action@v1.0.1 env: # Set environment-specific values APP_ENV: ${{ github.ref_name == 'main' && 'production' || github.ref_name == 'staging' && 'staging' || 'development' }} - name: Deploy to environment run: | echo "Deploying to $APP_ENV environment" # Your deployment logic here ``` ## Error Handling [Section titled “Error Handling”](#error-handling) The action provides error handling and reporting: ### Validation Errors [Section titled “Validation Errors”](#validation-errors) When environment variables fail validation, the action will: 1. **Show detailed error messages** in the action logs 2. **Set the `error-count` output** with the number of errors found 3. **Fail the action** if `fail-on-error` is set to `true` (default) .github/workflows/error-handling.yml ```yaml name: Error Handling Example on: push: branches: [main] jobs: validate: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v7 - name: Load environment variables id: varlock uses: dmno-dev/varlock-action@v1.0.1 with: fail-on-error: false # Don't fail on validation errors - name: Handle validation errors if: steps.varlock.outputs.error-count > '0' run: | echo "Found ${{ steps.varlock.outputs.error-count }} validation errors" echo "Check the varlock output above for details" # Your error handling logic here ``` ## Security Considerations [Section titled “Security Considerations”](#security-considerations) ### Sensitive Data Handling [Section titled “Sensitive Data Handling”](#sensitive-data-handling) The action automatically detects sensitive values based on your `.env.schema` configuration and handles them securely: * **Sensitive values** are exported as GitHub secrets available in the current workflow run * **Non-sensitive values** are exported as regular environment variables * **All values** are available in subsequent steps, but sensitive ones are masked in logs ### Environment Variable Scope [Section titled “Environment Variable Scope”](#environment-variable-scope) * Environment variables are only available within the job where the action runs * They are not persisted across jobs or workflow runs * Use the `json-env` output if you need to pass values between jobs, keeping in mind that this could possibly leak sensitive data if not handled correctly. You can also re-run the varlock action in a subsequent job to get the latest values. ## Best Practices [Section titled “Best Practices”](#best-practices) 1. **Always use `.env.schema` and \`.env.**\*: Define your environment structure and validation rules, see [environments guide](/guides/environments) for more information. 2. **Set `fail-on-error: true` (default)**: Catch configuration issues early in your CI/CD pipeline 3. **Handle errors gracefully**: Check the `error-count` output and provide meaningful feedback 4. **Secure sensitive data**: Mark sensitive values in your schema and let the action handle them securely ## Related Documentation [Section titled “Related Documentation”](#related-documentation) * [Environment Variables Guide](/guides/environments/) - Learn about managing multiple environments * [Schema Reference](/reference/root-decorators/) - Understand schema decorators and validation * [Getting Started](/getting-started/introduction/) - Set up Varlock in your project * [CLI Reference](/reference/cli-commands/) - Command-line interface documentation # Go > Use varlock with Go via a generated env package To use varlock with Go, [install the standalone binary](/getting-started/installation/#as-a-standalone-binary) and run your app under [`varlock run`](/reference/cli/load-and-run/#run). It loads and validates your env, then injects it into the process. Add [`@generateGoEnv`](/reference/root-decorators/#generategoenv) to your schema to generate a small, self-contained package: an `Env` struct, a `Load()` function that parses the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob, and a `SensitiveKeys` map. .env.schema ```env-spec # @generateGoEnv(path=env/env.go) ``` The package name follows the output directory (`env/env.go` → `package env`); override it with `package=`. The file is regenerated automatically on [`varlock load`](/reference/cli/load-and-run/#load) and [`varlock run`](/reference/cli/load-and-run/#run), or explicitly with [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen). Requires **Go 1.18+** (the generated code uses `any`). ## Reading values [Section titled “Reading values”](#reading-values) Call `Load()` once, then read fields off the struct. Required fields are the plain type, optional ones are pointers (deref when set): ```go package main import ( "log" "myapp/env" ) func main() { e, err := env.Load() // call once, hold or pass `e` around if err != nil { log.Fatal(err) } host := e.DbHost // string (required field) if e.DbPort != nil { // *int64 (optional field, deref when set) log.Printf("%s:%d", host, *e.DbPort) } } ``` ```bash varlock run -- go run . ``` `Load()` returns a clear error if `__VARLOCK_ENV` is missing (e.g. you forgot `varlock run`) or if a required key is absent. ## Sharing one instance [Section titled “Sharing one instance”](#sharing-one-instance) Every `Load()` call re-parses the blob. To avoid reloading, expose it as a package-level var in your own package: config/config.go ```go package config import "myapp/env" var Env = mustLoad() func mustLoad() env.Env { e, err := env.Load() if err != nil { panic(err) } return e } ``` anywhere.go ```go import "myapp/config" port := config.Env.DbPort // *int64 ``` Note Every key is also injected as a plain environment variable, so you can read any of them with `os.Getenv` (raw strings) if you don’t need the typed module. Enums become plain `string` (Go has no enum type), and keys whose names aren’t valid identifiers (they contain `.` or `-`) can’t be struct fields, so read those from the env var directly. See [Other languages](/integrations/other-languages/) for the shared details on the injected blob, raw env vars, and encryption. # Java > Use varlock with Java via a generated, typed env class To use varlock with Java, [install the standalone binary](/getting-started/installation/#as-a-standalone-binary) and run your app under [`varlock run`](/reference/cli/load-and-run/#run). It loads and validates your env, then injects it into the process. Add [`@generateJavaEnv`](/reference/root-decorators/#generatejavaenv) to your schema to generate a small, self-contained class: a typed `Env` with public final fields, a static `load()` that parses the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob, and a `SENSITIVE_KEYS` constant. .env.schema ```env-spec # @generateJavaEnv(path=src/main/java/Env.java) ``` The file is regenerated automatically on [`varlock load`](/reference/cli/load-and-run/#load) and [`varlock run`](/reference/cli/load-and-run/#run), or explicitly with [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen). Requires **Java 17+** and [Jackson](https://github.com/FasterXML/jackson) on the classpath (`jackson-databind`): pom.xml (snippet) ```xml com.fasterxml.jackson.core jackson-databind 2.17.2 ``` Optional `package=` and `class=` set the Java package and class name (default: no package, class `Env`). Field names are camelCase (`DB_HOST` → `dbHost`). ## Reading values [Section titled “Reading values”](#reading-values) Call `load()` once, then read fields off the object. Required scalars are primitives (`long`, `boolean`, …); optional ones are boxed (`Long`, `Boolean`, …) and may be `null`: ```java Env e = Env.load(); // call once, hold or pass `e` around String host = e.dbHost; // String (required field) if (e.dbPort != null) { // Long (optional field) System.out.println(host + ":" + e.dbPort); } ``` Build a jar as usual, then run it under varlock: ```bash mvn -q package varlock run -- java -jar target/myapp.jar ``` With [Spring Boot](https://spring.io/projects/spring-boot), wrap the usual run goal (or the packaged jar) the same way: ```bash varlock run -- ./mvnw spring-boot:run # or, after `./mvnw -q package`: varlock run -- java -jar target/myapp.jar ``` `load()` throws a clear error if `__VARLOCK_ENV` is missing (e.g. you forgot `varlock run`) or if a required key is absent. ## Sharing one instance [Section titled “Sharing one instance”](#sharing-one-instance) Every `load()` call re-parses the blob. To avoid reloading, bind the loaded instance wherever your app keeps shared config. Spring: expose it as a `@Bean` and inject `Env` where you need it: ```java @Configuration public class EnvConfig { @Bean public Env env() { return Env.load(); // call once at startup } } @Service public class DbService { private final Env env; public DbService(Env env) { this.env = env; } public void connect() { // env.dbHost, env.dbPort, … } } ``` No DI container? A small holder works: ```java public final class Config { public static final Env ENV = Env.load(); } // elsewhere: Config.ENV.dbPort (Long) ``` Note Every key is also injected as a plain environment variable, so you can read any of them with `System.getenv` (raw strings) if you don’t need the typed module. Keys whose names aren’t valid identifiers (they contain `.` or `-`) can’t be fields, so read those from the env var directly. See [Other languages](/integrations/other-languages/) for the shared details on the injected blob, raw env vars, and encryption. # JavaScript / Node.js > How to integrate Varlock with JavaScript and Node.js for secure, type-safe environment management There are a few different ways to integrate Varlock into a JavaScript / Node.js application. Some tools/frameworks may require an additional package, or have more specific instructions. Check the Integrations section in the navigation for more details. **Want to help us build more integrations? Join our [Discord](https://chat.dmno.dev)!** ## Node.js - `varlock/auto-load` [Section titled “Node.js - varlock/auto-load”](#nodejs---varlockauto-load) The best way to integrate varlock into a plain Node.js application (⚠️ version 22 or higher) is to import the `varlock/auto-load` module. This uses `execSync` to call out to the varlock CLI (or [reuses env injected by a parent `varlock run`](#reusing-an-injected-env-blob)), sets resolved env vars into `process.env`, and initializes varlock’s runtime code, including: * varlock’s `ENV` object * log redaction (if enabled) * leak detection (if enabled) example-index.js ```js import 'varlock/auto-load'; import { ENV } from 'varlock/env'; const FROM_VARLOCK_ENV = ENV.MY_CONFIG_ITEM; // ✨ recommended const FROM_PROCESS_ENV = process.env.MY_CONFIG_ITEM; // 🆗 still works ``` dotenv drop-in replacement If you are using [`dotenv`](https://www.npmjs.com/package/dotenv), or a package you are using is using it under the hood, you can swap in varlock using your package manager’s override feature. See the [migrate from dotenv](/guides/migrate-from-dotenv) guide for more information. ### Reporting load failures [Section titled “Reporting load failures”](#reporting-load-failures) When validation fails, `varlock/auto-load` writes the error to `stderr` and exits with a non-zero code. Because it exits during module import, an error reporter like Sentry may never see the failure. You can opt in to having auto-load **throw** the error instead of exiting, so a reporter can pick it up. This is never enabled automatically, so nothing changes for apps that do not opt in. **If your reporter is already initialized before varlock loads** (for example Sentry started via `node --import ./instrument.mjs`, with its DSN in a real environment variable), set `_VARLOCK_THROW_ON_LOAD_ERROR=1`. auto-load then throws instead of exiting, and the reporter’s existing `uncaughtException` handler catches it. Make sure the reporter is imported before varlock: index.js ```js import './instrument.js'; // initializes Sentry (registers its handler) import 'varlock/auto-load'; ``` **If the DSN itself comes from varlock, or you init your reporter in app code,** set a `globalThis._varlockOnLoadError` hook. It is called with the error and a map of the values that did resolve (so you can read `SENTRY_DSN` even though the overall load failed): varlock-error-hook.js ```js import * as Sentry from '@sentry/node'; globalThis._varlockOnLoadError = (err, env) => { Sentry.init({ dsn: env.SENTRY_DSN }); Sentry.captureException(err); return Sentry.close(2000); // return the promise; auto-load exits once it settles }; ``` index.js ```js import './varlock-error-hook.js'; // must be imported BEFORE auto-load import 'varlock/auto-load'; ``` Two things to keep in mind: * The hook must be registered **before** `varlock/auto-load` is imported. ES modules hoist all `import` statements, so register the hook via its own side-effect import placed above the auto-load import, not with an inline call between imports. * Reporting is best-effort. auto-load does not `await` your hook (that would change how env is injected), so it gives async work a short window and then forces the process to exit. Return a promise from the hook and auto-load will exit as soon as it settles. Only resolved values are available to the hook, so this does not help with `.env` **parse or schema errors**, where resolution is skipped entirely. To report those, the DSN must come from a real environment variable and be picked up by an already-initialized reporter. ### Reusing an injected env blob [Section titled “Reusing an injected env blob”](#reusing-an-injected-env-blob) When the process was launched by [`varlock run`](/reference/cli/load-and-run/#run), the resolved env is already present as the `__VARLOCK_ENV` blob. auto-load reuses it instead of calling the CLI again, so wrapping an auto-load app in `varlock run` does not cost a second resolution. Reuse only happens when a fresh resolution would produce the same result: * the blob was resolved in the same directory the app would resolve in (a root-level `varlock run` in a monorepo does not stop per-package resolution) * it resolved without errors * the `.env` files it was resolved from are unchanged on disk (editing an env file and restarting the app inside the same `varlock run` re-resolves and picks up the edit) * no env override recorded in the blob has changed since (`varlock run -- sh -c 'FOO=x node app.js'` re-resolves, so the new `FOO` wins) If any check fails, auto-load falls back to the CLI. Control it explicitly with [`_VARLOCK_USE_INJECTED_ENV`](/reference/reserved-variables/#_varlock_use_injected_env): `0` always re-resolves, `1` always trusts the blob, skipping the directory check. `varlock run` applies the same rules when it finds a blob, which covers non-Node workloads. Trust mode is how you hand an env into an environment with no `.env` files at all, like a remote sandbox; see the [E2B](/sandboxes/e2b/#passing-resolved-values) and [Fly.io](/sandboxes/flyio/#passing-resolved-values) guides. ## Boot via `varlock run` [Section titled “Boot via varlock run”](#boot-via-varlock-run) A less invasive way to use varlock with your application is to run your application via [`varlock run`](/reference/cli/load-and-run/#run). ```bash varlock run -- ``` This will load and validate your environment variables, then run the command you provided with those environment variables injected into the process. This will not inject any runtime code, and varlock’s `ENV` object will not be available. If you have installed varlock as a project dependency instead of globally, you should run this via your package manager: * npm ```bash npm exec -- varlock run -- ``` * pnpm ```bash pnpm exec -- varlock run -- ``` * bun ```bash bunx varlock run -- ``` * vlt ```bash vlx -- varlock run -- ``` * yarn ```bash yarn exec -- varlock run -- ``` In `package.json` scripts, calling `varlock` directly will work, as your package manager handles path issues: package.json ```json "scripts": { "start": "varlock run -- node index.js" } ``` Even when using a deeper integration for your code, you may still need to use `varlock run` when calling external scripts/tools, like database migrations, to pass along resolved env vars. Setting the current environment Varlock can load multiple environment-specific `.env` files (e.g., `.env.development`, `.env.production`) by using the [`@currentEnv` root decorator](/reference/root-decorators/#currentenv) to specify which env var will set the current environment. If not using the default (usually `development`), you’ll want to pass it in as an environment variable when running your command. ```bash APP_ENV=production varlock run -- node index.js ``` See the [environments guide](/guides/environments) for more information about how to set the current environment. ## Front-end frameworks [Section titled “Front-end frameworks”](#front-end-frameworks) While environment variables are not available in the browser, many frameworks expose some env vars that are available *at build time* to the client by embedding them into your bundled code. This is best accomplished using tool-specific integrations, especially for frameworks that are handling both client and server-side code. Isomorphic env vars The `varlock/env` module is designed to be imported on both the client and server, so frameworks that run code in both places (like Next.js) can import it. Help us build more integrations! If you are using a tool/framework that is not listed here, and you’d like to see support for it, or collaborate on building it, we’d love to hear from you. Please hop into our [Discord](https://chat.dmno.dev)! # mise > Install varlock with mise and wire validated env vars into your tasks [mise](https://mise.jdx.dev/) (mise-en-place) is a polyglot tool manager, environment manager, and task runner. You can use it to install the `varlock` CLI itself, and to wire varlock into the commands you run during development. Inject into commands, not your shell mise can load environment variables into your shell session (via `[env]`), but we **don’t recommend** loading your varlock-managed env (especially secrets) that way. Once values live in your shell, every process inherits them, they show up in `env`, and varlock’s [log redaction](/guides/secrets/#redaction-in-varlock-run) is bypassed. Instead, integrate varlock at the point where each program runs: either by wrapping the command with [`varlock run`](/reference/cli/load-and-run/#run) (works for anything), or with one of varlock’s [framework/runtime integrations](/integrations/overview/) (for JS/TS apps). Both approaches let you load env vars in different configurations per context (a different [environment](/guides/environments/) per command, build-time vs. runtime resolution, redacted output, leak protection) rather than forcing one global set into the shell where everything sees the same thing. See [Loading into your shell](#loading-env-vars-into-your-shell-advanced) for the narrow cases where shell injection still makes sense. ## Install varlock with mise [Section titled “Install varlock with mise”](#install-varlock-with-mise) mise can install and version-manage the `varlock` CLI alongside the rest of your toolchain. Already using varlock in a JS project? If varlock is already a dependency of your JavaScript/TypeScript project, you **don’t need any mise integration to install it**. Keep installing it with your package manager (`npm`/`pnpm`/`yarn`/`bun`) and run it via `npx varlock` / `bun run varlock` as usual. mise’s job there is managing your runtime (e.g. `node` or `bun`) and, optionally, [running your scripts as tasks](#running-your-commands-as-tasks). The backends below are for installing the standalone `varlock` CLI itself, useful for non-JS projects, or for sharing one varlock version across a team’s machines. * Standalone binary (recommended) The `github` backend installs varlock’s prebuilt, self-contained binary directly from our GitHub releases. It needs **no runtime** (no Node or Bun) and mise verifies the download’s checksum and build attestations automatically. mise.toml ```toml [tools] "github:dmno-dev/varlock" = "latest" ``` ```bash mise install varlock --version ``` Or try it without a config file: ```bash mise exec github:dmno-dev/varlock@latest -- varlock --version ``` `latest` can lag a fresh release by \~24h mise applies a default 24-hour `minimum_release_age` delay to releases as a supply-chain safeguard, so a brand-new version may be temporarily hidden from `latest`. To get a release immediately, pin the exact version: mise.toml ```toml [tools] "github:dmno-dev/varlock" = "1.7.0" ``` * npm package The `npm` backend installs varlock from npm. This **requires a Node.js runtime**, since mise uses Node’s `npm` under the hood, so declare `node` in the same `[tools]` table (mise will not provide it automatically): mise.toml ```toml [tools] node = "lts" "npm:varlock" = "latest" ``` ```bash mise install varlock --version ``` Prefer the standalone binary above when you only need the CLI. It avoids the Node dependency and starts faster. Using Bun? mise’s `npm` backend can’t be driven by Bun alone (it still needs Node for `npm`). If your project uses Bun or Node already, you typically wouldn’t install varlock as a mise *tool* at all. Add it as a project dependency and run it through [tasks](#running-your-commands-as-tasks) with your project’s runtime. See the [Bun](/integrations/bun/) and [JavaScript / Node.js](/integrations/javascript/) integrations. ## Running your commands as tasks [Section titled “Running your commands as tasks”](#running-your-commands-as-tasks) [mise tasks](https://mise.jdx.dev/tasks/) are a great place to run your project’s commands. Whether you need to mention varlock in the task depends on whether the command already loads it. ### When varlock is already wired into the command [Section titled “When varlock is already wired into the command”](#when-varlock-is-already-wired-into-the-command) Often it is. A `package.json` script may already call [`varlock run`](/reference/cli/load-and-run/#run), or your app may use a [framework/runtime integration](/integrations/overview/) (Next.js, Vite, Astro, Node, etc.) that loads and validates env on startup. In that case the mise task just runs the command as-is, and nothing varlock-specific belongs here: mise.toml ```toml [tasks.dev] run = "npm run dev" # e.g. "dev": "varlock run -- next dev", or the app uses an integration ``` ```bash mise run dev ``` ### When the command doesn’t load varlock on its own [Section titled “When the command doesn’t load varlock on its own”](#when-the-command-doesnt-load-varlock-on-its-own) For a raw binary, a shell script, or a one-off command that isn’t already varlock-aware, wrap it with `varlock run` so it gets a resolved, validated environment with redacted output, scoped to that one subprocess, never your shell: mise.toml ```toml [tools] "github:dmno-dev/varlock" = "latest" # makes `varlock` available to the task [tasks.migrate] run = "varlock run -- ./scripts/migrate.sh" [tasks.psql] run = "varlock run -- psql" ``` Declaring varlock in `[tools]` lets mise install it and put it on `PATH` before the task runs. If varlock is instead a dependency of your JS project, call the project-local version through your package manager and drop it from `[tools]`, e.g. `run = "npx varlock run -- ./scripts/migrate.sh"` or `run = "bun run varlock run -- ./scripts/migrate.sh"`. ## Loading env vars into your shell (advanced) [Section titled “Loading env vars into your shell (advanced)”](#loading-env-vars-into-your-shell-advanced) Sometimes you genuinely want a value available to any command you type in the terminal, typically **non-secret** config like `NODE_ENV` or a public base URL. You can do this without a plugin using mise’s `env._.source`, which captures variables exported by a script. Create a small script that emits varlock’s resolved env in shell format: .mise/load-env.sh ```bash varlock load --format shell --compact ``` Then source it from your config: mise.toml ```toml [env] _.source = "./.mise/load-env.sh" ``` `varlock load --format shell` outputs `export KEY='value'` lines; `--compact` skips undefined optional values. This puts values in your shell Anything loaded this way lives in your shell session and is inherited by every process you launch, and varlock’s [log redaction](/guides/secrets/#redaction-in-varlock-run) does **not** apply, since it only protects the output of `varlock run`. Scope a dedicated file to just the non-secret values you want ambient (and keep secrets in [tasks](#running-your-commands-as-tasks)), or use [direnv](/integrations/direnv/) if you specifically want enter/leave shell loading. mise’s `redact` isn’t a substitute for `varlock run` mise has its own [redactions](https://mise.jdx.dev/environments/#redactions) (`redact = true`, or a `redactions` list) that hide values from `mise run` task logs. But mise can only redact a value it manages as an env var, and by its own docs, redaction “does not prevent the values from being exported to child processes.” So it’s log hygiene, not isolation: the value still flows to everything. For real secrets, prefer [`varlock run`](#running-your-commands-as-tasks), which both scopes the value to a single subprocess **and** redacts its output before mise (or anything else) ever sees it. ## Validate when entering a project [Section titled “Validate when entering a project”](#validate-when-entering-a-project) If you just want a fast failure when a project’s environment is missing or invalid, without injecting anything into your shell, use a [hook](https://mise.jdx.dev/hooks.html) instead of `[env]`: mise.toml ```toml [hooks] enter = "varlock load > /dev/null" ``` `varlock load` exits non-zero on a validation error, so you’ll see the problem as soon as you `cd` in. Run `varlock load` directly to see the full colorized error output. # Next.js > How to integrate Varlock with Next.js for secure, type-safe environment management [![](https://img.shields.io/npm/v/@varlock/nextjs-integration?label=%40varlock%2Fnextjs-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/nextjs-integration) Varlock is a big upgrade over the [default Next.js environment variable tooling](https://nextjs.org/docs/pages/guides/environment-variables), adding validation, type safety, multi-environment management, log redaction, leak detection, and more. To integrate varlock into a Next.js application, use our [`@varlock/nextjs-integration`](https://npmx.dev/package/@varlock/nextjs-integration) package. It provides a drop-in replacement for [`@next/env`](https://www.npmjs.com/package/@next/env), the internal package that handles .env loading, plus a small config plugin that injects our additional security features. ## Setup [Section titled “Setup”](#setup) Requirements * Node.js v22 or higher * Next.js v14 or higher (v15 and v16 fully supported, including Turbopack) 1. **Install varlock and the Next.js integration package** * npm ```bash npm install @varlock/nextjs-integration varlock ``` * pnpm ```bash pnpm add @varlock/nextjs-integration varlock ``` * bun ```bash bun add @varlock/nextjs-integration varlock ``` * yarn ```bash yarn add @varlock/nextjs-integration varlock ``` * vlt ```bash vlt install @varlock/nextjs-integration varlock ``` 2. **Run `varlock init` to set up your `.env.schema` file** This will guide you through setting up your `.env.schema` file, based on your existing `.env` file(s). Make sure to review it carefully. * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Override `@next/env` with our drop-in replacement** Next.js does not have APIs we can hook into, so we must override their internal .env-loading package. Overriding dependencies is a bit different for each package manager: * npm See [NPM overrides docs](https://docs.npmjs.com/cli/v9/configuring-npm/package-json#overrides) package.json ```diff { +"overrides": { +"next": { +"@next/env": "npm:@varlock/nextjs-integration" + } + } } ``` npm may not apply the override on a plain `npm install` npm can be inconsistent about applying `overrides` for nested scoped packages like `@next/env`, especially when an existing `package-lock.json` has it pinned to a stale location. If after installing you hit `Cannot find module '@next/env'` or don’t see the varlock banner, delete both `node_modules` and `package-lock.json` and reinstall. See [Troubleshooting](#troubleshooting). * yarn See [yarn resolutions docs](https://yarnpkg.com/configuration/manifest#resolutions) root/package.json ```diff { +"resolutions": { +"**/@next/env": "npm:@varlock/nextjs-integration" + }, } ``` **In a monorepo, this override must be done in the monorepo’s root package.json file!** * pnpm * pnpm version 10+ See [pnpm v10 overrides docs](https://pnpm.io/settings#overrides) root/pnpm-workspace.yaml ```diff packages: # <- ⚠️ this field is also required - . # set this to '.' if not in a monorepo +overrides: +"@next/env": "npm:@varlock/nextjs-integration" ``` **This must be set in `pnpm-workspace.yaml`, which lives at the root of your repo, regardless of whether you are using a monorepo or not.** * pnpm version 9 See [pnpm v9 overrides docs](https://pnpm.io/9.x/package_json#pnpmoverrides) root/package.json ```diff { +"pnpm": { +"overrides": { +"@next/env": "npm:@varlock/nextjs-integration" + } + } } ``` **In a monorepo, this override must be done in the monorepo’s root package.json file!** * bun See [pnpm v10 overrides docs](https://pnpm.io/settings#overrides) root/pnpm-workspace.yaml ```diff packages: # <- ⚠️ this field is also required - . # set this to '.' if not in a monorepo +overrides: +"@next/env": "npm:@varlock/nextjs-integration" ``` **This must be set in `pnpm-workspace.yaml`, which lives at the root of your repo, regardless of whether you are using a monorepo or not.** * pnpm version 10+ See [pnpm v9 overrides docs](https://pnpm.io/9.x/package_json#pnpmoverrides) root/package.json ```diff { +"pnpm": { +"overrides": { +"@next/env": "npm:@varlock/nextjs-integration" + } + } } ``` **In a monorepo, this override must be done in the monorepo’s root package.json file!** * pnpm version 9 See [Bun overrides docs](https://bun.com/docs/pm/overrides) package.json ```diff { +"overrides": { +"@next/env": "npm:@varlock/nextjs-integration" + } } ``` Then re-run your package manager’s install command to apply the override: * npm ```bash npm install ``` * yarn ```bash yarn install ``` * pnpm ```bash pnpm install ``` * bun ```bash bun install ``` 4. **Enable the Next.js config plugin** At this point, varlock will now load your .env files into `process.env`. But to get the full benefits of this integration, you must add `varlockNextConfigPlugin` to your `next.config.*` file. next.config.ts ```diff import type { NextConfig } from "next"; +import { varlockNextConfigPlugin } from '@varlock/nextjs-integration/plugin'; const withVarlock = varlockNextConfigPlugin(); const nextConfig: NextConfig = { // your existing config... }; -export default nextConfig; +export default withVarlock(nextConfig); ``` 5. **Verify the override is active** Start your dev server (or run a build) and look at the startup output. When the override is working, varlock reports the `.env` files it loaded with a banner: next dev ```bash - Environments: .env.schema, ✨ loaded by varlock ✨ ``` If you **don’t** see `✨ loaded by varlock ✨`, or you get `Cannot find module '@next/env'` or `__VARLOCK_ENV is not set`, the override didn’t take effect. See [Troubleshooting](#troubleshooting) below. The override only needs to apply at build/dev time On serverless platforms (e.g. Vercel, Cloudflare), the fully-resolved env is baked into your build output by the config plugin, so the production server does not re-load `.env` files. This means you only need the `@next/env` override to be active when you run `next dev` and `next build`. There is no separate runtime config to set on the platform. *** ## Monorepos [Section titled “Monorepos”](#monorepos) In a [monorepo](/guides/monorepos/), the `@next/env` override from setup step 3 must live at the **workspace root** so every Next.js app resolves the same replacement. Package-manager overrides apply to **all** Next.js packages in the workspace, so only add the override once each app you run has its own `.env.schema` and `varlockNextConfigPlugin`. *** ## Accessing environment variables [Section titled “Accessing environment variables”](#accessing-environment-variables) You can continue to use `process.env.SOMEVAR` as usual, but we recommend using Varlock’s imported `ENV` object for better type-safety and improved developer experience: example.ts ```ts import { ENV } from 'varlock/env'; console.log(process.env.SOMEVAR); // 🆗 still works console.log(ENV.SOMEVAR); // ✨ recommended ``` Caution If you are not using the `varlockNextConfigPlugin`, only `process.env` will work. ### Type-safety and IntelliSense [Section titled “Type-safety and IntelliSense”](#type-safety-and-intellisense) To enable type-safety and IntelliSense for your env vars, enable the [`@generateTsTypes` root decorator](/reference/root-decorators/#generatetstypes) in your `.env.schema`. Note that if your schema was created using `varlock init`, it will include this by default. .env.schema ```diff +# @generateTsTypes(path='env.d.ts') # --- # your config items... ``` #### Why use `ENV` instead of `process.env`? [Section titled “Why use ENV instead of process.env?”](#why-use-env-instead-of-processenv) * Non-string values (e.g., number, boolean) are properly typed and coerced * All non-sensitive items are replaced at build time (not just `NEXT_PUBLIC_`) * Better error messages for invalid or unavailable keys * Enables future DX improvements and tighter control over what is bundled ## Managing multiple environments [Section titled “Managing multiple environments”](#managing-multiple-environments) Varlock can load multiple *environment-specific* `.env` files (e.g., `.env.development`, `.env.preview`, `.env.production`). By default, the environment flag is determined as follows (matching Next.js): * `test` if `NODE_ENV` is `test` * `development` if running `next dev` * `production` otherwise Tip Without a custom env flag, you cannot use non-production env files (like `.env.preview`, `.env.staging`) for non-prod deployments. Instead, we recommend explicitly setting your own environment flag using the [`@currentEnv` root decorator](/reference/root-decorators/#currentenv), e.g. `APP_ENV`. See the [environments guide](/guides/environments) for more information. Loading `.env.local` in `test` environment Next.js makes [a special exception](https://nextjs.org/docs/pages/guides/environment-variables#test-environment-variables) to skip loading `.env.local` if the current environment is `test`. Varlock does not (following Vite’s lead), but you may explicitly recreate that behavior: .env.local ```env-spec # @disable=forEnv(test) ``` Precedence of `.env.local` vs `.env.[currentEnv]` Next.js swaps the order of precedence for `.env.local` vs `.env.[currentEnv]` compared to Varlock. ### Setting the environment flag [Section titled “Setting the environment flag”](#setting-the-environment-flag) When running locally, or on a platform you control, you can set the env flag explicitly as an environment variable. However on some cloud platforms, there is a lot of magic happening, and the ability to set environment variables per branch is limited. In these cases you can use functions to transform env vars injected by the platform, like a current branch name, into the value you need. #### Local/custom scripts [Section titled “Local/custom scripts”](#localcustom-scripts) You can set the env var explicitly when you run a command, but often you will set it in `package.json` scripts: package.json ```json "scripts": { "build:preview": "APP_ENV=preview next build", "start:preview": "APP_ENV=preview next start", "build:prod": "APP_ENV=production next build", "start:prod": "APP_ENV=production next start", "test": "APP_ENV=test jest" } ``` #### Vercel [Section titled “Vercel”](#vercel) You can use the injected `VERCEL_ENV` variable to match their concept of environment types: .env.schema ```env-spec # @currentEnv=$APP_ENV # --- # @type=enum(development, preview, production) VERCEL_ENV= # @type=enum(development, preview, production, test) APP_ENV=fallback($VERCEL_ENV, development) ``` For more granular environments, use the branch name in `VERCEL_GIT_COMMIT_REF` (see Cloudflare example below). #### Cloudflare Workers Build [Section titled “Cloudflare Workers Build”](#cloudflare-workers-build) Use the branch name in `WORKERS_CI_BRANCH` to determine the environment: .env.schema ```env-spec # @currentEnv=$APP_ENV # --- WORKERS_CI_BRANCH= # @type=enum(development, preview, production, test) APP_ENV=remap($WORKERS_CI_BRANCH, "main", production, /.*/, preview, development ) ``` *** ## Managing sensitive config values [Section titled “Managing sensitive config values”](#managing-sensitive-config-values) Next.js uses the `NEXT_PUBLIC_` prefix to determine which env vars are public (bundled for the browser). Varlock lets you control this using decorators. Set [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive) and mark specific items [`@sensitive`](/reference/item-decorators/#sensitive): .env.schema ```diff +# @defaultSensitive=true # --- SECRET_FOO= # sensitive by default # @sensitive=false NON_SECRET_FOO= ``` Or, if you’d like to continue using Next.js’s prefix behavior: .env.schema ```diff +# @defaultSensitive=inferFromPrefix('NEXT_PUBLIC_') # --- FOO= # sensitive NEXT_PUBLIC_FOO= # non-sensitive, due to prefix ``` Bundling behavior All non-sensitive items are bundled at build time via `ENV`, while `process.env` replacements only include `NEXT_PUBLIC_`-prefixed items. ## Static vs dynamic config [Section titled “Static vs dynamic config”](#static-vs-dynamic-config) By default, dynamic behavior follows sensitivity: sensitive values stay runtime-only, public values are inlined at build time. Override per item with [`@dynamic`](/reference/item-decorators/#dynamic) / [`@static`](/reference/item-decorators/#static); see the [Static vs Dynamic Config guide](/guides/dynamic-config/) for the full model. .env.schema ```env-spec NEXT_PUBLIC_STATIC_FLAG=enabled # @public NEXT_PUBLIC_RUNTIME_FLAG=enabled # @public @dynamic SECRET_API_KEY= # sensitive by default, so also dynamic ``` Server components and route handlers read `ENV.KEY` directly. When a **dynamic** key is accessed during server rendering, the integration marks that route dynamic (not statically prerendered), including nested component access. If **browser code** needs a public+dynamic value, it must be fetched from your server. This is rarely needed, especially on Vercel where env changes trigger a rebuild anyway, but when it is, see [Loading dynamic vars in the client](/guides/dynamic-config/#loading-dynamic-vars-in-the-client). *** ## Extra setup for standalone mode [Section titled “Extra setup for standalone mode”](#standalone) **⚠️ This is only needed if you are using `output: standalone`** Next’s standalone build command will not copy all our `.env` files to the `.next/standalone` directory, so we must copy them manually. Add this to your build command: package.json ```json { "scripts": { "build": "next build && cp .env.* .next/standalone", } } ``` *you may need to adjust if you don’t want to copy certain .local files* Standalone builds do not copy dependency binaries, and varlock depends on the CLI to load. So wherever you are booting your standalone server, you will also need to [install the varlock binary](/getting-started/installation/) and boot your server via [`varlock run`](/reference/cli/load-and-run/#run) ```bash varlock run -- node .next/standalone/server.js ``` *** ## Encrypting the env blob [Section titled “Encrypting the env blob”](#encrypting-the-env-blob) When deploying to serverless platforms like Vercel, Varlock injects the fully resolved env data into your server-side build output. By default, this blob is plaintext JSON, meaning anyone with access to the build artifact can read your secrets. You can encrypt it for extra protection (e.g., against sourcemap leaks). See the [encrypted deployments guide](/guides/encrypted-deployments/) for setup instructions. *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) Almost all setup issues come down to the `@next/env` override not being applied. The fixes below are ordered by the symptom you see. Work top to bottom. * ❌ `Error: Cannot find module '@next/env'` (or no `✨ loaded by varlock ✨` banner)\ 💡 The override was recorded in your lockfile but your package manager didn’t materialize it correctly. This is a known package-manager issue with `overrides`/`resolutions` for nested scoped packages. npm in particular can create a dangling symlink under `node_modules/next/node_modules/@next/env`, or skip it entirely, when installing against a stale lockfile. * Delete **both** `node_modules` **and** your lockfile, then reinstall. Deleting the lockfile alone is not enough: * npm: `rm -rf node_modules package-lock.json && npm install` * pnpm: `rm -rf node_modules pnpm-lock.yaml && pnpm install` * yarn: `rm -rf node_modules yarn.lock && yarn install` * bun: `rm -rf node_modules bun.lock bun.lockb && bun install` * Commit the regenerated lockfile. * Re-verify with the banner above, or directly: `cat node_modules/@next/env/package.json` should show `"name": "@varlock/nextjs-integration"`. * ❌ `process.env.__VARLOCK_ENV is not set`\ 💡 The override is missing or in the wrong place, so Next loaded its own `@next/env` instead of ours. * Confirm the override config is present and in the **correct file** for your package manager (see [step 3](#setup)). For pnpm it must be in `pnpm-workspace.yaml` (v10) or `package.json#pnpm.overrides` (v9), and in a monorepo it must be at the repo root. * Re-run your package manager’s install command, then re-verify the banner. * ❌ `Error [ERR_REQUIRE_ESM]: require() of ES Module ...`\ 💡 Varlock requires node v22 or higher, which has better CJS/ESM interoperability * ❌ Every page 500s in dev with a `[turbopack-node]/transforms/transforms.ts ... lint TP1006` error, but only when the project has a `middleware.ts` file (Next 15.0–15.4 + Turbopack)\ 💡 Turbopack on those versions cannot apply JS loaders (which varlock uses) to edge-context files like middleware without breaking dev page rendering. Upgrade to `next@^15.5` (where varlock scopes its loader away from edge files automatically) or Next 16 (which fixed the underlying issue), or run `next dev` without `--turbopack`. * ❌ `Property 'SOMEVAR' does not exist on type 'TypedEnvSchema'`\ 💡 If the item does exist in your schema, then the generated types are not being loaded properly by TypeScript * make sure the [`@generateTsTypes` root decorator](/reference/root-decorators/#generatetstypes) is enabled * ensure the path to the generated types file is included in your `tsconfig.json` *** ## Reference [Section titled “Reference”](#reference) * [Root decorators reference](/reference/root-decorators) * [Item decorators reference](/reference/item-decorators) * [Functions reference](/reference/functions) * [Next.js environment variable docs](https://nextjs.org/docs/pages/guides/environment-variables) # Nuxt > How to integrate varlock with Nuxt for secure, type-safe environment management [![](https://img.shields.io/npm/v/@varlock/nuxt-integration?label=%40varlock%2Fnuxt-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/nuxt-integration) While Nuxt has [runtimeConfig](https://nuxt.com/docs/guide/going-further/runtime-config) to help with environment variables, we think Varlock has more to offer: * Your `.env.schema` is not tied to JavaScript, and is a better place to store this schema info versus your `nuxt.config.*` file * Facilitates loading and composing multiple `.env` files * Facilitates setting values and handling multiple environments, not just setting defaults * More data types and options available * Leak detection, log redaction, and more security guardrails To integrate varlock into a Nuxt application, you must use our [`@varlock/nuxt-integration`](https://npmx.dev/package/@varlock/nuxt-integration) package, which is a [Nuxt module](https://nuxt.com/docs/guide/concepts/modules). ## Setup [Section titled “Setup”](#setup) Requirements * Node.js v22 or higher * Nuxt v3 or v4 (both are covered by our test suite; v4 recommended) 1. **Install varlock and the Nuxt module** * npm ```bash npm install @varlock/nuxt-integration varlock ``` * pnpm ```bash pnpm add @varlock/nuxt-integration varlock ``` * bun ```bash bun add @varlock/nuxt-integration varlock ``` * yarn ```bash yarn add @varlock/nuxt-integration varlock ``` * vlt ```bash vlt install @varlock/nuxt-integration varlock ``` 2. **Run `varlock init` to set up your `.env.schema` file** This will guide you through setting up your `.env.schema` file, based on your existing `.env` file(s). Make sure to review it carefully. * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Enable the Nuxt module** Add `@varlock/nuxt-integration` to your `nuxt.config.ts` modules array: nuxt.config.ts ```diff export default defineNuxtConfig({ + modules: ['@varlock/nuxt-integration'], }) ``` *** ## Accessing environment variables [Section titled “Accessing environment variables”](#accessing-environment-variables) You can continue to use `process.env.SOMEVAR` or `useRuntimeConfig()` as usual, but we recommend using varlock’s imported `ENV` object for better type-safety and improved developer experience: server/api/example.ts ```ts import { ENV } from 'varlock/env'; console.log(process.env.SOMEVAR); // 🆗 still works console.log(ENV.SOMEVAR); // ✨ recommended ``` #### Why use `ENV` instead of `process.env`? [Section titled “Why use ENV instead of process.env?”](#why-use-env-instead-of-processenv) * Non-string values (e.g., number, boolean) are properly typed and coerced * All non-sensitive items are replaced at build time * Better error messages for invalid or unavailable keys * Enables future DX improvements and tighter control over what is bundled ### Using `useRuntimeConfig()` [Section titled “Using useRuntimeConfig()”](#using-useruntimeconfig) Existing code and third-party modules often read values through Nuxt’s [runtimeConfig](https://nuxt.com/docs/guide/going-further/runtime-config). That keeps working alongside varlock: Nuxt applies `NUXT_`-prefixed env vars (e.g. `NUXT_PUBLIC_API_BASE` for `runtimeConfig.public.apiBase`) onto declared runtime config keys at server start, and it reads them from `process.env`. So any `NUXT_`-prefixed item in your `.env.schema` flows into `runtimeConfig` as long as the values are in the process at runtime: this is automatic in dev, and in production it means launching through `varlock run` or using `ssrInjectMode: 'auto-load'`. For non-prefixed keys, populate `runtimeConfig` explicitly in `nuxt.config.ts` from `ENV` (see [Nuxt config timing](#nuxt-config-timing) below for config-time access). We recommend `ENV` for your own code; the interop mostly matters for modules you do not control. ### Nuxt’s own `.env` loading [Section titled “Nuxt’s own .env loading”](#nuxts-own-env-loading) The Nuxt CLI loads a single file named `.env` from the project root into `process.env` before any module runs. There is no multi-file cascade (no `.env.local` or `.env.production`), and there is no supported way to turn it off; the `--dotenv` flag only points it at a different single file. The simple fix: don’t keep a file named `.env` at all. Varlock’s conventions already point this way, with your schema and defaults in `.env.schema` and local overrides in `.env.local` (plus env-specific files like `.env.production`). None of those filenames are touched by Nuxt’s loader, so varlock stays the single source of truth. If you do keep a plain `.env` around, two details matter: * Nuxt’s `.env` values never override env vars that are already set in the process. If you launch through `varlock run`, varlock’s resolved values win. * Keys managed by varlock stay consistent, but any key varlock does not manage still lands in `process.env`, so `process.env.X` and `ENV.X` can diverge for it. ### Nuxt config timing [Section titled “Nuxt config timing”](#nuxt-config-timing) Nuxt evaluates `nuxt.config.ts` before module setup runs. Because of that, `ENV` from `varlock/env` is not initialized there by the module itself. If you need env values in `nuxt.config.ts`, import `varlock/auto-load` at the top of the config file. It loads and validates your env before the rest of the file evaluates, so you get the typed `ENV` object. This is for settings Nuxt or other modules only read at config time, like `app.baseURL`, `vite`/`nitro` options, or another module’s options: nuxt.config.ts ```ts import 'varlock/auto-load'; import { ENV } from 'varlock/env'; export default defineNuxtConfig({ modules: ['@varlock/nuxt-integration', '@nuxtjs/sitemap'], app: { baseURL: ENV.APP_BASE_PATH, }, site: { url: ENV.PUBLIC_SITE_URL, }, }) ``` In dev, the module re-resolves your env at the start of every restart it triggers, so config-time values stay in sync when env files change (see [Dev server behavior](#dev-server-behavior)). TypeScript config The module registers your generated types file with Nuxt’s TypeScript setup automatically, covering app code, server routes, and `nuxt.config.ts`. (Nuxt 4 splits its generated tsconfigs per context, and none of them include a root-level `env.d.ts` on their own.) It looks for `shared/env.d.ts`, then `env.d.ts`, relative to the project root. If you generate types to a custom path, point the module at it: nuxt.config.ts ```ts export default defineNuxtConfig({ modules: ['@varlock/nuxt-integration'], varlock: { envTypesPath: 'types/my-env.d.ts', }, }) ``` ### Within other scripts [Section titled “Within other scripts”](#within-other-scripts) You can use [`varlock run`](/reference/cli/load-and-run/#run) to inject resolved config into other scripts as regular env vars. * npm ```bash npm exec -- varlock run -- node ./script.js ``` * pnpm ```bash pnpm exec -- varlock run -- node ./script.js ``` * bun ```bash bunx varlock run -- node ./script.js ``` * vlt ```bash vlx -- varlock run -- node ./script.js ``` * yarn ```bash yarn exec -- varlock run -- node ./script.js ``` ### Type-safety and IntelliSense [Section titled “Type-safety and IntelliSense”](#type-safety-and-intellisense) To enable type-safety and IntelliSense for your env vars, enable the [`@generateTsTypes` root decorator](/reference/root-decorators/#generatetstypes) in your `.env.schema`. Note that if your schema was created using `varlock init`, it will include this by default. .env.schema ```diff +# @generateTsTypes(path='env.d.ts') # --- # your config items... ``` *** ## Public dynamic env endpoint [Section titled “Public dynamic env endpoint”](#public-dynamic-env-endpoint) When the schema contains any public+dynamic keys, the module automatically injects a server route at `/__varlock/public-env` that serves them to the browser. If browser code needs one of these values, load it with `loadPublicDynamicEnv()` before reading `ENV.KEY`; this is rarely needed, but when it is, see [Loading dynamic vars in the client](/guides/dynamic-config/#loading-dynamic-vars-in-the-client). You can control the injection through the module options: nuxt.config.ts ```ts export default defineNuxtConfig({ modules: ['@varlock/nuxt-integration'], varlock: { publicDynamicEndpoint: { path: '/api/public-env' }, // auto by default }, }) ``` * `undefined` (default): injected only when public+dynamic config items exist * `true` / `false`: force it on or off * `{ path }`: custom route path; the module also points the client runtime at it, so `loadPublicDynamicEnv()` works without passing `endpoint` *** ## Dev server behavior [Section titled “Dev server behavior”](#dev-server-behavior) The module watches every env file varlock loads, including `.env.schema`, and restarts the dev server when one changes. This keeps everything in sync: inlined page values, server routes, the Nitro side, and values captured in `nuxt.config.ts` itself (the module re-resolves env at the start of every dev-server reload, before the config re-evaluates, including reloads triggered by editing `nuxt.config.ts` directly). Nuxt’s own watcher only reacts to a plain `.env` file. If your config becomes invalid (a failed validation, a syntax error in a schema file), requests show an error page describing the problem, and the server recovers automatically once you fix and save the file. *** ## Managing multiple environments [Section titled “Managing multiple environments”](#managing-multiple-environments) Varlock can load multiple *environment-specific* `.env` files (e.g., `.env.development`, `.env.preview`, `.env.production`) by using the [`@currentEnv` root decorator](/reference/root-decorators/#currentenv). This is different from Nuxt’s default behavior, which relies on its own [environment overrides](https://nuxt.com/docs/getting-started/configuration#environment-overrides). .env.schema ```env-spec # @currentEnv=$APP_ENV # --- # @type=enum(development, preview, production, test) APP_ENV=development ``` This will cause varlock to automatically load `.env.development`, `.env.preview`, etc., based on the value of `APP_ENV` at load time. *** ## SSR injection modes [Section titled “SSR injection modes”](#ssr-injection-modes) When building a Nuxt application with server-side rendering, varlock injects initialization code into both the Vite SSR bundle and the Nitro server bundle. That init call is what makes `ENV` available to your server routes, and what installs log redaction and response leak prevention. The `ssrInjectMode` option controls where the resolved values come from: * `'init-only'` (default): initialize from env vars already present in the server process. Your deploy has to supply them, either by launching through `varlock run` or by setting them on the platform. * `'auto-load'`: the server loads varlock itself on startup. Use this for Node.js hosting where you run `node .output/server/index.mjs` directly and your `.env` files ship alongside the build. * `'resolved-env'`: bake the resolved values into the build artifact. Use this on platforms that build and run in separate steps with no `.env` files at runtime, such as Vercel or Netlify. See [encrypted deployments](/guides/encrypted-deployments/) before shipping sensitive values this way. nuxt.config.ts ```ts export default defineNuxtConfig({ modules: ['@varlock/nuxt-integration'], varlock: { ssrInjectMode: 'auto-load', }, }) ``` With the default `'init-only'` mode, start the built server through `varlock run`: package.json ```json { "scripts": { "preview": "varlock run -- node .output/server/index.mjs" } } ``` # Other languages > Integrating varlock into other languages To use varlock with other languages, [install the standalone binary](/getting-started/installation/#as-a-standalone-binary) rather than a JS package manager, and run your app under [`varlock run`](/reference/cli/load-and-run/#run). It loads and validates your environment, then runs your command with the resolved values injected into the process. ```bash varlock run -- ``` ## Generated env modules [Section titled “Generated env modules”](#generated-env-modules) Varlock can generate a typed env module from your schema for several languages via per-language [`@generate*Env`](/reference/root-decorators/#code-generation) root decorators (see the [Code generation guide](/guides/code-generation/) for the full picture). Each generated file is a small, self-contained module with no hand-rolled JSON parsing, containing: * a **typed, coerced env** object/type (numbers, booleans, parsed objects, not raw strings) * a **loader** that reads the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob and returns those typed values * a **`SENSITIVE_KEYS`** constant listing which keys hold sensitive values, so you can build your own redaction or leak-scanning It exposes only these primitives (no global singleton or imposed caching), so it stays idiomatic in each language. Pick your language for setup and usage: | Language | Decorator | Guide | | -------- | -------------------- | ------------------------------- | | Python | `@generatePythonEnv` | [Python](/integrations/python/) | | Rust | `@generateRustEnv` | [Rust](/integrations/rust/) | | Go | `@generateGoEnv` | [Go](/integrations/go/) | | PHP | `@generatePhpEnv` | [PHP](/integrations/php/) | | Java | `@generateJavaEnv` | [Java](/integrations/java/) | | C# | `@generateCsharpEnv` | [C#](/integrations/csharp/) | Files are generated automatically on [`varlock load`](/reference/cli/load-and-run/#load) and [`varlock run`](/reference/cli/load-and-run/#run), or explicitly via [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen). The loaders read `__VARLOCK_ENV`, so run your program under [`varlock run`](/reference/cli/load-and-run/#run), which always injects a **plaintext** blob. The generated loaders don’t decrypt, so they don’t support [`@encryptInjectedEnv`](/reference/root-decorators/#encryptinjectedenv) (a JS/SSR build-output feature); if a loader is handed an encrypted blob it fails with a clear message rather than a raw parse error. Note that `varlock run --inject vars` strips the `__VARLOCK_ENV` blob from the child process, so the generated loaders can’t work under it. Use the default injection mode (or `--inject blob`) when your program loads a generated module. ## Languages without a generated module [Section titled “Languages without a generated module”](#languages-without-a-generated-module) For languages without a `@generate*Env` decorator yet (Ruby, Elixir, and others), you have two options, both of which work under `varlock run`: **Read individual env vars.** Every config key is injected as a plain environment variable, so read them the way you normally would (`ENV['KEY']` in Ruby, `os.environ`, `getenv`, etc.). These values are always **strings** (uncoerced), so parse numbers/booleans yourself. **Parse the blob yourself.** For coerced values and metadata, read the [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) environment variable, a JSON object with a `config` map keyed by env var name. Skip entries with no `value` (unset optionals), and use `isSensitive` to build redaction: ```jsonc // shape of __VARLOCK_ENV { "config": { "DB_PORT": { "value": 5432, "isSensitive": false }, "API_KEY": { "value": "…", "isSensitive": true } } } ``` The generated modules above do exactly this, and they’re the fastest path if your language is supported. We’re planning first-class helper libraries and more generated languages. Raw string env vars Individual config keys are also injected as plain environment variables, but those values are always strings (uncoerced). Prefer the generated loader / `__VARLOCK_ENV` when you want coerced types. Keys whose names aren’t valid identifiers (they contain `.` or `-`) are **omitted from the typed value** in Python, Rust, Go, PHP, Java, and C#, since they can’t be a struct field / property. Read them from the raw environment variable instead (they’re still injected, and still listed in `SENSITIVE_KEYS`). TypeScript keeps them, accessible as `ENV['MY-KEY']`. Sensitive values in the blob `__VARLOCK_ENV` includes resolved sensitive values and metadata used by varlock integrations. For interactive shells or workflows where subprocesses inspect the environment, use [`--inject vars`](/reference/cli/load-and-run/#run) and read individual vars (as strings) instead. Note that this also disables the generated loaders, which need the blob. **We are planning deeper integrations with other languages. Want to help us build these? Join our [Discord](https://chat.dmno.dev).** # Integrations Overview > How Varlock integrates with popular frameworks, bundlers, and runtimes Varlock ships with official integrations that wire the CLI, runtime helpers, and framework-specific plugins together so your configuration is validated, type-safe, and protected everywhere it runs. If you are working in a monorepo, start with the [Monorepos guide](/guides/monorepos/) for schema layout, root-level dependency overrides, and CI patterns before picking a per-framework integration below. Integrations let you: * load and validate `.env` files automatically during dev and use them in CI/CD and production * inject config into your code at build or request time * enable runtime protections such as leak prevention, and log redaction ## JavaScript runtimes and frameworks [Section titled “JavaScript runtimes and frameworks”](#javascript-runtimes-and-frameworks) [JavaScript / Node.js ](/integrations/javascript/)Custom toolchains, scripts, and servers [Bun ](/integrations/bun/)Set up Varlock with Bun [Next.js ](/integrations/nextjs/)Drop-in replacement for @next/env [Vite-based ](/integrations/vite/)Vite plugin (also Qwik, React Router) [Astro ](/integrations/astro/)Astro integration on top of the Vite plugin [SvelteKit ](/integrations/sveltekit/)Vite integration, or Cloudflare Workers plugin [TanStack Start ](/integrations/tanstack-start/)Vite integration, or Cloudflare when on Workers [Cloudflare Workers ](/integrations/cloudflare/)Vite plugin or Wrangler vars/secrets [Expo / React Native ](/integrations/expo/)Babel plugin + Metro config wrapper ## Other languages [Section titled “Other languages”](#other-languages) [Python ](/integrations/python/)varlock run + generated typed env module [Rust ](/integrations/rust/)Generated serde-derived env module [Go ](/integrations/go/)Generated env package [PHP ](/integrations/php/)Generated typed env class [Java ](/integrations/java/)Generated typed env class (Jackson) [C# ](/integrations/csharp/)Generated typed env class [Other languages ](/integrations/other-languages/)Generated-module overview for any runtime ## Platforms and tooling [Section titled “Platforms and tooling”](#platforms-and-tooling) [Docker ](/integrations/docker/)Official image, entrypoints, compose, monorepos [GitHub Actions ](/integrations/github-action/)Validate .env.schema in workflows [mise ](/integrations/mise/)Install varlock and wire env into tasks [direnv ](/integrations/direnv/)Load validated env into your shell ## Coming soon [Section titled “Coming soon”](#coming-soon) We’re working on more first-party integrations for popular runtimes, frameworks and hosting platforms. If yours isn’t listed yet, let us know! Request an integration Join us on [Discord](https://chat.dmno.dev) or open a GitHub issue describing your use case so we can prioritize it. # PHP > Use varlock with PHP via a generated, typed env class To use varlock with PHP, [install the standalone binary](/getting-started/installation/#as-a-standalone-binary) and run your app under [`varlock run`](/reference/cli/load-and-run/#run). It loads and validates your env, then injects it into the process. Add [`@generatePhpEnv`](/reference/root-decorators/#generatephpenv) to your schema to generate a small, self-contained class: a readonly `Env` with typed promoted properties, a static `load()` that parses the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob, and a `SENSITIVE_KEYS` constant. .env.schema ```env-spec # @generatePhpEnv(path=Env.php) ``` The file is regenerated automatically on [`varlock load`](/reference/cli/load-and-run/#load) and [`varlock run`](/reference/cli/load-and-run/#run), or explicitly with [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen). Requires **PHP 8.1+** (readonly promoted properties). ## Reading values [Section titled “Reading values”](#reading-values) Call `load()` once, then read fields off the object. Required fields are the plain type, optional ones are nullable (`?type`): ```php DB_PORT; // ?int $debug = $env->DEBUG; // ?bool // SENSITIVE_KEYS is available for your own redaction foreach (Env::SENSITIVE_KEYS as $key) { /* ... */ } ``` ```bash varlock run -- php main.php ``` `load()` throws a clear error if `__VARLOCK_ENV` is missing (e.g. you forgot `varlock run`) or if a required key is absent. ## Sharing one instance [Section titled “Sharing one instance”](#sharing-one-instance) Every `load()` call re-parses the blob. To avoid reloading, bind the loaded instance wherever your app keeps shared services, e.g. your DI container: bootstrap.php ```php $container->instance(Env::class, Env::load()); // resolve Env anywhere, type-hinted ``` No container? A tiny static holder works: ```php final class Config { public static Env $env; } Config::$env = Env::load(); // elsewhere: Config::$env->DB_PORT (?int) ``` ## Composer / PSR-4 [Section titled “Composer / PSR-4”](#composer--psr-4) By default the class is a global `final class Env` loaded via `require_once`. For Composer projects, set `namespace=` and/or `class=` on the decorator and register the file via classmap autoload instead: .env.schema ```env-spec # @generatePhpEnv(path=src/Env.php, namespace="App\Config", class=AppEnv) ``` composer.json ```json { "autoload": { "classmap": ["src/Env.php"] } } ``` Under PHP-FPM, make sure the pool passes `__VARLOCK_ENV` through (`clear_env = no`). Note Every key is also injected as a plain environment variable, so you can read any of them with `getenv()` (raw strings) if you don’t need the typed module. Keys whose names aren’t valid identifiers (they contain `.` or `-`) can’t be properties, so read those from the env var directly. See [Other languages](/integrations/other-languages/) for the shared details on the injected blob, raw env vars, and encryption. # Python > Use varlock with Python via a generated typed env module, Pydantic Settings, or environs To use varlock with Python, [install the standalone binary](/getting-started/installation/#as-a-standalone-binary) and run your app under [`varlock run`](/reference/cli/load-and-run/#run). It loads and validates your env, then injects it into the process. Add [`@generatePythonEnv`](/reference/root-decorators/#generatepythonenv) to your schema to generate a small, self-contained module: a coerced `Env` [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict), a `load_env()` function that parses the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob, and a `SENSITIVE_KEYS` constant. .env.schema ```env-spec # @generatePythonEnv(path=env.py) ``` The file is regenerated automatically on [`varlock load`](/reference/cli/load-and-run/#load) and [`varlock run`](/reference/cli/load-and-run/#run), or explicitly with [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen). It has no dependencies, importing it has no side effects, and it imports on any **Python 3.7+** (it uses `from __future__ import annotations`, so `NotRequired`/`Literal` are type-checker-only). ## Reading values [Section titled “Reading values”](#reading-values) Call `load_env()` once, then read keys off the dict. Values are coerced (`int`/`bool`/etc.), not raw strings: ```python from env import load_env, SENSITIVE_KEYS env = load_env() # call once, hold or pass it around port = env["DB_PORT"] # int debug = env["DEBUG"] # bool # build your own redaction using SENSITIVE_KEYS safe = {k: ("***" if k in SENSITIVE_KEYS else v) for k, v in env.items()} ``` ```bash varlock run -- python main.py ``` `load_env()` raises a clear error if `__VARLOCK_ENV` is missing (e.g. you forgot `varlock run`). Optional keys that are unset are absent from the dict (`NotRequired`), not present as `None`. ## Sharing one instance [Section titled “Sharing one instance”](#sharing-one-instance) Every `load_env()` call re-parses the blob. To avoid reloading, load once in a config module and share it, the idiomatic “settings module” pattern: config.py ```python from env import load_env env = load_env() # loaded once, typed as Env ``` anywhere.py ```python from config import env port = env["DB_PORT"] # int ``` ## Running your server [Section titled “Running your server”](#running-your-server) [`varlock run`](/reference/cli/load-and-run/#run) wraps any command, so boot your ASGI server, task runner, or scripts through it. Local dev, CI, and production all use the same schema: ```bash varlock run -- uvicorn main:app --reload varlock run -- poetry run pytest varlock run -- python manage.py runserver ``` ### Auto-invoking `varlock run` from Python [Section titled “Auto-invoking varlock run from Python”](#auto-invoking-varlock-run-from-python) Some scripts re-exec themselves under `varlock run` so callers do not have to wrap every invocation. Two things to get right: check [`__VARLOCK_RUN`](/reference/reserved-variables/#__varlock_run) first so you do not recurse, and pick the interpreter path deliberately rather than passing `sys.executable`. ```python import os import sys if "__VARLOCK_RUN" not in os.environ: if sys.prefix != sys.base_prefix: bin_dir = "Scripts" if os.name == "nt" else "bin" exe = "python.exe" if os.name == "nt" else "python3" python = os.path.join(sys.prefix, bin_dir, exe) else: python = sys.executable os.execvp("varlock", ["varlock", "run", "--", python] + sys.argv) ``` Use `os.execvp` so the `varlock` binary is resolved from `PATH`. `os.execv` needs an absolute path, so it fails for a normal install. The interpreter path matters because `varlock run` passes through whatever executable path it is given. On Linux, `sys.executable` is usually the venv interpreter, so passing it works. On macOS with Homebrew Python, `sys.executable` is the fully resolved Cellar binary, not the venv symlink: re-execing that path means PEP 405 never finds `pyvenv.cfg`, the child leaves the venv, and the script fails in ways that look like a varlock problem. Rebuilding the path from `sys.prefix` keeps the child inside the venv on both platforms. Use `sys.prefix`, not `VIRTUAL_ENV`. `sys.prefix != sys.base_prefix` is [Python’s own check](https://docs.python.org/3/library/venv.html#how-venvs-work) for whether the *running* interpreter is in a venv, while `VIRTUAL_ENV` only reflects what the shell activated. The two disagree whenever a script is invoked through another venv’s interpreter directly, and following `VIRTUAL_ENV` there would re-exec into the wrong environment. Shell expansion Environment variables in the command itself are expanded by your shell **before** varlock runs. Pass overrides on the left (`APP_ENV=production varlock run -- …`) or use [`varlock printenv`](/reference/cli/load-and-run/#printenv) to read resolved values into the shell. ## Using a settings library [Section titled “Using a settings library”](#using-a-settings-library) If you’d rather keep the settings library you already use, every key is also injected as a plain environment variable, so [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/), [environs](https://github.com/sloria/environs), and friends work unchanged. They read the **raw string** values (e.g. `"5432"`, `"true"`), so your settings class is what coerces them: settings.py ```python from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(extra="ignore") app_env: str database_url: str openai_api_key: str settings = Settings() ``` config.py ```python from environs import Env env = Env() # Do not call env.read_env(); varlock run already populated os.environ DATABASE_URL = env.str("DATABASE_URL") DEBUG = env.bool("DEBUG", default=False) ``` Skip `.env` file loading in Python When using varlock, don’t also load `.env` from Python (`python-dotenv`, Pydantic’s `env_file=`, etc.) unless you intentionally want a second source of truth. Varlock is the single resolver: your schema, plugins, and environment files all flow through it before Python starts. Note Every key is also injected as a plain environment variable, so you can read any of them with `os.environ` (raw strings) if you don’t need the typed module. Keys whose names aren’t valid identifiers (they contain `.` or `-`) can’t be `TypedDict` fields, so read those from the env var directly. See [Other languages](/integrations/other-languages/) for the shared details on the injected blob, raw env vars, and encryption. # Rust > Use varlock with Rust via a generated, serde-derived env module To use varlock with Rust, [install the standalone binary](/getting-started/installation/#as-a-standalone-binary) and run your app under [`varlock run`](/reference/cli/load-and-run/#run). It loads and validates your env, then injects it into the process. Add [`@generateRustEnv`](/reference/root-decorators/#generaterustenv) to your schema to generate a small, self-contained module: a serde-derived `Env` struct, a `load()` function that parses the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob, and a `SENSITIVE_KEYS` constant. .env.schema ```env-spec # @generateRustEnv(path=src/env.rs) ``` The file is regenerated automatically on [`varlock load`](/reference/cli/load-and-run/#load) and [`varlock run`](/reference/cli/load-and-run/#run), or explicitly with [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen). It needs `serde` and `serde_json`: ```bash cargo add serde --features derive cargo add serde_json ``` ## Reading values [Section titled “Reading values”](#reading-values) Call `load()` once, then read fields off the struct. Required fields are the plain type, optional ones are `Option`: ```rust mod env; fn main() -> Result<(), Box> { let e = env::load()?; // call once, hold or pass `e` around let host = e.db_host; // String (required field) if let Some(port) = e.db_port { // Option (optional field) println!("{host}:{port}"); } Ok(()) } ``` ```bash varlock run -- cargo run ``` `load()` returns a clear error if `__VARLOCK_ENV` is missing (e.g. you forgot `varlock run`) or if a required key is absent. The struct derives a **redacting `Debug`**: sensitive fields print as ``, so `{:?}` won’t leak secrets. ## Sharing one instance [Section titled “Sharing one instance”](#sharing-one-instance) Every `load()` call re-parses the blob. If you want a process-wide instance, opt into a `LazyLock` yourself (loaded once, on first access; `LazyLock` needs Rust 1.80+, or use `once_cell::sync::Lazy` on older toolchains): config.rs ```rust use std::sync::LazyLock; use crate::env; pub static ENV: LazyLock = LazyLock::new(|| env::load().expect("failed to load env")); ``` anywhere.rs ```rust use crate::config::ENV; let port = ENV.db_port; // Option ``` Prefer no globals? Call `env::load()?` once in `main` and pass `&Env` through your app state. The type comes along either way. Note Every key is also injected as a plain environment variable, so you can read any of them with `std::env::var` (raw strings) if you don’t need the typed module. Enums become plain `String` (Rust has no string-literal type), and keys whose names aren’t valid identifiers (they contain `.` or `-`) can’t be struct fields, so read those from the env var directly. See [Other languages](/integrations/other-languages/) for the shared details on the injected blob, raw env vars, and encryption. # SvelteKit > How to integrate varlock with SvelteKit for secure, type-safe environment management [SvelteKit](https://svelte.dev/docs/kit) is built on [Vite](https://vite.dev), so there’s no dedicated SvelteKit package. You use the [Vite integration](/integrations/vite/) the same way regardless of where you deploy. Add `varlockVitePlugin()` to your Vite config ([setup below](#setup)) and it works across deploy targets. If you deploy to **Cloudflare Workers** (via [`@sveltejs/adapter-cloudflare`](https://svelte.dev/docs/kit/adapter-cloudflare)), the plugin detects the adapter and automatically wires up the runtime env-loader. You just need `@varlock/cloudflare-integration` installed as well (it ships `varlock-wrangler`). See [Cloudflare Workers setup](#setup-for-cloudflare-workers). Check out the [SvelteKit example project](https://github.com/dmno-dev/varlock-examples/tree/main/examples/sveltekit) for a working reference. *** ## Setup [Section titled “Setup”](#setup) [![](https://img.shields.io/npm/v/@varlock/vite-integration?label=%40varlock%2Fvite-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/vite-integration) Use `varlockVitePlugin` exactly as you would in any Vite project. This is the setup for every deploy target. See [Cloudflare Workers](#setup-for-cloudflare-workers) below for the one extra package CF deployments need. 1. **Install packages** * npm ```bash npm install @varlock/vite-integration varlock ``` * pnpm ```bash pnpm add @varlock/vite-integration varlock ``` * bun ```bash bun add @varlock/vite-integration varlock ``` * yarn ```bash yarn add @varlock/vite-integration varlock ``` * vlt ```bash vlt install @varlock/vite-integration varlock ``` 2. **Run `varlock init` to set up your `.env.schema`** * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Add the plugin to your Vite config.** It should come **before** `sveltekit()`: vite.config.ts ```diff import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +import { varlockVitePlugin } from '@varlock/vite-integration'; export default defineConfig({ plugins: [ +varlockVitePlugin(), sveltekit(), ], }); ``` Next steps See the [**Vite integration page**](/integrations/vite/) for configuration options, SSR injection modes, accessing env vars, managing environments, sensitive values, and more. ## Setup for Cloudflare Workers [Section titled “Setup for Cloudflare Workers”](#setup-for-cloudflare-workers) [![](https://img.shields.io/npm/v/@varlock/cloudflare-integration?label=%40varlock%2Fcloudflare-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/cloudflare-integration) For SvelteKit projects deploying to Cloudflare Workers via [`@sveltejs/adapter-cloudflare`](https://svelte.dev/docs/kit/adapter-cloudflare), use the same `varlockVitePlugin()` from the [setup above](#setup). When it detects the Cloudflare adapter, it automatically injects the runtime env-loader into SvelteKit’s SSR entry so the resolved env is available inside the worker. You just need to additionally install `@varlock/cloudflare-integration`, which provides `varlock-wrangler` and the Cloudflare-specific loader the plugin pulls in. (The standard `varlockCloudflareVitePlugin` isn’t used here because `@cloudflare/vite-plugin` doesn’t currently support SvelteKit. See [cloudflare/workers-sdk#8922](https://github.com/cloudflare/workers-sdk/issues/8922).) The adapter is detected whether you configure it in `svelte.config.js` or inline in your Vite config (SvelteKit ≥ 2.62), so no extra setup is needed either way. Static-only SvelteKit sites If your SvelteKit app is fully static (no SSR, typically using `@sveltejs/adapter-static` and deployed to Cloudflare Pages or any CDN), you don’t need `@varlock/cloudflare-integration` at all. The [base setup](#setup) is enough, since there’s no server bundle for us to inject into. ### Setup [Section titled “Setup”](#setup-1) 1. **Install packages** * npm ```bash npm install @varlock/vite-integration @varlock/cloudflare-integration varlock ``` * pnpm ```bash pnpm add @varlock/vite-integration @varlock/cloudflare-integration varlock ``` * bun ```bash bun add @varlock/vite-integration @varlock/cloudflare-integration varlock ``` * yarn ```bash yarn add @varlock/vite-integration @varlock/cloudflare-integration varlock ``` * vlt ```bash vlt install @varlock/vite-integration @varlock/cloudflare-integration varlock ``` 2. **Run `varlock init` to set up your `.env.schema`** * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Add the plugin to your Vite config.** This is the same as the base setup; it auto-detects the Cloudflare adapter: vite.config.ts ```diff import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +import { varlockVitePlugin } from '@varlock/vite-integration'; export default defineConfig({ plugins: [ +varlockVitePlugin(), sveltekit(), ], }); ``` 4. **Deploy with `varlock-wrangler`** Use `varlock-wrangler deploy` instead of `wrangler deploy` in your deploy script: package.json ```json { "scripts": { "dev": "vite dev", "build": "vite build", "deploy": "npm run build && varlock-wrangler deploy" } } ``` If you deploy via [Cloudflare Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) instead of a local/CI script, override the deploy command in your Cloudflare dashboard under **Settings → Build → Deploy command**. See [Workers Builds](#deploying-via-cloudflare-workers-builds) below. ### How it works [Section titled “How it works”](#how-it-works) * **In dev**: `vite dev` runs SvelteKit’s dev server; varlock resolves env via its normal flow and makes it available on `ENV.*`. * **In production**: The SvelteKit SSR bundle has a `cloudflare:workers` runtime loader injected at the top of its server entry. At worker boot, it reads the `__VARLOCK_ENV` binding and hydrates varlock’s runtime. The loader is guarded by a `navigator.userAgent === 'Cloudflare-Workers'` check so SvelteKit’s Node-side postbuild steps (prerender, fallback) don’t try to resolve `cloudflare:workers`. * `varlock-wrangler deploy` uploads non-sensitive values as Cloudflare vars and sensitive values as secrets. ### Deploying via Cloudflare Workers Builds [Section titled “Deploying via Cloudflare Workers Builds”](#deploying-via-cloudflare-workers-builds) If you deploy through [Cloudflare Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), two pieces of configuration must be set in the dashboard, and neither can be committed to the repo: * **Override the Deploy command.** Under **Settings → Build → Deploy command**, replace the default `npx wrangler deploy` with `npx varlock-wrangler deploy`. Without this, Cloudflare runs stock `wrangler deploy`, which skips varlock resolution and leaves your worker without its resolved vars/secrets. * **Set any *secret-zero* vars under Build variables.** Any env vars varlock itself needs *during load* (e.g. a 1Password service account token, a GCP key) must be set under **Settings → Build → Variables and Secrets** so they’re available at build time. `varlock-wrangler deploy` then resolves your full env graph and uploads the result to the worker runtime as regular vars/secrets. Next steps See the [**Cloudflare integration page**](/integrations/cloudflare/) for more on `varlock-wrangler`, multi-environment setup, and [Workers Builds configuration](/integrations/cloudflare/#cloudflare-workers-builds-cicd). *** ## Dynamic+public config [Section titled “Dynamic+public config”](#dynamicpublic-config) SvelteKit uses Vite, so dynamic/public behavior comes from varlock core + the Vite integration. Use `@dynamic` for public values that should not be inlined into client bundles; server-side reads (`load` functions, endpoints) keep using `ENV.KEY` as usual (values resolve at server boot), and prerendered routes should only use static values. If **browser code** needs one of these values, it must be loaded from the backend after build; see [Loading dynamic vars in the client](/guides/dynamic-config/#loading-dynamic-vars-in-the-client). ## Migrating from SvelteKit’s `$env/*` [Section titled “Migrating from SvelteKit’s $env/\*”](#migrating-from-sveltekits-env) SvelteKit ships [four built-in env modules](https://svelte.dev/docs/kit/$env-static-private) (`$env/static/private`, `$env/static/public`, `$env/dynamic/private`, `$env/dynamic/public`) which split env vars along two axes: *static vs dynamic* (inlined at build vs looked up at runtime) and *private vs public* (server-only vs bundled for the browser, gated by the `PUBLIC_` prefix). The key conceptual shift is that SvelteKit’s system is **access-driven**: the same underlying variable behaves differently depending on which module you import it from, and public/private is inferred from the variable’s name. Varlock is **schema-driven**: each item is declared once in `.env.schema` with decorators that determine its behavior (sensitivity, type, validation, whether it’s inlined or resolved at runtime), and every access site uses the same `ENV` object. You describe the variable once, and that description is authoritative everywhere it’s used. Varlock replaces all four `$env/*` modules with a single `ENV` object from `varlock/env`. The same two axes still exist, but they’re controlled by the schema rather than the import path: | SvelteKit concept | Varlock equivalent | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `$env/static/public` | `ENV.FOO` where `@sensitive=false`; non-sensitive values are inlined at build time on both client and server | | `$env/static/private` | `ENV.FOO` where `@sensitive=true`, referenced from SSR/server code; inlined into the server bundle, build errors if referenced from client code | | `$env/dynamic/public` | `ENV.FOO` where `@dynamic` + non-sensitive; loaded at runtime for client usage via `loadPublicDynamicEnv()` (see [above](#dynamicpublic-config)) | | `$env/dynamic/private` | `ENV.FOO`; in server contexts it’s read from the live runtime env (e.g. Cloudflare bindings via `varlockCloudflareVitePlugin`, or Node’s `process.env` elsewhere) rather than baked into the bundle | | `PUBLIC_` prefix requirement | Per-item [`@sensitive`](/reference/item-decorators/#sensitive) decorator, or a schema-wide [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive) rule | ### Migrating imports [Section titled “Migrating imports”](#migrating-imports) Everywhere you currently import from `$env/*`, switch to `varlock/env`: src/routes/+page.server.ts ```diff -import { API_KEY } from '$env/static/private'; -import { PUBLIC_API_URL } from '$env/static/public'; +import { ENV } from 'varlock/env'; export const load = async () => { const res = await fetch(`${PUBLIC_API_URL}/data`, { headers: { Authorization: `Bearer ${API_KEY}` }, const res = await fetch(`${ENV.PUBLIC_API_URL}/data`, { headers: { Authorization: `Bearer ${ENV.API_KEY}` }, }); }; ``` `ENV` works from both `+page.svelte` (client) and `+page.server.ts`/`+server.ts` (server); there’s no need to import from different paths depending on context. Varlock enforces the public/private boundary with both **build-time** checks (sensitive values are never included in the client bundle, and referencing one from client-reachable code surfaces as a build error) and **runtime** checks (log redaction and leak detection in server responses) so a mistake in either layer is caught rather than silently exposing a secret. ### Managing sensitive values [Section titled “Managing sensitive values”](#managing-sensitive-values) SvelteKit uses the `PUBLIC_` prefix to determine which vars are safe to bundle for the browser. Varlock uses decorators, either per-item or schema-wide via [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive). The simplest setup is to mark items explicitly: .env.schema ```diff +# @defaultSensitive=true # --- API_KEY= # sensitive by default # @sensitive=false PUBLIC_API_URL= # explicitly non-sensitive (bundled for browser) ``` If you’d rather keep SvelteKit’s `PUBLIC_`-prefix convention, you can infer sensitivity from the name: .env.schema ```diff +# @defaultSensitive=inferFromPrefix('PUBLIC_') # --- API_KEY= # sensitive (no prefix) PUBLIC_API_URL= # non-sensitive (has PUBLIC_ prefix) ``` Bundling behavior All non-sensitive items are replaced at build time wherever `ENV.FOO` appears. Sensitive items are only available in server contexts: build-time checks block them from client bundles, and runtime log redaction / response leak detection catch any that slip past the build layer. Encrypting the env blob When deploying SSR SvelteKit apps to serverless platforms, varlock injects the resolved env into your server-side build output as plaintext JSON. This is generally safe since it only appears in server code, but you can encrypt it for extra protection (e.g., against sourcemap leaks). See the [encrypted deployments guide](/guides/encrypted-deployments/) for setup instructions. # TanStack Start > How to integrate varlock with TanStack Start for secure, type-safe environment management [TanStack Start](https://tanstack.com/start) is a full-stack React framework built on [Vite](https://vite.dev), so there’s no dedicated TanStack Start package. Which integration you use depends on where you deploy. * **Node / Vercel / Netlify / self-hosted**: use the [**Vite integration**](/integrations/vite/). See [setup below](#setup). * **Cloudflare Workers**: use the [**Cloudflare integration**](/integrations/cloudflare/). See [Cloudflare setup below](#deploying-to-cloudflare-workers). Check out the [TanStack Start example project](https://github.com/dmno-dev/varlock-examples/tree/main/examples/tanstack-start) for a working reference. *** ## Setup (vite integration) [Section titled “Setup (vite integration)”](#setup-vite-integration) [![](https://img.shields.io/npm/v/@varlock/vite-integration?label=%40varlock%2Fvite-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/vite-integration) For any deployment target *other than* Cloudflare Workers, use `varlockVitePlugin` from the [Vite integration](/integrations/vite/). 1. **Install packages** * npm ```bash npm install @varlock/vite-integration varlock ``` * pnpm ```bash pnpm add @varlock/vite-integration varlock ``` * bun ```bash bun add @varlock/vite-integration varlock ``` * yarn ```bash yarn add @varlock/vite-integration varlock ``` * vlt ```bash vlt install @varlock/vite-integration varlock ``` 2. **Run `varlock init` to set up your `.env.schema`** This will guide you through setting up your `.env.schema` file, based on your existing `.env` file(s). Make sure to review it carefully. * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Add the plugin to your Vite config** app.config.ts ```diff import { defineConfig } from '@tanstack/react-start/config'; import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { varlockVitePlugin } from '@varlock/vite-integration'; export default defineConfig({ vite: { plugins: [ +varlockVitePlugin(), tanstackStart(), // ... other plugins ], }, }); ``` Next steps See the [**Vite integration page**](/integrations/vite/) for configuration options, SSR injection modes, accessing env vars, managing environments, sensitive values, and more. *** ## Setup for Cloudflare Workers [Section titled “Setup for Cloudflare Workers”](#setup-for-cloudflare-workers) [![](https://img.shields.io/npm/v/@varlock/cloudflare-integration?label=%40varlock%2Fcloudflare-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/cloudflare-integration) If you’re deploying your TanStack Start app to Cloudflare Workers, use `varlockCloudflareVitePlugin` from the [Cloudflare integration](/integrations/cloudflare/) instead. 1. **Install packages** You must also install `@cloudflare/vite-plugin` as a peer dependency. * npm ```bash npm install @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` * pnpm ```bash pnpm add @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` * bun ```bash bun add @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` * yarn ```bash yarn add @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` * vlt ```bash vlt install @varlock/cloudflare-integration @cloudflare/vite-plugin varlock ``` 2. **Run `varlock init` to set up your `.env.schema`** * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Add the Cloudflare Vite plugin** app.config.ts ```diff import { defineConfig } from '@tanstack/react-start/config'; import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { varlockCloudflareVitePlugin } from '@varlock/cloudflare-integration'; export default defineConfig({ vite: { plugins: [ +varlockCloudflareVitePlugin(), tanstackStart(), // ... ], }, }); ``` 4. **Deploy with `varlock-wrangler`** Use `varlock-wrangler deploy` instead of `wrangler deploy` in your deploy script: package.json ```json { "scripts": { "deploy": "varlock-wrangler deploy" } } ``` If you deploy via [Cloudflare Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) instead of a local/CI script, override the deploy command in your Cloudflare dashboard under **Settings → Build → Deploy command**. See [Workers Builds](#deploying-via-cloudflare-workers-builds) below. Next steps See the [**Cloudflare integration page**](/integrations/cloudflare/) for more on `varlock-wrangler`, multi-environment setup, and deploying secrets. ### Deploying via Cloudflare Workers Builds [Section titled “Deploying via Cloudflare Workers Builds”](#deploying-via-cloudflare-workers-builds) If you deploy through [Cloudflare Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), two pieces of configuration must be set in the dashboard, and neither can be committed to the repo: * **Override the Deploy command.** Under **Settings → Build → Deploy command**, replace the default `npx wrangler deploy` with `npx varlock-wrangler deploy`. Without this, Cloudflare runs stock `wrangler deploy`, which skips varlock resolution and leaves your worker without its resolved vars/secrets. * **Set any *secret-zero* vars under Build variables.** Any env vars varlock itself needs *during load* (e.g. a 1Password service account token, a GCP key) must be set under **Settings → Build → Variables and Secrets** so they’re available at build time. `varlock-wrangler deploy` then resolves your full env graph and uploads the result to the worker runtime as regular vars/secrets. See the [Cloudflare integration page](/integrations/cloudflare/) for more detail on `varlock-wrangler` and [Workers Builds configuration](/integrations/cloudflare/#cloudflare-workers-builds-cicd). *** ## Dynamic+public config [Section titled “Dynamic+public config”](#dynamicpublic-config) TanStack Start uses the Vite integration, so static/dynamic behavior works the same as in any Vite app: public values are inlined at build time unless marked [`@dynamic`](/reference/item-decorators/#dynamic), and server code keeps using `ENV.KEY` at runtime. For browser access to a public+dynamic value, add a server route that returns `getPublicDynamicEnv()` and hydrate with `loadPublicDynamicEnv()`. See the [TanStack Start recipe in the guide](/guides/dynamic-config/#loading-from-a-server-endpoint). ## Compared to TanStack Start’s built-in env var handling [Section titled “Compared to TanStack Start’s built-in env var handling”](#compared-to-tanstack-starts-built-in-env-var-handling) TanStack Start inherits [Vite’s env var approach](https://tanstack.com/start/v0/docs/framework/react/guide/environment-variables): `process.env` on the server and `import.meta.env` on the client, with only `VITE_`-prefixed vars exposed client-side. Their docs recommend maintaining a manual `src/env.d.ts` for TypeScript support and separate Zod schemas for runtime validation. With varlock, your `.env.schema` is the single source of truth. It handles validation, type coercion, TypeScript generation, sensitivity controls, and more, replacing the need for separate type declarations, Zod schemas, and the `VITE_` prefix convention. You also get leak detection (sensitive values are automatically scrubbed from outgoing HTTP responses) and clear error messages when env vars are missing or invalid, so no more silent `undefined` values at runtime. See the [Vite integration page](/integrations/vite/) for the full details. Encrypting the env blob When deploying SSR TanStack Start apps to serverless platforms, varlock injects the resolved env into your server-side build output as plaintext JSON. This is generally safe since it only appears in server code, but you can encrypt it for extra protection (e.g., against sourcemap leaks). See the [encrypted deployments guide](/guides/encrypted-deployments/) for setup instructions. # Vite > How to integrate varlock with Vite for secure, type-safe environment management [![](https://img.shields.io/npm/v/@varlock/vite-integration?label=%40varlock%2Fvite-integration\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/vite-integration) Some frameworks use Vite under the hood, and some projects use Vite directly. Either way, often there is some [automatic loading of .env files](https://vite.dev/guide/env-and-mode.html) happening, but it is fairly limited. To integrate varlock into a Vite-powered application, you must use our [`@varlock/vite-integration`](https://npmx.dev/package/@varlock/vite-integration) package, which is a [Vite plugin](https://vite.dev/guide/using-plugins.html). This plugin does a few things: * Loading and validating your .env files using varlock, injecting resolved env into process.env at build/dev time * Simplifies using env vars within your `vite.config.*` file * Build time replacements of `ENV.xxx` of non-sensitive items (no prefix required) * Within SSR contexts, injecting additional initialization code and enabling additional [security features](https://varlock.dev/guides/secrets/#security-enhancements) Astro users For [Astro](https://astro.build) - which is also powered by Vite - you should use our [Astro integration](/integrations/astro/). SvelteKit users For [SvelteKit](https://svelte.dev/docs/kit), the plain Vite integration below works for most adapters. If you’re deploying to Cloudflare Workers via `@sveltejs/adapter-cloudflare`, see our [SvelteKit guide](/integrations/sveltekit/), which points you at the Cloudflare-specific plugin. Deploying a non-static site to Cloudflare Workers? If your project has an SSR/server component that runs on Cloudflare Workers, the plain Vite integration won’t wire up runtime secrets correctly: secrets would end up bundled into the worker instead of being stored as Cloudflare secrets. Use the [Cloudflare integration](/integrations/cloudflare/) (or the [SvelteKit guide](/integrations/sveltekit/) for SvelteKit) instead. This page is fine for **static-only** Vite sites. ## Frameworks that use Vite [Section titled “Frameworks that use Vite”](#frameworks) If you’re using [Qwik](https://qwik.dev/) or [React Router](https://reactrouter.com/)/[Remix](https://remix.run/), you can follow the Vite instructions below. If you’re using [Cloudflare Workers](https://developers.cloudflare.com/workers/) with the [Cloudflare Vite plugin](https://developers.cloudflare.com/workers/vite-plugin/), use the [Cloudflare integration](/integrations/cloudflare/) instead of this page. For anything framework-specific on Cloudflare Workers (like Next.js) follow the [integration](/integrations/overview) docs for that framework. ## Setup [Section titled “Setup”](#setup) Requirements * Node.js v22 or higher * Vite v5 or higher 1. **Install varlock and the Vite integration package** * npm ```bash npm install @varlock/vite-integration varlock ``` * pnpm ```bash pnpm add @varlock/vite-integration varlock ``` * bun ```bash bun add @varlock/vite-integration varlock ``` * yarn ```bash yarn add @varlock/vite-integration varlock ``` * vlt ```bash vlt install @varlock/vite-integration varlock ``` 2. **Run `varlock init` to set up your `.env.schema` file** This will guide you through setting up your `.env.schema` file, based on your existing `.env` file(s). Make sure to review it carefully. * npm ```bash npm exec -- varlock init ``` * pnpm ```bash pnpm exec -- varlock init ``` * bun ```bash bunx varlock init ``` * vlt ```bash vlx -- varlock init ``` * yarn ```bash yarn exec -- varlock init ``` 3. **Enable the Vite config plugin** You must add our `varlockVitePlugin` to your `vite.config.*` file: vite.config.ts ```diff import { defineConfig } from 'vite'; +import { varlockVitePlugin } from '@varlock/vite-integration'; export default defineConfig({ plugins: [varlockVitePlugin(), otherPlugin()] }); ``` *** ## Custom .env file location [Section titled “Custom .env file location”](#custom-env-file-location) By default, varlock loads `.env` files from the current working directory. If you store your `.env` files in a custom directory, you can configure this using the `varlock.loadPath` option in your `package.json`: package.json ```json { "varlock": { "loadPath": "./envs/" } } ``` This works for all varlock commands and integrations, not just Vite. The `loadPath` can point to a directory (trailing `/` recommended) or a specific `.env` file. The varlock CLI `--path` flag overrides this setting when provided. Varlock looks for this config in the `package.json` in the current working directory only; it does not walk up to parent directories. Directory vs file path Point `loadPath` to a **directory** if you want varlock to automatically load all relevant files (`.env.schema`, `.env`, `.env.local`, etc.). Pointing to a specific file will only load that file and anything it explicitly imports via `@import`. ### Loading from multiple directories [Section titled “Loading from multiple directories”](#loading-from-multiple-directories) You can also provide an array of paths to combine env vars from multiple locations, useful in monorepos where different packages each have their own `.env` files: package.json ```json { "varlock": { "loadPath": ["./apps/my-package/envs/", "./apps/other-package/envs/"] } } ``` Each path in the array is loaded independently. Paths listed **later** in the array take higher precedence when the same variable is defined in multiple locations. Vite’s `envDir` option is not supported If you are using Vite’s `envDir` option to load `.env` files from a custom directory, note that **varlock ignores this option**. Use `varlock.loadPath` in your `package.json` instead, as shown above. Varlock will show a warning if it detects `envDir` is set. *** ## SSR Code Injection [Section titled “SSR Code Injection”](#ssr-code-injection) Within SSR builds, this plugin will automatically inject varlock initialization code into your entry points. There are 3 modes to choose from and specify during plugin initialization. For example: ```ts varlockVitePlugin({ ssrInjectMode: 'auto-load' }) ``` * `init-only` - injects varlock initialization code, but does not load the env vars. You must still boot your app via `varlock run` in this mode. * `auto-load` - injects `import 'varlock/auto-load';` to load your resolved env via the varlock CLI * `resolved-env` - injects the fully resolved env data into your built code. This is useful in environments like Vercel where you have no control over your build command, and limited access to use CLI commands or the filesystem. See the [encrypting the env blob](#encrypting-the-env-blob) section for more information. **If not specified, we will attempt to infer the correct mode based on the presence of other vite plugins and environment variables, which give us hints about how your application will be run.** Otherwise defaulting to `init-only`. Don’t use `resolved-env` on Cloudflare builds On Cloudflare Workers, production env is injected at runtime from bindings via [`varlock-wrangler`](/integrations/cloudflare/) — not baked into the bundle. If Cloudflare is detected (via `@varlock/cloudflare-integration` or SvelteKit’s `@sveltejs/adapter-cloudflare`) and `ssrInjectMode` is explicitly set to `'resolved-env'` for a production build, the plugin throws rather than silently shipping resolved (and possibly sensitive) values into the worker artifact for no reason. This only applies to builds — some adapters (e.g. Astro’s `@astrojs/cloudflare`) run SSR in workerd during their own dev server with no binding-injection hook available, so `resolved-env` is still used there by default. ## Accessing environment variables [Section titled “Accessing environment variables”](#accessing-environment-variables) You can continue to use `import.meta.env.SOMEVAR` as usual, but we recommend using varlock’s imported `ENV` object for better type-safety and improved developer experience: example.ts ```ts import { ENV } from 'varlock/env'; console.log(import.meta.env.SOMEVAR); // 🆗 still works console.log(ENV.SOMEVAR); // ✨ recommended ``` #### Why use `ENV` instead of `import.meta.env`? [Section titled “Why use ENV instead of import.meta.env?”](#why-use-env-instead-of-importmetaenv) * Non-string values (e.g., number, boolean) are properly typed and coerced * All non-sensitive items are replaced at build time (not just `VITE_` prefixed ones) * Better error messages for invalid or unavailable keys * Enables future DX improvements and tighter control over what is bundled ### Within `vite.config.*` [Section titled “Within vite.config.\*”](#within-viteconfig) It’s often useful to be able to access env vars in your Vite config. Without varlock, it’s a bit awkward, but with varlock it’s already available. Import varlock’s `ENV` object and reference env vars via `ENV.SOME_ITEM` like you do everywhere else. vite.config.ts ```diff import { defineConfig } from 'vite'; import { varlockVitePlugin } from '@varlock/vite-integration'; +import { ENV } from 'varlock/env'; +doSomethingWithEnvVar(ENV.FOO); export default defineConfig({ /* ... */ }); ``` TypeScript config If you find you are not getting type completion on `ENV`, you may need to add your vite config and generated type files (usually `env.d.ts`) to your `tsconfig.json`’s `include` array. ### Within HTML templates [Section titled “Within HTML templates”](#within-html-templates) Vite [natively supports](https://vite.dev/guide/env-and-mode.html#html-constant-replacement) injecting env vars into HTML files using a special syntax like `%SOME_VAR%`. This plugin injects additional replacements for strings like `%ENV.SOME_VAR%`. Note that unlike the native functionality which does not replace missing/non-existant items, we will try to replace all items, and will throw helpful errors if something goes wrong. HTML comments Note that replacements anywhere in the file, including HTML comments, are still attempted and can cause errors. For example `` will still fail! ### Within other scripts [Section titled “Within other scripts”](#within-other-scripts) Even in a static front-end project, you may have other scripts in your project that rely on sensitive config. You can use [`varlock run`](/reference/cli/load-and-run/#run) to inject resolved config into other scripts as regular environment vars. * npm ```bash npm exec -- varlock run -- node ./script.js ``` * pnpm ```bash pnpm exec -- varlock run -- node ./script.js ``` * bun ```bash bunx varlock run -- node ./script.js ``` * vlt ```bash vlx -- varlock run -- node ./script.js ``` * yarn ```bash yarn exec -- varlock run -- node ./script.js ``` ### Type-safety and IntelliSense [Section titled “Type-safety and IntelliSense”](#type-safety-and-intellisense) To enable type-safety and IntelliSense for your env vars, enable the [`@generateTsTypes` root decorator](/reference/root-decorators/#generatetstypes) in your `.env.schema`. Note that if your schema was created using `varlock init`, it will include this by default. .env.schema ```diff +# @generateTsTypes(path='env.d.ts') # --- # your config items... ``` *** ## Managing multiple environments [Section titled “Managing multiple environments”](#managing-multiple-environments) Varlock can load multiple *environment-specific* `.env` files (e.g., `.env.development`, `.env.preview`, `.env.production`) by using the [`@currentEnv` root decorator](/reference/root-decorators/#currentenv). **This is different than Vite’s default behaviour, which relies on it’s own [`MODE` flag](https://vite.dev/guide/env-and-mode.html#modes).** Usually this env var will be defaulted to something like `development` in your `.env.schema` file, and you can override it by overriding the value when running commands - for example `APP_ENV=production vite build`. For a JavaScript based project, this will often be done in your `package.json` scripts. package.json ```json { "scripts": { "dev": "vite dev", "test": "APP_ENV=test vitest", "build": "APP_ENV=production vite build", "preview": "APP_ENV=production vite preview", } } ``` In some cases, you could also set the current environment value based on other vars already injected by your CI platform, like the current branch name. See the [environments guide](/guides/environments) for more information. ## Managing sensitive config values [Section titled “Managing sensitive config values”](#managing-sensitive-config-values) Vite uses the `VITE_` prefix to determine which env vars are public (bundled for the browser). Varlock decouples the concept of being *sensitive* from key names, and instead you control this with the [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive) root decorator and the [`@sensitive`](/reference/item-decorators/#sensitive) item decorator. See the [secrets guide](/guides/secrets) for more information. Set a default and explicitly mark items: .env.schema ```diff +# @defaultSensitive=false # --- NON_SECRET_FOO= # sensitive by default # @sensitive SECRET_FOO= ``` Or if you’d like to continue using Vite’s prefix behavior: .env.schema ```diff +# @defaultSensitive=inferFromPrefix('VITE_') # --- FOO= # sensitive VITE_FOO= # non-sensitive, due to prefix ``` Bundling behavior All non-sensitive items are bundled at build time via `ENV`, while `import.meta.env` replacements continue to only include `VITE_`-prefixed items. *** ## Encrypting the env blob [Section titled “Encrypting the env blob”](#encrypting-the-env-blob) When using `ssrInjectMode: 'resolved-env'`, Varlock injects the fully resolved env data into your SSR build output. By default, this blob is plaintext JSON, meaning anyone with access to the build artifact can read your secrets. You can encrypt it for extra protection (e.g., against sourcemap leaks). See the [encrypted deployments guide](/guides/encrypted-deployments/) for setup instructions. ## Dynamic+public config [Section titled “Dynamic+public config”](#dynamicpublic-config) Use `@dynamic` for public values that should not be inlined into client bundles: .env.schema ```env-spec VITE_STATIC_FLAG=enabled # @public VITE_RUNTIME_FLAG=enabled # @public @dynamic ``` Server-side reads keep working with no extra code (values resolve when the server boots, not per request). If **browser code** needs one of these values, it must be loaded from the backend after build; see [Loading dynamic vars in the client](/guides/dynamic-config/#loading-dynamic-vars-in-the-client) in the guide. With plain Vite, you provide the endpoint in whatever server you pair with it. ## Reference [Section titled “Reference”](#reference) * [Root decorators reference](/reference/root-decorators) * [Item decorators reference](/reference/item-decorators) * [Functions reference](/reference/functions) * [Vite environment variable docs](https://vite.dev/guide/env-and-mode.html) # 1Password Plugin > Using 1Password with Varlock [![](https://img.shields.io/npm/v/@varlock/1password-plugin?label=%40varlock%2F1password-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/1password-plugin) Our [1Password](https://1password.com/) plugin enables secure loading of values from 1Password vaults using declarative instructions within your `.env` files. For local development, it (optionally) supports authenticating using the local 1Password desktop app, including using biometric unlock. Otherwise, it uses a [service account](https://www.1password.dev/service-accounts/) making it suitable for CI/CD and production environments. This plugin is compatible with any 1Password account type (personal, family, teams, business), but note that [rate limits](https://www.1password.dev/service-accounts/rate-limits/) vary by account type. ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/1password-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/1password-plugin) # # 2. Initialize the plugin - see below for more details on options # @initOp(token=$OP_TOKEN, allowAppAuth=forEnv(dev), account=acmeco) # --- # 3. Add a service account token config item (if applicable) # @type=opServiceAccountToken @sensitive @internal OP_TOKEN= ``` ### Vault setup [Section titled “Vault setup”](#vault-setup) If your secrets are already stored in 1Password, you may not need to do anything. However, if secrets live in a vault that holds other sensitive data, you should create a new vault and move your secrets to it, because **the access system of 1Password is based on vaults, not individual items**. You can create multiple vaults to segment access to different environments, services, etc. This can be done using any 1Password app, the web app, or the CLI. [link](https://support.1password.com/create-share-vaults/#create-a-vault) Remember to grant access to necessary team members, particularly if you plan on using the desktop app auth method during local development, as they will be authenticating as themselves. Vault organization best practices Consider how you want to organize your vaults and service accounts, keeping in mind [best practices](https://support.1password.com/business-security-practices/#access-management-and-the-principle-of-least-privilege). At a minimum, we recommend having a vault for highly sensitive production secrets and another for everything else. ### Service account setup (for deployed environments) [Section titled “Service account setup (for deployed environments)”](#service-account-setup-for-deployed-environments) If you plan on using data from 1Password in deployed environments (CI/CD, production, etc), you will need to create a [service account](https://www.1password.dev/service-accounts/get-started/) to allow machine-to-machine authentication. You could also use a service account for local development, although we recommend using the desktop app auth method described below for convenience. This service account token will now serve as your *secret-zero*, granting access to the rest of your sensitive data stored in 1Password. For local-only workflows, you can secure this value by using [device-local encryption](/guides/local-encryption/). 1. **Create a new service account** and grant access to necessary vault(s). This is a special account used for machine-to-machine communication. This can only be done in the 1Password web interface. Be sure to save the new service account token in another vault so you can find it later. [link](https://www.1password.dev/service-accounts/get-started/) Vault access is set during creation only Vault access rules cannot be edited after creation, so if your vault setup changes, you will need to create new service account(s) and update the tokens. 2. **Wire up the service account token in your config**. Add a config item of type `opServiceAccountToken` to hold the token value, and reference it when initializing the plugin. .env.schema ```diff # @plugin(@varlock/1password-plugin) # @initOp(token=$OP_TOKEN) # --- +# @type=opServiceAccountToken @sensitive @internal OP_TOKEN= ``` Service account tokens are `@internal` by default The `opServiceAccountToken` type is marked [`@internal`](/reference/item-decorators/#internal), so the token is used by varlock to fetch your other secrets but is **not** injected into your application, keeping this secret-zero out of your app’s environment. It still shows (redacted) in `varlock load` output so you can debug it. If your app genuinely needs the token directly, opt out with `@internal=false`. 3. **Set your service account token in deployed environments**. Copy the token value from where you saved it earlier, and set it in deployed environments using your platform’s env var management UI. Be sure to use the same name as you defined in your schema (e.g. `OP_TOKEN`). Ensure service account access is enabled Each vault has a toggle to disable service account access *in general*. It is on by default, so you will likely not need to do anything. [link](https://www.1password.dev/service-accounts/manage-service-accounts/) ### CLI-based service account auth (memory-constrained environments) [Section titled “CLI-based service account auth (memory-constrained environments)”](#cli-based-service-account-auth-memory-constrained-environments) By default, service account tokens use the 1Password JavaScript SDK (which bundles a WASM module, \~400 MB RSS). In memory-constrained environments such as 512 MB containers or VMs, this footprint may be prohibitive. Set `useCliWithServiceAccount=true` to use the `op` CLI binary instead of the SDK while still authenticating headlessly via the service account token: .env.schema ```env-spec # @plugin(@varlock/1password-plugin) # @initOp(token=$OP_TOKEN, useCliWithServiceAccount=true) # --- # @type=opServiceAccountToken @sensitive @internal OP_TOKEN= ``` **Requirements:** 1. **Install the `op` CLI**: [Installation guide](https://www.1password.dev/cli/get-started/) 2. The token referenced by `token=` must resolve to a valid service account token at load time. The `op` binary is significantly lighter than the WASM SDK. Authentication is handled headlessly via `OP_SERVICE_ACCOUNT_TOKEN`, with no desktop app or interactive sign-in needed. Multiple plugin instances configured with different service account tokens are fully isolated from one another. ### Desktop app auth (for local dev) [Section titled “Desktop app auth (for local dev)”](#desktop-app-auth-for-local-dev) During local development, you may find it convenient to skip the service account tokens and instead rely on your local 1Password desktop app (via the [CLI integration](https://www.1password.dev/cli/get-started/#step-2-turn-on-the-1password-desktop-app-integration)), including using its biometric unlocking features. 1. **Opt-in while initializing the plugin** .env.schema ```env-spec # @plugin(@varlock/1password-plugin) # @initOp(token=$OP_TOKEN, allowAppAuth=true) ``` You may use other functions to conditionally enable this, for example `forEnv(dev)`. 2. **Specify 1Password account (optional)** .env.schema ```env-spec # @plugin(@varlock/1password-plugin) # @initOp(token=$OP_TOKEN, allowAppAuth=true, account=acmeco) ``` This value is passed through under the `--account` flag to the `op` CLI, and accepts account shorthand, sign-in address, account ID, or user ID. You can run `op account list` to see your available accounts. The shorthand is the subdomain of your `x.1password.com` sign-in address. This is optional, but recommended if you have access to multiple 1Password accounts, to ensure you connect to the correct one. 3. **Ensure the `op` CLI is installed**. [docs](https://www.1password.dev/cli/get-started/) 4. **Enable the desktop app + CLI integration**. [docs](https://www.1password.dev/cli/get-started/#step-2-turn-on-the-1password-desktop-app-integration) With this option enabled, if the resolved service account token is empty, we will call out to the `op` cli installed on your machine (it must be in your `$PATH`) and use the auth it provides. With the desktop app integration enabled, it will call out and may trigger biometric verification to unlock. Connecting as yourself Keep in mind that this method is connecting as *YOU* who likely has more access than a tightly scoped service account. Consider only enabling this method for a plugin instance that will be handling non-production secrets. ## Pulling data from 1Password [Section titled “Pulling data from 1Password”](#pulling-data-from-1password) Once the plugin is installed and initialized, you can start adding config items that load values from 1Password using the `op()` resolver function. You can wire up individual items to specific fields in by using [1Password secret references](https://www.1password.dev/cli/secret-references/). ```env-spec DB_PASS=op(op://my-vault/database-password/password) ``` Where to find a secret reference The secret reference for invidivual fields within an item can be found by clicking on the down arrow icon on the field and selecting `Copy Secret Reference`. If you have multiple plugin instances, the `op()` function accepts an optional first parameter to specify which instance id to use. ```env-spec # @initOp(id=dev, token=$OP_TOKEN_DEV, allowAppAuth=true) # @initOp(id=prod, token=$OP_TOKEN_PROD, allowAppAuth=false) # --- DEV_ITEM=op(dev, op://vault-name/item-name/field-name) PROD_ITEM=op(prod, op://vault-name/item-name/field-name) ``` ### One-time passwords [Section titled “One-time passwords”](#one-time-passwords) 1Password items can hold a one-time password (TOTP) field, and a secret reference with `?attribute=otp` resolves to the current code rather than the stored seed. This is handy for CLIs that want a 2FA code on every invocation: .env.schema ```env-spec # @sensitive MFA_CODE=op("op://Private/aws/one-time password?attribute=otp") ``` The field name is whatever the field is called in the item (`one-time password` is the default for the built-in field). Use `Copy Secret Reference` on the field to get the exact reference, then append `?attribute=otp` yourself, since the copied reference does not include it. Alternatively, store the raw TOTP seed in a normal field and generate the code with [`generateOtp()`](/reference/functions/#generateotp): .env.schema ```env-spec # @internal @sensitive MFA_SECRET=op("op://Private/aws/totp seed") # @sensitive MFA_CODE=generateOtp($MFA_SECRET) ``` Which to pick: * **`?attribute=otp`** keeps the seed inside 1Password. The seed never reaches your machine, only the code does. Prefer this when it works for your auth mode. * **`generateOtp()`** works with any source (an encrypted local value, any other plugin, a plain field) and with all 1Password auth modes. The tradeoff is that the seed itself gets resolved locally, so keep it `@internal` and `@sensitive`. Support by auth mode: | Auth mode | `?attribute=otp` | | ---------------------------------------------------- | -------------------------------- | | Desktop app auth (`allowAppAuth`) | ✅ | | Service account via CLI (`useCliWithServiceAccount`) | ✅ | | Service account via SDK | ✅ | | 1Password Connect | ❌ (Connect has no OTP attribute) | With Connect, use the `generateOtp()` approach instead. Varlock raises an error explaining this rather than reporting a missing field. OTP references bypass `cacheTtl` If your instance sets [`cacheTtl`](#initop), references using `?attribute=otp` are excluded from that cache, since a cached code is expired by definition. Everything else on the instance still caches normally. See [`generateOtp()`](/reference/functions/#generateotp) for the rest of the caveats, including code reuse and what putting a seed next to a token costs you. ### Loading 1Password Environments [Section titled “Loading 1Password Environments”](#loading-1password-environments) Use `opLoadEnvironment()` with `@setValuesBulk` to load all variables from a [1Password environment](https://www.1password.dev/environments/) at once, instead of wiring up each secret individually: .env.schema ```env-spec # @plugin(@varlock/1password-plugin) # @initOp(token=$OP_TOKEN, allowAppAuth=forEnv(dev), account=acmeco) # @setValuesBulk(opLoadEnvironment(your-environment-id)) # --- # @type=opServiceAccountToken @sensitive @internal OP_TOKEN= API_KEY= DB_PASSWORD= ``` With a named instance: .env.schema ```env-spec # @initOp(id=prod, token=$OP_TOKEN_PROD, allowAppAuth=false) # @setValuesBulk(opLoadEnvironment(prod, your-environment-id)) ``` Beta CLI required for desktop app auth When using desktop app auth (`allowAppAuth`), the `op environment` command requires a beta version of the 1Password CLI (v2.33.0+). Download it from the [CLI release history](https://app-updates.agilebits.com/product_history/CLI2) (click “show betas”). Service account auth via the SDK does not have this requirement. *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initOp()` [Section titled “@initOp()”](#initop) Initializes an instance of the 1Password plugin, setting up options and authentication. Can be called multiple times to set up different instances. **Key/value args:** * `id` (optional): identifier for this instance, used when multiple instances are needed * `token` (optional): service account token. Should be a reference to a config item of type `opServiceAccountToken`. * `allowAppAuth` (optional): boolean flag to enable authenticating using the local desktop app * `account` (optional): limits the `op` cli to connect to specific 1Password account (shorthand, sign-in address, account ID, or user ID) * `useCliWithServiceAccount` (optional): when `true`, uses the `op` CLI binary instead of the WASM SDK for service account auth. Useful in memory-constrained environments. Requires the `op` CLI to be installed. * `cacheTtl` (optional): when set, resolved values from `op()` and `opLoadEnvironment()` are cached for the specified duration. Accepts the same format as the [`cache()` function](/reference/functions/#cache), for example `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared. Storage follows global [`@cache`](/reference/root-decorators/#cache) mode (`disk` or `memory`), and is disabled when caching is globally disabled or `--skip-cache` is used. For cache mode strategy and CLI cache controls, see the [Caching guide](/guides/caching/). ```env-spec # @initOp(id=notProd, token=$OP_TOKEN, allowAppAuth=forEnv(dev), account=acmeco, cacheTtl=1h) # --- # @type=opServiceAccountToken OP_TOKEN= ``` ### Data types [Section titled “Data types”](#data-types) #### `opServiceAccountToken` [Section titled “opServiceAccountToken”](#opserviceaccounttoken) Represents a [1Password service account token](https://www.1password.dev/service-accounts/). Validation ensures the token is in the correct format, and a link to the 1Password docs is added for convenience. Note that the type itself is marked as `@sensitive`, so adding an explicit `@sensitive` decorator is optional. ```env-spec # @type=opServiceAccountToken OP_TOKEN= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `op()` [Section titled “op()”](#op) Fetches an individual field using a 1Password secret reference **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `secretReference`: secret reference to fetch value from, in the format `op://vault-name/item-name/field-name` Secret references may include [query parameters](https://www.1password.dev/cli/secret-reference-syntax/), most usefully `?attribute=otp` to get a one-time password code instead of the stored seed. See [one-time passwords](#one-time-passwords). ```env-spec ITEM=op(op://vault-name/item-name/field-name) # example using a plugin instance id ITEM_WITH_INSTANCE_ID=op(prod, op://vault-name/item-name/field-name) # current 2FA code rather than the stored seed OTP_CODE=op("op://vault-name/item-name/one-time password?attribute=otp") ``` #### `opLoadEnvironment()` [Section titled “opLoadEnvironment()”](#oploadenvironment) Load all variables from a [1Password environment](https://www.1password.dev/environments/). Intended for use with `@setValuesBulk`. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `environmentId`: the 1Password environment ID to load variables from ```env-spec # Load all variables from a 1Password environment # @setValuesBulk(opLoadEnvironment(your-environment-id)) # With a named instance # @setValuesBulk(opLoadEnvironment(prod, your-environment-id)) ``` # Akeyless Plugin > Using Akeyless Platform secrets with Varlock [![](https://img.shields.io/npm/v/@varlock/akeyless-plugin?label=%40varlock%2Fakeyless-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/akeyless-plugin) Our Akeyless plugin enables secure loading of secrets from [Akeyless Platform](https://www.akeyless.io/) using declarative instructions within your `.env` files. The plugin uses Akeyless’s REST API with API Key or [OIDC](/guides/oidc/) authentication and supports static, dynamic, and rotated secret types. ## Features [Section titled “Features”](#features) * **API Key authentication** - Simple Access ID + Access Key authentication * **[OIDC authentication](/guides/oidc/)** - Authenticate using platform OIDC tokens (Vercel, GitHub Actions, etc.) * **Static secrets** - Fetch key/value secrets * **Dynamic secrets** - Fetch on-demand generated credentials (database, cloud, etc.) * **Rotated secrets** - Fetch auto-rotated credentials * **JSON key extraction** from secrets using `#` syntax or named `key` parameter * **Path prefixing** with `pathPrefix` option for organized secret management * **Gateway support** - Use a self-hosted [Akeyless Gateway](https://docs.akeyless.io/docs/api-gateway) via custom API URL * **Auto-infer secret name** from environment variable names * Support for multiple Akeyless instances * Automatic token caching and renewal * Lightweight implementation using REST API (no SDK dependencies) For global cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/akeyless-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/akeyless-plugin) # # 2. Initialize the plugin - see below for more details on options # @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY) ``` ### API Key authentication [Section titled “API Key authentication”](#api-key-authentication) The plugin authenticates using an API Key consisting of an Access ID and Access Key: 1. **Create an API Key in Akeyless** (see Akeyless Setup section below) 2. **Wire up the credentials in your config**. Add config items for the Access ID and Access Key, and reference them when initializing the plugin. .env.schema ```env-spec # @plugin(@varlock/akeyless-plugin) # @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY) # --- # @type=akeylessAccessId AKEYLESS_ACCESS_ID= # @type=akeylessAccessKey @sensitive @internal AKEYLESS_ACCESS_KEY= ``` 3. **Set your credentials in deployed environments**. Use your platform’s env var management UI to securely inject these values. ### OIDC authentication [Section titled “OIDC authentication”](#oidc-authentication) For deployments on platforms that issue OIDC tokens (Vercel, GitHub Actions, GitLab CI, Fly.io, GCP), you can use [OIDC workload identity federation](/guides/oidc/) to authenticate without an API key. Varlock auto-detects the platform’s OIDC token and uses it to authenticate via Akeyless’s OIDC access type. **Provider-side setup:** 1. Create an OIDC auth method in Akeyless Console 2. Configure it with the deployment platform’s issuer URL 3. Associate the auth method with an access role that has read permissions **Varlock config:** .env.schema ```env-spec # @plugin(@varlock/akeyless-plugin) # @initAkeyless(oidcAccessId="p-your-access-id") ``` No `accessKey` is needed. The `oidcAccessId` parameter triggers OIDC authentication. For platforms not auto-detected, pass an explicit token via `oidcToken`. ### Using an Akeyless Gateway [Section titled “Using an Akeyless Gateway”](#using-an-akeyless-gateway) If you are running a self-hosted [Akeyless Gateway](https://docs.akeyless.io/docs/api-gateway), provide the gateway URL via the `apiUrl` parameter: .env.schema ```env-spec # @initAkeyless( # accessId=$AKEYLESS_ACCESS_ID, # accessKey=$AKEYLESS_ACCESS_KEY, # apiUrl="https://gateway.example.com:8080" # ) ``` ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) If you need to connect to multiple Akeyless instances, register named instances: .env.schema ```env-spec # @initAkeyless(id=prod, accessId=$PROD_ACCESS_ID, accessKey=$PROD_ACCESS_KEY) # @initAkeyless(id=dev, accessId=$DEV_ACCESS_ID, accessKey=$DEV_ACCESS_KEY) # --- PROD_SECRET=akeyless(prod, "/MyApp/Secret") DEV_SECRET=akeyless(dev, "/MyApp/Secret") ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values using the `akeyless()` resolver function. ### Static secrets [Section titled “Static secrets”](#static-secrets) Static secrets are simple key/value pairs. This is the default secret type. .env.schema ```env-spec # Fetch a static secret by its full path DB_PASSWORD=akeyless("/MyApp/DB_PASSWORD") # Extract a JSON key from a static secret storing JSON DB_HOST=akeyless("/MyApp/DBConfig#host") # Or use named key parameter DB_PORT=akeyless("/MyApp/DBConfig", key="port") ``` ### Path prefixing [Section titled “Path prefixing”](#path-prefixing) Use `pathPrefix` to automatically prefix all secret paths for better organization: .env.schema ```env-spec # @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY, pathPrefix="/MyApp") # --- # Fetches from "/MyApp/DB_PASSWORD" DB_PASSWORD=akeyless("DB_PASSWORD") # Auto-infer also uses the prefix: fetches from "/MyApp/API_KEY" API_KEY=akeyless() ``` ### Dynamic secrets [Section titled “Dynamic secrets”](#dynamic-secrets) Dynamic secrets generate on-demand credentials (e.g., temporary database credentials, cloud access tokens). Use the `type=dynamic` parameter: .env.schema ```env-spec # Fetch entire dynamic secret as JSON DB_CREDENTIALS=akeyless("/MyApp/DynamicDBSecret", type=dynamic) # Extract a specific key from the dynamic secret response DB_USER=akeyless("/MyApp/DynamicDBSecret#user", type=dynamic) DB_PASS=akeyless("/MyApp/DynamicDBSecret#password", type=dynamic) ``` Multiple items that reference the same dynamic secret path are cached: only one API call is made, and each item extracts its key from the cached response. ### Rotated secrets [Section titled “Rotated secrets”](#rotated-secrets) Rotated secrets are auto-rotated credentials managed by Akeyless. Use the `type=rotated` parameter: .env.schema ```env-spec # Fetch entire rotated secret as JSON DB_ROTATED_CREDS=akeyless("/MyApp/RotatedDBPassword", type=rotated) # Extract individual keys from the rotated secret DB_USER=akeyless("/MyApp/RotatedDBPassword#user", type=rotated) DB_PASS=akeyless("/MyApp/RotatedDBPassword#password", type=rotated) ``` *** ## Akeyless Setup [Section titled “Akeyless Setup”](#akeyless-setup) ### Create an API Key [Section titled “Create an API Key”](#create-an-api-key) 1. **Log in** to the [Akeyless Console](https://console.akeyless.io) 2. **Create an Auth Method**: Go to **Auth Methods** → **New** → **API Key** 3. **Save the credentials**: Copy the generated **Access ID** (starts with `p-`) and **Access Key** ### Create a static secret [Section titled “Create a static secret”](#create-a-static-secret) You can create secrets via the Akeyless CLI or Console: ```bash # Using the Akeyless CLI akeyless create-secret --name "/MyApp/DB_PASSWORD" --value "supersecret" ``` Or in the Console: **Secrets & Keys** → **New** → **Static Secret** ### Set up access permissions [Section titled “Set up access permissions”](#set-up-access-permissions) 1. Go to **Access Roles** in the Akeyless Console 2. Create or edit a role and add rules to grant **read** access to the secrets your application needs 3. Associate the role with your API Key auth method Least privilege principle Only grant access to the specific secret paths your application needs. Avoid granting broad access to all secrets. *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initAkeyless()` [Section titled “@initAkeyless()”](#initakeyless) Initialize an Akeyless plugin instance. **Key/value args:** * `accessId` (optional): Akeyless Access ID (starts with `p-` for API Key auth). Required when using API Key auth. * `accessKey` (optional): Akeyless Access Key. Required when using API Key auth. * `oidcAccessId` (optional): Akeyless Access ID for OIDC authentication (alternative to `accessId`/`accessKey`) * `oidcToken` (optional): Explicit OIDC JWT token (auto-detected from platform if omitted) * `apiUrl` (optional): Akeyless API URL (defaults to `https://api.akeyless.io`). Use this for self-hosted Akeyless Gateway. * `pathPrefix` (optional): Prefix automatically prepended to all secret paths * `id` (optional): Instance identifier for multiple instances * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). Only `static` secrets are cached. `dynamic` and `rotated` secrets change per fetch and are never cached. ```env-spec # @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY, pathPrefix="/MyApp") ``` ### Data types [Section titled “Data types”](#data-types) #### `akeylessAccessId` [Section titled “akeylessAccessId”](#akeylessaccessid) Represents an Akeyless Access ID for API Key authentication. Validates that the value starts with `p-`. ```env-spec # @type=akeylessAccessId AKEYLESS_ACCESS_ID= ``` #### `akeylessAccessKey` [Section titled “akeylessAccessKey”](#akeylessaccesskey) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Represents an Akeyless Access Key for API Key authentication. This type is marked as `@sensitive`. ```env-spec # @type=akeylessAccessKey AKEYLESS_ACCESS_KEY= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `akeyless()` [Section titled “akeyless()”](#akeyless) Fetch a secret from Akeyless Platform. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `secretName` (optional): full path to the secret, optionally with `#KEY` to extract a JSON key (e.g., `"/MyApp/Secret#username"`). If omitted, uses the item key (variable name) as the secret name. **Named args:** * `type` (optional): secret type, one of `static` (default), `dynamic`, or `rotated` * `key` (optional): JSON key to extract from the secret value (overrides `#KEY` syntax) **Secret types:** * `static`: Simple key/value secrets (default). If the value is JSON, use `#KEY` or `key=` to extract individual keys. * `dynamic`: On-demand generated credentials (database, cloud, etc.). Returns JSON by default, or extract a specific key with `#KEY` or `key=`. * `rotated`: Auto-rotated credentials managed by Akeyless. Returns JSON by default, or extract a specific key with `#KEY` or `key=`. **Caching:** Multiple items referencing the same secret path (and type) share a single API call. This is especially useful for dynamic and rotated secrets where you need to extract multiple keys from the same response. ```env-spec # Uses item key as secret name (static) DATABASE_URL=akeyless() # Explicit secret path (static) DB_PASSWORD=akeyless("/MyApp/DB_PASSWORD") # Extract JSON key using # syntax DB_HOST=akeyless("/MyApp/DBConfig#host") # Extract JSON key using key= parameter DB_PORT=akeyless("/MyApp/DBConfig", key="port") # Dynamic secret - extract specific keys DB_USER=akeyless("/MyApp/DynamicDB#user", type=dynamic) DB_PASS=akeyless("/MyApp/DynamicDB#password", type=dynamic) # Rotated secret API_KEY=akeyless("/MyApp/RotatedKey#api_key", type=rotated) # With instance ID PROD_SECRET=akeyless(prod, "/MyApp/Secret") ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Secret not found [Section titled “Secret not found”](#secret-not-found) * Verify the secret exists in the Akeyless Console * Check the full secret path (e.g., `/MyFolder/MySecret`) * Ensure the path starts with `/` * If using `pathPrefix`, check the combined path is correct ### JSON key not found [Section titled “JSON key not found”](#json-key-not-found) * Verify the key exists in the secret value: check the Akeyless Console for the secret’s content * Key names are case-sensitive * For static secrets, ensure the value is valid JSON when using `#KEY` or `key=` ### Permission denied [Section titled “Permission denied”](#permission-denied) * Check the Access Role associated with your API Key auth method * Ensure the role includes read permission for the secret path * Verify the role is associated with the correct auth method ### Authentication failed [Section titled “Authentication failed”](#authentication-failed) * Verify the Access ID starts with `p-` (API Key auth) * Ensure the Access Key matches the Access ID * If using a Gateway, verify the `apiUrl` is correct and reachable * Check if the auth method is active in the Akeyless Console ## Resources [Section titled “Resources”](#resources) * [Akeyless Platform](https://www.akeyless.io/) * [Akeyless Documentation](https://docs.akeyless.io/) * [Akeyless API Key Authentication](https://docs.akeyless.io/docs/api-key) * [Akeyless REST API Reference](https://docs.akeyless.io/reference) * [Akeyless Gateway](https://docs.akeyless.io/docs/api-gateway) # AWS Secrets Manager Plugin > Using AWS Secrets Manager and Systems Manager Parameter Store with Varlock [![](https://img.shields.io/npm/v/@varlock/aws-secrets-plugin?label=%40varlock%2Faws-secrets-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/aws-secrets-plugin) Our AWS plugin enables secure loading of secrets from [AWS Secrets Manager (SM)](https://aws.amazon.com/secrets-manager/) and from [AWS Systems Manager Parameter Store (SSM)](https://aws.amazon.com/systems-manager/features/#Parameter_Store) using declarative instructions within your `.env` files. The plugin automatically integrates with AWS authentication, including IAM roles for AWS-hosted applications, AWS CLI credentials for local development, and explicit credentials for non-AWS environments. ## Features [Section titled “Features”](#features) * **Zero-config authentication** - Automatically uses AWS credentials from your environment * **IAM role support** - No credentials needed for AWS-hosted apps (EC2, ECS, Lambda, etc.) * **AWS CLI authentication** - Works with `aws configure` for local development * **Auto-infer secret/parameter names** from environment variable names * **JSON key extraction** from secrets/parameters using `#` syntax or named `key` parameter * **Name prefixing** with `namePrefix` option for organized secret management * Support for named AWS profiles * Support for explicit credentials * Support for temporary credentials with session tokens * **[OIDC workload identity](/guides/oidc/)** - Authenticate using platform OIDC tokens (Vercel, GitHub Actions, etc.) ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/aws-secrets-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/aws-secrets-plugin) # # 2. Initialize the plugin - see below for more details on options # @initAws(region=us-east-1) ``` ### Authentication options [Section titled “Authentication options”](#authentication-options) The plugin tries authentication methods in this priority order: 1. **OIDC** - If `oidcRoleArn` is provided, uses [OIDC workload identity federation](/guides/oidc/) 2. **Explicit credentials** - If `accessKeyId` and `secretAccessKey` are provided 3. **Named profile** - If `profile` is specified, uses credentials from `~/.aws/credentials` 4. **Default AWS credential chain** - Environment variables → `~/.aws/credentials` → IAM roles ### Automatic authentication (Recommended) [Section titled “Automatic authentication (Recommended)”](#automatic-authentication-recommended) For most use cases, you only need to provide the AWS region: .env.schema ```env-spec # @plugin(@varlock/aws-secrets-plugin) # @initAws(region=us-east-1) ``` **How this works:** * **Local development:** Run `aws configure` → automatically uses AWS CLI credentials * **AWS-hosted apps** (EC2, ECS, Lambda, Fargate): Attach an IAM role → automatically authenticates (no secrets needed!) * **Works everywhere** with zero configuration beyond the region! ### Explicit credentials (For non-AWS environments) [Section titled “Explicit credentials (For non-AWS environments)”](#explicit-credentials-for-non-aws-environments) If you’re deploying outside of AWS (e.g., Azure, GCP, on-premises), wire up IAM credentials: 1. **Create an IAM user** with the necessary permissions (see AWS Setup section below) 2. **Wire up the credentials in your config**. Add config items for the access key and secret key, and reference them when initializing the plugin. .env.schema ```env-spec # @plugin(@varlock/aws-secrets-plugin) # @initAws( # region=us-east-1, # accessKeyId=$AWS_ACCESS_KEY_ID, # secretAccessKey=$AWS_SECRET_ACCESS_KEY # ) # --- # @type=awsAccessKey AWS_ACCESS_KEY_ID= # @type=awsSecretKey @sensitive @internal AWS_SECRET_ACCESS_KEY= ``` 3. **Set your credentials in deployed environments**. Use your platform’s env var management UI to securely inject these values. ### Using named profiles [Section titled “Using named profiles”](#using-named-profiles) Use a specific profile from your `~/.aws/credentials` file: .env.schema ```env-spec # @plugin(@varlock/aws-secrets-plugin) # @initAws(region=us-east-1, profile=production) ``` You can run `aws configure --profile production` to create additional profiles, or manually edit `~/.aws/credentials`: ```ini [default] aws_access_key_id = AKIAIOSFODNN7EXAMPLE aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY [production] aws_access_key_id = AKIAI44QH8DHBEXAMPLE aws_secret_access_key = je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY ``` ### OIDC authentication [Section titled “OIDC authentication”](#oidc-authentication) For deployments on platforms that issue OIDC tokens (Vercel, GitHub Actions, GitLab CI, Fly.io, GCP), you can use [OIDC workload identity federation](/guides/oidc/) to authenticate without any long-lived credentials. Varlock auto-detects the platform’s OIDC token and exchanges it for temporary AWS credentials via STS `AssumeRoleWithWebIdentity`. **Provider-side setup:** 1. Create an IAM OIDC identity provider for your platform: ```bash aws iam create-open-id-connect-provider \ --url https://oidc.vercel.com \ --client-id-list sts.amazonaws.com \ --thumbprint-list ``` 2. Create an IAM role with a trust policy: ```json { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.vercel.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "oidc.vercel.com:aud": "sts.amazonaws.com" } } }] } ``` 3. Attach your secrets access policy to the role. **Varlock config:** .env.schema ```env-spec # @plugin(@varlock/aws-secrets-plugin) # @initAws(region=us-east-1, oidcRoleArn="arn:aws:iam::123456789012:role/varlock-oidc-role") ``` The `oidcRoleArn` parameter is the ARN of the IAM role to assume. You can optionally set `oidcSessionName` to customize the session name (defaults to `varlock-session`). For platforms not auto-detected, pass an explicit token via `oidcToken`. ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) If you need to connect to multiple regions or use different authentication, register multiple named instances: .env.schema ```env-spec # @initAws(id=us, region=us-east-1) # @initAws(id=eu, region=eu-west-1, profile=eu-prod) # --- US_DATABASE_URL=awsSecret(us, "db-connection") EU_DATABASE_URL=awsSecret(eu, "db-connection") ``` ## Loading secrets and parameters [Section titled “Loading secrets and parameters”](#loading-secrets-and-parameters) Once the plugin is installed and initialized, you can start adding config items that load values using the `awsSecret()` and `awsParam()` resolver functions. ### AWS Secrets Manager [Section titled “AWS Secrets Manager”](#aws-secrets-manager) The `awsSecret()` function fetches secrets from AWS Secrets Manager. .env.schema ```env-spec # Auto-infer secret names (DATABASE_URL -> "DATABASE_URL") DATABASE_URL=awsSecret() API_KEY=awsSecret() # Explicit secret names STRIPE_KEY=awsSecret("payments/stripe-secret-key") ``` ### Systems Manager Parameter Store [Section titled “Systems Manager Parameter Store”](#systems-manager-parameter-store) The `awsParam()` function fetches parameters from Parameter Store. .env.schema ```env-spec # Parameters from Parameter Store APP_CONFIG=awsParam("/prod/app/config") FEATURE_FLAGS=awsParam("/prod/features") # Auto-infer parameter names too DATABASE_HOST=awsParam() ``` ### JSON key extraction [Section titled “JSON key extraction”](#json-key-extraction) If your secrets or parameters contain JSON, you can extract specific keys: .env.schema ```env-spec # If "database-creds" contains: {"host": "db.example.com", "password": "secret"} # Using # syntax (shorthand) DB_HOST=awsSecret("database-creds#host") DB_PASSWORD=awsSecret("database-creds#password") # Or use named "key" parameter DB_PORT=awsSecret("database-creds", key="port") ``` ### Name prefixing [Section titled “Name prefixing”](#name-prefixing) Use `namePrefix` to automatically prefix all secret/parameter names for better organization: .env.schema ```env-spec # @initAws(region=us-east-1, namePrefix="prod/api/") # --- # Fetches "prod/api/DATABASE_URL" DATABASE_URL=awsSecret() # Fetches "prod/api/stripe-key" STRIPE_KEY=awsSecret("stripe-key") ``` You can even use dynamic prefixes: .env.schema ```env-spec # @initAws(region=us-east-1, namePrefix="${ENV}/") # --- # In prod: fetches "prod/DATABASE_URL" # In dev: fetches "dev/DATABASE_URL" DATABASE_URL=awsSecret() ``` *** ## AWS Setup [Section titled “AWS Setup”](#aws-setup) ### Required IAM permissions [Section titled “Required IAM permissions”](#required-iam-permissions) Your IAM user or role needs specific permissions to access secrets and parameters. #### For AWS Secrets Manager [Section titled “For AWS Secrets Manager”](#for-aws-secrets-manager) ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["secretsmanager:GetSecretValue"], "Resource": "arn:aws:secretsmanager:*:*:secret:*" } ] } ``` #### For AWS Systems Manager Parameter Store [Section titled “For AWS Systems Manager Parameter Store”](#for-aws-systems-manager-parameter-store) ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["ssm:GetParameter"], "Resource": "arn:aws:ssm:*:*:parameter/*" } ] } ``` Least privilege principle For production, scope down the `Resource` field to only the specific secrets/parameters your application needs. For example: `"arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/*"` ### IAM roles for AWS-hosted apps (Recommended) [Section titled “IAM roles for AWS-hosted apps (Recommended)”](#iam-roles-for-aws-hosted-apps-recommended) IAM roles are the AWS-native way to authenticate - no credentials needed! 1. **Create an IAM role** with the necessary permissions and trust policy for your service (EC2, ECS, or Lambda) 2. **Attach the role to your application** * **EC2:** Create an instance profile and attach it to your instance * **ECS:** Set the `taskRoleArn` in your task definition * **Lambda:** Set the execution role in your function configuration 3. **That’s it!** Your app will automatically authenticate using the IAM role. See the [AWS documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html) for detailed instructions on creating and attaching IAM roles. ### IAM user for non-AWS environments [Section titled “IAM user for non-AWS environments”](#iam-user-for-non-aws-environments) 1. **Create an IAM user** ```bash aws iam create-user --user-name varlock-secrets-reader ``` 2. **Attach the permissions policy** ```bash aws iam put-user-policy \ --user-name varlock-secrets-reader \ --policy-name secrets-access \ --policy-document file://policy.json ``` 3. **Create access credentials** ```bash aws iam create-access-key --user-name varlock-secrets-reader ``` Save the `AccessKeyId` and `SecretAccessKey` from the output - you’ll need them for your deployments. ### Configure AWS CLI for local development [Section titled “Configure AWS CLI for local development”](#configure-aws-cli-for-local-development) 1. **Run `aws configure`** ```bash aws configure # AWS Access Key ID: [your key] # AWS Secret Access Key: [your secret] # Default region name: us-east-1 # Default output format: json ``` 2. **Test the configuration** ```bash aws sts get-caller-identity ``` *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initAws()` [Section titled “@initAws()”](#initaws) Initialize an AWS plugin instance for accessing Secrets Manager and Parameter Store. **Key/value args:** * `region` (required): AWS region (e.g., `us-east-1`, `eu-west-1`) * `namePrefix` (optional): Prefix automatically prepended to all secret/parameter names * `accessKeyId` (optional): AWS access key ID for explicit authentication * `secretAccessKey` (optional): AWS secret access key for explicit authentication * `sessionToken` (optional): AWS session token for temporary credentials * `profile` (optional): Named profile from `~/.aws/credentials` * `oidcRoleArn` (optional): IAM role ARN for OIDC workload identity authentication * `oidcSessionName` (optional): Session name for OIDC role assumption (defaults to `varlock-session`) * `oidcToken` (optional): Explicit OIDC JWT token (auto-detected from platform if omitted) * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). * `id` (optional): Instance identifier for multiple instances ```env-spec # @initAws(region=us-east-1, namePrefix="prod/api/", cacheTtl="1h") ``` ### Data types [Section titled “Data types”](#data-types) #### `awsAccessKey` [Section titled “awsAccessKey”](#awsaccesskey) Represents an AWS access key ID (20-character alphanumeric string). Note that the type itself is marked as `@sensitive`, so adding an explicit `@sensitive` decorator is optional. ```env-spec # @type=awsAccessKey AWS_ACCESS_KEY_ID= ``` #### `awsSecretKey` [Section titled “awsSecretKey”](#awssecretkey) Represents an AWS secret access key (40-character string). This type is marked as `@sensitive`. ```env-spec # @type=awsSecretKey AWS_SECRET_ACCESS_KEY= ``` Does your app use these credentials too? The examples here mark the credential [`@internal`](/reference/item-decorators/#internal) so it’s used by varlock to fetch your secrets but **not** injected into your app. This is the recommended posture when varlock is the only consumer. Unlike the dedicated secrets-manager tokens, AWS credentials are **not** `@internal` by default (they’re commonly read directly by the AWS SDK), so if you need the credential at runtime for other purposes, opt out with `@internal=false` to keep it injected: ```env-spec # @type=awsSecretKey @internal=false AWS_SECRET_ACCESS_KEY= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `awsSecret()` [Section titled “awsSecret()”](#awssecret) Fetch a secret from AWS Secrets Manager. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `secretId` (optional): secret name, ARN, or name with JSON key using `#` syntax. If omitted, uses the variable name. * `key` (optional, named parameter): JSON key to extract from the secret value ```env-spec # Auto-infer secret name DATABASE_URL=awsSecret() # Explicit secret name STRIPE_KEY=awsSecret("payments/stripe-key") # Extract JSON key (shorthand) DB_HOST=awsSecret("database-creds#host") # Extract JSON key (named parameter) DB_PORT=awsSecret("database-creds", key="port") # With instance ID US_SECRET=awsSecret(us, "my-secret") ``` #### `awsParam()` [Section titled “awsParam()”](#awsparam) Fetch a parameter from AWS Systems Manager Parameter Store. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `parameterName` (optional): parameter name/path or name with JSON key using `#` syntax. If omitted, uses the variable name. * `key` (optional, named parameter): JSON key to extract from the parameter value ```env-spec # Auto-infer parameter name DATABASE_HOST=awsParam() # Explicit parameter path APP_CONFIG=awsParam("/prod/app/config") # Extract JSON key DB_CREDS=awsParam("/prod/db/creds#password") # With instance ID EU_CONFIG=awsParam(eu, "/prod/config") ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Secret not found [Section titled “Secret not found”](#secret-not-found) * Verify the secret exists: `aws secretsmanager list-secrets --query 'SecretList[?Name==\`my-secret\`]’\` * Check you’re using the correct region * Ensure the secret name matches exactly (including any prefix) ### Parameter not found [Section titled “Parameter not found”](#parameter-not-found) * Verify the parameter exists: `aws ssm describe-parameters --parameter-filters "Key=Name,Values=/my/param"` * Check you’re using the correct region * Parameter Store paths are case-sensitive ### Permission denied [Section titled “Permission denied”](#permission-denied) * Check your IAM permissions: Test with `aws sts get-caller-identity` to see which identity you’re using * For IAM roles on EC2/ECS/Lambda: Verify the role is attached and has the required permissions * Ensure the IAM policy includes `secretsmanager:GetSecretValue` and/or `ssm:GetParameter` ### Authentication failed [Section titled “Authentication failed”](#authentication-failed) * **Local dev:** Run `aws configure` or ensure `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are set * **AWS-hosted apps:** Verify IAM role is attached * **Other environments:** Verify credentials are correct and properly injected * Test credentials: `aws sts get-caller-identity` ### JSON parsing errors [Section titled “JSON parsing errors”](#json-parsing-errors) * Verify your secret/parameter contains valid JSON * Check that the key you’re extracting exists in the JSON * Test manually: `aws secretsmanager get-secret-value --secret-id my-secret --query SecretString --output text | jq .` ## Resources [Section titled “Resources”](#resources) * [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) * [AWS Systems Manager Parameter Store](https://aws.amazon.com/systems-manager/features/#Parameter_Store) * [IAM Roles](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html) * [AWS SDK for JavaScript](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/) # AWS SigV4 Plugin > SigV4 request re-signing for the Varlock credential proxy, for AWS and S3-compatible services [![](https://img.shields.io/npm/v/@varlock/aws-sigv4-plugin?label=%40varlock%2Faws-sigv4-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/aws-sigv4-plugin) This plugin adds an `aws-sigv4` request-signing scheme to the [credential proxy](/guides/proxy/). It covers credentials AWS never receives directly: every AWS API request carries a signature computed *with* the secret access key, so plain placeholder substitution cannot broker them. With this plugin, the agent’s AWS SDK signs requests normally using the placeholder credentials varlock injects into its environment. The proxy parses the region and service out of the inbound credential scope, strips the placeholder signature, and re-signs the request with the real keys at the network boundary. The secret access key never enters the agent’s environment, and the agent cannot produce a valid signature itself. ## Setup [Section titled “Setup”](#setup) .env.schema ```env-spec # @plugin(@varlock/aws-sigv4-plugin) # --- # @proxy(domain="*.amazonaws.com", transform={ # scheme="aws-sigv4", keyId=$AWS_ACCESS_KEY_ID, # allowedServices=[bedrock, s3], # }) AWS_SECRET_ACCESS_KEY=yourPreferredPlugin() # @sensitive AWS_ACCESS_KEY_ID=yourPreferredPlugin() ``` No SDK configuration is needed beyond pointing it at the proxy (which `varlock proxy run` does): the SDK signs with the placeholder credentials it sees, and one rule covers every AWS service and region the client talks to. ## Beyond AWS [Section titled “Beyond AWS”](#beyond-aws) SigV4 is AWS’s protocol, but it is also the auth standard across the S3-compatible ecosystem, since those services are built to work with the AWS SDKs unchanged: Cloudflare R2, MinIO, Backblaze B2 (S3 API), DigitalOcean Spaces, Wasabi, IBM Cloud Object Storage, and Google Cloud Storage’s XML interop mode with HMAC keys (which signs with an `s3` service scope). DynamoDB-compatible endpoints such as ScyllaDB Alternator work the same way. The client is still an AWS SDK pointed at a custom endpoint, so the request carries a normal SigV4 credential scope, and the rule’s `domain` decides where the scheme applies: .env.schema ```env-spec # @plugin(@varlock/aws-sigv4-plugin) # --- # Broker Cloudflare R2 credentials the same way: # @proxy(domain="myaccount.r2.cloudflarestorage.com", transform={ # scheme="aws-sigv4", keyId=$R2_ACCESS_KEY_ID, # }) R2_SECRET_ACCESS_KEY=yourPreferredPlugin() # @sensitive R2_ACCESS_KEY_ID=yourPreferredPlugin() ``` The same limitations apply as for AWS itself. In particular, current AWS SDKs default `requestChecksumCalculation` to `WHEN_SUPPORTED`, which makes S3-style uploads use `aws-chunked` streaming payloads that the proxy cannot re-sign; set it to `WHEN_REQUIRED` on the client for those calls. See the limitations below. ## Options [Section titled “Options”](#options) | Option | Meaning | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `keyId` | **(required)** Reference to the item holding the AWS access key id, e.g. `keyId=$AWS_ACCESS_KEY_ID` (travels in the Credential scope). | | `sessionToken` | Reference to the item holding a session token for temporary credentials, sent and signed as `X-Amz-Security-Token`. | | `allowedRegions` | Only sign requests whose scope names one of these regions, e.g. `[us-east-1]`. Omitted = any. | | `allowedServices` | Only sign requests whose scope names one of these services, e.g. `[bedrock, s3]`. Omitted = any. | ## Details and limitations [Section titled “Details and limitations”](#details-and-limitations) * A request without an inbound SigV4 signature is blocked with a message explaining the placeholder-signing setup (there is nothing to derive the region/service from). * Pre-signed URLs (`X-Amz-Credential` in the query string) are not supported and are blocked with a distinct message. * The payload hash covers the exact outbound body bytes, byte-for-byte (binary uploads included). If the client signed with the `UNSIGNED-PAYLOAD` sentinel, the proxy preserves it. `aws-chunked` streaming payloads (`STREAMING-*` sentinels, used by newer SDKs’ flexible checksums) cannot be re-signed and are blocked with a pointer at the SDK setting to disable them (`requestChecksumCalculation: "WHEN_REQUIRED"`). * Signing uses the official AWS SDK v3 signer, including the S3-specific path-encoding rules. See [Request transforms](/guides/proxy/rules/#request-transforms) for how transforms work in general. # Azure Key Vault Plugin > Using Azure Key Vault with Varlock [![](https://img.shields.io/npm/v/@varlock/azure-key-vault-plugin?label=%40varlock%2Fazure-key-vault-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/azure-key-vault-plugin) Our [Azure Key Vault](https://azure.microsoft.com/en-us/products/key-vault) plugin enables secure loading of secrets from Azure Key Vault using declarative instructions within your `.env` files. The plugin automatically integrates with Azure authentication, including Managed Identity for Azure-hosted applications, Azure CLI credentials for local development, and service principal credentials for non-Azure environments. ## Features [Section titled “Features”](#features) * **Zero-config authentication** - Just provide your vault URL, authentication happens automatically * **Managed Identity support** - No credentials needed for Azure-hosted apps (App Service, Container Instances, VMs, Functions, AKS) * **Azure CLI authentication** - Works with `az login` for local development * **Auto-infer secret names** from environment variable names (e.g., `DATABASE_URL` → `database-url`) * Support for service principal credentials (for non-Azure environments) * Support for versioned secrets * Automatic token caching and renewal * **[OIDC workload identity](/guides/oidc/)** - Authenticate using platform OIDC tokens (Vercel, GitHub Actions, etc.) * Lightweight implementation using REST API (47 KB bundle, no heavy Azure SDK dependencies) For global cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/azure-key-vault-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/azure-key-vault-plugin) # # 2. Initialize the plugin - see below for more details on options # @initAzure(vaultUrl="https://my-vault.vault.azure.net/") ``` ### Authentication options [Section titled “Authentication options”](#authentication-options) The plugin tries authentication methods in this priority order: 1. **Service Principal** - If all three credentials (`tenantId`, `clientId`, `clientSecret`) are provided 2. **OIDC federated credentials** - If `tenantId` and `clientId` are provided without `clientSecret`, uses [OIDC workload identity federation](/guides/oidc/) 3. **Managed Identity** - Automatically used when running on Azure infrastructure 4. **Azure CLI** - Falls back to `az login` for local development ### Automatic authentication (Recommended) [Section titled “Automatic authentication (Recommended)”](#automatic-authentication-recommended) For most use cases, you only need to provide the vault URL: .env.schema ```env-spec # @plugin(@varlock/azure-key-vault-plugin) # @initAzure(vaultUrl="https://my-vault.vault.azure.net/") ``` **How this works:** * **Local development:** Run `az login` → automatically uses Azure CLI credentials * **Azure-hosted apps** (App Service, Container Instances, VMs, Functions, AKS): Enable Managed Identity → automatically authenticates (no secrets needed!) * **Works everywhere** with zero configuration beyond the vault URL! Finding your vault URL Run `az keyvault show --name my-vault --query properties.vaultUri -o tsv` to get your vault URL. ### Service principal credentials (For non-Azure environments) [Section titled “Service principal credentials (For non-Azure environments)”](#service-principal-credentials-for-non-azure-environments) If you’re deploying outside of Azure (e.g., AWS, GCP, on-premises), wire up service principal credentials: 1. **Create a service principal** with the necessary permissions (see Azure Setup section below) 2. **Wire up the credentials in your config**. Add config items for the tenant ID, client ID, and client secret, and reference them when initializing the plugin. .env.schema ```env-spec # @plugin(@varlock/azure-key-vault-plugin) # @initAzure( # vaultUrl="https://my-vault.vault.azure.net/", # tenantId=$AZURE_TENANT_ID, # clientId=$AZURE_CLIENT_ID, # clientSecret=$AZURE_CLIENT_SECRET # ) # --- # @type=azureTenantId @sensitive AZURE_TENANT_ID= # @type=azureClientId @sensitive AZURE_CLIENT_ID= # @type=azureClientSecret @sensitive @internal AZURE_CLIENT_SECRET= ``` 3. **Set your credentials in deployed environments**. Use your platform’s env var management UI to securely inject these values. ### OIDC federated credentials [Section titled “OIDC federated credentials”](#oidc-federated-credentials) For deployments on platforms that issue OIDC tokens (Vercel, GitHub Actions, GitLab CI, Fly.io, GCP), you can use [OIDC workload identity federation](/guides/oidc/) to authenticate without a client secret. Varlock auto-detects the platform’s OIDC token and exchanges it for an Azure access token via federated credential authentication. **Provider-side setup:** 1. Create an App Registration (or use an existing one) 2. Add a federated credential: ```bash az ad app federated-credential create \ --id \ --parameters '{ "name": "vercel-oidc", "issuer": "https://oidc.vercel.com", "subject": "", "audiences": ["api://AzureADTokenExchange"] }' ``` 3. Grant the App Registration “Key Vault Secrets User” role on your vault. **Varlock config:** .env.schema ```env-spec # @plugin(@varlock/azure-key-vault-plugin) # @initAzure( # vaultUrl="https://my-vault.vault.azure.net/", # tenantId=$AZURE_TENANT_ID, # clientId=$AZURE_CLIENT_ID # ) ``` When `clientSecret` is omitted but `tenantId` and `clientId` are provided, the plugin automatically uses OIDC federated credentials. For platforms not auto-detected, pass an explicit token via `oidcToken`. ### Multiple vaults [Section titled “Multiple vaults”](#multiple-vaults) If you need to connect to multiple vaults, but never at the same time, you can alter the vault URL using a function: .env.schema ```env-spec # @plugin(@varlock/azure-key-vault-plugin) # @initAzure(vaultUrl="https://my-vault-${ENV}.vault.azure.net/") ``` Or if you need to connect to multiple vaults simultaneously, register multiple named instances: .env.schema ```env-spec # @initAzure(id=prod, vaultUrl="https://my-vault-prod.vault.azure.net/") # @initAzure(id=dev, vaultUrl="https://my-vault-dev.vault.azure.net/") # --- PROD_SECRET=azureSecret(prod, "database-url") DEV_SECRET=azureSecret(dev, "database-url") ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values using the `azureSecret()` resolver function. ### Basic usage [Section titled “Basic usage”](#basic-usage) The `azureSecret()` function fetches secrets from Azure Key Vault. .env.schema ```env-spec # Auto-infer secret names (DATABASE_URL -> "database-url") DATABASE_URL=azureSecret() API_KEY=azureSecret() # Explicit secret names CUSTOM_SECRET=azureSecret("my-custom-secret-name") ``` Secret name conversion Azure Key Vault uses hyphens instead of underscores. When auto-inferring, the plugin automatically converts `DATABASE_URL` to `database-url` to match Azure’s naming convention. ### Versioned secrets [Section titled “Versioned secrets”](#versioned-secrets) You can fetch specific versions of secrets by appending `@version` to the secret name: .env.schema ```env-spec # Fetch latest version (default) API_KEY=azureSecret("api-key") # Fetch specific version API_KEY_V1=azureSecret("api-key@abc123def456") ``` *** ## Azure Setup [Section titled “Azure Setup”](#azure-setup) ### Required permissions [Section titled “Required permissions”](#required-permissions) Your managed identity, service principal, or user needs one of: * **Access Policy**: “Get” permission for secrets * **RBAC**: “Key Vault Secrets User” role ### Managed Identity for Azure-hosted apps (Recommended) [Section titled “Managed Identity for Azure-hosted apps (Recommended)”](#managed-identity-for-azure-hosted-apps-recommended) Managed Identity is the Azure-native way to authenticate - no credentials needed! 1. **Enable system-assigned managed identity** for your Azure resource ```bash # For App Service az webapp identity assign --name my-app --resource-group my-rg # For Container Instance az container create --assign-identity --name my-container ... # For VM az vm identity assign --name my-vm --resource-group my-rg ``` 2. **Grant Key Vault access to the identity** Get the identity’s principal ID: ```bash PRINCIPAL_ID=$(az webapp identity show --name my-app --resource-group my-rg --query principalId -o tsv) ``` Then grant access using either RBAC or Access Policy: **Option A: RBAC (Recommended)** ```bash az role assignment create \ --role "Key Vault Secrets User" \ --assignee $PRINCIPAL_ID \ --scope /subscriptions//resourceGroups//providers/Microsoft.KeyVault/vaults/ ``` **Option B: Access Policy** ```bash az keyvault set-policy \ --name my-vault \ --object-id $PRINCIPAL_ID \ --secret-permissions get ``` 3. **That’s it!** Your app will automatically authenticate using Managed Identity. ### Service principal for non-Azure environments [Section titled “Service principal for non-Azure environments”](#service-principal-for-non-azure-environments) 1. **Create a service principal** ```bash az ad sp create-for-rbac --name "varlock-keyvault-reader" ``` Save the `appId`, `password`, and `tenant` from the output. 2. **Grant Key Vault access** **Option A: RBAC (Recommended)** ```bash az role assignment create \ --role "Key Vault Secrets User" \ --assignee \ --scope /subscriptions//resourceGroups//providers/Microsoft.KeyVault/vaults/ ``` **Option B: Access Policy** ```bash az keyvault set-policy \ --name my-vault \ --spn \ --secret-permissions get ``` ### Azure CLI for local development [Section titled “Azure CLI for local development”](#azure-cli-for-local-development) 1. **Install the Azure CLI** if you haven’t already: [Installation guide](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) 2. **Log in to Azure** ```bash az login ``` 3. **Verify your identity** ```bash az account show ``` 4. **Grant Key Vault access to your user account** (if needed) ```bash az keyvault set-policy \ --name my-vault \ --upn your-email@domain.com \ --secret-permissions get ``` *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initAzure()` [Section titled “@initAzure()”](#initazure) Initialize an Azure Key Vault plugin instance for accessing secrets. **Key/value args:** * `vaultUrl` (required): Azure Key Vault URL (e.g., `https://my-vault.vault.azure.net/`) * `tenantId` (optional): Azure AD tenant ID (directory ID) * `clientId` (optional): Service principal application (client) ID * `clientSecret` (optional): Service principal client secret (password) * `oidcToken` (optional): Explicit OIDC JWT token (auto-detected from platform if omitted) * `id` (optional): Instance identifier for multiple vaults * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). ```env-spec # @initAzure(vaultUrl="https://my-vault.vault.azure.net/") ``` ### Data types [Section titled “Data types”](#data-types) #### `azureTenantId` [Section titled “azureTenantId”](#azuretenantid) Represents an Azure AD tenant ID (UUID format). This type is marked as `@sensitive`. ```env-spec # @type=azureTenantId AZURE_TENANT_ID= ``` #### `azureClientId` [Section titled “azureClientId”](#azureclientid) Represents a service principal application (client) ID (UUID format). This type is marked as `@sensitive`. ```env-spec # @type=azureClientId AZURE_CLIENT_ID= ``` #### `azureClientSecret` [Section titled “azureClientSecret”](#azureclientsecret) Represents a service principal client secret (password). This type is marked as `@sensitive`. ```env-spec # @type=azureClientSecret AZURE_CLIENT_SECRET= ``` Does your app use these credentials too? The examples here mark the credential [`@internal`](/reference/item-decorators/#internal) so it’s used by varlock to fetch your secrets but **not** injected into your app. This is the recommended posture when varlock is the only consumer. Unlike the dedicated secrets-manager tokens, Azure credentials are **not** `@internal` by default (they’re commonly read directly by the Azure SDK), so if you need the credential at runtime for other purposes, opt out with `@internal=false` to keep it injected: ```env-spec # @type=azureClientSecret @internal=false AZURE_CLIENT_SECRET= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `azureSecret()` [Section titled “azureSecret()”](#azuresecret) Fetch a secret from Azure Key Vault. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `secretName` (optional): secret name or name with version using `@` syntax. If omitted, uses the variable name (converted to kebab-case). ```env-spec # Auto-infer secret name (DATABASE_URL -> "database-url") DATABASE_URL=azureSecret() # Explicit secret name CUSTOM_SECRET=azureSecret("my-custom-secret") # Specific version API_KEY_V1=azureSecret("api-key@abc123def456") # With instance ID PROD_SECRET=azureSecret(prod, "database-url") ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Secret not found [Section titled “Secret not found”](#secret-not-found) * Verify the secret exists: `az keyvault secret list --vault-name my-vault` * Remember: Azure uses hyphens, not underscores (use `database-url` not `database_url`) * Check for typos in the secret name ### Permission denied [Section titled “Permission denied”](#permission-denied) * Check your RBAC role: `az role assignment list --assignee --scope ` * Or check access policies: `az keyvault show --name my-vault --query properties.accessPolicies` * Ensure your identity has “Get” permission for secrets ### Authentication failed [Section titled “Authentication failed”](#authentication-failed) * **Local dev:** Run `az login` and ensure service principal env vars are empty * **Azure-hosted apps:** Verify Managed Identity is enabled and has Key Vault permissions * **Other environments:** Verify service principal credentials are correct and properly injected * Test identity: `az account show` ### Vault not accessible [Section titled “Vault not accessible”](#vault-not-accessible) * Verify the vault URL is correct * Check network access: Ensure firewall rules allow access from your IP/resource * Verify the vault exists in the specified subscription ## Resources [Section titled “Resources”](#resources) * [Azure Key Vault](https://azure.microsoft.com/en-us/products/key-vault) * [Managed Identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) * [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/) * [Key Vault Access Policies](https://learn.microsoft.com/en-us/azure/key-vault/general/assign-access-policy) # Bitwarden Plugin > Using Bitwarden Secrets Manager and Password Manager (and Vaultwarden) with Varlock [![](https://img.shields.io/npm/v/@varlock/bitwarden-plugin?label=%40varlock%2Fbitwarden-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/bitwarden-plugin) Our Bitwarden plugin enables secure loading of secrets from Bitwarden using declarative instructions within your `.env` files. It supports two distinct Bitwarden products: * **[Secrets Manager](https://bitwarden.com/products/secrets-manager/)**: programmatic secret storage, accessed via machine account access tokens over the REST API. Best for CI/CD and production. Uses `@initBitwarden()` + `bitwarden()`. * **[Password Manager](https://bitwarden.com/products/personal/)** (and self-hosted **[Vaultwarden](https://github.com/dani-garcia/vaultwarden)**): your personal/team password vault, accessed via the `bw` CLI. Best for local development. Uses `@initBwp()` + `bwp()`. ## Features [Section titled “Features”](#features) * **Zero-config authentication** - Just provide your machine account access token * **UUID-based secret access** - Fetch secrets by their unique identifiers * **Self-hosted Bitwarden support** - Configure custom API and identity URLs * **Multiple instances** - Connect to different organizations or self-hosted instances * **Helpful error handling** with resolution tips * **Lightweight implementation** using REST API (48 KB bundle, no native SDK dependencies) ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/bitwarden-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/bitwarden-plugin) # # 2. Initialize the plugin - see below for more details on options # @initBitwarden(accessToken=$BITWARDEN_ACCESS_TOKEN) # --- # 3. Add a machine account access token config item # @type=bitwardenAccessToken @sensitive @internal BITWARDEN_ACCESS_TOKEN= ``` ### Machine account setup [Section titled “Machine account setup”](#machine-account-setup) 1. **Create a machine account** in your Bitwarden organization Navigate to your Bitwarden organization’s **Secrets Manager** → **Machine accounts** → Click **New machine account**. Provide a name (e.g., “Production App”) and save it. 2. **Copy the access token** (displayed only once!) After creating the machine account, you’ll see an **Access token**. Copy it immediately - it will only be displayed once. Save the token securely Store the access token securely. You won’t be able to see it again after this step! 3. **Grant access to secrets** Grant your machine account access to the specific projects or secrets you need. **Via Projects:** * Create or select a project in Secrets Manager * Add secrets to the project * Grant your machine account access to the project **Direct Secret Access:** * Navigate to a specific secret * Click **Access** * Add your machine account with “Can read” permissions 4. **Wire up the token in your config** .env.schema ```env-spec # @plugin(@varlock/bitwarden-plugin) # @initBitwarden(accessToken=$BITWARDEN_ACCESS_TOKEN) # --- # @type=bitwardenAccessToken @sensitive @internal BITWARDEN_ACCESS_TOKEN= ``` 5. **Set your access token in environments** Use your CI/CD system or platform’s env var management to securely inject the `BITWARDEN_ACCESS_TOKEN` value. Permission levels Machine accounts can have *Can read* (retrieve secrets only) or *Can read, write* (retrieve, create, and edit secrets) permissions. For most use cases, *Can read* is sufficient. ### Self-hosted Bitwarden [Section titled “Self-hosted Bitwarden”](#self-hosted-bitwarden) For self-hosted Bitwarden instances, you’ll need to provide both the API and identity URLs: .env.schema ```env-spec # @plugin(@varlock/bitwarden-plugin) # @initBitwarden( # accessToken=$BITWARDEN_ACCESS_TOKEN, # apiUrl="https://bitwarden.yourcompany.com/api", # identityUrl="https://bitwarden.yourcompany.com/identity" # ) ``` ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) If you need to connect to multiple organizations or instances, register multiple named instances: .env.schema ```env-spec # @initBitwarden(id=prod, accessToken=$PROD_ACCESS_TOKEN) # @initBitwarden(id=dev, accessToken=$DEV_ACCESS_TOKEN) # --- PROD_SECRET=bitwarden(prod, "11111111-1111-1111-1111-111111111111") DEV_SECRET=bitwarden(dev, "22222222-2222-2222-2222-222222222222") ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values using the `bitwarden()` resolver function. ### Basic usage [Section titled “Basic usage”](#basic-usage) Fetch secrets by their UUID: .env.schema ```env-spec # Fetch secrets by UUID DATABASE_URL=bitwarden("12345678-1234-1234-1234-123456789abc") API_KEY=bitwarden("87654321-4321-4321-4321-cba987654321") ``` Finding secret UUIDs To find a secret’s UUID: 1. Open your Bitwarden Secrets Manager 2. Navigate to the secret 3. Copy the UUID from the URL or secret details (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) ### Multiple instances [Section titled “Multiple instances”](#multiple-instances-1) If you have multiple plugin instances, specify which instance to use: .env.schema ```env-spec PROD_ITEM=bitwarden(prod, "11111111-1111-1111-1111-111111111111") DEV_ITEM=bitwarden(dev, "22222222-2222-2222-2222-222222222222") ``` *** ## Bitwarden Setup [Section titled “Bitwarden Setup”](#bitwarden-setup) ### Create a machine account [Section titled “Create a machine account”](#create-a-machine-account) Machine accounts provide programmatic access to Bitwarden Secrets Manager. 1. **Log in to your Bitwarden organization** web vault 2. **Navigate to Secrets Manager → Machine accounts** 3. **Click “New machine account”** 4. **Provide a name** (e.g., “Production App”) 5. **Copy the Access token** (shown only once!) 6. **Grant access** to specific projects or secrets **Permission Levels:** * **Can read** - Retrieve secrets only (recommended for most use cases) * **Can read, write** - Retrieve, create, and edit secrets Access token security Store the access token securely - it will only be displayed once during creation! ### Grant access to secrets [Section titled “Grant access to secrets”](#grant-access-to-secrets) **Via Projects (Recommended):** 1. Create or select a project in Secrets Manager 2. Add secrets to the project 3. Grant your machine account access to the project This approach makes it easier to manage access to multiple secrets at once. **Direct Secret Access:** 1. Navigate to a specific secret 2. Click **Access** 3. Add your machine account with appropriate permissions *** ## Password Manager / Vaultwarden [Section titled “Password Manager / Vaultwarden”](#password-manager--vaultwarden) Separate from Secrets Manager, you can also load values from your personal or team **Bitwarden Password Manager** vault, or a self-hosted **Vaultwarden** server. This path uses the Bitwarden [`bw` CLI](https://bitwarden.com/help/cli/) rather than the REST API, so it’s best suited to local development. varlock acquires and caches a CLI **session token** for you: on first load it runs `bw unlock` (prompting for your master password), then caches the token in varlock’s encrypted cache and reuses it on subsequent loads. You no longer need to manually unlock and paste a session token into a `.env` file. By default the token is cached indefinitely (`sessionTtl="forever"`). This is safe because the cached token is encrypted at rest with varlock’s local key (biometric-gated with Touch ID / Windows Hello on platforms with a secure enclave), so reading it requires *you*. Note the `bw` CLI session does **not** auto-lock on system sleep/lock/restart (those are app-only vault-timeout settings), so by default a fresh master-password unlock is only needed when the session is explicitly invalidated (`bw lock`/`bw logout`, an external `bw unlock`, or an account policy). Set a shorter `sessionTtl` if you want varlock to force periodic master-password re-auth. ### Prerequisites [Section titled “Prerequisites”](#prerequisites) 1. **Install the `bw` CLI** from a trusted source. Install from trusted sources only Install the official CLI via a trusted channel and keep it up to date. Supply-chain incidents have affected the broader Bitwarden CLI ecosystem in the past. varlock never installs `bw` for you; it only invokes whatever `bw` is already on your `PATH`. ```bash # macOS brew install bitwarden-cli # Linux snap install bw # Windows choco install bitwarden-cli ``` Or download the official binary from the [Bitwarden CLI docs](https://bitwarden.com/help/cli/). 2. **Log in once** with your account: ```bash bw login # For Vaultwarden / self-hosted, point the CLI at your server first: # bw config server https://vault.yourcompany.com ``` varlock handles unlocking from here on, so you don’t need to run `bw unlock` yourself. ### Basic usage [Section titled “Basic usage”](#basic-usage-1) .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/bitwarden-plugin) # # 2. Initialize the Password Manager instance (no token needed) # @initBwp() # --- # 3. Load values from vault items by name or UUID DATABASE_PASSWORD=bwp("Production DB") ``` On the first `varlock load`, you’ll be prompted for your master password once; the resulting session token is cached and reused (`sessionTtl`, default `forever`). The `bw` CLI session does **not** lock on system sleep, lock, or restart (those vault-timeout settings apply to the Bitwarden apps and extension, not the CLI), so in practice you re-enter the master password only when the session is explicitly invalidated (see below) or when you set a shorter `sessionTtl`. Using `bw` outside varlock can invalidate the cached session Each `bw unlock` (and `bw login`) issues a new session key and **invalidates any previously issued ones**; `bw lock` / `bw logout` invalidate them too. So if you run those commands in your own scripts or shell (or keep a `BW_SESSION` exported elsewhere), varlock’s cached token gets invalidated (and vice-versa: varlock unlocking invalidates your shell’s `BW_SESSION`). Plain reads (`bw get`, `bw list`, `bw sync`) are fine and don’t invalidate anything. This is not fatal: the next `varlock` load detects the stale session and re-unlocks once, at the cost of one extra master-password prompt. If you frequently `bw unlock` outside varlock, consider letting varlock own the unlock (don’t re-unlock manually) or pass a shared `sessionToken=$BWP_SESSION` so both sides use the same token. ### Selecting a field [Section titled “Selecting a field”](#selecting-a-field) By default `bwp()` returns the item’s **password**. Use the `field` argument to fetch a different field. Standard fields are `password`, `username`, `notes`, `totp`, and `uri`; any other name is matched against the item’s custom fields (case-insensitive). .env.schema ```env-spec DB_USER=bwp("Production DB", field=username) DB_PASSWORD=bwp("Production DB", field=password) # the stored TOTP secret/seed, not a generated 6-digit code (see note below) API_TOTP_SEED=bwp("Some Service", field=totp) # custom field on the item STRIPE_KEY=bwp("Stripe", field="secret-key") ``` `totp` returns the secret, not a code `field=totp` returns the **stored TOTP secret/seed** (a base32 key or `otpauth://` URI), not a generated 6-digit code. It is read verbatim from `bw get item`, the same way every other field is. There is no field that returns a live code: a one-time code rotates every \~30 seconds, but config is resolved once at load time, so a captured code would be stale before it could be used. ### Caching and TTLs [Section titled “Caching and TTLs”](#caching-and-ttls) .env.schema ```env-spec # @initBwp(sessionTtl="1h", cacheTtl="5m") ``` * `sessionTtl` (default `forever`): how long the unlocked CLI session token is cached before varlock forces a fresh `bw unlock`. `forever` keeps the token until the CLI session is invalidated externally (`bw lock`/`bw logout`, an external `bw unlock`, or account policy), at which point the next load re-unlocks automatically; set e.g. `"1h"` to force periodic master-password re-auth. * `cacheTtl` (optional): additionally cache the **resolved item values**, avoiding a `bw` invocation per value within the window. See the [Caching guide](/guides/caching/). ### Non-interactive loads [Section titled “Non-interactive loads”](#non-interactive-loads) Interactive unlock needs a TTY, so a non-interactive load (a dev server started by a tool that has no TTY, an editor task runner, etc.) can’t show the master-password prompt. How you fix it depends on the environment: **Local dev, using auto-unlock**: if you’re letting varlock unlock the vault itself (no `sessionToken` / `masterPassword` configured on `@initBwp()`), run `varlock load` once in a regular terminal (`npx varlock load`, `pnpm exec varlock load`, etc.). That performs the interactive unlock and caches the session token in varlock’s encrypted cache, so subsequent non-interactive commands reuse it without prompting (until the session is invalidated, see `sessionTtl` above). This only applies to the auto-unlock flow. If you’ve configured `sessionToken=` or `masterPassword=`, no interactive `varlock load` is needed. **CI / truly headless**: there’s no terminal to unlock from, so provide credentials explicitly instead: .env.schema ```env-spec # Option A: pass a pre-obtained session token (from `bw unlock --raw`) # @initBwp(sessionToken=$BWP_SESSION) # Option B: let varlock unlock non-interactively with a master password # @initBwp(masterPassword=$BW_MASTER_PASSWORD) # --- # @type=bwSessionToken @sensitive @internal BWP_SESSION= ``` ### Multiple accounts / servers [Section titled “Multiple accounts / servers”](#multiple-accounts--servers) The `bw` CLI keeps **one logged-in account per data directory**, so to read from more than one account or server (e.g. personal + work, or bitwarden.com + a self-hosted Vaultwarden) you give each instance its own data dir via `appDataDir`. Set up each dir once with the `bw` CLI (`bw config server …` if needed, then `bw login`), then point an instance at it. Since a data-dir path is specific to your machine, reference it from a per-developer value rather than hardcoding it: .env.schema ```env-spec # @initBwp(id=personal, appDataDir=$BWP_PERSONAL_DIR) # @initBwp(id=work, appDataDir=$BWP_WORK_DIR) # --- # bw data dirs, set per-machine in .env.local (a leading ~ is expanded) BWP_PERSONAL_DIR= BWP_WORK_DIR= PERSONAL_TOKEN=bwp(personal, "My Token") WORK_DB=bwp(work, "Work DB", field=password) ``` varlock unlocks and caches a session token **per account** (keyed by `appDataDir`), so the two accounts never share or invalidate each other’s sessions. `appDataDir` is machine-specific A data-dir path points at a directory on *your* machine, so avoid hardcoding it in a shared `.env.schema`. There’s no way to override an already-declared instance’s settings from another file, so make the **value** local rather than the instance: reference `appDataDir=$BWP_WORK_DIR` and set that var in your (gitignored) `.env.local`. If the whole multi-account workflow is personal, you can instead declare the entire `@initBwp(...)` in `.env.local`; root decorators are honored there too. Caution Without distinct `appDataDir`s, multiple instances all point at the same global `bw` account, so `bwp(personal, …)` and `bwp(work, …)` would read the *same* vault. The `appDataDir` is what makes them separate. (This differs from Secrets Manager, where each instance has its own `accessToken`.) *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initBitwarden()` [Section titled “@initBitwarden()”](#initbitwarden) Initialize a Bitwarden Secrets Manager plugin instance for accessing secrets. **Key/value args:** * `accessToken` (required): Machine account access token. Should be a reference to a config item of type `bitwardenAccessToken`. * `apiUrl` (optional): API URL for self-hosted Bitwarden (defaults to `https://api.bitwarden.com`) * `identityUrl` (optional): Identity service URL for self-hosted Bitwarden (defaults to `https://identity.bitwarden.com`) * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). * `id` (optional): Instance identifier for multiple instances ```env-spec # @initBitwarden(accessToken=$BITWARDEN_ACCESS_TOKEN) # --- # @type=bitwardenAccessToken @sensitive @internal BITWARDEN_ACCESS_TOKEN= ``` #### `@initBwp()` [Section titled “@initBwp()”](#initbwp) Initialize a Password Manager / Vaultwarden instance accessed via the `bw` CLI. With no arguments, varlock unlocks the vault interactively on first use and caches the session token. **Key/value args (all optional):** * `sessionToken`: a pre-obtained CLI session token (e.g. `$BWP_SESSION`). When provided and non-empty, it’s used as-is instead of auto-unlocking. * `masterPassword`: master password used to unlock the vault non-interactively (e.g. in CI). Reference a `@sensitive` config item. * `sessionTtl`: how long the auto-unlocked session token is cached before a fresh `bw unlock` (default `forever`, kept until the CLI session is invalidated externally; e.g. `"1h"` to force periodic master-password re-auth). * `cacheTtl`: cache resolved item values for the specified duration. See the [Caching guide](/guides/caching/). * `appDataDir`: bw CLI data directory (`BITWARDENCLI_APPDATA_DIR`) for this instance. Point separate instances at separate dirs to read from different accounts/servers. A leading `~` is expanded. The dir must be set up independently (`bw config server` + `bw login`). * `id`: instance identifier for multiple instances. ```env-spec # @initBwp(sessionTtl="1h") # --- DB_PASSWORD=bwp("Production DB") ``` ### Data types [Section titled “Data types”](#data-types) #### `bitwardenAccessToken` [Section titled “bitwardenAccessToken”](#bitwardenaccesstoken) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Represents a Bitwarden Secrets Manager machine account access token. Validation ensures the token is in the correct format (`0..:`). Note that the type itself is marked as `@sensitive`, so adding an explicit `@sensitive` decorator is optional. ```env-spec # @type=bitwardenAccessToken BITWARDEN_ACCESS_TOKEN= ``` #### `bitwardenSecretId` [Section titled “bitwardenSecretId”](#bitwardensecretid) Represents a secret UUID in Bitwarden Secrets Manager. Validation ensures the ID is a valid UUID format. ```env-spec # @type=bitwardenSecretId MY_SECRET_ID=12345678-1234-1234-1234-123456789abc ``` #### `bitwardenOrganizationId` [Section titled “bitwardenOrganizationId”](#bitwardenorganizationid) Represents an organization UUID in Bitwarden. Validation ensures the ID is a valid UUID format. ```env-spec # @type=bitwardenOrganizationId BITWARDEN_ORG_ID=87654321-4321-4321-4321-cba987654321 ``` #### `bwSessionToken` [Section titled “bwSessionToken”](#bwsessiontoken) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Represents a Bitwarden CLI session token (the output of `bw unlock`). Only needed when supplying a token manually for non-interactive use. Marked `@sensitive`. ```env-spec # @type=bwSessionToken BWP_SESSION= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `bitwarden()` [Section titled “bitwarden()”](#bitwarden) Fetch a secret from Bitwarden Secrets Manager by UUID. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `secretId` (required): secret UUID in the format `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` ```env-spec # Fetch by secret UUID DATABASE_URL=bitwarden("12345678-1234-1234-1234-123456789abc") # With instance ID PROD_SECRET=bitwarden(prod, "11111111-1111-1111-1111-111111111111") ``` #### `bwp()` [Section titled “bwp()”](#bwp) Fetch a field from a Password Manager / Vaultwarden vault item via the `bw` CLI. **Args:** * `instanceId` (optional, positional): instance identifier when multiple `@initBwp()` instances are initialized * `item` (required, positional): item name or UUID to look up * `field` (optional, key/value): which field to return. One of `password` (default), `username`, `notes`, `totp` (the stored TOTP secret/seed, not a generated code), `uri`, or a custom field name ```env-spec # Default field (password) DB_PASSWORD=bwp("Production DB") # Specific field DB_USER=bwp("Production DB", field=username) # With instance ID WORK_DB=bwp(work, "Work DB", field=password) ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Secret not found [Section titled “Secret not found”](#secret-not-found) * Verify the secret UUID is correct (must be valid UUID format) * Check that the secret exists in your Bitwarden Secrets Manager * Ensure your machine account has access to the secret or its project ### Permission denied [Section titled “Permission denied”](#permission-denied) * Verify your machine account has “Can read” or “Can read, write” permissions * Check that the machine account has access to the specific secret * Review the access settings in Bitwarden Secrets Manager console ### Authentication failed [Section titled “Authentication failed”](#authentication-failed) * Verify the access token is correct * Check if the access token has been revoked or expired * Ensure the machine account is not disabled * For self-hosted: verify `apiUrl` and `identityUrl` are correct ### Invalid UUID format [Section titled “Invalid UUID format”](#invalid-uuid-format) * Secret IDs must be valid UUIDs: `12345678-1234-1234-1234-123456789abc` * Check for typos or incorrect format * UUIDs should contain 32 hexadecimal characters and 4 hyphens ### Password Manager (`bwp()`) [Section titled “Password Manager (bwp())”](#password-manager-bwp) * **`bw` not found**: install the CLI from a trusted source and ensure it’s on your `PATH`. * **Not logged in**: run `bw login` once (for Vaultwarden, also `bw config server ` first). * **Cannot unlock without an interactive terminal**: you’re auto-unlocking (no `sessionToken` / `masterPassword` configured) in a non-TTY context. For local dev, run `varlock load` once in a regular terminal to unlock and cache the session token; for CI / headless, provide `sessionToken=$BWP_SESSION` or `masterPassword=...` to `@initBwp()`. See [Non-interactive loads](#non-interactive-loads). * **Item / field not found**: verify the item name or UUID; the error lists the item’s available custom fields. ## Resources [Section titled “Resources”](#resources) * [Bitwarden Secrets Manager](https://bitwarden.com/products/secrets-manager/) * [Machine Accounts Documentation](https://bitwarden.com/help/machine-accounts/) * [Self-Hosting Bitwarden](https://bitwarden.com/help/manage-your-secrets-org/#self-hosting) * [Bitwarden CLI](https://bitwarden.com/help/cli/) * [Vaultwarden](https://github.com/dani-garcia/vaultwarden) # Dashlane Plugin > Using Dashlane with Varlock [![](https://img.shields.io/npm/v/@varlock/dashlane-plugin?label=%40varlock%2Fdashlane-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/dashlane-plugin) Our [Dashlane](https://www.dashlane.com/) plugin enables secure loading of secrets from Dashlane using declarative instructions within your `.env` files. It shells out to the official Dashlane CLI (`dcli`) to resolve secret references in the `dl://` format. ## Features [Section titled “Features”](#features) * **Secret references via `dl://` URIs** (e.g. `dl:///password`) * **Headless CI/CD support** via service device keys for non-interactive authentication * **Optional vault sync** before reads with `autoSync` * **Automatic vault locking** on process exit in headless mode (configurable via `lockOnExit`) * **In-session caching** per resolution run * **Multiple instances** for accessing different Dashlane accounts * **Helpful error messages** with resolution tips ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/dashlane-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. ### Prerequisites [Section titled “Prerequisites”](#prerequisites) You must have `dcli` (Dashlane CLI) installed and available in your `PATH`. See the [Dashlane CLI installation docs](https://cli.dashlane.com/installation) for setup instructions. Note The plugin does **not** fail at load time if `dcli` is not installed. It only fails when you actually try to access a secret, making it safe to include in shared configs where some developers may not have dcli set up. ### Authentication [Section titled “Authentication”](#authentication) The plugin supports two authentication modes: **Interactive (local dev):** If you are already logged into `dcli`, just initialize without credentials: .env.schema ```env-spec # @plugin(@varlock/dashlane-plugin) # @initDashlane() ``` **Headless (CI/prod):** Use [service device keys](https://cli.dashlane.com/personal/devices) for non-interactive authentication: .env.schema ```env-spec # @plugin(@varlock/dashlane-plugin) # @initDashlane(serviceDeviceKeys=$DASHLANE_SERVICE_DEVICE_KEYS) # --- # @type(dashlaneDeviceKeys) DASHLANE_SERVICE_DEVICE_KEYS= ``` In headless mode (when `serviceDeviceKeys` is provided), the plugin automatically locks the vault when the process exits. This is best-effort and may not run in all exit scenarios (e.g. `SIGKILL`). You can control this behavior with the `lockOnExit` param. Lock your vault In interactive mode, the vault is **not** locked automatically on exit (to avoid requiring master password re-entry during live-reload). Always run `dcli lock` when you are done working. You can override this by setting `lockOnExit=true`. ### Vault sync [Section titled “Vault sync”](#vault-sync) The plugin does not sync the vault by default. To run `dcli sync` once before the first secret read, set `autoSync=true`: .env.schema ```env-spec # @plugin(@varlock/dashlane-plugin) # @initDashlane(serviceDeviceKeys=$DASHLANE_SERVICE_DEVICE_KEYS, autoSync=true) ``` Sync failures are non-blocking. If the sync fails (e.g. network issues), the plugin still reads from the existing local vault. ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) Access multiple Dashlane accounts by providing an `id`: .env.schema ```env-spec # @plugin(@varlock/dashlane-plugin) # @initDashlane(id=personal) # @initDashlane(id=team, serviceDeviceKeys=$TEAM_DASHLANE_KEYS) # --- MY_TOKEN=dashlane(personal, "dl://abc123/password") SHARED_KEY=dashlane(team, "dl://def456/password") ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Use the `dashlane()` resolver function to fetch a secret by its `dl://` reference: .env.schema ```env-spec # @plugin(@varlock/dashlane-plugin) # @initDashlane() # --- DB_PASSWORD=dashlane("dl://abc123/password") API_KEY=dashlane("dl://def456/password") ``` Tip Use ID-based references (`dl:///field`) for fast, reliable lookups. Title-based references require full vault decryption and are slower. When you have multiple plugin instances, pass the instance id as the first argument: .env.schema ```env-spec DB_PASSWORD=dashlane(prod, "dl://abc123/password") ``` ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initDashlane()` [Section titled “@initDashlane()”](#initdashlane) Initialize a Dashlane plugin instance for `dashlane()` resolver. **Key/value args:** * `id` (optional): instance identifier for multiple instances * `serviceDeviceKeys` (optional): service device keys for headless authentication * `autoSync` (optional): if `true`, runs `dcli sync` once before the first read * `lockOnExit` (optional): lock the vault on process exit. Defaults to `true` in headless mode, `false` in interactive mode. * `allowMissing` (optional): if `true`, entries that do not exist in the vault resolve as empty instead of failing. Only applies to missing entries: a locked vault or timeout still fails. Can be overridden per item. * `timeoutMs` (optional): maximum time in milliseconds to wait for each `dcli` call (default `30000`) ```env-spec # Interactive (local dev) # @initDashlane() # Headless (CI/prod) with auto-sync # @initDashlane(serviceDeviceKeys=$DASHLANE_SERVICE_DEVICE_KEYS, autoSync=true) # Named instance # @initDashlane(id=prod, serviceDeviceKeys=$DASHLANE_SERVICE_DEVICE_KEYS) ``` ### Data types [Section titled “Data types”](#data-types) #### `dashlaneDeviceKeys` [Section titled “dashlaneDeviceKeys”](#dashlanedevicekeys) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Service device keys for non-interactive Dashlane CLI authentication. Must start with `dls_`. See: [Dashlane CLI device registration](https://cli.dashlane.com/personal/devices) ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `dashlane()` [Section titled “dashlane()”](#dashlane) Fetch a secret from Dashlane by `dl://` reference. **Array args:** * `instanceId` (optional, if 2 args): instance identifier * `dlReference` (required): `dl://` secret reference URI **Key/value args:** * `allowMissing` (optional): if `true`, resolves as empty when the entry does not exist in the vault, instead of failing. Overrides the instance-level `allowMissing` setting. A locked vault or timeout still fails. ```env-spec # Default instance DB_PASSWORD=dashlane("dl://abc123/password") # With explicit instance DB_PASSWORD=dashlane(prod, "dl://abc123/password") # Tolerate a missing entry (item must not be required) # @optional OPTIONAL_TOKEN=dashlane("dl://abc123/token", allowMissing=true) ``` ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### `dcli` command not found [Section titled “dcli command not found”](#dcli-command-not-found) * Install dcli following the [installation docs](https://cli.dashlane.com/installation) * Ensure `dcli` is in your `PATH` ### Authentication failed [Section titled “Authentication failed”](#authentication-failed) * Verify you are logged in: `dcli sync` * For headless auth, check that `DASHLANE_SERVICE_DEVICE_KEYS` is set correctly * See [Dashlane CLI authentication](https://cli.dashlane.com/personal/authentication) for details ### Entry not found [Section titled “Entry not found”](#entry-not-found) * Verify the entry exists: `dcli password -o json | jq '.[].title'` * Use the entry ID for reliable lookups: `dashlane("dl:///password")` * Set `allowMissing=true` (per item or in `@initDashlane`) if a missing entry should resolve as empty instead of failing ### Vault locked or not synced [Section titled “Vault locked or not synced”](#vault-locked-or-not-synced) * Run `dcli sync` to sync your vault * Set `autoSync=true` in `@initDashlane` to sync automatically before reads `dcli` calls run with stdin closed and are killed after `timeoutMs` (default 30 seconds), so a locked vault fails fast instead of hanging the load. ## Resources [Section titled “Resources”](#resources) * [Dashlane CLI documentation](https://cli.dashlane.com/) * [Dashlane CLI device registration](https://cli.dashlane.com/personal/devices) * [Dashlane CLI secret references](https://cli.dashlane.com/personal/secrets/read) # Doppler Plugin > Using Doppler with Varlock [![](https://img.shields.io/npm/v/@varlock/doppler-plugin?label=%40varlock%2Fdoppler-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/doppler-plugin) Our [Doppler](https://www.doppler.com/) plugin enables secure loading of secrets from Doppler using declarative instructions within your `.env` files. The plugin uses service tokens for programmatic access to your Doppler secrets, making it suitable for both local development and production environments. ## Features [Section titled “Features”](#features) * **Fetch secrets** from Doppler projects and configs * **Bulk-load secrets** with `dopplerBulk()` via `@setValuesBulk` * **Service token authentication** for secure, scoped API access * **Efficient caching**: a single API call is shared across all secret lookups in the same config * **Multiple plugin instances** for different projects/configs * **Auto-infer secret names** from variable names for convenience * **Helpful error messages** with resolution tips ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/doppler-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/doppler-plugin) # # 2. Initialize the plugin - see below for more details on options # @initDoppler( # project=my-project, # config=dev, # serviceToken=$DOPPLER_TOKEN # ) # --- # 3. Add your service token # @type=dopplerServiceToken @sensitive @internal DOPPLER_TOKEN= ``` ### Service token setup [Section titled “Service token setup”](#service-token-setup) 1. **Navigate to your Doppler project config** Go to the [Doppler dashboard](https://dashboard.doppler.com), select your project, and open the config (e.g., `dev`, `stg`, `prd`) you want to access. 2. **Generate a service token** Click on **Access** → **Service Tokens** → **Generate Service Token**. Give it a descriptive name. 3. **Save the token** (displayed only once!) Copy the service token immediately. It will only be displayed once. Save token securely Store the service token securely. You won’t be able to see it again after this step! 4. **Wire up the token in your config** .env.schema ```env-spec # @plugin(@varlock/doppler-plugin) # @initDoppler( # project=my-project, # config=dev, # serviceToken=$DOPPLER_TOKEN # ) # --- # @type=dopplerServiceToken @sensitive @internal DOPPLER_TOKEN= ``` 5. **Set the token in your environment** Use your CI/CD system or platform’s env var management to securely inject the `DOPPLER_TOKEN` value. For detailed instructions, see [Doppler Service Tokens documentation](https://docs.doppler.com/docs/service-tokens). ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) If you need to connect to multiple projects or configs, register multiple named instances: .env.schema ```env-spec # @initDoppler(id=dev, project=my-app, config=dev, serviceToken=$DEV_DOPPLER_TOKEN) # @initDoppler(id=prod, project=my-app, config=prd, serviceToken=$PROD_DOPPLER_TOKEN) # --- DEV_DATABASE=doppler(dev, "DATABASE_URL") PROD_DATABASE=doppler(prod, "DATABASE_URL") ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values using the `doppler()` resolver function. ### Basic usage [Section titled “Basic usage”](#basic-usage) Fetch secrets from Doppler: .env.schema ```env-spec # Secret name defaults to the config item key DATABASE_URL=doppler() API_KEY=doppler() # Or explicitly specify the secret name STRIPE_SECRET=doppler("STRIPE_SECRET_KEY") ``` When called without arguments, `doppler()` automatically uses the config item key as the secret name in Doppler. This provides a convenient convention-over-configuration approach. ### Using a named instance [Section titled “Using a named instance”](#using-a-named-instance) .env.schema ```env-spec # @initDoppler(id=backend, project=backend-app, config=dev, serviceToken=$BACKEND_TOKEN) # --- DB_HOST=doppler(backend, "DB_HOST") DB_PASSWORD=doppler(backend, "DB_PASSWORD") ``` ### Bulk loading secrets [Section titled “Bulk loading secrets”](#bulk-loading-secrets) Use `dopplerBulk()` with `@setValuesBulk` to load all secrets from a Doppler config at once, instead of wiring up each secret individually: .env.schema ```env-spec # @plugin(@varlock/doppler-plugin) # @initDoppler(project=my-project, config=dev, serviceToken=$DOPPLER_TOKEN) # @setValuesBulk(dopplerBulk()) # --- # @type=dopplerServiceToken @sensitive @internal DOPPLER_TOKEN= API_KEY= DB_PASSWORD= REDIS_URL= ``` With a named instance: .env.schema ```env-spec # @setValuesBulk(dopplerBulk(prod)) ``` *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initDoppler()` [Section titled “@initDoppler()”](#initdoppler) Initialize a Doppler plugin instance for accessing secrets. **Key/value args:** * `project` (required): Doppler project name * `config` (required): Config name (e.g., `dev`, `stg`, `prd`, or a branch config) * `serviceToken` (required): Doppler service token. Should be a reference to a config item of type `dopplerServiceToken`. * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). * `id` (optional): Instance identifier for multiple instances ```env-spec # @initDoppler( # project=my-project, # config=dev, # serviceToken=$DOPPLER_TOKEN, # cacheTtl="1h" # ) # --- # @type=dopplerServiceToken @sensitive @internal DOPPLER_TOKEN= ``` ### Data types [Section titled “Data types”](#data-types) #### `dopplerServiceToken` [Section titled “dopplerServiceToken”](#dopplerservicetoken) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Represents a Doppler service token. This type is marked as `@sensitive`. ```env-spec # @type=dopplerServiceToken @sensitive @internal DOPPLER_TOKEN= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `doppler()` [Section titled “doppler()”](#doppler) Fetch a secret from Doppler. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `secretName` (optional): secret name in Doppler. If omitted, uses the variable name. ```env-spec # Auto-infer secret name from variable DATABASE_URL=doppler() # Explicit secret name STRIPE_KEY=doppler("STRIPE_SECRET_KEY") # With instance ID DEV_SECRET=doppler(dev, "DATABASE_URL") ``` #### `dopplerBulk()` [Section titled “dopplerBulk()”](#dopplerbulk) Bulk-load all secrets from a Doppler config. Intended for use with `@setValuesBulk`. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized ```env-spec # Load all secrets from default instance # @setValuesBulk(dopplerBulk()) # With instance ID # @setValuesBulk(dopplerBulk(prod)) ``` *** ## Example Configurations [Section titled “Example Configurations”](#example-configurations) ### Development setup with auto-named secrets [Section titled “Development setup with auto-named secrets”](#development-setup-with-auto-named-secrets) .env.schema ```env-spec # @plugin(@varlock/doppler-plugin) # @initDoppler(project=my-app, config=dev, serviceToken=$DOPPLER_TOKEN) # --- # @type=dopplerServiceToken @sensitive @internal DOPPLER_TOKEN= # Secret names automatically match config keys DATABASE_URL=doppler() REDIS_URL=doppler() STRIPE_KEY=doppler() ``` ### Multi-environment setup [Section titled “Multi-environment setup”](#multi-environment-setup) .env.schema ```env-spec # @plugin(@varlock/doppler-plugin) # @initDoppler(id=dev, project=my-app, config=dev, serviceToken=$DEV_DOPPLER_TOKEN) # @initDoppler(id=staging, project=my-app, config=stg, serviceToken=$STG_DOPPLER_TOKEN) # @initDoppler(id=prod, project=my-app, config=prd, serviceToken=$PROD_DOPPLER_TOKEN) # --- DEV_DATABASE=doppler(dev, "DATABASE_URL") STAGING_DATABASE=doppler(staging, "DATABASE_URL") PROD_DATABASE=doppler(prod, "DATABASE_URL") ``` ### Bulk loading for simple setups [Section titled “Bulk loading for simple setups”](#bulk-loading-for-simple-setups) .env.schema ```env-spec # @plugin(@varlock/doppler-plugin) # @initDoppler(project=my-app, config=dev, serviceToken=$DOPPLER_TOKEN) # @setValuesBulk(dopplerBulk()) # --- # @type=dopplerServiceToken @sensitive @internal DOPPLER_TOKEN= # These will be populated from Doppler secrets with matching names DATABASE_URL= API_KEY= STRIPE_SECRET_KEY= SENDGRID_API_KEY= ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Secret not found [Section titled “Secret not found”](#secret-not-found) * Verify the secret exists in your Doppler project config * Check the secret name matches exactly (case-sensitive) * Ensure you’re looking at the correct config (dev vs stg vs prd) ### Authentication failed [Section titled “Authentication failed”](#authentication-failed) * Verify the service token is correct and not expired * Generate a new service token from the Doppler dashboard * Check that the service token has access to the requested project/config ### Access denied [Section titled “Access denied”](#access-denied) * Service tokens are scoped to a specific config, so ensure you’re using the right one * Verify the token hasn’t been revoked ### Wrong config [Section titled “Wrong config”](#wrong-config) * Double-check the `config` parameter matches the Doppler config where your secrets are stored * Remember Doppler configs are hierarchical (root → development/staging/production → branch configs) ## Resources [Section titled “Resources”](#resources) * [Doppler Documentation](https://docs.doppler.com) * [Service Tokens](https://docs.doppler.com/docs/service-tokens) * [Doppler API Reference](https://docs.doppler.com/reference) * [Projects and Configs](https://docs.doppler.com/docs/enclave-project-setup) # Google Secret Manager Plugin > Using Google Cloud Secret Manager with Varlock [![](https://img.shields.io/npm/v/@varlock/google-secret-manager-plugin?label=%40varlock%2Fgoogle-secret-manager-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/google-secret-manager-plugin) Our [Google Cloud Secret Manager](https://cloud.google.com/secret-manager) plugin enables secure loading of secrets from GCP Secret Manager using declarative instructions within your `.env` files. It supports authentication via [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials), explicitly passing in a service account JSON key, or [OIDC Workload Identity Federation](/guides/oidc/). **Key features:** * ✅ Automatic secret naming using config item keys * ✅ Application Default Credentials or Service Account authentication * ✅ [OIDC Workload Identity Federation](/guides/oidc/) for platform-based authentication (Vercel, GitHub Actions, etc.) * ✅ Versioned secret access * ✅ Multiple plugin instances for different projects ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/google-secret-manager-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/google-secret-manager-plugin) # # 2. Initialize the plugin - see below for more details on options # @initGsm(projectId=my-gcp-project) ``` Project ID The `projectId` parameter is optional if your service account JSON includes a `project_id` field, or if you have set `GOOGLE_CLOUD_PROJECT` in your environment. However, it’s recommended to set it explicitly for clarity. If you don’t provide `projectId` and are using ADC, the plugin will attempt to detect it from your `gcloud` configuration or environment variables. ### Using Application Default Credentials (ADC) - recommended [Section titled “Using Application Default Credentials (ADC) - recommended”](#using-application-default-credentials-adc---recommended) By default (when no `credentials` parameter is set), this plugin will use [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials) to authenticate with Google Cloud. This is the recommended way to authenticate for local dev, and within GCP. Within GCP, you will need to set up a [service account](https://cloud.google.com/iam/docs/service-accounts) with the correct permissions, and attach it to the resources where your code will be running. Outside of GCP, you may set up ADC credentials using the [`gcloud auth application-default login`](https://docs.cloud.google.com/sdk/gcloud/reference/auth/application-default/login) command, which will store credentials locally, and make them available for ADC. ### Using a service account key [Section titled “Using a service account key”](#using-a-service-account-key) In rare cases, it may be useful to pass in a service account key explicitly. This is useful for deployed environments other than GCP, or if you need to use a different service account than the one attached to your service. 1. **Create and download a JSON key** for your service account. This can be done via the [Cloud Console](https://console.cloud.google.com/iam-admin/serviceaccounts) or using the `gcloud` CLI. [docs](https://cloud.google.com/iam/docs/keys-create-delete) ```bash gcloud iam service-accounts keys create key.json \ --iam-account=SERVICE_ACCOUNT_EMAIL ``` 2. **Wire up the service account key in your config**. Add a config item of type `gcpServiceAccountJson` to hold the key value, and reference it when initializing the plugin. .env.schema ```diff # @plugin(@varlock/google-secret-manager-plugin) # @initGsm(projectId=my-gcp-project, credentials=$GCP_SA_KEY) # --- +# @type=gcpServiceAccountJson @sensitive @internal GCP_SA_KEY= ``` 3. **Set your service account key in deployed environments**. Copy the JSON key content from the file you downloaded, and set it in deployed environments using your platform’s env var management UI. Be sure to use the same name as you defined in your schema (e.g. `GCP_SA_KEY`). Keep service account keys secure Service account JSON keys are highly sensitive credentials. Store them securely and never commit them to version control. Consider using your platform’s secret management system to store the key itself. ### OIDC Workload Identity Federation [Section titled “OIDC Workload Identity Federation”](#oidc-workload-identity-federation) For deployments on platforms that issue OIDC tokens (Vercel, GitHub Actions, GitLab CI, Fly.io), you can use [OIDC workload identity federation](/guides/oidc/) to authenticate without a service account key. Varlock auto-detects the platform’s OIDC token and exchanges it for GCP credentials via Workload Identity Federation. **Provider-side setup:** 1. Create a Workload Identity Pool and Provider: ```bash gcloud iam workload-identity-pools create varlock-pool \ --location=global gcloud iam workload-identity-pools providers create-oidc vercel \ --location=global \ --workload-identity-pool=varlock-pool \ --issuer-uri=https://oidc.vercel.com \ --attribute-mapping="google.subject=assertion.sub" ``` 2. Grant the pool access to a service account: ```bash gcloud iam service-accounts add-iam-policy-binding \ my-sa@my-project.iam.gserviceaccount.com \ --role=roles/iam.workloadIdentityUser \ --member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/varlock-pool/*" ``` 3. Grant the service account Secret Manager access. **Varlock config:** .env.schema ```env-spec # @plugin(@varlock/google-secret-manager-plugin) # @initGsm( # projectId=my-project, # workloadIdentityProvider="//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/varlock-pool/providers/vercel", # serviceAccountEmail="my-sa@my-project.iam.gserviceaccount.com" # ) ``` The `workloadIdentityProvider` is the full resource name of the provider. For platforms not auto-detected, pass an explicit token via `oidcToken`. ### GCP Prerequisites [Section titled “GCP Prerequisites”](#gcp-prerequisites) If you are already using GCP Secret Manager, you likely have completed these steps already, but if not, you will need to do so before using this plugin: 1. **Enable the Secret Manager API** (if not already done) Go to the Google Cloud Console and enable the Secret Manager API for your project. 2. **Create a new service account** in your GCP project. This can be done via the [Google Cloud Console](https://console.cloud.google.com/iam-admin/serviceaccounts) or using the `gcloud` CLI. [docs](https://cloud.google.com/iam/docs/service-accounts-create) This service account, whether accessed using ADC or a service account key, will now serve as your *secret-zero*, granting access to the rest of your sensitive data stored in Secret Manager. For local-only workflows, you can use [device-local encryption](/guides/local-encryption/) to secure this value. 3. **Grant the service account permissions** to access secrets. The service account needs the `Secret Manager Secret Accessor` role (`roles/secretmanager.secretAccessor`) on the secrets or project level. [docs](https://cloud.google.com/secret-manager/docs/access-control) ```bash gcloud projects add-iam-policy-binding PROJECT_ID \ --member="serviceAccount:SERVICE_ACCOUNT_EMAIL" \ --role="roles/secretmanager.secretAccessor" ``` 4. **Attach the service account to GCP resources** You must [attach this service account](https://docs.cloud.google.com/iam/docs/attach-service-accounts#attaching-to-resources) to any resources where your code will be running. ## Pulling data from Secret Manager [Section titled “Pulling data from Secret Manager”](#pulling-data-from-secret-manager) Once the plugin is installed and initialized, you can start adding config items that load values from Google Secret Manager using the new `gsm()` resolver function. ### Basic Secret Fetching [Section titled “Basic Secret Fetching”](#basic-secret-fetching) ```env-spec # @plugin(@varlock/google-secret-manager-plugin) # @initGsm(projectId=my-project) # --- # Secret name defaults to the config item key SIMPLEST_VAR=gsm() # Or you can explicitly specify the secret name RENAMED_VAR=gsm("database-password") # You can fetch a specific version API_KEY_LATEST=gsm("api-key@latest") API_KEY_V5=gsm("api-key@5") # Use complete resource paths for maximum control: FULL_PATH_VAR=gsm("projects/my-project/secrets/db-url/versions/3") ``` Auto-naming When called without arguments, `gsm()` automatically uses the config item key as the secret name in Google Secret Manager (e.g., `DATABASE_URL=gsm()` will fetch a secret named `DATABASE_URL`). This provides a clean, convention-over-configuration approach when your secret names match your config keys. Secret versions If you don’t specify a version in the short format, `latest` will be used automatically. For the full path format, you must include the version (use `latest` if you want the most recent version). ### Multiple plugin instances [Section titled “Multiple plugin instances”](#multiple-plugin-instances) If you need to connect using different project ids, or different credentials, particularly at the same time, you can create multiple named instances, and then use that id when fetching secrets. ```env-spec # @plugin(@varlock/google-secret-manager-plugin) # @initGsm(id=prod, projectId=prod-project, credentials=$PROD_KEY) # @initGsm(id=dev, projectId=dev-project, credentials=$DEV_KEY) # --- PROD_DATABASE=gsm(prod, "database-url") DEV_DATABASE=gsm(dev, "database-url") ``` ### Dynamic project ID [Section titled “Dynamic project ID”](#dynamic-project-id) The `projectId` parameter supports dynamic resolution, allowing you to specify project IDs from environment variables or other resolver functions. This is useful when you need to use different project IDs based on your deployment environment. ```env-spec # @plugin(@varlock/google-secret-manager-plugin) # @initGsm(projectId=$GCP_PROJECT_ID) # --- # Use environment variable for project ID GCP_PROJECT_ID= API_KEY=gsm("api-key") ``` You can also use resolver functions to construct the project ID dynamically: ```env-spec # @plugin(@varlock/google-secret-manager-plugin) # @initGsm(projectId=concat($APP_ENV, "-project")) # --- APP_ENV= API_KEY=gsm("api-key") ``` *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initGsm()` [Section titled “@initGsm()”](#initgsm) Initializes an instance of the Google Secret Manager plugin - setting up options and authentication. Can be called multiple times to set up different instances. **Key/value args:** * `id` (optional): identifier for this instance, used when multiple instances are needed * `projectId` (optional): GCP project ID. Required for short secret reference format, or if credentials don’t include a `project_id` field * `credentials` (optional): service account JSON key. Should be a reference to a config item of type `gcpServiceAccountJson`. If omitted, Application Default Credentials will be used * `workloadIdentityProvider` (optional): full resource name of the Workload Identity Provider for OIDC authentication * `serviceAccountEmail` (optional): service account email to impersonate via Workload Identity Federation * `oidcToken` (optional): explicit OIDC JWT token (auto-detected from platform if omitted) * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). ```env-spec # @initGsm(id=prod, projectId=my-gcp-project, credentials=$GCP_SA_KEY) # --- # @type=gcpServiceAccountJson @sensitive @internal GCP_SA_KEY= ``` ### Data types [Section titled “Data types”](#data-types) #### `gcpServiceAccountJson` [Section titled “gcpServiceAccountJson”](#gcpserviceaccountjson) Represents a [Google Cloud service account JSON key](https://cloud.google.com/iam/docs/service-accounts). Validation ensures the JSON is valid and contains required fields (`type`, `project_id`, `private_key`, `client_email`). The type itself is marked as `@sensitive`, so adding an explicit `@sensitive` decorator is optional. ```env-spec # @type=gcpServiceAccountJson GCP_SA_KEY= ``` Does your app use these credentials too? The examples here mark the credential [`@internal`](/reference/item-decorators/#internal) so it’s used by varlock to fetch your secrets but **not** injected into your app. This is the recommended posture when varlock is the only consumer. Unlike the dedicated secrets-manager tokens, GCP service account keys are **not** `@internal` by default (they’re commonly read directly by the Google Cloud SDK), so if you need the credential at runtime for other purposes, opt out with `@internal=false` to keep it injected: ```env-spec # @type=gcpServiceAccountJson @internal=false GCP_SA_KEY= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `gsm()` [Section titled “gsm()”](#gsm) Fetches a secret value from Google Secret Manager **Signatures:** * `gsm()` - Fetch using config item key as secret name from default instance (e.g., `DATABASE_URL=gsm()` will fetch a secret named `DATABASE_URL`) * `gsm(secretRef)` - Fetch specific secret from default instance * `gsm(instanceId, secretRef)` - Fetch from named instance **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `secretReference` (optional): secret reference, either in short format (`"secret-name"` or `"secret-name@version"`) or full path format (`"projects/PROJECT_ID/secrets/SECRET_NAME/versions/VERSION"`). If omitted, uses the config item key as the secret name **Secret Reference Formats:** * `"secret-name"` - Uses latest version from configured project * `"secret-name@5"` - Specific version from configured project * `"projects/PROJECT/secrets/NAME/versions/VERSION"` - Full resource path **Returns:** The secret value as a string ```env-spec # Secret name defaults to the config item key SIMPLEST_VAR=gsm() # Or you can explicitly specify the secret name RENAMED_VAR=gsm("database-password") # You can fetch a specific version API_KEY_V5=gsm("api-key@5") # Use complete resource paths for maximum control: FULL_PATH_VAR=gsm("projects/my-project/secrets/db-url/versions/3") # Example using a plugin instance id PROD_SECRET=gsm(prod, "prod-secret") ``` # HashiCorp Vault Plugin > Using HashiCorp Vault and OpenBao with Varlock [![](https://img.shields.io/npm/v/@varlock/hashicorp-vault-plugin?label=%40varlock%2Fhashicorp-vault-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/hashicorp-vault-plugin) Our HashiCorp Vault plugin enables secure loading of secrets from [HashiCorp Vault](https://www.vaultproject.io/) (KV v2 secrets engine) and [OpenBao](https://openbao.org/) using declarative instructions within your `.env` files. The plugin supports multiple authentication methods including explicit tokens, AppRole for CI/CD, JWT/OIDC for platform-based workload identity, and automatic CLI token detection for local development. ## Features [Section titled “Features”](#features) * **Zero-config authentication** - Automatically uses Vault token from CLI login * **AppRole authentication** - For automated and CI/CD workflows * **Vault CLI integration** - Works with `vault login` for local development * **OpenBao compatible** - Works with OpenBao (detects `~/.bao-token` automatically) * **Auto-infer secret keys** from environment variable names * **JSON key extraction** from secrets using `#` syntax or named `key` parameter * **Path prefixing** with `pathPrefix` option for organized secret management * **Default path** support for sharing a common secret path across items * **[JWT/OIDC authentication](/guides/oidc/)** - Authenticate using platform OIDC tokens (Vercel, GitHub Actions, etc.) * Support for Vault Enterprise namespaces * Support for multiple Vault instances ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/hashicorp-vault-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/hashicorp-vault-plugin) # # 2. Initialize the plugin - see below for more details on options # @initHcpVault(url="https://vault.example.com:8200") ``` ### Authentication options [Section titled “Authentication options”](#authentication-options) The plugin tries authentication methods in this priority order: 1. **Explicit token** - If `token` is provided in `@initHcpVault()` 2. **JWT/OIDC** - If `jwtRole` is provided, uses [OIDC workload identity federation](/guides/oidc/) 3. **AppRole** - If both `roleId` and `secretId` are provided 4. **CLI token file** - From `~/.vault-token` (created by `vault login`) or `~/.bao-token` (created by `bao login` for OpenBao) ### Automatic authentication (Recommended for local dev) [Section titled “Automatic authentication (Recommended for local dev)”](#automatic-authentication-recommended-for-local-dev) For local development, just provide the Vault URL - the plugin will pick up your CLI token automatically: .env.schema ```env-spec # @plugin(@varlock/hashicorp-vault-plugin) # @initHcpVault(url="https://vault.example.com:8200") ``` **How this works:** * **Local development:** Run `vault login` → automatically uses the token from `~/.vault-token` * **OpenBao users:** Run `bao login` → automatically uses the token from `~/.bao-token` ### AppRole auth (For CI/CD and automated workflows) [Section titled “AppRole auth (For CI/CD and automated workflows)”](#approle-auth-for-cicd-and-automated-workflows) AppRole is the recommended auth method for CI/CD and server environments: 1. **Set up AppRole in Vault** (see Vault Setup section below) 2. **Wire up the credentials in your config**. Add config items for the role ID and secret ID, and reference them when initializing the plugin. .env.schema ```env-spec # @plugin(@varlock/hashicorp-vault-plugin) # @initHcpVault( # url="https://vault.example.com:8200", # roleId=$VAULT_ROLE_ID, # secretId=$VAULT_SECRET_ID # ) # --- VAULT_ROLE_ID= # @sensitive VAULT_SECRET_ID= ``` 3. **Set your credentials in deployed environments**. Use your platform’s env var management UI to securely inject these values. ### JWT/OIDC authentication [Section titled “JWT/OIDC authentication”](#jwtoidc-authentication) For deployments on platforms that issue OIDC tokens (Vercel, GitHub Actions, GitLab CI, Fly.io, GCP), you can use [OIDC workload identity federation](/guides/oidc/) to authenticate with Vault’s JWT/OIDC auth method. Varlock auto-detects the platform’s OIDC token and uses it to authenticate. **Provider-side setup:** 1. Enable the JWT auth method: ```bash vault auth enable jwt ``` 2. Configure it with the OIDC provider: ```bash vault write auth/jwt/config \ oidc_discovery_url="https://oidc.vercel.com" ``` 3. Create a role with bound claims: ```bash vault write auth/jwt/role/varlock-role \ role_type="jwt" \ bound_audiences="https://vault.example.com" \ user_claim="sub" \ policies="secrets-reader" \ ttl=1h ``` **Varlock config:** .env.schema ```env-spec # @plugin(@varlock/hashicorp-vault-plugin) # @initHcpVault(url="https://vault.example.com:8200", jwtRole="varlock-role") ``` You can optionally set `jwtAuthPath` if the JWT auth method is mounted at a non-default path. For platforms not auto-detected, pass an explicit token via `oidcToken`. ### Explicit token [Section titled “Explicit token”](#explicit-token) You can also provide a token directly: .env.schema ```env-spec # @initHcpVault( # url="https://vault.example.com:8200", # token=$VAULT_TOKEN # ) # --- # @type=vaultToken @sensitive @internal VAULT_TOKEN= ``` ### Vault Enterprise namespaces [Section titled “Vault Enterprise namespaces”](#vault-enterprise-namespaces) For Vault Enterprise, specify the namespace: .env.schema ```env-spec # @initHcpVault(url="https://vault.example.com:8200", namespace="admin/team-a") ``` ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) If you need to connect to multiple Vault instances, register named instances: .env.schema ```env-spec # @initHcpVault(id=prod, url="https://vault-prod.example.com:8200") # @initHcpVault(id=dev, url="https://vault-dev.example.com:8200") # --- PROD_KEY=vaultSecret(prod, "secret/api/keys#API_KEY") DEV_KEY=vaultSecret(dev, "secret/api/keys#API_KEY") ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values using the `vaultSecret()` resolver function. ### Basic usage [Section titled “Basic usage”](#basic-usage) Since Vault KV v2 always stores key/value pairs, the item key (variable name) is automatically used as the JSON key to extract from the secret: .env.schema ```env-spec # Fetches "secret/db/config" and extracts "DB_HOST" key DB_HOST=vaultSecret("secret/db/config") # Override the extracted key with # syntax DB_PASSWORD=vaultSecret("secret/db/config#password") # Or use named "key" parameter DB_PORT=vaultSecret("secret/db/config", key="PORT") # Fetch entire secret as JSON blob DB_CONFIG=vaultSecret("secret/db/config", raw=true) ``` ### Default path [Section titled “Default path”](#default-path) Use `defaultPath` to set a common path for secrets when no path argument is provided: .env.schema ```env-spec # @initHcpVault(url="https://vault.example.com:8200", defaultPath=secret/myapp/config) # --- # Both fetch from "secret/myapp/config" extracting item key DB_PASSWORD=vaultSecret() API_KEY=vaultSecret() # Override the inferred key using # syntax STRIPE_KEY=vaultSecret("#stripe_api_key") # Explicit path still extracts item key by default OTHER_SECRET=vaultSecret("secret/other/path") ``` ### Path prefixing [Section titled “Path prefixing”](#path-prefixing) Use `pathPrefix` to automatically prefix all secret paths for better organization: .env.schema ```env-spec # @initHcpVault(url="https://vault.example.com:8200", pathPrefix="secret/myapp") # --- # Fetches from "secret/myapp/db/config" DB_HOST=vaultSecret("db/config#HOST") ``` You can even use dynamic prefixes: .env.schema ```env-spec # @initHcpVault(url="https://vault.example.com:8200", pathPrefix="secret/${ENV}") # --- # In prod: fetches from "secret/prod/db/config" # In dev: fetches from "secret/dev/db/config" DB_HOST=vaultSecret("db/config#HOST") ``` ### Bulk loading secrets [Section titled “Bulk loading secrets”](#bulk-loading-secrets) Use `raw=true` with `@setValuesBulk` to load all key/value pairs from a Vault path at once, instead of wiring up each secret individually: .env.schema ```env-spec # @initHcpVault(url="https://vault.example.com:8200") # @setValuesBulk(vaultSecret("secret/myapp/config", raw=true)) # --- DB_HOST= DB_PASSWORD= API_KEY= ``` This fetches all keys stored at `secret/myapp/config` and maps them to matching item keys. Only items declared in your schema will be populated; any extra keys in the Vault secret are ignored. ### Exposing the client token [Section titled “Exposing the client token”](#exposing-the-client-token) Use `vaultToken()` when another tool needs the same Vault client token the plugin already authenticated with (for example OpenTofu OpenBao state encryption via `BAO_TOKEN`). The resolver returns the token from the active auth method (explicit token, AppRole, JWT/OIDC, or CLI file) and reuses the plugin’s cached login instead of authenticating again. .env.schema ```env-spec # @plugin(@varlock/hashicorp-vault-plugin@2.0.0) # @initHcpVault( # url=$BAO_ADDR, # jwtRole=$VAULT_ROLE, # jwtAuthPath="gitlab", # oidcToken=$GITLAB_OIDC # ) # --- # @sensitive BAO_TOKEN=vaultToken() # @sensitive BAO_TOKEN_PROD=vaultToken(prod) ``` Mark the item `@sensitive`. If you also use `@type=vaultToken`, set `@internal=false` so the value is injected for consumers (the type defaults to `@internal`). Prefer a consumer-specific name like `BAO_TOKEN` over `VAULT_TOKEN` when `VAULT_TOKEN` is already an input to `@initHcpVault(token=$VAULT_TOKEN)`, which would create a dependency cycle. With multiple instances, pass the instance id: `vaultToken(prod)`. *** ## Vault Setup [Section titled “Vault Setup”](#vault-setup) ### Enable KV v2 secrets engine [Section titled “Enable KV v2 secrets engine”](#enable-kv-v2-secrets-engine) ```bash # KV v2 is enabled by default at "secret/" in dev mode # For production, enable it explicitly: vault secrets enable -version=2 -path=secret kv ``` ### Create a policy [Section titled “Create a policy”](#create-a-policy) Create a policy that allows reading secrets: policy.hcl ```hcl path "secret/data/*" { capabilities = ["read"] } ``` ```bash vault policy write varlock-reader policy.hcl ``` Least privilege principle For production, scope down the `path` to only the specific secret paths your application needs. For example: `path "secret/data/myapp/*"` ### Set up AppRole auth (Recommended for CI/CD) [Section titled “Set up AppRole auth (Recommended for CI/CD)”](#set-up-approle-auth-recommended-for-cicd) 1. **Enable AppRole auth method** ```bash vault auth enable approle ``` 2. **Create a role** ```bash vault write auth/approle/role/varlock-role \ secret_id_ttl=24h \ token_ttl=1h \ token_max_ttl=4h \ token_policies=varlock-reader ``` 3. **Get the role ID and generate a secret ID** ```bash vault read auth/approle/role/varlock-role/role-id vault write -f auth/approle/role/varlock-role/secret-id ``` Save the `role_id` and `secret_id` from the output for your CI/CD configuration. ### Create a token (For simple setups) [Section titled “Create a token (For simple setups)”](#create-a-token-for-simple-setups) ```bash vault token create -policy=varlock-reader -ttl=24h ``` ### Store secrets [Section titled “Store secrets”](#store-secrets) ```bash # Store a single key/value vault kv put secret/myapp/config DB_PASSWORD=supersecret # Store multiple keys vault kv put secret/myapp/config \ DB_HOST=db.example.com \ DB_PASSWORD=supersecret \ API_KEY=abc123 ``` ### Vault CLI for local development [Section titled “Vault CLI for local development”](#vault-cli-for-local-development) 1. **Set the Vault address** ```bash export VAULT_ADDR="https://vault.example.com:8200" ``` 2. **Login to Vault** ```bash vault login ``` This writes a token to `~/.vault-token` which the plugin will automatically pick up. 3. **Test the configuration** ```bash vault kv get secret/myapp/config ``` *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initHcpVault()` [Section titled “@initHcpVault()”](#inithcpvault) Initialize a HashiCorp Vault / OpenBao plugin instance. **Key/value args:** * `url` (required): Vault server URL (e.g., `https://vault.example.com:8200`) * `token` (optional): Explicit Vault authentication token * `roleId` (optional): AppRole role ID for automated authentication * `secretId` (optional): AppRole secret ID for automated authentication * `namespace` (optional): Vault Enterprise namespace * `jwtRole` (optional): Vault JWT/OIDC auth role name for OIDC authentication * `jwtAuthPath` (optional): Mount path for the JWT auth method (defaults to `jwt`) * `oidcToken` (optional): Explicit OIDC JWT token (auto-detected from platform if omitted) * `defaultPath` (optional): Default secret path when no path argument is given to `vaultSecret()` * `pathPrefix` (optional): Prefix automatically prepended to all secret paths * `id` (optional): Instance identifier for multiple instances * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). ```env-spec # @initHcpVault(url="https://vault.example.com:8200", defaultPath=secret/myapp/config) ``` ### Data types [Section titled “Data types”](#data-types) #### `vaultToken` [Section titled “vaultToken”](#vaulttoken) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Represents a HashiCorp Vault authentication token. This type is marked as `@sensitive`. ```env-spec # @type=vaultToken VAULT_TOKEN= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `vaultSecret()` [Section titled “vaultSecret()”](#vaultsecret) Fetch a secret from HashiCorp Vault’s KV v2 secrets engine. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `secretRef` (optional): secret path, optionally with `#KEY` to override the extracted key. If omitted, uses `defaultPath`. Use `#key` (without a path) to override the inferred key while still using `defaultPath`. **Named args:** * `key` (optional): JSON key to extract from the secret (overrides `#KEY` syntax and item key default) * `raw` (optional): set to `true` to return all key/value pairs as a JSON blob instead of extracting a single key. Useful with `@setValuesBulk` for bulk loading. **Key extraction:** By default, the item key (variable name) is used as the JSON key. Override with `#KEY` or `key=`, or use `raw=true` to get everything. **How paths work:** Vault KV v2 stores key/value pairs at a path. Given a path like `secret/myapp/config`, the plugin calls `GET /v1/secret/data/myapp/config` (the first path segment is the mount point, and `/data/` is inserted for the KV v2 API). ```env-spec # Uses defaultPath, extracts item key DATABASE_URL=vaultSecret() # Override inferred key using # syntax (still uses defaultPath) STRIPE_KEY=vaultSecret("#stripe_api_key") # Explicit path, extracts item key "DB_HOST" DB_HOST=vaultSecret("secret/db/config") # Override key with # syntax DB_PASSWORD=vaultSecret("secret/db/config#password") # Extract key (named parameter) DB_PORT=vaultSecret("secret/db/config", key="PORT") # Fetch full secret as JSON DB_CONFIG=vaultSecret("secret/db/config", raw=true) # With instance ID PROD_SECRET=vaultSecret(prod, "secret/api/keys") ``` #### `vaultToken()` [Section titled “vaultToken()”](#vaulttoken-1) Returns the Vault client token for an initialized plugin instance. Useful when another tool (for example OpenTofu OpenBao state encryption) needs the same short-lived credential the plugin already obtained. Always treated as sensitive. Reuses the plugin’s existing auth cache (explicit token, AppRole, JWT/OIDC, or CLI file) instead of logging in again. **Array args:** * `instanceId` (optional): instance identifier when multiple plugin instances are initialized ```env-spec # @sensitive BAO_TOKEN=vaultToken() # With instance ID # @sensitive BAO_TOKEN_PROD=vaultToken(prod) ``` If you use `@type=vaultToken`, also set `@internal=false` so the value is injected. Do not assign `vaultToken()` to `VAULT_TOKEN` when that same item is passed as `@initHcpVault(token=$VAULT_TOKEN)` (dependency cycle). Prefer a consumer-specific name such as `BAO_TOKEN`. *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Secret not found [Section titled “Secret not found”](#secret-not-found) * Verify the secret exists: `vault kv get secret/myapp/config` * Check the mount point is correct (first path segment, typically `secret`) * Ensure you’re using KV v2, not KV v1 (different API format) ### Permission denied [Section titled “Permission denied”](#permission-denied) * Check your token’s policies: `vault token lookup` * Ensure your policy includes `read` capability on `secret/data/*` (note the `/data/` prefix for KV v2) * For AppRole: verify the role has the correct policies attached ### Authentication failed [Section titled “Authentication failed”](#authentication-failed) * **Local dev:** Run `vault login` (or `bao login` for OpenBao) and ensure `VAULT_ADDR` is set correctly * **CI/CD:** Verify your token or AppRole credentials are properly wired up in `@initHcpVault()` * Check if the token has expired: `vault token lookup` * For AppRole: verify the secret ID hasn’t expired and generate a new one if needed ### JSON key not found [Section titled “JSON key not found”](#json-key-not-found) * Verify the key exists at the path: `vault kv get -field=MY_KEY secret/myapp/config` * Key names are case-sensitive * Check available keys: `vault kv get secret/myapp/config` ## Resources [Section titled “Resources”](#resources) * [HashiCorp Vault](https://www.vaultproject.io/) * [OpenBao](https://openbao.org/) * [Vault KV v2 Secrets Engine](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2) * [Vault AppRole Auth Method](https://developer.hashicorp.com/vault/docs/auth/approle) * [Vault Tokens](https://developer.hashicorp.com/vault/docs/concepts/tokens) # Infisical Plugin > Using Infisical with Varlock [![](https://img.shields.io/npm/v/@varlock/infisical-plugin?label=%40varlock%2Finfisical-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/infisical-plugin) Our [Infisical](https://infisical.com/) plugin loads secrets from Infisical using declarative instructions within your `.env` files. The plugin uses machine identities with Universal Auth or [OIDC workload identity](/guides/oidc/) for programmatic access to your Infisical secrets, making it suitable for both CI/CD and production environments. ## Features [Section titled “Features”](#features) * **Fetch secrets** from Infisical projects and environments * **Bulk-load secrets** with `infisicalBulk()` via `@setValuesBulk` * **Universal Auth** with Client ID and Client Secret * **[OIDC authentication](/guides/oidc/)**: Authenticate using platform OIDC tokens (Vercel, GitHub Actions, etc.) * **Support for self-hosted** Infisical instances * **Secret paths** for hierarchical organization * **Filter by tag** to load only matching secrets * **Multiple plugin instances** for different projects/environments * **Auto-infer secret names** from variable names for convenience * **`allowMissing`** option for optional secrets * **Helpful error messages** with resolution tips ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/infisical-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/infisical-plugin) # # 2. Initialize the plugin - see below for more details on options # @initInfisical( # projectId=your-project-id, # environment=dev, # clientId=$INFISICAL_CLIENT_ID, # clientSecret=$INFISICAL_CLIENT_SECRET # ) # --- # 3. Add machine identity credentials # @type=infisicalClientId INFISICAL_CLIENT_ID= # @type=infisicalClientSecret @sensitive @internal INFISICAL_CLIENT_SECRET= ``` Note for EU users If you are using the Infisical EU Cloud, you must set `@initFisical(siteUrl=https://eu.infisical.com)` (See [Universal Auth Documentation](https://infisical.com/docs/documentation/platform/identities/universal-auth)) ### Machine identity setup [Section titled “Machine identity setup”](#machine-identity-setup) 1. **Create a machine identity** in Infisical Navigate to your Infisical project settings → **Access Control** → **Machine Identities** → Click **Create Identity**. 2. **Select Universal Auth** Choose **Universal Auth** as the authentication method. 3. **Save the credentials** (displayed only once!) Copy the **Client ID** and **Client Secret** immediately - they will only be displayed once. Save credentials securely Store the client ID and secret securely. You won’t be able to see them again after this step! 4. **Grant access to your project and environment** Ensure the machine identity has access to the specific project and environment you’ll be using. 5. **Wire up the credentials in your config** .env.schema ```env-spec # @plugin(@varlock/infisical-plugin) # @initInfisical( # projectId=your-project-id, # environment=dev, # clientId=$INFISICAL_CLIENT_ID, # clientSecret=$INFISICAL_CLIENT_SECRET # ) # --- # @type=infisicalClientId INFISICAL_CLIENT_ID= # @type=infisicalClientSecret @sensitive @internal INFISICAL_CLIENT_SECRET= ``` 6. **Set your credentials in environments** Use your CI/CD system or platform’s env var management to securely inject the credential values. For detailed instructions, see [Infisical Machine Identities documentation](https://infisical.com/docs/documentation/platform/identities/machine-identities). ### OIDC authentication [Section titled “OIDC authentication”](#oidc-authentication) For deployments on platforms that issue OIDC tokens (Vercel, GitHub Actions, GitLab CI, Fly.io, GCP), you can use [OIDC workload identity federation](/guides/oidc/) to authenticate without client credentials. Varlock auto-detects the platform’s OIDC token and uses it to authenticate via Infisical’s OIDC machine identity auth. **Provider-side setup:** 1. Create a Machine Identity in Infisical 2. Add an OIDC auth method to the identity, configuring the issuer and claims 3. Grant the identity access to your project **Varlock config:** .env.schema ```env-spec # @plugin(@varlock/infisical-plugin) # @initInfisical( # projectId=your-project-id, # environment=production, # identityId=your-identity-id # ) ``` No `clientId` or `clientSecret` is needed. The `identityId` parameter triggers OIDC authentication. For platforms not auto-detected, pass an explicit token via `oidcToken`. ### Self-hosted Infisical [Section titled “Self-hosted Infisical”](#self-hosted-infisical) For self-hosted Infisical instances, specify the `siteUrl`: .env.schema ```env-spec # @plugin(@varlock/infisical-plugin) # @initInfisical( # projectId=my-project, # environment=production, # clientId=$CLIENT_ID, # clientSecret=$CLIENT_SECRET, # siteUrl=https://infisical.mycompany.com # ) ``` `siteUrl` can also be set dynamically. Pass a reference like `siteUrl=$INFISICAL_SITE_URL` to point at different Infisical instances per environment. ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) If you need to connect to multiple projects or environments, register multiple named instances: .env.schema ```env-spec # @initInfisical(id=dev, projectId=dev-project, environment=development, clientId=$DEV_CLIENT_ID, clientSecret=$DEV_CLIENT_SECRET) # @initInfisical(id=prod, projectId=prod-project, environment=production, clientId=$PROD_CLIENT_ID, clientSecret=$PROD_CLIENT_SECRET) # --- DEV_DATABASE=infisical(dev, "DATABASE_URL") PROD_DATABASE=infisical(prod, "DATABASE_URL") ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values using the `infisical()` resolver function. ### Basic usage [Section titled “Basic usage”](#basic-usage) Fetch secrets from Infisical: .env.schema ```env-spec # Secret name defaults to the config item key DATABASE_URL=infisical() API_KEY=infisical() # Or explicitly specify the secret name STRIPE_SECRET=infisical("STRIPE_SECRET_KEY") ``` When called without arguments, `infisical()` uses the config item key as the secret name in Infisical. ### Optional secrets [Section titled “Optional secrets”](#optional-secrets) Use `allowMissing=true` on `infisical()` to return `undefined` when a secret doesn’t exist in Infisical, then combine with `fallback()` to provide a default value: .env.schema ```env-spec RESEND_API_KEY=fallback(infisical(allowMissing=true), undefined) SOME_OPTIONAL_KEY=fallback(infisical(allowMissing=true), "") ``` You can also set `allowMissing` on `@initInfisical()` to apply it to all `infisical()` calls for that instance. It can be a dynamic value, e.g. `forEnv(development)` to only allow missing secrets in dev: .env.schema ```env-spec # @initInfisical(projectId=my-project, environment=dev, clientId=$ID, clientSecret=$SECRET, allowMissing=forEnv(development)) ``` When `allowMissing` is enabled, mark optional items with `@required=false` or wrap the resolver with `fallback()` so validation does not fail. ### Using secret paths [Section titled “Using secret paths”](#using-secret-paths) Organize secrets with hierarchical paths: .env.schema ```env-spec # Default path for all secrets # @initInfisical(projectId=my-project, environment=production, clientId=$ID, clientSecret=$SECRET, secretPath=/production/app) # --- # Fetches from /production/app/DB_PASSWORD DB_PASSWORD=infisical("DB_PASSWORD") ``` Or specify path per secret: .env.schema ```env-spec # @initInfisical(projectId=my-project, environment=production, clientId=$ID, clientSecret=$SECRET) # --- DB_PASSWORD=infisical("DB_PASSWORD", "/database") API_KEY=infisical("API_KEY", "/api") ``` ### Bulk loading secrets [Section titled “Bulk loading secrets”](#bulk-loading-secrets) Use `infisicalBulk()` with `@setValuesBulk` to load all secrets from a project environment at once, instead of wiring up each secret individually: .env.schema ```env-spec # @plugin(@varlock/infisical-plugin) # @initInfisical(projectId=my-project, environment=dev, clientId=$INFISICAL_CLIENT_ID, clientSecret=$INFISICAL_CLIENT_SECRET) # @setValuesBulk(infisicalBulk()) # --- # @type=infisicalClientId INFISICAL_CLIENT_ID= # @type=infisicalClientSecret @sensitive @internal INFISICAL_CLIENT_SECRET= API_KEY= DB_PASSWORD= ``` You can filter by path and/or tag: .env.schema ```env-spec # Load secrets from a specific path # @setValuesBulk(infisicalBulk(path="/database")) # Load secrets with a specific tag # @setValuesBulk(infisicalBulk(tag="backend")) # Combine path and tag # @setValuesBulk(infisicalBulk(path="/production", tag="app")) # With a named instance # @setValuesBulk(infisicalBulk(prod, path="/database")) ``` *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initInfisical()` [Section titled “@initInfisical()”](#initinfisical) Initialize an Infisical plugin instance for accessing secrets. **Key/value args:** * `projectId` (required): Infisical project ID * `environment` (required): Environment name (e.g., `dev`, `staging`, `production`) * `clientId` (required): Universal Auth Client ID. Should be a reference to a config item of type `infisicalClientId`. * `clientSecret` (required): Universal Auth Client Secret. Should be a reference to a config item of type `infisicalClientSecret`. * `siteUrl` (optional): Custom Infisical instance URL (defaults to `https://app.infisical.com`) * `identityId` (optional): Machine identity ID for OIDC authentication (alternative to `clientId`/`clientSecret`) * `oidcToken` (optional): Explicit OIDC JWT token (auto-detected from platform if omitted) * `secretPath` (optional): Default secret path for all secrets (defaults to `/`) * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). * `allowMissing` (optional): if `true`, missing secrets return `undefined` instead of throwing. Can be a dynamic value (e.g. `allowMissing=forEnv(development)`). Other errors (auth failures, permission errors, etc.) still throw. * `id` (optional): Instance identifier for multiple instances ```env-spec # @initInfisical( # projectId=your-project-id, # environment=dev, # clientId=$INFISICAL_CLIENT_ID, # clientSecret=$INFISICAL_CLIENT_SECRET, # cacheTtl="1h" # ) # --- # @type=infisicalClientId INFISICAL_CLIENT_ID= # @type=infisicalClientSecret @sensitive @internal INFISICAL_CLIENT_SECRET= ``` ### Data types [Section titled “Data types”](#data-types) #### `infisicalClientId` [Section titled “infisicalClientId”](#infisicalclientid) Represents an Infisical Universal Auth Client ID. This is not marked as sensitive. ```env-spec # @type=infisicalClientId INFISICAL_CLIENT_ID= ``` #### `infisicalClientSecret` [Section titled “infisicalClientSecret”](#infisicalclientsecret) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Represents an Infisical Universal Auth Client Secret. This type is marked as `@sensitive`. ```env-spec # @type=infisicalClientSecret INFISICAL_CLIENT_SECRET= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `infisical()` [Section titled “infisical()”](#infisical) Fetch a secret from Infisical. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `secretName` (optional): secret name in Infisical. If omitted, uses the variable name. * `secretPath` (optional): path to the secret (overrides default path) **Key/value args:** * `allowMissing` (optional): if `true`, returns `undefined` instead of throwing when the secret is not found. Can be a dynamic value (e.g. `allowMissing=forEnv(development)`). Useful with `fallback()` to provide a default value. Other errors still throw. ```env-spec # Auto-infer secret name from variable DATABASE_URL=infisical() # Explicit secret name STRIPE_KEY=infisical("STRIPE_SECRET_KEY") # With custom path DB_PASSWORD=infisical("DB_PASSWORD", "/database") # With instance ID DEV_SECRET=infisical(dev, "DATABASE_URL") # Full form PROD_SECRET=infisical(prod, "DATABASE_URL", "/production") # Optional secret with fallback RESEND_API_KEY=fallback(infisical(allowMissing=true), undefined) ``` #### `infisicalBulk()` [Section titled “infisicalBulk()”](#infisicalbulk) Bulk-load all secrets from an Infisical project environment. Intended for use with `@setValuesBulk`. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized **Key/value args:** * `path` (optional): secret path to fetch from (overrides default path from `@initInfisical`) * `tag` (optional): tag slug to filter secrets by ```env-spec # Load all secrets from default instance # @setValuesBulk(infisicalBulk()) # Load from a specific path # @setValuesBulk(infisicalBulk(path="/database")) # Filter by tag # @setValuesBulk(infisicalBulk(tag="backend")) # With instance ID and path # @setValuesBulk(infisicalBulk(prod, path="/database")) ``` *** ## Example Configurations [Section titled “Example Configurations”](#example-configurations) ### Development setup with auto-named secrets [Section titled “Development setup with auto-named secrets”](#development-setup-with-auto-named-secrets) .env.schema ```env-spec # @plugin(@varlock/infisical-plugin) # @initInfisical(projectId=dev-app, environment=dev, clientId=$INFISICAL_CLIENT_ID, clientSecret=$INFISICAL_CLIENT_SECRET) # --- # @type=infisicalClientId INFISICAL_CLIENT_ID= # @type=infisicalClientSecret @sensitive @internal INFISICAL_CLIENT_SECRET= # Secret names automatically match config keys DATABASE_URL=infisical() REDIS_URL=infisical() STRIPE_KEY=infisical() ``` ### Production with path organization [Section titled “Production with path organization”](#production-with-path-organization) .env.schema ```env-spec # @plugin(@varlock/infisical-plugin) # @initInfisical( # projectId=prod-app, # environment=production, # clientId=$INFISICAL_CLIENT_ID, # clientSecret=$INFISICAL_CLIENT_SECRET, # secretPath=/production # ) # --- # Database secrets at /production/database DB_HOST=infisical("DB_HOST", "/database") DB_PASSWORD=infisical("DB_PASSWORD", "/database") # API keys at /production/api STRIPE_KEY=infisical("STRIPE_KEY", "/api") SENDGRID_KEY=infisical("SENDGRID_KEY", "/api") ``` ### Multi-region setup [Section titled “Multi-region setup”](#multi-region-setup) .env.schema ```env-spec # @plugin(@varlock/infisical-plugin) # @initInfisical(id=us, projectId=app-us, environment=production, clientId=$US_CLIENT_ID, clientSecret=$US_CLIENT_SECRET) # @initInfisical(id=eu, projectId=app-eu, environment=production, clientId=$EU_CLIENT_ID, clientSecret=$EU_CLIENT_SECRET) # --- US_DATABASE=infisical(us, "DATABASE_URL") EU_DATABASE=infisical(eu, "DATABASE_URL") ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Secret not found [Section titled “Secret not found”](#secret-not-found) * Verify the secret exists in your Infisical project and environment * Check the secret name matches exactly (including case) * Verify the secret path is correct if using paths * Ensure your machine identity has access to the secret * If the secret is genuinely optional, set `allowMissing=true` on `infisical()` or `@initInfisical()` and use `@required=false` or `fallback()` ### Access denied [Section titled “Access denied”](#access-denied) * Check that your machine identity has been granted access to the project and environment * Verify the machine identity permissions in Infisical console * Ensure the project ID and environment match your configuration ### Authentication failed [Section titled “Authentication failed”](#authentication-failed) * Verify the client ID and client secret are correct * Check if the machine identity has been revoked or disabled * For self-hosted: verify `siteUrl` is correct ### Wrong environment [Section titled “Wrong environment”](#wrong-environment) * Double-check the `environment` parameter matches the environment where your secret is stored * Remember that secrets in Infisical are environment-specific ## Resources [Section titled “Resources”](#resources) * [Infisical Documentation](https://infisical.com/docs) * [Machine Identities](https://infisical.com/docs/documentation/platform/identities/machine-identities) * [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) * [Infisical Node SDK](https://infisical.com/docs/sdks/languages/node) # KeePass Plugin > Using KeePass / KeePassXC databases with Varlock [![](https://img.shields.io/npm/v/@varlock/keepass-plugin?label=%40varlock%2Fkeepass-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/keepass-plugin) Our [KeePass](https://keepass.info/) plugin enables loading secrets from KDBX 4.0 database files using declarative instructions within your `.env` files. It supports two modes of operation: **file mode** (default) reads `.kdbx` files directly using a pure WASM implementation, and **CLI mode** uses `keepassxc-cli` for development workflows with system-level key management (YubiKey, Windows Hello, etc.). ## Features [Section titled “Features”](#features) * **KDBX 4.0 support**: reads KeePass database files directly via [kdbxweb](https://github.com/keeweb/kdbxweb) with pure WASM argon2 * **KeePassXC CLI integration**: use `keepassxc-cli` for development workflows * **Key file support**: authenticate with password + optional key file * **`#attribute` syntax**: read any entry field (Password, UserName, URL, Notes, or custom fields) * **Auto-infer entry paths** from variable names for convenience * **Custom attributes object**: load all custom fields from a single entry for `@setValuesBulk` * **Bulk loading** with `kpBulk()` via `@setValuesBulk` to load all entries in a group * **Multiple instances** for accessing different databases ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/keepass-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/keepass-plugin) # # 2. Initialize with your database path and password # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD) ``` ### With key file [Section titled “With key file”](#with-key-file) .env.schema ```env-spec # @plugin(@varlock/keepass-plugin) # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD, keyFile="./secrets.keyx") ``` ### CLI mode [Section titled “CLI mode”](#cli-mode) When `useCli=true`, the plugin uses `keepassxc-cli` to read entries instead of reading the file directly. This is useful during development when you want to use KeePassXC’s system integration. The `useCli` option can be dynamic. For example, `useCli=forEnv(dev)` uses the CLI only in development: .env.schema ```env-spec # @plugin(@varlock/keepass-plugin) # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD, useCli=forEnv(dev)) ``` You must have KeePassXC installed, which includes `keepassxc-cli`: ```bash # macOS brew install --cask keepassxc # Ubuntu/Debian sudo apt install keepassxc # Fedora/RHEL sudo dnf install keepassxc # Arch pacman -S keepassxc ``` See [KeePassXC downloads](https://keepassxc.org/download/) for more options. ### Multiple databases [Section titled “Multiple databases”](#multiple-databases) Access secrets from multiple databases using the `id` parameter: .env.schema ```env-spec # @plugin(@varlock/keepass-plugin) # @initKeePass(id=prod, dbPath="./prod.kdbx", password=$KP_PROD_PASSWORD) # @initKeePass(id=dev, dbPath="./dev.kdbx", password=$KP_DEV_PASSWORD) # --- PROD_SECRET=kp(prod, "Database/production") DEV_SECRET=kp(dev, "Database/development") ``` Note The plugin does **not** fail at load time if the database credentials are invalid. It only fails when you actually try to read a secret, making it safe to include in shared configs. ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values using the `kp()` resolver function. ### Basic usage [Section titled “Basic usage”](#basic-usage) Fetch secrets using entry paths with `#attribute` syntax: .env.schema ```env-spec # Fetch password (default attribute) from an entry DB_PASSWORD=kp("Database/production") # Fetch a different attribute using #attribute syntax DB_USER=kp("Database/production#UserName") DB_URL=kp("Database/production#URL") # Read a custom string field API_KEY=kp("Services/stripe#SecretKey") ``` ### Inferring entry name from key [Section titled “Inferring entry name from key”](#inferring-entry-name-from-key) When the env var key matches a KeePass entry title, you can omit the entry path: .env.schema ```env-spec # Looks up entry titled "DB_PASSWORD", reads the Password field DB_PASSWORD=kp() # Looks up entry titled "DB_USER", reads the UserName field DB_USER=kp("#UserName") ``` ### Entry paths [Section titled “Entry paths”](#entry-paths) Entry paths use forward slashes to separate groups from the entry title: ```plaintext Group/SubGroup/EntryTitle ``` For example, if your KeePass database has: * Root * Database * production (entry with Password, UserName fields) * Services * stripe (entry with a custom “SecretKey” field) You would reference them as `"Database/production"` and `"Services/stripe"`. ### Custom attributes object [Section titled “Custom attributes object”](#custom-attributes-object) Use `customAttributesObj=true` to load all custom (non-standard) fields from a single entry as a JSON object. This is useful with `@setValuesBulk` to expand custom fields into env vars: .env.schema ```env-spec # Given an entry "Database/production" with custom fields: HOST, PORT, DB_NAME # @setValuesBulk(kp("Database/production", customAttributesObj=true), createMissing=true) # --- HOST= PORT= DB_NAME= ``` Standard fields (Title, Password, UserName, URL, Notes) are excluded; only custom fields are included. ### Bulk loading secrets [Section titled “Bulk loading secrets”](#bulk-loading-secrets) Use `kpBulk()` with `@setValuesBulk` to fetch the Password field from all entries under a group: .env.schema ```env-spec # @plugin(@varlock/keepass-plugin) # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD) # @setValuesBulk(kpBulk(Production), createMissing=true) # --- # These will be populated from entries under the "Production" group DB_PASSWORD= API_KEY= ``` You can customize the scope: .env.schema ```env-spec # Load all entries from the database root # @setValuesBulk(kpBulk()) # Load from a specific group # @setValuesBulk(kpBulk("Services/APIs")) # With a named instance # @setValuesBulk(kpBulk(prod, Production)) ``` Entry paths in the JSON output are sanitized to valid env var names (uppercased, non-alphanumeric characters replaced with underscores). *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initKeePass()` [Section titled “@initKeePass()”](#initkeepass) Initialize a KeePass plugin instance for accessing secrets from a KDBX database. **Key/value args:** * `dbPath` **(required)**: Path to the `.kdbx` database file * `password` **(required)**: Master password (typically from an env var like `$KP_PASSWORD`) * `keyFile` (optional): Path to a key file for additional authentication * `useCli` (optional): Use `keepassxc-cli` instead of reading the file directly (default: `false`). Can be dynamic, e.g. `useCli=forEnv(dev)` * `id` (optional): Instance identifier for multiple databases The database is opened the first time a `kp()` or `kpBulk()` resolver runs, not when the schema loads. An instance that nothing reads from will not fail, even if `password` is empty or `dbPath` does not exist. ```env-spec # Basic setup # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD) # With key file and CLI mode # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD, keyFile="./key.keyx", useCli=true) # Multiple instances # @initKeePass(id=prod, dbPath="./prod.kdbx", password=$KP_PROD_PW) ``` ### Data types [Section titled “Data types”](#data-types) #### `kdbxPassword` [Section titled “kdbxPassword”](#kdbxpassword) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. A sensitive string type for KeePass database master passwords. Validates that the value is a non-empty string and is automatically marked as sensitive. ```env-spec # @type=kdbxPassword @sensitive @internal KP_PASSWORD= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `kp()` [Section titled “kp()”](#kp) Fetch a single entry field from a KeePass database. Returns the Password field by default. **Array args:** * `instanceId` (optional): instance identifier when multiple plugin instances are initialized * `entryPath` (optional): path to the entry (uses `#attribute` for non-Password fields). If omitted, uses the variable name. **Key/value args:** * `attribute` (optional): alternative to `#attribute` syntax for specifying the field to read * `customAttributesObj` (optional): if `true`, returns all custom fields as a JSON object (for use with `@setValuesBulk`) ```env-spec # Default password field DB_PASSWORD=kp("Database/production") # Specific attribute via #attribute DB_USER=kp("Database/production#UserName") # Infer entry name from variable key DB_PASSWORD=kp() # Infer entry name + specify attribute DB_USER=kp("#UserName") # Custom fields as JSON object # @setValuesBulk(kp("Database/production", customAttributesObj=true)) # With instance ID SECRET=kp(prod, "Database/production") ``` #### `kpBulk()` [Section titled “kpBulk()”](#kpbulk) Fetch the Password field from all entries under a group as a JSON map. Intended for use with `@setValuesBulk`. Entry paths are sanitized to valid env var names (uppercased, non-alphanumeric replaced with underscores). **Array args:** * `instanceId` (optional): instance identifier when multiple plugin instances are initialized * `groupPath` (optional): group path to load entries from (loads all entries if omitted) ```env-spec # Load all entries from the database root # @setValuesBulk(kpBulk()) # Load entries under a specific group # @setValuesBulk(kpBulk(Production)) # With instance ID # @setValuesBulk(kpBulk(prod, Production)) ``` *** ## Example configurations [Section titled “Example configurations”](#example-configurations) ### Simple setup with password from env [Section titled “Simple setup with password from env”](#simple-setup-with-password-from-env) .env.schema ```env-spec # @plugin(@varlock/keepass-plugin) # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD) # --- # @type=kdbxPassword @sensitive @internal KP_PASSWORD= DB_PASSWORD=kp("Database/production") API_KEY=kp("Services/stripe#SecretKey") SMTP_USER=kp("Email/smtp#UserName") ``` ### Bulk loading a group [Section titled “Bulk loading a group”](#bulk-loading-a-group) .env.schema ```env-spec # @plugin(@varlock/keepass-plugin) # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD) # @setValuesBulk(kpBulk(Production), createMissing=true) # --- # @type=kdbxPassword @sensitive @internal KP_PASSWORD= # These are auto-populated from entries in the "Production" group DB_PASSWORD= API_KEY= REDIS_URL= ``` ### Custom fields from a single entry [Section titled “Custom fields from a single entry”](#custom-fields-from-a-single-entry) .env.schema ```env-spec # @plugin(@varlock/keepass-plugin) # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD) # @setValuesBulk(kp("Database/production", customAttributesObj=true), createMissing=true) # --- # @type=kdbxPassword @sensitive @internal KP_PASSWORD= # Custom fields from the "Database/production" entry HOST= PORT= DB_NAME= ``` ### Dev CLI / prod file mode [Section titled “Dev CLI / prod file mode”](#dev-cli--prod-file-mode) .env.schema ```env-spec # @plugin(@varlock/keepass-plugin) # @initKeePass(dbPath="./secrets.kdbx", password=$KP_PASSWORD, useCli=forEnv(dev)) # --- # @type=kdbxPassword @sensitive @internal KP_PASSWORD= # In dev: reads via keepassxc-cli (YubiKey, etc.) # In prod: reads the .kdbx file directly DB_PASSWORD=kp("Database/production") ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Invalid credentials [Section titled “Invalid credentials”](#invalid-credentials) * Check that the database password is correct * If using a key file, verify the path is correct and the file matches the database ### Entry not found [Section titled “Entry not found”](#entry-not-found) * Entry paths are case-sensitive * Use forward slashes to separate groups: `"Group/SubGroup/Entry"` * In CLI mode, list entries with: `keepassxc-cli ls ` ### `keepassxc-cli` not found (CLI mode only) [Section titled “keepassxc-cli not found (CLI mode only)”](#keepassxc-cli-not-found-cli-mode-only) * Install KeePassXC which includes the CLI (see [CLI mode](#cli-mode)) * Ensure `keepassxc-cli` is in your `PATH` ### Database file not found [Section titled “Database file not found”](#database-file-not-found) * Check the `dbPath` value; it’s resolved relative to the working directory * Use an absolute path if needed ## Resources [Section titled “Resources”](#resources) * [KeePassXC](https://keepassxc.org/): cross-platform KeePass-compatible password manager * [KeePass](https://keepass.info/): original KeePass Password Safe * [KDBX format](https://keepass.info/help/kb/kdbx.html): KeePass database format specification * [kdbxweb](https://github.com/keeweb/kdbxweb): JavaScript KDBX reader library # Keeper Plugin > Using Keeper Security Secrets Manager with Varlock [![](https://img.shields.io/npm/v/@varlock/keeper-plugin?label=%40varlock%2Fkeeper-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/keeper-plugin) Our [Keeper Security](https://keepersecurity.com/) plugin loads secrets from Keeper vaults via the [Keeper Secrets Manager](https://docs.keeper.io/secrets-manager/) SDK. The plugin uses base64-encoded configuration tokens for programmatic access to your Keeper secrets, making it suitable for both CI/CD and production environments. ## Features [Section titled “Features”](#features) * **SDK-based authentication**: access via base64-encoded Secrets Manager config tokens * **Secret access** by record UID, title, `#field` selector, or [Keeper notation](https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library/javascript-sdk#notation) * **Standard and custom fields**: access any field type including password, login, URL, notes, and custom fields * **Multiple instances**: connect to different Keeper applications or vaults at once * **Opt-in secret caching** via `cacheTtl`: see the [Caching guide](/guides/caching/) * **Error handling** with helpful tips ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/keeper-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/keeper-plugin) # # 2. Initialize the plugin - see below for more details on options # @initKeeper(token=$KSM_CONFIG) # --- # 3. Add a config item for the Secrets Manager config token # @type=keeperSmToken @sensitive @internal KSM_CONFIG= ``` ### Secrets Manager application setup [Section titled “Secrets Manager application setup”](#secrets-manager-application-setup) 1. **Create a Secrets Manager Application** in the Keeper Admin Console Navigate to **Secrets Manager** → **Applications** → Click **Create Application**. Give it a descriptive name (e.g., “varlock-prod”). 2. **Share a folder** with the application In your Keeper vault, right-click the folder containing your secrets, select **Share Folder**, and share it with your Secrets Manager application. 3. **Generate a one-time access token** In the application settings, click **Add Device** and copy the one-time access token. 4. **Initialize and export the config** Use the [KSM CLI](https://docs.keeper.io/secrets-manager/secrets-manager/secrets-manager-command-line-interface) to create a config: ```bash pip install keeper-secrets-manager-cli ksm profile init ksm profile export --format json | base64 ``` Save the config securely Store the base64-encoded config token securely. The one-time access token can only be used once to initialize a device. 5. **Wire up the token in your config** .env.schema ```env-spec # @plugin(@varlock/keeper-plugin) # @initKeeper(token=$KSM_CONFIG) # --- # @type=keeperSmToken @sensitive @internal KSM_CONFIG= ``` 6. **Set your config token in environments** Use your CI/CD system or platform’s env var management to securely inject the `KSM_CONFIG` value. ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) If you need to connect to different Keeper applications or vaults, register multiple named instances: .env.schema ```env-spec # @plugin(@varlock/keeper-plugin) # @initKeeper(token=$KSM_PROD, id=prod) # @initKeeper(token=$KSM_DEV, id=dev) # --- # @type=keeperSmToken @sensitive @internal KSM_PROD= # @type=keeperSmToken @sensitive @internal KSM_DEV= PROD_SECRET=keeper(prod, "XXXXXXXXXXXXXXXXXXXX") DEV_SECRET=keeper(dev, "XXXXXXXXXXXXXXXXXXXX") ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values using the `keeper()` resolver function. ### By record UID [Section titled “By record UID”](#by-record-uid) Fetch secrets by record UID. By default, the `password` field is returned: .env.schema ```env-spec # Fetches the "password" field from the record DB_PASSWORD=keeper("XXXXXXXXXXXXXXXXXXXX") ``` ### With a field selector [Section titled “With a field selector”](#with-a-field-selector) Use the `#` syntax to access a specific field: .env.schema ```env-spec # Standard fields DB_USER=keeper("XXXXXXXXXXXXXXXXXXXX#login") SITE_URL=keeper("XXXXXXXXXXXXXXXXXXXX#url") # Custom fields by label API_KEY=keeper("XXXXXXXXXXXXXXXXXXXX#API_KEY") ``` Or use the named `field` parameter: .env.schema ```env-spec DB_HOST=keeper("XXXXXXXXXXXXXXXXXXXX", field="host") ``` ### Using Keeper notation [Section titled “Using Keeper notation”](#using-keeper-notation) The plugin supports [Keeper’s notation syntax](https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library/javascript-sdk#notation) for advanced access patterns: .env.schema ```env-spec # Standard field by type DB_PASS=keeper("XXXX/field/password") # Login field DB_USER=keeper("XXXX/field/login") # Custom field by label MY_SECRET=keeper("XXXX/custom_field/MySecretLabel") # By record title instead of UID API_KEY=keeper("My API Keys/field/password") ``` ### With named instances [Section titled “With named instances”](#with-named-instances) When using multiple instances, specify which one to use as the first argument: .env.schema ```env-spec PROD_SECRET=keeper(prod, "XXXX/field/password") DEV_SECRET=keeper(dev, "YYYY#password") ``` Record UIDs vs titles You can reference records by either their UID or their title. UIDs are more reliable since titles can be changed, but titles are more readable. UIDs are case-sensitive. *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initKeeper()` [Section titled “@initKeeper()”](#initkeeper) Initialize a Keeper Secrets Manager plugin instance for accessing secrets. **Key/value args:** * `token` (required): Base64-encoded Secrets Manager config token. Should be a reference to a config item of type `keeperSmToken`. * `id` (optional): Instance identifier for multiple instances * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). ```env-spec # @initKeeper(token=$KSM_CONFIG) # --- # @type=keeperSmToken @sensitive @internal KSM_CONFIG= ``` ### Data types [Section titled “Data types”](#data-types) #### `keeperSmToken` [Section titled “keeperSmToken”](#keepersmtoken) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Represents a base64-encoded configuration token for the Keeper Secrets Manager SDK. Validation ensures the value is a valid base64-encoded JSON string. Note that the type itself is marked as `@sensitive`, so adding an explicit `@sensitive` decorator is optional. ```env-spec # @type=keeperSmToken KSM_CONFIG= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `keeper()` [Section titled “keeper()”](#keeper) Fetch a secret field from Keeper Secrets Manager. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `reference` (required): record UID, title, UID with `#field` selector, or Keeper notation string **Key/value args:** * `field` (optional): explicit field type or label to extract from the record ```env-spec # By record UID (defaults to password field) DB_PASSWORD=keeper("XXXXXXXXXXXXXXXXXXXX") # With field selector DB_USER=keeper("XXXXXXXXXXXXXXXXXXXX#login") # With named field parameter DB_HOST=keeper("XXXXXXXXXXXXXXXXXXXX", field="host") # Using Keeper notation API_KEY=keeper("XXXX/field/password") CUSTOM=keeper("XXXX/custom_field/API_KEY") # With instance ID PROD_SECRET=keeper(prod, "XXXXXXXXXXXXXXXXXXXX") ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Failed to parse config token [Section titled “Failed to parse config token”](#failed-to-parse-config-token) * The `KSM_CONFIG` value must be a valid base64-encoded JSON string * Regenerate it using the KSM CLI: ```bash ksm profile export --format json | base64 ``` ### Access denied [Section titled “Access denied”](#access-denied) * Verify the Secrets Manager application has not been revoked * Check that the shared folder permissions are still active * The one-time token may have expired before being used; generate a new one ### Record not found [Section titled “Record not found”](#record-not-found) * Verify the record UID or title is correct * Ensure the record is in a folder shared with the Secrets Manager application * Record UIDs are case-sensitive ### Field not found in record [Section titled “Field not found in record”](#field-not-found-in-record) * Check available field types: `login`, `password`, `url`, `oneTimeCode`, `note` * Custom fields use the label: `keeper("uid/custom_field/My Label")` * Use the `#` syntax for quick field access: `keeper("uid#login")` ### Config token issues [Section titled “Config token issues”](#config-token-issues) * One-time access tokens can only be used once to initialize a device * If you need a new config, create a new device in the application settings * Config tokens contain encrypted keys; do not modify them manually ## Resources [Section titled “Resources”](#resources) * [Keeper Security](https://keepersecurity.com/) * [Keeper Secrets Manager](https://docs.keeper.io/secrets-manager/) * [JavaScript SDK Documentation](https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library/javascript-sdk) * [KSM CLI Documentation](https://docs.keeper.io/secrets-manager/secrets-manager/secrets-manager-command-line-interface) # Kubernetes > Load Kubernetes Secrets and ConfigMaps into your varlock env graph [![](https://img.shields.io/npm/v/@varlock/kubernetes-plugin?label=%40varlock%2Fkubernetes-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/kubernetes-plugin) Our Kubernetes plugin enables loading values from Kubernetes [Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) and [ConfigMaps](https://kubernetes.io/docs/concepts/configuration/configmap/) using declarative instructions within your `.env` files. ## Scope [Section titled “Scope”](#scope) This plugin is **read-only**. It performs `get` requests on Secrets and ConfigMaps in a configured namespace and surfaces the values to your `.env` schema, nothing more. It does **not** create, update, or delete cluster resources, generate or template manifests, watch for changes, or manage deployments. **Typical use cases:** * **Local development**: pull dev/staging Secrets and ConfigMaps from a cluster into your local app without copying values by hand * **In-cluster runtime**: read additional Secrets/ConfigMaps at runtime that aren’t already mounted into the pod via `envFrom` / `valueFrom` * **CI/CD**: read Secrets/ConfigMaps from a cluster using an explicit service account token It supports local kubeconfig, in-cluster service account credentials, and explicit API server + token authentication. Want deeper Kubernetes integration? We’re considering broader Kubernetes support (manifest generation, schema-to-deployment validation, change watching, etc.). If you have a use case this plugin doesn’t cover, or feedback from running it in production, come chat on [Discord](https://chat.dmno.dev). We’d love to hear from you. ## Features [Section titled “Features”](#features) * **Zero-config local development**: automatically uses your default kubeconfig (`~/.kube/config`) * **In-cluster authentication**: auto-detects the mounted service account when running inside a pod * **Explicit auth**: provide a cluster API URL and bearer token directly * **Fetch Secret keys** with `k8sSecret()` (values are automatically base64-decoded) * **Fetch ConfigMap keys** with `k8sConfigMap()` (including `binaryData`) * **Bulk-load** whole Secrets or ConfigMaps with `k8sSecretBulk()` / `k8sConfigMapBulk()` * **Auto-infer keys** from environment variable names * **Multiple instances** for different namespaces or clusters * **Read-only**: the plugin never mutates cluster state ## Guides in this section [Section titled “Guides in this section”](#guides-in-this-section) * [Installation and setup](/plugins/kubernetes/setup/) * [Loading values](/plugins/kubernetes/loading/) * [Kubernetes setup](/plugins/kubernetes/cluster-setup/) * [Reference](/plugins/kubernetes/reference/) # Kubernetes cluster setup > RBAC, namespaces, and cluster configuration for the varlock Kubernetes plugin ## Kubernetes Setup [Section titled “Kubernetes Setup”](#kubernetes-setup) ### Required RBAC permissions [Section titled “Required RBAC permissions”](#required-rbac-permissions) The identity used by the plugin (your kubeconfig user, an in-cluster service account, or an explicit token) needs read access to Secrets and/or ConfigMaps in the target namespace. The minimum permissions are `get` on `secrets` and `configmaps`: varlock-rbac.yaml ```yaml apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: varlock-reader namespace: default rules: - apiGroups: [""] resources: ["secrets", "configmaps"] verbs: ["get"] ``` Apply it with: ```bash kubectl apply -f varlock-rbac.yaml ``` Least privilege principle Scope down `resources` to only what you need. If you’re only reading ConfigMaps, omit `"secrets"` entirely. For multi-namespace access, use a `ClusterRole` + `ClusterRoleBinding` instead, but prefer namespaced `Role`s whenever possible. ### Service account for in-cluster use [Section titled “Service account for in-cluster use”](#service-account-for-in-cluster-use) When running inside a pod, the plugin uses the pod’s mounted service account. Create a dedicated service account and bind it to the role above: 1. **Create a service account** ```bash kubectl create serviceaccount varlock-reader -n default ``` 2. **Bind the role to the service account** varlock-rolebinding.yaml ```yaml apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: varlock-reader-binding namespace: default subjects: - kind: ServiceAccount name: varlock-reader namespace: default roleRef: kind: Role name: varlock-reader apiGroup: rbac.authorization.k8s.io ``` ```bash kubectl apply -f varlock-rolebinding.yaml ``` 3. **Use the service account in your pod spec** deployment.yaml ```yaml spec: serviceAccountName: varlock-reader containers: - name: app image: my-app:latest ``` ### Generate a bearer token for explicit auth [Section titled “Generate a bearer token for explicit auth”](#generate-a-bearer-token-for-explicit-auth) For CI/CD or other external use cases that need an explicit token, create a long-lived service account token: ```bash # Create a token secret bound to the service account kubectl apply -f - < Resolver functions and patterns for Secrets and ConfigMaps ## Loading values [Section titled “Loading values”](#loading-values) Once the plugin is installed and initialized, you can start adding config items that load values using the `k8sSecret()` and `k8sConfigMap()` resolver functions. ### How Secrets and ConfigMaps are structured [Section titled “How Secrets and ConfigMaps are structured”](#how-secrets-and-configmaps-are-structured) A Kubernetes Secret or ConfigMap is a **named resource that holds a map of key/value pairs**: Example Secret ```yaml apiVersion: v1 kind: Secret metadata: name: app-secrets # ← the resource name data: DATABASE_URL: cG9zdGdyZXM6... # ← keys inside the resource API_KEY: c2VjcmV0LWtleQ== ``` So fetching a value is always a two-level lookup: which Secret/ConfigMap (`name`), and which key inside it (`key`). The resolvers reflect this directly: * `k8sSecret(name, key)`: read `name.data.key` * `k8sConfigMap(name, key)`: read `name.data.key` (or `name.binaryData.key`) When `key` is omitted, the plugin uses the config item’s name as the key. This works well when your Secret keys already match your env var names. ### Secret keys [Section titled “Secret keys”](#secret-keys) The `k8sSecret()` function fetches a key from a Kubernetes Secret. Values stored in Secret `data` are base64-encoded; the plugin decodes them automatically before returning. .env.schema ```env-spec # Auto-infer key from item name (fetches "DATABASE_URL" from "app-secrets") DATABASE_URL=k8sSecret(app-secrets) # Explicit key name DB_URL=k8sSecret(app-secrets, DATABASE_URL) # Named args also work DB_URL=k8sSecret(name=app-secrets, key=DATABASE_URL) # With named instance PROD_DB_URL=k8sSecret(prod, app-secrets, DATABASE_URL) ``` ### ConfigMap keys [Section titled “ConfigMap keys”](#configmap-keys) The `k8sConfigMap()` function fetches a key from a Kubernetes ConfigMap. Both `data` (string) and `binaryData` (base64-encoded) fields are supported. .env.schema ```env-spec # Auto-infer key from item name PUBLIC_API_HOST=k8sConfigMap(app-config) # Explicit key name API_HOST=k8sConfigMap(app-config, PUBLIC_API_HOST) ``` ### Default Secret/ConfigMap (Recommended for the common case) [Section titled “Default Secret/ConfigMap (Recommended for the common case)”](#default-secretconfigmap-recommended-for-the-common-case) The idiomatic Kubernetes deployment pattern is one Secret + one ConfigMap per app, mounted into the pod via `envFrom`. If your app follows this pattern, set `defaultSecret` and `defaultConfigMap` once on the init decorator and skip the name argument on every call: .env.schema ```env-spec # @plugin(@varlock/kubernetes-plugin) # @initKubernetes( # namespace=default, # defaultSecret=app-secrets, # defaultConfigMap=app-config, # ) # --- # Both default to app-secrets / app-config and infer the key from the item name DATABASE_URL=k8sSecret() API_KEY=k8sSecret() JWT_SECRET=k8sSecret() PUBLIC_API_HOST=k8sConfigMap() # Override just the key while still using the default Secret STRIPE_KEY=k8sSecret(key=stripe_api_key) # Override the resource name to read from a different Secret SHARED_TOKEN=k8sSecret(shared-secrets, AUTH_TOKEN) ``` You can mix positional and named arguments, but you can’t provide the same field twice. For example, `k8sSecret(app-secrets, name=other-secrets)` is a schema error. ### Bulk loading [Section titled “Bulk loading”](#bulk-loading) Use bulk loading when a single Secret or ConfigMap contains several environment variables you want to map into your config. The bulk resolvers return all keys as a JSON object, which pairs naturally with `@setValuesBulk`: .env.schema ```env-spec # @plugin(@varlock/kubernetes-plugin) # @initKubernetes(namespace=default) # @setValuesBulk(k8sSecretBulk(app-secrets), format=json) # @setValuesBulk(k8sConfigMapBulk(app-config), format=json) # --- DATABASE_URL= API_KEY= PUBLIC_API_HOST= ``` Only items declared in your schema will be populated; any extra keys in the Secret or ConfigMap are ignored. Bulk resolvers also pick up `defaultSecret`/`defaultConfigMap`, so the names can be omitted entirely: .env.schema ```env-spec # @initKubernetes(defaultSecret=app-secrets, defaultConfigMap=app-config) # @setValuesBulk(k8sSecretBulk(), format=json) # @setValuesBulk(k8sConfigMapBulk(), format=json) ``` ### Optional values [Section titled “Optional values”](#optional-values) By default, fetching a key from a missing Secret/ConfigMap throws an error. If you want missing resources or keys to resolve to `undefined` instead, set `allowMissing=true`: .env.schema ```env-spec # @initKubernetes(namespace=default, allowMissing=true) # --- # @required=false OPTIONAL_FLAG=k8sConfigMap(feature-flags, NEW_UI) ``` When `allowMissing=true`, also mark the corresponding items with `@required=false` (or wrap the resolver with `fallback()`) so validation does not fail. *** # Kubernetes plugin reference > Resolver and decorator reference, troubleshooting, and resources ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initKubernetes()` [Section titled “@initKubernetes()”](#initkubernetes) Initialize a Kubernetes plugin instance for `k8sSecret()`, `k8sConfigMap()`, `k8sSecretBulk()`, and `k8sConfigMapBulk()` resolvers. **Key/value args:** * `id` (optional, static): Instance identifier for multiple instances, defaults to `_default` * `namespace` (optional): Kubernetes namespace to read from. Defaults to the kubeconfig context namespace, the pod’s mounted service account namespace (when in-cluster), or `default` * `context` (optional): Kubeconfig context name to use (overrides the current context) * `kubeconfig` (optional): Path to a kubeconfig file, or raw kubeconfig YAML/JSON content * `clusterServer` (optional): Kubernetes API server URL for explicit auth (e.g., `https://kubernetes.example.com:6443`) * `token` (optional): Bearer token for explicit auth * `skipTlsVerify` (optional): Set to `true` to skip TLS verification on the cluster certificate. Only applies when using `clusterServer` + `token` * `allowMissing` (optional): If `true`, missing resources or keys return `undefined` instead of throwing * `defaultSecret` (optional): Default Secret name used by `k8sSecret()` / `k8sSecretBulk()` when no name argument is provided * `defaultConfigMap` (optional): Default ConfigMap name used by `k8sConfigMap()` / `k8sConfigMapBulk()` when no name argument is provided ```env-spec # @initKubernetes(namespace=default, defaultSecret=app-secrets, defaultConfigMap=app-config) ``` ### Data types [Section titled “Data types”](#data-types) #### `kubernetesBearerToken` [Section titled “kubernetesBearerToken”](#kubernetesbearertoken) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Represents a Kubernetes bearer token used for API server authentication (typically a service account token). This type is marked as `@sensitive`. ```env-spec # @type=kubernetesBearerToken KUBERNETES_TOKEN= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `k8sSecret()` [Section titled “k8sSecret()”](#k8ssecret) Fetch a single key from a Kubernetes Secret. Secret `data` values are base64-decoded automatically. Arguments can be provided positionally or as named arguments, but the same field cannot be provided both ways. **Array args (positional):** * `instanceId` (optional, static): Instance identifier to use when multiple plugin instances are initialized * `name` (optional): Name of the Secret resource. Required unless `defaultSecret` is set on `@initKubernetes()` * `key` (optional): Key inside the Secret’s `data` to fetch. If omitted, uses the item key (variable name) **Named args:** * `id` (optional, static): same as positional `instanceId` * `name` (optional): same as positional `name` * `key` (optional): same as positional `key` ```env-spec # Auto-infer key from item name DATABASE_URL=k8sSecret(app-secrets) # Explicit key name (positional) DB_URL=k8sSecret(app-secrets, DATABASE_URL) # Override just the key (uses defaultSecret from @initKubernetes) STRIPE_KEY=k8sSecret(key=stripe_api_key) # Both positional and named DB_URL=k8sSecret(name=app-secrets, key=DATABASE_URL) # With instance ID PROD_DB=k8sSecret(prod, app-secrets, DATABASE_URL) ``` #### `k8sConfigMap()` [Section titled “k8sConfigMap()”](#k8sconfigmap) Fetch a single key from a Kubernetes ConfigMap. Both `data` and `binaryData` fields are supported. Arguments can be provided positionally or as named arguments, but the same field cannot be provided both ways. **Array args (positional):** * `instanceId` (optional, static): Instance identifier to use when multiple plugin instances are initialized * `name` (optional): Name of the ConfigMap resource. Required unless `defaultConfigMap` is set on `@initKubernetes()` * `key` (optional): Key inside the ConfigMap to fetch. If omitted, uses the item key (variable name) **Named args:** * `id` (optional, static): same as positional `instanceId` * `name` (optional): same as positional `name` * `key` (optional): same as positional `key` ```env-spec # Auto-infer key from item name PUBLIC_API_HOST=k8sConfigMap(app-config) # Explicit key name API_HOST=k8sConfigMap(app-config, PUBLIC_API_HOST) # Named args API_HOST=k8sConfigMap(name=app-config, key=PUBLIC_API_HOST) # With instance ID DEV_HOST=k8sConfigMap(dev, app-config, PUBLIC_API_HOST) ``` #### `k8sSecretBulk()` [Section titled “k8sSecretBulk()”](#k8ssecretbulk) Fetch all keys from a Kubernetes Secret as a JSON object string. Designed to be used with `@setValuesBulk(..., format=json)`. **Array args (positional):** * `instanceId` (optional, static): Instance identifier to use when multiple plugin instances are initialized * `name` (optional): Name of the Secret resource. Required unless `defaultSecret` is set on `@initKubernetes()` **Named args:** * `id` (optional, static): same as positional `instanceId` * `name` (optional): same as positional `name` ```env-spec # Uses defaultSecret from @initKubernetes # @setValuesBulk(k8sSecretBulk(), format=json) # Explicit name # @setValuesBulk(k8sSecretBulk(app-secrets), format=json) # With instance ID # @setValuesBulk(k8sSecretBulk(prod, app-secrets), format=json) ``` #### `k8sConfigMapBulk()` [Section titled “k8sConfigMapBulk()”](#k8sconfigmapbulk) Fetch all keys from a Kubernetes ConfigMap as a JSON object string. Designed to be used with `@setValuesBulk(..., format=json)`. **Array args (positional):** * `instanceId` (optional, static): Instance identifier to use when multiple plugin instances are initialized * `name` (optional): Name of the ConfigMap resource. Required unless `defaultConfigMap` is set on `@initKubernetes()` **Named args:** * `id` (optional, static): same as positional `instanceId` * `name` (optional): same as positional `name` ```env-spec # @setValuesBulk(k8sConfigMapBulk(app-config), format=json) # @setValuesBulk(k8sConfigMapBulk(prod, app-config), format=json) ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Secret or ConfigMap not found (404) [Section titled “Secret or ConfigMap not found (404)”](#secret-or-configmap-not-found-404) * Verify the resource exists: `kubectl get secret -n ` or `kubectl get configmap -n ` * Double-check the namespace; the plugin reads only from the configured namespace * Resource names are case-sensitive and namespace-scoped * If the resource is genuinely optional, set `allowMissing=true` on `@initKubernetes()` and `@required=false` on the item ### Permission denied (403) [Section titled “Permission denied (403)”](#permission-denied-403) * Check that the active identity has the required RBAC: `kubectl auth can-i get secrets -n ` * For in-cluster use, verify the pod’s `serviceAccountName` is set and bound to a `Role`/`ClusterRole` that grants `get` on `secrets`/`configmaps` * The error message includes the exact `Role` snippet you need to grant ### Authentication failed (401) [Section titled “Authentication failed (401)”](#authentication-failed-401) * **Local dev:** Run `kubectl config current-context` and verify it points to the right cluster; run `kubectl get secrets` to confirm your kubeconfig works * **Explicit token:** Verify the token isn’t expired or revoked. Service account tokens created from `kubernetes.io/service-account-token` Secrets are long-lived, but TokenRequest-issued tokens have shorter TTLs * **In-cluster:** Check the pod’s mounted service account token at `/var/run/secrets/kubernetes.io/serviceaccount/token` ### Connection refused or TLS errors [Section titled “Connection refused or TLS errors”](#connection-refused-or-tls-errors) * Verify the cluster API URL is reachable from your machine/pod * For clusters with self-signed certificates and explicit auth, set `skipTlsVerify=true` (development only) * If using `kubectl` works but the plugin doesn’t, your kubeconfig may rely on an [exec credential plugin](https://kubernetes.io/docs/reference/config-api/client-authentication.v1beta1/) (e.g., `aws eks get-token`, `gke-gcloud-auth-plugin`, `kubelogin`). Ensure the helper binary is on your `$PATH` ### Wrong namespace [Section titled “Wrong namespace”](#wrong-namespace) * The plugin uses (in order): the explicit `namespace` argument, the current kubeconfig context’s namespace, the pod’s mounted SA namespace, or `default` * To force a specific namespace, pass it explicitly: `@initKubernetes(namespace=my-ns)` ## Resources [Section titled “Resources”](#resources) * [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) * [Kubernetes ConfigMaps](https://kubernetes.io/docs/concepts/configuration/configmap/) * [Kubernetes RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) * [Service Account Tokens](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) * [`@kubernetes/client-node`](https://github.com/kubernetes-client/javascript) # Kubernetes plugin setup > Install and configure the @varlock/kubernetes-plugin ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/kubernetes-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/kubernetes-plugin) # # 2. Initialize the plugin - see below for more details on options # @initKubernetes(namespace=default) ``` ### Authentication options [Section titled “Authentication options”](#authentication-options) The plugin tries authentication methods in this priority order: 1. **Explicit cluster server + token** - If `clusterServer` is provided in `@initKubernetes()` 2. **Explicit kubeconfig** - If `kubeconfig` is provided (file path or raw YAML/JSON string) 3. **In-cluster service account** - Auto-detected via `KUBERNETES_SERVICE_HOST`/`KUBERNETES_SERVICE_PORT` env vars (set automatically inside pods) 4. **Default kubeconfig** - Loads from `$KUBECONFIG` or `~/.kube/config` ### Automatic authentication (Recommended for local dev) [Section titled “Automatic authentication (Recommended for local dev)”](#automatic-authentication-recommended-for-local-dev) For local development, just initialize the plugin and the plugin will pick up your default kubeconfig automatically: .env.schema ```env-spec # @plugin(@varlock/kubernetes-plugin) # @initKubernetes(namespace=default) ``` **How this works:** * **Local development:** Uses your active `kubectl` context from `~/.kube/config` (or `$KUBECONFIG`) * **Inside a pod:** Uses the pod’s mounted service account credentials at `/var/run/secrets/kubernetes.io/serviceaccount/` If your kubeconfig has multiple contexts, you can select one explicitly: .env.schema ```env-spec # @initKubernetes(namespace=default, context=my-dev-cluster) ``` ### Explicit cluster server and token [Section titled “Explicit cluster server and token”](#explicit-cluster-server-and-token) If you don’t want to rely on a kubeconfig file (for example, in CI/CD or other deployed environments), provide the API server URL and a bearer token directly: 1. **Create a service account and RBAC** in your cluster (see Kubernetes Setup section below) 2. **Wire up the credentials in your config**. Add a config item for the token and reference it when initializing the plugin. .env.schema ```env-spec # @plugin(@varlock/kubernetes-plugin) # @initKubernetes( # namespace=default, # clusterServer="https://kubernetes.example.com:6443", # token=$KUBERNETES_TOKEN # ) # --- # @type=kubernetesBearerToken @sensitive @internal KUBERNETES_TOKEN= ``` 3. **Set your credentials in deployed environments**. Use your platform’s env var management UI to securely inject the bearer token. For clusters that present self-signed or custom-CA certificates, you can disable TLS verification by adding `skipTlsVerify=true` to `@initKubernetes()`. This should generally be avoided outside of development. ### Raw kubeconfig [Section titled “Raw kubeconfig”](#raw-kubeconfig) You can also pass an entire kubeconfig as a string (YAML or JSON), useful when injecting credentials from a secret manager or platform env var: .env.schema ```env-spec # @initKubernetes(kubeconfig=$KUBECONFIG_DATA) # --- # @sensitive KUBECONFIG_DATA= ``` The plugin auto-detects whether `kubeconfig` is a file path or raw content by looking for YAML/JSON markers. ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) If you need to read from multiple namespaces or clusters, register multiple named instances: .env.schema ```env-spec # @initKubernetes(id=dev, namespace=dev) # @initKubernetes(id=prod, namespace=prod, context=prod-cluster) # --- DEV_DATABASE_URL=k8sSecret(dev, app-secrets, DATABASE_URL) PROD_DATABASE_URL=k8sSecret(prod, app-secrets, DATABASE_URL) ``` # macOS Keychain > Using the macOS Keychain as a secrets source with Varlock The built-in `keychain()` resolver lets you load secrets directly from the **macOS Keychain** using declarative instructions in your `.env` files. It communicates with the Keychain through Varlock’s native Swift daemon, which enforces biometric authentication (Touch ID) and per-session access control. Built-in support No plugin installation is required. `keychain()` is built into Varlock and available on macOS. Or use `varlock()` If you don’t already have secrets stored in your macOS Keychain, you may want to use the [`varlock()` resolver](/guides/local-encryption) instead. It provides the same biometric-gated, device-local encryption benefits without needing to manage Keychain items, and it works cross-platform. See the [local encryption guide](/guides/local-encryption/) for more details. ## Features [Section titled “Features”](#features) * **Built-in**: no plugin or extra dependency needed * **Biometric gating**: access is protected by Touch ID via Varlock’s native daemon * **Interactive picker**: use `keychain(prompt)` to browse and select items via a native dialog * **Auto-write-back**: prompt mode writes the resolved reference back to your config file * **Named or positional syntax** for quick or precise lookups * **Field selection**: extract specific fields from keychain items * **Multiple keychain support**: access the login, System, or custom keychains Caution `keychain()` is only supported on **macOS**. It will throw an error on other platforms. If your team includes non-Mac developers, consider wrapping keychain items with a fallback or using a cross-platform secrets source for shared configs. ## Getting started [Section titled “Getting started”](#getting-started) Rather than wiring up items individually, the easiest way to get started is by using `keychain(prompt)`. This will open a native picker dialog where you can select existing keychain items, or create new ones. After selection, Varlock will automatically write the resolved reference back into your config file for future use. ```env-spec ITEM=keychain(prompt) # Opens a native picker dialog ``` ## Import plaintext env files [Section titled “Import plaintext env files”](#import-plaintext-env-files) If you already have sensitive plaintext values in a local `.env` file, import them into macOS Keychain and replace the plaintext with stable `keychain(...)` references. The file to import is the first argument: ```sh varlock keychain import .env --profile jb ``` By default this **edits the file in place**: each sensitive plaintext value is replaced by its `keychain(...)` ref, so the secret no longer lives on disk. Comments and non-sensitive values are left untouched. To write the refs to a *different* file instead and leave the source as-is, pass `--write-to`: ```sh varlock keychain import .env --profile jb --write-to .env.jb ``` Import requires an existing `.env.schema` file for the input env file so Varlock knows which input variables are secrets and which are not. It only imports variables marked `@sensitive` in that schema and never prints secret values. Re-running is safe: values already converted to `keychain(...)` refs are skipped. By default, Varlock refuses to overwrite an existing Keychain item (or, with `--write-to`, an existing ref in the target file); pass `--force` to overwrite. The file you name must be one Varlock loads as part of your env setup. It resolves your normal env graph (from `package.json` `varlock.loadPath`, or the files in the current directory) to read sensitivity from your schema, the same way `varlock encrypt --file` works. An arbitrary file outside that setup can’t be imported. The value stored in Keychain is taken from the file you named specifically; an override of the same variable in another file (such as `.env.local`) does not change what gets imported. Generated refs use `service="varlock"` and account names like `::`. The project defaults to the current directory name and can be overridden: ```sh varlock keychain import .env --profile jb --project my-app ``` ## Set one secret manually [Section titled “Set one secret manually”](#set-one-secret-manually) To store one secret without putting the value in shell history, run `set` and enter the value at the masked prompt: ```sh varlock keychain set API_KEY --profile jb --write-to .env.jb ``` This stores the item under `service="varlock"` with account `::API_KEY`, then writes the matching `keychain(...)` ref when `--write-to` is provided. If you need to paste a multi-line secret, pipe it through stdin instead of passing it as a command-line argument: ```sh cat secret.txt | varlock keychain set PRIVATE_KEY --profile jb --write-to .env.jb ``` By default, `set` refuses to overwrite an existing Keychain item or env ref. Pass `--force` to replace both. ## Access management [Section titled “Access management”](#access-management) If VarlockEnclave cannot read an existing Keychain item, grant access without using `/usr/bin/security` directly: ```sh varlock keychain fix-access --account "my-app:jb:API_KEY" ``` `--service` defaults to `varlock`, but can be overridden for legacy or manually-created Keychain items: ```sh varlock keychain fix-access --service "com.company.api" --account "admin" ``` You can also fix every explicit `keychain(...)` ref in an env file: ```sh varlock keychain fix-access --path .env.jb ``` ## List Keychain items [Section titled “List Keychain items”](#list-keychain-items) To see which Keychain items are available, list them by service name. This shows metadata only (service, account, and keychain) and never reads secret values: ```sh varlock keychain list # same as bare `varlock keychain` varlock keychain list API_KEY # filter (matches service, account, or label) ``` Pass `--keychain` to search a specific keychain, such as `System`: ```sh varlock keychain list --keychain System ``` ## Reference [Section titled “Reference”](#reference) #### `keychain()` [Section titled “keychain()”](#keychain) Fetch a secret from the macOS Keychain. Communicates with the Keychain through Varlock’s native daemon, which enforces biometric (Touch ID) authentication. **Array args:** * `service` (optional): Service name of the keychain item (positional shorthand) * `prompt` (optional): Enter interactive picker mode **Key/value args:** * `service` (optional): Service name of the keychain item * `account` (optional): Account identifier for the keychain item * `keychain` (optional): Name of a specific keychain to search (e.g., `"System"`) * `field` (optional): Specific field to extract from the keychain item * `prompt` (optional): If set, opens a native picker dialog for interactive selection ```env-spec # Positional shorthand DATABASE_PASSWORD=keychain("com.company.database") # Named service param API_KEY=keychain(service="com.company.api") # With account ADMIN_PW=keychain("com.company.db", account="admin") # Targeting a specific keychain CERT=keychain("com.company.cert", keychain="System") # Field selection TOKEN=keychain("com.company.auth", field="password") # Interactive picker mode NEW_SECRET=keychain(prompt) ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### ”keychain() is only supported on macOS” [Section titled “”keychain() is only supported on macOS””](#keychain-is-only-supported-on-macos) This resolver requires macOS. It is not available on Linux or Windows. If you need cross-platform secret management, consider using one of the [plugin-based secret sources](/plugins/overview). ### Failed to read keychain item [Section titled “Failed to read keychain item”](#failed-to-read-keychain-item) * Verify the item exists in Keychain Access.app * Check that the service name and account match exactly * Try `keychain(prompt)` to browse available items and grant VarlockEnclave access via the native dialog ### Selection was cancelled [Section titled “Selection was cancelled”](#selection-was-cancelled) If you dismiss the native picker without selecting an item, the resolution will fail. Run `varlock` again to retry, or replace `keychain(prompt)` with an explicit `keychain(service="...")` reference. ### VarlockEnclave access [Section titled “VarlockEnclave access”](#varlockenclave-access) The first time you access a keychain item through Varlock, macOS may prompt you to grant access to VarlockEnclave. Approve this to allow future reads. If you accidentally denied access, you can update the item’s Access Control settings in Keychain Access.app. # Plugins Overview > Varlock plugins overview Plugins allow extending the functionality of Varlock. Most load secrets from an external source (password managers, secrets platforms, cloud secret stores), but plugins can also extend other parts of varlock, such as adding request-transform schemes to the [credential proxy](/guides/proxy/). See the [plugins guide](/guides/plugins/) for more details on using plugins. The plugins listed below are the official ones, published under the `@varlock` npm scope. Third-party plugins are also supported. You can load them from npm or from a local path. See the [plugins guide](/guides/plugins/#installation) for more information. Loading plugins from other sources (git, http, jsr) is not supported yet. ## Password managers [Section titled “Password managers”](#password-managers) [1Password ](/plugins/1password/)@varlock/1password-plugin [Bitwarden ](/plugins/bitwarden/)@varlock/bitwarden-plugin [Dashlane ](/plugins/dashlane/)@varlock/dashlane-plugin [Keeper ](/plugins/keeper/)@varlock/keeper-plugin [KeePass ](/plugins/keepass/)@varlock/keepass-plugin [Pass ](/plugins/pass/)@varlock/pass-plugin [Passbolt ](/plugins/passbolt/)@varlock/passbolt-plugin [Proton Pass ](/plugins/proton-pass/)@varlock/proton-pass-plugin [macOS Keychain ](/plugins/macos-keychain/)Built-in (no npm package) ## Secrets platforms [Section titled “Secrets platforms”](#secrets-platforms) [Doppler ](/plugins/doppler/)@varlock/doppler-plugin [Infisical ](/plugins/infisical/)@varlock/infisical-plugin [HashiCorp Vault ](/plugins/hashicorp-vault/)@varlock/hashicorp-vault-plugin [Akeyless ](/plugins/akeyless/)@varlock/akeyless-plugin ## Cloud secret stores [Section titled “Cloud secret stores”](#cloud-secret-stores) [AWS SSM/SM ](/plugins/aws-secrets/)@varlock/aws-secrets-plugin [Azure Key Vault ](/plugins/azure-key-vault/)@varlock/azure-key-vault-plugin [GCP Secret Manager ](/plugins/google-secret-manager/)@varlock/google-secret-manager-plugin ## Infrastructure [Section titled “Infrastructure”](#infrastructure) [Kubernetes ](/plugins/kubernetes/)@varlock/kubernetes-plugin ## Credential proxy [Section titled “Credential proxy”](#credential-proxy) These plugins extend the [credential proxy](/guides/proxy/) rather than loading secrets: [AWS SigV4 signing ](/plugins/aws-sigv4/)@varlock/aws-sigv4-plugin ## Package reference [Section titled “Package reference”](#package-reference) | Plugin | npm package | | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [1Password](/plugins/1password/) | [![](https://img.shields.io/npm/v/@varlock/1password-plugin?label=%40varlock%2F1password-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/1password-plugin) | | [Akeyless](/plugins/akeyless/) | [![](https://img.shields.io/npm/v/@varlock/akeyless-plugin?label=%40varlock%2Fakeyless-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/akeyless-plugin) | | [AWS](/plugins/aws-secrets/) (Secrets Manager & Parameter Store) | [![](https://img.shields.io/npm/v/@varlock/aws-secrets-plugin?label=%40varlock%2Faws-secrets-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/aws-secrets-plugin) | | [AWS SigV4 signing](/plugins/aws-sigv4/) (credential proxy; AWS and S3-compatible) | [![](https://img.shields.io/npm/v/@varlock/aws-sigv4-plugin?label=%40varlock%2Faws-sigv4-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/aws-sigv4-plugin) | | [Azure Key Vault](/plugins/azure-key-vault/) | [![](https://img.shields.io/npm/v/@varlock/azure-key-vault-plugin?label=%40varlock%2Fazure-key-vault-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/azure-key-vault-plugin) | | [Bitwarden](/plugins/bitwarden/) | [![](https://img.shields.io/npm/v/@varlock/bitwarden-plugin?label=%40varlock%2Fbitwarden-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/bitwarden-plugin) | | [Dashlane](/plugins/dashlane/) | [![](https://img.shields.io/npm/v/@varlock/dashlane-plugin?label=%40varlock%2Fdashlane-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/dashlane-plugin) | | [Doppler](/plugins/doppler/) | [![](https://img.shields.io/npm/v/@varlock/doppler-plugin?label=%40varlock%2Fdoppler-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/doppler-plugin) | | [Google Secrets Manager](/plugins/google-secret-manager/) | [![](https://img.shields.io/npm/v/@varlock/google-secret-manager-plugin?label=%40varlock%2Fgoogle-secret-manager-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/google-secret-manager-plugin) | | [HashiCorp Vault](/plugins/hashicorp-vault/) (and OpenBao) | [![](https://img.shields.io/npm/v/@varlock/hashicorp-vault-plugin?label=%40varlock%2Fhashicorp-vault-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/hashicorp-vault-plugin) | | [Infisical](/plugins/infisical/) | [![](https://img.shields.io/npm/v/@varlock/infisical-plugin?label=%40varlock%2Finfisical-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/infisical-plugin) | | [KeePass](/plugins/keepass/) | [![](https://img.shields.io/npm/v/@varlock/keepass-plugin?label=%40varlock%2Fkeepass-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/keepass-plugin) | | [Keeper](/plugins/keeper/) | [![](https://img.shields.io/npm/v/@varlock/keeper-plugin?label=%40varlock%2Fkeeper-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/keeper-plugin) | | [Kubernetes](/plugins/kubernetes/) (Secrets & ConfigMaps) | [![](https://img.shields.io/npm/v/@varlock/kubernetes-plugin?label=%40varlock%2Fkubernetes-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/kubernetes-plugin) | | [macOS Keychain](/plugins/macos-keychain/) | N/A - built-in | | [Pass](/plugins/pass/) (unix password manager) | [![](https://img.shields.io/npm/v/@varlock/pass-plugin?label=%40varlock%2Fpass-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/pass-plugin) | | [Passbolt](/plugins/passbolt/) | [![](https://img.shields.io/npm/v/@varlock/passbolt-plugin?label=%40varlock%2Fpassbolt-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/passbolt-plugin) | | [Proton Pass](/plugins/proton-pass/) | [![](https://img.shields.io/npm/v/@varlock/proton-pass-plugin?label=%40varlock%2Fproton-pass-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/proton-pass-plugin) | Looking for something else? If you have a specific plugin in mind, please join us on [Discord](https://chat.dmno.dev) and let us know! # Pass Plugin > Using pass (the standard unix password manager) with Varlock [![](https://img.shields.io/npm/v/@varlock/pass-plugin?label=%40varlock%2Fpass-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/pass-plugin) Our [pass](https://www.passwordstore.org/) plugin enables loading secrets from `pass` (the standard unix password manager) using declarative instructions within your `.env` files. Pass stores each secret as a GPG-encrypted file in `~/.password-store`, organized in a simple directory hierarchy. This plugin shells out to the `pass` CLI, so it works with your existing GPG agent, git-backed stores, and all standard pass configuration. ## Features [Section titled “Features”](#features) * **Zero-config**: works with your existing pass store * **GPG-backed encryption**: uses pass’s native GPG security model * **Auto-infer entry paths** from variable names * **Bulk-load secrets** with `passBulk()` via `@setValuesBulk` * **Multiple store instances** for accessing different pass stores * **Name prefixing** for scoped entry access * **`allowMissing`** option for optional secrets * **In-session caching**: each entry is decrypted only once per resolution * **Helpful error messages** with resolution tips ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/pass-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/pass-plugin) # # 2. Initialize the plugin - no arguments needed for default setup # @initPass() ``` ### Prerequisites [Section titled “Prerequisites”](#prerequisites) You must have `pass` installed on your system: ```bash # macOS brew install pass # Ubuntu/Debian sudo apt-get install pass # Arch pacman -S pass ``` Your password store must already be initialized (`pass init "Your GPG Key ID"`). See the [pass documentation](https://www.passwordstore.org/) for setup details. Note The plugin does **not** fail at load time if `pass` is not installed. It only fails when you actually try to access a secret, making it safe to include in shared configs where some developers may not have pass set up. ### Custom store path [Section titled “Custom store path”](#custom-store-path) If your password store is in a non-standard location, use `storePath`: .env.schema ```env-spec # @plugin(@varlock/pass-plugin) # @initPass(storePath=/path/to/custom/store) ``` This sets `PASSWORD_STORE_DIR` for all pass commands issued by this plugin instance. ### Name prefixing [Section titled “Name prefixing”](#name-prefixing) Use `namePrefix` to scope all entry lookups under a common prefix: .env.schema ```env-spec # @plugin(@varlock/pass-plugin) # @initPass(namePrefix=production/app/) # --- # Fetches "production/app/DATABASE_PASSWORD" from the store DATABASE_PASSWORD=pass() # Fetches "production/app/stripe-key" STRIPE_KEY=pass("stripe-key") ``` ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) Access multiple different password stores (e.g., personal and team): .env.schema ```env-spec # @plugin(@varlock/pass-plugin) # @initPass(id=personal) # @initPass(id=team, storePath=/shared/team-store) # --- MY_TOKEN=pass(personal, "tokens/github") SHARED_KEY=pass(team, "api-keys/stripe") ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values using the `pass()` resolver function. ### Basic usage [Section titled “Basic usage”](#basic-usage) Fetch secrets from your pass store: .env.schema ```env-spec # Entry path defaults to the variable name DATABASE_PASSWORD=pass() API_KEY=pass() # Or explicitly specify the entry path STRIPE_KEY=pass("services/stripe/live-key") # Nested entries DB_URL=pass("production/database/url") ``` When called without arguments, `pass()` uses the config item key as the entry path. ### Handling optional secrets [Section titled “Handling optional secrets”](#handling-optional-secrets) Use `allowMissing` when a secret may not exist in the store: .env.schema ```env-spec # Returns empty string instead of erroring if entry doesn't exist OPTIONAL_KEY=pass("monitoring/datadog-key", allowMissing=true) ``` ### Multiline entries [Section titled “Multiline entries”](#multiline-entries) By default, `pass()` returns only the **first line** of the entry (the password), matching pass’s own convention where the password lives on line 1 and metadata follows. This is the same behavior as `pass -c` (copy to clipboard). To retrieve the full multiline content, use `multiline=true`: .env.schema ```env-spec # Only returns the first line (the password) DB_PASSWORD=pass("production/database") # Returns all lines (password + metadata) DB_FULL_ENTRY=pass("production/database", multiline=true) ``` ### Bulk loading secrets [Section titled “Bulk loading secrets”](#bulk-loading-secrets) Use `passBulk()` with `@setValuesBulk` to fetch all entries under a directory in your pass store in one go, instead of wiring up each secret individually: .env.schema ```env-spec # @plugin(@varlock/pass-plugin) # @initPass() # @setValuesBulk(passBulk("services")) # --- # These will be populated from entries under services/ in the pass store STRIPE_KEY= DATABASE_URL= ``` `passBulk()` lists entries via `pass ls`, then fetches each one in parallel. Each entry returns the first line only (matching the `pass()` default). You can customize the scope: .env.schema ```env-spec # Load everything from the store root # @setValuesBulk(passBulk()) # Load from a specific subdirectory # @setValuesBulk(passBulk("production/api")) # With a named instance # @setValuesBulk(passBulk(team, "shared")) ``` *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initPass()` [Section titled “@initPass()”](#initpass) Initialize a pass plugin instance for accessing secrets from a password store. **Key/value args:** * `storePath` (optional): Custom password store path (overrides `PASSWORD_STORE_DIR`, defaults to `~/.password-store`) * `namePrefix` (optional): Prefix automatically prepended to all entry paths * `id` (optional): Instance identifier for multiple instances ```env-spec # Default setup # @initPass() # Custom store location # @initPass(storePath=/path/to/store) # With prefix and ID # @initPass(id=prod, namePrefix=production/) ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `pass()` [Section titled “pass()”](#pass) Fetch a secret from the pass store. Returns the first line of the entry (the password) by default, matching pass’s convention. **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `entryPath` (optional): path to the entry in the pass store. If omitted, uses the variable name. **Key/value args:** * `allowMissing` (optional): if `true`, returns empty string instead of erroring when the entry doesn’t exist * `multiline` (optional): if `true`, returns the full entry content instead of just the first line ```env-spec # Auto-infer entry path from variable name DATABASE_PASSWORD=pass() # Explicit entry path STRIPE_KEY=pass("services/stripe/live-key") # With instance ID TEAM_SECRET=pass(team, "shared/api-key") # Allow missing entries OPTIONAL=pass("maybe/exists", allowMissing=true) # Get full multiline content FULL_ENTRY=pass("services/config", multiline=true) ``` #### `passBulk()` [Section titled “passBulk()”](#passbulk) Fetch all entries under a directory in the pass store at once. Intended for use with `@setValuesBulk`. Lists entries via `pass ls`, then fetches each one in parallel. Each entry returns the first line only (matching the `pass()` default). **Array args:** * `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized * `pathPrefix` (optional): directory prefix to load entries from ```env-spec # Load all entries from the store root # @setValuesBulk(passBulk()) # Load entries under a specific path # @setValuesBulk(passBulk("services")) # With instance ID # @setValuesBulk(passBulk(team, "shared")) ``` *** ## Example configurations [Section titled “Example configurations”](#example-configurations) ### Simple development setup [Section titled “Simple development setup”](#simple-development-setup) .env.schema ```env-spec # @plugin(@varlock/pass-plugin) # @initPass() # --- # Entry paths match variable names DATABASE_URL=pass() REDIS_URL=pass() STRIPE_KEY=pass() ``` ### Production with path organization [Section titled “Production with path organization”](#production-with-path-organization) .env.schema ```env-spec # @plugin(@varlock/pass-plugin) # @initPass(namePrefix=production/) # --- # Fetches production/database/url, production/database/password, etc. DB_URL=pass("database/url") DB_PASSWORD=pass("database/password") STRIPE_KEY=pass("api/stripe-key") SENDGRID_KEY=pass("api/sendgrid-key") ``` ### Team and personal stores [Section titled “Team and personal stores”](#team-and-personal-stores) .env.schema ```env-spec # @plugin(@varlock/pass-plugin) # @initPass(id=personal) # @initPass(id=team, storePath=/shared/team-pass-store) # --- # Personal dev tokens GH_TOKEN=pass(personal, "tokens/github") # Shared team secrets SHARED_DB=pass(team, "databases/staging") SHARED_API_KEY=pass(team, "api-keys/internal") ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### `pass` command not found [Section titled “pass command not found”](#pass-command-not-found) * Install pass using your system package manager (see [Prerequisites](#prerequisites)) * Ensure `pass` is in your `PATH` ### Entry not found [Section titled “Entry not found”](#entry-not-found) * Verify the entry exists: `pass show ` * List available entries: `pass ls` * Check for typos in the entry path * If using `namePrefix`, remember it’s prepended automatically ### GPG decryption failed [Section titled “GPG decryption failed”](#gpg-decryption-failed) * Ensure your GPG key is available: `gpg --list-keys` * Start the GPG agent: `gpgconf --launch gpg-agent` * You may need to enter your GPG passphrase ### Password store not initialized [Section titled “Password store not initialized”](#password-store-not-initialized) * Run `pass init "Your GPG Key ID"` to initialize the store * See `pass init --help` for details ## Resources [Section titled “Resources”](#resources) * [pass - The Standard Unix Password Manager](https://www.passwordstore.org/) * [pass man page](https://git.zx2c4.com/password-store/about/) * [GPG documentation](https://gnupg.org/documentation/) # Passbolt Plugin > Using Passbolt with Varlock [![](https://img.shields.io/npm/v/@varlock/passbolt-plugin?label=%40varlock%2Fpassbolt-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/passbolt-plugin) Our [Passbolt](https://www.passbolt.com/) plugin loads secrets from Passbolt password manager using declarative instructions within your `.env` files. Passbolt is an open-source, self-hosted password manager built for teams. This plugin connects to your Passbolt instance’s API using your account kit and passphrase, decrypting secrets via OpenPGP. ## Features [Section titled “Features”](#features) * **Account kit authentication**: provide your account kit and passphrase * **UUID-based secret access**: fetch secrets by their resource UUID * **`#field` syntax**: read specific fields (username, password, URI, TOTP, custom fields) * **Bulk loading** with `passboltBulk()` to load all passwords from a folder * **Custom fields object** with `passboltCustomFieldsObj()` for `@setValuesBulk` * **Self-hosted support**: API URL is read from your account kit * **Multiple instances**: connect to different Passbolt instances ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/passbolt-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/passbolt-plugin) # # 2. Initialize the plugin - see below for auth setup # @initPassbolt(accountKit=$PB_ACCOUNT_KIT, passphrase=$PB_PASSPHRASE) # --- # 3. Add config items for your credentials # @type=passboltAccountKit @sensitive @internal PB_ACCOUNT_KIT= # @sensitive PB_PASSPHRASE= ``` ### Authentication setup [Section titled “Authentication setup”](#authentication-setup) The plugin authenticates using your Passbolt **account kit** (a base64-encoded bundle containing your server URL, keys, and user info) and your **passphrase** (used to decrypt your private key). 1. **Download your account kit** from Passbolt: * Open your Passbolt instance * Navigate to **Manage account** → **Desktop app setup** * Click **Download your account kit** and copy the base64 contents 2. **Wire up your credentials in the schema**: .env.schema ```env-spec # @plugin(@varlock/passbolt-plugin) # @initPassbolt(accountKit=$PB_ACCOUNT_KIT, passphrase=$PB_PASSPHRASE) # --- # @type=passboltAccountKit @sensitive @internal PB_ACCOUNT_KIT= # @sensitive PB_PASSPHRASE= ``` 3. **Set your credentials in deployed environments**. Copy the account kit and passphrase values and set them using your platform’s env var management UI. ### Multiple instances [Section titled “Multiple instances”](#multiple-instances) If you need to connect to multiple Passbolt instances or users, register named instances using the `id` parameter: .env.schema ```env-spec # @initPassbolt(id=prod, accountKit=$PROD_ACCOUNT_KIT, passphrase=$PROD_PASSPHRASE) # @initPassbolt(id=dev, accountKit=$DEV_ACCOUNT_KIT, passphrase=$DEV_PASSPHRASE) ``` ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Once the plugin is installed and initialized, you can start adding config items that load values from Passbolt using the `passbolt()` resolver function. ### Basic usage [Section titled “Basic usage”](#basic-usage) Fetch secrets by resource UUID. By default, the password field is returned: .env.schema ```env-spec # Fetch password (default field) by resource UUID DB_PASSWORD=passbolt("01234567-0123-4567-890a-bcdef0123456") API_KEY=passbolt("76543210-3210-4321-a098-ba9876543210") ``` ### Fetching specific fields [Section titled “Fetching specific fields”](#fetching-specific-fields) Use `#field` syntax to fetch a specific field from a resource: .env.schema ```env-spec # Built-in fields LOGIN_URI=passbolt("01234567-0123-4567-890a-bcdef0123456#uri") LOGIN_USER=passbolt("01234567-0123-4567-890a-bcdef0123456#username") LOGIN_PASS=passbolt("01234567-0123-4567-890a-bcdef0123456#password") # TOTP fields TOTP_SECRET=passbolt("01234567-0123-4567-890a-bcdef0123456#totp.secret") TOTP_CODE=passbolt("01234567-0123-4567-890a-bcdef0123456#totp.code") # Custom fields: any unrecognized name is treated as a custom field MY_FIELD=passbolt("01234567-0123-4567-890a-bcdef0123456#MyCustomField") ``` Alternatively, use the named `field` parameter: .env.schema ```env-spec LOGIN_PASS=passbolt("01234567-0123-4567-890a-bcdef0123456", field="password") CUSTOM=passbolt("01234567-0123-4567-890a-bcdef0123456", field="AnotherField") ``` ### Using multiple instances [Section titled “Using multiple instances”](#using-multiple-instances) When multiple plugin instances are initialized, pass the instance ID as the first argument: .env.schema ```env-spec PROD_SECRET=passbolt(prod, "11111111-1111-4111-a111-111111111111") DEV_SECRET=passbolt(dev, "22222222-2222-4222-b222-222222222222") ``` ### Bulk loading from a folder [Section titled “Bulk loading from a folder”](#bulk-loading-from-a-folder) Use `passboltBulk()` with `@setValuesBulk` to load all passwords from a Passbolt folder at once. Resources are matched by name: .env.schema ```env-spec # @plugin(@varlock/passbolt-plugin) # @initPassbolt(accountKit=$PB_ACCOUNT_KIT, passphrase=$PB_PASSPHRASE) # @setValuesBulk(passboltBulk(folderPath="Database/Dev")) # --- # @type=passboltAccountKit @sensitive @internal PB_ACCOUNT_KIT= # @sensitive PB_PASSPHRASE= # These will be populated from Passbolt (matched by resource name) API_KEY= DB_PASSWORD= ``` ### Bulk loading custom fields [Section titled “Bulk loading custom fields”](#bulk-loading-custom-fields) Use `passboltCustomFieldsObj()` with `@setValuesBulk` to load all custom fields from a single resource: .env.schema ```env-spec # @setValuesBulk(passboltCustomFieldsObj("01234567-0123-4567-890a-bcdef0123456")) # --- # These will be populated from custom field names API_KEY= DB_PASSWORD= ``` ### Finding resource UUIDs [Section titled “Finding resource UUIDs”](#finding-resource-uuids) To find a resource’s UUID: 1. Open your Passbolt instance 2. Navigate to the resource 3. Copy the UUID from the URL (format: `xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx`) *** ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initPassbolt()` [Section titled “@initPassbolt()”](#initpassbolt) Initializes an instance of the Passbolt plugin, setting up authentication and options. Can be called multiple times for different instances. **Key/value args:** * `accountKit` **(required)**: Passbolt account kit (base64-encoded). Should be a reference to a config item of type `passboltAccountKit`. * `passphrase` **(required)**: Passphrase to decrypt your private key. Should be a reference to a sensitive config item. * `cacheTtl` (optional): cache resolved values for the specified duration (e.g. `"5m"`, `"1h"`, `"1d"`, or `forever` to cache until manually cleared). For cache mode behavior and CLI cache controls, see the [Caching guide](/guides/caching/). * `id` (optional): instance identifier for multiple instances ```env-spec # @initPassbolt(accountKit=$PB_ACCOUNT_KIT, passphrase=$PB_PASSPHRASE, cacheTtl="1h") # --- # @type=passboltAccountKit @sensitive @internal PB_ACCOUNT_KIT= # @sensitive PB_PASSPHRASE= ``` ### Data types [Section titled “Data types”](#data-types) #### `passboltAccountKit` [Section titled “passboltAccountKit”](#passboltaccountkit) This type is [`@internal`](/reference/item-decorators/#internal) by default: varlock uses it to fetch your other secrets but does **not** inject it into your application. Override with `@internal=false` if your app uses the credential directly, for example to write secrets back or fetch additional secrets at runtime. Represents a Passbolt account kit (base64-encoded). Validation ensures the value is valid base64. The type is marked as `@sensitive`, so adding an explicit `@sensitive` decorator is optional. ```env-spec # @type=passboltAccountKit @sensitive @internal PB_ACCOUNT_KIT= ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `passbolt()` [Section titled “passbolt()”](#passbolt) Fetches a field from a Passbolt resource. Returns the password field by default. **Array args:** * `instanceId` (optional): instance identifier when multiple plugin instances are initialized * `resourceId`: UUID of the resource to fetch. Supports `#field` suffix for field extraction. **Key/value args:** * `field` (optional): alternative to `#field` syntax for specifying which field to read **Built-in fields:** `password` (default), `username`, `uri`, `totp.secret`, `totp.code`. Any other name is treated as a custom field. ```env-spec # Default password field SECRET=passbolt("01234567-0123-4567-890a-bcdef0123456") # Specific field via #field USER=passbolt("01234567-0123-4567-890a-bcdef0123456#username") # Named field parameter USER=passbolt("01234567-0123-4567-890a-bcdef0123456", field="username") # With instance ID SECRET=passbolt(prod, "01234567-0123-4567-890a-bcdef0123456") ``` #### `passboltBulk()` [Section titled “passboltBulk()”](#passboltbulk) Loads all passwords from a Passbolt folder as a JSON map keyed by resource name. Intended for use with `@setValuesBulk`. **Array args:** * `instanceId` (optional): instance identifier when multiple plugin instances are initialized **Key/value args:** * `folderPath` **(required)**: folder path to load resources from. Use `/` to separate nested folders (escape literal `/` in names with `\`). ```env-spec # Load from a folder # @setValuesBulk(passboltBulk(folderPath="Production")) # Nested folder # @setValuesBulk(passboltBulk(folderPath="Production/Database")) # With instance ID # @setValuesBulk(passboltBulk(prod, folderPath="Production")) ``` #### `passboltCustomFieldsObj()` [Section titled “passboltCustomFieldsObj()”](#passboltcustomfieldsobj) Fetches all custom fields from a Passbolt resource as a JSON object. Intended for use with `@setValuesBulk`. **Array args:** * `instanceId` (optional): instance identifier when multiple plugin instances are initialized * `resourceId`: UUID of the resource ```env-spec # Load custom fields from a resource # @setValuesBulk(passboltCustomFieldsObj("01234567-0123-4567-890a-bcdef0123456")) # With instance ID # @setValuesBulk(passboltCustomFieldsObj(prod, "01234567-0123-4567-890a-bcdef0123456")) ``` *** ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Authentication failed [Section titled “Authentication failed”](#authentication-failed) * Verify the account kit is correct (must be valid base64) * Ensure the passphrase matches the one used when the account was created ### Resource not found [Section titled “Resource not found”](#resource-not-found) * Verify the resource UUID is correct (must be valid UUID v4 format) * Check that the resource exists in your Passbolt instance * Ensure the resource is shared with your user ### Folder not found [Section titled “Folder not found”](#folder-not-found) * Check that the folder path is correct and exists * Folder paths are case-sensitive * Use `/` to separate nested folders ## Resources [Section titled “Resources”](#resources) * [Passbolt](https://www.passbolt.com/): open-source password manager for teams * [Passbolt Help](https://help.passbolt.com/): official documentation * [Passbolt Community](https://community.passbolt.com/): community forums # Proton Pass Plugin > Using Proton Pass with Varlock [![](https://img.shields.io/npm/v/@varlock/proton-pass-plugin?label=%40varlock%2Fproton-pass-plugin\&color=9B55F5\&logo=npm)](https://npmx.dev/package/@varlock/proton-pass-plugin) Our [Proton Pass](https://proton.me/pass) plugin loads secrets from Proton Pass using declarative instructions within your `.env` files. It shells out to the official Proton Pass CLI (`pass-cli`) to resolve secret references in the format `pass://vault/item/field`. ## Features [Section titled “Features”](#features) * **Secret references via `pass://` URIs** (`pass://vault/item/field`) * **Personal access token login** for CI (`PROTON_PASS_PERSONAL_ACCESS_TOKEN`): non-interactive, scoped, no full account credentials * **Non-interactive password login** using environment variables (`PROTON_PASS_PASSWORD`, `PROTON_PASS_TOTP`, `PROTON_PASS_EXTRA_PASSWORD`) * **In-session caching** per resolution run * **Helpful error messages** with resolution tips ## Installation and setup [Section titled “Installation and setup”](#installation-and-setup) In a JS/TS project, you may install the `@varlock/proton-pass-plugin` package as a normal dependency. Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. ### Install Proton Pass CLI [Section titled “Install Proton Pass CLI”](#install-proton-pass-cli) You must have `pass-cli` installed: ```bash curl -fsSL https://proton.me/download/pass-cli/install.sh | bash ``` See: [Proton Pass CLI overview](https://protonpass.github.io/pass-cli/). ### Configure the plugin [Section titled “Configure the plugin”](#configure-the-plugin) In production/CI you typically want a fully non-interactive login. The plugin supports two authentication methods: a **personal access token** (recommended) or a **username + password**. Login credentials are `@internal` by default The login credential types (`protonPassPersonalAccessToken`, `protonPassPassword`, `protonPassTotp`, `protonPassExtraPassword`) are marked [`@internal`](/reference/item-decorators/#internal): varlock uses them to authenticate but does **not** inject them into your application. Override with `@internal=false` if needed. #### Personal access token (recommended) [Section titled “Personal access token (recommended)”](#personal-access-token-recommended) A [personal access token](https://protonpass.github.io/pass-cli/commands/login/#personal-access-token-login) is a scoped, non-interactive credential. It never needs a username, password, or TOTP, which makes it the cleanest option for CI. Create one with the Proton Pass CLI, then grant it access to the vaults/items it needs: ```bash # Create a token (the full value is only shown once, so save it somewhere safe) pass-cli pat create --name "deploy-bot" --expiration 3m # PROTON_PASS_PERSONAL_ACCESS_TOKEN=pst_xxxx...xxxx::TOKENKEY # Grant it access to a vault or item pass-cli pat access grant ... ``` .env.schema ```env-spec # 1. Load the plugin # @plugin(@varlock/proton-pass-plugin) # # 2. Initialize Proton Pass plugin instance # @initProtonPass( # id=prod, # personalAccessToken=$PROTON_PASS_PERSONAL_ACCESS_TOKEN # ) # --- # @type=protonPassPersonalAccessToken @sensitive @internal PROTON_PASS_PERSONAL_ACCESS_TOKEN= ``` When a `personalAccessToken` is provided it takes precedence: the plugin runs `pass-cli login` with the token and ignores `username`/`password`/`totp`/`extraPassword`. Token expiration Personal access tokens have a **mandatory expiration** set when you create them (`1d`, `1w`, `1m`, `3m`, `6m`, or `1y`) and do **not** auto-renew. When a token expires, mint a new one with `pass-cli pat renew` (or `pat create`) and update the value in your config. There is nothing for the plugin to refresh or cache on your behalf. #### Username + password [Section titled “Username + password”](#username--password) .env.schema ```env-spec # @initProtonPass( # id=prod, # username=$PROTON_PASS_USERNAME, # password=$PROTON_PASS_PASSWORD, # totp=$PROTON_PASS_TOTP, # extraPassword=$PROTON_PASS_EXTRA_PASSWORD # ) # --- # @type=protonPassPassword @sensitive @internal PROTON_PASS_PASSWORD= # @type=protonPassTotp @sensitive @internal PROTON_PASS_TOTP= # @type=protonPassExtraPassword @sensitive @internal PROTON_PASS_EXTRA_PASSWORD= ``` The `username` is passed as an argument to `pass-cli login --interactive `. The password/TOTP/extra password are provided to the CLI via environment variables, as supported by Proton Pass CLI login docs: [login command](https://protonpass.github.io/pass-cli/commands/login/). Already logged in? If `pass-cli` is already logged in, you can also omit credentials and just rely on the existing session. This mostly applies to local development, in CI, you will probably want static credentials. ### Reducing local login prompts [Section titled “Reducing local login prompts”](#reducing-local-login-prompts) For local development, especially with multi-process task runners like Turbo, these patterns help reduce repeated prompts: * Store Proton Pass credentials in local config items (for example `.env.local`), wire them into `@initProtonPass(...)`, and encrypt sensitive values using [Varlock local encryption](/guides/local-encryption/). * Reuse an existing `pass-cli` session when possible (`pass-cli login` once in your terminal before starting your dev tasks). TOTP in local config If your account requires TOTP, a static value in config will expire quickly. For fully non-interactive workflows, use a [personal access token](#personal-access-token-recommended) instead. ## Loading secrets [Section titled “Loading secrets”](#loading-secrets) Use the `protonPass()` resolver function to fetch a field value from a Proton Pass secret reference. Secret reference syntax (as documented by Proton Pass CLI): [secret references](https://protonpass.github.io/pass-cli/commands/contents/secret-references/). .env.schema ```env-spec # Fetch the `password` field from a secret reference: DB_PASSWORD=protonPass(pass://Production/Database/password) ``` When you need multiple plugin instances, use the optional first argument to select the `id`: .env.schema ```env-spec DB_PASSWORD=protonPass(prod, pass://Production/Database/password) ``` ### Optional secrets [Section titled “Optional secrets”](#optional-secrets) If a secret might not exist, pass `allowMissing=true`: .env.schema ```env-spec OPTIONAL_DB_PASSWORD=protonPass(pass://Production/Database/password, allowMissing=true) ``` When allowed, missing secrets resolve to an empty string (`""`). ## Reference [Section titled “Reference”](#reference) ### Root decorators [Section titled “Root decorators”](#root-decorators) #### `@initProtonPass()` [Section titled “@initProtonPass()”](#initprotonpass) Initialize a Proton Pass plugin instance for `protonPass()` resolver. **Key/value args:** * `id` (optional): instance identifier for multiple instances * `personalAccessToken` (optional): personal access token (maps to `PROTON_PASS_PERSONAL_ACCESS_TOKEN`). When set, takes precedence over username/password. * `username` (optional): login username/email passed to `pass-cli login --interactive` * `password` (optional): `pass-cli` password (maps to `PROTON_PASS_PASSWORD`) * `totp` (optional): `pass-cli` TOTP code (maps to `PROTON_PASS_TOTP`) * `extraPassword` (optional): `pass-cli` extra password (maps to `PROTON_PASS_EXTRA_PASSWORD`) ```env-spec # @initProtonPass( # id=prod, # personalAccessToken=$PROTON_PASS_PERSONAL_ACCESS_TOKEN # ) ``` ### Resolver functions [Section titled “Resolver functions”](#resolver-functions) #### `protonPass()` [Section titled “protonPass()”](#protonpass) Fetch a field value from Proton Pass using a secret reference. **Array args:** * `secretRef` (required): `pass://vault/item/field` * `instanceId` (optional, if 2 args are provided): instance identifier **Key/value args:** * `allowMissing` (optional): if `true`, returns empty string when the secret is missing ```env-spec # With secret reference (default instance) DB_PASSWORD=protonPass(pass://Production/Database/password) # With explicit instance DB_PASSWORD=protonPass(prod, pass://Production/Database/password) ``` ### Under the hood [Section titled “Under the hood”](#under-the-hood) This resolver uses: * `pass-cli item view --output json ` to fetch the requested field * `pass-cli login` only when an auth error occurs, then retries the original command. With a `personalAccessToken` it runs `pass-cli login` with `PROTON_PASS_PERSONAL_ACCESS_TOKEN` set; otherwise it runs `pass-cli login --interactive ` with the password env vars. # Builtin variables > Auto-detected VARLOCK_* variables for CI platform, branch, commit, and environment info Varlock provides a set of **builtin `VARLOCK_*` variables** that are automatically populated with information about the current CI/deploy platform, git branch, commit, and inferred deployment environment. They are entirely **opt-in**: they only exist in your schema when you reference them. ## Usage [Section titled “Usage”](#usage) Builtin variables are activated when you reference them via `$VARLOCK_*` in a value expression: .env.schema ```env-spec # @currentEnv=$VARLOCK_ENV # --- BUILD_TAG="build-$VARLOCK_COMMIT_SHA_SHORT" DB_URL=if( eq($VARLOCK_ENV, development), postgres://localhost/myapp, postgres://${VARLOCK_ENV}-db.example.com/myapp ) ``` If you want to include a builtin variable in your resolved env without referencing it from another item, define it with an empty value and varlock will populate it automatically: .env.schema ```env-spec VARLOCK_BRANCH= VARLOCK_COMMIT_SHA_SHORT= ``` You can also use `VARLOCK_ENV` as your environment flag with `@currentEnv`, which means you don’t need to create your own `APP_ENV` variable. Varlock will auto-detect the environment for you. Verify detection works for your setup Auto-detection is based on environment variables set by each CI/deploy platform. Different platforms expose different information, and detection heuristics (especially branch-to-environment inference) may not match your conventions. **Always verify that the detected values match your expectations** before relying on them in production. You can check the resolved values using `varlock run -- env | grep VARLOCK_` or by inspecting the output of `varlock load`. ## Builtin Vars [Section titled “Builtin Vars”](#builtin-vars) ### `VARLOCK_ENV` [Section titled “VARLOCK\_ENV”](#varlock_env) **Type:** `string`, one of `development`, `preview`, `staging`, `production`, `test` The inferred deployment environment. Detection follows this priority: 1. **Test environment**: detected from `NODE_ENV=test`, `VITEST`, `JEST_WORKER_ID`, or `VITEST_POOL_ID` 2. **Platform-provided**: uses the platform’s own environment concept (e.g., Vercel’s `VERCEL_ENV`, Netlify’s `CONTEXT`) 3. **Branch inference**: in CI, infers from branch name: `main`/`master`/`production`/`prod` → `production`, `staging`/`stage`/`develop`/`dev` → `staging`, `qa`/`test` → `test`, anything else → `preview` 4. **CI fallback**: if in CI but no branch info is available, defaults to `preview` 5. **Local fallback**: if not in CI, defaults to `development` #### Using with `@currentEnv` [Section titled “Using with @currentEnv”](#using-with-currentenv) .env.schema ```env-spec # @currentEnv=$VARLOCK_ENV # --- DB_HOST=if(forEnv(production), "prod-db.example.com", "localhost") DB_NAME=myapp DB_URL="postgres://$DB_HOST/$DB_NAME" ``` #### Test environment caveat [Section titled “Test environment caveat”](#test-environment-caveat) Test runners and `VARLOCK_ENV` Many test runners (Vitest, Jest, etc.) set `NODE_ENV=test` **after** the process has started, often after varlock has already loaded and resolved your env vars. This means `VARLOCK_ENV` may not detect `test` automatically in all setups. If you depend on `VARLOCK_ENV=test` to load `.env.test` or toggle behavior via `forEnv(test)`, **explicitly pass it** when running tests: ```bash VARLOCK_ENV=test bun run test # or VARLOCK_ENV=test varlock run -- vitest ``` This is the same pattern recommended for any environment flag. See the [environments guide](/guides/environments/) for more details. ### `VARLOCK_IS_CI` [Section titled “VARLOCK\_IS\_CI”](#varlock_is_ci) **Type:** `boolean` Whether the current process is running in a CI environment. ### `VARLOCK_BRANCH` [Section titled “VARLOCK\_BRANCH”](#varlock_branch) **Type:** `string | undefined` The current git branch name. In CI environments, sourced from the platform’s environment variables. When running locally (non-CI), auto-detected via `git branch --show-current`. Undefined if the branch cannot be determined (e.g., detached HEAD state, no git repo, or platform doesn’t expose branch info). ### `VARLOCK_PR_NUMBER` [Section titled “VARLOCK\_PR\_NUMBER”](#varlock_pr_number) **Type:** `string | undefined` The pull/merge request number, if the current build is for a PR. Undefined otherwise. ### `VARLOCK_COMMIT_SHA` [Section titled “VARLOCK\_COMMIT\_SHA”](#varlock_commit_sha) **Type:** `string | undefined` The full git commit SHA. ### `VARLOCK_COMMIT_SHA_SHORT` [Section titled “VARLOCK\_COMMIT\_SHA\_SHORT”](#varlock_commit_sha_short) **Type:** `string | undefined` The short (7-character) git commit SHA. ### `VARLOCK_PLATFORM` [Section titled “VARLOCK\_PLATFORM”](#varlock_platform) **Type:** `string | undefined` The name of the detected CI/deploy platform (e.g., `"GitHub Actions"`, `"Vercel"`, `"Netlify CI"`). ### `VARLOCK_BUILD_URL` [Section titled “VARLOCK\_BUILD\_URL”](#varlock_build_url) **Type:** `url | undefined` A URL linking to the current build or deploy in the CI platform’s UI. ### `VARLOCK_REPO` [Section titled “VARLOCK\_REPO”](#varlock_repo) **Type:** `string | undefined` The repository name in `owner/repo` format. ### `VARLOCK_RUNTIME` [Section titled “VARLOCK\_RUNTIME”](#varlock_runtime) **Type:** `string | undefined` The current JS runtime, one of `node`, `bun`, `deno`, `workerd`, `fastly`, `netlify`, `edge-light`, or `browser`. Detected from ambient globals (not environment variables), so it reflects the actual process regardless of which CI/deploy platform is detected. ### `VARLOCK_OS` [Section titled “VARLOCK\_OS”](#varlock_os) **Type:** `string | undefined` The current OS platform: `darwin`, `win32`, or `linux`. Undefined in environments without a `process.platform` (e.g., browsers, Cloudflare Workers). ## Supported platforms [Section titled “Supported platforms”](#supported-platforms) Detection is built-in for these platforms (no configuration required): * GitHub Actions * GitLab CI * Vercel * Netlify * Cloudflare Pages / Workers * AWS Amplify / CodeBuild * Azure Pipelines * Bitbucket Pipelines * Buildkite * CircleCI * Jenkins * Railway * Render * Travis CI * Google Cloud Run * Deno Deploy * Zeabur * Firebase App Hosting * and [many more](https://github.com/dmno-dev/varlock/tree/main/packages/ci-env-info/src/platforms.ts) Interactive dev sandboxes (CodeSandbox, StackBlitz, GitHub Codespaces, Gitpod, Replit) are also detected and reported via `VARLOCK_PLATFORM`, but `VARLOCK_IS_CI` is `false` for these since they aren’t a CI pipeline. Not all platforms expose all fields. For example, some may not provide branch name or PR number. CI/deploy platform detection is powered by [`@varlock/ci-env-info`](https://npmx.dev/package/@varlock/ci-env-info), which can also be used as a standalone package. # CLI Commands > Reference documentation for Varlock CLI commands Varlock provides a command-line interface for managing environment variables and secrets. This reference documents all available CLI commands. See [installation](/getting-started/installation) for instructions on how to install Varlock. You can also enable [shell completion](/guides/shell-completion/) for tab completion of commands and flags. ### Running commands in JS projects [Section titled “Running commands in JS projects”](#running-commands-in-js-projects) If you have installed varlock as a `package.json` dependency, rather than a standalone binary, the best way to invoke the CLI is via your package manager: * npm ```bash npm exec -- varlock ... ``` * pnpm ```bash pnpm exec -- varlock ... ``` * bun ```bash bunx varlock ... ``` * vlt ```bash vlx -- varlock ... ``` * yarn ```bash yarn exec -- varlock ... ``` Also note that within package.json scripts, you can use it directly: package.json ```json { "scripts": { "start": "varlock run -- node app.js" } } ``` ### `package.json` configuration [Section titled “package.json configuration”](#packagejson-configuration) You can configure varlock’s default behavior by adding a `varlock` key to your `package.json`: package.json ```json { "varlock": { "loadPath": "./envs/" } } ``` | Option | Description | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `loadPath` | Path (or array of paths) to a directory or specific `.env` file to use as the default entry point. Defaults to the current working directory if not set. Use a **directory path** (with trailing `/`) to automatically load all relevant files (`.env.schema`, `.env`, `.env.local`, etc.); a file path only loads that file and its explicit imports. When an array is provided, all paths are loaded and combined, with later entries taking higher precedence. Can be overridden by the `--path` CLI flag. Varlock looks for this config in the `package.json` in the current working directory only. | Plugins While plugins cannot add additional CLI commands, they can extend varlock with additional [resolver functions](/reference/functions/), [data types](/reference/data-types/), and [decorators](/reference/item-decorators/). See the [Plugins guide](/guides/plugins/) for more information. ## Common options [Section titled “Common options”](#common-options) Several commands share these flags. They behave identically wherever they appear, except where a command’s own section calls out a deviation. * `--path` / `-p` ``: Path to a specific `.env` file or directory to use as the entry point (overrides `varlock.loadPath` in `package.json`). Can be specified multiple times to load from multiple paths, where later paths take higher precedence. *(For [`scan`](/reference/cli/project/#scan) and [`audit`](/reference/cli/encryption/#audit) this is the **schema** entry point used to resolve sensitive values; `audit` accepts a single path only.)* * `--env `: Resolve in the context of a specific environment (e.g., `--env production`). Overridden by `@currentEnv` if it is set in your `.env.schema`. * `--clear-cache`: Clear the active cache store before resolving values, then re-resolve all values (when combined with `--skip-cache`, the cache is cleared first, then reads and writes are skipped for the run). * `--skip-cache`: Skip cache entirely for this invocation (no reads or writes). This overrides `@cache=disk`/`@cache=memory`. * `--filter` (on [`load`](/reference/cli/load-and-run/#load) and [`run`](/reference/cli/load-and-run/#run)): Filter which items are shown/injected. See [Filtering items](#filtering-items) below. ### Filtering items [Section titled “Filtering items”](#filtering-items) `--filter` takes a comma-separated list of selectors: * a key name or glob, e.g. `STRIPE_*` (matches `*` and `?`) * `!selector` to negate any of the below, e.g. `!STRIPE_DEBUG_KEY` * `@sensitive` / `@required` / `@dynamic` to select by decorator (negate for the opposite, e.g. `!@dynamic` selects static items) * `#tagname` to select items tagged via [`@tag(tagname)`](/reference/item-decorators/#tag) **How selectors combine:** every non-negated selector is OR’d together into one inclusion set, regardless of kind: mixing a glob, a `@decorator`, and a `#tag` in the same filter just widens that set. Anything matching a negated (`!`) selector is then subtracted from that set, again regardless of kind. If a filter has no non-negated selectors at all, the inclusion set starts as “everything” before negations are subtracted. ```bash varlock load --filter="KEY1,!NOT_THIS,STRIPE_*" # KEY1 and STRIPE_* keys, except NOT_THIS varlock load --filter="@sensitive" # only items marked @sensitive varlock load --filter="@required" # only required items varlock load --filter="#billing" # only items tagged @tag(billing) varlock load --filter="@dynamic" # only runtime-resolved (dynamic) items varlock load --filter="!@dynamic,!@sensitive" # inlineable public items only varlock load --filter="@sensitive,#billing" # sensitive items OR billing-tagged items varlock load --filter="STRIPE_*,!@sensitive" # STRIPE_* keys, minus any that are sensitive varlock load --filter="!#debug" # everything except items tagged @tag(debug) ``` There’s no way to express an intersection (e.g. “`STRIPE_*` AND `@sensitive`”): only unions of non-negated selectors minus unions of negated ones. A negated selector always subtracts from the whole inclusion set; it isn’t scoped to only the positive selector(s) that happened to include a given item. [`@internal`](/reference/item-decorators/#internal) items follow their usual visibility rules: `--filter` can only narrow a view further, never cause an internal item to appear somewhere it otherwise wouldn’t (even if a selector matches it by exact key name). On plain `json`, `env`, and `shell` output, and in `run`’s injected env / `__VARLOCK_ENV` blob, internal items are always excluded. The views that can show internal items keep doing so under a filter: the default `pretty` format always shows them, `--agent` shows them redacted, and `--format json-full` (on `load`) and `run` include them only with `--include-internal`. In every case, internal items still have to satisfy `--filter` to appear. On `run`, `--filter` doesn’t just skip injection: excluded schema keys are also stripped from the `__VARLOCK_ENV` blob and removed from the child environment even when set in the ambient environment (same treatment as `@internal` items), so a filtered-out var can’t reach the child process at all. Can also be set via the [`_VARLOCK_FILTER`](/reference/reserved-variables/#_varlock_filter) env var, for wrapper scripts, CI config, or anywhere else passing a CLI flag is inconvenient. An explicit `--filter` flag takes precedence. A filter that matches no items (e.g. a typo’d key or tag) prints a warning to stderr. The command still succeeds, with empty output on `load` or no schema vars injected on `run`. **A `--filter` also scopes resolution and validation**, not just output: only items it selects (plus their dependencies) are resolved, so an unrelated broken item outside the filter won’t block `load`/`run`, and excluded items’ value resolvers (exec commands, secrets managers, etc.) never run. This is useful for scoping validation differently across contexts, e.g. a build step that only needs `--filter="#frontend"` shouldn’t fail because an unrelated backend-only var is misconfigured, and `--filter="!@dynamic"` at build time skips runtime-only vars (e.g. platform-injected values that don’t exist yet at build time) including their `@required` checks. Decorator selectors match on *computed* state, which can be value-dependent (e.g. `@required=forEnv(prod)`), so for those varlock resolves each candidate item’s decorator metadata first (cheap - no value resolvers run), then matches exactly and only resolves values for selected items. Values that decorator functions themselves reference (e.g. `@required=eq($OTHER, x)` needs `OTHER`) are true dependencies of evaluating the filter and do get resolved. ## Commands [Section titled “Commands”](#commands) Command docs are grouped by topic: * [Load and run](/reference/cli/load-and-run/): `load`, `run`, `printenv`, `explain` * [Project commands](/reference/cli/project/): `init`, `scan`, `install-plugin`, `flatten`, `telemetry`, `help` * [Encryption commands](/reference/cli/encryption/): `encrypt`, `reveal`, `lock`, `audit`, `generate-key` * [Cache and codegen](/reference/cli/cache-and-codegen/): `cache`, `codegen` * [Proxy command](/reference/cli/proxy/): `proxy` # @type data types > A reference page of available data types to be used with the `@type` item decorator The [`@type` item decorator](/reference/item-decorators/#type) sets the data type associated with an item. The data type affects coercion, validation, and [generated type files](/reference/root-decorators/#code-generation). ### Additional data type options [Section titled “Additional data type options”](#additional-data-type-options) All types (except `enum`) can be used without any arguments, but most take optional arguments that further narrow the type’s behavior. ```env-spec # @type=string NO_ARGS= # @type=string(minLength=5, maxLength=10, toUpperCase=true) WITH_ARGS= ``` ### Dynamic type options [Section titled “Dynamic type options”](#dynamic-type-options) Type option values can be [resolver functions](/reference/functions/) instead of static values, so validation can vary per environment or based on other items: ```env-spec # @type=enum(dev, production) APP_ENV=dev # stricter length requirement in production # @type=string(minLength=if(eq($APP_ENV, production), 32, 8)) API_TOKEN= # require at least one entry only in production # @type=array(email, minLength=if(eq($APP_ENV, production), 1, 0)) ALERT_EMAILS=[] ``` The whole type can also be dynamic, resolving to a type name: ```env-spec # validate as a url in production, allow anything in dev # @type=if(eq($APP_ENV, production), url, string) SERVICE_HOST=localhost:3000 ``` One constraint: dynamic parts may vary validation behavior, but not the **generated** types (generated code must not differ per environment). All possible types must generate the same type. A `url` and a `string` both generate a string, so switching between them is fine; switching between `number` and `string` is a schema error. For generated code, the first static candidate in the expression is used (`url` above). The same rule applies to options that affect generated types (e.g. `number(isInt=...)` must stay static). Dynamic whole types resolve to a bare type name; to combine a dynamic type with options, put the dynamic part in the option instead (e.g. `string(minLength=if(...))`). Enum members can also be sourced from other items. A referenced array spreads its elements into the member list: ```env-spec # @type=array(string) ALLOWED_MODES=[dev, staging, prod] # value must be one of the elements of ALLOWED_MODES # @type=enum($ALLOWED_MODES) APP_MODE=dev # static members and references can be mixed # @type=enum(local, $ALLOWED_MODES) BUILD_MODE=local ``` Since membership is only known at resolution time, generated code types a dynamic enum as a plain string, and members must resolve to strings. ### Coercion & validation process [Section titled “Coercion & validation process”](#coercion--validation-process) Once a raw value is resolved - which could from a static value in an `.env` file, a [function](/reference/functions/), or an override passed into the process - the raw value will be coerced and validated based on the type, respecting additional arguments provided to the type. Consider the following example: ```env-spec # @type=number(precision=0, max=100) ITEM="123.45" ``` The internal coercion/validation process looks like:\ `"123.45"` -> `123.45` -> `123` -> ❌ invalid (greater than max) ### Default behavior [Section titled “Default behavior”](#default-behavior) When no `@type` is specified, a type will be inferred where possible - for static values, and some functions that return a known type. Note that the use of quotes matters. Otherwise the type will default to `string`. ```env-spec INFERRED_STRING_QUOTED="foo" INFERRED_STRING_UNQUOTED=foo INFERRED_NUMBER=123 # infers number type QUOTED_NUM_STRING="123" # remains a string unless @type=number is used INFERRED_BOOLEAN=true # return type of some functions can be inferred CONCAT_INFERS_STRING=`concat-${SOMEVAR}-will-be-string` FN_INFER_BOOLEAN=eq($VAR1, $VAR2) DEFAULTS_TO_STRING_FN=fnThatCannotInferType() # with no other info, we default to string DEFAULTS_TO_STRING= ``` Note that numeric values that would lose precision, or change any formatting (like leading/trailing zeros), will be treated as strings unless explicitly adding `@type=number`. In any slightly ambiguous situation, it is better to explicitly add a `@type` decorator. ## Built-in data types [Section titled “Built-in data types”](#built-in-data-types) These are the built-in data types. [Plugins](/guides/plugins/) may register additional data types, which appear in [generated code](/guides/code-generation/#extending-with-plugins) according to the coerced type they declare (as strings if they don’t declare one). ### `string` [Section titled “string”](#string) **Options:** * `minLength` (number): Minimum length of the string * `maxLength` (number): Maximum length of the string * `isLength` (number): Exact length required * `startsWith` (string): Required starting substring * `endsWith` (string): Required ending substring * `matches` (string|RegExp): Regular expression pattern to match. Use `/pattern/flags` syntax or a quoted string pattern (see [regex-like strings](/reference/functions#regex-like-strings)) * `toUpperCase` (boolean): Convert to uppercase * `toLowerCase` (boolean): Convert to lowercase * `allowEmpty` (boolean): has no effect at load time. Empty values skip data type validation entirely, so use [`@required`](/reference/item-decorators/#required)/[`@optional`](/reference/item-decorators/#optional) to control whether an empty value is an error. The [VS Code extension](/env-spec/vs-code-ext/) does still flag an explicit `ITEM=""` as empty unless `allowEmpty=true` is set ```env-spec # @type=string(minLength=5, maxLength=10, toUpperCase=true) MY_STRING=value ``` The default type is string No need to add `@type=string` on everything, as it is the default. ### `number` [Section titled “number”](#number) **Options:** * `min` (number): Minimum allowed value (inclusive) * `max` (number): Maximum allowed value (inclusive) * `coerceToMinMaxRange` (boolean): Coerce value to be within `min`/`max` range * `isDivisibleBy` (number): Value must be divisible by this number * `isInt` (boolean): Value must be an integer (equivalent to `precision=0`) * `precision` (number): Number of decimal places to keep ```env-spec # @type=number(min=0, max=100, precision=1) MY_NUMBER=42.5 ``` ### `boolean` [Section titled “boolean”](#boolean) The following values will be coerced to a boolean and considered valid: * True values: `"t"`, `"true"`, `true`, `"yes"`, `"on"`, `"1"`, `1` * False values: `"f"`, `"false"`, `false`, `"no"`, `"off"`, `"0"`, `0` Anything else will be considered invalid. ```env-spec # @type=boolean MY_BOOL=true ``` ### `url` [Section titled “url”](#url) **Options:** * `prependHttps` (boolean): Automatically prepend “https\://” if no protocol is specified * `allowedProtocols` (string\[]): List of allowed protocols. Protocol names are case-insensitive and can include the trailing colon. If omitted, any valid URL protocol is allowed * `allowedDomains` (string\[]): List of allowed hosts, matched in full and case-insensitively. An entry without a port allows any port, so `[localhost]` accepts `http://localhost:3000`; add a port to pin it (`["localhost:3000"]`). Each entry must be a hostname with an optional port: a scheme, path, or credentials in an entry is an error, as is an empty list. A bare string is treated as a single host; list two or more as an array * `noTrailingSlash` (boolean): Disallow a trailing slash on the URL, so the value is safe to concatenate onto. A root `/` counts, so `https://example.com/` is rejected while `https://example.com` passes * `matches` (string|RegExp): Regular expression pattern the full URL must match. Use `/pattern/flags` syntax or a quoted string pattern (see [regex-like strings](/reference/functions#regex-like-strings)) ```env-spec # @type=url(prependHttps=true) MY_URL=example.com/foobar # @type=url(allowedProtocols=[postgres, postgresql]) DATABASE_URL=postgres://root:password@localhost:5432/local # @type=url(allowedDomains=[example.com, api.example.com]) WEBHOOK_URL=https://api.example.com/hooks # @type=url(noTrailingSlash=true, matches=/^https:\/\/api\./) API_URL=https://api.example.com/v1 ``` ### `domain` [Section titled “domain”](#domain) Checks for a bare domain name (hostname) like `example.com`, with no protocol, port, or path. Useful for cookie domains, CORS config, or building URLs from parts. **Options:** * `allowWildcard` (boolean): Allow a leading wildcard label like `*.example.com` * `allowSingleLabel` (boolean): Allow single-label hostnames like `localhost` or internal service names * `allowIp` (boolean): Also accept an IPv4 address, useful for HOST-style vars that hold a hostname in one environment and an IP in another * `allowIpV6` (boolean): Also accept an IPv6 address, either bracketed (`[::1]`) as it appears in a URL, or bare (`::1`) * `normalize` (boolean): Convert to lowercase * `matches` (string|RegExp): Regular expression pattern the domain must match ```env-spec # @type=domain COOKIE_DOMAIN=app.example.com # @type=domain(allowWildcard=true) CORS_DOMAIN=*.example.com # @type=domain(allowIp=true) DB_HOST=10.0.3.12 ``` ### `enum` [Section titled “enum”](#enum) Checks a value is contained in a list of possible values. It must match one exactly. Members can also be sourced from other items (see [Dynamic type options](#dynamic-type-options)). `process.env` and `overrideValues` are always strings. Numeric and boolean members still match those overrides (`LEVEL=1`, `FLAG=true`) after coercion. **NOTE** - this is the only type that cannot be used without any additional arguments ```env-spec # @type=enum(development, staging, production) ENV=development # @type=enum(1, 2, 3) LEVEL=2 ``` ### `email` [Section titled “email”](#email) **Options:** * `normalize` (boolean): Convert email to lowercase ```env-spec # @type=email(normalize=true) MY_EMAIL=User@Example.com ``` ### `port` [Section titled “port”](#port) Checks for a valid integer port number (0-65535). Coerces to a number. Fractional values like `80.5` are rejected. **Options:** * `min` (number): Minimum port number (default: 0) * `max` (number): Maximum port number (default: 65535) ```env-spec # @type=port(min=1024, max=9999) MY_PORT=3000 ``` ### `ip` [Section titled “ip”](#ip) Checks for a valid [IP address](https://en.wikipedia.org/wiki/IP_address). IPv6 accepts IPv4-mapped addresses such as `::ffff:192.168.1.1`. **Options:** * `version` (`4|6`): IPv4 or IPv6 * `normalize` (boolean): Convert to lowercase ```env-spec # @type=ip(version=4, normalize=true) MY_IP=192.168.1.1 # @type=ip(version=6) MAPPED=::ffff:192.168.1.1 ``` ### `semver` [Section titled “semver”](#semver) Checks for a valid [semantic version](https://semver.org/). ```env-spec # @type=semver MY_VERSION=1.2.3-beta.1 ``` ### `isoDate` [Section titled “isoDate”](#isodate) Checks for valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) date strings with optional time and milliseconds. ```env-spec # @type=isoDate MY_DATE=2024-03-20T15:30:00Z ``` ### `uuid` [Section titled “uuid”](#uuid) Checks for valid [UUID](https://en.wikipedia.org/wiki/UUID) (versions 1-5 per RFC4122, including `NIL`). ```env-spec # @type=uuid MY_UUID=123e4567-e89b-12d3-a456-426614174000 ``` ### `md5` [Section titled “md5”](#md5) Checks for a valid [MD5 hash](https://en.wikipedia.org/wiki/MD5) (32 hex digits). Uppercase hex is accepted and normalized to lowercase. ```env-spec # @type=md5 MY_HASH=d41d8cd98f00b204e9800998ecf8427e ``` ### `simple-object` [Section titled “simple-object”](#simple-object) Validates and coerces JSON strings into objects. Equivalent to a bare [`record`](#record); prefer `record`, which can also validate keys and values. ```env-spec # @type=simple-object MY_OBJECT={"key": "value"} ``` ### `duration` [Section titled “duration”](#duration) Flexible duration type. Accepts human-readable strings (`"1h"`, `"30m"`, `"500ms"`, `"2days"`) or bare numbers (interpreted as milliseconds, plain decimals only, no hex/exponent/`Infinity` notation), and outputs a number in the unit you specify. **Options:** * `output`: output unit: `ms` (default), `seconds`, `minutes`, `hours`, `days`, or `weeks` * `min` / `max`: bounds in any duration format (e.g. `min="1s"`, `max="1d"`) ```env-spec # Default: output is milliseconds # @type=duration REQUEST_TIMEOUT=30s # Output in seconds, typical for HTTP client configs # @type=duration(output="seconds") HTTP_TIMEOUT=1h # With min/max bounds # @type=duration(output="minutes", min="1m", max="1d") POLL_INTERVAL=15m ``` Same parser is used by `cache(..., ttl=...)` and the plugin `cacheTtl` option, so any string that works there also works here. For cache mode behavior and troubleshooting, see the [Caching guide](/guides/caching/). ### `array` [Section titled “array”](#array) A list of values, each coerced and validated with an element type. **Element type** is the first positional argument: a type name (`array(email)`) or a nested type call for types that take their own options or positional args (`array(email(normalize=true))`, `array(enum(dev, staging, prod))`). Omitting it defaults to string elements. **Array options:** * `separator` (string, default `","`): splits plain-string input (e.g. a real environment variable override) and joins the value back into a string for `process.env` * `format` (`separator` | `json`, default `separator`): how the value serializes back into `process.env`. `json` emits a JSON array string. Arrays of objects/arrays always use JSON * `minLength` / `maxLength` / `isLength` (number): element count bounds (or an exact count). `minLength` defaults to 1, so an explicitly-empty `[]` is invalid unless you set `minLength=0` (an explicit `isLength` also overrides the default) * `unique` (boolean): reject duplicate elements Values can be written three ways: ```env-spec # native literal - elements support refs and function calls # @type=array(email) ALLOWED_EMAILS=[admin@example.com, ${SUPPORT_EMAIL}] # separator-joined string - how a real env var override arrives # @type=array(url, separator=";") SERVICE_URLS="https://a.example.com;https://b.example.com" # JSON array string # @type=array(number) SCORES='[10, 20, 30]' ``` Validation errors are reported per element (`[1] Current value is not in list of possible values`). A scalar element that contains the separator fails validation (it could not round-trip through `process.env`); set `format=json` or pick a different separator. In application code, `ENV.ALLOWED_EMAILS` is a real typed array (`string[]`, `number[]`, etc. via [type generation](/guides/code-generation/)). In `process.env` the value is the flat string form (separator-joined, or JSON when `format=json`). An empty or whitespace-only string input resolves to a missing value (`undefined`), never an empty array; the only way to express an empty array is the explicit `[]` literal (sanctioned via `minLength=0`). An untyped item whose value is an array literal (e.g. `ITEM=[a, b]`) is inferred as an array automatically. ### `record` [Section titled “record”](#record) An object (a keyed record) whose values are coerced and validated with a value type, with optional key validation. Named `record` (matching TS `Record` and zod convention) since it types ALL values uniformly rather than specific keys. **Value type** is the first positional argument, same forms as `array`. Bare `@type=record` accepts any object without per-value validation. **Object options:** * `keyType`: a type used to validate every key, e.g. `keyType=enum(us, eu)` or `keyType=string(matches="[a-z]+")` * `entriesMinLength` / `entriesMaxLength` / `entriesIsLength` (number): entry count bounds (or an exact count). `entriesMinLength` defaults to 1, so an explicitly-empty `{}` is invalid unless you set `entriesMinLength=0` ```env-spec # every value must be a valid url # @type=record(url) ENDPOINTS={api=https://api.example.com, docs=https://docs.example.com} # keys restricted to an enum, values validated as numbers # @type=record(number, keyType=enum(us, eu)) REGION_LIMITS={us=100, eu=50} # JSON object strings also work # @type=record(number) LIMITS='{"low": 1, "high": 100}' ``` Validation errors are reported per entry (`"apac" is not a valid key - ...`). In application code, `ENV.ENDPOINTS` is typed as `Record` (enum-constrained keys narrow further). In `process.env` the value is JSON. An untyped item whose value is an object literal (e.g. `ITEM={k=v}`) is inferred as a record automatically. The existing `simple-object` type behaves the same as a bare `record`. # Resolver functions > A reference of all available function resolvers in varlock You may use *resolver functions* instead of static values within both config items and decorator values. Functions can be composed together to create more complex value resolution logic. ```env-spec ITEM=fn(arg1, arg2) COMPOSITION=fn1(fn1Arg1, fn2(fn2Arg1, fn2Arg2)) ``` Note that many built-in utility functions have *expansion* equivalents and often it will be more clear to use them that way. For example: ```env-spec EXPANSION_EQUIVALENT="pre-${OTHER}-post" USING_FN_CALLS=concat("pre-", ref(OTHER), "-post") # mixed example CONFIG=exec(`./scripts/load-config.sh ${APP_ENV}`) ``` There are built-in utility functions, [random value generators](#random-value-generators), [`generateOtp()`](#generateotp) for 2FA codes, a [`cache()`](#cache) function for reusing values according to your global cache mode, encryption functions for device-local secrets, and plugin-provided resolver functions that can fetch data from external providers. See the [Plugins guide](/guides/plugins/) for more information on plugin-provided functions. ## Core [Section titled “Core”](#core) ### `ref()` [Section titled “ref()”](#ref) References another config item (env var) - which is useful when composing multiple functions together. Expansion equivalent: `ref(OTHER_VAR)` === `${OTHER_VAR}` (and also `$OTHER_VAR`) We recommend using the bracketed version within string templates, and the simpler version when referencing an item directly. ```env-spec API_URL=https://api.example.com USERS_API_URL=${API_URL}/users USERS_API_URL2=concat(ref("API_URL"), "/users") # without using expansion ``` ### `concat()` [Section titled “concat()”](#concat) Concatenates multiple values into a single string. Expansion uses `concat()` to combine multiple parts of strings when they include multiple parts. ```env-spec PATH=concat("base/", ref("APP_ENV"), "/config.json") PATH2=`base/${APP_ENV}/config.json` # equivalent using expansion ``` ### `exec()` [Section titled “exec()”](#exec) Executes a CLI command and uses its output as the value. This is particularly useful for integrating with external tools and services. Note Many CLI tools output an additional newline. `exec()` will trim this automatically. Prefer a plugin when one exists If varlock has a [plugin](/plugins/overview/) for your provider (1Password, AWS, Vault, …), use its resolver (e.g. `op()`) instead of shelling out. Plugins handle auth, caching, and validation. Reach for `exec()` only for tools without a plugin, or for custom logic. Expansion equivalent: `exec(command)` === `$(command)` ```env-spec # A custom or internal secrets CLI API_KEY=exec(`my-secrets-cli get api-key`) # Any command whose stdout becomes the value GIT_SHA=exec(`git rev-parse HEAD`) ``` ### `fallback()` [Section titled “fallback()”](#fallback) Returns the first non-empty value in a list of possible values. ```env-spec POSSIBLY_EMPTY= ANOTHER= EXAMPLE=fallback(ref(POSSIBLY_EMPTY), ref(ANOTHER), "default-val") ``` ### `remap()` [Section titled “remap()”](#remap) Maps a value to a new value based on a set of lookup pairs. This is useful for translating one value, often provided by an external platform, into another. * The first argument is the value to remap (often a `ref()` to another variable). * All following arguments are pairs of `(matchValue, resultValue)`. * An optional trailing default value can be added as the last argument (when the total number of remaining args is odd). * Match values can be a string, `undefined`, or a [regex-like string](/reference/functions#regex-like-strings) (`/pattern/`). * If no match is found and there is no default, the original value is returned. ```env-spec # env var that is set by CI/platform CI_BRANCH= # @type=enum(development, preview, production) APP_ENV=remap($CI_BRANCH, "main", production, /.*/, preview, undefined, development) ``` Quoting path-like values An unquoted match value that looks like a valid regex (i.e., `/something/` with optional flags) will be treated as a regex pattern. If you need to match a literal string containing slashes (like a file path), wrap it in quotes. ```env-spec # /usr/local/ looks like a valid regex, so use quotes to match it literally ITEM=remap($PATH_VAR, "/usr/local/", found, default) ``` Deprecated syntax The old key=value syntax (`result=match`) is still supported but deprecated. Use positional pairs instead. ```env-spec # deprecated - key is the result, value is what to match (backwards and limited by key naming) APP_ENV=remap($CI_BRANCH, production="main", preview=/.*/, development=undefined) ``` ## Utilities [Section titled “Utilities”](#utilities) ### `ifs()` [Section titled “ifs()”](#ifs) Evaluates a series of condition/value pairs, returning the value for the first truthy condition. Similar to Excel’s `IFS` function. * Arguments are pairs of `(condition, value)`. * An optional trailing default value can be added as the last argument (when the total number of args is odd). * If no condition is truthy and there is no default, returns `undefined`. ```env-spec ENV=staging # returns the value matching the first truthy condition API_URL=ifs( eq($ENV, production), https://api.example.com, eq($ENV, staging), https://staging-api.example.com, http://localhost:3000 ) ``` ### Regex-like strings [Section titled “Regex-like strings”](#regex-like-strings) Certain functions like `remap()` and type options like `matches` support regex pattern matching. You can use JavaScript-style regex syntax (`/pattern/flags`) as an unquoted value. These will be automatically detected and treated as regular expressions. A string is treated as a regex when it: * Starts and ends with `/` (with optional flags like `i`, `g`, `m`, `s`, `u`, `y` after the closing `/`) * Is **not** wrapped in quotes ```env-spec # regex pattern in remap, matches case-insensitively ENV_TYPE=remap($APP_ENV, /^dev.*/i, dev, "production", prod) # regex pattern in type options # @type=string(matches=/^sk-[a-zA-Z0-9]+$/) API_KEY= # @type=url(matches=/^https:\/\/api\./) API_URL= ``` Paths vs regex ambiguity Since `/something/` looks like a valid regex, values like `/usr/lib/` will be treated as regex patterns in contexts that support them (like `remap()` match values). To use a literal string containing slashes, wrap it in quotes: ```env-spec # unquoted, treated as regex pattern matching "usr/lib" ITEM=remap($VAR, /usr/lib/, matched) # quoted, treated as the literal string "/usr/lib/" ITEM=remap($VAR, "/usr/lib/", matched) ``` Note that as a top-level config value (e.g., `MY_PATH=/usr/local/bin`), slash-containing strings are always treated as plain strings. `regex()` function The older `regex("pattern")` function wrapper is still supported but the `/pattern/` literal syntax is preferred, since it’s more concise and supports flags. ```env-spec # older style - still works ENV_TYPE=remap($APP_ENV, regex("^dev.*"), dev, "production", prod) ``` ### `forEnv()` [Section titled “forEnv()”](#forenv) Resolves to a boolean, if the current [environment](/reference/root-decorators/#currentenv) matches any in the list passed in as args. **Requirements:** * Requires an [`@currentEnv`](/reference/root-decorators/#currentenv) to be set in your `.env.schema` file * Takes one or more environment names as arguments ```env-spec # @currentEnv=$APP_ENV @defaultRequired=false # @disable=forEnv(test) # entire file will be disabled if env is test # --- APP_ENV=staging # Required only in development # @required=forEnv(development) DEV_API_KEY= # Required in staging and production # @required=forEnv(staging, production) PROD_API_KEY= ``` ### `eq()` [Section titled “eq()”](#eq) Checks if 2 values are equal and resolves to a boolean. ```env-spec IS_STAGING_DEPLOYMENT=eq($GIT_BRANCH, "staging") ``` ### `if()` [Section titled “if()”](#if) Checks a boolean to return a true/false option ```env-spec API_URL=if(eq($GIT_BRANCH, "main"), api.example.com, staging-api.example.com) ``` ### `not()` [Section titled “not()”](#not) Negates a value and returns a boolean. Falsy values are - `false`, `""`, `0`, `undefined`, and will be negated to `true`. Otherwise will return `false`. ```env-spec # Negate the result of another function SHOULD_DISABLE_FEATURE=not(forEnv(production)) ``` ### `isEmpty()` [Section titled “isEmpty()”](#isempty) Returns `true` if the value is `undefined` or an empty string, `false` otherwise. ```env-spec # Check if a value is empty HAS_API_KEY=not(isEmpty($API_KEY)) # Use with conditional logic API_URL=if(isEmpty($CUSTOM_API_URL), "https://api.default.com", $CUSTOM_API_URL) ``` ### `domainFromUrl()` [Section titled “domainFromUrl()”](#domainfromurl) Extracts the domain (host) from a URL, dropping the protocol, credentials, port, path, query, and fragment. Useful for deriving things like a cookie domain or an allowed origin from a URL you already have. The value is parsed as a URL, so the result is the host a request would actually be sent to. A value with no scheme (`example.com`, `example.com:8080`) is treated as a bare host. The result is lowercased, and internationalized domains are converted to punycode. This returns the full host, not the registrable domain: `domainFromUrl("https://api.example.co.uk")` is `api.example.co.uk`, not `example.co.uk`. To rewrite specific hosts, wrap it in [`remap()`](#remap). If you need a parent domain that covers every subdomain, such as a cookie or email domain, set the root domain as its own item and build the URLs from it with [`concat()`](#concat) instead of taking them apart. The item’s type is inferred as `domain` unless you set one explicitly. An empty or undefined input resolves to undefined. Anything else that cannot be parsed as a URL, or a URL with no host at all (like `mailto:someone@example.com`), is a resolution error. api.example.com ```env-spec API_URL=https://api.example.com/v1 API_DOMAIN=domainFromUrl($API_URL) # use it to build another value ALLOWED_ORIGIN=concat("https://", domainFromUrl($API_URL)) ``` ## Random value generators [Section titled “Random value generators”](#random-value-generators) These functions generate random values using cryptographically secure randomness (`node:crypto`). In ephemeral environments, it can be helpful to generate values that are unique per run/deployment. For local dev, the [`cache()`](#cache) function can be used to keep a value stable for a period of time. ### `randomNum()` [Section titled “randomNum()”](#randomnum) Generates a random number. **Integer by default**; if you pass `precision=N`, returns a float with `N` decimal places. * With 1 arg: generates between `0` and `max` (inclusive) * With 2 args: generates between `min` and `max` (inclusive) * `precision=N` option switches to float mode (decimal places, 0-20) ```env-spec # Cached random port between 3000 and 4000 (integer) DEV_PORT=cache(randomNum(3000, 4000)) # One-off random integer up to 1000 SEED=randomNum(1000) # Random float between 0 and 1 with 4 decimal places RATE=randomNum(0, 1, precision=4) # Cached random float between 10 and 20 with 4 decimal places THRESHOLD=cache(randomNum(10, 20, precision=4)) ``` ### `randomUuid()` [Section titled “randomUuid()”](#randomuuid) Generates a random UUID v4. ```env-spec # Unique identifier for this environment (stable across runs) INSTANCE_ID=cache(randomUuid()) # Per-run / per-evaluation ID REQUEST_ID=randomUuid() ``` ### `randomHex()` [Section titled “randomHex()”](#randomhex) Generates a random hexadecimal string. By default the argument is the **character length** of the output. Pass `bytes=true` to interpret the argument as a byte count instead (where each byte = 2 hex characters). Default is `32` characters. ```env-spec # 32-character hex string (default) # @sensitive SESSION_SECRET=cache(randomHex()) # 64-character hex string # @sensitive ENCRYPTION_KEY=cache(randomHex(64)) # 32 bytes (= 64 hex chars), byte-length mode # @sensitive HMAC_KEY=cache(randomHex(32, bytes=true)) # Non-sensitive one-off value NONCE=randomHex(16) ``` ### `randomString()` [Section titled “randomString()”](#randomstring) Generates a random alphanumeric string. Default length is `16` characters using `A-Za-z0-9`. * First arg: character length (default: 16) * `charset=S` option: custom character set to draw from ```env-spec # 32-character alphanumeric string # @sensitive API_SECRET=cache(randomString(32)) # 8-character string from custom charset PIN_CODE=cache(randomString(8, charset="0123456789")) # One-off random string TEMP_LABEL=randomString(10) ``` ## One-time passwords [Section titled “One-time passwords”](#one-time-passwords) ### `generateOtp()` [Section titled “generateOtp()”](#generateotp) Generates a time-based one-time password (TOTP) code from a shared secret, the same codes an authenticator app shows. Useful for CLIs that require a 2FA code on every invocation, e.g. `aws sts get-session-token --token-code 123456`. * First arg: the shared secret. Either the base32 seed from your 2FA setup, or a full `otpauth://totp/...` URI. Spaces, hyphens, lowercase, and padding in a base32 seed are all fine. * `digits=N` option: code length, 6 to 10 (default: 6) * `period=N` option: how long each code is valid, **in seconds** (default: 30). A duration string like `"60s"` also works. * `algorithm=S` option: `SHA1` (default), `SHA256`, or `SHA512` * `encoding=S` option: how the secret itself is encoded, `base32` (default), `hex`, or `ascii` When the secret is an `otpauth://` URI, any `digits`, `period`, and `algorithm` params in the URI are used, and explicitly passed options override them. The seed is a long-lived credential, so keep it [`@internal`](/reference/item-decorators/#internal) (resolved by varlock, never injected into your app or child processes) and [`@sensitive`](/reference/item-decorators/#sensitive). Only the generated code gets injected. ```env-spec # TOTP seed from your provider's 2FA setup, encrypted at rest # @internal @sensitive MFA_SECRET=varlock(local:abc123...) # @sensitive MFA_CODE=generateOtp($MFA_SECRET) # non-default params # @sensitive LEGACY_CODE=generateOtp($LEGACY_SEED, digits=8, period=60, algorithm=SHA256) ``` Then hand the code to whatever needs it. Tools that take a flag need the expansion to happen in the child shell, so use single quotes: ```bash varlock run -- sh -c 'aws sts get-session-token --serial-number $AWS_MFA_ARN --token-code $MFA_CODE' ``` Tools that read a code from the environment need no extra wrapping, since varlock injects it directly. To get the seed in the first place, you generally have to enroll (or re-enroll) the second factor: on the “scan this QR code” screen, take the manual-entry / setup-key option and save that string. Most authenticator apps will not show you a seed after the fact. One code per command A code covers one authenticated call, and providers generally reject a reused one. If a command makes several authenticated calls in a run, prefer handing it the seed and letting it mint a code per call. [fledgling](https://github.com/dmno-dev/fledgling), for example, takes `FLEDGLING_OTP_SECRET`. Codes cannot be cached Wrapping this in [`cache()`](#cache) is an error, since a cached code is expired by definition. Cache the *secret* instead: `generateOtp(cache(op("op://vault/aws/mfa seed")))`. A few other things to know: * **Codes expire.** They rotate every `period` seconds, so generate at the moment of use. A long command that only needs the code at the very end can outlive it. * **Your clock has to be right.** Codes are derived from the current time. Check clock sync before assuming the seed is wrong. * **This weakens your second factor.** A seed sitting next to the token it protects, on the same machine, is no longer an independent factor. That can be a reasonable trade for a local workflow where the seed is encrypted at rest and never injected into your app. It is a bad trade in CI, where [OIDC workload identity](/guides/oidc/) or credentials that do not require 2FA are the better answer. ## Caching [Section titled “Caching”](#caching) ### `cache()` [Section titled “cache()”](#cache) Wraps any resolver to cache its result. See [caching guide](/guides/caching/) for more details. * First arg: the resolver to cache * `ttl=D` option: how long to cache (default: `forever`). Accepts duration strings with `ms`, `s`, `m`, `h`, `d`, `w` suffixes (long forms and plurals also work), bare numbers as milliseconds, or the keyword `forever` to cache until manually cleared. * `key=S` option: use an explicit cache key instead of the auto-generated one. Useful when the same cached value should be shared across files or when you want a stable key that doesn’t change with resolver edits. * Keys must be non-empty printable text (no control characters) and at most 2048 characters. The cache automatically invalidates when you change the wrapped resolver expression (unless using a custom `key`). ```env-spec # Cache a random UUID forever (until manually cleared) INSTANCE_ID=cache(randomUuid()) # Cache an API token for 1 hour AUTH_TOKEN=cache(exec(`get-token.sh`), ttl="1h") # Cache for 30 minutes TEMP_KEY=cache(randomHex(32), ttl="30m") # Use an explicit cache key (shared across files/projects) SHARED_TOKEN=cache(exec(`fetch-org-token.sh`), ttl="1d", key="org-auth-token") ``` Use the [`varlock cache`](/reference/cli/cache-and-codegen/#cache) CLI command to view or clear the **disk cache**. Use `--clear-cache` or `--skip-cache` flags on `varlock load` / `varlock run` / `varlock printenv` to control caching behavior for a single invocation. Global cache behavior is configured with the [`@cache` root decorator](/reference/root-decorators/#cache). In `memory` mode, `varlock cache` will not show in-memory entries from a running process. For strategy recommendations and troubleshooting, see the [Caching guide](/guides/caching/). Tip Plugin authors can also use the cache API via `plugin.cache.getOrSet()` (or `get()` / `set()`) to cache expensive API calls. See the [Plugins guide](/guides/plugins/#plugin-caching) for more information. ## Encryption [Section titled “Encryption”](#encryption) ### `varlock()` [Section titled “varlock()”](#varlock) Decrypts a locally encrypted value, or prompts for a new secret to encrypt. This is the built-in resolver for varlock’s [device-local encryption](/guides/local-encryption/) feature. **Decrypt mode**: pass an encrypted payload to decrypt at load time: ```env-spec # @sensitive API_KEY=varlock("local:") ``` **Prompt mode**: prompts the user to enter a secret, encrypts it, and writes the encrypted value back to the source file: ```env-spec # @sensitive API_KEY=varlock(prompt) # also valid as a key=value param: API_KEY=varlock(prompt=1) ``` On first run with `prompt` mode, you’ll be asked to enter the secret value. Once entered, the file is automatically updated with the encrypted payload. On macOS with Secure Enclave, a native dialog with biometric authentication is used. Values are encrypted using the best available backend on your platform. See the [Local encryption guide](/guides/local-encryption/) for details. Encrypted payload lifecycle: * Store encrypted payloads (`varlock("local:...")`) in local override files (typically `.env.local`) * Decryption happens at runtime during `varlock load` / `varlock run` * Use [`varlock reveal`](/reference/cli/encryption/#reveal) when you need to inspect a decrypted value interactively ### `keychain()` [Section titled “keychain()”](#keychain) Reads a secret from the **macOS Keychain**. This built-in resolver communicates through Varlock’s native Swift daemon, enforcing biometric (Touch ID) authentication and per-session access control. See the [macOS Keychain page](/plugins/macos-keychain/) for full documentation. **Array args:** * `service` (optional): Service name of the keychain item (positional shorthand) * `prompt` (optional): Enter interactive picker mode **Key/value args:** * `service` (optional): Service name of the keychain item * `account` (optional): Account identifier for the keychain item * `keychain` (optional): Name of a specific keychain to search (e.g., `"System"`) * `field` (optional): Specific field to extract from the keychain item * `prompt` (optional): If set, opens a native picker dialog for interactive selection ```env-spec # Positional shorthand DATABASE_PASSWORD=keychain("com.company.database") # Named service with account ADMIN_PW=keychain("com.company.db", account="admin") # Interactive picker mode, writes back resolved reference NEW_SECRET=keychain(prompt) ``` Caution `keychain()` is only available on macOS. For cross-platform local encryption, use [`varlock()`](#varlock) instead. # Config Item @decorators > A reference page of available env-spec decorators for items Decorators in a comment block *directly* preceding a config item will be attached to that item. Multiple decorators can be specified on the same line. A comment block is broken by either an empty line or a divider (`# ---` is optional, but often used for clarity). ```env-spec # @required @sensitive @type=string(startsWith=sk-) # @docs(https://docs.servicex.com/api-keys) SERVICE_X_API_KEY= ``` More details of the minutiae of decorator handling can be found in the [@env-spec reference](/env-spec/reference/#comments-and-decorators). ## Built-in item decorators [Section titled “Built-in item decorators”](#built-in-item-decorators) These are the item decorators built into Varlock. [Plugins](/guides/plugins/) can extend the DSL with additional decorators. ### `@required` [Section titled “@required”](#required) **Value type:** `boolean` Sets whether an item is *required* - meaning validation will fail if the value resolves to `undefined` or an empty string. Default behavior for all items within the same file can be toggled using the [`@defaultRequired` root decorator](/reference/root-decorators/#defaultrequired). 💡 Use the [`forEnv()` function](/reference/functions/#forenv) to set required based on the current environment. ```env-spec # @defaultRequired=false # --- # @required # same as @required=true REQUIRED_ITEM= # @required=forEnv(prod) REQUIRED_FOR_PROD_ITEM= # @required=eq($OTHER, foo) REQUIRED_IF_OTHER_IS_FOO= ``` ### `@optional` [Section titled “@optional”](#optional) **Value type:** `boolean` Opposite of [`@required`](#required). Equivalent to writing `@required=false`. ```env-spec # @defaultRequired=true # --- # @optional OPTIONAL_ITEM= ``` ### `@sensitive` [Section titled “@sensitive”](#sensitive) **Value type:** `boolean` Sets whether the item should be considered *sensitive* - meaning it cannot be exposed to the public. The value will be always be redacted in CLI output, and client integrations can take further action to prevent leaks. Default behavior for all items can be set using the [`@defaultSensitive` root decorator](/reference/root-decorators/#defaultsensitive) Sensitivity may also be implied by the item’s [data type](/reference/data-types). For example, plugin “secret-zero” credential types (like a [1Password `opServiceAccountToken`](/plugins/1password/)) are sensitive by default, so you don’t need to add `@sensitive` explicitly (use `@public` or `@sensitive=false` to override). An explicit `@sensitive` on the item always wins. Redaction replaces a sensitive value wherever it appears, and it can’t tell a leaked secret from ordinary text that happens to match. Varlock therefore checks that a value marked sensitive can actually be protected that way, and reports the ones that can’t be: very short values, booleans and numbers, the [`@currentEnv`](/reference/root-decorators/#currentenv) item, and secrets embedded in a non-sensitive value. See [values varlock can’t protect](/guides/secrets/#values-varlock-cant-protect) for what each case means and how to resolve it. 📖 See the [secrets management guide](/guides/secrets/) for best practices on handling sensitive values, and how to use plugins to fetch them from secret management platforms. ```env-spec # @sensitive SERVICE_X_PRIVATE_KEY= # @sensitive=false SERVICE_X_CLIENT_ID= ``` **Per-item options:** pass an object value to tune protection for a single sensitive item: * `enabled` (`boolean`, default `true`): whether the item is sensitive. Lets you keep options while toggling sensitivity, including dynamically via a function (e.g. `enabled=forEnv(production)`). `@sensitive={enabled=false}` is equivalent to `@sensitive=false`. * `preventLeaks` (`boolean`, default `true`): when `false`, this item’s value is excluded from [leak detection](/reference/root-decorators/#preventleaks), so it will *not* trigger an error if it appears in a response. Useful when an endpoint legitimately returns a secret to another system. The value is still redacted in logs. * `allowShortValue` (`boolean`, default `false`): when `true`, silences the [short-value warning](/guides/secrets/#values-varlock-cant-protect) for secrets that are legitimately short and can’t be lengthened (a one-time code, a PIN). Redaction is unchanged; this only records that the collision risk was read and accepted. It does not apply to values under 3 characters: those are an error when you wrote `@sensitive` yourself and a warning when `@defaultSensitive` swept them in, and the option suppresses neither. ```env-spec # @sensitive={preventLeaks=false} TOKEN_FORWARDED_TO_PARTNER= # sensitive only in production, and allowed to leave the system # @sensitive={enabled=forEnv(production), preventLeaks=false} PARTNER_TOKEN= ``` Use `preventLeaks=false` sparingly Leak detection is a safety net that stops a secret from accidentally being sent in a response. Setting `preventLeaks=false` removes that net for this item, so only use it when a secret is *deliberately* meant to leave the system (e.g. an endpoint forwarding a token to another service), never just to silence a leak-detection error. Prefer disabling it for a single item like this over turning off [`@preventLeaks`](/reference/root-decorators/#preventleaks) globally. ### `@public` [Section titled “@public”](#public) **Value type:** `boolean` Opposite of [`@sensitive`](#sensitive). Equivalent to writing `@sensitive=false`. ```env-spec # @defaultSensitive=true # --- # @public PUBLIC_API_URL=https://api.example.com ``` ### `@internal` [Section titled “@internal”](#internal) **Value type:** `boolean` Marks an item as used **only internally by varlock**, not by your application. Internal items are still resolved and can be referenced by other items (e.g. via [`ref()`](/reference/functions/#ref) or string expansion), but they are **excluded** from everything that reaches your app: the injected env vars, the [serialized graph](/reference/reserved-variables/#__varlock_env) blob, and generated types. Framework integrations (Vite, Next.js, Astro, Cloudflare) inherit this exclusion too, since they all source their config from the same CLI output. The most common use is a “secret zero”: a credential used only to fetch *other* secrets (for example a vault/secrets-manager token). Your application never needs it, and keeping it out of the process environment reduces your attack surface. ```env-spec # the token below is only used to resolve the secrets that follow it # @internal @sensitive OP_TOKEN= # resolved using OP_TOKEN, but only DATABASE_URL is injected into the app # @sensitive DATABASE_URL=exec(`op read "op://app/db/url"`) ``` Some plugin data types are **internal by default**. For example, a [1Password `opServiceAccountToken`](/plugins/1password/) or other secrets-manager credential authenticates varlock but is rarely needed in app code. You can opt back in with `@internal=false`: ```env-spec # @type=opServiceAccountToken @internal=false OP_TOKEN= ``` Marking something internal stops injecting it Because internal items still resolve and validate, marking a variable internal does **not** produce an error if your application actually reads it. The value simply won’t be in the environment at runtime. If your app uses one of these credentials directly (e.g. to write secrets back, renew Vault leases, or fetch more secrets at runtime), set `@internal=false` so it keeps being injected. Note `@internal` controls what varlock *injects*, not what it shows. Internal items still appear (redacted) in inspection output (the `varlock load` summary and `varlock load --agent`), so they can be set and debugged. `varlock run` strips internal items from the child process environment **even if they were set in the ambient environment** (e.g. `OP_TOKEN=xxx varlock run ...`), so a secret-zero token never leaks into your app. If a *nested* `varlock run` needs the internal value for its own resolution, pass [`--include-internal`](/reference/cli/load-and-run/#run) to keep it in the child env. `varlock load --format json-full` excludes internal items by default too, since that format is commonly consumed programmatically (framework integrations shell out to this exact command to get their injected config) - pass [`--include-internal`](/reference/cli/load-and-run/#load) there for local debugging. ### `@dynamic` [Section titled “@dynamic”](#dynamic) **Value type:** `boolean` Sets whether the item is *dynamic* - meaning integrations should avoid replacing it at build time and instead resolve it at runtime. By default, dynamic behavior follows sensitivity (sensitive items are dynamic, non-sensitive items are static), but this can be overridden globally with [`@defaultDynamic`](/reference/root-decorators/#defaultdynamic). Items can also be selected by this state via the `@dynamic` selector (or `!@dynamic` for static items) in the [`--filter` language](/reference/cli-commands/#filtering-items). See the [Static vs Dynamic Config guide](/guides/dynamic-config/) for the full model. ```env-spec # @dynamic PUBLIC_RUNTIME_FLAG= ``` ### `@static` [Section titled “@static”](#static) **Value type:** `boolean` Opposite of [`@dynamic`](#dynamic). Equivalent to writing `@dynamic=false`. ```env-spec # @static SENSITIVE_BUILD_TIME_TOKEN= ``` ### `@type` [Section titled “@type”](#type) **Value type:** [`data type`](/reference/data-types) (name only or function call) Sets the data type of the item - which affects validation, coercion, and generated types. Note that some data types take additional arguments. See [data types reference](/reference/data-types) for more details. If not specified, a data type will be inferred when possible, or default to `string` otherwise. ```env-spec # @type=url # name only SOME_URL= # @type=string(startsWith=abc) # function call with options EXAMPLE_WITH_TYPE_OPTIONS= INFER_NUMBER=123 # data type of `number` will be inferred from the value ``` ### `@example` [Section titled “@example”](#example) **Value type:** `string` Provides an example value for the item. This lets you avoid setting placeholder values that are not meant to be used. ```env-spec # @example="sk-abc123" SECRET_KEY= ``` ### `@docs()` [Section titled “@docs()”](#docs) **Arg types:** `[ url: string ] | [ description: string, url: string ]` URL of documentation related to the item. Will be included in [generated types](/reference/root-decorators/#code-generation). *Can be called multiple times.* ```env-spec # @docs(https://xyz.com/docs/api-keys) # @docs("Authentication guide", https://xyz.com/docs/auth-guide) XYZ_API_KEY= ``` ![example of docs() in generated types](/_astro/multiple-docs-intellisense.DsdGRzO3.png)*example of `docs()` info in generated types / IntelliSense* ### `@tag()` [Section titled “@tag()”](#tag) **Arg types:** `[ tag: string, ... ]` Attaches one or more tags to the item. Tags don’t affect resolution or validation; they’re used to select items with the [`--filter` CLI flag](/reference/cli-commands/#filtering-items) on `varlock load`/`varlock run`, and with the [`filter=` arg](/reference/root-decorators/#code-generation) on `@generate*` code-generation decorators (e.g. `--filter="#billing"`, `@generateTsTypes(filter=#billing)`). *Can be called multiple times; tags from every call accumulate (duplicates collapse).* Tag names must start with a letter or number, followed by letters, numbers, `_`, or `-`. This keeps every tag selectable with a `#tagname` filter, which reserves characters like `,`, `!`, and `*`. An invalid tag name is a schema error. ```env-spec # @tag(billing) # @tag(prod, critical) STRIPE_SECRET_KEY= ``` ```bash varlock load --filter="#billing" ``` ### `@docsUrl` (deprecated) [Section titled “@docsUrl (deprecated)”](#docsurl) **Value type:** `string` URL of documentation related to the item. Use [`@docs()`](#docs) instead, which supports multiple docs entries with optional descriptions. `@docsUrl=https://example.com` -> `@docs(https://example.com)` ### `@icon` [Section titled “@icon”](#icon) **Value type:** `string` Attaches an icon identifier to the item, using iconify ids `collection-name:icon-name`. This icon will be included in autogenerated types. See to browse available icons. Useful for generated docs and UI surfaces that show schema metadata. ```env-spec # @icon=mdi:key SERVICE_X_API_KEY= ``` ### `@deprecated` [Section titled “@deprecated”](#deprecated) **Value type:** `boolean | string` Marks an item as deprecated. Does not affect resolution or validation: the item still resolves and stays valid. Use this when a variable should keep working during a migration, but callers should move off it. * bare `@deprecated` or `@deprecated=true`: marks the item deprecated (no message) * `@deprecated="message"`: marks it deprecated with a message (for example, what to use instead) * `@deprecated=false`: explicitly not deprecated Effects: * Pretty CLI output (e.g. `varlock load`): the key is struck through and a deprecated badge is shown * [Generated types](/reference/root-decorators/#code-generation): TypeScript gets a `@deprecated` JSDoc tag (with the message when set); other language emitters include a `Deprecated:` doc line ```env-spec # @deprecated # same as @deprecated=true LEGACY_API_URL= # @deprecated="Use NEW_API_URL instead" OLD_API_URL= # @deprecated=false STILL_SUPPORTED= ``` ### `@auditIgnore` [Section titled “@auditIgnore”](#auditignore) **Value type:** `boolean` Suppresses “unused in schema” warnings from [`varlock audit`](/reference/cli/encryption/#audit) for this item. Useful for items that are only consumed by external tools and won’t appear in your application code. ```env-spec # @auditIgnore # used by CI tooling, not referenced in code CI_DEPLOY_TOKEN= ``` ### `@placeholder` [Section titled “@placeholder”](#placeholder) **Value type:** `string` Sets an explicit placeholder string for the item, used in place of the real value when the item is routed through the [credential proxy](/guides/proxy/). A placeholder is what an untrusted child process (e.g. an AI agent) sees instead of the secret. Without an explicit `@placeholder`, varlock derives one from the item’s [`@type`](#type) format, falling back to a generic value. Set this when a generic placeholder would fail a client’s key-format validation (e.g. an `sk-…` prefix check). ```env-spec # @proxy(domain="api.openai.com") # @placeholder=sk-proj-000000000000000000000000 OPENAI_API_KEY=yourPreferredPlugin() ``` ### `@proxy` [Section titled “@proxy”](#proxy) **Value type:** function `@proxy(domain=..., ...)` *or* value `@proxy=passthrough|omit` Routes an item’s secret through the [credential proxy](/guides/proxy/) so an untrusted child process only ever sees a placeholder, while the real value is injected into matching outbound requests at the network boundary. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive). **Function form** `@proxy(domain=..., [path], [method], [block], [approval], [keys], [substituteIn], [rules], [transform])`: | Option | Meaning | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `domain` | **(required)** Host to match: a single host or an array (`[a.com, b.com]`); supports globs (`*.example.com`). | | `path` | Restrict to matching URL paths (glob), e.g. `"/v1/**"`. | | `method` | Restrict to one or more HTTP methods, e.g. `[GET, POST]`. | | `block` | `block=true` denies matching requests outright. | | `approval` | `approval=true` holds matching requests for an interactive yes/no in the `proxy start` terminal before they proceed. A self-contained one-shot `proxy run` has no terminal to prompt in and denies them. | | `keys` | Array of additional item names to inject for this rule, e.g. `keys=[OTHER_KEY]`. | | `substituteIn` | Where the placeholder may be swapped for the real value: `header` (default), `header:`, `path`, `query`, `query:`, or `body:`, e.g. `[header, "body:client_secret"]`. Body always requires a path (`body:*` allows anywhere, for bodies that can’t be parsed into one). Each target is worth one substitution per request. A placeholder outside every target is skipped: forwarded unsubstituted and audited. A repeat at one target, or an occurrence off the named path/param inside a targeted body or query, blocks the request. See [Substitution surface](/guides/proxy/rules/#substitution-surface). | | `rules` | Array of policy refinements sharing this rule’s `domain`, e.g. `rules=[{path="/v1/**", block=true}]`. Each entry may set `path`/`method`/`block`/`approval`/`substituteIn` (not `domain`/`keys`) and injects nothing on its own. See the [Grouping rules guide](/guides/proxy/rules/#grouping-rules-for-one-domain). | | `transform` | Request transform config, e.g. `transform={scheme="hmac-sha256", stringToSign="{timestamp}{method}{path}{body}", signatureHeader="X-Signature"}`. The proxy computes the credential for the final outbound request rather than substituting a placeholder. Built-in schemes: `hmac-sha256`, `hmac-sha512`, and `http-basic` (which composes the `Authorization: Basic` header the proxy sends, since base64 hides a placeholder from substitution); plugins add more, e.g. `aws-sigv4`. Credential items are always named with `$ITEM` references (`secretKey=$PARTNER_SECRET`, or `username`/`password` for `http-basic`), never inline values; on an attached rule the decorated item supplies the credential by default. See [Request transforms](/guides/proxy/rules/#request-transforms) for per-scheme options. | The same decorator in the **header** creates a *detached* policy rule (no injection unless it lists `keys`). **Value form** `@proxy=passthrough` injects the real value into the child (escape hatch); `@proxy=omit` explicitly withholds it. The value and function forms are mutually exclusive on one item. ```env-spec # @proxy(domain="api.stripe.com", path="/v1/**") STRIPE_SECRET_KEY=yourPreferredPlugin() # @sensitive # @proxy=passthrough LEGACY_TOKEN=yourPreferredPlugin() ``` See the [credential proxy guide](/guides/proxy/) for the full workflow. # Reserved variables > varlock's reserved _VARLOCK_* and __VARLOCK_* environment variables: what they do and how the reserved namespace behaves varlock reserves a couple of environment variable namespaces for its own use. Knowing them helps you avoid surprises and understand how varlock passes state between processes. | Prefix | Example | Who sets it | | --------------------------------- | ------------------ | -------------------------------------------------------------------------- | | `VARLOCK_*` (no underscore) | `VARLOCK_ENV` | varlock (computed); see [Builtin variables](/reference/builtin-variables/) | | `_VARLOCK_*` (single underscore) | `_VARLOCK_ENV_KEY` | **you**, to configure varlock’s behavior | | `__VARLOCK_*` (double underscore) | `__VARLOCK_ENV` | varlock internals, injected automatically; never set these yourself | The `_VARLOCK_` prefix is reserved Config items whose key starts with `_VARLOCK_` are reserved for varlock. They are **excluded** from the injected env blob, generated types, and override provenance, even if you define one in your `.env.schema`. varlock emits a warning if you do, since such an item won’t behave like a normal config value. Don’t name your own variables with this prefix. ## Configuration variables (`_VARLOCK_*`) [Section titled “Configuration variables (\_VARLOCK\_\*)”](#configuration-variables-_varlock_) Set these in your shell, CI, or deploy environment to configure varlock’s behavior. ### `_VARLOCK_ENV_KEY` [Section titled “\_VARLOCK\_ENV\_KEY”](#_varlock_env_key) Encryption key used to decrypt the injected env blob at runtime. Typically set in deploy environments. See [Encrypted deployments](/guides/encrypted-deployments/). ### `_VARLOCK_CACHE_KEY` [Section titled “\_VARLOCK\_CACHE\_KEY”](#_varlock_cache_key) Encryption key for the on-disk resolved-value cache. When set (e.g. as a CI secret), it enables the disk cache in environments without OS keychain access. See [Caching](/guides/caching/). ### `_VARLOCK_REDACT_STDOUT` [Section titled “\_VARLOCK\_REDACT\_STDOUT”](#_varlock_redact_stdout) Overrides [`varlock run`](/reference/cli/load-and-run/#run) output redaction: * `true` / `1`: force redaction on (only applies to piped/redirected output; errors if attached to an interactive terminal) * `false` / `0`: disable redaction entirely The `--redact-stdout` / `--no-redact-stdout` flags take precedence over this env var. Useful when you can’t easily change the command being run, for example in a wrapper script or CI config. ### `_VARLOCK_FILTER` [Section titled “\_VARLOCK\_FILTER”](#_varlock_filter) Fallback for the [`--filter`](/reference/cli-commands/#filtering-items) flag on [`varlock load`](/reference/cli/load-and-run/#load) / [`varlock run`](/reference/cli/load-and-run/#run). An explicit `--filter` flag takes precedence over this env var. Useful when you can’t easily pass a CLI flag, for example a wrapper script, CI config, or a build-time integration that invokes varlock’s loading APIs directly. ```bash _VARLOCK_FILTER="#billing" varlock load --format json ``` ### `_VARLOCK_DYNAMIC_BUILD_ACCESS_MODE` [Section titled “\_VARLOCK\_DYNAMIC\_BUILD\_ACCESS\_MODE”](#_varlock_dynamic_build_access_mode) Set to `warn` to downgrade the build/prerender-time guard on [public+dynamic](/guides/dynamic-config/) config access from an error to a one-time warning per key. Useful while migrating an existing app that still reads dynamic public values in prerendered pages. ### `_VARLOCK_THROW_ON_LOAD_ERROR` [Section titled “\_VARLOCK\_THROW\_ON\_LOAD\_ERROR”](#_varlock_throw_on_load_error) When set (`1` / `true`), [`varlock/auto-load`](/integrations/javascript/#reporting-load-failures) throws the error on a load failure instead of exiting, so an already-initialized error tracker (e.g. Sentry) can capture it via its `uncaughtException` handler. Setting a `globalThis._varlockOnLoadError` hook enables the same throw behavior. See [Reporting load failures](/integrations/javascript/#reporting-load-failures). ### `_VARLOCK_USE_INJECTED_ENV` [Section titled “\_VARLOCK\_USE\_INJECTED\_ENV”](#_varlock_use_injected_env) Controls whether [`varlock/auto-load`](/integrations/javascript/#reusing-an-injected-env-blob) and [`varlock run`](/reference/cli/load-and-run/#run) reuse an already-injected [`__VARLOCK_ENV`](#__varlock_env) blob (e.g. from a parent `varlock run`) instead of re-resolving: * unset (default): reuse the blob only when it was resolved in the same directory the consumer would resolve in, and nothing relevant has changed since * `1` / `true`: always trust the blob, skipping the directory check. Use this to hand a blob into an environment with no `.env` files (e.g. a sandbox): auto-load hydrates a Node app from it, and `varlock run` injects its values into any child command. Missing or unusable blobs are hard errors. * `0` / `false`: always re-resolve Values are matched case-insensitively; any other value is ignored (same as unset). `varlock run` flags that change what a resolution produces (`--path`, `--filter`, `--clear-cache`, `--skip-cache`, `--include-internal`) disable reuse, and are rejected when combined with `1`. See [Reusing an injected env blob](/integrations/javascript/#reusing-an-injected-env-blob). ## Internal variables (`__VARLOCK_*`) [Section titled “Internal variables (\_\_VARLOCK\_\*)”](#internal-variables-__varlock_) These are injected automatically so that varlock state survives across process boundaries. You should never set them yourself. They’re documented here only so you recognize them if you see them in a process environment. ### `__VARLOCK_ENV` [Section titled “\_\_VARLOCK\_ENV”](#__varlock_env) The serialized env graph (resolved config values plus metadata) injected by [`varlock run`](/reference/cli/load-and-run/#run) and build-time integrations, so the runtime can load your config without re-invoking the CLI. [`@internal`](/reference/item-decorators/#internal) items are excluded from this blob by default everywhere it’s built, including framework integrations. ### `__VARLOCK_RUN` [Section titled “\_\_VARLOCK\_RUN”](#__varlock_run) A marker set so a child process can detect that it is running under `varlock run`. Scripts that re-exec themselves under `varlock run` should check this first so they do not recurse. On macOS with Homebrew Python, do not pass `sys.executable` into that re-exec: see [Auto-invoking varlock run from Python](/integrations/python/#auto-invoking-varlock-run-from-python). ### `__VARLOCK_EXECUTION_PHASE` [Section titled “\_\_VARLOCK\_EXECUTION\_PHASE”](#__varlock_execution_phase) Set to `build` by build-time integrations (e.g. the Vite plugin during `vite build`) so the runtime can detect app code executing during build/prerender and apply the [public+dynamic access guard](/guides/dynamic-config/#prerenderbuild-guardrails). # Root @decorators > A reference page of available env-spec decorators that apply to the schema itself, rather than individual items Root decorators appear in the *header* section of a .env file - which is any comment block(s) at the beginning of the file, before the first config item. Usually root decorators are used only in your `.env.schema` file. .env.schema ```env-spec # This is the header, it can contain root decorators # @defaultSensitive=false @defaultRequired=infer # @generateTsTypes(path=./env.d.ts) ``` Two different notions of “default” are at play below, so it is worth separating them. Each decorator lists the behavior you get when that decorator is **absent** from a file. Separately, [`varlock init`](/reference/cli/project/#init) writes `@defaultRequired=infer` and `@defaultSensitive=false` into the schema it generates, as shown above, because those suit a typical project better than the built-in behavior. So most projects run with `infer` and non-sensitive-by-default even though an unmarked file behaves as `@defaultRequired=true @defaultSensitive=true`. Check your own file header rather than assuming either one. More details of the minutiae of decorator handling can be found in the [@env-spec reference](/env-spec/reference/#comments-and-decorators). ## General root decorators [Section titled “General root decorators”](#general-root-decorators) These are the root decorators that are built into Varlock. [Plugins](/guides/plugins/) may introduce more. ### `@currentEnv` [Section titled “@currentEnv”](#currentenv) **Value type:** [`ref()`](/reference/functions/#ref) (usually written as `$ITEM_NAME`) Sets the current *environment* value, which will be used when determining if environment-specific .env files will be loaded (e.g. `.env.production`), and also may affect other dynamic behaviour in your schema, such as the [`forEnv()` function](/reference/functions/#forenv). We refer to the name of this item as your *environment flag*. * It *must* be set to a simple reference to a single config item (e.g. `$APP_ENV`). * This decorator should only be set in your `.env.schema` file. * The referenced item must be defined in the same file, or brought in by `@import` (and included by any `pick`/`omit` filter on that import). * This will override the `--env` CLI flag if it is set. * We do not recommend using `NODE_ENV` as your environment flag, as it has other implications, and is often set out of your control. See [environments guide](/guides/environments) for more info. ```env-spec # @currentEnv=$APP_ENV # --- # @type=enum(dev, preview, prod, test) APP_ENV=dev ``` In a monorepo, the flag can live in a shared schema and be imported: ```env-spec # @currentEnv=$DEPLOY_ENV # @import(../../../.env.schema, pick=[DEPLOY_ENV]) # --- ``` ### `@envFlag` (deprecated) [Section titled “@envFlag (deprecated)”](#envflag) **Value type:** `string` (must be a valid item name within same file) Sets the current *environment flag* by name. ⚠️ Deprecated at v0.1 - use [`@currentEnv`](#currentenv) instead. `@envFlag=APP_ENV` -> `@currentEnv=$APP_ENV` ### `@defaultRequired` [Section titled “@defaultRequired”](#defaultrequired) **Value type:** `boolean | "infer"` Sets the default behavior of each item being *required*. Only applied to items that have a definition within the same file. Can be overridden on individual items using [`@required`](/reference/item-decorators/#required)/[`@optional`](/reference/item-decorators/#optional). * `infer`: Items with a value set in the same file will be required; items with an empty string or no value are optional. * `true` (behavior when this decorator is absent): All items are required unless marked optional. * `false`: All items are optional unless marked required. This setting applies per file, so an item defined only in a file that does not set `@defaultRequired` itself, such as a `.env.local` or an [imported](/guides/import/) file, falls back to being required even when the root schema sets `infer` or `false`. Mark those items with `@optional`, or set `@defaultRequired` in that file. ```env-spec # @defaultRequired=infer # --- FOO=bar # required (static value) BAR=fnCall() # required (function value) BAZ= # optional (no value) QUX='' # optional (empty string) # @optional OPTIONAL_ITEM=foo # optional (explicit) # @required REQUIRED_ITEM= # required (explicit) ``` ### `@defaultSensitive` [Section titled “@defaultSensitive”](#defaultsensitive) **Value type:** `boolean | inferFromPrefix(PREFIX)` Sets the default state of each item being treated as [*sensitive*](/guides/secrets/). Only applied to items that have a definition within the same file. Can be overridden on individual items using [`@sensitive`](/reference/item-decorators/#sensitive). * `true` (behavior when this decorator is absent): All items are sensitive unless marked otherwise. * `false`: All items are not sensitive unless marked otherwise. * `inferFromPrefix(PREFIX)`: Item is marked not sensitive if key starts with the given `PREFIX`; all others are sensitive. Useful for marking e.g. `PUBLIC_` keys as non-sensitive by default. 📖 See the [secrets management guide](/guides/secrets/) for best practices on handling sensitive values, and how to use plugins to fetch them from secret management platforms. ```env-spec # @defaultSensitive=inferFromPrefix(PUBLIC_) # --- PUBLIC_FOO= # not sensitive (due to matching prefix) OTHER_FOO= # sensitive (default when prefix does not match) # @sensitive PUBLIC_BAR= # sensitive (explicit decorator overrides prefix) # @sensitive=false OTHER_BAR= # not sensitive (explicit) ``` ### `@defaultDynamic` [Section titled “@defaultDynamic”](#defaultdynamic) **Value type:** `boolean | inferFromSensitive` Sets the default state of each item being treated as *dynamic* (runtime-resolved) vs *static* (eligible for build-time replacement). Only applied to items that have a definition within the same file. Can be overridden on individual items using [`@dynamic`](/reference/item-decorators/#dynamic)/[`@static`](/reference/item-decorators/#static). * `inferFromSensitive` (behavior when this decorator is absent): Dynamic behavior follows sensitivity. * `true`: All items are dynamic unless explicitly marked static. * `false`: All items are static unless explicitly marked dynamic. ```env-spec # @defaultSensitive=inferFromPrefix(PUBLIC_) # @defaultDynamic=inferFromSensitive # --- PUBLIC_FOO= # static by default (public) SECRET_BAR= # dynamic by default (sensitive) ``` ### `@disable` [Section titled “@disable”](#disable) **Value type:** `boolean` If true, disables loading the file - meaning no items or plugins are loaded from it. Useful for temporarily or conditionally disabling a `.env` file. 💡 The [`forEnv()`](/reference/functions/#forenv) function can disable an explicitly [imported](/guides/import/) file based on the current [environment](/guides/environments/). ```env-spec # @disable # (shorthand for @disable=true) # # @plugin(@varlock/x-plugin) # will not be loaded # --- FOO=bar # will be ignored ``` ### `@import()` [Section titled “@import()”](#import) **Arg types:** `[ path: string ]`\ **Named args:** `enabled?: boolean`, `allowMissing?: boolean`, `pick?: string[]`, `omit?: string[]` Imports other `.env` file(s) - useful for sharing config across monorepos and splitting up large schemas. *Can be called multiple times.* You may import a specific file, or a directory of files. Importing a **directory** (trailing `/`) brings everything that directory would load on its own (its `.env.schema` plus sibling `.env`, `.env.local`, and environment-specific files, resolved by the current environment flag), whereas importing a single file brings only that file. To pull in a sibling or root package’s values (not just its schema), import the directory. The optional `enabled` parameter allows conditional imports based on boolean expressions. It defaults to `true` if not specified. The optional `allowMissing` parameter makes the import optional - if set to `true`, the import will be silently skipped if the file or directory doesn’t exist instead of causing a loading error. It defaults to `false` if not specified. **Filtering keys:** by default every key from the imported file(s) is brought in. Use `pick` to import only an allowlist of keys, or `omit` to import everything except a denylist. You can’t use both in one import, and both accept simple globs (`*`, `?`). Across nested imports, filters intersect: a key must pass every filter in the chain. Positional keys are deprecated Listing keys as positional args (`@import(./.env.other, KEY1, KEY2)`) is **deprecated**; use `pick=[KEY1, KEY2]` instead. The positional form still works (exact-match only, no globs) but will warn. See the [imports guide](/guides/import/) for more details and advanced usage. ```env-spec # @import(./.env.imported) # import a specific file # @import(./.env.other, pick=[KEY1, KEY2]) # import only these keys (allowlist) # @import(./.env.other, pick=[API_*]) # globs are supported # @import(./.env.other, omit=[SECRET]) # import everything except these (denylist) # @import(../shared-env/) # import a directory # @import(~/.env.shared) # import from home directory # @import(./.env.dev, enabled=eq($ENV, "dev")) # conditional import # @import(./.env.local, allowMissing=true) # optional import (no error if missing) # --- # this definition is merged with any found in imports, but this one has more precedence IMPORTED_ITEM=overridden-value ``` ### `@setValuesBulk()` [Section titled “@setValuesBulk()”](#setvaluesbulk) **Arg types:** `[ data: string ]` **Named args:** `format?: "json" | "env"`, `createMissing?: boolean`, `enabled?: boolean`, `pick?: string[]`, `omit?: string[]` Injects multiple config values at once from an external data source. The first argument is a resolver that produces a string, typically a bulk resolver from a [secrets provider plugin](/plugins/overview/) such as [`opLoadEnvironment()`](/plugins/1password/#oploadenvironment) (1Password), [`infisicalBulk()`](/plugins/infisical/), or [`vaultSecret(…, raw=true)`](/plugins/hashicorp-vault/) (HashiCorp Vault). The string is parsed and injected as definitions within the file containing the decorator. Bulk values participate in the normal file override chain: `process.env` still overrides everything, higher-precedence files (`.env.local`, `.env.production`, etc.) override bulk values, and bulk values override schema-defined defaults in the same file. You control precedence by choosing which file to put `@setValuesBulk` in. **Options:** * `format`: How to parse the data string. `json` expects a flat JSON object, `env` expects `.env` file format. If not specified, auto-detected by checking if the string starts with `{`. * `createMissing`: If `true`, keys in the bulk data that don’t already exist in your schema will be created as new config items. Defaults to `false` (unknown keys are silently skipped). * `enabled`: If `false`, the bulk data resolver is skipped entirely. Accepts any boolean expression, including dynamic references to other config items. Defaults to `true`. * `pick`: An array of key names to inject, an **allowlist**. Only matching keys are injected; everything else from the source is ignored. * `omit`: An array of key names to skip, a **denylist**. Every key *except* the matches is injected. By default (neither `pick` nor `omit`) every key from the source is injected. You can’t use `pick` and `omit` together. Both accept simple glob patterns: `*` matches any run of characters and `?` matches a single character (e.g. `pick=[API_*]`). *Can be called multiple times; later calls overwrite earlier ones for the same keys.* .env.schema ```env-spec # Load all values from a 1Password environment # @plugin(@varlock/1password-plugin) # @initOp(token=$OP_TOKEN, allowAppAuth=forEnv(dev)) # @setValuesBulk(opLoadEnvironment(your-environment-id)) # --- # @type=opServiceAccountToken @sensitive OP_TOKEN= API_KEY= DB_PASSWORD= ``` .env.schema ```env-spec # Inject from a .env-style blob stored in a single 1Password field # @setValuesBulk(op("op://vault/app-secrets/dotenv"), format=env) # --- API_KEY= DB_PASSWORD= ``` .env.schema ```env-spec # Allowlist: only inject these keys (globs allowed, e.g. API_*) # @setValuesBulk(opLoadEnvironment(your-environment-id), pick=[API_KEY, DB_PASSWORD]) # --- API_KEY= DB_PASSWORD= ``` .env.schema ```env-spec # Denylist: inject everything except these keys # @setValuesBulk(infisicalBulk(), omit=[LEGACY_TOKEN, DEBUG_*]) ``` .env.schema ```env-spec # Create items not already in the schema # @setValuesBulk(infisicalBulk(), createMissing=true) ``` .env.schema ```env-spec # Only fetch from the source in non-production environments # @setValuesBulk(opLoadEnvironment(dev-environment-id), enabled=eq($APP_ENV, "dev")) # --- API_KEY= APP_ENV=dev ``` If a plugin doesn’t exist for your provider, any CLI tool works via [`exec()`](/reference/functions/#exec), e.g. `@setValuesBulk(exec("my-secrets-cli export --format=json"), format=json)`. Note When using `format=env`, function calls like `$VAR` references in unquoted values are not supported and will cause an error. Use single quotes for literal `$` signs (e.g., `'$LITERAL'`) or use `format=json` instead. `createMissing` and type generation Items created via `createMissing=true` (those that don’t already exist in your schema) **will not be included in generated TypeScript types**. Type generation happens at build/schema-load time before values are fetched, so dynamically created keys are invisible to the type generator. For this reason, **`createMissing=true` is not recommended**. Instead, declare all expected items explicitly in your schema (with an empty value) so they are known at type-generation time. ### `@plugin()` [Section titled “@plugin()”](#plugin) **Arg types:** `[ identifier: string ]` Loads a plugin, which can register new root decorators, item decorators, and resolver functions. *Can be called multiple times.* See [plugins guide](/guides/plugins/) for more details. ```env-spec # @plugin(@varlock/1password-plugin) # @initOp(allowAppAuth=true) # new root decorator # --- # @type=opServiceAccountToken # new data type OP_TOKEN= # @sensitive XYZ_API_KEY=op(op://api-prod/xyz/api-key) # new resolver ``` ### `@cache` [Section titled “@cache”](#cache) **Value type:** `"auto" | "memory" | "disk" | "disabled"` Sets global cache mode for [`cache()`](/reference/functions/#cache) and plugin cache APIs. **Modes:** * `memory`: cache only in-process (not persisted) * `disk`: cache to encrypted disk storage (persists across invocations) * `disabled`: disable caching globally * `auto` (default): uses `disk` when a native encryption backend is available outside CI; otherwise uses `disk` encrypted with `_VARLOCK_CACHE_KEY` if that env var is set, falling back to `memory` (see the [Caching guide](/guides/caching/)) The value can also be set dynamically with a function, for example to change cache mode per environment, and it can reference other config items. A function that resolves to no value falls back to `auto`. ```env-spec # @cache=memory # --- SESSION_SECRET=cache(randomHex(32)) ``` ```env-spec # dynamic - only use disk caching in development # @cache=if(forEnv(dev), "disk") # --- ``` ```env-spec # dynamic - based on another config item # @cache=if($USE_CACHE, "memory", "disabled") # --- USE_CACHE=true ``` Note `--skip-cache` on `varlock load` / `varlock run` / `varlock printenv` disables cache reads and writes for that invocation, regardless of `@cache`. See the [Caching guide](/guides/caching/) for mode selection guidance by environment. ### `@redactLogs` [Section titled “@redactLogs”](#redactlogs) **Value type:** `boolean` Controls whether sensitive config values are automatically redacted from console output. When enabled, any sensitive values will be replaced with `▒▒▒▒▒` in logs. *Only applies in JavaScript based projects where varlock runtime code is imported.* * `true` (default): Console logs are automatically redacted * `false`: Console logs are not redacted (useful for debugging) ```env-spec # @redactLogs=false # --- SECRET_KEY=my-secret-value # @sensitive ``` ```js console.log(process.env.SECRET_KEY) // This will log "my▒▒▒▒▒" instead of "my-secret-value" when @redactLogs=true ``` Caution There is a potential performance impact for both `@preventLeaks` and `@redactLogs` when enabled. It depends on the integration and how your application is served. Please [reach out](https://chat.dmno.dev) if you have any questions. We feel that they are beneficial enough to have them on by default but you can always opt out if you prefer. ### `@preventLeaks` [Section titled “@preventLeaks”](#preventleaks) **Value type:** `boolean` Controls whether leak prevention is enabled. When enabled, varlock will scan outgoing HTTP responses to detect if sensitive values are being leaked. *Only applies in JavaScript based projects where varlock runtime code is imported.* **Options:** * `true` (default): Leak detection is enabled * `false`: Leak detection is disabled (useful for debugging) ```env-spec # @preventLeaks=false # --- SECRET_KEY=my-secret-value # @sensitive ``` To opt out leak detection for a *single* item (for example a secret an endpoint legitimately returns to another system) while keeping it enabled everywhere else, use the [`@sensitive` options form](/reference/item-decorators/#sensitive): `@sensitive={preventLeaks=false}`. The value is still redacted in logs. ![Leak prevention](/_astro/leak.D-sjPUs5_1XAfab.png) *a sample leak detection warning in an [Astro project](/integrations/astro/)* Caution See note on [`@redactLogs`](#redactlogs) about potential performance impact. ### `@encryptInjectedEnv` [Section titled “@encryptInjectedEnv”](#encryptinjectedenv) **Value type:** `boolean` Controls whether the injected env blob in server-side build output is encrypted. When enabled, varlock encrypts the blob with AES-256-GCM using the `_VARLOCK_ENV_KEY` environment variable. This is primarily relevant for serverless deployments (Vercel, Netlify, etc.) where varlock injects resolved env data into the build output. The blob only appears in server-side code and is generally safe, but encryption provides extra protection, particularly against secrets leaking via sourcemaps. When enabled, it also applies when `varlock run` / `varlock proxy run` spawn a child in blob-only inject mode (`--inject blob`): the injected blob is encrypted with an ephemeral key carried alongside it (an ambient `_VARLOCK_ENV_KEY` is reused when present, so nested runs share one key), and resolved values never sit as plaintext in the child’s environment. Since the key rides next to the ciphertext, this protects against accidental leaks (crash reporters, env dumps, logs), not against an attacker who can read the full environment. **Options:** * `false` (default): Blob is injected as plaintext JSON * `true`: Blob is encrypted, `_VARLOCK_ENV_KEY` required at build time and runtime * `forEnv(...)`: Conditionally enable based on the current environment ```env-spec # @encryptInjectedEnv # --- SECRET_KEY= # @sensitive ``` ```env-spec # only encrypt in production # @encryptInjectedEnv=forEnv(prod) # --- SECRET_KEY= # @sensitive ``` **Key management:** * For **Cloudflare Workers**, the key is auto-generated and uploaded as a secret binding, so no manual setup is needed * For **local dev**, the Vite plugin mints a temporary key in an early config hook, so dev runtimes spawned by other plugins (for example Nitro’s worker) inherit it; Cloudflare dev targets fall back to plaintext (see [Local development](/guides/encrypted-deployments/#local-development)) * For **production deploys** to other platforms, you must set `_VARLOCK_ENV_KEY` on your platform (see the [encrypted deployments guide](/guides/encrypted-deployments/)) ### `@disableProcessEnvInjection` [Section titled “@disableProcessEnvInjection”](#disableprocessenvinjection) **Value type:** `boolean` Prevents varlock from injecting resolved values into `process.env`. When enabled, env vars are only accessible via the `ENV` proxy; `process.env.MY_VAR` will not contain varlock-resolved values. This is useful for security hardening. Combined with [`@encryptInjectedEnv`](#encryptinjectedenv), it ensures that no plaintext secrets exist in `process.env` at all. When set, [`@generateTsTypes`](#generatetstypes) stops augmenting `process.env` by default (its `processEnv` option defaults to `none`), so your generated types match the fact that `process.env` isn’t populated. **Options:** * `false` (default): Values are injected into `process.env` as usual * `true`: Values are only accessible via `ENV` The value must be static (`true`/`false`). Because code generation reads this flag, an environment-dependent value would make generated output differ per environment. ```env-spec # @disableProcessEnvInjection # --- SECRET_KEY= # @sensitive PUBLIC_URL= ``` Caution When enabled, any code that reads from `process.env` directly (including third-party libraries) will not see varlock-resolved values. Make sure all env access in your application goes through the `ENV` proxy. Note This decorator only affects the runtime `initVarlockEnv()` behavior. It does **not** affect [`varlock run`](/reference/cli/load-and-run/#run), which always injects individual env vars into the child process by default. Use `varlock run --no-inject-vars` if you also want to skip individual var injection when using `varlock run`. ### `@injectUndefinedAsEmpty` [Section titled “@injectUndefinedAsEmpty”](#injectundefinedasempty) **Value type:** `boolean` Controls how items that resolve to `undefined` (for example an optional `MY_VAR=` with no value set) are injected into `process.env`. By default they are left out of `process.env` entirely, so `process.env.MY_VAR === undefined` and patterns like `process.env.MY_VAR ?? 'fallback'` work as expected. This matches the documented value semantics: no value is `undefined`, while an explicit `MY_VAR=""` is an empty string, and the two stay distinct all the way through injection. Most other .env loaders instead set unset vars to an empty string. If you have code that relies on that (for example truthiness checks that expect `""`, or `'MY_VAR' in process.env`), set this decorator to restore that behavior. **Options:** * `false` (default): Items that resolve to `undefined` are not injected. This applies to auto-load / `initVarlockEnv()`, `varlock run`, and `varlock load --format shell`. * `true`: Items that resolve to `undefined` are injected as empty strings (`""`), matching dotenv-style loaders. The value must be static (`true`/`false`). Because code generation reads this flag, an environment-dependent value would make generated output differ per environment. ```env-spec # @injectUndefinedAsEmpty # --- OPTIONAL_VAR= # @optional ``` Generated TypeScript types reflect this: when enabled, the `process.env` augmentation from [`@generateTsTypes`](#generatetstypes) marks every schema key as always present, since unset items are injected as `""`. An optional string types as `string` instead of `string | undefined`, and literal-typed items keep their unions with `""` added (an optional `enum(alpha, beta)` becomes `"alpha" | "beta" | ""`). The `import.meta.env` augmentation keeps its optional keys: frameworks like Vite and Astro only expose prefixed keys through `import.meta.env`, so a schema key can be absent there regardless of this setting. Note The `ENV` proxy is unaffected: `ENV.OPTIONAL_VAR` always returns the real resolved value (`undefined` when unset), regardless of this setting, and its generated types keep optional keys optional. The same goes for the other language generators (`@generatePythonEnv`, `@generateGoEnv`, etc.): their loaders parse the `__VARLOCK_ENV` blob into coerced values, so unset items stay absent/`None`/`Option::None` rather than becoming empty strings. ### `@auditIgnorePaths()` [Section titled “@auditIgnorePaths()”](#auditignorepaths) **Arg types:** `[ ...paths: string[] ]` Excludes directories from the [`varlock audit`](/reference/cli/encryption/#audit) code scanner. *Can be called multiple times; paths are merged additively.* Useful for excluding generated code, vendored dependencies, or directories that contain env var references you don’t want to audit. Entries work like `.gitignore` patterns: An entry is either a directory name or a path, and it says which by how it starts: * A bare name matches **any** directory with that name, wherever it appears. `fixtures` skips both `./fixtures` and `./src/deep/fixtures`. * A path matches that one directory. It uses the same prefixes as [`@import()`](#import): `./` or `../` relative to your project root, `~/` for your home directory, or an absolute path. Relative entries resolve against the project root even when you narrow a run with `varlock audit ./some/dir`, so they keep meaning the same directory. `./apps/docs` leaves a `docs` elsewhere in the tree alone, and `./fixtures` skips the top-level one but not `src/deep/fixtures`. Entries name directories, not files. Three mistakes are reported rather than quietly matching nothing: a path written without one of those prefixes (`apps/docs` tells you to write `./apps/docs`), a path that resolves outside the project, and a path that points at a file. A path to a directory that doesn’t exist is fine, since a schema is shared across branches and checkouts; it simply never matches. Trailing separators are ignored, and `\` works as a separator so Windows-style entries are fine. Prefer `./` paths in a committed schema. An absolute or `~/` path is specific to one machine, so it will resolve differently, or outside the project entirely, for your teammates and in CI. ```env-spec # any directory named fixtures, plus the docs app specifically # @auditIgnorePaths(fixtures, ./apps/docs) # --- API_KEY= ``` ```env-spec # called multiple times, entries are merged # @auditIgnorePaths(fixtures) # @auditIgnorePaths(./generated/config, e2e) # --- API_KEY= ``` These are merged with any [`--ignore`](/reference/cli/encryption/#audit) entries passed on the command line, which accept the same forms. `.git`, `node_modules`, `dist`, `build`, `.next`, `vendor` and `.venv` are always skipped, so they don’t need listing. ### `@auditExtraPatterns()` [Section titled “@auditExtraPatterns()”](#auditextrapatterns) **Arg types:** `[ ...patterns: RegExp[], fileTypes?: string[] ]` Adds project-specific patterns to the [`varlock audit`](/reference/cli/encryption/#audit) code scanner. This is the escape hatch for env access idioms the built-in patterns don’t cover (e.g. NestJS `configService.get('KEY')`). Each pattern is a regex (a `regex('...')` call or a quoted `'/.../flags'` literal); the **first capture group is the env key**, so patterns without one match nothing. *Can be called multiple times; patterns are merged additively.* Use the `regex('...')` form (or a quoted slash-delimited literal) rather than a bare `/.../ `literal: bare decorator args cannot contain spaces, commas or parentheses, so realistic patterns would not survive parsing. Anything that is neither is rejected as an error instead of silently scanning nothing. ```env-spec # @auditExtraPatterns(regex('config\.get\(\s*\'([A-Z_]+)\'\)')) # --- API_KEY= ``` Unlike the built-in patterns, these see string contents as written, so a key that isn’t a bare identifier (`config.get('app.database.url')`) still matches. Code inside comments is skipped for both. #### Scoping patterns to file types [Section titled “Scoping patterns to file types”](#scoping-patterns-to-file-types) By default a pattern is applied to every file the scanner reads, which is every file whose extension it recognizes: `.js`, `.mjs`, `.cjs`, `.jsx`, `.ts`, `.mts`, `.cts`, `.tsx`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.py`, `.go`, `.rb`, `.php`, `.rs`, `.java`, `.cs`. Pass `fileTypes=[...]` to restrict the patterns in that call to certain file extensions. Naming an extension the scanner doesn’t already handle (`tf`, `yaml`, `sh`) also brings those files into the scan, so this is how you cover env references in Terraform, Helm values, CI configs or shell scripts. Extensions are written with or without a leading dot and matched case-insensitively. ```env-spec # a NestJS idiom, TypeScript only # @auditExtraPatterns(regex('config\.get\(\s*\'([A-Z_]+)\'\)'), fileTypes=[ts, tsx]) # terraform and helm, which the scanner would not otherwise read # @auditExtraPatterns(regex('cfg\.get\(\s*"([A-Z_]+)"\)'), fileTypes=[tf, yaml]) # no fileTypes: applies to every recognized source file # @auditExtraPatterns('/metrics\.inc\("([A-Z_]+)"\)/') # --- API_KEY= ``` Each call is its own scope: `fileTypes=[...]` applies to the patterns listed alongside it and to nothing else. A pattern with no `fileTypes` covers the recognized source extensions above, and is **not** extended to file types another call pulled in, so adding a Terraform rule can’t quietly change what your other rules match. Files brought in this way have no known language. The built-in patterns are never applied to them, and with no comment syntax to go on, their raw contents are scanned (commented-out lines are not skipped). Caution Every extension you name is walked and read on each audit. Extensions covering large generated or vendored trees will slow the scan; pair them with [`@auditIgnorePaths()`](#auditignorepaths). Files with no extension at all (`Dockerfile`, `Makefile`) can’t be selected this way. ### `@proxyConfig` [Section titled “@proxyConfig”](#proxyconfig) **Value type:** `{egress?: "permissive" | "strict", reload?: "off" | "manual" | "auto"}` Configures proxy-wide [credential proxy](/guides/proxy/) settings. The proxy is driven by [`@proxy`](/reference/item-decorators/#proxy) decorators on your items, so this header decorator is **optional**. It is a single-use, object-value decorator (`@proxyConfig={...}`), not a function call. `egress` controls requests that don’t match any [`@proxy`](#proxy) rule: * `permissive` *(default)*: unmatched hosts pass through untouched. * `strict`: only requests matching a `@proxy` rule are allowed; everything else is blocked. `reload` controls live [hot-reload](/guides/proxy/running/#editing-the-schema-while-a-session-is-running) of the running proxy after a schema edit: * `off`: no reload; restart the proxy to pick up schema changes. * `manual`: a human applies it by running `varlock proxy reload` from a trusted terminal; a reload requested from inside the proxied agent is refused and logged. * `auto` *(default)*: resolved at launch. Conservative today: `manual` for an interactive `varlock proxy start`, `off` for headless runs or a one-shot `varlock proxy run`. It only widens (toward agent-triggered reload behind an approver) when that becomes safe, e.g. inside a sandbox. The `--allow-reload` / `--no-allow-reload` flag overrides it per run. ```env-spec # @proxyConfig={egress="strict", reload="manual"} # --- # @proxy(domain="api.openai.com") OPENAI_API_KEY=yourPreferredPlugin() ``` See the [credential proxy guide](/guides/proxy/) for the full workflow. ### `@proxy()` [Section titled “@proxy()”](#proxy) **Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, approval?, keys?: string[], rules?: object[], ...)` Defines a **detached** [credential proxy](/guides/proxy/) rule: a domain-level policy (match or `block`). It injects no secret on its own, but can inject named items via `keys=[...]`, or transform matching requests via `transform={..., secretKey=$ITEM_NAME}` (the consumed credential is required on a detached rule since there is no attached item to default to, and is always written as a reference; see [Request transforms](/guides/proxy/rules/#request-transforms)). This is the header (root) form of the decorator. To inject a specific secret into requests for a domain, put [`@proxy`](/reference/item-decorators/#proxy) on the **item** instead (an *attached* rule). The two forms share the same options; see the [item decorator reference](/reference/item-decorators/#proxy). ```env-spec # Block a dangerous endpoint regardless of which key would be used: # @proxy(domain="api.stripe.com", path="/v1/refunds/**", method=[POST, DELETE], block=true) # Inject several keys for a host: # @proxy(domain="api.stripe.com", keys=[STRIPE_KEY, WEBHOOK_SECRET]) # --- # @sensitive STRIPE_KEY=yourPreferredPlugin() # @sensitive WEBHOOK_SECRET=yourPreferredPlugin() ``` ## Code generation [Section titled “Code generation”](#code-generation) Code-generation decorators turn your schema into generated code, such as TypeScript type declarations or ready-to-use env modules for other languages, and [plugins](/guides/plugins/) can register generators that emit other kinds of code. See the [Code generation guide](/guides/code-generation/) for the full picture, and [Other languages](/integrations/other-languages/) for per-language usage. Every `@generate*` decorator below is a root decorator that *can be called multiple times* (one per output file) and shares these common args: * `path`: Relative filepath to write the generated file to. * `auto`: Controls whether generation runs automatically on every load (defaults to `true`). Set to `false` to generate only when you run [`varlock codegen`](/reference/cli/cache-and-codegen/#codegen) explicitly, useful in a CI pipeline or a dedicated build step. * `executeWhenImported`: overrides the default of not executing when the containing file is imported (defaults to `false`). * `filter`: Restrict this generated file to a subset of items, using the same selector language as the CLI [`--filter` flag](/reference/cli-commands/#filtering-items): key names/globs, `!negations`, `@sensitive`/`@required`/`@dynamic`, and `#tagname` (set via [`@tag()`](/reference/item-decorators/#tag)). Quote the value if it has more than one comma-separated selector (the decorator parser splits args on unquoted commas), e.g. `filter="STRIPE_*,!STRIPE_DEBUG_KEY"`. Call the same decorator multiple times with different `path`/`filter` pairs to emit several subset files from one schema. ```env-spec # only ship billing-tagged keys to the billing package's generated types # @generateTsTypes(path=./billing/env.d.ts, filter=#billing) ``` Tip Usually these decorators live in your primary `.env.schema` file, and are ignored within imported files. To override this, set `executeWhenImported` to `true`. ### `@generateTsTypes()` [Section titled “@generateTsTypes()”](#generatetstypes) TypeScript type declarations. Makes `import { ENV } from 'varlock/env'` typed and augments `process.env` / `import.meta.env`. In addition to the [common code-generation args](#code-generation), it accepts: * `exposeEnv`: where the coerced `ENV` object lives (defaults to `global`). * `global`: globally augment `varlock/env` so `import { ENV } from 'varlock/env'` is typed. * `local`: export a package-local typed `ENV` from the generated file (with **no** global augmentation, since `processEnv`/`importMetaEnv` default to `none` too, so nothing merges across packages). Use this in monorepos where multiple packages have different schemas. Requires a `.ts` output path (not `.d.ts`, since it contains a runtime re-export), and you import from the generated file (e.g. `import { ENV } from './env'`). * `none`: emit only the type definitions, no `ENV` binding. * `processEnv`: how `process.env` is typed: `strict`, `loose`, or `none` (defaults to `strict`; to `none` when `exposeEnv=local`, or when [`@disableProcessEnvInjection`](#disableprocessenvinjection) is set, since values aren’t put on `process.env` then). Note that `@types/node` declares a base string index signature that can’t be removed, so extra keys remain allowed on `process.env` regardless. It also defaults to `none` when another `.d.ts` sitting next to your schema (or next to the generated file) already declares `NodeJS.ProcessEnv`. The common case is `worker-configuration.d.ts`, written by [`wrangler types`](/integrations/cloudflare/#type-safety-and-intellisense). TypeScript merges every declaration of that one interface and requires each shared key to be identical, so two of them can’t coexist: an optional item alone (`FOO?: string` vs `FOO: string`) is enough to fail with `TS2320`. The generated file names the file that triggered the skip. Set `processEnv=strict` to emit ours anyway. * `importMetaEnv`: how `import.meta.env` is typed: `strict`, `loose`, or `none` (defaults to `strict`; to `none` when `exposeEnv=local`). ```env-spec # @generateTsTypes(path=./env.d.ts) # monorepo-friendly: importable local ENV, no globals # @generateTsTypes(path=./env.ts, exposeEnv=local) ``` ### `@generatePythonEnv()` [Section titled “@generatePythonEnv()”](#generatepythonenv) A self-contained Python module: a coerced `Env` `TypedDict`, a `load_env()` that parses the injected env, and a `SENSITIVE_KEYS` constant. See [Python usage](/integrations/python/). ```env-spec # @generatePythonEnv(path=./env.py) ``` ### `@generateRustEnv()` [Section titled “@generateRustEnv()”](#generaterustenv) A self-contained Rust module: a serde-derived `Env` struct, a `load()` function, and a `SENSITIVE_KEYS` constant. Requires the `serde` and `serde_json` crates. See [Rust usage](/integrations/rust/). ```env-spec # @generateRustEnv(path=./src/env.rs) ``` ### `@generateGoEnv()` [Section titled “@generateGoEnv()”](#generategoenv) A self-contained Go package: an `Env` struct, a `Load()` function, and a `SensitiveKeys` map. See [Go usage](/integrations/go/). ```env-spec # @generateGoEnv(path=./env/env.go) ``` The package name defaults to the output directory (e.g. `path=env/env.go` → `package env`); override it with `package=`. ### `@generatePhpEnv()` [Section titled “@generatePhpEnv()”](#generatephpenv) A self-contained PHP class: a readonly `Env` with a static `load()` and a `SENSITIVE_KEYS` constant. Requires PHP 8.1+. See [PHP usage](/integrations/php/). ```env-spec # @generatePhpEnv(path=./Env.php) ``` Defaults to a global `final class Env`. For Composer/PSR-4 projects, set `namespace=` and/or `class=` (e.g. `@generatePhpEnv(path=src/Env.php, namespace="App\\Config", class=AppEnv)`). ### `@generateJavaEnv()` [Section titled “@generateJavaEnv()”](#generatejavaenv) A self-contained Java class: a typed `Env` with a static `load()` and a `SENSITIVE_KEYS` constant. Requires Java 17+ and Jackson (`jackson-databind`) on the classpath. See [Java usage](/integrations/java/). ```env-spec # @generateJavaEnv(path=./Env.java) ``` Optional `package=` and `class=` (default: no package, class `Env`). ### `@generateCsharpEnv()` [Section titled “@generateCsharpEnv()”](#generatecsharpenv) A self-contained C# class: a typed `Env` with a static `Load()` and a `SensitiveKeys` set. Requires .NET 6+ (`System.Text.Json`). See [C# usage](/integrations/csharp/). ```env-spec # @generateCsharpEnv(path=./Env.cs) ``` Optional `namespace=` and `class=` (default: no namespace, class `Env`). ### `@generateTypes()` (deprecated) [Section titled “@generateTypes() (deprecated)”](#generatetypes-deprecated) Deprecated TypeScript-only alias for [`@generateTsTypes`](#generatetstypes). `@generateTypes(lang=ts, path=...)` still works; for other languages use the matching `@generate*Env` decorator (`@generateTypes(lang=py, ...)` errors with a pointer to `@generatePythonEnv`). # Agent Safehouse > Launch Agent Safehouse with varlock proxy env ## Agent Safehouse [Section titled “Agent Safehouse”](#agent-safehouse) [Agent Safehouse](https://github.com/eugene1g/agent-safehouse) is a macOS Seatbelt wrapper with deny-first, composable profiles for coding agents. It hardens filesystem access; it does not broker credentials. ### Install [Section titled “Install”](#install) ```bash brew install eugene1g/safehouse/agent-safehouse ``` ### Launch with proxy env [Section titled “Launch with proxy env”](#launch-with-proxy-env) Seatbelt shares the host network, so host loopback from `proxy env` works: ```bash eval "$(varlock proxy env)" safehouse claude --dangerously-skip-permissions ``` Or a shell alias that always sources the proxy session first: \~/.zshrc ```bash safe-claude() { eval "$(varlock proxy env)" safehouse claude --dangerously-skip-permissions "$@" } ``` Tune profiles with `--add-dirs-ro`, `--append-profile`, and the [Safehouse docs](https://agent-safehouse.dev/docs/) so the agent can only read what it needs; keep real secrets out of those paths when they are proxied via varlock. # bubblewrap > Launch bubblewrap with varlock proxy env ## bubblewrap [Section titled “bubblewrap”](#bubblewrap) [bubblewrap](https://github.com/containers/bubblewrap) is the Linux namespace primitive behind Fence and many other sandboxes. Use it directly when you want a minimal DIY jail without Fence’s policy layer. ### Install [Section titled “Install”](#install) ```bash # Debian / Ubuntu sudo apt install bubblewrap # Fedora sudo dnf install bubblewrap ``` For a higher-level wrapper around per-project bubblewrap configs, see [sandbox-run](https://codeberg.org/Grauwolf/sandbox-run). ### Launch with proxy env [Section titled “Launch with proxy env”](#launch-with-proxy-env) Share the host network so the jail can reach varlock on loopback, bind your project read-write, and keep the rest of `$HOME` out: ```bash eval "$(varlock proxy env)" bwrap \ --die-with-parent \ --unshare-pid \ --share-net \ --ro-bind /usr /usr \ --ro-bind /bin /bin \ --ro-bind /lib /lib \ --ro-bind /lib64 /lib64 \ --ro-bind /etc /etc \ --bind "$PWD" "$PWD" \ --chdir "$PWD" \ --dev /dev \ --proc /proc \ --tmpfs /tmp \ -- claude ``` Adjust binds for your distro layout and tools (`node`, agent binaries under `/home`, etc.). Prefer Fence if you want packaged agent templates and domain allowlisting without maintaining a long `bwrap` line. Note `--share-net` is what keeps `HTTP_PROXY=http://127.0.0.1:…` meaningful. A fully unshared network namespace cannot dial the host loopback proxy without extra bridging. # Docker Sandboxes > Using varlock with Docker Sandboxes (sbx), so an agent in a local microVM routes through the varlock credential proxy and holds only placeholders. [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) (the `sbx` CLI) runs agents in local microVMs. Each sandbox gets its own Docker daemon, filesystem, and network, and all egress is forced through a host-side gateway with a deny-by-default domain allowlist (`sbx policy`). That gateway is the seam varlock plugs into: point the agent at a varlock broker, allow the broker in policy, and the agent holds only [placeholders](/guides/proxy/rules/#placeholders) while varlock injects real secrets at the wire. The recommended shape for local development is a **broker on your own machine**: secrets, resolver [plugins](/guides/plugins/), biometric unlock, and the interactive request log all stay on the host, and the sandbox reaches the broker through the sbx gateway. To share one broker across a fleet, run it on [infrastructure you operate](#remote-broker) and reach it at a public URL instead. ## How sbx egress works [Section titled “How sbx egress works”](#how-sbx-egress-works) Three facts about the gateway shape the setup: * **Everything goes through the gateway.** Inside a sandbox, `HTTP_PROXY` / `HTTPS_PROXY` point at `gateway.docker.internal:3128` and the sbx CA is in the trust store. Raw TCP, UDP, and ICMP are blocked at the network layer, DNS included, so names are resolved by the gateway and a request to a host with no policy rule comes back as a `403` from the gateway rather than a name-resolution failure. `varlock proxy run --url` [honors those proxy env vars](/guides/proxy/running/#remote-proxy-start---expose--proxy-run---url) and dials its tunnel through the gateway automatically, so no shim is needed. * **The gateway can reach a host-local service** when you allow it in policy. This is what makes a host broker reachable from the microVM. * **Traffic you route through a broker leaves from the broker’s machine, not the sandbox.** Once the agent’s requests ride the varlock tunnel, sbx policy sees only the connection to the broker. Egress control moves to varlock, which is why the schema below sets `egress="strict"`. ## Broker on your machine [Section titled “Broker on your machine”](#broker-on-your-machine) ### 1. Schema [Section titled “1. Schema”](#1-schema) Mark the secrets your agent uses with [`@proxy(domain=...)`](/reference/item-decorators/#proxy), give each an explicit [`@placeholder`](/reference/item-decorators/#placeholder), and set [`@proxyConfig={egress="strict"}`](/guides/proxy/rules/#egress-modes) in the header: .env.schema ```env-spec # @proxyConfig={egress="strict"} # --- # @proxy(domain="api.anthropic.com") # @placeholder=sk-ant-api03-000000000000000000000000 ANTHROPIC_API_KEY=yourPreferredPlugin() ``` Values are resolved by the broker on your host, so the right-hand side is whatever your setup already uses: a [plugin](/guides/plugins/) call as shown, or a value the broker picks up from its own environment or a local `.env` file. An item left empty with no source fails validation and the broker will not start. Set `egress="strict"` for this topology varlock’s egress mode [defaults to `permissive`](/guides/proxy/rules/#egress-modes), where a request matching no rule passes through untouched. That default is fine when the sandbox’s own allowlist is still the outer boundary. Here it is not: the agent’s requests exit from the broker on your host, so they never reach `sbx policy` at all. A sandbox that can reach the broker can reach anything the broker can, and the sandbox’s request log will show only the broker connection. `strict` restores the boundary by blocking anything that matches no `@proxy` rule, and varlock’s own [audit log](/guides/proxy/running/#auditing) records the decisions. ### 2. Start the broker on the host [Section titled “2. Start the broker on the host”](#2-start-the-broker-on-the-host) `--expose` binds off-loopback and serves the [tunnel](/guides/proxy/running/#remote-proxy-start---expose--proxy-run---url), minting a data-plane token. Pin the token so you can hand the same value to the agent: ```bash export VARLOCK_PROXY_TOKEN=$(uuidgen) varlock proxy start --expose --port 8080 ``` Bare `--expose` binds `0.0.0.0`, so the broker is reachable from your whole network, not just the sandbox. The data-plane token gates it, but on an untrusted network (a cafe, a conference) firewall the port or stay off that network while the broker runs. Serving the tunnel requires an off-loopback bind, so a loopback-only broker is not an option even though the gateway would reach it. ### 3. Allow the broker in sbx policy [Section titled “3. Allow the broker in sbx policy”](#3-allow-the-broker-in-sbx-policy) The gateway rewrites `host.docker.internal` to `localhost` before forwarding, and policy is matched against the destination it forwards to. So the rule that matches is for **`localhost`**, even though the agent connects to `host.docker.internal`: ```bash sbx policy allow network "localhost:8080" ``` Allow `localhost`, not `host.docker.internal` This trips people up, so it is worth stating plainly: a rule for `host.docker.internal:8080` does not work, and `sbx policy check network host.docker.internal:8080` reports `Allowed` anyway while the request itself returns `403`. The check evaluates the literal rule; the gateway rewrites the host before policy runs. Docker documents the rewrite under [accessing host services from a sandbox](https://docs.docker.com/ai/sandboxes/workflows/#accessing-host-services-from-a-sandbox) and in the [Model Runner guide](https://docs.docker.com/guides/claude-code-sandbox-model-runner/). ### 4. Run the agent through the broker [Section titled “4. Run the agent through the broker”](#4-run-the-agent-through-the-broker) Install varlock in the sandbox, then wrap the agent command with `proxy run --url`. The agent connects to the broker at `host.docker.internal`, and varlock self-wires its placeholder env and CA certs over the tunnel: ```bash # in a shell sandbox (sbx create --name my-sandbox shell . && sbx exec my-sandbox bash -lc '...'): npm i -g varlock VARLOCK_PROXY_TOKEN=$YOUR_TOKEN \ varlock proxy run --url ws://host.docker.internal:8080 -- your-agent-command ``` That is the whole path: the agent holds placeholders, the broker on your host injects real values only on verified TLS connections to hosts your schema allows, and every request is checked against your [`@proxy` rules](/guides/proxy/rules/#routing-rules) and recorded in the [audit log](/guides/proxy/running/#auditing). Installing varlock in the sandbox `npm i -g varlock` is the least friction here: the `shell` agent image ships Node 22.22, above varlock’s 22.3 minimum, `registry.npmjs.org` is already in the Balanced preset’s allowlist, and the package lands on `PATH`. It is also the lighter install (\~9 MB against the \~100 MB binary). The [install script](/getting-started/installation/) works too, but needs two extra steps. `varlock.dev` is not in the Balanced allowlist, so add `sbx policy allow network varlock.dev` first. The script also does not update `PATH`, so pin its destination with `curl -sSfL https://varlock.dev/install.sh | sh -s -- --dir="$HOME/.local/bin"`, then invoke `$HOME/.local/bin/varlock` or export the directory yourself. Baking varlock into a [custom template](https://docs.docker.com/ai/sandboxes/customize/) skips the per-sandbox install entirely, which matters for a fleet. Pass the token via the environment (`sbx exec -e`, or append an `export` to `/etc/sandbox-persistent.sh` to [persist it across launches](https://docs.docker.com/ai/sandboxes/faq/#how-do-i-set-custom-environment-variables-inside-a-sandbox)), not on the command line, so it stays out of process listings. ## Remote broker [Section titled “Remote broker”](#remote-broker) To share one broker across machines or a fleet, run it on infrastructure you operate and expose it at a URL that carries WebSockets. Because sbx allows direct TLS to allowlisted domains, the agent reaches a public `wss://` broker directly (no gateway asymmetry), so you only allow the broker’s domain: ```bash sbx policy allow network "broker.example.com" # in the sandbox: VARLOCK_PROXY_TOKEN=$YOUR_TOKEN \ varlock proxy run --url wss://broker.example.com -- your-agent-command ``` The data-plane token gates the tunnel, and the gateway passes allowlisted TLS through without terminating it, so the tunnel’s TLS runs end to end and an intermediary sees only ciphertext. One caveat: sbx does terminate TLS for hosts it injects its own credentials into, and its CA is already in the sandbox trust store, so if your broker’s domain ever became such a host the tunnel would still connect while no longer being end to end. Keep the broker on a domain sbx has no reason to intercept. See the [topologies overview](/sandboxes/overview/#topologies) and the [E2B](/sandboxes/e2b/) / [Fly.io](/sandboxes/flyio/) guides for the same broker shape on cloud providers. ## varlock proxy vs sbx secrets [Section titled “varlock proxy vs sbx secrets”](#varlock-proxy-vs-sbx-secrets) Docker Sandboxes ships its own credential injection (`sbx secret`): the gateway substitutes a stored value into request headers for a matching host. It covers the basic case. Route through a varlock broker when you want: * **Custody in your secret manager.** Secrets come from wherever you already keep them through [plugins](/guides/plugins/) (1Password, Vault, AWS, Doppler, …) and stay in your custody, instead of being copied into another store. * **One schema.** Your `.env.schema` describes every value, its type, and its routing in one declarative layer, legible to people and agents alike. * **Response scrubbing.** varlock scans response bodies and redacts injected secret values, so an allowlisted endpoint that echoes a request header cannot hand the real secret back to the agent. * **Policy and audit.** Match on path and method, [hot-reload](/guides/proxy/running/#editing-the-schema-while-a-session-is-running) the schema, and keep your own [audit log](/guides/proxy/running/#auditing). The two compose: sbx provides microVM isolation, and the varlock broker provides custody, injection, scrubbing, and (in `strict` mode) the egress boundary. They also overlap on variable names: a sandbox starts with the env vars for sbx’s [built-in services](https://docs.docker.com/ai/sandboxes/security/credentials/#built-in-services) (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, …) preset to the sentinel `proxy-managed`, whether or not you have stored a secret for them. varlock’s child env is layered on top, so a key your schema routes through `@proxy` reaches the agent as varlock’s placeholder and the varlock rule applies; keys your schema does not describe keep the sentinel and stay on the sbx path. ## Trust model [Section titled “Trust model”](#trust-model) The broker holds real secrets on whatever machine runs it. For a host broker in local development, that is your own machine, the same place the secrets already live. The agent’s microVM never holds them: a compromised or prompt-injected agent yields placeholders and only the requests your rules and egress mode allow. Note where each boundary applies. sbx policy governs what the sandbox reaches **directly**, so keep it tight: the broker, plus anything the agent legitimately fetches on its own (package registries, and `varlock.dev` if you install by script). Everything the agent sends through the broker is governed by varlock instead, which is what `egress="strict"` is for. Adding a host to sbx policy does not widen or narrow the proxied path, and leaving varlock permissive does not get caught by sbx policy. Treat the data-plane token like any shared secret (rotate by restarting the broker with a new one), and keep the broker’s port off untrusted networks, since `--expose` binds all interfaces. # E2B > Using varlock with E2B cloud sandboxes, from resolving and validating sandbox env vars to running the credential proxy so sandboxes only hold placeholders. [E2B](https://e2b.dev) runs code in cloud microVM sandboxes, commonly as the execution layer for AI agents. Sandboxes get their env vars from the code that creates them (`envs` at sandbox creation or per command), which makes that orchestrator code the natural place for varlock to do its job. For workloads you trust with the credentials they use, [pass resolved values](#passing-resolved-values). For agentic workloads, the recommended shape is the [broker sandbox](#credential-proxy-the-broker-sandbox): one sandbox runs the [credential proxy](/guides/proxy/) and holds the real secrets, and agent sandboxes route through it holding only [placeholders](/guides/proxy/rules/#placeholders). ## Passing resolved values [Section titled “Passing resolved values”](#passing-resolved-values) Import [`varlock/auto-load`](/integrations/javascript/) at the top of the orchestrator: varlock resolves your values (plugins, `.env.local`, etc.), validates them against your schema, and redacts them in the orchestrator’s logs. orchestrate.ts ```ts import 'varlock/auto-load'; import { ENV } from 'varlock/env'; import { execSync } from 'node:child_process'; import { Sandbox } from 'e2b'; // one blob with the resolved env, scoped by --filter to what // this sandbox should see const envBlob = execSync( 'varlock load --format json-full --compact --filter "STRIPE_*,SENTRY_DSN"', { encoding: 'utf8' }, ).trim(); const sandbox = await Sandbox.create({ envs: { // a Node app that imports varlock hydrates process.env, the ENV object, // and log redaction from the blob - no .env files or CLI in the sandbox __VARLOCK_ENV: envBlob, _VARLOCK_USE_INJECTED_ENV: '1', // for workloads that don't import varlock, enumerate plain vars instead: // STRIPE_SECRET_KEY: ENV.STRIPE_SECRET_KEY, }, }); ``` This is the standard E2B posture: sandboxes hold real values, and the values transit E2B’s API. Consuming the blob needs either the `varlock` npm package (Node 22+) or the varlock CLI (`varlock run -- ` injects plain env vars from the blob, for any workload). See [`_VARLOCK_USE_INJECTED_ENV`](/reference/reserved-variables/#_varlock_use_injected_env). From a shell, `varlock run --inject blob --filter ... -- sh -c '...'` exposes the blob as `$__VARLOCK_ENV` for forwarding. ## Credential proxy: the broker sandbox [Section titled “Credential proxy: the broker sandbox”](#credential-proxy-the-broker-sandbox) One long-lived sandbox (the broker) runs `varlock proxy start --expose`, which serves the built-in WebSocket tunnel on its proxy port (E2B’s public sandbox URLs carry it). Agent sandboxes reach it with `varlock proxy run --url`, which self-wires their placeholder env and CA certs from the broker over the tunnel. The proxy injects real values into requests at the wire, on verified TLS connections to hosts your schema allows, with every request checked against your [`@proxy` rules](/guides/proxy/rules/#routing-rules) and recorded in the [audit log](/guides/proxy/running/#auditing). A compromised or prompt-injected agent can exfiltrate nothing but placeholders. ```plaintext [agent sandbox] [broker sandbox] varlock proxy run --url ── ws ──▶ proxy :8080 + tunnel (loopback proxy + agent) (public getHost URL) ``` ### Schema setup [Section titled “Schema setup”](#schema-setup) Mark the secrets your agents use with [`@proxy(domain=...)`](/reference/item-decorators/#proxy) and give each one an explicit [`@placeholder`](/reference/item-decorators/#placeholder): .env.schema ```env-spec # @proxy(domain="api.anthropic.com") # @placeholder=sk-ant-api03-000000000000000000000000 ANTHROPIC_API_KEY= # @proxy(domain="api.stripe.com") # @placeholder=sk_test_00000000000000000000000000 STRIPE_SECRET_KEY= ``` An explicit `@placeholder` is optional (the sandbox pulls whatever the schema produces from the broker), but worth setting when an SDK checks the key format client-side: a realistic-looking placeholder passes that check where a generic `vlk_placeholder_…` would not. Egress is permissive by default: proxied requests to hosts without a rule pass through untouched, which is usually fine because agents hold only placeholders. If the broker should refuse anything that does not match a rule, set [`@proxyConfig={egress="strict"}`](/guides/proxy/rules/#egress-modes) in the schema header. You will also need an [E2B API key](https://e2b.dev/docs) for the orchestrator that creates sandboxes. It can be a varlock-managed secret like any other. ### Start the broker [Section titled “Start the broker”](#start-the-broker) orchestrate.ts ```ts import 'varlock/auto-load'; import { ENV } from 'varlock/env'; import { Sandbox } from 'e2b'; // One data-plane token, shared by the broker and every agent; generate it // yourself (or let the broker mint one and read it back with `varlock proxy // token`). It is the credential to USE the broker over the tunnel, not to read // its secrets. const PROXY_TOKEN = crypto.randomUUID(); const VARLOCK = '/home/user/.config/varlock/bin/varlock'; const broker = await Sandbox.create({ timeoutMs: 60 * 60 * 1000 }); await broker.commands.run('curl -sSfL https://varlock.dev/install.sh | sh -s'); // upload the schema (plus any other .env files your project loads); // real values arrive via envs below instead await broker.files.write('/home/user/proj/.env.schema', envSchemaContents); // start the proxy bound off-loopback so the tunnel is reachable; the token is // pinned via VARLOCK_PROXY_TOKEN so we already know it. --persist-ca reuses the // CA across broker restarts, so agents that already trust it keep working. // --allow-reload lets you apply schema edits later without a restart; the // reload channel is only reachable from inside the broker, not by agents. // Schema keys resolve from the process env, so envs carries the bootstrap: // usually just your plugin's secret-zero (shown: a 1Password service account). await broker.commands.run( `cd /home/user/proj && ${VARLOCK} proxy start --expose --port 8080 --cert-dir /home/user/proj/.varlock-ca --persist-ca --allow-reload > /home/user/proxy.log 2>&1`, { background: true, timeoutMs: 0, envs: { VARLOCK_PROXY_TOKEN: PROXY_TOKEN, OP_SERVICE_ACCOUNT_TOKEN: ENV.OP_SERVICE_ACCOUNT_TOKEN, }, }, ); // ready once the port answers (a bare GET returns 400, which is fine; we only // need the listener up, so no -f). await broker.commands.run("until curl -s -o /dev/null http://127.0.0.1:8080 --proxy ''; do sleep 0.2; done", { timeoutMs: 30_000 }); const brokerHost = broker.getHost(8080); // e.g. 8080-.e2b.app ``` The `envs` block carries whatever bootstraps your schema. With secrets resolved from a manager via a [plugin](/guides/plugins/) (the usual setup), that is one service-account token, the secret zero, and the schema resolves everything else inside the broker. If some values exist only on your side (like the minimal example schema above), enumerate them instead (`ANTHROPIC_API_KEY: ENV.ANTHROPIC_API_KEY`, …), so they are the orchestrator’s own resolved values passing through. Either way agent sandboxes hold no real secrets at all. ### Start agent sandboxes [Section titled “Start agent sandboxes”](#start-agent-sandboxes) An agent needs nothing but varlock and `proxy run --url`. It pulls its placeholder env and CA certs from the broker over the tunnel, so there is no env or cert plumbing to pass: orchestrate.ts (continued) ```ts const agent = await Sandbox.create({ timeoutMs: 60 * 60 * 1000 }); await agent.commands.run('curl -sSfL https://varlock.dev/install.sh | sh -s'); // connect to the broker and run the workload through the tunnel. The agent only // ever holds placeholders; the broker injects real values at the wire. await agent.commands.run( `${VARLOCK} proxy run --url wss://${brokerHost} -- your-agent-command`, // the token rides the env rather than the command line, so it stays out of // process listings and E2B's command logs { envs: { VARLOCK_PROXY_TOKEN: PROXY_TOKEN } }, ); ``` Installing varlock The install script drops a self-contained binary and works on any sandbox. E2B’s base template ships Node 20, which is **below** varlock’s Node 22.3 requirement, so `npm i -g varlock` installs but won’t run there. If you build a [custom template](https://e2b.dev/docs/template/quickstart) with Node 22.3+ (or bun), `npm i -g varlock` is a lighter install (\~9 MB vs the \~100 MB binary). Either way, baking varlock into the template skips the per-sandbox install entirely, which matters most when a fleet spawns many sandboxes. To see what agents are doing, run [`varlock proxy audit`](/reference/cli/proxy/) (or `proxy status --watch`) inside the broker: every request records its host, path, decision, and which keys were injected. ### Lock down agent egress [Section titled “Lock down agent egress”](#lock-down-agent-egress) So far the proxy governs proxied traffic, but an agent could still make direct connections that bypass the tunnel. Those requests carry placeholders at worst, so no secrets are at stake, but E2B can close the gap entirely with its [network egress rules](https://e2b.dev/docs/sandbox/internet-access): restrict the agent to the broker’s URL and every request has to go through varlock policy. Apply the restriction **after** provisioning, not at `Sandbox.create`. The agent needs open egress briefly to install varlock (skip that by baking it into your template), and the clamp does not disturb an established tunnel: ```ts await agent.updateNetwork({ allowOut: [brokerHost], denyOut: ['0.0.0.0/0'], }); ``` This is enforced by E2B’s infrastructure outside the VM, so nothing the agent does from inside can lift it. Domain-based `allowOut` rules match by SNI and Host header, per E2B’s docs; IP-literal traffic needs CIDR rules. ### Trust model [Section titled “Trust model”](#trust-model) Be clear-eyed about what this shape protects against. The broker holds real secrets inside E2B’s cloud, so E2B’s infrastructure is inside your trust boundary, same as it would be for secrets passed to any sandbox. What changes is the blast radius on your side: agents never hold secrets, so a compromised agent sandbox yields placeholders and only whatever requests your rules and egress mode allow. Rotation, policy, and audit live in one place instead of N sandboxes. Two practical notes: * No human is attached to the broker, so its policy must run unattended: allow rules, `block` rules, `@proxy=omit`, and strict egress. To change policy, write the edited schema with `files.write` and run `${VARLOCK} proxy reload` via `commands.run`: the proxy validates the edit in its own context before applying, and a broken edit is refused and reported back. Rule changes apply to agent traffic immediately; a newly added key shows up for newly started `proxy run` commands. * The token authenticates the tunnel and, over it, unlocks the placeholder env an agent adopts. Agents hold it deliberately; it is the credential to *use* the broker, not to read its secrets, which never leave it. Treat it like any shared secret (rotate by restarting the broker with a new one). * A broker sandbox is a single point of failure for its fleet. Manage its lifetime explicitly (`timeoutMs`, or E2B’s pause/resume); `proxy run --url` opens a fresh tunnel per connection, so transient blips recover, and `--persist-ca` above keeps the CA stable across a broker restart. Reserve that flag for brokers: it writes the CA private key to disk, which is only reasonable because that machine already holds your real secrets. ## Other topologies [Section titled “Other topologies”](#other-topologies) For local development, run the proxy on your machine instead: secrets, resolver plugins, biometric unlock, and the interactive request log stay local. Expose it through any tunnel service that carries WebSockets and reuse the same agent-side command: ```bash export VARLOCK_PROXY_TOKEN=$(uuidgen) varlock proxy start --expose --port 8080 ngrok http 8080 # or cloudflared, Tailscale funnel, ... # agents: VARLOCK_PROXY_TOKEN=… varlock proxy run --url wss://abc123.ngrok.app -- ``` The data-plane token gates the tunnel, so a public URL is not usable by whoever finds it. Be precise about what the tunnel service itself can see, though, because it terminates the outer TLS: it observes the WebSocket handshake, which carries that token, the `CONNECT` metadata naming each upstream host, and the placeholder env the agent bootstraps. What it does not see is your real secrets, or the contents of proxied HTTPS requests, which ride an inner TLS session between the agent and your proxy. Plain HTTP has no inner session: it crosses the tunnel in absolute form, so a terminating service reads those requests in full. The proxy fails closed rather than injecting a secret into a cleartext connection, so what is exposed there is traffic carrying no injected secret. Pick a tunnel service you would trust with the token. The same pattern reaches a proxy on any infrastructure you run; see the [topologies overview](/sandboxes/overview/#topologies). Compared to E2B request transforms E2B has a beta feature ([`network.rules`](https://e2b.dev/docs/sandbox/internet-access)) that injects headers into egress requests for matching hosts at their gateway, so a sandbox can call an API without holding the key. The varlock proxy covers the same ground and more: * **Seamless.** Substitution happens anywhere in a request, not just one static header per host, and policy can match on path and method. * **Any source.** Secrets come from wherever you already keep them through [plugins](/guides/plugins/) (1Password, Vault, AWS, Doppler, …) and stay in your custody, instead of being handed to E2B’s control plane. * **One schema.** Your `.env.schema` is a single declarative layer describing every value, its type, and its routing rules, and it is legible to both people and agents. `network.rules` are static per sandbox, set at creation. * Plus [hot reload](/guides/proxy/running/#editing-the-schema-while-a-session-is-running), response scrubbing, your own audit log, and the same setup on any other platform. # Fence > Wire Fence through varlock proxy ## Fence [Section titled “Fence”](#fence) [Fence](https://github.com/fencesandbox/fence) wraps agents in Seatbelt (macOS) or bubblewrap (Linux), with filesystem rules, command denies, and a domain allowlist proxy. It does **not** inject credentials; that stays varlock’s job. ### Install [Section titled “Install”](#install) ```bash brew tap fencesandbox/tap brew install fencesandbox/tap/fence ``` On Linux, also install `bubblewrap` and `socat` (for example `sudo apt install bubblewrap socat`). ### Wire Fence through varlock [Section titled “Wire Fence through varlock”](#wire-fence-through-varlock) Fence’s own proxy enforces domain policy. Important detail from Fence’s [upstream proxy docs](https://github.com/fencesandbox/fence/blob/main/docs/configuration.md): traffic that matches `allowedDomains` goes **direct** to the origin and **bypasses** `upstreamProxy`. Hosts that need varlock injection must therefore sit in the grey zone (not listed in `allowedDomains`), with `defaultAction: "proxy"` and `upstreamProxy` pointed at varlock. 1. Start varlock on a fixed port, so `fence.json` can name the proxy address without per-session editing: ```bash varlock proxy start --port 54321 ``` 2. Project `fence.json` (extends the `code` template; keep package registries on the allowlist so they stay direct; leave model API hosts for varlock): fence.json ```json { "$schema": "https://raw.githubusercontent.com/fencesandbox/fence/main/docs/schema/fence.schema.json", "extends": "code", "network": { "allowedDomains": [ "github.com", "*.npmjs.org", "registry.yarnpkg.com", "registry.npmjs.org" ], "deniedDomains": ["169.254.169.254"], "defaultAction": "proxy", "upstreamProxy": "http://127.0.0.1:54321" } } ``` `--port` keeps that address stable across sessions, so `fence.json` can be committed as-is (match `upstreamProxy` to the port you chose). Only `http://` upstream URLs are supported by Fence today. 3. Export placeholders + CA trust, then launch: ```bash eval "$(varlock proxy env)" fence --settings ./fence.json -- claude ``` The agent talks to Fence’s local proxy first. Grey-zone hosts (including `api.anthropic.com` when it is not in `allowedDomains`) forward to varlock, which injects secrets under your `@proxy` rules. Keep `@proxyConfig={egress="strict"}` so unmatched grey-zone destinations die at varlock instead of leaking outbound. CA trust Varlock MITMs HTTPS with an ephemeral CA. Keep the CA env vars from `proxy env` in the process Fence launches (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, and related). If TLS handshakes fail after wiring `upstreamProxy`, confirm those vars survived into the sandboxed agent, and that Fence’s filesystem rules let it read the cert file. If the default temp-dir path is blocked, add `--cert-dir ./.varlock-proxy` to the `proxy start` in step 1 so the CA lives inside your project. # Fly.io > Using varlock with Fly.io Sprites, from resolving and validating sandbox env vars to running the credential proxy so sprites only hold placeholders. [Sprites](https://fly.io/sprites/) are Fly.io’s agent sandboxes: persistent, hardware-isolated Linux microVMs with their own CLI and SDKs. Sprites get env vars from the code that creates and execs into them, which makes that orchestrator code the natural place for varlock to do its job. For workloads you trust with the credentials they use, [pass resolved values](#passing-resolved-values). For agentic workloads, the recommended shape is the [broker sprite](#credential-proxy-the-broker-sprite): one sprite runs the [credential proxy](/guides/proxy/) and holds the real secrets, and agent sprites route through it holding only [placeholders](/guides/proxy/rules/#placeholders). ## Passing resolved values [Section titled “Passing resolved values”](#passing-resolved-values) Sprite env is per exec, so credentials exist only for the duration of one command. Import [`varlock/auto-load`](/integrations/javascript/) in the orchestrator: varlock resolves your values (plugins, `.env.local`, etc.), validates them against your schema, and redacts them in the orchestrator’s logs. orchestrate.ts ```ts import 'varlock/auto-load'; import { ENV } from 'varlock/env'; import { execSync } from 'node:child_process'; import { SpritesClient } from '@fly/sprites'; // your Sprites token is a varlock-managed secret like any other const sprite = new SpritesClient(ENV.SPRITES_TOKEN).sprite('my-sprite'); // simplest: pass individual resolved values await sprite.exec('node agent.js', { env: { STRIPE_SECRET_KEY: ENV.STRIPE_SECRET_KEY }, }); // or pass the whole env as one blob, scoped by --filter. A Node app that // imports varlock hydrates process.env, the ENV object, and log redaction // from it - no .env files or CLI in the sprite const envBlob = execSync( 'varlock load --format json-full --compact --filter "STRIPE_*,SENTRY_DSN"', { encoding: 'utf8' }, ).trim(); await sprite.exec('node agent.js', { env: { __VARLOCK_ENV: envBlob, _VARLOCK_USE_INJECTED_ENV: '1' }, }); // non-Node workloads: install varlock in the sprite (npm i -g varlock) and // wrap the command - varlock run injects plain env vars from the same blob await sprite.exec('varlock run -- ./my-agent', { env: { __VARLOCK_ENV: envBlob, _VARLOCK_USE_INJECTED_ENV: '1' }, }); ``` This is the standard Sprites posture, and what Fly recommends: the workload holds real values transiently. See [`_VARLOCK_USE_INJECTED_ENV`](/reference/reserved-variables/#_varlock_use_injected_env). The sprite CLI’s `--env` cannot carry the blob: it parses comma-delimited `KEY=v,KEY2=v2` pairs and silently truncates at the first comma. Upload the blob with `--file` instead: ```bash varlock load --format json-full --compact --filter "STRIPE_*,SENTRY_DSN" > env-blob # read-and-delete inside the command, so the blob does not linger on disk sprite exec -s my-sprite --file env-blob:/tmp/env-blob -- sh -c \ '__VARLOCK_ENV="$(cat /tmp/env-blob; rm /tmp/env-blob)" _VARLOCK_USE_INJECTED_ENV=1 varlock run -- ./my-agent' rm env-blob ``` ## Credential proxy: the broker sprite [Section titled “Credential proxy: the broker sprite”](#credential-proxy-the-broker-sprite) One sprite (the broker) runs `varlock proxy start --expose` as a sprite *service*, serving the built-in WebSocket tunnel on the sprite’s public URL. Agent sprites reach it with `varlock proxy run --url`, which self-wires their placeholder env and CA certs from the broker over the tunnel. The proxy injects real values into requests at the wire, on verified TLS connections to hosts your schema allows, with every request checked against your [`@proxy` rules](/guides/proxy/rules/#routing-rules) and recorded in the [audit log](/guides/proxy/running/#auditing). A compromised or prompt-injected agent can exfiltrate nothing but placeholders. ### Schema setup [Section titled “Schema setup”](#schema-setup) Mark the secrets your agents use with [`@proxy(domain=...)`](/reference/item-decorators/#proxy) and give each one an explicit [`@placeholder`](/reference/item-decorators/#placeholder): .env.schema ```env-spec # @proxy(domain="api.anthropic.com") # @placeholder=sk-ant-api03-000000000000000000000000 ANTHROPIC_API_KEY= # @proxy(domain="api.stripe.com") # @placeholder=sk_test_00000000000000000000000000 STRIPE_SECRET_KEY= ``` Egress is permissive by default: proxied requests to hosts without a rule pass through untouched, which is usually fine because agents hold only placeholders. If the broker should refuse anything that does not match a rule, set [`@proxyConfig={egress="strict"}`](/guides/proxy/rules/#egress-modes) in the schema header. ### Set up the broker [Section titled “Set up the broker”](#set-up-the-broker) Set up the broker once; its filesystem persists. The steps use the sprite CLI for readability, but each one is a plain REST call (the `sprite api` steps show the shape), so an orchestrator can drive the same setup from code. ```bash # one data-plane token, shared by the broker and every agent; generate it yourself # (or let the broker mint one and read it back with `varlock proxy token`). It is # the credential to USE the broker over the tunnel, not to read its secrets, and # it travels as an env var rather than an argument so it stays out of process # listings and logs. export PROXY_TOKEN=$(uuidgen) # sprites ship Node 24+, so npm works. Node lives under nvm here, and nvm's bin # dir is only on the PATH in login shells; ~/.local/bin is always on the PATH, # so the symlink makes varlock visible to services and `sprite exec`. sprite create varlock-broker sprite exec -s varlock-broker -- sh -c 'npm i -g varlock && ln -sf "$(npm prefix -g)/bin/varlock" ~/.local/bin/varlock' # upload the schema (plus any other .env files your project loads); # real values ride the service env instead sprite exec -s varlock-broker --dir /home/sprite/proj \ --file .env.schema:/home/sprite/proj/.env.schema -- true # run the proxy as a sprite service: it restarts on wake, and http_port routes # the sprite's URL to it (waking the sprite when a connection arrives). # --persist-ca keeps the CA on the persistent filesystem so a wake or restart # doesn't invalidate agents that already trust it. --allow-reload lets you apply # schema edits later without a restart; the reload channel is only reachable # from inside the sprite, not by agents. Schema keys resolve from the process # env, so the env map carries the bootstrap: usually just your plugin's # secret-zero (shown: a 1Password service account). sprite api /v1/sprites/varlock-broker/services/varlock-proxy -- -X PUT \ -H 'Content-Type: application/json' -d @- < ``` The data-plane token gates the tunnel, so a public URL is not usable by whoever finds it. Be precise about what the tunnel service itself can see, though, because it terminates the outer TLS: it observes the WebSocket handshake, which carries that token, the `CONNECT` metadata naming each upstream host, and the placeholder env the agent bootstraps. What it does not see is your real secrets, or the contents of proxied HTTPS requests, which ride an inner TLS session between the agent and your proxy. Plain HTTP has no inner session: it crosses the tunnel in absolute form, so a terminating service reads those requests in full. The proxy fails closed rather than injecting a secret into a cleartext connection, so what is exposed there is traffic carrying no injected secret. Pick a tunnel service you would trust with the token, or skip the public hop entirely with the 6PN and WireGuard options under [Raw Fly Machines](#raw-fly-machines) below, which expose nothing publicly (those two are not yet validated end to end). See the [topologies overview](/sandboxes/overview/#topologies) for the full menu. Compared to Sprites Connectors [Connectors](https://sprites.dev/api/connectors) are Sprites’ native credential feature: credentials live in your Sprites org, and sprites call APIs through `api.sprites.dev/v1/gateway/...` URLs where the gateway injects the real credential. The varlock proxy covers the same ground and more: * **Seamless.** Substitution happens transparently at the wire, so SDKs, CLIs, and agent tools work unmodified. Connectors rewrite the base URL, so anything calling an API has to be changed to point at the gateway. * **Any source.** Secrets come from wherever you already keep them through [plugins](/guides/plugins/) (1Password, Vault, AWS, Doppler, …), rather than being copied into one platform’s store, and they stay in your custody. * **One schema.** Your `.env.schema` is a single declarative layer describing every value, its type, and its routing rules, and it is legible to both people and agents. Connector policy lives in the platform’s API, separate from your project. * Plus response scrubbing, your own audit log, and the same setup on any other platform. ## Raw Fly Machines [Section titled “Raw Fly Machines”](#raw-fly-machines) If you run agent workloads on [Fly Machines](https://fly.io/docs/machines/) directly (custom OCI images, existing Fly infra), the varlock basics carry over with a few Machines-specific facts: * Env goes in at create time (`config.env`, or `--env` on `fly machine run`); there is no per-exec env. [Fly app secrets](https://fly.io/docs/apps/secrets/) are app-level, delivered as plain env at boot, and setting them restarts every machine in the app. * The Machines exec API buffers output and caps commands at 60 seconds; run real workloads as the machine’s boot command. * Bake varlock into your image (see the [Docker guide](/integrations/docker/)) and wrap the entrypoint in `varlock run`. * Machines have no destination-level egress filtering (network policies are port/protocol only), so there is no Sprites-style “only the broker” clamp. Machines also open two proxy topologies that Sprites cannot use, because machines sit on your org’s private IPv6 network (6PN), which carries any port with no HTTP ingress in the path: a broker machine on a dedicated [custom network](https://fly.io/docs/networking/custom-private-networks/) is reachable directly at `ws://.internal:8080` (bind with `--expose=::`; 6PN is IPv6), and [WireGuard peering](https://fly.io/docs/blueprints/connect-private-network-wireguard/) is bidirectional, so machines can dial a proxy on your laptop at `._peer.internal` with no tunnel service and nothing exposed publicly. We have not yet validated these end to end the way the sprite recipes above are; if you want them written up as full recipes, [tell us](https://github.com/dmno-dev/varlock/discussions). # Minimal > Wire the Minimal sandbox through varlock proxy ## Minimal [Section titled “Minimal”](#minimal) [Minimal](https://minimal.dev/) runs tasks in an isolated sandbox ([task sandbox](https://docs.minimal.dev/concepts/sandboxing)). Start varlock on the **host**, pass the proxy/CA/placeholder environment into the Minimal task, then run the agent inside Minimal so outbound HTTPS goes through varlock. ```bash eval "$(varlock proxy env)" minimal run claude # or: minimal run ``` `varlock proxy env` prints the child view for the active session: placeholder values for proxied secrets, plus `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`, `NO_PROXY`, and CA trust vars (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, and related). Sourcing it before `minimal run` forwards that environment when Minimal inherits the launching shell’s env. One-shot alternative ```bash varlock proxy run -- minimal run claude ``` Prefer `proxy start` + `proxy env` when you want a durable session log while you iterate on the sandbox task. Confirm the agent sees placeholders, not real secrets: ```bash # inside the Minimal task / agent shell, after env was passed through printenv ANTHROPIC_API_KEY # expect something like sk-ant-api03-0000… (your @placeholder), not the vault value ``` ### Verify on your install [Section titled “Verify on your install”](#verify-on-your-install) Minimal’s isolation mode affects whether the guest can reach the host proxy: * **Linux process sandboxing** that still shares host loopback can use `HTTP_PROXY=http://127.0.0.1:` as emitted by `proxy env`. * **microVM** isolation typically cannot reach the host’s `127.0.0.1`. Varlock’s proxy currently binds loopback only; non-loopback “sandbox bridging” is not available yet. If `minimal run` cannot connect through the proxy on your platform, that is the gap to watch, not a misconfigured schema. If HTTPS clients inside the sandbox fail TLS handshake, confirm the CA bundle vars from `proxy env` were actually present in the task environment (not only on the host shell that launched Minimal). ### Claude Code and host key pinholes [Section titled “Claude Code and host key pinholes”](#claude-code-and-host-key-pinholes) Minimal’s `claude-code` package can wire host paths such as `~/.claude` into the task sandbox so Claude can keep state and find an API key on disk. That pinhole is useful for agent state, but it undercuts credential isolation if the real key lives there. For secrets you mark with `@proxy`, prefer the placeholder + proxy path above, and avoid relying on a real key file inside the sandbox for those same credentials. # Modal > Using varlock with Modal sandboxes, from resolving and validating sandbox env vars to running the credential proxy so agent sandboxes only hold placeholders. [Modal](https://modal.com) runs code in gVisor-isolated cloud sandboxes, commonly as the execution layer for AI agents. It already has a secret store: [`modal.Secret`](https://modal.com/docs/guide/secrets) holds a dictionary of environment variables and injects them through `secrets=[...]` on `Sandbox.create()` and on each `exec`. That is the right tool for workloads you trust with the credentials they use. For agentic workloads it leaves a gap, because the agent ends up holding the real key in `os.environ`. The recommended shape there is the [broker sandbox](#credential-proxy-the-broker-sandbox): one sandbox runs the [credential proxy](/guides/proxy/) and holds the real secrets, and agent sandboxes route through it holding only [placeholders](/guides/proxy/rules/#placeholders). Modal is a good fit for this because both seams varlock needs are first-class: env injection at sandbox creation, and an egress allowlist that can pin an agent to a single host and be tightened while the sandbox is running. ## Passing resolved values [Section titled “Passing resolved values”](#passing-resolved-values) Run the orchestrator under [`varlock run`](/reference/cli/load-and-run/) (or import [`varlock/auto-load`](/integrations/javascript/) if it is Node): varlock resolves your values (plugins, `.env.local`, etc.), validates them against your schema, and redacts them in the orchestrator’s logs. Then hand the sandbox a scoped subset. orchestrate.py ```python import subprocess import modal # one blob with the resolved env, scoped by --filter to what this sandbox # should see env_blob = subprocess.run( ["varlock", "load", "--format", "json-full", "--compact", "--filter", "STRIPE_*,SENTRY_DSN"], capture_output=True, text=True, check=True, ).stdout.strip() app = modal.App.lookup("my-agents", create_if_missing=True) sb = modal.Sandbox.create( "sleep", "infinity", app=app, secrets=[modal.Secret.from_dict({ # a Node app that imports varlock hydrates process.env, the ENV object, # and log redaction from the blob - no .env files or CLI in the sandbox "__VARLOCK_ENV": env_blob, "_VARLOCK_USE_INJECTED_ENV": "1", # for workloads that don't import varlock, enumerate plain vars instead })], ) ``` This is the standard Modal posture: sandboxes hold real values, and the values transit Modal’s control plane. Consuming the blob needs either the `varlock` npm package (Node 22.3+) or the varlock CLI (`varlock run -- ` injects plain env vars from the blob, for any workload). See [`_VARLOCK_USE_INJECTED_ENV`](/reference/reserved-variables/#_varlock_use_injected_env). ## Credential proxy: the broker sandbox [Section titled “Credential proxy: the broker sandbox”](#credential-proxy-the-broker-sandbox) One long-lived sandbox (the broker) runs `varlock proxy start --expose`, which serves the built-in WebSocket tunnel on its proxy port. Modal’s `encrypted_ports` tunnel carries it. Agent sandboxes reach it with `varlock proxy run --url`, which self-wires their placeholder env and CA certs from the broker over the tunnel. The proxy injects real values into requests at the wire, on verified TLS connections to hosts your schema allows, with every request checked against your [`@proxy` rules](/guides/proxy/rules/#routing-rules) and recorded in the [audit log](/guides/proxy/running/#auditing). A compromised or prompt-injected agent can exfiltrate nothing but placeholders. ```plaintext [agent sandbox] [broker sandbox] varlock proxy run --url ── wss ──▶ proxy :8080 + tunnel (egress pinned to the broker) (encrypted_ports tunnel URL) ``` Modal client version The egress controls below need **modal 1.5 or newer**, which requires **Python 3.10+**. Installing under an older Python (macOS still ships 3.9 as `python3`) silently resolves to modal 1.2.x, which has only `block_network` and a legacy `cidr_allowlist`: no domain allowlist and no live policy update. Check with `python -c "import modal; print(modal.__version__)"`. Use the `sb.filesystem.*` calls for sandbox files (`make_directory`, `write_text`, `read_text`). The older top-level `sb.mkdir()` / `sb.open()` are deprecated and, per Modal’s [filesystem migration guide](https://modal.com/docs/guide/migrate-sandbox-filesystem), are removed in 1.6.0. Some workspaces already refuse them server-side before then (`ConflictError: The legacy Sandbox filesystem API is no longer supported`, seen on 1.5.4), so migrate rather than relying on the version boundary. Note the argument order in the new API: `write_text(contents, remote_path)`. ### Schema setup [Section titled “Schema setup”](#schema-setup) Mark the secrets your agents use with [`@proxy(domain=...)`](/reference/item-decorators/#proxy) and give each one an explicit [`@placeholder`](/reference/item-decorators/#placeholder): .env.schema ```env-spec # @proxy(domain="api.anthropic.com") # @placeholder=sk-ant-api03-000000000000000000000000 ANTHROPIC_API_KEY= # @proxy(domain="api.stripe.com") # @placeholder=sk_test_00000000000000000000000000 STRIPE_SECRET_KEY= ``` An explicit `@placeholder` is optional (the sandbox pulls whatever the schema produces from the broker), but worth setting when an SDK checks the key format client-side: a realistic-looking placeholder passes that check where a generic `vlk_placeholder_…` would not. Egress is permissive by default: proxied requests to hosts without a rule pass through untouched, which is usually fine because agents hold only placeholders. If the broker should refuse anything that does not match a rule, set [`@proxyConfig={egress="strict"}`](/guides/proxy/rules/#egress-modes) in the schema header. ### Start the broker [Section titled “Start the broker”](#start-the-broker) orchestrate.py ```python import os, secrets, urllib.parse import modal PROXY_PORT = 8080 VARLOCK = "/root/.config/varlock/bin/varlock" PROJ = "/root/proj" # One data-plane token, shared by the broker and every agent; generate it # yourself (or let the broker mint one and read it back with `varlock proxy # token`). It is the credential to USE the broker over the tunnel, not to read # its secrets. PROXY_TOKEN = secrets.token_hex(16) app = modal.App.lookup("my-agents", create_if_missing=True) image = modal.Image.debian_slim().apt_install("curl", "ca-certificates") broker = modal.Sandbox.create( "sleep", "infinity", app=app, image=image, workdir=PROJ, encrypted_ports=[PROXY_PORT], timeout=60 * 60, ) broker.exec("bash", "-lc", f"mkdir -p {PROJ} && curl -sSfL https://varlock.dev/install.sh | sh -s").wait() # upload the schema (plus any other .env files your project loads); # real values arrive via secrets below instead with open(".env.schema") as f: broker.filesystem.write_text(f.read(), f"{PROJ}/.env.schema") # Schema keys resolve from the process env, so this Secret carries the # bootstrap: usually just your plugin's secret-zero (shown: a 1Password # service account). broker_secrets = [modal.Secret.from_dict({ "VARLOCK_PROXY_TOKEN": PROXY_TOKEN, "OP_SERVICE_ACCOUNT_TOKEN": os.environ["OP_SERVICE_ACCOUNT_TOKEN"], })] # start the proxy bound off-loopback so the tunnel is reachable. --persist-ca # reuses the CA across broker restarts, so agents that already trust it keep # working. --allow-reload lets you apply schema edits later without a restart; # the reload channel is only reachable from inside the broker, not by agents. broker.exec("bash", "-lc", f"cd {PROJ} && setsid nohup {VARLOCK} proxy start --expose --port {PROXY_PORT} " f"--cert-dir {PROJ}/.varlock-ca --persist-ca --allow-reload " f"> /root/proxy.log 2>&1 < /dev/null & echo launched", secrets=broker_secrets, ).wait() # ready once the port answers (a bare GET returns 400, which is fine; we only # need the listener up, so no -f) broker.exec("bash", "-lc", f"until curl -s -o /dev/null --proxy '' http://127.0.0.1:{PROXY_PORT}; do sleep 0.3; done", ).wait() broker_url = broker.tunnels()[PROXY_PORT].url broker_host = urllib.parse.urlparse(broker_url).hostname ``` The `modal.Secret` here carries whatever bootstraps your schema. With secrets resolved from a manager via a [plugin](/guides/plugins/) (the usual setup), that is one service-account token, the secret zero, and the schema resolves everything else inside the broker. If some values exist only on your side, enumerate them instead (`"ANTHROPIC_API_KEY": ...`), so they are the orchestrator’s own resolved values passing through. Either way agent sandboxes hold no real secrets at all. ### Start agent sandboxes [Section titled “Start agent sandboxes”](#start-agent-sandboxes) An agent needs nothing but varlock and `proxy run --url`. It pulls its placeholder env and CA certs from the broker over the tunnel, so there is no env or cert plumbing to pass. Create it **pre-armed**: both allowlists have to be initialized at creation to stay updatable later, and the agent needs open egress briefly to install varlock. orchestrate.py (continued) ```python agent = modal.Sandbox.create( "sleep", "infinity", app=app, image=image, workdir=PROJ, timeout=60 * 60, # pre-armed so the policy can be tightened while it runs outbound_domain_allowlist=["*"], outbound_cidr_allowlist=["0.0.0.0/0"], ) agent.exec("bash", "-lc", f"mkdir -p {PROJ} && curl -sSfL https://varlock.dev/install.sh | sh -s").wait() # now clamp: the agent may reach the broker tunnel and nothing else agent._experimental_set_outbound_network_policy( outbound_domain_allowlist=[broker_host], outbound_cidr_allowlist=[], ) # the token rides a Secret rather than the command line, so it stays out of # process listings agent.exec("bash", "-lc", f"cd {PROJ} && {VARLOCK} proxy run --url wss://{broker_host} -- your-agent-command", secrets=[modal.Secret.from_dict({"VARLOCK_PROXY_TOKEN": PROXY_TOKEN})], ).wait() ``` To see what agents are doing, run [`varlock proxy audit`](/reference/cli/proxy/) (or `proxy status --watch`) inside the broker: every request records its host, path, decision, and which keys were injected. ### Lock down agent egress [Section titled “Lock down agent egress”](#lock-down-agent-egress) The clamp above is what turns “the agent holds placeholders” into “the agent cannot talk to anything except varlock policy”. Modal enforces it outside the sandbox, so nothing the agent does from inside can lift it. A few details matter: **Pin the exact tunnel host.** Modal tunnel hostnames look like `ta---.w.modal.host`: per-sandbox, with a random component, under a shared apex. Entries without a `*.` prefix match that one host only, which is what you want. Do **not** allowlist `*.modal.host` or `*.w.modal.host`: that opens every Modal tunnel in every workspace, which is an exfiltration path. **Clamp after provisioning, not at creation.** `outbound_domain_allowlist` only permits TLS on port 443, and once clamped to the broker the agent can no longer reach `varlock.dev` to install. Install first, then tighten. Baking varlock into a [custom image](https://modal.com/docs/guide/custom-container) skips the window entirely, which matters most when a fleet spawns many sandboxes. **Pre-arm both lists.** A list that starts empty or unset cannot be updated later, and `block_network=True` is incompatible with the allowlists. Start with `["*"]` and `["0.0.0.0/0"]` and narrow from there. To cut a sandbox off completely, set both to empty rather than reaching for `block_network`. **Non-TLS traffic needs CIDR rules.** Domain entries cover TLS on 443 only; raw TCP, UDP, and plain HTTP are matched by `outbound_cidr_allowlist`. Leaving it empty, as above, blocks all of it. The JS SDK exposes the same control as `updateNetworkPolicy()`. ### Trust model [Section titled “Trust model”](#trust-model) Be clear-eyed about what this shape protects against. The broker holds real secrets inside Modal’s cloud, so Modal’s infrastructure is inside your trust boundary, same as it would be for secrets passed to any sandbox. What changes is the blast radius on your side: agents never hold secrets, so a compromised agent sandbox yields placeholders and only whatever requests your rules and egress mode allow. Rotation, policy, and audit live in one place instead of N sandboxes. Modal helps here in one respect worth naming: sandboxes are not authorized to access other resources in your Modal workspace, so a compromised agent cannot call `Secret.from_name()` to reach your other secrets. That bounds the damage to what the sandbox was given, which is exactly the thing varlock reduces to placeholders. Three practical notes: * No human is attached to the broker, so its policy must run unattended: allow rules, `block` rules, `@proxy=omit`, and strict egress. To change policy, write the edited schema into the broker and run `${VARLOCK} proxy reload`: the proxy validates the edit in its own context before applying, and a broken edit is refused and reported back. Rule changes apply to agent traffic immediately; a newly added key shows up for newly started `proxy run` commands. * The token authenticates the tunnel and, over it, unlocks the placeholder env an agent adopts. Agents hold it deliberately; it is the credential to *use* the broker, not to read its secrets, which never leave it. Treat it like any shared secret (rotate by restarting the broker with a new one). * A broker sandbox is a single point of failure for its fleet. Manage its lifetime explicitly (`timeout`, `idle_timeout`); `proxy run --url` opens a fresh tunnel per connection, so transient blips recover, and `--persist-ca` above keeps the CA stable across a broker restart. Reserve that flag for brokers: it writes the CA private key to disk, which is only reasonable because that machine already holds your real secrets. ## Other topologies [Section titled “Other topologies”](#other-topologies) For local development, run the proxy on your machine instead: secrets, resolver plugins, biometric unlock, and the interactive request log stay local. Expose it through any tunnel service that carries WebSockets and reuse the same agent-side command: ```bash export VARLOCK_PROXY_TOKEN=$(uuidgen) varlock proxy start --expose --port 8080 ngrok http 8080 # or cloudflared, Tailscale funnel, ... # agents: VARLOCK_PROXY_TOKEN=… varlock proxy run --url wss://abc123.ngrok.app -- ``` The data-plane token gates the tunnel, so a public URL is not usable by whoever finds it. Be precise about what the tunnel service itself can see, though, because it terminates the outer TLS: it observes the WebSocket handshake, which carries that token, the `CONNECT` metadata naming each upstream host, and the placeholder env the agent bootstraps. What it does not see is your real secrets, or the contents of proxied HTTPS requests, which ride an inner TLS session between the agent and your proxy. Plain HTTP has no inner session: it crosses the tunnel in absolute form, so a terminating service reads those requests in full. The proxy fails closed rather than injecting a secret into a cleartext connection, so what is exposed there is traffic carrying no injected secret. Pick a tunnel service you would trust with the token, or use the platform’s private networking where it exists. The same pattern reaches a proxy on any infrastructure you run; see the [topologies overview](/sandboxes/overview/#topologies). Compared to modal.Secret `modal.Secret` and the varlock proxy solve different halves of the problem, and they compose: the broker’s bootstrap above is itself a `modal.Secret`. Where they differ: * **The agent never holds the key.** `secrets=[...]` injects real values as environment variables, so anything running in the sandbox can read and exfiltrate them. varlock hands the agent a placeholder and substitutes at the wire. * **Custody stays where you keep it.** Secrets resolve from your existing manager through [plugins](/guides/plugins/) (1Password, Vault, AWS, Doppler, …) instead of being copied into Modal’s control plane. Rotation happens at the source, with no redeploy. * **One schema.** Your `.env.schema` describes every value, its type, and its routing rules in one declarative layer that is legible to both people and agents, rather than a dictionary of strings. * Plus per-request policy on host, path, and method, [hot reload](/guides/proxy/running/#editing-the-schema-while-a-session-is-running), response scrubbing, your own audit log, and the same setup on any other platform. # MXC (Windows) > Wire MXC through varlock proxy on Windows ## MXC (Windows) [Section titled “MXC (Windows)”](#mxc-windows) [Microsoft eXecution Containers (MXC)](https://github.com/microsoft/mxc) is an open-source (MIT), policy-driven sandbox SDK. On Windows 11 24H2+ its default backend is `processcontainer` (AppContainer-style process isolation). It does **not** broker credentials; it can force outbound HTTP(S) through a localhost proxy, which is the hook for varlock. Preview MXC is an early public preview. Microsoft documents that some generated policies are still overly permissive and should not be treated as a hard security boundary yet. Pair it with varlock for credential isolation, and keep tightening MXC filesystem policy as the project matures. ### Install [Section titled “Install”](#install) Requires Windows 11 24H2+ (build 26100+) for `processcontainer`, and Node.js 18+. ```powershell npm install @microsoft/mxc-sdk ``` ### Wire MXC through varlock [Section titled “Wire MXC through varlock”](#wire-mxc-through-varlock) 1. Start the proxy on the host with a fixed port, so `mxc-agent.json` can name it up front: ```powershell varlock proxy start --port 54321 ``` 2. In PowerShell, load placeholders + CA trust from JSON (bash-style `eval "$(varlock proxy env)"` is for Unix shells; on Windows prefer `--format json`): ```powershell $envMap = varlock proxy env --format json | ConvertFrom-Json $envMap.PSObject.Properties | ForEach-Object { Set-Item -Path "Env:$($_.Name)" -Value $_.Value } ``` 3. Point MXC’s process-container network at that port. MXC’s [external proxy example](https://github.com/microsoft/mxc/blob/main/docs/examples.md) routes sandbox traffic to `localhost:`: mxc-agent.json ```json { "script": "claude", "timeout": 0, "processContainer": { "name": "varlock-agent", "capabilities": ["internetClient"] }, "filesystem": { "readwritePaths": ["C:\\path\\to\\your\\project"] }, "network": { "proxy": { "localhost": 54321 } } } ``` Match the port to the `--port` you chose in step 1, and set `readwritePaths` to your project. Keep `@proxyConfig={egress="strict"}` in `.env.schema` so only declared hosts get secrets. Host allow/block lists are not enforced on Windows yet in MXC; varlock’s egress rules cover injection scope. 4. Launch with a small Node script (or `wxc-exec.exe` if you build the native binary from the [MXC repo](https://github.com/microsoft/mxc)): run-sandboxed-agent.ts ```typescript import { readFileSync } from 'node:fs'; import { spawnSandboxFromConfig } from '@microsoft/mxc-sdk'; const proxyUrl = process.env.HTTPS_PROXY; if (!proxyUrl) { throw new Error('Load varlock proxy env first (HTTPS_PROXY missing)'); } const port = Number(new URL(proxyUrl).port); const config = JSON.parse(readFileSync('./mxc-agent.json', 'utf8')); config.network = { proxy: { localhost: port } }; config.script = process.argv.slice(2).join(' ') || config.script; const child = spawnSandboxFromConfig(config, { usePty: true }); child.on('exit', (code: number | null) => process.exit(code ?? 1)); ``` ```powershell npx tsx run-sandboxed-agent.ts claude ``` The child inherits the placeholder + CA environment you set in PowerShell. MXC’s `network.proxy` steers AppContainer egress at varlock on loopback, which the host process can reach. WSL on Windows If you already develop under WSL2, you can run the [Fence](/sandboxes/fence/) or [bubblewrap](/sandboxes/bubblewrap/) recipes inside the distro instead. Start `varlock proxy` in the same WSL environment (or confirm the Windows-side loopback address the distro can reach) so `HTTP_PROXY` still points at a reachable proxy. # Sandboxes Overview > Recipes for wiring third-party sandboxes through the varlock credential proxy These are recipes for running an agent inside a third-party sandbox while varlock holds the real credentials. The sandbox contains the agent; the [credential proxy](/guides/proxy/) gives it placeholders and swaps in real secrets only on verified connections to hosts you allow. Read the [sandboxing guide](/guides/proxy/sandboxing/) first. It covers why the two layers belong together, varlock’s own [built-in `--sandbox`](/guides/proxy/sandboxing/#built-in-sandbox) (macOS jail or a container, no third-party install), and the [shared setup](/guides/proxy/sandboxing/#shared-setup) most local-tool recipes below assume. Filesystem, process, and network isolation varies by tool, so check each recipe for its actual boundaries. Most don’t broker credentials themselves, so varlock stays the wire injector. Reach for one when you already run that tool, need Windows, or want a different isolation model than the built-in sandbox offers. ## Topologies [Section titled “Topologies”](#topologies) There are a handful of ways to combine varlock with sandboxed agents, ordered here by how far real secrets stay from the agent. The provider guides below each bless one path (usually the broker instance) rather than documenting all of them. 1. **Inject resolved values.** Run the orchestrator under [`varlock run`](/reference/cli/load-and-run/) and pass values through the provider’s env machinery (or install varlock inside the sandbox and let it resolve its own). You get resolution, validation, and log redaction; the sandbox holds real values. Right for workloads you trust with the credentials they use. 2. **Proxy inside the agent’s own sandbox.** Running [`proxy run`](/guides/proxy/) on the same host as an untrusted agent is **not recommended**: the agent and the broker share a machine, so the boundary is a bar-raiser, not a wall (see [why sandbox + proxy](/guides/proxy/sandboxing/#why-sandbox--proxy)). Use a broker instance instead. 3. **Broker instance.** The proxy runs in one dedicated sandbox holding the real secrets (or a plugin secret-zero); agent sandboxes connect over the [built-in tunnel](/guides/proxy/running/#remote-proxy-start---expose--proxy-run---url) and hold only placeholders. The recommended shape for cloud fleets, and what each provider guide shows. 4. **Proxy on infrastructure you run.** The same broker shape, but on a VM or container platform you already operate, reachable at a URL that carries WebSockets. Useful when one broker should serve fleets across providers, or outlive any one provider’s sandbox lifecycle. 5. **Proxy on your machine.** For the dev loop: secrets, resolver plugins, biometric unlock, and the interactive request log stay local, and sandboxes reach the proxy through a tunnel service (or the platform’s private networking where it exists). ## Cloud sandboxes [Section titled “Cloud sandboxes”](#cloud-sandboxes) Cloud sandbox providers run the agent in a remote VM, so the proxy is reached over the built-in tunnel ([`proxy start --expose` + `proxy run --url`](/reference/cli/proxy/)) instead of loopback. Each guide leads with the recommended path for that platform and keeps the rest short. [E2B ](/sandboxes/e2b/)Broker sandbox or tunnel to your machine; egress lockdown via network rules [Fly.io ](/sandboxes/flyio/)Broker sprite or tunnel to your machine; egress lockdown via network policy [Modal ](/sandboxes/modal/)Broker sandbox or tunnel to your machine; egress pinned to the broker host, clampable while running ## Local tools [Section titled “Local tools”](#local-tools) These run on your own machine. Most reach the proxy over loopback via the [shared setup](/guides/proxy/sandboxing/#shared-setup). Docker Sandboxes is the exception: a local microVM with its own egress gateway, so its agent reaches the proxy over the tunnel (like the cloud shapes above), and its guide differs accordingly. [Docker Sandboxes ](/sandboxes/docker-sandboxes/)Local microVMs (sbx); agent reaches a host or remote broker through the gateway. macOS, Windows, Linux [Minimal ](/sandboxes/minimal/)Task / microVM or process sandbox. macOS, Linux [smolvm ](/sandboxes/smolvm/)libkrun microVM; guest loopback reaches the host proxy. macOS, Linux, Windows [Fence ](/sandboxes/fence/)Seatbelt / bubblewrap + domain allowlist. macOS, Linux [yolobox ](/sandboxes/yolobox/)Container, home dir stays on the host. macOS, Linux [Agent Safehouse ](/sandboxes/agent-safehouse/)Deny-first Seatbelt profiles. macOS [bubblewrap ](/sandboxes/bubblewrap/)Linux namespaces (DIY). Linux [MXC ](/sandboxes/mxc/)Windows AppContainer (processcontainer). Windows 11 Some tools already broker credentials at their own network boundary (microsandbox, Anthropic `srt` credential `mask`, and similar). You can use those on their own; varlock adds schema-driven policy, response scrubbing, your own audit log, and custody in your secret manager on top. [Docker Sandboxes](/sandboxes/docker-sandboxes/) is one such tool with a guide here, because its policy can also route the agent through a varlock broker (host or remote), keeping varlock as the injector. # smolvm > Wire smolvm microVMs through varlock proxy ## smolvm [Section titled “smolvm”](#smolvm) [smolvm](https://smolmachines.com/) ([GitHub](https://github.com/smol-machines/smolvm)) runs workloads in hardware-isolated microVMs (libkrun, own guest kernel) with fast boots and network off by default. Start varlock on the **host**, pass the proxy/CA/placeholder environment into the VM, and mount the proxy CA so HTTPS clients inside trust it. The agent holds only placeholders; real secrets are injected on the wire by the host proxy. This recipe was verified against smolvm 1.7.1 on macOS. See [caveats](#caveats) for two version-specific notes that matter here. ### Recipe [Section titled “Recipe”](#recipe) Follow the [shared setup](/guides/proxy/sandboxing/#shared-setup), then start a session with a pinned port and CA location (the VM’s env is set at creation with `-e`, and the CA dir must be a mountable path): ```bash varlock proxy start --port 18080 --cert-dir ./.varlock-proxy ``` Turn the session’s child view into `-e` flags and run the VM. [`varlock proxy env --full`](/reference/cli/proxy/#proxy) prints the complete env a proxied agent runs with: placeholder values for proxied secrets, proxy vars (`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`, `NO_PROXY`), and CA trust vars, with `--cert-dir /varlock-ca` repointing the CA paths at where the guest will see the mount: ```bash env_flags=() while IFS= read -r kv; do env_flags+=(-e "$kv"); done \ < <(varlock proxy env --full --cert-dir /varlock-ca --format json \ | jq -r 'to_entries[] | "\(.key)=\(.value)"') smolvm machine run --net \ --image your-agent-image \ --volume ./.varlock-proxy:/varlock-ca \ "${env_flags[@]}" \ -- claude ``` `--image` must contain your agent command (smolvm boots OCI images or local rootfs archives; it can’t know your toolchain). Images pull from inside the VM, so the first run needs network. Why `HTTPS_PROXY=http://127.0.0.1:18080` works from inside a VM: with plain `--net`, smolvm uses libkrun’s TSI backend (syscall-level socket interception), so guest sockets are executed host-side and the guest’s `127.0.0.1` **is** the host’s loopback. No forwarder, sidecar, or tunnel is needed. This is the cleanest guest-to-host-proxy path of any sandbox we have integrated. Confirm the agent sees placeholders, not real secrets: ```bash smolvm machine run --net --image your-agent-image "${env_flags[@]}" -- printenv ANTHROPIC_API_KEY # expect your @placeholder value (sk-ant-api03-0000...), not the vault value ``` On a proxied request you’ll see `inject: ` in the host session log, and responses that echo the secret back are scrubbed to the placeholder before the guest sees them. ### Caveats [Section titled “Caveats”](#caveats) Two things to know about smolvm’s networking backends, verified on 1.7.1: 1. **This recipe gives credential isolation, not egress lockdown.** smolvm’s egress controls (`--allow-host`, `--allow-cidr`, `--outbound-localhost-only`) are enforced by its virtio-net gateway backend, and passing any of them switches the VM to that backend (correctly so: the TSI backend does not enforce them). Under plain `--net` (TSI), the guest can reach the network directly, bypassing the proxy. The agent still never holds real secrets, but a misbehaving one could exfiltrate data over its own connections. This is the same tier as credential-only setups; treat egress as open. 2. **You can’t yet combine enforced egress with the host proxy.** Under the virtio-net gateway, guest `127.0.0.1` is the guest’s own loopback, and the intended host-access path (dialing the gateway IP `100.96.0.1`, which the relay redirects to host loopback) shipped upstream after the 1.7.1 release ([smol-machines/smolvm#784](https://github.com/smol-machines/smolvm/pull/784), merged 2026-07-30). On a release containing that fix, the locked-down shape becomes: `--allow-cidr 100.96.0.1/32` (egress only to the gateway IP) plus `varlock proxy env --full --proxy-url http://100.96.0.1:18080 --cert-dir /varlock-ca`. Know what that allowlist admits: the redirect preserves the destination port, so the guest can reach **any TCP service bound to host loopback** on its own port (a local database, another dev server), not just the varlock proxy. That is the same loopback-wide egress surface as varlock’s built-in [`--sandbox`](/guides/proxy/sandboxing/#built-in-sandbox) jail (which allows `localhost:*` too), and the same advice applies: keep sensitive loopback services authenticated. Port-scoped allow rules in smolvm would tighten this to proxy-only. Also note smolvm’s own `--secret-env KEY=HOST_VAR` / `--secret-file` flags copy **real** values into the guest env. For secrets you mark with [`@proxy`](/reference/item-decorators/#proxy), use the placeholder path above instead so the real value never enters the VM. ### smol cloud [Section titled “smol cloud”](#smol-cloud) smolmachines’ hosted offering creates machines over a REST API with env vars at creation and a `network` policy (`blocked` by default, `open`, or `allowCidrs` which accepts hostnames). That is the standard remote shape covered in the [sandboxes overview](/sandboxes/overview/): run a [broker instance](/sandboxes/overview/#topologies) or expose your local proxy over the [built-in tunnel](/guides/proxy/running/#remote-proxy-start---expose--proxy-run---url), allowlist only the tunnel hostname in `allowCidrs`, and pass the placeholder env at machine creation. We have not yet published a verified cloud recipe. ### Reference [Section titled “Reference”](#reference) * [Credential proxy](/guides/proxy/) * [`varlock proxy`](/reference/cli/proxy/#proxy) CLI * [smolvm README](https://github.com/smol-machines/smolvm) (networking flags, image sources) # yolobox > Launch yolobox with varlock proxy env ## yolobox [Section titled “yolobox”](#yolobox) [yolobox](https://yolobox.dev/) runs the agent inside a container with your project mounted at its real path and your home directory left on the host. It has optional credential *forwarding* (real env into the box); skip that and use varlock placeholders instead. ### Install [Section titled “Install”](#install) ```bash brew install finbarr/tap/yolobox # or: curl -fsSL https://raw.githubusercontent.com/finbarr/yolobox/master/install.sh | bash ``` ### Launch with proxy env [Section titled “Launch with proxy env”](#launch-with-proxy-env) ```bash eval "$(varlock proxy env)" yolobox claude ``` By default yolobox can pass host env into the container. That is what you want for `HTTP_PROXY`, CA paths, and placeholders. Do **not** use yolobox flags that forward real API keys or mount host secret dirs for credentials you already marked `@proxy`. CA path inside the container The CA env vars from `proxy env` point at files on the host, which by default live in a temp dir the container cannot see. yolobox mounts your project at its real path, so start the proxy with the CA inside the project to make those paths valid in the container too: ```bash varlock proxy start --cert-dir ./.varlock-proxy ``` Add `.varlock-proxy/` to `.gitignore`; the cert is public but regenerated per run, so there is no reason to commit it. Container loopback The guest’s `127.0.0.1` is not the host’s. Varlock binds loopback only today, so `HTTP_PROXY=http://127.0.0.1:` from `proxy env` may not reach the host proxy from inside the container. Verify on your runtime (Docker Desktop `host.docker.internal`, Podman host gateway, or Apple container networking). Until varlock supports non-loopback sandbox bridging, treat yolobox + proxy as “works when the container can dial the host proxy address.” For a tighter box, still exclude project secret files you do not want the agent to read: ```bash eval "$(varlock proxy env)" yolobox claude --exclude ".env*" --exclude "secrets/**" ```