process.env values are always strings. PORT is "3000", not 3000. DEBUG=false is the string "false", which is truthy in JavaScript. A missing DATABASE_URL does not stop your app from starting. It fails three hours later when a user hits the broken code path.
Validating environment variables at startup is the fix. Your app should refuse to boot with bad config, not limp into production and crash at 2am.
Here are the five main approaches teams use in 2026, with code you can copy today.
TL;DR
| Approach | Best for |
|---|---|
| 1. Manual checks | Scripts, prototypes |
| 2. dotenv + manual casting | Small apps already on dotenv |
| 3. Zod schema | Apps already using Zod everywhere |
| 4. t3-env | T3 / Next.js stacks |
| 5. envconfig-kit | Zero-dep, built-in .env, actionable errors |
Why validate at startup?
Common production failures:
-
ALLOWED_ORIGINSunset → CORS crash on first real request -
STRIPE_SECRET_KEYwith trailing whitespace → auth errors for hours -
ENABLE_CACHING=falseas a string →if (config.ENABLE_CACHING)is truthy
Health checks pass. Deploy succeeds. Users hit the bug later.
Fail fast: validate once when the process boots. Crash with a clear message during deploy, not during peak traffic.
For the story behind this, see Make your app crash at startup, not in production.
Way 1: Manual validation
The baseline. No dependencies. Works everywhere.
function loadConfig() {
const port = Number(process.env.PORT ?? '3000')
if (Number.isNaN(port)) {
throw new Error('PORT must be a number')
}
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
throw new Error('DATABASE_URL is required')
}
const debug = process.env.DEBUG === 'true'
return { port, databaseUrl, debug }
}
export const config = loadConfig()
Pros: Obvious, zero deps, full control
Cons: Grows messy at 15+ variables, no shared types, easy to forget new vars, boolean parsing bugs repeat everywhere
Fine for CLIs and throwaway services. Painful in APIs with dozens of env vars.
Way 2: dotenv + manual casting
Add dotenv to load .env in development:
import 'dotenv/config'
const port = parseInt(process.env.PORT ?? '3000', 10)
const apiKey = process.env.API_KEY
if (!apiKey) throw new Error('Missing API_KEY')
const enableCache = process.env.ENABLE_CACHING === 'true'
Pros: Standard .env workflow, tiny dependency
Cons: Still manual validation per variable, no URL/email/port validators, secrets can leak into error logs, TypeScript types are manual
Most Node tutorials stop here. Most production incidents start here too.
Way 3: Zod + dotenv
If Zod is already in your stack for API validation, extend it to env:
import { z } from 'zod'
import 'dotenv/config'
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
DEBUG: z.coerce.boolean().default(false),
})
export const config = envSchema.parse(process.env)
Pros: Powerful refinements, great TypeScript inference, familiar if you use Zod for requests
Cons: Zod adds bundle weight (~50KB+ minified depending on usage), separate dotenv setup, error messages aimed at developers not ops, no built-in secret masking in errors
Also consider: envalid if you want env-specific validators (port(), url()) without writing Zod coercions yourself:
import { cleanEnv, str, port, bool, url } from 'envalid'
export const config = cleanEnv(process.env, {
PORT: port({ default: 3000 }),
DATABASE_URL: url(),
API_KEY: str(),
DEBUG: bool({ default: false }),
})
Way 4: t3-env
Popular in Next.js / T3 stacks. Splits server and client env with compile-time safety:
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
API_KEY: process.env.API_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
})
Pros: Client/server split prevents leaking secrets to the browser, well documented in T3 community
Cons: Requires Zod or Valibot, framework-oriented, runtimeEnv boilerplate, overkill for a plain Express API
Pick this if you are already on the T3 stack. Skip it for a standalone Node service.
Way 5: envconfig-kit
Zero-dependency validation with built-in .env loading and startup errors that tell you how to fix the problem:
import { env } from 'envconfig-kit'
export const config = env({
PORT: { type: 'port', default: 3000 },
NODE_ENV: {
type: 'enum',
values: ['development', 'staging', 'production'] as const,
default: 'development',
},
DATABASE_URL: { type: 'url' },
API_KEY: { type: 'string', secret: true },
DEBUG: { type: 'boolean', default: false },
ALLOWED_HOSTS: { type: 'array' },
FEATURE_FLAGS: { type: 'json' },
ADMIN_EMAIL: { type: 'email', optional: true },
})
No dotenv import. Loads .env, .env.local, and .env.${NODE_ENV} automatically. Priority: process.env → .env.local → .env.${NODE_ENV} → .env.
On failure:
❌ Environment validation failed:
API_KEY: Missing required environment variable
➜ Fix: Add to .env file
Example: API_KEY=your-value-here
STRIPE_SECRET_KEY: Value contains leading/trailing whitespace
➜ Fix: Remove extra spaces
Pros: ~3KB gzipped, zero dependencies, secret masking (secret: true → **** in errors), presets for database/redis/auth, CLI (check, generate, doctor)
Cons: Smaller ecosystem than Zod, not tied to React/Next client env split
Real-world API config with presets:
import { env, presets } from 'envconfig-kit'
export const config = env({
...presets.server(),
...presets.database(),
...presets.redis(),
...presets.auth(),
...presets.cors(),
...presets.aws(),
...presets.smtp(),
STRIPE_KEY: { type: 'string', secret: true },
FEATURE_MODE: {
type: 'enum',
values: ['basic', 'premium', 'enterprise'] as const,
default: 'basic',
},
MAX_CONNECTIONS: {
type: 'integer',
validate: (value) => value > 0 && value <= 1000,
default: 100,
},
})
npm install envconfig-kit
Full API (transforms, schema export, all types): README
Comparison table
| Approach | Dependencies | Bundle (approx) | Built-in .env
|
Secret masking in errors | TypeScript inference |
|---|---|---|---|---|---|
| Manual | 0 | 0 | No | No | Manual |
| dotenv + manual | 1 | ~2KB | Yes | No | Manual |
| Zod + dotenv | 2 | ~50KB+ | Manual | No | Strong |
| envalid | 1 | ~20KB | No | No | Good |
| t3-env | 2+ | Medium | Manual | No | Strong |
| envconfig-kit | 0 | ~3KB | Yes | Yes | Strong |
Which should you pick?
| Situation | Pick |
|---|---|
| 5 env vars, internal tool | Manual or dotenv + manual |
| Already standardized on Zod | Zod schema (or t3-env for Next.js) |
| Next.js / T3 App Router | t3-env |
| Express/Fastify API, want zero deps | envconfig-kit |
Need port(), url() helpers without Zod |
envalid |
| Regulated / secret-heavy config | Anything with masking; envconfig-kit has it built in |
Honest take: there is no wrong choice among 3–5 if you validate at startup. The wrong choice is skipping validation entirely.
What to validate
Minimum checklist for a typical API:
-
URLs:
DATABASE_URL,REDIS_URL, webhook endpoints -
Secrets: API keys, JWT secrets (
secret: trueor never log config) -
Ports and numeric limits:
PORT, pool sizes, timeouts -
Enums:
NODE_ENV, feature modes, log levels -
Booleans: parsed explicitly, never
if (process.env.FLAG) -
Arrays:
CORS_ORIGINS,ALLOWED_HOSTS(comma-separated)
CLI validation in CI
Validate before deploy, not only at runtime:
npx envconfig-kit check
npx envconfig-kit generate # .env.example from schema
npx envconfig-kit doctor # health check
For Zod-based setups, parse in a small validate-env.ts script in your CI pipeline.
FAQ
How do I validate environment variables in Node.js?
Define a schema at startup and throw (or process.exit(1)) if validation fails. Use manual checks, Zod, envalid, t3-env, or envconfig-kit depending on your stack.
Should I use dotenv in production?
Usually no. Load secrets from your host (Kubernetes, Railway, AWS). Use .env files locally. envconfig-kit and dotenv both respect process.env taking precedence.
Zod or envconfig-kit for env only?
If Zod is everywhere already, use Zod. If you want env-only validation with zero dependencies and built-in .env loading, envconfig-kit is simpler.
What is fail-fast startup validation?
The process exits immediately when config is invalid, so deploy fails before users are affected. Prefer this over runtime undefined errors.
envconfig-kit v0.2.0 highlights
If you use envconfig-kit (Way 5), v0.2.0 adds:
- Framework presets (
server,database,redis,auth,aws,smtp,cors) - Validators:
enum,array,json,regex,integer - Secret masking in error output
- Custom
validate()hooks per field - Custom preset prefixes (
presets.database('DB_'))
Full v0.2.0 launch notes archived in seo-content/envconfig-kit/post-v0.2.0-launch-archive.md.
Checklist
- [ ] Centralize config in one module, not scattered
process.envreads - [ ] Validate every required var before the server listens
- [ ] Parse booleans and numbers explicitly
- [ ] Never log raw secrets on validation failure
- [ ] Commit
.env.example, never.env - [ ] Run env check in CI before deploy
What approach does your team use? Curious if anyone still runs manual-only validation in production APIs.
Top comments (3)
This seems to be very useful, might use it in some on my project 👍🏻
Thanks! Hope it saves you some time. Feel free to reach out if you have any questions or feedback.
Definitely 👍🏻