Have you ever accidentally pushed an API key, database password, or access token to a public GitHub repository? It’s an easy mistake to make — Snyk’s State of Secrets report logged 28 million leaked credentials across GitHub in 2025 alone. Properly setting up your .gitignore is the first line of defence, and the .env file is the most common attack surface.
This guide covers what 2023-era “add .env to gitignore” tutorials miss: the standard multi-line .env* block used by Next.js, Vite, and Create React App; what to actually do when a secret was already pushed (rotate FIRST, then rewrite history); git filter-repo (since git filter-branch was deprecated in Git 2.41); GitHub Push Protection that auto-blocks 250+ secret patterns; the gitleaks / TruffleHog / detect-secrets pre-commit scanner trio; encrypted-env alternatives like dotenvx and Doppler; and why --skip-worktree beats --assume-unchanged for config files.
Related guides: Comment Multiple Lines in JavaScript · Convert String to Date in JavaScript
What Is the .env File?
The .env file is a plain-text file used in web development projects to store environment-specific configuration variables. Each line is a KEY=value pair representing one variable:
# .env — typical contents
DATABASE_URL=postgres://user:password@localhost:5432/myapp
STRIPE_SECRET_KEY=sk_live_4eC39HqLyjWDarjtT1zdp7dc
JWT_SECRET=this-should-never-leave-your-machine
SENTRY_DSN=https://[email protected]/4567
NODE_ENV=development
These values are loaded into process.env at runtime by libraries like dotenv (Node.js), python-dotenv (Python), godotenv (Go), or natively by frameworks like Next.js, Vite, and Astro. The values often include secrets — API keys, passwords, signing tokens, OAuth client secrets — that must never be committed to version control.
Quick Answer: The Standard Multi-Line .env Block
If you came here for one thing, here it is — the gitignore env file example every modern starter ships:
# .env files — never commit these
.env
.env.local
.env.*.local
.env.development
.env.production
.env.test
# But DO commit the template so teammates know what variables to set
!.env.example
!.env.sample
The ! prefix is a negation pattern — it un-ignores a file that an earlier rule would have ignored. So .env.example is committed (as a template showing required variables, with placeholder values), while every other .env* file is not.
Framework-specific variants
| Framework | Convention |
|---|---|
| Next.js | .env.local (personal overrides), .env.development.local, .env.production.local — never committed; .env, .env.development, .env.production can be committed if they contain no secrets |
| Vite | .env.local, .env.*.local — never committed |
| Create React App | Same as Next.js convention |
| Astro | Same as Vite |
| Node + dotenv | .env only — no automatic per-environment loading |
The block above covers all four — copy-paste it and move on.
How to Add .env to .gitignore
Three steps. If you already have a .gitignore file, skip step 1.
Step 1 — Create the .gitignore file (if you don’t have one)
In your project’s root directory, create a file named .gitignore (note the leading dot):
# macOS / Linux
touch .gitignore
# Windows (PowerShell)
New-Item .gitignore
# Or just create it in VS Code / your editor — File → New File → name it .gitignore
Step 2 — Add the .env block
Open .gitignore and add the standard block from the Quick Answer above. If your project is brand new and you only want the bare minimum:
.env
.env.local
But the multi-line block is what you actually want long-term.
Step 3 — Commit the .gitignore file
git add .gitignore
git commit -m "chore: ignore .env files"
git push
The .gitignore file itself must be committed — that’s how teammates’ clones inherit the same ignore rules.
The .env.example File Pattern
Pair your ignored .env with a committed .env.example (also called .env.sample or .env.template). It’s a template showing which variables exist, with placeholder or example values that are safe to commit:
# .env.example — safe to commit
DATABASE_URL=postgres://user:password@localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk_test_replace_me
JWT_SECRET=generate-a-32-char-random-string
SENTRY_DSN=
NODE_ENV=development
Then teammates run cp .env.example .env after cloning and fill in real values locally.
Keep it in sync — add a PR checklist item: “If you added a new env var, did you also add it to .env.example?” Onboarding a new developer is the moment this debt becomes painful.
What Should I Put in .gitignore for env Files?
The 5-line block answer for most projects:
.env
.env.local
.env.*.local
!.env.example
!.env.sample
For a Next.js / Vite / Astro project, use the full block from the Quick Answer section. For a simple Node + dotenv project, the 5 lines above are enough.
Why Should You Ignore the .env File?
Three reasons, in order of importance:
-
Secrets exposure. API keys, database passwords, JWT signing keys, OAuth client secrets — these belong in
.envfiles. A committed.envin a public GitHub repository is harvested by automated bots within seconds. Sourcegraph’s research shows ~5 minutes is the average time between a secret being pushed and being abused. -
Per-developer / per-environment configuration. Your local
DATABASE_URLpoints tolocalhost. Your teammate’s points to their machine. Production points to AWS RDS. None of those values should overwrite each other —.envis local-only by design. -
Repository size.
.envfiles sometimes contain large base64-encoded secrets (service-account JSON, certificate chains). Committing them bloats the repo and the secret is in history forever even if you “delete” it later.
Env File Already Committed to Git — What to Do
STOP. Don’t just delete the file and push. A committed .env is in your Git history forever — anyone who clones the repo, or browses the commit history on GitHub, can still see those secrets. The fix has four steps in order, and step 1 is the most important:
Step 1 — Rotate every secret in the file (do this FIRST)
Open every provider dashboard mentioned in the leaked .env and rotate (regenerate) those credentials before doing anything else:
- Stripe: Dashboard → Developers → API keys → “Roll” the secret key
- AWS: IAM → Users → Security credentials → Make access key Inactive, create new
- Database: Change the user’s password (or rotate the connection string user)
- JWT / signing keys: Generate a new random string, deploy it, invalidate old tokens
- GitHub PATs / OAuth apps: Settings → Developer settings → revoke + regenerate
- Third-party APIs: Each provider’s API key management page
If you skip rotation and only rewrite history, the original secret is still valid in any clone, fork, or scraped backup. Treat any pushed secret as already compromised.
Step 2 — Add .env to .gitignore (if you haven’t already)
.env
.env.local
.env.*.local
!.env.example
Step 3 — Remove .env from the current commit
# Untrack the file but keep it locally
git rm --cached .env
# If you have multiple .env variants
git rm --cached .env.local .env.production
git commit -m "chore: stop tracking .env files"
git push
This stops .env from being tracked going forward, but it’s still in history. Step 4 fixes that.
Step 4 — Remove .env from Git history with git filter-repo
git filter-branch was deprecated in Git 2.41 (released 2023). The modern tool is git filter-repo — install it once via:
# macOS
brew install git-filter-repo
# Pip (cross-platform)
pip install git-filter-repo
Then nuke the file from every commit:
# Verify your working tree is clean and you have a backup branch first
git checkout -b backup-before-rewrite
# Switch back to the branch you want to rewrite
git checkout main
# Remove .env from ALL history (every commit, every branch)
git filter-repo --path .env --invert-paths
# If you have multiple paths
git filter-repo --path .env --path .env.local --path .env.production --invert-paths
# Force-push the rewritten history
git push origin --force --all
git push origin --force --tags
The force-push trap: force-pushing rewrites the remote branch, but forks, clones, and CI caches still have the secret. Anyone who cloned before your fix gets the leaked secret. This is why rotation in Step 1 is non-negotiable — assume the secret is public.
For binary-heavy repositories, BFG Repo-Cleaner is faster than git filter-repo and uses a friendlier syntax — but git filter-repo is the modern default for text files like .env.
Step 5 — Notify collaborators and fork owners
# Tell every collaborator to re-clone — their local clone has the rewritten history
git clone https://github.com/you/yourrepo.git fresh-clone
Anyone using git pull after a force-push gets nasty conflicts. Easier to re-clone. Notify on Slack / email and check the GitHub “Forks” page — DM fork owners and ask them to either delete their fork or git filter-repo it themselves.
Verifying .env Is Ignored
Three commands tell you the truth:
# 1. Is .env currently ignored? (Best command — shows WHICH rule is matching)
git check-ignore -v .env
# Output: .gitignore:1:.env .env ← rule matched + line number
# 2. Quick visual check — should NOT appear in untracked files
git status
# 3. Has .env EVER been committed in this repo's history?
git log --all --full-history -- .env
# If the log shows ANY commits, .env was once tracked.
# That means it's in your history — see "Env File Already Committed" above.
git check-ignore -v is the most reliable — it shows you exactly which .gitignore line is matching, so you can spot rules that match .env* patterns you didn’t realise existed.
Defense in Depth: Stop Secrets Before They Push
.gitignore is the first line of defence, but assume someone on your team will eventually forget to add a new .env.something file. Three layers prevent that:
Layer 1 — GitHub Secret Scanning + Push Protection
As of March 2026, GitHub’s secret scanning detects 250+ token formats (Stripe, AWS, GitHub PATs, Slack tokens, Azure, GCP, OpenAI, Anthropic, etc.) and Push Protection blocks the git push itself when it detects one. The dialog says “Push protection blocked your push” and you can either remove the secret or bypass with a justification.
- Free for all public repositories — enabled by default since 2023
- Included with GitHub Advanced Security for private repos (or free on certain plans)
- Enable it: Repo Settings → Code security and analysis → Secret scanning + Push protection → Enable
This catches the secret BEFORE it leaves your machine. The single highest-leverage 30-second action you can take today.
Layer 2 — Pre-commit Hook (Local Scanner)
Three scanners dominate the current landscape:
| Tool | Best for | Install |
|---|---|---|
| gitleaks | Speed — pre-commit hook | brew install gitleaks or pre-commit framework |
| TruffleHog | CI verification — verifies secrets are LIVE | brew install trufflesecurity/trufflehog/trufflehog |
| detect-secrets (Yelp) | Baseline rollout on existing repos | pip install detect-secrets |
The simplest setup uses the pre-commit framework which manages multiple hooks via a single config file:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
# One-time setup per machine
pip install pre-commit
pre-commit install
# Now `git commit` runs gitleaks first; if it finds a secret, commit is blocked
Layer 3 — GitGuardian / Snyk Code (Org-wide)
For teams, a SaaS scanner like GitGuardian or Snyk Code integrates with your GitHub org and scans every push from every developer, including those who skipped Layer 2 locally. Free tiers exist for small teams; paid plans for larger orgs.
Multiple .env Files — The Pattern Most Projects Use
project-root/
├── .env.example ← COMMITTED (template, safe values)
├── .env ← LOCAL (real dev values, ignored)
├── .env.local ← LOCAL (personal overrides, ignored)
├── .env.development ← LOCAL (or committed if no secrets)
├── .env.production ← NEVER COMMITTED
└── .env.test ← LOCAL (or committed if it's mock data only)
The .env.local convention (used by Vite, Next.js, Astro) means: this file overrides any other .env* file and is per-developer. Use it for your personal database URL, your personal API keys, debug flags you don’t want anyone else to inherit. Always in .gitignore.
Tracking Changes Without Committing — —skip-worktree vs —assume-unchanged
The old advice was git update-index --assume-unchanged .env. That’s wrong for config files. Here’s why:
| Flag | Intent | Problem for .env |
|---|---|---|
--assume-unchanged | Performance hint: “I promise this file won’t change, don’t check it” | Git can clobber it on git pull if the remote has changes; flag is reset on many Git operations |
--skip-worktree | Behaviour: “Pretend this file doesn’t exist in the worktree” | This is what you actually want for config files |
# Wrong — performance hint, not a behaviour change
git update-index --assume-unchanged .env
# Right — Git treats the file as unchanged until you explicitly tell it otherwise
git update-index --skip-worktree .env
# Undo it
git update-index --no-skip-worktree .env
Better still: don’t track .env in the first place. Use .gitignore + .env.example. --skip-worktree is the workaround for the awkward case where you have a tracked config file you can’t easily un-track (e.g., a legacy config/database.yml).
Global Gitignore for .env*
Belt-and-suspenders move: ignore .env* on your machine for every repo you touch, in addition to per-repo .gitignore files. This catches the case where you clone someone else’s repo that forgot to ignore .env:
# Create a global gitignore file
touch ~/.gitignore_global
# Tell Git to use it
git config --global core.excludesfile ~/.gitignore_global
# ~/.gitignore_global
.env
.env.local
.env.*.local
# Also catch other common local-only files
.DS_Store
Thumbs.db
*.swp
.vscode/
.idea/
# Windows variant — global excludes file lives at:
git config --global core.excludesfile "$env:USERPROFILE\.gitignore_global"
Beyond .gitignore — Env-File Alternatives
If your team is past the “just don’t commit secrets” stage, modern tools let you commit secrets safely or skip files entirely:
| Tool | Model | When to use |
|---|---|---|
| dotenvx | Encrypts .env files with a key that lives in .env.keys (ignored). The encrypted .env IS safe to commit. | Replacing plain dotenv with minimal workflow change |
| Doppler | SaaS secrets manager; CLI injects vars at runtime; no .env files on disk | Cross-team / cross-environment, devs share a single source of truth |
| SOPS (Mozilla) | Encrypts files with AWS KMS / GCP KMS / age; commit the encrypted file | Infrastructure-as-code repos, Kubernetes secrets |
| HashiCorp Vault | Self-hosted secrets server with fine-grained access policies | Enterprise, regulated environments, dynamic secrets |
| chamber (Segment) | AWS Parameter Store wrapper | AWS-native teams |
| GitHub Actions / Vercel / Netlify env vars | Set per-environment in the platform UI | CI/CD and serverless deployments |
The modern default for new projects: dotenvx for solo / small team, Doppler for cross-team. Plain .env + .gitignore is still fine — just make sure you also have Push Protection on the repo.
Key Takeaways
- The standard block: ignore
.env,.env.local,.env.*.local,.env.production, but allow!.env.examplefor the committed template - Pair
.envwith.env.example— a committed file showing required variables with placeholder values - Verify with
git check-ignore -v .env— shows you exactly which rule matched - If you accidentally committed
.env: rotate ALL secrets FIRST, then rewrite history withgit filter-repo(not the deprecatedgit filter-branch), force-push, and notify collaborators + fork owners git filter-repois the modern history-rewrite tool —git filter-branchwas deprecated in Git 2.41- GitHub Push Protection auto-blocks 250+ secret patterns before they push — free for public repos, enable it today
- Pre-commit scanners: gitleaks for speed, TruffleHog for live-secret verification, detect-secrets for baseline rollout
--skip-worktreebeats--assume-unchangedfor tracked-but-don’t-pull-changes config files- Global
.gitignoreviagit config --global core.excludesfileignores.env*across every repo you touch - Modern alternatives: dotenvx (encrypted + committable), Doppler (SaaS), SOPS (Mozilla), Vault (enterprise)
- Force-push doesn’t reach forks and clones — assume any pushed secret is already harvested by bots within minutes
FAQ
How do I verify .env is being ignored?
Run git check-ignore -v .env. The output shows the .gitignore file and line number of the rule that’s matching — e.g. .gitignore:1:.env .env. If you get no output, the file is NOT being ignored (and git status will show it as untracked). git status alone shows ignored files only when you pass --ignored.
Why is .gitignore not ignoring my .env file?
Almost always because the file was tracked before the rule was added. .gitignore only applies to untracked files — anything already tracked stays tracked. Fix: run git rm --cached .env to untrack it, then commit. The .gitignore rule will start applying.
Can I have multiple .env files in a project?
Yes — and most modern projects do. The convention: .env for committed defaults (only if NO secrets), .env.local for per-developer local overrides, .env.development / .env.production for per-environment, and .env.example as the committed template. Vite, Next.js, Astro, and Create React App all support this pattern out of the box. The standard .gitignore block at the top of this guide ignores all the .local variants while allowing .env.example.
What should I do if I accidentally committed and pushed my .env file?
Five steps in this order: (1) rotate every secret in the file via each provider’s dashboard — Stripe, AWS, your database, your JWT signing key, etc. The secret is compromised the moment it’s pushed; (2) add .env to .gitignore; (3) git rm --cached .env && git commit && git push to stop tracking it; (4) git filter-repo --path .env --invert-paths to remove it from all history, then git push --force --all; (5) notify collaborators to re-clone and check the GitHub Forks page for any clones that need cleaning. Rotation in step 1 is the only step that actually fixes the security problem — the rest just stops the leak from continuing.
How do I remove .env from Git history?
Use git filter-repo --path .env --invert-paths (install with brew install git-filter-repo or pip install git-filter-repo). This rewrites every commit, removing the file. Then force-push with git push --force --all && git push --force --tags. Note: git filter-branch was deprecated in Git 2.41 — don’t use it.
Is it possible to track changes in .env without committing it?
Yes, but it’s almost always a mistake. The clean answer: use .env.example (committed) for the structure, and .env (ignored) for values. If you genuinely need to track a config file that shouldn’t be pulled by collaborators, use git update-index --skip-worktree .env (NOT --assume-unchanged — that’s a performance hint, not a behaviour change). Undo with --no-skip-worktree.
What are the alternatives to .env files for storing configuration?
For modern projects: dotenvx encrypts .env so you can commit it safely; Doppler is a SaaS secrets manager that injects vars at runtime with no files on disk; SOPS (Mozilla) encrypts secret files with KMS keys for infra-as-code repos; HashiCorp Vault is the enterprise choice; GitHub Actions / Vercel / Netlify UIs let you set env vars per-environment for CI/CD. For local dev on a small team, plain .env + .gitignore + GitHub Push Protection is still perfectly fine.
What is the difference between .gitignore and .gitattributes?
.gitignore tells Git which files to ignore — they exist on disk but Git pretends they’re not there. .gitattributes tells Git how to handle files — line-endings (CRLF vs LF), merge strategies, diff filters, export behavior. The two are unrelated. For .env files, you only need .gitignore.
How does GitHub Secret Scanning work?
GitHub’s secret scanning runs on every push to detect 250+ known secret formats (Stripe keys start with sk_live_, AWS access keys with AKIA, etc.). When detected, it notifies the secret provider (so they can revoke), the repository admin, and — with Push Protection enabled — blocks the push entirely with a dialog explaining what was found. It’s enabled by default on all public repos since 2023 and included with GitHub Advanced Security for private repos.
Does .gitignore protect secrets that are already in commit history?
No. .gitignore only affects future tracking — anything already in Git history is still there and visible to anyone who clones, forks, or browses the commit log on GitHub. To remove a committed secret from history you need git filter-repo. To actually protect the secret, rotate it — the moment a secret hits a public push, assume it’s been harvested by automated bots within seconds.
References: