JSON and Environment Variables: dotenv, Zod Validation, and Config Patterns

Last updated:

Node.js applications commonly combine two approaches for configuration: environment variables for secrets and deployment-specific values, and JSON config files for structured application settings. This guide covers dotenv, Zod validation, JSON-in-env patterns, and best practices for managing secrets across environments.

dotenv Basics

dotenv loads a .env file at startup and injects its key-value pairs into process.env. It's the de-facto standard for local development configuration in Node.js.

# .env (never commit this file)
PORT=3000
DATABASE_URL=postgres://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
API_KEY=sk-dev-1234567890abcdef
NODE_ENV=development
ENABLE_FEATURE_X=true
// src/index.ts
import 'dotenv/config'  // loads .env before anything else

// process.env is now populated from .env
console.log(process.env.PORT)        // "3000" (string)
console.log(process.env.DATABASE_URL) // "postgres://..."

// Call dotenv.config() directly for custom paths
import dotenv from 'dotenv'
dotenv.config({ path: '.env.local' })
dotenv.config({ path: '.env' })  // .env.local takes precedence (already set)
dotenv.config({ override: true })  // force .env to win over shell env

Existing environment variables (set by Docker, the shell, or CI/CD) are never overwritten by default. This is the correct behavior for production: the real environment takes precedence over any .env file.

Validating Env Vars with Zod

Environment variables are strings. Zod lets you parse, transform, and type-check them all at startup — before any request handler runs.

import { z } from 'zod'
import 'dotenv/config'

const envSchema = z.object({
  // Required string
  DATABASE_URL: z.string().url(),
  API_KEY:      z.string().min(1),

  // Optional with default
  PORT: z.string().transform(Number).default('3000'),

  // Enum
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),

  // Boolean from string
  ENABLE_FEATURE_X: z
    .string()
    .transform((v) => v === 'true')
    .default('false'),

  // Optional
  REDIS_URL: z.string().url().optional(),
})

// Type is inferred automatically
export type Env = z.infer<typeof envSchema>

// Throws ZodError with all failures at once if invalid
export const env = envSchema.parse(process.env)

// Or use safeParse for a non-throwing version
const result = envSchema.safeParse(process.env)
if (!result.success) {
  console.error('Invalid environment variables:', result.error.flatten())
  process.exit(1)
}

Import env from this module throughout your app instead of accessing process.env directly. TypeScript ensures you only reference defined variables.

t3-env for Next.js

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

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    API_SECRET: z.string().min(1),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_ANALYTICS_ID: z.string().optional(),
  },
  // Bind client vars so they're available at build time
  experimental__runtimeEnv: {
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
  },
})

t3-env enforces that server variables are never accessed in client components — it throws at build time if you try. NEXT_PUBLIC_ variables must be listed in both client and experimental__runtimeEnv.

Storing JSON in an Environment Variable

When you need to pass structured config (like a database config object or cloud credentials) through a string-only interface, serialize the JSON and store it as one env var.

# .env
DB_CONFIG={"host":"localhost","port":5432,"database":"myapp","ssl":false}

# Shell (escape quotes)
export DB_CONFIG='{"host":"db.prod.example.com","port":5432,"database":"myapp","ssl":true}'
import { z } from 'zod'

const dbConfigSchema = z.object({
  host:     z.string(),
  port:     z.number(),
  database: z.string(),
  ssl:      z.boolean(),
})

// Parse and validate in one step
const dbConfig = dbConfigSchema.parse(
  JSON.parse(process.env.DB_CONFIG ?? '{}')
)

// Type: { host: string; port: number; database: string; ssl: boolean }
console.log(dbConfig.host)

Always validate the parsed JSON with Zod — don't trust raw JSON.parse output. A missing key or wrong type will cause confusing runtime errors far from the source.

JSON Config Files with Env Overrides

JSON config files work well for non-secret, structured configuration. The pattern: commit a config.json with defaults, then override with environment variables at runtime.

// config.json (committed to repo — no secrets)
{
  "server": {
    "port": 3000,
    "timeout": 30000
  },
  "cache": {
    "ttl": 300,
    "maxSize": 1000
  },
  "features": {
    "newDashboard": false,
    "betaApi": false
  }
}
// config.ts
import { z } from 'zod'
import { readFileSync } from 'fs'
import { resolve } from 'path'

const configSchema = z.object({
  server: z.object({
    port:    z.number().default(3000),
    timeout: z.number().default(30000),
  }),
  cache: z.object({
    ttl:     z.number().default(300),
    maxSize: z.number().default(1000),
  }),
  features: z.object({
    newDashboard: z.boolean().default(false),
    betaApi:      z.boolean().default(false),
  }),
})

function loadConfig() {
  const base = JSON.parse(
    readFileSync(resolve(process.cwd(), 'config.json'), 'utf-8')
  )

  // Override with environment variables where provided
  const merged = {
    ...base,
    server: {
      ...base.server,
      port: process.env.PORT ? Number(process.env.PORT) : base.server.port,
    },
    features: {
      ...base.features,
      newDashboard: process.env.FEATURE_NEW_DASHBOARD === 'true' || base.features.newDashboard,
      betaApi: process.env.FEATURE_BETA_API === 'true' || base.features.betaApi,
    },
  }

  return configSchema.parse(merged)
}

export const config = loadConfig()

Environment Files: .env.local, .env.test, .env.production

Multiple .env files allow different configs per environment. The convention (used by Vite, Next.js, Create React App):

FileWhen loadedCommit?
.envAll environmentsYes (non-secrets only)
.env.localLocal overrides, not testNo — gitignored
.env.developmentNODE_ENV=developmentYes (non-secrets)
.env.testNODE_ENV=testYes (non-secrets)
.env.productionNODE_ENV=productionNo — injected by CI/CD
.env.exampleNever loadedYes — documents required vars

