
Every secret in git.
Just one key in prod.
vars is a single config file that holds your secrets — encrypted — alongside schemas and defaults. Commit it to your repo. In CI, set one VARS_KEY and every secret is there. No syncing, no dashboards, no drift.
env(dev, staging, prod)
public PORT : z.number().min(1024).max(65535) = 3000
DATABASE_URL : z.string().url() {
dev = enc:v2:aes256gcm-det:7f3a9b2c:d4e5f6a1:g7h8i9b2
staging = enc:v2:aes256gcm-det:b2c3d4e5:f6g7h8a1:i9j0k1b2
prod = enc:v2:aes256gcm-det:e8d1f0a3:k5l6m7c3:n8o9p0d4
}
API_KEY : z.string().min(32) {
dev = enc:v2:aes256gcm-det:9c2b4f7a:w7x8y9g7:z0a1b2h8
staging = enc:v2:aes256gcm-det:a1b2c3d4:e5f6g7h8:i9j0k1l2
prod = enc:v2:aes256gcm-det:f3e2d1c0:c3d4e5i9:f6g7h8j0
} (description = "Primary API key", expires = 2026-09-01)env(dev, staging, prod)
public PORT : z.number().min(1024).max(65535) = 3000
DATABASE_URL : z.string().url() {
dev = "postgres://localhost:5432/myapp"
staging = "postgres://admin@staging.db.internal:5432/myapp"
prod = "postgres://admin@prod.db.internal:5432/myapp"
}
API_KEY : z.string().min(32) {
dev = "dev_key_a1b2c3d4e5f6g7h8i9j0k1l2m3"
staging = "stg_key_m3l2k1j0i9h8g7f6e5d4c3b2a1"
prod = "prod_key_x9y8w7v6u5t4s3r2q1p0o9n8m7"
} (description = "Primary API key", expires = 2026-09-01)env(dev, staging, prod)
public PORT : z.number().min(1024).max(65535) = 3000
DATABASE_URL : z.string().url() {
dev = enc:v2:aes256gcm-det:7f3a9b2c:d4e5f6a1:g7h8i9b2
staging = enc:v2:aes256gcm-det:b2c3d4e5:f6g7h8a1:i9j0k1b2
prod = enc:v2:aes256gcm-det:e8d1f0a3:k5l6m7c3:n8o9p0d4
}
API_KEY : z.string().min(32) {
dev = enc:v2:aes256gcm-det:9c2b4f7a:w7x8y9g7:z0a1b2h8
staging = enc:v2:aes256gcm-det:a1b2c3d4:e5f6g7h8:i9j0k1l2
prod = enc:v2:aes256gcm-det:f3e2d1c0:c3d4e5i9:f6g7h8j0
} (description = "Primary API key", expires = 2026-09-01)env(dev, staging, prod)
public PORT : z.number().min(1024).max(65535) = 3000
DATABASE_URL : z.string().url() {
dev = "postgres://localhost:5432/myapp"
staging = "postgres://admin@staging.db.internal:5432/myapp"
prod = "postgres://admin@prod.db.internal:5432/myapp"
}
API_KEY : z.string().min(32) {
dev = "dev_key_a1b2c3d4e5f6g7h8i9j0k1l2m3"
staging = "stg_key_m3l2k1j0i9h8g7f6e5d4c3b2a1"
prod = "prod_key_x9y8w7v6u5t4s3r2q1p0o9n8m7"
} (description = "Primary API key", expires = 2026-09-01)The .env file is broken.
Here's what changes with vars.
# .env.developmentDATABASE_URL=postgres://localhost:5432/myappAPI_KEY=dev_abc123def456ghi789PORT=3000# .env.production (don't commit this!)DATABASE_URL=postgres://admin:s3cret@prod.db.com/myappAPI_KEY=prod_xyz987uvw654rst321PORT=8080# .env.staging (copy-paste from prod, change 2 things)DATABASE_URL=postgres://admin:s3cret@staging.db.com/myappAPI_KEY=stg_lmn456opq789rst012PORT=8080# config.vars — just one file to manage
env(dev, staging, prod)
public PORT : z.number().min(1024) = 3000
DATABASE_URL : z.string().url() {
dev = enc:v2:aes256gcm-det:a3b4c5:d6e7f8:g9h0i1
staging = enc:v2:aes256gcm-det:j2k3l4:m5n6o7:p8q9r0
prod = enc:v2:aes256gcm-det:e8d1f0:s1t2u3:v4w5x6
}
API_KEY : z.string().min(20) {
dev = enc:v2:aes256gcm-det:y7z8a9:b0c1d2:e3f4g5
staging = enc:v2:aes256gcm-det:f6g7h8:i9j0k1:l2m3n4
prod = enc:v2:aes256gcm-det:9c2b4f:o5p6q7:r8s9t0
}What no other tool does.

