Complete Environment Variables Management Guide
ID | EN

Complete Environment Variables Management Guide

Sunday, Dec 28, 2025

Ever pushed an API key to GitHub? Or had your application crash in production because of a forgotten environment variable? You’re not alone. Environment variables management is a skill that’s often overlooked, yet it can be the difference between a secure application and a disaster waiting to happen.

In this article, we’ll cover everything from basics to production-ready practices for managing environment variables.

Why Environment Variables Matter

Environment variables are a way to store configuration that differs across environments (development, staging, production) without hardcoding in source code.

Imagine you hardcode like this:

// ❌ NEVER DO THIS
const stripe = new Stripe('sk_live_xxxxxxxxxxxxx');
const dbUrl = 'postgresql://admin:[email protected]:5432/myapp';

The problems:

  • Security risk: Secrets exposed in Git history forever
  • Not flexible: Must change code for different environments
  • Hard to rotate: If key leaks, must redeploy

With environment variables:

// ✅ The correct way
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const dbUrl = process.env.DATABASE_URL;

.env File Structure

Basic .env File

# .env
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=dev_api_key_12345
JWT_SECRET=super_secret_jwt_key

Multiple Environment Files

Best practice is to have several files for different environments:

project/
├── .env                 # Default values (committed, no secrets)
├── .env.local           # Local overrides (gitignored)
├── .env.development     # Development environment
├── .env.staging         # Staging environment
├── .env.production      # Production values (gitignored, or in CI/CD)
└── .env.example         # Template for new developers

.env.example Template

This file is committed to the repo as documentation:

# .env.example
# Copy this file to .env.local and fill with appropriate values

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname

# Authentication
JWT_SECRET=your-jwt-secret-here
JWT_EXPIRES_IN=7d

# External APIs
STRIPE_SECRET_KEY=sk_test_xxx
SENDGRID_API_KEY=SG.xxx

# Feature Flags
ENABLE_NEW_DASHBOARD=false

Client vs Server Environment Variables

This is very important for frameworks like Next.js, Nuxt, or SvelteKit.

Next.js Convention

# Server-side only (not exposed to browser)
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_live_xxx

# Client-side accessible (exposed to browser!)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
NEXT_PUBLIC_API_URL=https://api.example.com

Simple rule:

  • NEXT_PUBLIC_* = accessible in browser (DON’T put secrets!)
  • Without prefix = server-side only

Why is This Important?

// ❌ DANGEROUS - secret exposed to browser
// In React component
const apiKey = process.env.STRIPE_SECRET_KEY; // undefined on client

// ✅ SAFE - server only
// In API route or getServerSideProps
export async function getServerSideProps() {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  // ...
}

Other Frameworks

FrameworkClient PrefixServer Default
Next.jsNEXT_PUBLIC_No prefix
Nuxt 3NUXT_PUBLIC_NUXT_
ViteVITE_No server access
Create React AppREACT_APP_All exposed!
SvelteKitPUBLIC_No prefix

Dotenv and Environment Loading

Basic Setup with dotenv

npm install dotenv
// src/config.ts
import dotenv from 'dotenv';

// Load .env file
dotenv.config();

// Or load specific file
dotenv.config({ path: '.env.local' });

Loading Order (Next.js Style)

// Load based on NODE_ENV
import dotenv from 'dotenv';
import path from 'path';

const envFiles = [
  `.env.${process.env.NODE_ENV}.local`,
  `.env.local`,
  `.env.${process.env.NODE_ENV}`,
  '.env',
];

envFiles.forEach((file) => {
  dotenv.config({ path: path.resolve(process.cwd(), file) });
});

Environment Validation with Zod

Don’t trust environment variables without validation. Better for the app to crash at startup than random runtime errors.

Setup Zod Validation

npm install zod
// src/env.ts
import { z } from 'zod';

const envSchema = z.object({
  // Database
  DATABASE_URL: z.string().url(),
  
  // Server
  NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  
  // Authentication
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
  JWT_EXPIRES_IN: z.string().default('7d'),
  
  // External Services
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  
  // Optional with defaults
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  ENABLE_CACHE: z.coerce.boolean().default(true),
});

// Parse and validate
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('❌ Invalid environment variables:');
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

Usage

import { env } from './env';

// Type-safe and validated!
console.log(env.DATABASE_URL); // string
console.log(env.PORT);         // number
console.log(env.ENABLE_CACHE); // boolean
npm install @t3-oss/env-nextjs zod
// src/env.mjs
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().min(1),
  },
  client: {
    NEXT_PUBLIC_API_URL: z.string().url(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  },
});

