File Format
The .vars file format — from simple key-value pairs to full config systems, one level at a time.
A .vars file can be as simple as a .env file or as complex as a full config system. You only use the features you need.
Level 0: Just Like .env
A .vars file can be just key-value pairs.
DATABASE_URL = "postgres://localhost:5432/myapp"
SECRET_KEY = "sk_test_abc123"Every variable is a secret by default. Values are redacted in logs and wrapped in Redacted<T> at runtime.
Comments start with #. Unlike .env files, inline comments are supported too:
# This whole line is a comment
DATABASE_URL = "postgres://localhost:5432/myapp" # inline commentInside a quoted string, # is part of the value and is not stripped.
Level 1: Public Variables and Basic Types
Prefix a variable with public to mark it as non-secret:
public APP_NAME = "my-app"
public PORT : z.number().int().min(1).max(65535) = 3000
public DEBUG : z.boolean() = falsePORT must be an integer between 1 and 65535. DEBUG must be a boolean. Validated at build time and runtime.
Public variables are never encrypted — their values stay plaintext in the committed file regardless of lock state. They generate plain TypeScript types (string, number) rather than Redacted<string>. Use public for config that is safe to expose: port numbers, feature flags, public API keys.
Variables without public are secret by default: encrypted at rest, generated as Redacted<string>.
Level 2: Per-Environment Values
Instead of separate .env.dev, .env.prod files, declare your environments once with env() and put per-environment values inline.
env(dev, staging, prod)
public APP_NAME = "my-app"
public PORT : z.number() = 3000
DATABASE_URL : z.string().url() {
dev = "postgres://localhost:5432/myapp"
staging = "postgres://staging.db:5432/myapp"
prod = enc:v2:aes256gcm-det:abc123:def456:ghi789
}
LOG_LEVEL : z.enum(["debug", "info", "warn", "error"]) = "info" {
dev = "debug"
prod = "warn"
}The env() declaration lists the valid environment names for this file. Any environment name used in the file that isn't listed here is a parse error — this catches typos like stagng or prd at parse time rather than silently producing wrong values at runtime.
A variable can mix a default and environment-specific overrides. LOG_LEVEL defaults to "info" for any environment without an explicit override — staging gets "info" here. Only dev and prod have their own values.
A variable with the same value everywhere stays flat (no braces needed):
public APP_NAME = "my-app"The prod DATABASE_URL is encrypted, so the plaintext never appears in the file or version control.
Level 3: Interpolation
Reference other variables inside string values with ${}. No copy-pasting the same host in three places.
env(dev, prod)
DB_HOST : z.string() {
dev = "localhost"
prod = "prod.db.internal"
}
DB_PORT : z.number() = 5432
DB_NAME = "myapp"
DB_URL : z.string().url() = "postgres://${DB_HOST}:${DB_PORT}/${DB_NAME}"DB_URL resolves from the other variables at load time. Change DB_HOST and DB_URL follows. Circular references are caught.
References resolve per-environment: when DB_URL is resolved for prod, ${DB_HOST} pulls the prod value of DB_HOST. References look up the current file first, then use-imported files.
Interpolation happens at resolution time — during vars run, vars export, and vars check. vars show decrypts encrypted literals in-place but does not resolve interpolation: the template ${DB_PASS} remains as-is in the unlocked file.
Escape a literal ${ with a backslash: \${.
Cross-group references use dot notation:
DB_URL = "postgres://${database.HOST}:${database.PORT}/myapp"Level 4: Groups
Keep related variables together. Groups organize variables and prefix their generated names.
env(dev, prod)
group stripe {
SECRET_KEY : z.string() {
dev = "sk_example_placeholder"
prod = enc:v2:aes256gcm-det:...
}
public PUBLISHABLE_KEY : z.string() {
dev = "pk_example_placeholder"
prod = "pk_live_real"
}
}
group database {
HOST : z.string() = "localhost"
PORT : z.number() = 5432
}Groups produce:
- Flat
process.envnames:STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,DATABASE_HOST,DATABASE_PORT - Nested TypeScript interfaces:
vars.stripe.SECRET_KEY,vars.database.HOST
Inside a group, reference variables from another group using dot notation: ${stripe.SECRET_KEY}. Groups cannot be nested — flat groups only. The public keyword is per-variable, never on the group itself.
Level 5: Arrays, Objects, and Multi-line Values
Not everything is a string.
Arrays and objects
env(dev, prod)
public CORS_ORIGINS : z.array(z.string().url()) {
dev = ["http://localhost:3000"]
prod = ["https://app.example.com", "https://admin.example.com"]
}
FEATURE_FLAGS : z.object({
new_checkout: z.boolean(),
max_upload_mb: z.number()
}) = {
new_checkout: true,
max_upload_mb: 50
}Structured values are serialized to environment variables as JSON strings. The generated TypeScript parses them back to the correct type.
Multi-line strings
Triple-quoted strings for certificates, private keys, and other multi-line content:
TLS_CERT : z.string() {
prod = """
-----BEGIN CERTIFICATE-----
MIIBxTCCAWug...
-----END CERTIFICATE-----
"""
}Leading whitespace is stripped using the column position of the closing """ as the baseline — the same convention as Kotlin. Each line has that many leading characters removed.
Interpolation works inside triple-quoted strings. Use r"""...""" to suppress it:
RAW_TEMPLATE : z.string() = r"""
Hello ${name}, welcome!
"""Level 6: Metadata
Track who owns a variable, when it expires, and why it exists. Annotate a variable with a parenthesized block after its value:
env(dev, prod)
API_KEY : z.string().min(32) {
dev = "sk_example_placeholder_32_chars_long!"
prod = enc:v2:aes256gcm-det:abc:def:ghi
} (
description = "Primary API key"
owner = "backend-team"
expires = 2026-09-01
deprecated = "Migrating to OAuth — use OAUTH_CLIENT_SECRET instead"
tags = [auth, critical]
see = "https://wiki.internal/api-keys"
)Or inline for short annotations:
STRIPE_KEY : z.string() {
dev = "sk_example_placeholder"
prod = enc:v2:aes256gcm-det:...
} (owner = "payments-team", expires = 2026-12-31)| Key | Value | Effect |
|---|---|---|
description | string | What the variable is for |
owner | string | Team or person responsible |
expires | date (YYYY-MM-DD) | vars check warns when approaching |
deprecated | string | Warning message shown on use |
tags | [tag, tag] | Organizational labels |
see | string | Link or reference |
expires is validated. vars doctor warns when a secret is within 30 days of expiry. deprecated shows a warning at build time.
Unknown keys produce a parse warning and are ignored (forward-compatible).
Level 7: Parameters and Conditionals
Sometimes environment isn't enough. Maybe the same prod has different values per region or tenant.
Declaring parameters
env(dev, staging, prod)
param region : enum(us, eu) = usDeclares a parameterized dimension with an enum type and a required default value. Multiple params are supported:
env(dev, staging, prod)
param region : enum(us, eu) = us
param tier : enum(free, pro) = freeUsing conditionals
when blocks switch a value based on a param:
public GDPR_MODE : z.boolean() {
when region = eu => true
else => false
}
DATABASE_URL : z.string().url() {
dev = "postgres://localhost/myapp"
when region = us { prod = "postgres://us-prod.db/myapp" }
when region = eu { prod = "postgres://eu-prod.db/myapp" }
}Conditionals can return a simple value (when ... => value) or contain per-environment overrides (when ... { env = value }). else provides a fallback.
Runtime usage
Pass params at runtime:
vars run --env prod --param region=eu -- node server.js
vars export --env prod --param region=eu --format jsonIf no --param is given, the default from the param declaration is used.
Rules:
- Each
whenmatches exactly one param value. Compound expressions are not supported. - For multi-dimensional matrices (region × tier), use file composition with
use. - Params affect values, not types.
vars gendoes not accept--param.
Level 8: Check Blocks
Write rules about your config. They run at build time and in CI, so bad combinations don't make it to prod.
env(dev, prod)
DEBUG : z.boolean() = false
LOG_LEVEL : z.enum(["debug", "info", "warn", "error"]) = "info"
check "No debug logging in prod" {
env == "prod" => LOG_LEVEL != "debug"
}
check "Debug flag consistency" {
LOG_LEVEL == "debug" => DEBUG == true
}
check "TLS required in prod" {
env == "prod" => defined(TLS_CERT)
}
check "Pool bounds" {
database.POOL_SIZE >= 5 and database.POOL_SIZE <= 200
}Predicates support the following operators and built-in functions. This is a restricted expression language — not JavaScript.
Checks run during vars check and vars run (before spawning). They do not run during vars gen.
Operators
| Operator | Meaning |
|---|---|
==, != | Equality, inequality |
>, <, >=, <= | Numeric comparison |
and, or, not | Boolean logic |
=> | Implication: "if left is true, right must also be true" |
Built-in functions
| Function | Description |
|---|---|
defined(var) | True if the variable has a value for the current env |
matches(var, "regex") | Regex test |
one_of(var, ["a", "b"]) | Enum membership |
length(var) | String or array length |
starts_with(var, "prefix") | String prefix check |
Special variables
env— current environment name ("dev","prod", etc.)- Declared param names — e.g.,
regionifparam regionis declared.
Level 9: File Composition
In a monorepo, you probably have shared infra config that multiple services need. Pull variables from other files with use.
infra.vars — shared by all services:
env(dev, prod)
SHARED_HOST : z.string() {
dev = "localhost"
prod = "shared.internal"
}
public SHARED_PORT : z.number() = 8080api-service.vars — imports what it needs:
env(dev, prod)
use "./infra.vars" { pick: [SHARED_HOST] }
use "../../shared/secrets.vars" { omit: [INTERNAL_DEBUG] }
APP_NAME = "api-service"picknarrows the import to the listed variable names only.omitexcludes the listed variable names.- Importing the same variable from two different files is a parse error. Use
pick/omitto resolve. - A local declaration always shadows an import — intentional override, no error.
- Paths are relative to the importing file's directory.
- Circular imports are caught.
Level 10: Everything Together
Here's what a real service config might look like using most of the features above.
env(dev, staging, prod)
param region : enum(us, eu, ap) = us
use "./infra.vars" { omit: [LEGACY_FLAG] }
# --- App identity ---
public APP_NAME = "payments-service"
public APP_VERSION : z.string() = "2.1.0"
# --- Database ---
group database {
HOST : z.string() {
dev = "localhost"
when region = us { prod = "us-db.payments.internal" }
when region = eu { prod = "eu-db.payments.internal" }
when region = ap { prod = "ap-db.payments.internal" }
}
public PORT : z.number() = 5432
NAME = "payments"
URL : z.string().url() = "postgres://${database.HOST}:${database.PORT}/${database.NAME}"
}
# --- Secrets ---
STRIPE_SECRET_KEY : z.string().startsWith("sk_") {
dev = "sk_test_placeholder_for_local_dev_1234"
prod = enc:v2:aes256gcm-det:a1b2c3:d4e5f6:encrypted_prod_key
} (
description = "Stripe API secret key"
owner = "payments-team"
expires = 2026-12-01
tags = [payments, critical]
)
# --- Feature flags ---
public GDPR_MODE : z.boolean() {
when region = eu => true
else => false
}
public MAX_RETRY : z.number().int().min(0).max(10) = 3 {
dev = 0
}
# --- Invariants ---
check "Encrypted secrets in prod" {
env == "prod" => defined(STRIPE_SECRET_KEY)
}
check "GDPR on in EU" {
when region = eu => GDPR_MODE == true
}A weekend project stays at Level 0. A multi-region production system uses everything. Same syntax either way.
Encrypted Values
In the locked (committed) state, secret values are encrypted blobs:
enc:v2:aes256gcm-det:<iv>:<ciphertext>:<tag>Five colon-separated segments after enc. All three data parts are base64-encoded.
| Segment | What it is |
|---|---|
enc | Literal marker |
v2 | Format version |
aes256gcm-det | AES-256-GCM with deterministic (HMAC-derived) IV |
<iv> | 12-byte IV derived from HMAC-SHA256(key, "VAR_NAME@env:" + plaintext) |
<ciphertext> | The encrypted value |
<tag> | 16-byte authentication tag |
You never write these by hand. vars hide encrypts plaintext values into the file; vars show decrypts them back.
The auth tag catches tampering — decryption fails loudly rather than silently producing corrupted data.
The deterministic IV means the same value for the same variable in the same environment always produces the same ciphertext. Unchanged values produce zero diff on re-encryption. See Encryption & Security for the full model.
Quick Reference
| Syntax | Effect |
|---|---|
env(dev, staging, prod) | Declare valid environment names |
param name : enum(a, b) = a | Declare a parameterized dimension |
use "path" | Import variables from another file |
use "path" { pick: [A, B] } | Import only listed variables |
use "path" { omit: [C] } | Import all except listed variables |
public NAME = value | Non-secret variable (never encrypted) |
NAME : z.schema() = default | Variable with schema and default |
NAME { dev = "a"; prod = "b" } | Per-environment values |
NAME { when p = v => x; else => y } | Param-conditional value |
group name { ... } | Organize variables, prefix generated names |
(description = "...", owner = "...") | Metadata annotation |
"postgres://${DB_HOST}/db" | Interpolation |
""" ... """ | Multi-line triple-quoted string |
["a", "b"] | Array value |
{ key: value } | Object value |
check "desc" { predicate } | Validation rule |
enc:v2:aes256gcm-det:iv:ct:tag | Encrypted value (in locked file) |
# comment | Line or inline comment |