A plaintext .env slips past your ignore rules and lands in the Git index, exposing credentials in history. This page gives a deterministic, CLI-driven workflow to detect tracked secret files, purge them, and inject decrypted values into the shell session without persisting plaintext — sitting under provision a local vault and rotate leases and the wider environment sync and CI parity baseline.

The failure is quiet by design. A .gitignore rule added after a file is already tracked does nothing, so git status stays clean while DB_PASS and API_KEY ride along in every clone. The steps below turn that invisible state into an explicit checklist: detect, remove from the index, harden the ignore boundary, decrypt at runtime, and — if the leak already shipped — rewrite history and rotate.

Diagnostic

Check whether a secret file is actually ignored, and whether any landed in recent history:

#!/usr/bin/env bash
set -euo pipefail

git check-ignore -v .env.local || echo "NOT IGNORED: .env.local is tracked or untracked-but-not-ignored"
git ls-tree -r HEAD --name-only | grep -E '\.(env|secret|key)$' || echo "No secret files in HEAD"

Expected BAD output — the file is committed and others are tracked:

NOT IGNORED: .env.local is tracked or untracked-but-not-ignored
config/.env.production
src/services/auth/.env.local

git check-ignore -v is the authoritative test: if it prints a rule reference the file is ignored; if it exits non-zero (triggering the echo) the path is either tracked or simply not covered by any pattern. The second command walks the committed tree at HEAD, so it catches files that are ignored now but were committed before the ignore rule existed — the exact case a naive git status misses. To scan the entire reachable history rather than just the current tip, widen the search:

#!/usr/bin/env bash
set -euo pipefail
git log --all --pretty=format: --name-only --diff-filter=A \
  | sort -u | grep -E '\.(env|secret|key|pem)$' || echo "No secret files ever added"

That lists every path ever added across all branches, which tells you whether a rollback of the working tree is enough or whether a full history rewrite is required.

Deciding how far to clean a leaked secret A decision from whether the secret file is only in the working tree, the index, or already in pushed history, leading to three different cleanup depths. How Far Must You Clean? Where is the secret? Working tree only add ignore rule In the index git rm --cached Pushed history rewrite + rotate
The diagnostic output tells you which of three cleanup depths applies.

Root cause

Git tracks files by the staging index, not the working directory. When a .env file is git add-ed before ignore rules exist — or when a monorepo workspace override conflicts with the root .gitignore — the index keeps the file even after you add an ignore pattern. The ignore rule only prevents new untracked files from being staged; it does nothing for a path already in the index, which is why the secret stays committed while everything looks safe.

The lifecycle is the key to understanding why one command is never enough. A secret moves through four distinct stores, and removing it from one leaves it in the others. It exists first in the working directory as a plaintext file, then in the index once staged, then in the commit object once you commit, and finally in the reflog and remote once you push. A .gitignore edit touches none of these — it only filters what git add will pick up next time. To fully eliminate an exposure you have to unwind each store in order, which is why the resolution below is a sequence, not a single flag.

How a secret propagates from disk to remote A left-to-right flow showing a secret moving from the working directory into the index, then a commit, then the remote, with a gitignore edit only affecting the first hop. Where the Secret Lives Working dir .env on disk Index staged blob Commit tree object Remote every clone .gitignore filters only this hop
A .gitignore rule stops the first hop; it never reaches back into the index, commit, or remote.

Resolution

  1. Remove the file from the index while keeping it on disk:
    #!/usr/bin/env bash
    set -euo pipefail
    git rm --cached .env .env.local 2>/dev/null || true
  2. Enforce strict ignore rules:
    #!/usr/bin/env bash
    set -euo pipefail
    { echo '.env'; echo '.env.*'; echo '!.env.example'; } >> .gitignore
  3. Inject decrypted values into the shell without writing plaintext to a tracked path. The SOPS approach compared against Vault and dotenv-vault keeps the ciphertext file in Git and the private age key on disk only:
    #!/usr/bin/env bash
    set -euo pipefail
    export SOPS_AGE_KEY_FILE="${HOME}/.config/sops/age/keys.txt"
    sops decrypt .env.local.sops > .env.local
  4. Confirm the variables are present in the session:
    #!/usr/bin/env bash
    set -euo pipefail
    env | grep -E 'DB_|API_|SECRET_' || echo "No matching vars exported"

Step 1 is the single most important line: git rm --cached deletes the path from the index but leaves your on-disk copy intact, so the next commit records the removal without you losing the file you still need locally. The 2>/dev/null || true guard keeps the script idempotent — re-running it after the file is already untracked exits cleanly instead of aborting the pipeline. Step 3 is deliberately never sourced into a tracked file: .env.local.sops holds only ciphertext (safe to commit), the plaintext .env.local it produces is already covered by the ignore rule from step 2, and the decrypting key never leaves ~/.config.

If you would rather avoid materialising plaintext on disk at all, pipe the decrypted output straight into the process environment and skip the intermediate file:

#!/usr/bin/env bash
set -euo pipefail
set -a
eval "$(sops decrypt --output-type dotenv .env.local.sops)"
set +a
node server.js

Here set -a marks every variable defined by the eval for export, so the child node process inherits DB_PASS without a single plaintext byte ever touching the filesystem.

Expected output

rm '.env.local'
DB_HOST=localhost
DB_PASS=decrypted_value
API_KEY=sk_live_redacted

The leading rm '.env.local' line comes from git rm --cached confirming the index entry is gone; the three KEY=value lines are the env | grep from step 4 proving the variables reached the session. If step 4 prints No matching vars exported, the decrypt succeeded but nothing was exported — check that you sourced the file (set -a; . .env.local; set +a) rather than merely writing it.

