March 26, 20267 min read

Environment Variables — The Complete Guide for Developers

Everything about environment variables: .env files, secrets management, framework-specific patterns, CI/CD configuration, and the mistakes that cause production outages.

environment variables dotenv secrets configuration devops
Ad 336x280

Environment variables seem trivial. They're strings. DATABASE_URL=postgres://.... Set them and move on. Then you deploy to production and the app crashes because an env var is missing. Or you accidentally commit your Stripe secret key to GitHub. Or your staging environment uses the production database because someone copy-pasted the wrong .env file.

Environment variables are the interface between your code and its infrastructure. Getting them wrong causes outages, security breaches, and hours of debugging "it works on my machine" problems.

How Environment Variables Actually Work

Environment variables are key-value string pairs available to a process. The operating system sets some (PATH, HOME, USER), and you add your own for application configuration.

# Set a variable for the current shell session
export DATABASE_URL="postgres://localhost:5432/myapp"

# Set a variable for a single command
DATABASE_URL="postgres://localhost:5432/myapp" node server.js

# View all environment variables
env

# View a specific variable
echo $DATABASE_URL

In Node.js, access them through process.env:

const dbUrl = process.env.DATABASE_URL;
const port = parseInt(process.env.PORT || "3000", 10);
const isProduction = process.env.NODE_ENV === "production";

In Python:

import os

db_url = os.environ.get("DATABASE_URL")
port = int(os.environ.get("PORT", "3000"))
debug = os.environ.get("DEBUG", "false").lower() == "true"

In Go:

import "os"

dbURL := os.Getenv("DATABASE_URL")
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}

The .env File

Typing export commands is tedious. The .env file convention (popularized by the dotenv package) lets you define variables in a file:

# .env
DATABASE_URL=postgres://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
JWT_SECRET=some-random-string-change-this
PORT=3000
NODE_ENV=development

dotenv in Node.js

npm install dotenv
// Load at the very top of your entry point
require("dotenv").config();

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

Or use the --env-file flag (Node.js 20.6+):

node --env-file=.env server.js

What Goes in .env vs. What Doesn't

In .env (local development only)NOT in .env
Database connection stringsProduction secrets
API keys for dev/testPasswords for real accounts
Service URLs (localhost)Anything that should be in the hosting platform
Feature flags for local testing

The .env.example Pattern

Commit a .env.example file (without actual secrets) so new developers know what variables they need:

# .env.example — Copy to .env and fill in values
DATABASE_URL=postgres://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_your_key_here
JWT_SECRET=generate-a-random-string
Never commit .env to git. Add it to .gitignore:
# .gitignore
.env
.env.local
.env.*.local

Framework-Specific Patterns

Different frameworks handle environment variables differently. Know the rules for yours.

Next.js

Next.js has a built-in convention with client/server separation:

# .env.local (not committed, overrides .env)
DATABASE_URL=postgres://localhost:5432/myapp       # Server only
NEXT_PUBLIC_API_URL=http://localhost:3000/api       # Available in browser

# .env (committed, defaults)
NEXT_PUBLIC_SITE_NAME=My App

The NEXT_PUBLIC_ prefix is critical. Variables without it are only available on the server (API routes, getServerSideProps, Server Components). Variables with it are bundled into the client JavaScript — visible to anyone who views your page source.

// Server Component — can access all env vars
const db = new Database(process.env.DATABASE_URL);

// Client Component — only NEXT_PUBLIC_ vars
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

Vite

Vite uses the VITE_ prefix:

VITE_API_URL=http://localhost:3000/api
DATABASE_URL=postgres://...   # Not exposed to client
// In Vite client code
const apiUrl = import.meta.env.VITE_API_URL;

Create React App

Uses REACT_APP_ prefix:

REACT_APP_API_URL=http://localhost:3000/api

The Prefix Rule

FrameworkClient prefixAccess pattern
Next.jsNEXT_PUBLIC_process.env.NEXT_PUBLIC_X
ViteVITE_import.meta.env.VITE_X
CRAREACT_APP_process.env.REACT_APP_X
NuxtNUXT_PUBLIC_useRuntimeConfig().public.x
SvelteKitPUBLIC_$env/static/public
Any variable without the prefix stays on the server. This prevents accidentally leaking secrets to the browser.