Secrets Management for Production

Vercel

# Via CLI
vercel env add STRIPE_SECRET_KEY production
vercel env add DATABASE_URL production

# Pull env to local
vercel env pull .env.local

In Vercel Dashboard:

  • Settings → Environment Variables
  • Can set per environment (Production, Preview, Development)
  • Sensitive values automatically encrypted

Railway

# Via CLI
railway variables set STRIPE_SECRET_KEY=sk_live_xxx

# Or link to service
railway link
railway variables

Railway has Reference Variables feature:

# Reference from database service
DATABASE_URL=${{Postgres.DATABASE_URL}}

AWS (Parameter Store & Secrets Manager)

Parameter Store (for regular config):

aws ssm put-parameter \
  --name "/myapp/production/API_KEY" \
  --value "secret_value" \
  --type SecureString
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";

const ssm = new SSMClient({ region: "us-east-1" });

async function getParameter(name: string) {
  const command = new GetParameterCommand({
    Name: name,
    WithDecryption: true,
  });
  const response = await ssm.send(command);
  return response.Parameter?.Value;
}

const apiKey = await getParameter("/myapp/production/API_KEY");

Secrets Manager (for frequently rotated secrets):

import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";

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

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

const dbCredentials = await getSecret("myapp/database");

HashiCorp Vault

For enterprise-grade secrets management:

import Vault from 'node-vault';

const vault = Vault({
  apiVersion: 'v1',
  endpoint: 'https://vault.example.com:8200',
  token: process.env.VAULT_TOKEN,
});

const secret = await vault.read('secret/data/myapp/production');
const dbPassword = secret.data.data.DB_PASSWORD;

12-Factor App Principles

From 12factor.net, principle #3 about Config:

Store config in the environment

What Counts as “Config”?

Should be in environment variables:

  • Database credentials
  • API keys and secrets
  • External service URLs
  • Feature flags per environment

Not config (stays in code):

  • Routes and URL patterns
  • Dependency injection config
  • Internal business logic settings

12-Factor Implementation

// ✅ Good: Config from environment
const config = {
  db: {
    url: process.env.DATABASE_URL,
    poolSize: parseInt(process.env.DB_POOL_SIZE || '10'),
  },
  redis: {
    url: process.env.REDIS_URL,
  },
  features: {
    newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
  },
};

// ❌ Bad: Hardcoded per environment
const config = {
  db: {
    url: process.env.NODE_ENV === 'production' 
      ? 'postgresql://prod...' 
      : 'postgresql://localhost...',
  },
};

Gitignore Best Practices

.gitignore for Environment Files

# Environment files
.env
.env.local
.env.*.local
.env.development.local
.env.test.local
.env.production.local
.env.staging

# Keep example file
!.env.example

# IDE
.idea/
.vscode/

# OS
.DS_Store

Pre-commit Hook to Prevent Secret Leaks

Install git-secrets:

# macOS
brew install git-secrets

# Setup in repo
git secrets --install
git secrets --register-aws

Or use gitleaks:

# Install
brew install gitleaks

# Scan repo
gitleaks detect --source . -v

GitHub Secret Scanning

Enable in Settings → Code security → Secret scanning. GitHub will alert if secrets are pushed.

Development vs Production Environment

Separation of Concerns

// src/config/index.ts
import { z } from 'zod';

const baseSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
});

const developmentSchema = baseSchema.extend({
  DATABASE_URL: z.string().default('postgresql://localhost:5432/myapp_dev'),
  DEBUG: z.coerce.boolean().default(true),
});

const productionSchema = baseSchema.extend({
  DATABASE_URL: z.string().url(),
  DEBUG: z.literal(false).default(false),
  SENTRY_DSN: z.string().url(),
});

export const env = process.env.NODE_ENV === 'production'
  ? productionSchema.parse(process.env)
  : developmentSchema.parse(process.env);

Environment-Specific Behavior

// src/lib/logger.ts
import { env } from '../config';

export const logger = {
  debug: (...args: unknown[]) => {
    if (env.LOG_LEVEL === 'debug') {
      console.debug('[DEBUG]', ...args);
    }
  },
  info: (...args: unknown[]) => {
    console.info('[INFO]', ...args);
  },
  error: (...args: unknown[]) => {
    console.error('[ERROR]', ...args);
    
    // Send to Sentry in production
    if (env.NODE_ENV === 'production') {
      // Sentry.captureException(args[0]);
    }
  },
};