Prevention

  • Add gitleaks or detect-secrets to a pre-commit hook so staged secrets are blocked before the commit lands:
    #!/usr/bin/env bash
    set -euo pipefail
    gitleaks protect --staged --verbose
  • Distribute a centralized .gitignore template via repository scaffolding so every workspace starts with the same boundaries. In a monorepo, prefer one root .gitignore with absolute-anchored patterns (/**/.env) over per-package files that drift apart.
  • Decrypt on shell entry through direnv so no one manually exports — eliminating drift. Commit an .envrc that runs the SOPS decrypt on cd, and the plaintext only ever lives in the process environment:
    #!/usr/bin/env bash
    set -euo pipefail
    # .envrc — direnv runs this on directory entry
    export SOPS_AGE_KEY_FILE="${HOME}/.config/sops/age/keys.txt"
    eval "$(sops decrypt --output-type dotenv secrets.enc.env)"

The vault and key-lifecycle side is covered in the parent topic on provisioning a local vault and rotating leases. Pairing a pre-commit scanner with a validation step also stops the inverse failure — a missing secret at startup — which is handled in catching missing env vars before container startup.

The three defenses are complementary, not redundant. The pre-commit hook catches a secret at the moment of staging, the shared ignore template stops it being staged at all, and direnv-driven decryption removes the human step where plaintext would otherwise be hand-copied. Skipping any one leaves a gap the other two do not fully cover.

WSL2: Keep the age key file inside native ext4 (~/.config/sops/...), not /mnt/c, or SOPS decryption stalls on cross-filesystem reads and direnv hooks time out. macOS / Windows: git filter-branch is slow and error-prone on large repos; prefer git filter-repo or the BFG Repo-Cleaner shown below. Apple Silicon (ARM64): install sops and age from Homebrew ARM builds, not Rosetta x86 binaries, so SOPS_AGE_KEY_FILE resolution and keychain access work without an emulation shim.

Rollback

If a secret was already committed, purge it from the index and history, then rotate the credential:

#!/usr/bin/env bash
set -euo pipefail

git rm --cached -r --ignore-unmatch '.env*'
git commit -m "chore: remove committed secrets from index"
# Rewrite history if already pushed — coordinate with your security team first:
bfg --delete-files '.env.local'
echo "Rotate the exposed credential now; history removal does not un-leak it"

Choose the history-rewrite tool by repo size and how surgical the removal must be. The git filter-branch built-in is correct but O(commits) slow and easy to misconfigure; git filter-repo is the maintained replacement and an order of magnitude faster; the BFG is fastest for the narrow "delete this filename everywhere" case. The chart below is representative wall-clock time to strip one file from a repository of roughly 20,000 commits.

History-rewrite time by tool Bar chart comparing wall-clock seconds to strip one file from a 20000-commit repository using filter-branch, filter-repo, and BFG. Rewrite Time to Strip One File (s) filter-branch 240s filter-repo 29s BFG 18s
For a filename-scoped purge, the BFG and filter-repo finish in seconds where filter-branch takes minutes.

One subtlety trips people up here: even after git rm --cached and a fresh commit, the secret's blob still lives in the object database, reachable through the reflog and old commit objects. That is why a bare removal commit is not enough for a leak that already shipped — the value is recoverable with git cat-file until the history is rewritten and the dangling objects are pruned. Force a cleanup after any rewrite so the loose blob cannot be resurrected:

#!/usr/bin/env bash
set -euo pipefail
git reflog expire --expire=now --all
git gc --prune=now --aggressive

The final echo in the rollback script states the rule that matters most: rewriting history removes the value from the repository, but anyone who cloned or fetched before the rewrite still has it, and automated scanners may already have harvested it. History surgery is containment, not remediation — the credential is compromised the moment it is pushed, so rotate it regardless of how cleanly you rewrite. After rotation, force-push the rewritten refs and have every collaborator re-clone or hard-reset, since a stale local ref can reintroduce the removed blob on the next push. Log the rotation timestamp and the affected key names in your incident record, because parallel scanners and provider dashboards key their alerts on the old value and need to be told it was retired. The mechanics of swapping the live value without downtime are covered in rotating secrets without restarting containers.

Frequently Asked Questions

Does adding a path to .gitignore remove a file that is already committed?

No. .gitignore only prevents untracked files from being staged. A path already in the index stays tracked and keeps appearing in every commit until you run git rm --cached <path> to drop the index entry, then commit that removal. Verify with git check-ignore -v <path> — if it exits non-zero the file is still tracked despite the rule.

Is committing the encrypted .sops file safe?

Yes, that is the intended workflow. A SOPS-encrypted file contains only ciphertext plus non-secret metadata (which keys can decrypt it), so it is safe to commit and share. The decrypting age private key lives outside the repo at SOPS_AGE_KEY_FILE and is never committed. Anyone without that key sees only opaque values.

How do I load decrypted secrets without ever writing plaintext to disk?

Pipe the decrypt straight into the environment: set -a; eval "$(sops decrypt --output-type dotenv secrets.enc.env)"; set +a. The set -a marks the resulting variables for export so a child process inherits them, and no intermediate .env file is ever created. direnv can run the same block from an .envrc on directory entry.

After I rewrite history to remove a secret, is the credential safe again?

No. History rewriting removes the value from the repository, but any clone, fork, or CI cache taken before the rewrite still holds it, and scanners may have already captured it. Treat the credential as compromised from the moment it was pushed and rotate it. The rewrite is containment; rotation is the actual fix.