What you'll learn
Quick Answer
Environment variables hold configuration that differs between machines — database URLs, API keys, ports — outside your code, so the same code runs everywhere and secrets never enter version control. Keep them in a .env file that is gitignored, commit a .env.example listing only the names, and validate that required variables exist at startup. Anything sent to a browser is public regardless of how it is stored.
The Problem They Solve
Configuration differs between where you develop and where the code runs.
// Hardcoded — wrong in several ways at once
const db = connect('mongodb://localhost:27017/myapp_dev');
const stripeKey = 'sk_live_4eC39HqLyjWDarjtT1zdp7dc';Three problems. The database URL is wrong in production. The secret key is now in your git history permanently. And changing either means editing and redeploying code rather than changing a setting.
// Configuration comes from the environment
const db = connect(process.env.DATABASE_URL);
const stripeKey = process.env.STRIPE_SECRET_KEY;Now the same code runs anywhere, and the values are supplied by whatever machine it runs on.
What belongs in environment variables: database connection strings, API keys and tokens, the port, the environment name, feature flags, and third-party service URLs.
What does not: anything that is the same everywhere and is not secret. Application constants, route paths and business rules belong in code, where they are version-controlled and reviewable.
This is one of the twelve-factor app principles, and it is the difference between an application you can deploy and one that only runs on your laptop.
Using Them in Practice
In development, a .env file in the project root holds the values:
# .env — never committed
DATABASE_URL=postgresql://localhost:5432/myapp
JWT_SECRET=a-long-random-value-generated-not-invented
PORT=3000
NODE_ENV=developmentLoad it as early as possible, before anything reads a variable:
// Node — the very first line of your entry file
require('dotenv').config();
// Python
from dotenv import load_dotenv
load_dotenv()
import os
db_url = os.environ.get('DATABASE_URL')Load order matters and catches people out. If a module reads process.env.X at import time and dotenv.config() runs after that import, the value is undefined. The symptom is a variable that is set correctly and still comes through empty.
In production you usually do not use a .env file at all — the hosting platform provides variables through its own configuration, or systemd, or a container environment. That is the point: the mechanism changes, the code does not.
Everything comes back as a string.
process.env.PORT // '3000', not 3000
process.env.DEBUG // 'false' — which is TRUTHY as a string
const port = Number(process.env.PORT) || 3000;
const debug = process.env.DEBUG === 'true'; // compare explicitlyThe 'false' case is a genuine bug source — a string of any length is truthy, so a flag you set to false stays on.
Keeping Them Out of Git
Add .env to .gitignore before you create it, not afterwards.
# .gitignore
.env
.env.local
.env.*.localCommit a .env.example listing the names with no real values. This tells anyone cloning the project what they need to supply:
# .env.example — safe to commit
DATABASE_URL=
JWT_SECRET=
PORT=3000
STRIPE_SECRET_KEY=Without it, a new developer clones the project, runs it, gets an unhelpful crash, and has to read the source to discover what is missing.
Fail fast on missing variables. Validating at startup converts a mysterious runtime error into a clear one at the right moment:
const required = ['DATABASE_URL', 'JWT_SECRET'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}This is a small amount of code that prevents a specific painful failure — an application that starts successfully and then returns 500s for hours because one variable was never set on the server.
Never write a fallback for a secret.
const secret = process.env.JWT_SECRET || 'dev-secret'; // dangerousThe day the variable is missing in production, every token is signed with a value anyone who has read your repository already knows. Crash instead.
Frontend Variables Are Not Secret
This misunderstanding causes real key leaks, and it is worth being blunt about.
Frontend build tools expose variables with a specific prefix to browser code — REACT_APP_, VITE_, NEXT_PUBLIC_. The prefix is a safety mechanism to stop you leaking things accidentally, but the variables that do get exposed are baked into the JavaScript bundle and readable by anyone.
// This ends up in the bundle, visible in devtools
const key = process.env.NEXT_PUBLIC_API_KEY;Open the site, view the source, search for the value — it is there. There is no way to hide a secret in client-side code, regardless of how it was configured, minified or obfuscated.
So a secret key must never reach the browser. If your frontend needs to call a service with a private key, route the call through your own backend, which holds the key and is not a browser.
Some keys are designed to be public — a Firebase web config, a publishable Stripe key, a Google Maps key. These are fine in frontend code because the provider expects it, and security comes from server-side rules and domain restrictions rather than secrecy.
The test to apply: if someone reading this value could do damage, it cannot be in the frontend.
If You Commit a Secret
It happens, including to experienced developers. What matters is the response, and the order is not what most people assume.
1. Rotate the secret immediately. This is the step that actually protects you. Generate a new key at the provider, deploy it, then revoke the old one. Assume the old one is compromised — automated scanners crawl public repositories continuously and credentials are often exploited within minutes.
2. Then stop tracking the file:
git rm --cached .env
echo ".env" >> .gitignore
git commit -m "Stop tracking .env"3. Only then consider rewriting history. Tools such as git filter-repo or BFG can remove the value from past commits, but this rewrites every subsequent hash and disrupts everyone's clone. And it does not help if the repository was public — the value was already exposed.
Do not skip step 1 and only do steps 2 and 3. Deleting a file from history does not un-leak a credential. Rotation is the fix; cleanup is housekeeping.
Preventing it: add .env to .gitignore before creating the file, check git status before committing, and enable your platform's secret scanning, which warns when a recognisable key pattern is pushed.
Generate secrets rather than inventing them:
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
