|

JavaScript Environment Variables & Config 2026: The Complete Setup Guide

Hardcoding database URLs, API keys, and feature flags directly in your source code is one of the most common — and dangerous — mistakes developers make. Environment variables and proper configuration management separate your code from its environment, making your applications secure, portable, and easy to deploy. This guide covers everything from basic .env files to production-grade config patterns used at scale.

This lesson builds on the Node.js basics and Express.js guides. If you are building server-side JavaScript applications, proper config management is essential before going to production.

What Are Environment Variables?

Environment variables are key-value pairs stored in the operating system’s environment. They exist outside your code and change depending on where your application runs — your laptop, a staging server, or production. Every process inherits environment variables from its parent, and they are accessible in Node.js through the process.env object.

// Access environment variables
console.log(process.env.NODE_ENV);    // 'development' or 'production'
console.log(process.env.PORT);         // '3000' (always a string!)
console.log(process.env.DATABASE_URL); // 'postgresql://...'
console.log(process.env.HOME);         // '/home/chirag' (OS-level var)

Critical detail: process.env values are always strings. process.env.PORT returns "3000", not 3000. You must parse them yourself: Number(process.env.PORT) or process.env.DEBUG === 'true'. Forgetting this causes subtle bugs where "false" is truthy in JavaScript.

You can set environment variables inline when running a command:

# Linux/Mac
PORT=4000 NODE_ENV=production node server.js

# Windows (PowerShell)
$env:PORT=4000; $env:NODE_ENV="production"; node server.js

The .env File

Typing environment variables every time you start your app is tedious. The .env file convention stores them in a file at your project root.

# .env
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
API_KEY=sk-dev-key-not-real
JWT_SECRET=super-secret-dev-key
CORS_ORIGIN=http://localhost:5173
LOG_LEVEL=debug

Rules for .env files:

  • One variable per line, KEY=value format
  • No spaces around the = sign
  • Comments start with #
  • Values with spaces need quotes: APP_NAME="My Cool App"
  • Multi-line values use double quotes with

Never commit .env to Git. Add it to .gitignore immediately:

# .gitignore
.env
.env.local
.env.*.local

Instead, commit a .env.example file that shows the required variables without real values:

# .env.example (commit this)
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/myapp
API_KEY=your-api-key-here
JWT_SECRET=change-this-in-production

Node.js Native –env-file

Since Node.js 20.6+, you can load .env files natively without any third-party package. This is the recommended approach in 2026.

# Load .env file automatically
node --env-file=.env server.js

# Load multiple files (later files override earlier ones)
node --env-file=.env --env-file=.env.local server.js

# In package.json scripts
{
  "scripts": {
    "dev": "node --env-file=.env --watch server.js",
    "start": "node --env-file=.env.production server.js"
  }
}

The native --env-file flag loads variables before your code runs, making them available immediately in process.env. No library imports needed, no timing issues, no package to install. If you are on Node.js 22+, this is the way to go.

Using dotenv (Legacy Approach)

Before native support, the dotenv package was the standard. You will encounter it in existing codebases.

npm install dotenv
// Load at the very top of your entry file
import 'dotenv/config';

// Or with more control
import dotenv from 'dotenv';
dotenv.config(); // Loads .env from current directory
dotenv.config({ path: '.env.local' }); // Custom path

// Now process.env has your variables
console.log(process.env.DATABASE_URL);

Important: dotenv must be imported before anything that reads process.env. If your database connection module runs before dotenv loads, it will see undefined values. The native --env-file flag avoids this timing problem entirely.

Config Validation with Zod

Reading raw process.env values throughout your codebase is fragile. Missing variables cause cryptic runtime errors. Instead, validate all config at startup and fail fast if anything is wrong.