Validation: Catch Missing Variables at Startup

The worst time to discover a missing env var is when a user triggers the code path that uses it. Validate at startup:

Simple Validation

// config.js
const required = [
  "DATABASE_URL",
  "REDIS_URL",
  "STRIPE_SECRET_KEY",
  "JWT_SECRET",
];

for (const key of required) {
if (!process.env[key]) {
console.error(Missing required environment variable: ${key});
process.exit(1);
}
}

module.exports = {
database: {
url: process.env.DATABASE_URL,
},
redis: {
url: process.env.REDIS_URL,
},
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || "7d",
},
port: parseInt(process.env.PORT || "3000", 10),
isProduction: process.env.NODE_ENV === "production",
};

With Zod (Type-Safe)

import { z } from "zod";

const envSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
JWT_SECRET: z.string().min(32),
PORT: z.string().regex(/^\d+$/).transform(Number).default("3000"),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

export const env = envSchema.parse(process.env);

// Type-safe access
// env.PORT is number
// env.NODE_ENV is "development" | "production" | "test"

If any variable is missing or invalid, Zod throws with a clear error message at startup.

Environment Variables in CI/CD

GitHub Actions

# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      NODE_ENV: production
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

Store secrets in Settings → Secrets and Variables → Actions. GitHub masks them in logs automatically.

Docker

# Dockerfile — DON'T bake secrets into the image
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
# Pass env vars at runtime
docker run -e DATABASE_URL="postgres://..." -e STRIPE_SECRET_KEY="sk_..." myapp

# Or use an env file
docker run --env-file .env.production myapp
# docker-compose.yml
services:
  app:
    build: .
    env_file:
      - .env.production
    environment:
      - NODE_ENV=production

Multiple Environments

A typical setup has multiple .env files:

.env                  # Default values (committed)
.env.local            # Local overrides (not committed)
.env.development      # Development-specific (committed)
.env.production       # Production defaults (committed, no secrets)
.env.production.local # Production secrets (not committed)

Load order (Next.js example):


  1. .env (lowest priority)

  2. .env.local

  3. .env.development (or .env.production)

  4. .env.development.local (or .env.production.local) (highest priority)


Security Rules

RuleReason
Never commit .env files with real secretsGit history is permanent. Even if you delete the file, the secret is in the history
Rotate secrets immediately if exposedDon't assume nobody saw it. Bots scan GitHub for leaked keys within seconds
Use different secrets per environmentIf staging secrets leak, production is still safe
Don't log environment variablesconsole.log(process.env) dumps all secrets to your log aggregator
Don't put secrets in Docker imagesImages are often stored in registries accessible to many people
Use a secrets manager in productionAWS Secrets Manager, Vault, Doppler — not .env files on a server

What to Do When You Accidentally Commit a Secret

  1. Immediately rotate the secret. Generate a new API key, password, etc.
  2. Remove from Git history:
   git filter-branch --force --index-filter \
     "git rm --cached --ignore-unmatch .env" \
     --prune-empty --tag-name-filter cat -- --all
  1. Force push (coordinate with your team)
  2. Check audit logs of the affected service for unauthorized access

Common Mistakes

  1. Using process.env everywhere instead of a config module. Centralizing env var access into a config file means one place to validate, one place to add defaults, and type safety if you use TypeScript.
  1. Assuming env vars are available in client-side code. Frontend frameworks bundle env vars at build time, and only prefixed variables. process.env.SECRET_KEY in a React component is undefined.
  1. Not providing defaults for optional variables. parseInt(process.env.PORT) returns NaN when PORT is undefined. Always provide a fallback: parseInt(process.env.PORT || "3000", 10).
  1. Overusing environment variables. Feature flags, UI text, complex configuration — these belong in a config file or database, not environment variables. Env vars are for secrets and deployment-specific settings.
  1. Different .env file formats across tools. Docker, Node.js dotenv, and shell scripts handle quoting differently. Stick to KEY=value without spaces around =. Quote values containing spaces: KEY="value with spaces".
Environment configuration is one of those unglamorous skills that separates production-ready developers from tutorial-level developers. For more on deployment, configuration, and DevOps patterns, explore the exercises on CodeUp.
Ad 728x90