varsvars

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 comment

Inside 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() = false

PORT 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.env names: 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)
KeyValueEffect
descriptionstringWhat the variable is for
ownerstringTeam or person responsible
expiresdate (YYYY-MM-DD)vars check warns when approaching
deprecatedstringWarning message shown on use
tags[tag, tag]Organizational labels
seestringLink 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) = us

Declares 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) = free

Using 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 json

If no --param is given, the default from the param declaration is used.

Rules:

  • Each when matches 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 gen does 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

OperatorMeaning
==, !=Equality, inequality
>, <, >=, <=Numeric comparison
and, or, notBoolean logic
=>Implication: "if left is true, right must also be true"

Built-in functions

FunctionDescription
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., region if param region is 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() = 8080

api-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"
  • pick narrows the import to the listed variable names only.
  • omit excludes the listed variable names.
  • Importing the same variable from two different files is a parse error. Use pick/omit to 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.

SegmentWhat it is
encLiteral marker
v2Format version
aes256gcm-detAES-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

SyntaxEffect
env(dev, staging, prod)Declare valid environment names
param name : enum(a, b) = aDeclare 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 = valueNon-secret variable (never encrypted)
NAME : z.schema() = defaultVariable 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:tagEncrypted value (in locked file)
# commentLine or inline comment