One command generates your Zod schema, TypeScript types, and .env.example automatically. No more stale documentation or runtime surprises.
The .env Problem Nobody Solved
You have a .env file.
Three things need to exist:
-
.env.example- So teammates know what variables to set - Zod schema - So you validate at runtime
- TypeScript types - So you get autocomplete
Here's what usually happens:
// .env
DATABASE_URL=postgres://localhost/mydb
API_KEY=secret123
PORT=3000
DEBUG=true
// .env.example
DATABASE_URL=
API_KEY=
PORT=
DEBUG=
// schema.ts (manually written)
export const envSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
PORT: z.coerce.number().int().min(1).max(65535),
DEBUG: z.enum(['true', 'false']).transform(v => v === 'true'),
});
// types.ts (also manually written)
export type Env = z.infer<typeof envSchema>;
Then what happens?
You add a new env variable. You forget to update .env.example. Three months later, a new dev joins, copies .env.example, and their app crashes at runtime because STRIPE_API_KEY doesn't exist.
The real problem?
These three files are basically the same data, written THREE TIMES.
The Solution: Envoy
One command. Generates everything.
npx envoy generate --all
Done.
✅ env.schema.ts (with Zod validation)
✅ .env.example (with comments)
✅ env.types.ts (TypeScript types)
What Envoy Actually Does
1. Inference (The Magic Part)
Envoy looks at your .env and infers types automatically:
PORT=3000
↓ Detects: "PORT" pattern + numeric value
→ Infers: z.coerce.number().int().min(1).max(65535)
DATABASE_URL=postgres://localhost/mydb
↓ Detects: "*_URL" pattern + valid URL
→ Infers: z.string().url()
JWT_SECRET=abc123
↓ Detects: "*_SECRET" pattern
→ Infers: z.string().min(1) with ⚠️ secret warning
DEBUG=true
↓ Detects: "DEBUG" pattern + boolean value
→ Infers: z.enum(['true','false']).transform(v => v === 'true')
No config needed. Just run it.
2. Automatic Schema Generation
Generates production-ready Zod schemas:
// AUTO-GENERATED (don't edit!)
import { z } from 'zod'
export const envSchema = z.object({
/** Runtime environment */
NODE_ENV: z.enum(['development', 'staging', 'production', 'test']),
/** Port number (1-65535) */
PORT: z.coerce.number().int().min(1).max(65535),
/** ⚠️ Secret — never commit the real value */
JWT_SECRET: z.string().min(1),
/** Database connection string */
DATABASE_URL: z.string().url(),
/** Enable debug logging */
DEBUG: z.enum(['true', 'false']).transform(v => v === 'true'),
/** Stripe API key for payments */
STRIPE_SECRET_KEY: z.string().min(1),
})
export type Env = z.infer<typeof envSchema>
// Usage:
export const env = envSchema.parse(process.env)
Now you have:
- ✅ Type-safe environment variables
- ✅ Runtime validation (fails fast on startup)
- ✅ Autocomplete in your IDE
- ✅ Documentation built-in
3. Automatic .env.example
Generates from your actual .env:
# Runtime environment
NODE_ENV=development
# Port number (1-65535)
PORT=3000
# ⚠️ Secret — never commit the real value
JWT_SECRET=
# Database connection string
DATABASE_URL=postgres://localhost/mydb
# Enable debug logging
DEBUG=true
# Stripe API key for payments
STRIPE_SECRET_KEY=
Benefits:
- Always in sync with actual
.env - Clear, auto-generated comments
- New devs know exactly what's required
4. Inspect Command (Debugging)
See what Envoy inferred about your variables:
$ envoy inspect
NODE_ENV enum Runtime environment
PORT port Port number (1-65535)
DATABASE_URL url Database connection string
JWT_SECRET secret ⚠️ Secret — never commit
DEBUG boolean Enable debug logging
STRIPE_SECRET_KEY secret ⚠️ Secret — never commit
5. Check Command (CI/CD)
Validate that your .env has all required variables:
$ envoy check
✅ All 12 variables are present in .env
Or:
$ envoy check
❌ Missing 2 variable(s) in .env:
• STRIPE_SECRET_KEY
• SENDGRID_API_KEY
→ Copy these from .env.example or add them manually
Perfect for CI pipelines:
# .github/workflows/deploy.yml
- name: Validate environment
run: npx envoy check
- name: Deploy
run: npm run deploy
The Inference Rules
Envoy detects types by key patterns and value heuristics:
| Pattern | Type | Zod Validation |
|---|---|---|
*_URL, *_ENDPOINT
|
URL | z.string().url() |
PORT |
number | z.coerce.number().int().min(1).max(65535) |
*_SECRET, *_KEY, *_TOKEN
|
secret | z.string().min(1) |
DEBUG, IS_*, ENABLED
|
boolean | z.enum(['true','false']).transform(...) |
NODE_ENV, APP_ENV
|
enum | z.enum(['dev','staging','prod','test']) |
*_TIMEOUT, *_LIMIT
|
number | z.coerce.number() |
Value: https://...
|
URL | detected automatically |
Value: true/false
|
boolean | detected automatically |
| Value: digits only | number | detected automatically |
Real Example: Before & After
Before Envoy
// developer-handbook.md
"Here are the env variables you need:
- NODE_ENV: dev/staging/prod
- PORT: 3000-9000
- DATABASE_URL: postgres://...
- JWT_SECRET: (generate one)
- DEBUG: true/false
- STRIPE_KEY: your stripe key
"
// Actually you forgot some, so:
// .env.example (manually written, outdated)
NODE_ENV=
PORT=
DATABASE_URL=
JWT_SECRET=
DEBUG=
# (missing STRIPE_KEY)
// schema.ts (manually written, duplicates .env.example)
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number(),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string(),
DEBUG: z.enum(['true', 'false']),
// (still missing STRIPE_KEY)
});
// types.ts (you'll probably skip this)
// (code duplication everywhere)
// 3 weeks later...
// Dev joins team, copies .env.example, app crashes
After Envoy
$ npx envoy generate --all
✅ Generated env.schema.ts (125 lines, with comments)
✅ Generated env.types.ts (auto-inferred)
✅ Generated .env.example (in sync, always)
// env.schema.ts (AUTO-GENERATED)
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production', 'test']),
PORT: z.coerce.number().int().min(1).max(65535),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(1),
DEBUG: z.enum(['true', 'false']).transform(v => v === 'true'),
STRIPE_KEY: z.string().min(1),
});
export type Env = z.infer<typeof envSchema>;
// app.ts
export const env = envSchema.parse(process.env);
// If anything is missing → error at startup
# .env.example (AUTO-GENERATED)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://localhost/mydb
JWT_SECRET=
DEBUG=true
STRIPE_KEY=
Result:
- ✅ Single source of truth (your
.env) - ✅ Always in sync
- ✅ New dev runs one command, everything works
- ✅ Runtime validation from day one
- ✅ Zero boilerplate
Installation
npm install -g @envoy-cli/envoy
Or use without installing:
npx @envoy-cli/envoy generate --all
Usage
Basic
# Generate Zod schema (default)
npx envoy generate
# Generate TypeScript types
npx envoy generate --types
# Generate .env.example
npx envoy generate --example
# Generate everything
npx envoy generate --all
Custom Input/Output
npx envoy generate -i .env.prod -o ./config --all
Inspect Variables
npx envoy inspect
# Shows what types were inferred
Check in CI
npx envoy check
# Exits 0 if all variables present, 1 if missing
Real-World Scenarios
Scenario 1: New Developer Joins
Without Envoy:
- Gets README with env variables (probably outdated)
- Copies
.env.example(might be missing new vars) - App crashes at runtime because
STRIPE_KEYis missing - Spends 30 minutes debugging
With Envoy:
- Clones repo
- Copies
.env.example(always current) - Runs app
- If anything is missing → clear error message
- Adds the variable
- Done ✅
Scenario 2: Adding New Environment Variable
Without Envoy:
- Update
.env - Remember to update
.env.example(you'll forget) - Remember to update schema.ts
- Remember to update types.ts
- 4 files to edit, easy to miss one
With Envoy:
# Add to .env
echo "NEW_VAR=value" >> .env
# Regenerate
npx envoy generate --all
# Done. All 3 files updated.
Scenario 3: Onboarding New Environment (staging)
Without Envoy:
- Create
.env.staging - Manually copy variables from
.env.example - Hope you didn't miss any
- Hope the types match
- Hope it works
With Envoy:
cp .env.example .env.staging
# Edit values
npx envoy check -i .env.staging
# ✅ All variables present
Integrations
With TypeScript
// env.ts
import { envSchema } from './env.schema';
export const env = envSchema.parse(process.env);
// app.ts
import { env } from './env';
const port = env.PORT; // TypeScript knows it's a number
const db = env.DATABASE_URL; // TypeScript knows it's a string
With Next.js
// next.config.js
import { envSchema } from './env.schema';
const env = envSchema.parse(process.env);
export default {
env: {
NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL,
},
};
With Express
import { env } from './env';
const app = express();
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});
Why This Matters
1. DRY Principle
Don't repeat yourself. .env.example, schema, and types are the same data.
2. Runtime Safety
Bad env variables crash at startup, not in production.
3. Developer Experience
New devs don't spend 30 minutes debugging missing env vars.
4. Type Safety
Autocomplete + compile-time checking for environment variables.
5. Automation
No manual work. Generate, commit, move on.
Roadmap
- 🔜
--watchmode (regenerate when.envchanges) - 🔜 AI-powered inference (when types are ambiguous)
- 🔜 VSCode extension
- 🔜 Support for
.env.*files (.env.local,.env.staging, etc) - 🔜 JSON/YAML output formats
Try It Now
# Zero setup
npx @envoy-cli/envoy generate --all
# Install for your team
npm install -g @envoy-cli/envoy
The Philosophy
Your .env is already documented in your code. Let's use it.
Stop writing the same information three times.
Stop outdated .env.example files causing crashes.
Stop manual Zod schemas when the data already exists.
Let Envoy do the boilerplate.
Links
- GitHub: github.com/frxcisxo/envoy
- NPM: @envoy-cli/envoy
One More Thing
If you're using environment variables (and you are), try this:
npx envoy inspect
See what types it inferred.
If they match your actual usage, generate the schema:
npx envoy generate --all
One command. Problem solved.
Questions? Open an issue on GitHub.
Made with ❤️ for developers who hate boilerplate.
If you found this useful, share with your team! Especially if you:
- Are tired of outdated .env.example files
- Want type-safe environment variables
- Need runtime validation without boilerplate
- Are onboarding new developers
Top comments (1)
instead of building tooling to keep things in sync, use varlock.dev - your example becomes a schema, which can contain values, validation, docs, types.
also adds leak detection, plugins to pull from 15 places, imports, much more.