DEV Community

Cover image for Stop Writing .env Boilerplate (We Did It For You)
Francisco Molina
Francisco Molina

Posted on

Stop Writing .env Boilerplate (We Did It For You)

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:

  1. .env.example - So teammates know what variables to set
  2. Zod schema - So you validate at runtime
  3. 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>;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Done.

✅ env.schema.ts (with Zod validation)
✅ .env.example (with comments)
✅ env.types.ts (TypeScript types)
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

5. Check Command (CI/CD)

Validate that your .env has all required variables:

$ envoy check

✅ All 12 variables are present in .env
Enter fullscreen mode Exit fullscreen mode

Or:

$ envoy check

❌ Missing 2 variable(s) in .env:
   • STRIPE_SECRET_KEY
   • SENDGRID_API_KEY

→ Copy these from .env.example or add them manually
Enter fullscreen mode Exit fullscreen mode

Perfect for CI pipelines:

# .github/workflows/deploy.yml
- name: Validate environment
  run: npx envoy check

- name: Deploy
  run: npm run deploy
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After Envoy

$ npx envoy generate --all
Enter fullscreen mode Exit fullscreen mode
✅ Generated env.schema.ts (125 lines, with comments)
✅ Generated env.types.ts (auto-inferred)
✅ Generated .env.example (in sync, always)
Enter fullscreen mode Exit fullscreen mode
// 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
Enter fullscreen mode Exit fullscreen mode
# .env.example (AUTO-GENERATED)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://localhost/mydb
JWT_SECRET=
DEBUG=true
STRIPE_KEY=
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or use without installing:

npx @envoy-cli/envoy generate --all
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Custom Input/Output

npx envoy generate -i .env.prod -o ./config --all
Enter fullscreen mode Exit fullscreen mode

Inspect Variables

npx envoy inspect
# Shows what types were inferred
Enter fullscreen mode Exit fullscreen mode

Check in CI

npx envoy check
# Exits 0 if all variables present, 1 if missing
Enter fullscreen mode Exit fullscreen mode

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_KEY is 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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Integrations

With TypeScript

// env.ts
import { envSchema } from './env.schema';

export const env = envSchema.parse(process.env);
Enter fullscreen mode Exit fullscreen mode
// 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
Enter fullscreen mode Exit fullscreen mode

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,
  },
};
Enter fullscreen mode Exit fullscreen mode

With Express

import { env } from './env';

const app = express();
app.listen(env.PORT, () => {
  console.log(`Server running on port ${env.PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

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

  • 🔜 --watch mode (regenerate when .env changes)
  • 🔜 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
Enter fullscreen mode Exit fullscreen mode

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


One More Thing

If you're using environment variables (and you are), try this:

npx envoy inspect
Enter fullscreen mode Exit fullscreen mode

See what types it inferred.

If they match your actual usage, generate the schema:

npx envoy generate --all
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
theoephraim profile image
Theo Ephraim

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.