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 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 strings | Production secrets |
| API keys for dev/test | Passwords 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
| Framework | Client prefix | Access pattern |
|---|---|---|
| Next.js | NEXT_PUBLIC_ | process.env.NEXT_PUBLIC_X |
| Vite | VITE_ | import.meta.env.VITE_X |
| CRA | REACT_APP_ | process.env.REACT_APP_X |
| Nuxt | NUXT_PUBLIC_ | useRuntimeConfig().public.x |
| SvelteKit | PUBLIC_ | $env/static/public |
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):
.env(lowest priority).env.local.env.development(or.env.production).env.development.local(or.env.production.local) (highest priority)
Security Rules
| Rule | Reason |
|---|---|
Never commit .env files with real secrets | Git history is permanent. Even if you delete the file, the secret is in the history |
| Rotate secrets immediately if exposed | Don't assume nobody saw it. Bots scan GitHub for leaked keys within seconds |
| Use different secrets per environment | If staging secrets leak, production is still safe |
| Don't log environment variables | console.log(process.env) dumps all secrets to your log aggregator |
| Don't put secrets in Docker images | Images are often stored in registries accessible to many people |
| Use a secrets manager in production | AWS Secrets Manager, Vault, Doppler — not .env files on a server |
What to Do When You Accidentally Commit a Secret
- Immediately rotate the secret. Generate a new API key, password, etc.
- Remove from Git history:
git filter-branch --force --index-filter \
"git rm --cached --ignore-unmatch .env" \
--prune-empty --tag-name-filter cat -- --all
- Force push (coordinate with your team)
- Check audit logs of the affected service for unauthorized access
Common Mistakes
- Using
process.enveverywhere 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.
- Assuming env vars are available in client-side code. Frontend frameworks bundle env vars at build time, and only prefixed variables.
process.env.SECRET_KEYin a React component isundefined.
- Not providing defaults for optional variables.
parseInt(process.env.PORT)returnsNaNwhen PORT is undefined. Always provide a fallback:parseInt(process.env.PORT || "3000", 10).
- 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.
- Different .env file formats across tools. Docker, Node.js dotenv, and shell scripts handle quoting differently. Stick to
KEY=valuewithout spaces around=. Quote values containing spaces:KEY="value with spaces".