guide12 min readpublished 2026-04-19

Environment variables: the developer's playbook (2026)

What env vars actually are, how to set them across shells and frameworks, why they break production, and the 7 rules we follow after shipping too many broken deploys.

Environment variables are the connective tissue between your code and the machine it runs on. They're also the single most common cause of "it works on my laptop" bugs. This is the playbook our team wrote after shipping too many deploys that failed because of one missing key.

The short version: what they are, how to set them across every stack you'll touch, where to store them safely, and the seven rules that keep a multi-engineer team from setting fire to production.

The 30-second primer

An environment variable is a named string that lives in the process environment. Your operating system gives one to every program it launches. Your shell has a set. Your running app inherits them, then your app's config layer reads them into typed settings.

Unlike hard-coded constants, env vars change between deploys without touching source code. The same binary runs in local, staging, and production — only the env changes. This is principle III of the Twelve-Factor App: store config in the environment.

For a deeper primer, we have a full guide on .env files. This post is broader — it covers the shell, the runtime, the host, and the team practices around all of them.

How to set environment variables

On the shell (one-off, for the current session)

# macOS / Linux (bash, zsh)
export DATABASE_URL=postgres://localhost/app
export JWT_SECRET=xxxxx

# Verify
echo $DATABASE_URL

# Windows PowerShell (session-only)
$env:DATABASE_URL = "postgres://localhost/app"

# Windows CMD (session-only)
set DATABASE_URL=postgres://localhost/app

# Windows, persistent (requires new shell to pick up)
setx DATABASE_URL "postgres://localhost/app"

Persistent per-user (Unix)

# Append to ~/.zshrc, ~/.bashrc, or ~/.profile
echo 'export DATABASE_URL=postgres://localhost/app' >> ~/.zshrc
source ~/.zshrc

Important: variables you set this way leak into every process you launch. A DATABASE_URL meant for one project now sits in every shell, including when you're working on a different project. This is why per-project direnv or .env files almost always beat ~/.zshrc.

Per-project (the grown-up approach)

Drop an .env file in your repo root and let your app load it at startup. Every modern framework supports this natively or via a one-line package.

Never put the real file in git — commit a .env.example with keys only. Our example generator produces one from your live .env in one click.

In production (do NOT ship .env)

Production hosts inject env vars for you — no files on disk, no secrets checked into git. Every host has a dashboard: Vercel, Railway, Fly, Render, AWS (via Parameter Store or Secrets Manager), GCP, Azure, Kubernetes ConfigMaps/Secrets. See our .env vs Kubernetes Secrets comparison for the Kubernetes-specific gotchas.

Reading environment variables — by language

Node.js / TypeScript

// Node 20+ can load a file natively:
// node --env-file=.env server.js

// Or use dotenv:
import 'dotenv/config';

const dbUrl = process.env.DATABASE_URL;  // string | undefined

// Type-safe: validate with Zod at boot
import { z } from 'zod';
const env = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().default(3000),
}).parse(process.env);

Next.js (App Router)

Next.js reads .env.local automatically. Any variable prefixed with NEXT_PUBLIC_ is inlined into the client bundle at build time; everything else stays server-only. See our NEXT_PUBLIC_ guide for the full semantics.

Python

import os
from dotenv import load_dotenv

load_dotenv()
db = os.environ["DATABASE_URL"]      # required, raises KeyError if missing
debug = os.getenv("DEBUG", "false")  # optional, with default

# Better: Pydantic BaseSettings for type-safe validation
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
    database_url: str
    debug: bool = False
    class Config:
        env_file = ".env"
settings = Settings()

Ruby / Rails

# Gemfile
group :development, :test do
  gem 'dotenv-rails'
end

# Anywhere
ENV['DATABASE_URL']

# Rails: prefer encrypted credentials for production
Rails.application.credentials.db[:password]

Go

import "github.com/joho/godotenv"
import "os"

godotenv.Load()
dbURL := os.Getenv("DATABASE_URL")

// Type-safe with envconfig
type Config struct {
    DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
    Port        int    `envconfig:"PORT" default:"8080"`
}
var cfg Config
envconfig.MustProcess("", &cfg)

PHP / Laravel

# .env (auto-loaded by phpdotenv)
APP_KEY=base64:...
DB_CONNECTION=mysql

// config/database.php  — only call env() inside config files
'default' => env('DB_CONNECTION', 'mysql'),

Laravel gotcha: config:cache freezes env values at build time. Calling env() outside config files returns null in production. Move env reads to config and use config() everywhere else.

Docker

# docker-compose.yml
services:
  api:
    env_file:
      - .env              # passes vars into the container
    environment:
      - NODE_ENV=production  # inline override

# Dockerfile
ENV NODE_ENV=production    # baked into the image
ARG BUILD_VERSION           # build-time only, not in runtime

Need to turn a local .env into a docker-compose.yml, Dockerfile, or Kubernetes Secret? The browser converter does it in one click.

Why env vars exist (and where they come from)

The environment as a concept predates modern app development by decades. It lives in the kernel — every process has a string-array "environ" block inherited from its parent. That's why a shell variable you export shows up in every program you launch from that shell.

Two consequences:

  • They're cheap. No file I/O, no API call — reading an env var is a memory lookup. Fine to do in a hot path.
  • They're global within a process. Changing process.env.X after startup works, but most frameworks cache values and won't pick up the change.

