How to remove a .env file from git history
It is one of the most common ways credentials leak, and one of the few where the instinctive fix — delete the file, commit, move on — leaves the problem completely intact. The file is gone from the working tree and every value in it is still sitting in the history, one `git log -p` away.
Do this first, before any git command
If the commit was ever pushed to a remote — GitHub, GitLab, a mirror, anything — then rotate every credential in that file. Not after the cleanup. Before it, and before you finish reading this page.
Rewriting history is housekeeping. It is not containment. By the time a commit reaches a hosted remote, the values have been through CI logs, fork caches, clones on other laptops, and — if the repository was ever public — automated scrapers that watch the public events firehose for exactly this. Those scrapers are fast. Assume the key was read.
Rotate, then clean. Cleaning first only makes the leak harder to find later, including for you.
Rotate in this order, so nothing is broken for longer than it needs to be:
- Issue the new credential in the provider, alongside the old one.
- Update it in your secret manager and in the deployment platform.
- Confirm the application is running on the new value.
- Then revoke the old one. Revoking before step 3 turns a leak into an outage.
If you have not pushed yet
A local-only commit is the easy case — nothing has escaped, and you can amend it away:
# the .env is in the most recent commit, which is not yet pushed
git rm --cached .env
echo ".env" >> .gitignore
git commit --amend --no-edit
Check that it is genuinely gone before trusting it:
git log --all --full-history -- .env
Empty output means the file appears in no commit that is reachable. Any output at all means you are in the case below.
If it is anywhere in the pushed history
Every commit after the one that added the file has a hash derived from it, so
removing it means rewriting all of them. Two tools do this well.
git filter-branch is not one of them — it is slow, easy to get
subtly wrong, and git itself now warns you off it.
git filter-repo (recommended)
Work on a fresh clone, so a mistake costs you nothing:
git clone --mirror [email protected]:you/your-repo.git repo-clean
cd repo-clean
# rewrite every commit as though the file had never existed
git filter-repo --path .env --invert-paths
To catch several at once — the usual case, because .env rarely
travels alone:
git filter-repo \
--path .env \
--path .env.local \
--path .env.production \
--invert-paths
BFG Repo-Cleaner
BFG is blunter and faster, and takes globs:
bfg --delete-files '.env*' repo-clean.git
cd repo-clean.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive
Pushing the rewrite
git push --force --all
git push --force --tags
Then tell everyone with a clone, because their copy still contains the old
history and a routine git push from any of them will put it straight
back. They should re-clone rather than pull — a merge of the old history into the
new one restores exactly what you just removed.
The part people miss: the remote keeps its own copies
A force-push rewrites the branch. It does not reach everything the hosting provider is holding:
- Pull request diffs keep the original blobs, and stay viewable after the branch is gone.
- Forks are separate repositories. Your rewrite does not touch them.
- Cached views can serve an unreachable commit by hash for a long time afterwards.
- CI logs and build artifacts may have printed the values at the time.
For a repository that was public, or one with forks you do not control, open a support request with the provider and ask them to purge the cached views. And keep in mind that this entire paragraph is a good argument for the rotation you already did at the top.
Making it not happen again
Add the ignore rule before the first commit, not after the incident:
# .gitignore
.env
.env.*
!.env.example
The !.env.example line matters more than it looks. A committed
template of key names with no values is what stops the next person from
inventing their own .env and guessing at what belongs in it.
Then remove the reason the file was in the repository at all. A .env
gets committed because it is the only copy someone has, and losing it means
losing the credentials. Take that away and the pressure goes with it — with Sink,
the environment lives server-side under AES-256-GCM envelope encryption and the
local file is disposable:
sink init # writes .sink.json — ids only, safe to commit
sink pull # writes .env with 0600 permissions
sink diff # what differs from the environment
sink push # send local changes back up
.sink.json is designed to be the file you commit: it names the
workspace, team, project and environment by id and contains no values. The
.env beside it becomes regenerable, which is the property that makes
it safe to .gitignore and forget about.
Belt and braces: a pre-commit hook, or a scanner like
gitleaks in CI,
catches the case where someone adds a credential to a file that is not
called .env — which is the leak that the .gitignore rule
above will never see.
The sequence, in order
- Rotate every credential in the file. First. Always.
- Not pushed?
git rm --cachedand amend. - Pushed?
git filter-repo --path .env --invert-pathson a mirror clone, then force-push. - Tell every clone holder to re-clone.
- Ask the provider to purge cached PR views if the repository was public or forked.
- Add
.envto.gitignore, commit a.env.example, and move the real values somewhere they can be pulled instead of copied.
Sink keeps every environment under AES-256-GCM envelope encryption, with roles, versions and an audit trail — and puts it back in your .env with one command. The free tier does not ask for a card.