guide8 min readpublished 2026-04-17

How to validate a .env file: 5 ways (2026)

From a one-line grep to full CI-time schema validation — every way to check a .env before it hits production, with code for Node.js, Python, and Go.

Validating a .env means different things at different stages: syntax now, required keys at boot, types at CI. The five approaches below go from "one-line check" to "full type-safe schema."

1. Browser-based validator (fastest, zero install)

Paste the file into the online env validator — it runs client-side, flags missing values, duplicates, invalid identifiers, and unterminated quotes in < 50ms.

When to use: debugging a broken deploy, checking a teammate's .env.local, reviewing a PR.

When to skip: you need this in CI or at app startup — the browser isn't available there.

2. A shell one-liner (good for CI)

The bare-minimum "does every key have a value" check:

# fail if any non-comment line has empty value
awk -F= '!/^#/ && NF>1 && $2==""' .env | \
  { if read -r line; then echo "empty: $line"; exit 1; fi; }

Useful when you can't install anything in the CI runner.

3. Missing-keys check against a schema list

Most production breakage isn't bad syntax — it's a required key that's missing. The missing env detector takes two inputs:

  • Your .env
  • A list of required keys (or your .env.example)

It tells you exactly which required keys are missing or empty, and which keys exist but aren't on the required list (often dead code).

For automation, do the same check in CI with a two-line script:

# CI check — fail if any key in .env.example is missing in .env
comm -23 \
  <(grep -E '^[A-Z_]+=' .env.example | cut -d= -f1 | sort) \
  <(grep -E '^[A-Z_]+=' .env | cut -d= -f1 | sort) | \
  { if read -r line; then echo "missing: $line"; exit 1; fi; }

4. Type-safe validation at boot (production-grade)

The gold standard: validate at app startup, fail fast if any key is missing or wrong type.

Node.js / TypeScript with Zod

// lib/env.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

export const env = envSchema.parse(process.env);
// TypeScript now knows env.PORT is a number, env.DATABASE_URL is a URL, etc.

Next.js with t3-env

// env.ts
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  },
  client: {
    NEXT_PUBLIC_SITE_URL: z.string().url(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
  },
});

t3-env splits client and server schemas, so you can't accidentally ship a server-only secret to the browser.

Python with Pydantic BaseSettings

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    jwt_secret: str
    log_level: str = "info"

    class Config:
        env_file = ".env"

settings = Settings()
# Raises on missing fields, coerces types automatically

Go with envconfig

import "github.com/kelseyhightower/envconfig"

type Config struct {
    DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
    Port        int    `envconfig:"PORT" default:"8080"`
    LogLevel    string `envconfig:"LOG_LEVEL" default:"info"`
}

var config Config
envconfig.MustProcess("", &config)

5. CI-time validation (belt + suspenders)

Even with runtime schema validation, catch mistakes earlier — at PR time. A GitHub Action that boots the app briefly to trigger env validation:

# .github/workflows/env-check.yml
name: env check
on: [pull_request]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: |
          cp .env.example .env
          # fill in fake but well-typed values
          echo 'DATABASE_URL=postgres://fake:pass@localhost/test' >> .env
          echo 'JWT_SECRET=$(openssl rand -hex 32)' >> .env
          node -e "require('./dist/env.js')"  # triggers zod parse

This catches every missing or bad-typed key on every PR, not just the ones the developer thought to test.

Which approach should you pick?

ScenarioPick
One-off PR reviewBrowser validator
Solo side projectZod at boot
Team Next.js appt3-env
Python servicePydantic BaseSettings
Go serviceenvconfig
Regulated shopZod at boot + CI-time validation

The two checks every team should run

  1. Syntax: browser validator on every PR that touches .env.example.
  2. Required keys: missing env detector or thecomm one-liner in CI.

Bonus: if you also run the leak checker before every push, the same pipeline catches syntax + missing + leaked-secret issues in one go.

Try these tools

Related guides