.env vs .env.local, and which one belongs in git

Two files, the same key defined in both, and only one of them wins. Which one depends entirely on the framework you are running, and a lot of the advice written about this quietly assumes Next.js and then generalises.

The short version: .env.local is a machine-specific override and is never committed. .env is either shared defaults or the whole real file, and which of those it is changes per stack. Everything below that is worth knowing per framework, because the differences are the bit that bites.

Next.js

Next.js loads several files and the first place a key is found wins:

  1. The process environment (anything already exported in the shell, or set by the host)
  2. .env.$(NODE_ENV).local
  3. .env.local
  4. .env.$(NODE_ENV)
  5. .env

NODE_ENV is development under next dev, production for next build and next start, and test under a test runner. So during development .env.development.local beats .env.local, which beats .env.development, which beats .env.

There is one exception, and it is the one people lose an afternoon to: .env.local is not loaded at all when NODE_ENV is test. That is deliberate, so tests get identical values on every machine and in CI rather than picking up whatever a developer happens to have locally. Test values belong in .env.test.

A variable prefixed NEXT_PUBLIC_ is inlined into the JavaScript at build time. Two consequences: changing it means rebuilding, not restarting, and anyone who opens devtools can read it. Next.js’s own guidance is that .env, .env.development and .env.production are repository files holding defaults, while anything matching .env*.local is gitignored.

The Next.js page covers what Dotvault recognises in a Next.js project.

Vite

Vite reads four files, and the mode decides which of them apply:

  • .env and .env.local load in every mode.
  • .env.[mode] and .env.[mode].local load only in that mode.

Mode-specific beats generic and .local beats non-local, so .env.production.local sits at the top of the pile and plain .env at the bottom. The mode is development for the dev server, production for a build, or whatever you pass to --mode. Variables that already exist in the environment when Vite starts are not overwritten by any of those files.

Only variables prefixed VITE_ are exposed to client code through import.meta.env. The prefix is configurable, but changing it is a decision you make once. Vite’s advice is to gitignore *.local, which is why its scaffolds ship with that rule already in place.

Laravel

Laravel has one .env and no .local convention at all. If APP_ENV is set as a real environment variable before the framework boots (web server config, your shell, or --env= on an Artisan command) Laravel loads .env.<that value> instead, falling back to .env when the file does not exist. Setting APP_ENV inside .env does not switch which file loads, because by then the file has already been chosen. A test suite that sets APP_ENV=testing will pick up .env.testing if you have one.

Real environment variables override anything in the file, which is how most hosting platforms inject configuration without a file on disk at all.

The git answer is unusually clear here: Laravel’s own skeleton .gitignore ignores .env outright, along with a couple of variants. The default is that nothing real is committed and .env.example does the documenting.

Create React App and Vue CLI

These two popularised the .local layering that Vite and Next.js later inherited. Create React App resolves .env.development.local, .env.local, .env.development, .env on start, the same shape with production on build, and skips .env.local under test. Vue CLI uses the same four-file model as Vite. Client exposure is REACT_APP_ and VUE_APP_ respectively.

Both are legacy now. Create React App is no longer the recommended way to start a React project and Vue CLI is in maintenance mode pointing at Vite. If you are reading either one’s documentation for load order, read the successor’s instead.

Node and dotenv itself

This is the part most posts skip. Plain dotenv loads one file, .env from the working directory, and stops. No modes, no cascade, no .local, no precedence rules, and it will not overwrite a variable already present in process.env unless you ask it to. Node’s own --env-file flag loads exactly one file per flag.

So the layering is not a dotenv feature. Every cascade above is a framework convention built on top of it, which is why the answer differs per stack and why a plain Node script gets none of it until you wire it up yourself.

Rails, Python and Django

Rails ships no env file mechanism. The built-in answer is encrypted credentials committed to the repo with the master key gitignored, so the file in git is ciphertext and the secret is the key you keep out of it. Add the dotenv-rails gem and you get a layered cascade of its own, with the ordering documented by the gem rather than inherited from anywhere above.

Python’s python-dotenv behaves like plain dotenv: one file, no cascade, existing variables left alone. Django has no opinion of its own, and the common convention splits environments across settings modules chosen by DJANGO_SETTINGS_MODULE rather than across filenames. The split lives in Python, not in the filesystem.

Which files belong in git

File Commit it? Why
.env.example, .env.sample, .env.template Yes Keys and shape, no values. The only file that is unambiguously safe.
.env.local, .env.*.local No Machine-specific by definition. Every framework that defines them says as much.
.env in Laravel, Rails, plain Node No It is the real file with the real values in it.
.env in Next.js and Vite Judgement call Intended as shared defaults. Safe only while it genuinely holds no secrets.
.env.development, .env.production, .env.test Judgement call Fine committed while they hold non-secret config. The moment one key lands in one, it stops being fine and nothing tells you.
.env*.encrypted Yes Ciphertext. The key is shared out of band.

The reasoning behind the judgement calls: committing a defaults file saves a new contributor a few minutes of setup, while committing a secret costs a rotation and a rewrite of history that never quite catches every copy. Those are not comparable risks, so when you are not certain, do not commit it.

Three traps

A .gitignore containing .env does not cover the rest. Gitignore matches names and patterns, not prefixes, so a rule saying .env matches .env and nothing else. .env.local, .env.production and .env.development.local all sail straight through. The usual fix is .env* followed by !.env.example, in that order, since the negation has to come after the rule it undoes. Worth knowing too: gitignore does nothing about a file that is already tracked. That needs git rm --cached, the value stays in the history regardless, and rotating the secret is the only fix that actually fixes anything.

A public prefix means the value is published. NEXT_PUBLIC_, VITE_, REACT_APP_, VUE_APP_, NUXT_PUBLIC_. A value behind any of those is compiled into the bundle your users download. How carefully it was stored beforehand is irrelevant, encryption included, because the build step copies it into a public file. If a real secret has gone out under a public prefix, the answer is a different credential with narrower scope, or a rotation.

The file that wins is often not a file. Every precedence list above starts with the process environment. A leftover export DATABASE_URL in a shell profile beats every env file in Next.js, Vite, Laravel and dotenv alike, and nothing in any of those files hints that it is happening. Before going through the files looking for a wrong value, run printenv for the key in the shell you actually start the app from. When it really is a file, remember that the more specific and more local one wins, so the file you have been editing may well be the one being ignored.

Where Dotvault helps with this

Dotvault lists every env file in a project folder side by side in one sidebar, so .env, .env.local and .env.production sit next to each other rather than in three editor tabs. Each file gets an environment type worked out from its filename, and you can click the badge to override it when the guess is wrong.

For “which of these two files is different”, the Diff tab takes a source and a target file and lists every key across both, highlighting rows where the values differ or a key is missing from one side. The Compare tab goes wider, showing one row per variable and one column per file. Syncing keys between files, and keeping .env.example current, is covered in Syncing env files across environments.

The sidebar’s I badge tells you a file is gitignored, which is the fastest way to confirm your .env* rule really did catch .env.local instead of trusting the pattern to be right. Keeping .env files out of git goes further into that side of it.

Two honest limitations. Dotvault does not evaluate load order, so it shows you what each file contains rather than which value your app ends up with at runtime, and that depends on NODE_ENV, the mode and your shell. And a project is a single folder with a scan that is not recursive, so a monorepo where each package keeps its own env files is several projects in the sidebar rather than one.

If side-by-side env files with a diff view and snapshot history behind every save sounds useful, Dotvault is a Mac app that does exactly that.