Dotenv Best Practices: Fixing "Environment Variables Not Loading"
Dotenv best practices for fixing environment variables not loading — load order, .env.local precedence, and the mistakes that silently break config.
Try it now: ENV Parser — Convert a .env file to JSON, YAML, shell exports, Docker flags or tfvars, and catch duplicate keys and quoting mistakes before they reach a container.
Why 'Environment Variable Not Loading' Almost Always Has a Boring Cause
Nearly every “my environment variable isn't loading” report traces back to one of four mechanical causes, not a bug in the loader library. The value is never actually missing from the .envfile — it's the file the process can't find, a stale process that hasn't re-read it, a shell variable silently winning over it, or a value that was never checked into anyone else's copy of the repo. This is a diagnostic walkthrough of those four causes, in the order worth checking them, followed by the version-control and secrets-management habits that keep them from recurring.
Cause 1: The .env File Isn't in the Working Directory the Process Actually Uses
Most dotenv-style loaders resolve .env relative to the process's current working directory — not the directory the entry-point script lives in, and not the project root by assumption. Those are the same directory when you run npm start from the repo root, which is why the distinction goes unnoticed for months. It stops being the same directory the moment something else launches the process: a monorepo task runner invoking a package from its own subfolder, a process manager like PM2 or systemd with a different working-directory setting, a Docker WORKDIR that doesn't match where the file was copied, or an IDE run configuration that defaults to the project root instead of the package folder.
repo/
├── .env ← lives at the repo root
└── apps/
└── api/
└── server.js ← process.env.DATABASE_URL is undefined here
# running from repo root:
$ node apps/api/server.js
# cwd is repo/ → .env found, DATABASE_URL loads
# running via a task runner that cd's into the package first:
$ pnpm --filter api start
# cwd is repo/apps/api/ → no .env there → DATABASE_URL is undefinedThe fix is either to move .envinto every directory a process actually starts from, or to load it with an explicit path rather than relying on the loader's default lookup — require("dotenv").config({ path: require("path").resolve(__dirname, "../../.env") }) removes the ambiguity entirely. When debugging this, don't guess: log process.cwd() at the top of the entry point and compare it to where the .env file actually sits on disk.
Cause 2: The Process Was Started Before the File Existed or Changed
Almost every .env loader reads the file exactly once, at process startup, and copies its values into process.env in memory. Editing .envafter that point does nothing to a running process — there's no file watcher involved unless the framework explicitly adds one. Saving the file is not the same action as reloading it, and this is the single most common “I definitely set it, why isn't it there” moment: the variable was added, or its value was corrected, while the dev server or background worker from ten minutes ago was still running against the environment it captured at boot.
$ node server.js & # started before API_KEY existed in .env
$ echo "API_KEY=sk_live_x" >> .env
$ curl localhost:3000/status
{"apiKey": null} # server.js is still the process from before the edit
$ kill %1 && node server.js # restart — re-reads .env at startup
$ curl localhost:3000/status
{"apiKey": "sk_live_x"} # now correctFrameworks with hot reload (Next.js dev mode among them) restart their own module graph on file changes, but that doesn't always extend to environment variables read once at process boot rather than per request — check the framework's docs for which category a given variable falls into, and when in doubt, restart the process rather than trusting a save.
Cause 3: A Stray Shell Export Is Silently Winning
By design, most dotenv-style loaders do notoverride a variable that's already present in process.env when the loader runs — .envfills gaps, it doesn't replace existing exports. That default exists so a real deployment environment's variables (set by the platform, the CI runner, or an orchestrator) always take priority over a local file meant for development. The failure mode it creates: a variable exported into a shell session earlier — testing something, following an old tutorial, sourcing a stale .bashrc line — stays exported for the lifetime of that shell, and every process launched from it inherits the old value no matter what .env says.
# earlier in the same terminal session, months ago: $ export NODE_ENV=production # .env now says: NODE_ENV=development $ node server.js # process.env.NODE_ENV is still "production" — dotenv did not override it # confirm the collision: $ printenv NODE_ENV production $ unset NODE_ENV $ node server.js # now .env's "development" value takes effect
printenv VAR_NAME (or echo $VAR_NAME) before starting the process is the fastest way to rule this out — if the shell already reports a value, that's what wins, regardless of what.env contains.
Committing .env Is a Security Incident, Not Just a Bug
Checking a real .envfile into version control does two kinds of damage. The security damage: once a secret is committed, deleting the file in a later commit does not remove it — it stays retrievable from git history indefinitely, in every clone and fork, until history itself is rewritten and every existing clone is invalidated. The collaboration damage: teammates end up running against whatever values happened to be committed, which silently diverge from what a fresh setup actually needs, producing the exact “works on my machine” bugs that are hardest to reproduce because the environment difference is invisible.
- Add
.envto.gitignorebefore the first commit that could contain one— retrofitting it after a secret has already been pushed only stops the bleeding, it doesn't undo the exposure. - Commit a
.env.exampleinstead, listing every variable name the project expects with placeholder or empty values, so setup is documented without any real secret ever touching the repository. - Rotate the credential immediately if a real secret does end up in a commit — treat the old value as burned rather than relying on a force-push and history rewrite to fully contain it.
.env Files Are a Local-Development Convenience, Not a Production Secrets Store
A .envfile solves a narrow problem well: giving one developer's local process a set of values without hardcoding them into source. It was never designed to be the security boundary for production credentials, and using it that way in deployment usually means a plaintext file with database passwords and API keys sitting on a server's disk, readable by anything with filesystem access and rarely rotated because rotating it means editing a file by hand on every host. Production secrets belong in whatever mechanism the platform already provides for encrypted, access-controlled environment configuration — a managed secrets manager, or the hosting platform's own encrypted environment variable settings — not a text file shipped alongside the code.
Before any of that, though, the file has to actually parse the way the code expects — a duplicate key that silently overwrites an earlier one, or a value that needed quoting but wasn't quoted, produces exactly the same symptom as the four causes above: a variable that looks present in the file but isn't what the process receives. GenKitLab's ENV Parser converts a .env file to JSON, YAML, shell exports, Docker flags, or Terraform tfvars, and flags duplicate keys and quoting mistakes in the process — before they reach a container instead of after. It runs entirely in your browser; nothing you paste is uploaded anywhere. If you need the reference for .env syntax itself — quoting rules, comments, multiline values — that's covered in the dotenv file syntax guide.
Frequently asked questions
›Why is my environment variable not loading even though it's in the .env file?
Check, in order: whether the process's current working directory is actually the folder containing .env (loaders resolve it relative to cwd, not the script's location); whether the process was started before the file existed or was last edited, since most loaders read .env once at startup, not on every access; and whether the same variable is already exported in the shell session, since dotenv-style loaders typically don't override an existing process environment variable.
›Do I need to restart my server after editing .env?
Yes, in most setups. The loader reads the file once at process startup and copies the values into memory — editing and saving the file afterward doesn't change what a running process already has, unless the framework specifically implements file watching for environment variables.
›Why does a shell-exported variable override my .env value?
By design. Most dotenv-style loaders only fill in variables that aren't already present in process.env, so that a real deployment's environment variables always take priority over a local file. If a variable was exported earlier in the same shell session, it stays set for that session's lifetime and wins over whatever .env specifies.
›Is it safe to commit a .env file to Git if I delete it later?
No. Once a commit contains the file, it remains retrievable from git history in every clone and fork even after a later commit removes it — only rewriting history and invalidating existing clones fully removes it, and any real secret that was exposed should be rotated regardless.
›What should I commit instead of .env?
A .env.example file listing every variable name the project expects, with placeholder or empty values instead of real ones. Add .env itself to .gitignore before the first commit that could contain real values.
›Should production secrets live in a .env file on the server?
No. .env files are a local-development convenience, not an access-controlled or encrypted store. Production credentials belong in a dedicated secrets manager or the hosting platform's own encrypted environment variable configuration, not a plaintext file on disk.
Last updated