Skip to main content
Ganesh Joshi
Back to Blogs

Environment variables: security and best practices

February 15, 20264 min read
Tips
Code or config on screen

Environment variables keep configuration and secrets out of your codebase. Used well, they make it easy to switch between local, staging, and production without hardcoding values or committing secrets.

What belongs in environment variables

Store in env vars Do not store
API keys Non-secret defaults
Database URLs Values that never change
OAuth secrets Large blobs of data
Feature flags Complex objects
External service URLs
Encryption keys

Rule: if it is secret or changes per environment, use an env var.

File organization

Typical setup

project/
  .env                # Defaults, committed (no secrets)
  .env.local          # Local overrides, gitignored
  .env.development    # Dev-specific, committed
  .env.production     # Prod-specific, committed
  .env.example        # Template with dummy values
  .gitignore          # Includes .env.local

.gitignore

# Environment files with secrets
.env.local
.env.*.local

# Keep .env.example
!.env.example

.env.example

Document required variables:

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

# Authentication
AUTH_SECRET=generate-with-openssl-rand-base64-32
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

# External services
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

# Optional
ENABLE_ANALYTICS=false

Framework-specific patterns

Next.js

# Server-only (default)
DATABASE_URL=postgres://...
API_SECRET=xxx

# Client-exposed (NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_APP_NAME=My App

Load order:

  1. .env (all environments)
  2. .env.local (all environments, gitignored)
  3. .env.development or .env.production
  4. .env.development.local or .env.production.local
// Server Component or Route Handler
const secret = process.env.API_SECRET; // Works

// Client Component
const apiUrl = process.env.NEXT_PUBLIC_API_URL; // Works
const secret = process.env.API_SECRET; // undefined!

Vite

# Server-only
DATABASE_URL=postgres://...

# Client-exposed (VITE_ prefix)
VITE_API_URL=https://api.example.com
VITE_APP_NAME=My App
// In client code
const apiUrl = import.meta.env.VITE_API_URL;
const mode = import.meta.env.MODE; // 'development' or 'production'

Node.js

# Load with dotenv
npm install dotenv
// At the top of your entry file
import 'dotenv/config';

// Or Node 20+ with --env-file flag
// node --env-file=.env app.js

const dbUrl = process.env.DATABASE_URL;

Validation at startup

Catch missing variables early:

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

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32),
  GOOGLE_CLIENT_ID: z.string(),
  GOOGLE_CLIENT_SECRET: z.string(),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});

function validateEnv() {
  const result = envSchema.safeParse(process.env);
  
  if (!result.success) {
    console.error('Invalid environment variables:');
    console.error(result.error.format());
    process.exit(1);
  }
  
  return result.data;
}

export const env = validateEnv();

Usage:

import { env } from '@/lib/env';

const db = createConnection(env.DATABASE_URL);

Type-safe access

// lib/env.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      DATABASE_URL: string;
      AUTH_SECRET: string;
      NEXT_PUBLIC_API_URL: string;
    }
  }
}

export {};

Security rules

Do

  • Store secrets in env vars, not code
  • Use .env.local for local secrets
  • Validate required variables at startup
  • Rotate secrets if exposed
  • Use different values per environment

Do not

  • Commit real secrets to git
  • Log full env var contents
  • Expose server secrets with NEXT_PUBLIC_ or VITE_
  • Share secrets via insecure channels
  • Use the same secrets for dev and prod

Checking for exposed secrets

// Prevent accidental exposure
if (typeof window !== 'undefined') {
  // This is client-side
  const serverVar = process.env.API_SECRET;
  // serverVar will be undefined, which is correct
}

Deployment patterns

Vercel

# CLI
vercel env add DATABASE_URL production
vercel env pull .env.local

# Or use dashboard:
# Project Settings > Environment Variables

GitHub Actions

# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
    steps:
      - run: npm run build

Set secrets in: Repository Settings > Secrets and variables > Actions

Docker

# Build-time variables
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

# Runtime variables (docker run -e or docker-compose)
# docker-compose.yml
services:
  app:
    environment:
      - DATABASE_URL=${DATABASE_URL}
    env_file:
      - .env.local

Common patterns

Feature flags

ENABLE_NEW_CHECKOUT=true
ENABLE_ANALYTICS=false
const isNewCheckout = process.env.ENABLE_NEW_CHECKOUT === 'true';

URL configuration

# Base URLs
API_URL=https://api.example.com
CDN_URL=https://cdn.example.com

# With paths
WEBHOOK_URL=${API_URL}/webhooks/stripe

Database URLs

# Development
DATABASE_URL=postgresql://user:password@localhost:5432/myapp_dev

# Production (use connection pooling)
DATABASE_URL=postgresql://user:password@db.example.com:5432/myapp?sslmode=require

Debugging

Check loaded variables

// Development only!
if (process.env.NODE_ENV === 'development') {
  console.log('Loaded env vars:', {
    NODE_ENV: process.env.NODE_ENV,
    DATABASE_URL: process.env.DATABASE_URL ? '[set]' : '[missing]',
    API_SECRET: process.env.API_SECRET ? '[set]' : '[missing]',
  });
}

Common issues

Problem Cause Fix
Variable undefined Not loaded Check file location and name
NEXT_PUBLIC_ undefined client-side Build-time not available Rebuild after adding
Works locally, fails in prod Not set in deployment Add to deployment platform
Wrong value Override order Check .env.local precedence

Summary

Environment variables best practices:

  1. Separate secrets from code
  2. Use appropriate prefixes (NEXT_PUBLIC_, VITE_)
  3. Validate at startup with schema
  4. Document in .env.example
  5. Never commit secrets to git
  6. Set per environment in deployment platform

This keeps your configuration secure and your deployments predictable.

Frequently Asked Questions

Store API keys, database URLs, secrets, and any configuration that changes per environment. Never hardcode secrets in source code.

Prefix with NEXT_PUBLIC_. Only these are inlined at build time and visible in client code. Never put secrets in NEXT_PUBLIC_ variables.

Validating at startup catches missing or invalid variables immediately with clear error messages, instead of failing later with cryptic errors.

Set variables in your CI platform's settings (GitHub Actions secrets, Vercel environment variables, etc.). Never commit real values to the repository.

.env is committed and contains defaults or non-sensitive values. .env.local is gitignored and contains local overrides and secrets.

Related Posts