Your types, your values, your environments.
One file.
t3-env gives you schema validation. Doppler gives you a secrets vault. vars gives you both — in a single file you commit to git. The schema is the documentation. New devs read it and know exactly what every variable needs, what type it is, and which environments it spans.
env(dev, staging, prod)
public PORT : z.number().min(1024).max(65535) = 3000
DATABASE_URL : z.string().url() {
dev = "postgres://localhost:5432/myapp"
staging = enc:v2:aes256gcm-det:b2c3d4:f6g7h8:i9j0k1
prod = enc:v2:aes256gcm-det:e8d1f0:k5l6m7:n8o9p0
}
API_KEY : z.string().min(32) {
dev = "dev_test_key_not_a_secret_at_all"
staging = enc:v2:aes256gcm-det:a1b2c3:e5f6g7:i9j0k1
prod = enc:v2:aes256gcm-det:f3e2d1:c3d4e5:f6g7h8
} (description = "Primary API key", expires = 2026-09-01)
# GitHub Actions — this is your ENTIRE secrets configenv: VARS_KEY: ${{ secrets.VARS_KEY }}steps: - run: npx dotvars run --env prod -- npm start # ✔ 12 secrets decrypted and injected12 dashboard secrets?
Now it's 1.
Every secret is already in your repo, encrypted. In CI, you set a single VARS_KEY environment variable. That's it. No more pasting 12 secrets into Vercel one by one. No more hoping staging matches prod. Add a secret? Edit, commit, push — CI picks it up automatically.

Your AI agent can't read
your secrets.
When an AI coding agent tries to decrypt your .vars file, it hits a native system dialog — a real OS-level prompt that requires your PIN. No session caching, no token persistence. Every single decryption needs your explicit approval. The agent sees encrypted blobs, never plaintext.
$ cursor run "add a new database migration"
Your new workflow.
Five steps. That's the whole thing.
Init
One command. Set a PIN. Auto-detects your framework and migrates existing .env files.
npx dotvars initInstalls a pre-commit hook that blocks plaintext secrets from being committed.
Edit
Decrypt with vars show, add variables with Zod schemas. VS Code gives you autocomplete and validation.
npx dotvars showz.string().url(), z.number().min(1024) — same Zod you already know.
Lock
Run vars hide. Every secret value encrypted individually. Structure stays readable.
npx dotvars hideSafe to commit. Variable names and schemas are visible, only values are locked.
Commit
Push to git. Teammates clone the repo and enter the PIN. That's the entire onboarding.
git commit -m "update config"No Slack DMs, no shared vaults, no waiting for access.
Deploy
Set VARS_KEY in CI once. Generates typed exports with Redacted<T> — typos become compile errors.
npx dotvars run --env prod -- npm startOne secret in your dashboard replaces every env var you used to paste.
And that's not even half of it.
Everything you need. Nothing you don't.

Zod-native schemas
No proprietary DSL. Write the same Zod expressions you already use. Validated at build time.
DATABASE_URL : z.string().url().startsWith("postgres://")PORT : z.coerce.number().int().min(1024).max(65535)NODE_ENV : z.enum(["development", "staging", "production"])
Multi-environment
dev, staging, prod in one file. Side by side. They can never drift apart.

Full CLI
17 commands: show, hide, run, gen, check, export, rotate, diff, doctor, and more.

VS Code extension
Autocomplete, inline validation, hover docs, go-to-definition. Full LSP.

Check blocks
Cross-variable constraints validated at build time. "If prod, no debug logging."

TypeScript codegen
Generated types with Redacted<T>. Typos become compile errors, not 3am incidents.
import { vars } from "#vars"// Public values are plain typesconst port: number = vars.PORT// Secrets require explicit unwrap — can't accidentally log themconst db: string = vars.DATABASE_URL.unwrap()// ^^^^^^^^^^^^^^^^^^^^^^^^^ Redacted<string>
Interpolation
${} variable references with per-environment resolution.

PIN rotation
vars rotate re-encrypts everything with a new PIN. One command.

Export anywhere
Export resolved values to .env, JSON, or Kubernetes secret format.
$ vars export --env prod > .env # dotenv format$ vars export --env prod --format json # JSON format
Pre-commit hooks
Auto-installed during init. Blocks you from committing decrypted secrets to git.

Diff & coverage
Compare values across environments. See which envs are missing values at a glance.

vars doctor
Diagnose your setup — key health, .gitignore, hooks, expiring secrets, schema errors.

Stop managing secrets.
Start committing them.
One file in your repo. One key in CI. Every secret encrypted, typed, and version-controlled. Set up in five minutes.
Works with any framework