Environment variables are the simplest configuration mechanism there is — read a string, keyed by name, from the process's environment. That simplicity is exactly why they're everywhere, and also why the same handful of mistakes show up in almost every codebase that uses them.
Config lives outside your code, on purpose
The reason to use environment variables instead of a config file checked into git is separation: the same build artifact should run in development, staging, and production without being rebuilt for each — only what surrounds it changes. A database URL, an API key, a feature flag threshold — none of that belongs baked into the code itself, and environment variables are the lowest-friction way to inject it at runtime instead.
.env files are a local convenience, not a production mechanism
This trips up a lot of people new to the convention: a .env file is read by a library (dotenv in Node, python-dotenv in Python, and similar ports elsewhere) that opens the file and copies its contents into the process's environment at startup. Nothing about .env is special to the operating system or the language runtime — it's userland convenience for local development, where there's no deployment platform around to set real environment variables for you.
In production, there's usually no .env file at all. Vercel, Heroku, Docker (-e / --env-file at the container runtime level, not inside the image), Kubernetes (ConfigMap/Secret), and CI platforms all set real process-level environment variables through their own mechanism. The application code doesn't need to know or care which one — it just reads process.env.WHATEVER either way, which is the whole point.
Every value is a string — always
This is universal across languages, not a JavaScript quirk: an environment variable is a string, full stop. PORT=3000 gives you the three-character string "3000", not a number — pass it through Number() or parseInt() before doing arithmetic with it. DEBUG=false gives you the string "false", and in JavaScript if (process.env.DEBUG) is true for any non-empty string — including the string "false". Compare explicitly (process.env.DEBUG === "true") rather than relying on truthiness.
.env.example: document the shape, not the secrets
The standard convention is a .env file that's gitignored (never committed) alongside a .env.example that is committed, listing every key the app expects with placeholder or empty values:
# .env.example — committed
DATABASE_URL=
API_KEY=
NODE_ENV=development
# .env — gitignored, real values
DATABASE_URL=postgres://localhost/myapp_dev
API_KEY=sk_test_...
NODE_ENV=developmentThe failure mode isn't forgetting this convention exists — it's the template quietly drifting out of sync with reality. Someone adds a new required variable, updates their own .env, and forgets .env.example; the next person to set up the project hits a confusing runtime failure instead of an obvious missing-config error. Environment Variable Diff exists specifically for this — run your .env.example against a real .env and any variable the template declares that never actually got set shows up immediately.
The parsing gotchas that bite silently
.env syntax looks simple enough that people assume it can't really go wrong, which is exactly what makes its edge cases dangerous — they fail silently instead of throwing. An unquoted value gets truncated at the first #, whether or not there's a space before it; a key defined twice has its earlier value discarded with no warning; an unclosed quote can silently swallow several lines' worth of other variables. None of these raise an error — they just produce a value that isn't what you wrote. .env Validator checks a file against the real parsing rules and flags exactly this class of problem before it costs you a debugging session.
One format, many destinations
The same key/value data ends up needing to live in more than one shape: a flat .env for local development, a YAML mapping for a Kubernetes ConfigMap or a GitHub Actions env: block, or a Helm values.yaml. Converting by hand is where the string-vs-other-types problem above resurfaces in a new form: an unquoted PORT: 3000 or FLAG: yes in YAML is read back as a number or boolean by most parsers, not the string it needs to be. .env to YAML and YAML to .env handle the conversion in both directions with every value safely quoted, so the type never silently changes underneath you.
If a secret leaks, rotate — don't just delete
A real credential that ends up in a .env file committed to git, pasted into a chat, or logged somewhere is compromised the moment that happens — removing the file afterward doesn't undo the exposure, especially once it's been pushed (git history keeps it) or indexed anywhere. Rotate the credential at its source first, then clean up the file. This is the same principle covered in more depth for CI secrets specifically in GitHub Actions Secrets Checker's documentation, and it applies just as much to a stray .env.
Try it yourself
.env Validator catches the parsing gotchas above before they cost you a debugging session, Environment Variable Diff compares two files (or a template against reality) using their actually-resolved values, and .env to YAML / YAML to .env convert between the two formats safely in either direction. All four run entirely in your browser.