Should you commit .env to git?

No. Commit a .env.example instead, and keep the real .env out of the repo with .gitignore.

If that is all you came for, stop reading. The rest of this page covers why the answer is that firm, how to get .gitignore right, and the case that actually brings people here: the .env is already committed and has been for months.

Why a committed .env is worse than it looks

“Secrets leak” is the summary. The mechanics are the part worth knowing.

Git history is permanent by design. Deleting the file in a later commit does not remove it from the repo. Every commit that ever contained it still contains it, and getting the value back out is one command:

git log --all --oneline -S 'sk_live_'
git show <commit>:.env

That is not an obscure recovery trick. It is the first thing any secret scanner does, and there are a lot of secret scanners.

Clones and forks carry all of it. A clone is the full object database, not a snapshot of the current files. Anyone who cloned before you noticed has the secret on their disk, and a force-push later will not reach into their machine and take it back. A fork is worse: it is a separate repository owned by someone else, and the commit stays reachable there whether or not you ever find out it exists.

CI logs are a second copy. Build systems print environment values into logs more often than anyone intends: a set -x, a failing config dump, a debug flag left on at 2am. If the .env is in the repo the CI job reads it, log retention runs to months, and those logs are usually visible to everyone with read access.

A public repo is indexed within minutes. Automated scrapers watch the public event feed, and cloud credentials in particular are a known target. Assume the window between the push and the first attempt is minutes, not days.

A .env is also just wrong to share. Set the secrets aside. A .env is machine-specific config: your database host, your local ports, the mail driver you use for local testing. Commit it and everyone who clones starts with your machine’s settings, edits the file, and then shows up in git status with a modified .env forever. Nobody can tell a real config change from someone else’s local tweak, and the file becomes a permanent merge conflict. A .env holding nothing sensitive at all still does not belong in git.

Commit .env.example instead

.env.example is the file that gets committed. It documents the shape of the config without carrying the values.

What belongs in it: every key the app needs, including the ones with no safe default (an empty APP_KEY= is useful, a missing APP_KEY is not); genuinely safe defaults where one exists, like APP_ENV=local or MAIL_MAILER=log; comments saying where a value comes from, because “Stripe dashboard, test mode” saves the next person a Slack message; and the same ordering as the real file, so a diff between the two reads cleanly.

What must never be in it is a real value. Not a key, not a password, not the staging database host. An example file with a live value in it is the same problem as a committed .env, with the added disadvantage that everyone assumes it is safe.

Naming is convention rather than standard. .env.example is the most common, and .env.sample and .env.template are both in wide use. Pick one and use it consistently.

Getting .gitignore right

Here is the trap. This is not enough:

.env

Gitignore patterns are not prefixes. .env matches a file named exactly .env and nothing else, so .env.local, .env.production and .env.staging all sail through and get committed the first time someone runs git add .. This is the most common way a secret ends up in a repo that supposedly has “a gitignore for it”.

What you want is a wildcard plus a negation for the one file you do want tracked:

.env*
!.env.example

Two things about that. Order matters, because gitignore applies the last matching pattern: put !.env.example first and the line below silently overrides it. And .env* is broad enough to catch .envrc, which direnv users usually do commit, so add a second negation if that is you.

To check what git actually thinks rather than what you hope:

git check-ignore -v .env .env.local .env.example

It prints each file with the pattern that matched it. Files it does not print are not ignored. Run that before you trust a gitignore, especially in a monorepo where one root file has to cover env files sitting several packages deep.

The caveat that matters: .gitignore has no effect on a file that is already tracked. Git keeps managing files it already knows about, ignore rules or not. Adding the pattern to a repo where .env is already committed changes nothing at all, which is the hard case.

It is already committed

Work in this order. The order is the point.

1. Rotate the credentials, before anything else

The value is out, and has been since the push. Every minute spent arguing about history rewrites is a minute that credential is still valid, and rewriting history does not make an exposed key un-exposed. Issue a new key, revoke the old one, deploy the new one.

This is GitHub’s own advice: their guidance on removing sensitive data puts revoking or rotating the secret first, and their support team will only help purge cached copies where the risk cannot be handled by rotation. Rotation is not the fallback for when a history rewrite looks too hard. It is the actual fix.

If the repo was ever public, even briefly, treat the secret as burned regardless of what the logs show. No evidence of abuse is not evidence of no abuse.

2. Stop tracking the file

Once the credential is dead, stop the bleeding for future commits:

printf '.env*\n!.env.example\n' >> .gitignore
git rm --cached .env
git add .gitignore
git commit -m "Stop tracking .env"

git rm --cached removes the file from git’s index and leaves it on your disk. Add the gitignore entry first, or the file reappears as untracked and gets re-added by the next git add ..

One gotcha that catches teams every time: this commits a file deletion. When your colleagues pull it, git dutifully deletes their local .env too, because as far as git is concerned a tracked file went away. Tell them to copy their .env somewhere safe before they pull.

