The .env file is the single most common way a credential enters a Git repository. It is not
carelessness — it is the file that exists specifically to hold configuration values on your machine,
sitting in the project directory, matched by git add .
This page is about keeping it out, and about the thing everybody gets wrong when it is already in.
The short answer
Section titled “The short answer”For a new project:
.env.env.*!.env.exampleCommit a .env.example with keys and placeholder values. Never a real one.
For a project where .env is already tracked:
{/* Stop tracking it, keeping the local file */}git rm --cached .envgit commit -m "Stop tracking .env"Then rotate every credential in it, because it is still in history and in every clone.
Why this file specifically
Section titled “Why this file specifically”Three properties combine badly.
It is in the project directory. .env sits beside your source, so any command that stages
broadly picks it up. git add . and git add -A are the two most commonly typed staging commands in
existence.
It exists to contain secrets. That is its entire purpose. Unlike a config file that might contain a credential, this one does by definition.
It is invisible. A leading dot hides it from ls and from most file browsers. People forget it is
there, which is why it survives the tidy-up before a repository is made public.
The .gitignore pattern, explained
Section titled “The .gitignore pattern, explained”{/* The file itself */}.env
{/* Variants: .env.local, .env.production, .env.staging */}.env.*
{/* Except the template, which must stay tracked */}!.env.exampleThe order matters. Git applies patterns in sequence, and a later pattern overrides an earlier one, so the negation has to come after the rule it is negating. Reversing those last two lines silently ignores the template.
Verified in a scratch repository with .env.example and .env.local both present. With the
negation last, git status --porcelain -uall offers only the template:
?? .env.example?? .gitignoreWith the negation moved above .env.*, the template disappears from the list entirely — it is
matched by the later, broader rule and silently ignored:
?? .gitignoreNothing warns you. The template simply never gets committed, and the next contributor finds no configuration at all.
A more complete set for a typical project:
.env.env.*!.env.example!.env.template
*.pem*.key*.p12credentials.jsonsecrets.yaml
.terraform/*.tfstate*.tfstate.*Note *.tfstate. Terraform state records the values Terraform managed, which routinely includes
generated passwords and keys. It is a credential file that does not look like one.
When it is already tracked
Section titled “When it is already tracked”The important case, and the one where the intuitive fix does nothing.
git rm --cached .envWhat it doesRemoves the file from Git's index while leaving it on disk.
Why we run itIt stops Git tracking the file from this commit onward, without deleting the copy your application needs.
Expected resultrm '.env', and the file still present in the directory.
Without --cached, git rm deletes the file from disk as well — which breaks your local environment
and, worse, may lose values that exist nowhere else.
The full sequence:
-
Confirm it is tracked:
Terminal window git ls-files | grep -E '^\.env'Output means tracked. No output means it is already ignored correctly.
-
Untrack it, keeping the file:
Terminal window git rm --cached .env -
Ensure
.gitignorecovers it, so it is not re-added. -
Commit both changes together.
-
Rotate every credential the file contained. This is the step that matters. The values are still in history and in every clone that has ever been made.
-
Decide about history. Optional, and only after step 5 — see Removing secrets from Git history.
Step 5 is not optional and is the one most often skipped, because steps 1 to 4 feel like they solved the problem. They changed what happens next. They did nothing to the credentials.
The template pattern
Section titled “The template pattern”.env.example is what makes the rule survivable. Without it, a new contributor clones the repository,
finds no configuration, and asks someone to send them a working .env — which is how credentials end
up in chat.
{/* Copy to .env and fill in real values. Never commit .env. */}DATABASE_URL=postgresql://user:password@localhost:5432/dbnameAPI_KEY=YOUR_API_KEY_HERESTRIPE_SECRET_KEY=sk_test_YOUR_KEY_HEREJWT_SECRET=generate-with-openssl-rand-hex-32LOG_LEVEL=infoFour properties of a good template:
Every key the application reads. A missing key becomes a runtime error somebody debugs for an hour.
Obviously fake values. YOUR_API_KEY_HERE is unmistakable. A realistic-looking placeholder is
worse than useless — it will be committed by somebody who thinks it is already an example, and it may
trip push protection.
Instructions where the value is generated rather than issued. generate-with-openssl-rand-hex-32
tells the reader what to do.
Safe defaults for non-secrets. Log levels, ports and feature flags can carry real values.
Where the values should live instead
Section titled “Where the values should live instead”A rule with no alternative produces workarounds. “Do not commit .env” needs an answer to “then where
does the value go?”
Local development. An untracked .env, generated from the committed template. This is the case
.env was designed for and it is fine — the file is only a problem when it is tracked.
Shared development values that are not secret. Commit them. A local database port or a feature flag default belongs in the repository, and separating genuinely-secret values from merely-local configuration makes the secret set small enough to manage.
CI/CD. GitHub Actions secrets, and environment secrets for anything that reaches a real environment. See Secrets and Environments.
Cloud access from CI. Nothing stored — OIDC exchanges a short-lived token at run time. See Remove long-lived cloud credentials.
Runtime. A secret manager, read at start-up or injected at deploy. The value exists in the process and never in an artefact.
The pattern is that .env is a development mechanism. Using it in production — shipping an
environment file into an image or onto a server — is where it stops being reasonable, because the
file then exists somewhere it can be read by anything that gets a copy of the artefact.
The other files that leak
Section titled “The other files that leak”.env gets the attention because it is the most frequent. Several others are less common and less
expected, which makes them worth listing.
Jupyter notebooks. A notebook stores output cells, so a cell that printed a token stores the token
in the .ipynb file. nbstripout as a pre-commit hook is the standard fix.
Editor and IDE directories. .vscode/settings.json and JetBrains configuration files hold
per-project settings, including connection strings and API endpoints that people paste in.
Log and debug output. A crash log or a verbose HTTP trace captured to a file and committed “temporarily” for a colleague to look at.
Test fixtures and recorded HTTP interactions. Tools that record real API traffic for replay
capture the Authorization header along with everything else.
Database dumps. A .sql file taken from a real environment for local testing carries whatever the
database held.
Terraform plan and state files. Both record values, and both are commonly written into the working
directory during a CI run and then swept up by a broad git add.
The common factor: every one of them is a file someone generated locally for a legitimate reason and committed without reading. That is why file-list checks in CI are worth having alongside content scanning — they catch the category, not the string.
Onboarding, where the rule gets broken
Section titled “Onboarding, where the rule gets broken”The most predictable failure is social rather than technical. A new contributor clones the repository,
cannot start the application, and asks for help. The fastest way for a colleague to help is to send
their working .env, and that credential is now in a chat archive.
The controls that prevent this are all documentation-shaped:
A .env.example that is complete. If it is missing keys, it does not answer the question, and
somebody will answer it by sending a real file.
A README section naming where the real values come from. “Request access to the shared secret manager” is an answer. Silence is not.
Start-up validation that names the missing variable. Missing required configuration: STRIPE_KEY
sends the reader to the template. A NoneType error sends them to a colleague.
Values that can be self-served. Where a developer can generate their own credential for a sandbox rather than needing someone else’s, the sharing problem disappears entirely. This is the strongest version of the fix, and it is an infrastructure decision rather than a policy one.
Layers in front of Git
Section titled “Layers in front of Git”.gitignore is one control with one failure mode: it does not cover a pattern you did not write, and
it does nothing for a tracked file.
A pre-commit hook catches it before there is a commit to fix:
repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.30.1 hooks: - id: gitleaksSkippable with --no-verify and not installed on a fresh clone, which is why it is a layer rather
than the answer.
Push protection blocks supported patterns server-side, and cannot be skipped by local configuration. See Push protection.
A CI check that fails the build if an environment file appears:
- name: Fail if an environment file is tracked run: | if git ls-files | grep -qE '^\.env($|\.)' ; then echo "::error::A .env file is tracked in this repository" exit 1 fiCheap, deterministic and fast — it does not scan content, only the file list, so it has essentially no false positives.
Docker and build contexts
Section titled “Docker and build contexts”A specific case that catches people, because it bypasses Git entirely.
COPY . . in a Dockerfile copies the build context, which is the directory — not the Git
repository. A .env file that Git correctly ignores is copied into the image anyway, where it sits in
a layer that anyone who pulls the image can read.
Two fixes, and you want both:
.env.env.*.git*.pem*.keyAnd do not copy broadly:
COPY package.json package-lock.json ./RUN npm ciCOPY src/ ./src/.dockerignore is a separate file from .gitignore and neither implies the other. A repository with
a correct .gitignore and no .dockerignore publishes its environment file in an image.
Note .git in that list too. Copying the repository’s history into an image ships every credential
ever committed, which is a considerably larger disclosure than the current .env.
Auditing what is already tracked
Section titled “Auditing what is already tracked”Before adding controls, find out what is already in. These three commands take under a minute and routinely produce a surprise.
{/* Environment files tracked right now */}git ls-files | grep -E '(^|/)\.env' || echo "none tracked"{/* Key material and credential-shaped filenames, tracked right now */}git ls-files | grep -E '\.(pem|key|p12|pfx|tfstate)$|credentials|secrets\.' || echo "none tracked"{/* Every path ever committed that looks like an environment file, across all history */}git log --all --pretty=format: --name-only --diff-filter=A | sort -u | grep -E '(^|/)\.env' || echo "none in history"The third one is the important one, and it answers a different question from the first. A repository
can be perfectly clean today and still have three years of .env files in its history — which is what
everybody with a clone actually has.
If that third command produces output, the response is rotation first, then a decision about whether rewriting history is warranted. Not the other way round.
Common mistakes
Section titled “Common mistakes”Adding .env to .gitignore and thinking it is done. It governs untracked files. The tracked one
keeps being tracked and the history is unchanged.
Using git rm without --cached. Deletes the local file, taking values that may exist nowhere
else.
Committing a realistic-looking .env.example. Placeholders should be unmistakable. A realistic
one gets committed as real by somebody.
Putting the negation before the rule. !.env.example above .env.* does nothing; ordering
decides.
Forgetting .dockerignore. Git ignoring the file has no effect on what COPY . . puts in an
image.
Ignoring *.tfstate. State files record managed values, including generated passwords.
Untracking without rotating. The credentials are still in history and in every clone, and they still work.
Mental model
Section titled “Mental model”
.gitignoreis a filter on what Git starts tracking. It has no opinion about what is already tracked, and no reach into what has already been committed. Those are three different problems with three different solutions.
What you learned
Section titled “What you learned”.gitignoregoverns untracked files; a tracked file keeps being tracked regardlessgit rm --cacheduntracks while keeping the file; without--cachedit deletes it- Negation patterns must come after the rule they negate
.env.exampleis what stops people sending each other real files- Placeholders should be obviously fake, and start-up should fail loudly on a missing variable
.dockerignoreis separate from.gitignore, andCOPY . .copies the directory, not the repository- Copying
.gitinto an image ships every credential ever committed - Terraform state files record managed values and belong in the ignore list
- Untracking a file does nothing to the credentials it contained — rotate them
Exercise
Section titled “Exercise”Use a disposable repository and fake values only.
-
Create a repository and commit a
.envcontainingAPI_KEY=EXAMPLE_NOT_REAL_1234. -
Add
.envto.gitignoreand commit. Predict: is.envstill tracked? Rungit ls-files | grep env. -
Edit
.envand rungit status. Predict: does Git report the change? -
Run
git rm --cached .envand commit. Repeat step 3. Predict: what changes? -
Run
git log --all -p -- .env. Predict: is the original value still visible? -
Add
.env.*and!.env.example, then create.env.localand.env.example. Predict: which of the two doesgit statusoffer to add? -
Reverse the order of those two
.gitignorelines and repeat step 6. Predict: does it still work? -
Delete the repository.
Related lessons
Section titled “Related lessons”The secrets management checklist and least-privilege token guide are in the Professional Toolkit.