How to remove a .env file from Git history
You committed .env. Maybe it was git add . on a repository whose .gitignore was written
after the first commit. Maybe a teammate renamed .env.example and lost the suffix. Either
way the file is in the history now, and deleting it in a new commit does nothing: every
previous commit still contains it, and git show will hand it to anyone who asks.
Here is the order of operations that actually resolves it.
Rotate the secrets first
This is the part people skip, and it is the only part that is not optional.
The moment a secret reaches a remote, treat it as public. It has been copied to every clone, every CI cache, every fork, and — if the repository was ever public — to the various services that index public commits within seconds of a push. GitHub itself scans pushes for known token formats and notifies providers. Rewriting history does not reach any of those copies.
So before touching the history, go and rotate every credential in that file. New database password, new API keys, new signing secret. If you cannot rotate something, at minimum constrain it: narrow an API key's IP allowlist, revoke unused scopes, or expire the session family it signs.
Once the values are dead, the copies in the history are worthless, and everything below is housekeeping rather than an emergency.
Rewrite the history
Use git-filter-repo. Git's own documentation
recommends it over filter-branch, which is slow and has enough sharp edges that Git prints a
warning when you run it.
# Work on a fresh clone. filter-repo refuses to run on a repo with a remote
# configured, precisely so you do not destroy the only copy you have.
git clone --mirror git@github.com:you/your-repo.git repo-clean
cd repo-clean
git filter-repo --invert-paths --path .env
Check the result, then force-push every branch and tag:
git push --force --all
git push --force --tags
If you cannot install filter-repo, the BFG Repo-Cleaner is a reasonable alternative:
bfg --delete-files .env
git reflog expire --expire=now --all && git gc --prune=now --aggressive
Clean up what the rewrite cannot reach
A history rewrite changes every commit hash after the touched commit, which has consequences:
- Every clone is now wrong. Anyone who pulls will get a mess of conflicts. Tell your team to re-clone rather than merge. A merge will quietly reintroduce the old commits.
- Open pull requests break. They point at commits that no longer exist and generally need to be recreated.
- The forge keeps unreferenced commits for a while. On GitHub, a commit stays reachable by its SHA even after a force-push until garbage collection runs, and cached views can linger. Open a support request to have them dropped if it matters.
Then stop it happening again
.gitignore is necessary and not sufficient — it only helps for files Git is not already
tracking, which is exactly the case that failed. Two additions help more:
Add a pre-commit hook that refuses staged files matching .env* unless they are examples:
#!/bin/sh
if git diff --cached --name-only | grep -E '(^|/)\.env($|\.)' | grep -qv '\.example$'; then
echo "refusing to commit a .env file" >&2
exit 1
fi
And run a secret scanner in CI — gitleaks or trufflehog — so a secret that reaches a branch fails the build rather than sitting in the history until someone notices.
The underlying problem
All of the above is a treatment, not a cure. The reason .env files end up in Git is that
they are the only copy of a value that several people need, so they get passed around, and
things that get passed around eventually get committed. A file that lives on one laptop cannot
be shared, so it stops being a file and becomes a Slack message, a screenshot, or a commit.
The fix is to have somewhere the values actually live, that developers can read on demand, so the local file is a disposable cache rather than the source of truth. That is what turboEnv is for: variables are stored encrypted, versioned on every save, and pulled at startup, so the file on disk can be deleted at any time without losing anything.
npx turboenv-cli run -- npm start
No .env in the working tree means no .env to commit.