3. Decide whether to rewrite history

The file is gone from the tip of the branch and still in every commit that ever held it. Removing it properly means rewriting history, which is a coordinated, disruptive operation rather than a command you run on a Tuesday afternoon.

The tool is git-filter-repo. git filter-branch is deprecated and too slow to use on a real repo.

git filter-repo --path .env --invert-paths

--path selects a path and --invert-paths inverts the selection, so together they mean “keep everything except this”. Before you run it:

  • It refuses to run outside a fresh clone unless you pass --force. That guard exists so you cannot destroy local history that lives nowhere else. Respect it and work in a fresh clone.
  • It removes the origin remote when it finishes, deliberately, so nobody pulls the old history back in and re-pushes it.
  • It expires reflogs and garbage-collects immediately, so it is not reversible in that clone.
  • Every commit hash after the rewrite point changes. Open pull requests, tags, CI caches and anything pinned to a SHA will break, and everyone has to reclone. A colleague who merges an old branch afterwards puts the whole thing straight back.

BFG Repo-Cleaner is the other option and is faster on very large repos. It works on a --mirror clone, and it deliberately does not touch your latest commit, so step 2 has to happen first or BFG will leave the file alone.

Either way, be clear about what each step buys:

Step What it achieves What it does not
Rotate the credential The exposed value stops working. The only step that removes the actual risk. Nothing about the repo. The old string is still in history.
git rm --cached plus .gitignore The file stops being committed from now on. Touches history not at all. Every old commit still holds the value.
History rewrite The value is gone from your repo’s history. Existing clones and forks, cached views reachable by commit hash, pull request refs, CI logs, backups, anyone’s local copy.

That right-hand column is why rotation comes first. A rewrite earns its disruption when the leaked material is not a rotatable credential, such as a customer data dump, or as tidy-up once the credential is already dead. It is never a reason to delay rotating.

Where this actually bites in practice

CI variables shared across environments. One set of secrets configured at the org or repo level and injected into every job means the production database credential is available to a pull request build and to any workflow file someone can edit. Scope secrets per environment and require approval on the ones holding real values.

docker build baking the file into a layer. COPY . . copies .env in with everything else, and a RUN rm .env later does not help: the earlier layer still has the file, and anyone who can pull the image can extract it. Add .env to .dockerignore, and pass real secrets at runtime or through build secret mounts rather than build arguments, which persist in the final image.

Editor and shell backup files. .env~, .env.save, .env.bak, .env.swp. A gitignore listing .env* catches these. One listing only .env catches none of them.

.env in a published npm package. npm falls back to .gitignore only when there is no .npmignore. Add an .npmignore and it replaces .gitignore entirely, so a properly gitignored .env becomes publishable, and .env is not on npm’s list of always-excluded files. Run npm pack --dry-run and read the file list first.

Where Dotvault helps

Dotvault is a macOS app for editing and managing .env files. Three parts of it help with this problem, and none of them is a cleanup button.

You can see at a glance whether a file is git-visible. Each env file in the sidebar carries a badge for its git state: M modified, S staged, U untracked, I ignored. A clean tracked file shows no badge. You want I next to your real .env files, and its absence is the signal that the gitignore is not doing what you assumed. More in Working with git and what the badges mean.

Values that look like real credentials get flagged when git can see them. When a value matches a known secret pattern (Stripe, GitHub, Slack and AWS prefixes among others, plus key names like PASSWORD, SECRET and API_KEY) in a file that is tracked and not gitignored, Dotvault marks that row as an exposed secret. An ignored file raises nothing from this check, because a gitignored secret is exactly where a secret should be. It is a warning and only a warning: it does not block saving, does not untrack the file, and will not rewrite history. The fixes are the ones above, done by you. See what “exposed secret” means.

There is one warning gitignoring does not clear, and it is the other way round by design. If your framework compiles a prefix into the browser bundle, NEXT_PUBLIC_, VITE_, GATSBY_ and the rest, a value under it that can only be a secret is flagged whatever git thinks of the file. Keeping .env.local out of the repo is the right thing to do and changes nothing about what gets served to the page. See what “client-exposed secret” means.

.env.example stops drifting. After you save an env file, Dotvault checks whether any of your keys are missing from the committed example file and offers to add them. Keys only is the default, so the example gets the shape without the values, and it warns you specifically if you ask it to copy values into a file named .env.example, .env.sample or .env.template. See syncing env files and how example sync works.

One related point, since a correctly gitignored .env has no git history to consult: Dotvault keeps its own local, encrypted snapshot history of every env file, so you can diff and restore versions git has never seen. Per-variable git blame is shown too, but only on tracked files, which in a healthy repo means .env.example rather than .env.

None of that replaces the basics here. Get the gitignore right, commit an example file, rotate anything exposed. If you also want the app that keeps those habits visible while you work, Dotvault has a 14-day trial with no account and no card required. It runs on Apple Silicon Macs (why) and is a one-time purchase rather than a subscription (what it costs).