Managing Local Secrets Without Committing to Git
Keep secrets out of Git for good. Detect tracked .env files, purge them from the index and history, and inject decrypted values into the shell without writing plaintext.
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 local secret vaults and rotation and the wider environment sync and CI parity baseline.
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
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.
Resolution
- 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 - Enforce strict ignore rules:
#!/usr/bin/env bash set -euo pipefail { echo '.env'; echo '.env.*'; echo '!.env.example'; } >> .gitignore - Inject decrypted values into the shell without writing plaintext to a tracked path (SOPS + age):
#!/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 - 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"
Expected output
rm '.env.local'
DB_HOST=localhost
DB_PASS=decrypted_value
API_KEY=sk_live_redacted
Prevention
- Add
gitleaksordetect-secretsto 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
.gitignoretemplate via repository scaffolding so every workspace starts with the same boundaries. - Decrypt on shell entry through
direnvso no one manually exports — eliminating drift. The vault and key-lifecycle side is covered in local secret vaults and rotation.
WSL2: Keep the age key file inside native ext4 (
~/.config/sops/...), not/mnt/c, or SOPS decryption stalls on cross-filesystem reads. macOS / Windows:git filter-branchis slow and error-prone on large repos; prefergit filter-repoor the BFG Repo-Cleaner shown below.
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"