Secrets Management Best Practices

# .gitignore
.env
.env.local
.env.*.local
*.pem
*.key

# .env.example (committed — no real values)
PORT=3000
DATABASE_URL=postgres://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
API_KEY=your-api-key-here
STRIPE_SECRET_KEY=sk_test_...

For production, use a dedicated secrets manager instead of .env files. Popular options:

  • AWS Secrets Manager — integrates with ECS, Lambda, Kubernetes via IRSA
  • HashiCorp Vault — self-hosted, dynamic secrets, fine-grained policies
  • Doppler — developer-friendly SaaS, syncs to any environment
  • Infisical — open-source Doppler alternative
  • GitHub Secrets — for CI/CD workflows only
# Doppler — inject secrets as env vars at runtime
doppler run -- node dist/index.js

# AWS SSM Parameter Store
aws ssm get-parameters-by-path --path /myapp/prod --with-decryption   | jq -r '.Parameters[] | "export " + .Name[12:] + "=" + .Value' > /tmp/env.sh
source /tmp/env.sh

FAQ

What is the difference between .env files and JSON config files?

.env files use a simple KEY=VALUE format per line and are loaded by dotenv or the host environment. JSON config files support nested structures, arrays, and typed values. The typical pattern: use .env for secrets and deployment-specific values (database URLs, API keys) that change per environment and must not be committed to version control; use JSON config files for application settings (timeouts, feature flags, default values) that are safe to commit. Both can be validated with Zod at startup for type safety and early error detection.

How do I validate environment variables with Zod?

Define a Zod schema matching your expected environment variables. Use z.string().transform(Number) to coerce string env vars to numbers, z.enum() for constrained choices, and .default() for optional vars with fallback values. Call envSchema.parse(process.env) at application startup — if any variable is missing or invalid, Zod throws a ZodError with a detailed list of all failures at once. Use safeParse for the non-throwing variant. The z.infer<typeof envSchema> type flows through your entire application, replacing unsafe process.env.X as string casts.

Can I store JSON in an environment variable?

Yes. Environment variables are strings, so serialize your JSON to a compact string and store it. In a .env file, wrap it in single quotes to avoid shell quoting issues: DB_CONFIG={"host":"localhost","port":5432}. In Node.js, parse it with JSON.parse(process.env.DB_CONFIG) and immediately validate the result with Zod. This pattern is useful for CI/CD systems (GitHub Actions secrets, Doppler) where each secret must be a flat string. Keep JSON env vars compact and avoid large objects — some systems have per-variable length limits.

What is t3-env and how does it differ from Zod alone?

t3-env (@t3-oss/env-nextjs) is a Next.js-specific wrapper around Zod for environment validation. It adds two things plain Zod doesn't have: (1) automatic server/client segregation — server-only variables throw a runtime error if accessed in a Client Component, and (2) build-time validation via Next.js's NEXT_PUBLIC_ convention. For non-Next.js Node.js apps, plain Zod is simpler and sufficient. The t3-env pattern is ideal when you need to catch the common Next.js mistake of importing server-only secrets in client bundles.

How does dotenv work under the hood?

dotenv reads the .env file line by line, parses each KEY=VALUE pair (with support for quoted values, inline comments, and multi-line values using \n), and assigns each parsed value to process.env[KEY] — but only if process.env[KEY] is not already set. This means real environment variables (injected by Docker, Kubernetes, or the shell) always win. Call dotenv.config() before any other imports that access process.env, or use import 'dotenv/config' at the very top of your entry file. Multi-file loading: call dotenv.config() multiple times for .env.local and .env — first load wins per key.

How do I handle different environments with JSON config?

The cleanest pattern: commit a config.json with safe defaults, then merge environment-variable overrides at startup. Load the JSON file, spread it, and override the fields that vary by environment. Validate the merged result with Zod. This separates concerns: the JSON file documents the config structure and provides development defaults; environment variables inject production values at deploy time. Never put secrets in committed JSON files. For structured config with environment inheritance, libraries like convict or cosmiconfig automate the merge logic and add validation schemas.

What is the best way to manage secrets — .env vs secrets manager?

.env is acceptable for local development only. For staging and production, use a secrets manager (AWS Secrets Manager, Vault, Doppler, Infisical). Secrets managers provide: audit trails (who accessed what and when), secret rotation without code changes, fine-grained access control, and elimination of the "committed .env file" incident that affects thousands of projects every year. The integration model: the secrets manager injects values into process.env at container or process startup. Your application code reads from process.env exactly as with dotenv — no SDK required in application code for most patterns.

How do I validate a config.json file with Zod?

Read the file with fs.readFileSync('config.json', 'utf-8'), parse it with JSON.parse(), then pass the result to configSchema.parse(). Zod returns a fully typed object matching your schema, or throws a ZodError listing every field that failed with its path, expected type, and received value. For async file reads, use z.safeParseAsync(). Export the validated config as a singleton so all modules import from one place. The z.infer<typeof configSchema> type means TypeScript knows the exact shape of your config at every call site — no any casts or optional chaining for fields that are guaranteed present.

Further reading and primary sources

  • dotenv documentationOfficial dotenv README: config options, multi-file loading, and dotenv-expand
  • Zod documentationComplete Zod API reference: parsing, transforms, refinements, and error handling
  • t3-envNext.js environment variable validation with server/client type safety
  • JSON Config Files (Jsonic)package.json, tsconfig.json, .eslintrc.json — key fields and patterns
  • Zod Schema Validation (Jsonic)Complete guide to Zod: z.object(), safeParse(), transforms, and TypeScript types