The three common "kinds" of env var: system-wide (defined for every user, e.g., PATH), user-specific (your shell rc file), and process-specific (the app's own .env, overrides from Docker or the host). A running app usually sees all three merged.

The seven rules

These are the rules our team adopted after each of us caused at least one production incident. Internalize them and most classes of env-related bugs disappear.

1. Never commit secrets — ever, even "temporarily"

Put .env, .env.local, and any environment overrides in .gitignore before your first commit. Committing .env.example is fine and encouraged — keys only, no values.

If a secret ever reaches a remote, assume it's compromised. Rotate the key, remove it from git history with git filter-repo, and audit access logs. Full procedure in our hide .env from git guide.

2. Validate on boot; crash loud if a key is missing

The nastiest env bugs happen when a variable is missing and the app silently treats it as undefined, then fails three hours later with a confusing stack trace. Fix: validate every required variable at boot and fail fast.

// TypeScript, with Zod
import { z } from 'zod';
const env = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
}).parse(process.env);

// Export the typed object, never process.env
export { env };

Equivalent libraries: envalid, t3-env (Next.js), convict, Pydantic BaseSettings (Python), envconfig (Go). Pick one.

3. Generate secrets — never invent them

A human-typed JWT secret has maybe 30 bits of entropy. A generated one has 256+. Use openssl rand -base64 48, python -c "import secrets; print(secrets.token_urlsafe(48))", or our browser-based secret generator which uses crypto.getRandomValues — the same primitive.

When a key leaks, you need to rotate. If your secret is mycompany-prod-secret, you can't rotate — you just picked a marginally different guessable string. Random secrets are the only kind worth having.

4. Prefix consistently; scope explicitly

Good names are prefixed by ownership:

# Clear scoping
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLIC_KEY=pk_...

DATABASE_URL=postgres://...
DATABASE_POOL_SIZE=10
DATABASE_STATEMENT_TIMEOUT=5000

# Client-safe (Next.js inlines into the browser bundle)
NEXT_PUBLIC_SITE_URL=https://mycompany.com
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_...

Grep-ability matters. Six months later when someone asks "which keys touch Stripe?" you want grep STRIPE_ .env to give a complete answer.

5. Diff environments before every release

Staging and production drift silently. A key gets added to one and not the other. A value changes. You don't notice until the release. Run a diff check before every deploy:

# One-line CI check: keys in prod that aren't in staging
comm -23 \
  <(grep -E '^[A-Z_]+=' prod.env | cut -d= -f1 | sort) \
  <(grep -E '^[A-Z_]+=' staging.env | cut -d= -f1 | sort)

Or paste both files into our online .env diff checker — masks values and gives you a shareable report.

6. One secret per purpose

Don't reuse JWT_SECRET as your session cookie key. Don't share a Stripe key between two projects. Don't use the same database password in staging and production. Blast radius stays small when secrets are scoped — a single rotation shouldn't take down three apps.

7. Scan before you share

Before you paste an .env into a ticket, Slack, or an email: run it through a leak scanner. Our browser scanner flags 17+ known secret patterns (AWS, Stripe, GitHub, OpenAI, Anthropic, Slack, private key blocks). Takes two seconds. Saves hours of rotation work.

Where to actually store production secrets

In rough order of team size / risk tolerance:

Solo or tiny team: host dashboard

Vercel, Railway, Fly, Render, Netlify all have an env UI. Set values, redeploy, done. No infrastructure to run.

Small team: shared password manager or secret manager

1Password Secrets Automation, or a managed secrets platform like Doppler or Infisical. Both have generous free tiers and auto-inject secrets into your app at runtime.

Regulated shops: Hashicorp Vault or cloud-native secret stores

AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or Hashicorp Vault for dynamic secrets and fine-grained policy. Higher operational cost, but required for SOC 2 / HIPAA / PCI.

For a deeper trade-off analysis, read our .env vs secrets manager comparison.

Tool-driven workflow we actually use

This is the loop we run when touching env vars:

  1. Generate a starting .env with our env generator (pick your stack: Node.js, Next.js, Django, Rails, Laravel, Stripe, Firebase, Supabase, OpenAI — secrets auto-generated in-browser).
  2. Validate it with the validator — catches syntax bugs, duplicates, empty values, bad identifiers.
  3. Scan for accidentally-committed real secrets with the leak checker.
  4. Diff against the other environments with the diff checker to confirm no drift.
  5. Detect missing keys against your .env.example with the missing env detector.
  6. Produce a clean .env.example with the example generator before committing.

Everything runs in your browser. Nothing ever gets uploaded — that's the one non-negotiable about env tooling.

Frequently asked

Can I use env vars for feature flags?

For simple boolean toggles that flip per deploy — yes. FEATURE_NEW_CHECKOUT=true is fine. For anything with percentage rollouts, user targeting, or A/B logic, use a real feature-flag service (LaunchDarkly, Unleash, Flagsmith). Env vars change on rebuild; flag services change at runtime.

Are env vars encrypted?

No. At rest, they're plain strings in your host's database or on your machine's filesystem. Most hosts encrypt their database at rest, so the values are encrypted there — but in your process memory, they're just strings.

What's the maximum length / size?

Unix allows up to ~128KB for the total environment on most systems (ARG_MAX). Windows limits individual variables to 32KB. Don't stuff certificates or JSON blobs into env vars — use mounted files or a secrets manager for that.

Do env vars affect security headers?

Indirectly. Your code reads env vars to decide what to emit — but the headers themselves come from your framework's response pipeline. If you set CSP_REPORT_URI via env, make sure your middleware reads it once at boot rather than on every request.

TL;DR

  • Env vars are the standard way to separate config from code.
  • Set them in the shell, in .env files, or on your host's dashboard — never hardcode.
  • Validate on boot; crash loud on missing required vars.
  • Never commit real values; always commit .env.example.
  • Generate secrets — don't invent them.
  • Diff environments before every release.
  • Scan for leaks before sharing.
  • Use a secrets manager once your team is bigger than five.

Bookmark this page, paste your .env into the validator, and the worst class of prod incidents largely goes away.

Try these tools

Related guides