Keep secrets out of git: .env files, gitignore and what to do after a leak
Load passwords and API keys from an untracked .env file, commit a safe .env.example instead, catch mistakes before they are pushed, and the exact steps when a secret already landed in a repository.
Drafted with AI from official documentation. Scripts were run in a test environment and repository links verified. Awaiting a human check. Did it work for you? Last edited Outdated?
A database password in config.py, an API key in docker-compose.yml, a token pasted into a script “just for testing”: one git push later it is in the history of every clone and, on a public repository, scanned by bots within minutes. The fix is a habit: configuration that differs per machine or is secret never lives in tracked files.
1. The pattern: .env plus .env.example
Put secrets in a file called .env in the project root, one KEY=value per line, and read them from the environment in your code. Commit a .env.example with the same keys and placeholder values so a new developer knows what to fill in.
# .env (never committed)
DATABASE_URL=postgres://app:s3cret@localhost:5432/app
STRIPE_API_KEY=sk_live_...
# .env.example (committed)
DATABASE_URL=postgres://app:password@localhost:5432/app
STRIPE_API_KEY=
Most ecosystems have a small library that loads .env into the environment at startup: python-dotenv, Node’s built-in node --env-file=.env (Node 20.6 and later) or the dotenv package, godotenv in Go. Docker Compose reads .env from the project directory automatically and substitutes ${VAR} in the compose file. In production, the same variables come from the service manager instead: a systemd EnvironmentFile=, Kubernetes Secrets, or your platform’s settings page.
2. Make git refuse the file
cat >> .gitignore <<'EOF'
.env
.env.*
!.env.example
*.pem
*.key
EOF
git check-ignore -v .env # prints the matching rule when it is ignored
git status # .env must not appear
Do this in the very first commit of a project, before any .env exists. For your own machine, a global ignore file catches editor files and local secrets in every repository: git config --global core.excludesFile ~/.gitignore_global.
3. Catch mistakes before the push
A scanner that looks for things shaped like keys is cheap insurance. gitleaks is the common choice:
# scan the working tree and the whole history of this repository
gitleaks git .
# as a pre-commit hook with the pre-commit framework: .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.0
hooks:
- id: gitleaks
GitHub also scans pushes for known key formats (push protection on public repositories and for organizations that enable it) and blocks the push; GitLab has the same feature. Treat a block as a gift, not an obstacle.
4. If a secret is already in the repository
Deleting the file in a new commit does not help: the old commit still contains it, and anyone who cloned or forked already has it. Work in this order, and assume the secret is public from the moment it was pushed.
- Rotate the secret first. Create a new key or password in the service that issued it, deploy the new one, and revoke the old one. This is the only step that actually makes you safe; everything below is cleanup. Check the service’s access logs for use of the old key.
- Stop tracking the file.
git rm --cached .envkeeps the file on disk but removes it from the index; add it to.gitignore; commit. - Rewrite history if the repository is private or small enough to coordinate. Use
git filter-repo(a separate tool; the oldfilter-branchis discouraged by git itself):git filter-repo --invert-paths --path .envremoves the file from every commit, orgit filter-repo --replace-text expressions.txtreplaces the secret string everywhere. Thengit push --force --allandgit push --force --tags, and have every collaborator re-clone. - Purge the server side. Forks, pull request references and caches on GitHub or GitLab still hold the old commits; GitHub support can remove them on request, and their documentation describes the steps. Until then, consider the secret still exposed, which is why step 1 comes first.
- Find out how it happened and close that path: missing
.gitignoreline, a secret hard-coded instead of read from the environment, a CI log that printed it. Add the scanner from step 3.
Secrets that are not in files
- Shell history:
curl -H "Authorization: Bearer abc123"ends up in~/.bash_history. Put the token in a variable read from a file, or prefix the command with a space ifHISTCONTROL=ignorespaceis set. - CI logs: mark variables as masked or protected in your CI system, and never
echothem for debugging. - Container images: a secret copied in during
docker buildstays in a layer even if a later step deletes it. Use build secrets (RUN --mount=type=secret) or pass it at run time. - Commit messages and issue comments are history too.
Official documentation
Did this work for you?
No votes yet. Be the first.
If it did not work, the report form below tells the editors what went wrong.
See also
- 0033 The everyday Git workflow: status, add, commit, push, pull Git moves your changes through three places: the working folder, the staging area, and the commit history. These five commands, plus .gitignore… Beginner · Explains an idea · Any system Article Housekeeping · Setup 15 Oct 2026
- 0043 Delete local branches that are already merged A bash script that lists local branches fully merged into main and, if you ask, deletes them. It never touches the current… Intermediate · Step by step · Linux, macOS, Windows Script Housekeeping 15 Oct 2026
- 0306 What is hashing? Hashing turns any data into a fixed length fingerprint that cannot be reversed, used to check files and store passwords. Beginner · Explains an idea · Any system Article Secrets & access 15 Oct 2026
- 0354 sudo rules: give a user exactly the rights they need Write sudoers rules with visudo: full admin for operators, a short list of commands for a deploy account, no-password rules for automation,… Intermediate · Step by step · Linux Article Secrets & access · Users, sudo & hardening 15 Oct 2026
Repos in the wild
- git-extras tj/git-extras A collection of extra Git subcommands for common chores, such as summarizing a repository, deleting merged branches, and undoing commits. GitHub
- Gitleaks: find secrets that were committed to a Git repository gitleaks/gitleaks Gitleaks scans Git history, working trees and arbitrary files for passwords, API keys, tokens and private keys using a library of patterns, and is usually run as a pre-commit hook and in CI. GitHub
- age: a small, modern tool for encrypting files FiloSottile/age age is a simple file encryption tool and Go library with short public keys, SSH key support and no configuration options, designed as a modern replacement for ad hoc GPG use when you need to encrypt a backup or a secret. GitHub
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- Dominik +4,656 chars