// config.js
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().min(1).max(65535).default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),
  API_KEY: z.string().min(10),
  JWT_SECRET: z.string().min(32),
  CORS_ORIGIN: z.string().url().default('http://localhost:5173'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

function loadConfig() {
  const result = envSchema.safeParse(process.env);

  if (!result.success) {
    console.error('Invalid environment configuration:');
    for (const issue of result.error.issues) {
      console.error(`  ${issue.path.join('.')}: ${issue.message}`);
    }
    process.exit(1);
  }

  return Object.freeze(result.data);
}

export const config = loadConfig();

// Usage throughout your app:
// import { config } from './config.js';
// app.listen(config.PORT);

This pattern gives you type-safe, validated configuration with clear error messages at startup. If DATABASE_URL is missing, your app fails immediately with a helpful message instead of crashing minutes later when the first database query runs.

Config File Patterns

For complex applications, a centralized config module is cleaner than reading process.env everywhere.

// config.js — structured config with defaults
export const config = {
  app: {
    name: process.env.APP_NAME || 'MyApp',
    port: Number(process.env.PORT) || 3000,
    env: process.env.NODE_ENV || 'development',
    isDev: process.env.NODE_ENV !== 'production',
  },
  db: {
    url: process.env.DATABASE_URL,
    poolSize: Number(process.env.DB_POOL_SIZE) || 10,
    ssl: process.env.DB_SSL === 'true',
  },
  auth: {
    jwtSecret: process.env.JWT_SECRET,
    jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
    bcryptRounds: Number(process.env.BCRYPT_ROUNDS) || 12,
  },
  redis: {
    url: process.env.REDIS_URL,
    ttl: Number(process.env.CACHE_TTL) || 3600,
  },
  email: {
    from: process.env.EMAIL_FROM || 'noreply@myapp.com',
    smtpHost: process.env.SMTP_HOST,
    smtpPort: Number(process.env.SMTP_PORT) || 587,
  },
};

Now your entire app imports config and accesses structured, typed values. No more scattered process.env.SOME_VAR calls with inconsistent defaults.

Secrets Management

Environment variables work for development, but production secrets need stronger protection. Never store production secrets in .env files on servers — if someone gains file access, they get every secret at once.

Cloud secret managers are the production standard:

// AWS Secrets Manager example
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'us-east-1' });

async function getSecret(secretName) {
  const command = new GetSecretValueCommand({ SecretId: secretName });
  const response = await client.send(command);
  return JSON.parse(response.SecretString);
}

// Load secrets at startup
const dbSecrets = await getSecret('myapp/database');
const apiKeys = await getSecret('myapp/api-keys');

Other secret management options include HashiCorp Vault, Google Cloud Secret Manager, and Azure Key Vault. For smaller projects, encrypted environment variables in your CI/CD platform (GitHub Actions secrets, Vercel environment variables) are sufficient.

Environment Variables in Frontend Apps

Frontend environment variables work differently from Node.js. Since browser JavaScript cannot access process.env, your bundler replaces environment variable references at build time.

// Vite: prefix with VITE_
// .env
VITE_API_URL=https://api.myapp.com
VITE_APP_TITLE=My App
DB_PASSWORD=secret  // NOT exposed (no VITE_ prefix)

// In your code
const apiUrl = import.meta.env.VITE_API_URL;
const title = import.meta.env.VITE_APP_TITLE;

// Webpack / Create React App: prefix with REACT_APP_
const apiUrl = process.env.REACT_APP_API_URL;

// Next.js: prefix with NEXT_PUBLIC_
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

Security warning: Every frontend environment variable is embedded in the JavaScript bundle that ships to the user’s browser. Anyone can view it in DevTools. Never put API secrets, database credentials, or private keys in frontend environment variables. Only expose public values like API base URLs and feature flags.

Multi-Environment Setup

Real applications run in multiple environments. Here is a clean pattern:

# File structure
.env                  # Shared defaults (committed)
.env.local            # Local overrides (gitignored)
.env.development      # Development-specific
.env.production       # Production-specific
.env.test             # Test-specific
{
  "scripts": {
    "dev": "node --env-file=.env --env-file=.env.development --watch server.js",
    "start": "node --env-file=.env --env-file=.env.production server.js",
    "test": "node --env-file=.env --env-file=.env.test --experimental-vm-modules node_modules/.bin/vitest"
  }
}

Later files override earlier ones, so .env.production overrides values from .env. This lets you set sensible defaults in .env and only override what changes per environment.

Common Mistakes

Committing secrets to Git: Even if you delete a .env file later, the secrets remain in Git history. If this happens, rotate every compromised credential immediately. Use tools like TruffleHog or gitleaks to scan for leaked secrets.

Not validating at startup: Discovering a missing DATABASE_URL when the first user request hits your database is too late. Validate all required config at startup and crash immediately if something is wrong.

Using NODE_ENV for feature flags: NODE_ENV should only be development, production, or test. Use separate variables for feature flags: ENABLE_BETA_FEATURES=true.

Defaulting secrets in code: Writing process.env.JWT_SECRET || 'default-secret' means your production server silently uses a default secret if the variable is missing. Secrets should never have defaults — crash if they are not set.

Inconsistent naming: Stick to SCREAMING_SNAKE_CASE for all environment variables. Mix of camelCase, PascalCase, and snake_case across variables creates confusion and bugs.

Proper environment and config management is the line between amateur and professional Node.js development. Get this right early, and you avoid an entire category of bugs, security incidents, and deployment headaches down the road.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *