Skip to content

Environment Variables in Node.js

Environment variables externalise configuration that changes between environments (development, staging, production) — database URLs, API keys, feature flags, port numbers.

// Access any environment variable
const port = process.env.PORT;
const dbUrl = process.env.DATABASE_URL;
const nodeEnv = process.env.NODE_ENV; // 'development', 'production', 'test'

All values are strings — parse numbers and booleans explicitly:

const port = parseInt(process.env.PORT ?? '3000', 10);
const debugMode = process.env.DEBUG === 'true';
const maxRetries = Number(process.env.MAX_RETRIES ?? '3');
Terminal window
npm install dotenv
Terminal window
# .env (never commit this file)
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/mydb
JWT_SECRET=my-secret-key
API_KEY=sk-abc123
// Load as early as possible — before any other imports that need env vars
import 'dotenv/config'; // ESM
// or:
require('dotenv').config(); // CommonJS
Terminal window
# .env — local secrets, gitignored
# .env.example — committed, shows what variables are needed (no real values)
# .env.test — values for test environment
# .env.production — never committed; set via CI/CD or hosting platform

.env.example (commit this):

Terminal window
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/mydb_dev
JWT_SECRET=change-me

Centralise and validate all env vars in one place:

src/config.ts
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
export const config = {
port: parseInt(process.env.PORT ?? '3000', 10),
nodeEnv: process.env.NODE_ENV ?? 'development',
database: {
url: requireEnv('DATABASE_URL'),
poolSize: parseInt(process.env.DB_POOL_SIZE ?? '10', 10),
},
jwt: {
secret: requireEnv('JWT_SECRET'),
expiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
},
api: {
key: requireEnv('API_KEY'),
},
} as const;

This approach:

  • Fails fast at startup if required variables are missing
  • Gives you autocomplete throughout the codebase
  • Keeps all config logic in one place
Terminal window
npm install zod
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.string().transform(Number).default('3000'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.flatten());
process.exit(1);
}
export const env = parsed.data;

Locally:

Terminal window
# .env file (dotenv handles this)
PORT=3000

Shell (one-off):

Terminal window
PORT=4000 NODE_ENV=production node dist/server.js

GitHub Actions:

- name: Run tests
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
run: npm test

Docker:

Terminal window
docker run -e NODE_ENV=production -e PORT=3000 my-app
# Or from a file
docker run --env-file .env.production my-app

Add to .gitignore:

.env
.env.local
.env.production
*.pem

For production, use:

  • Azure: App Service configuration / Key Vault
  • AWS: Parameter Store / Secrets Manager
  • GCP: Secret Manager
  • Kubernetes: Secrets + Workload Identity
// Different .env files per environment
// dotenv-flow handles this automatically
npm install dotenv-flow
// Loads .env, then .env.{NODE_ENV}, then .env.{NODE_ENV}.local
// More specific files override earlier ones