DEV Community

Cover image for 5 Ways to Validate Environment Variables in Node.js and TypeScript (2026)
Chintan Shah
Chintan Shah

Posted on • Edited on

5 Ways to Validate Environment Variables in Node.js and TypeScript (2026)

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_ORIGINS unset → CORS crash on first real request
  • STRIPE_SECRET_KEY with trailing whitespace → auth errors for hours
  • ENABLE_CACHING=false as 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()
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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,
  },
})
Enter fullscreen mode Exit fullscreen mode
npm install envconfig-kit
Enter fullscreen mode Exit fullscreen mode

Full API (transforms, schema export, all types): README

npm | GitHub


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

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.env reads
  • [ ] 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)

Collapse
 
isocyanideisgood profile image
Abhijeet Bhale

This seems to be very useful, might use it in some on my project 👍🏻

Collapse
 
chintanshah35 profile image
Chintan Shah

Thanks! Hope it saves you some time. Feel free to reach out if you have any questions or feedback.

Collapse
 
isocyanideisgood profile image
Abhijeet Bhale

Definitely 👍🏻