Environment Variables in Docker

Dockerfile

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

# Don't hardcode ENV in Dockerfile!
# ENV DATABASE_URL=xxx  ❌

# Use ARG for build-time variables
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV

EXPOSE 3000
CMD ["node", "dist/index.js"]

Docker Compose

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - LOG_LEVEL=debug
    env_file:
      - .env.local
    depends_on:
      - db
      - redis

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: ${DB_USER:-postgres}
      POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
      POSTGRES_DB: ${DB_NAME:-myapp}
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  postgres_data:

Docker Secrets (Swarm Mode)

# docker-compose.prod.yml
version: '3.8'

services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - stripe_key
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
      STRIPE_KEY_FILE: /run/secrets/stripe_key

secrets:
  db_password:
    external: true
  stripe_key:
    external: true
// Read from file in production
import fs from 'fs';

function getSecret(envVar: string): string {
  const fileEnv = `${envVar}_FILE`;
  
  if (process.env[fileEnv]) {
    return fs.readFileSync(process.env[fileEnv], 'utf8').trim();
  }
  
  return process.env[envVar] || '';
}

const dbPassword = getSecret('DB_PASSWORD');

Common Mistakes and How to Avoid Them

1. Committing .env to Git

# If already committed
git rm --cached .env
echo ".env" >> .gitignore
git commit -m "Remove .env from tracking"

# But secret is already in history! Must rotate all keys.

Solution: Use pre-commit hooks and secret scanning.

2. Not Validating Environment Variables

// ❌ Crash at runtime
const port = parseInt(process.env.PORT); // NaN if undefined!

// ✅ Validate at startup
const port = z.coerce.number().default(3000).parse(process.env.PORT);

3. Hardcoded Conditionals per Environment

// ❌ Hard to maintain
const apiUrl = process.env.NODE_ENV === 'production'
  ? 'https://api.prod.com'
  : process.env.NODE_ENV === 'staging'
  ? 'https://api.staging.com'
  : 'http://localhost:3001';

// ✅ Single source of truth
const apiUrl = process.env.API_URL;

4. Forgetting to Set in Production

// Checklist before deploy
const requiredEnvs = [
  'DATABASE_URL',
  'JWT_SECRET', 
  'STRIPE_SECRET_KEY',
];

requiredEnvs.forEach((env) => {
  if (!process.env[env]) {
    throw new Error(`Missing required env: ${env}`);
  }
});

5. Exposing Secrets on Client-Side

// ❌ NEXT_PUBLIC_ means exposed to browser!
NEXT_PUBLIC_DATABASE_URL=xxx  // DANGEROUS!
NEXT_PUBLIC_API_SECRET=xxx    // DANGEROUS!

// ✅ Only public keys for client
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
NEXT_PUBLIC_ANALYTICS_ID=G-XXXXXXX

6. Not Rotating Secrets

Create reminders to rotate secrets regularly:

  • API keys: every 90 days
  • Database passwords: every 30-90 days
  • JWT secrets: every 180 days

7. Logging Environment Variables

// ❌ DON'T log secrets!
console.log('Config:', process.env);
console.log('DB URL:', process.env.DATABASE_URL);

// ✅ Mask sensitive values
const safeConfig = {
  NODE_ENV: process.env.NODE_ENV,
  DATABASE_URL: process.env.DATABASE_URL?.replace(/\/\/.*@/, '//***@'),
  API_KEY: process.env.API_KEY ? '***' : 'not set',
};
console.log('Config:', safeConfig);

Environment Variables Checklist

Before deploying, make sure:

  • All .env* files (except .env.example) are in .gitignore
  • Environment variables are validated at startup
  • No hardcoded secrets in code
  • Client-side variables don’t contain secrets
  • All required env vars are set in production
  • Pre-commit hook to detect secrets
  • Secret scanning enabled on GitHub
  • .env.example documentation is up to date
  • Secrets rotation schedule

Conclusion

Environment variables management isn’t just about creating a .env file. It’s about:

  1. Security - Never commit secrets
  2. Validation - Fail fast with Zod
  3. Separation - Client vs Server, Dev vs Prod
  4. Automation - Secret scanning and pre-commit hooks
  5. Documentation - .env.example that’s always updated

Start simple: set up correct .gitignore, validate with Zod, and use managed secrets on production platforms like Vercel or Railway.

If you have questions or want to discuss further, reach out on Twitter or comment below. Happy coding! 🔐