Skip to content

Prevent .env Files from Being Committed

Lesson 5 of 8Beginner → Intermediate10 min readGit Security & DevSecOps · Secret SecurityVerified: git 2.43.0 on Ubuntu 24.04, September 2026

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.

For a new project:

.gitignore
.env
.env.*
!.env.example

Commit a .env.example with keys and placeholder values. Never a real one.

For a project where .env is already tracked:

Terminal window
{/* Stop tracking it, keeping the local file */}
git rm --cached .env
git commit -m "Stop tracking .env"

Then rotate every credential in it, because it is still in history and in every clone.

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.

.gitignore
{/* The file itself */}
.env
{/* Variants: .env.local, .env.production, .env.staging */}
.env.*
{/* Except the template, which must stay tracked */}
!.env.example

The 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
?? .gitignore

With the negation moved above .env.*, the template disappears from the list entirely — it is matched by the later, broader rule and silently ignored:

?? .gitignore

Nothing 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:

.gitignore
.env
.env.*
!.env.example
!.env.template
*.pem
*.key
*.p12
credentials.json
secrets.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.

The important case, and the one where the intuitive fix does nothing.

Terminal window
git rm --cached .env

What 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:

  1. Confirm it is tracked:

    Terminal window
    git ls-files | grep -E '^\.env'

    Output means tracked. No output means it is already ignored correctly.

  2. Untrack it, keeping the file:

    Terminal window
    git rm --cached .env
  3. Ensure .gitignore covers it, so it is not re-added.

  4. Commit both changes together.

  5. 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.

  6. 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.

.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.

.env.example
{/* Copy to .env and fill in real values. Never commit .env. */}
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
API_KEY=YOUR_API_KEY_HERE
STRIPE_SECRET_KEY=sk_test_YOUR_KEY_HERE
JWT_SECRET=generate-with-openssl-rand-hex-32
LOG_LEVEL=info

Four 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.

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.

.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.

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.

.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:

.pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks

Skippable 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
fi

Cheap, deterministic and fast — it does not scan content, only the file list, so it has essentially no false positives.

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:

.dockerignore
.env
.env.*
.git
*.pem
*.key

And do not copy broadly:

COPY package.json package-lock.json ./
RUN npm ci
COPY 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.

Before adding controls, find out what is already in. These three commands take under a minute and routinely produce a surprise.

Terminal window
{/* Environment files tracked right now */}
git ls-files | grep -E '(^|/)\.env' || echo "none tracked"
Terminal window
{/* Key material and credential-shaped filenames, tracked right now */}
git ls-files | grep -E '\.(pem|key|p12|pfx|tfstate)$|credentials|secrets\.' || echo "none tracked"
Terminal window
{/* 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.

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.

.gitignore is 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.

  • .gitignore governs untracked files; a tracked file keeps being tracked regardless
  • git rm --cached untracks while keeping the file; without --cached it deletes it
  • Negation patterns must come after the rule they negate
  • .env.example is what stops people sending each other real files
  • Placeholders should be obviously fake, and start-up should fail loudly on a missing variable
  • .dockerignore is separate from .gitignore, and COPY . . copies the directory, not the repository
  • Copying .git into 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

Use a disposable repository and fake values only.

  1. Create a repository and commit a .env containing API_KEY=EXAMPLE_NOT_REAL_1234.

  2. Add .env to .gitignore and commit. Predict: is .env still tracked? Run git ls-files | grep env.

  3. Edit .env and run git status. Predict: does Git report the change?

  4. Run git rm --cached .env and commit. Repeat step 3. Predict: what changes?

  5. Run git log --all -p -- .env. Predict: is the original value still visible?

  6. Add .env.* and !.env.example, then create .env.local and .env.example. Predict: which of the two does git status offer to add?

  7. Reverse the order of those two .gitignore lines and repeat step 6. Predict: does it still work?

  8. Delete the repository.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

The secrets management checklist and least-privilege token guide are in the Professional Toolkit.