.env files — and why you never commit secrets

Somewhere on GitHub right now there's a repository with a live AWS key in a file called config.js, committed three years ago by someone who thought they'd "delete it later." Automated bots found it within minutes of the push. They always do.

The .env file is the standard answer — a plain-text file that keeps secrets out of your code and out of your history. But it's easy to get wrong in ways that don't look wrong until it's too late. Here's the whole practice: what the file is, the one .gitignore line that matters, and what to actually do if the secret is already in.

In this post
  • What a .env file actually is — and what loads it
  • Why secrets in code are permanent (git history never forgets)
  • The .env.example convention and the one .gitignore line that matters
  • What to do if you've already committed a secret — rotate first
  • When .env stops being enough

What a .env file actually is

A .env file is a plain-text list of KEY=value pairs, one per line. That's the whole format:

.env
# .env — local development (NEVER committed)

# database
DATABASE_URL=postgres://app:local-pass@localhost:5432/app_dev

# auth — long, random, unique per environment
JWT_SECRET=8f2kq9…use a real random string here

# third-party
STRIPE_SECRET_KEY=sk_test_51abc…

# behavior flags
DEBUG=true
LOG_LEVEL=debug

The important thing to understand: .env is a convention, not a language feature. Nothing reads it automatically. Your runtime or framework — dotenv in Node, python-dotenv, Docker, your hosting platform — loads the file and puts each pair into an environment variable, which your code then reads via process.env.JWT_SECRET or os.environ["JWT_SECRET"].

The payoff is the 12-factor principle: anything that changes between deploys — credentials, hostnames, feature flags — lives in the environment, not the code. The same code runs in dev, staging, and production; only the .env differs.

Why secrets never belong in code

It's tempting to hardcode a key "for now" — it's right there, it works, you'll move it later. Three reasons you can't:

  • Git history is forever. Delete the secret in the next commit and it's still there. Anyone with the repo can run git log -p and read every version of every file you've ever committed.
  • Public repos leak instantly. Bots scan GitHub for key-shaped strings continuously. A secret in a public repo is compromised within minutes — often before your CI finishes running.
  • Code travels. It gets forked, copied into new projects, pasted into Slack, screenshotted for a question. A secret in code has a social life you can't control.
There's no such thing as a frontend secret

Anything shipped to a browser is public — in a JS bundle, in HTML, in a "hidden" env var that your build tool inlines. An API key your frontend needs is not a secret; treat it as public, restrict it (domain-locked, tightly scoped), or proxy the call through your backend.

The two conventions that make it work

The whole system rests on two files working together:

.gitignore
# add these on day one — BEFORE the .env file exists
.env
.env.local
.env.*.local
.env.example — COMMITTED
# Copy to .env and fill in your real values.

DATABASE_URL=postgres://user:password@localhost:5432/dbname
JWT_SECRET=replace-with-a-long-random-string
STRIPE_SECRET_KEY=sk_test_xxxxx
DEBUG=true

.gitignore keeps the real file out of git. .env.example is its committed shadow: every key, but with placeholder values. It answers a new teammate's first question — "what do I need to run this?" — without leaking anything. Copy it to .env, fill in your values, and you're running.

The ordering matters

Add the .gitignore rule before you create the .env file. Git only ignores files it isn't already tracking — if .env gets committed even once, adding it to .gitignore afterward does nothing about the copy that's already in history.

If you've already committed a secret

It happens to everyone eventually. Here's the order of operations, and the order matters more than any single step:

damage-control.sh
# 1 · ROTATE THE SECRET FIRST.
#    The leak already happened — revoking the key is the actual fix.

# 2 · remove the file from ALL history, not just the latest commit
git filter-repo --path .env --invert-paths

# 3 · force-push, then have every collaborator re-clone
git push origin main --force
Rotate before you scrub

People reach for git filter-repo first — that's backwards. If the repo was ever public, assume the secret is already scraped. Scrubbing history hides the leak from the future; only rotation undoes the damage. And git rm .env plus a new commit does nothing about history — the secret stays in every earlier commit.

git-filter-repo (or the older BFG Repo-Cleaner) rewrites history to excise the file entirely. It's a destructive operation — it changes every commit hash — so coordinate with your team before the force-push.

When .env stops being enough

A .env file is perfect for local development and fine for simple deploys. It starts to strain as things grow:

  • Teams — secrets get shared over Slack "just this once," and everyone's .env drifts out of sync.
  • Production — a file full of production credentials sitting on disk is one misconfigured backup away from a leak.
  • CI/CD — pipelines need secrets injected per run, not read from a file that would have to be committed to exist.

The mature answer is the same principle with better tooling: secrets live outside the repo and get injected at runtime. GitHub Actions secrets, your platform's env-var dashboard (Vercel, Render, Railway), or a dedicated secret manager (Vault, AWS Secrets Manager, 1Password). A .env file is just the local-development flavor of that idea — so the mental model carries over cleanly when you graduate.

The mistakes that actually leak things

  • Committing .env "just this once." History is forever. The .gitignore rule goes in on day one.
  • Believing git rm removed it. It removed the file, not the history. filter-repo or BFG is the only real scrub.
  • Real values in .env.example. It's committed on purpose — placeholders only, always.
  • Logging secrets. A stray console.log(process.env) puts every secret in your logs, which get shipped to a logging service you forgot about.
  • One secret for dev and prod. The dev secret ends up in a tutorial, a screenshot, a question on a forum — and now it's your production secret too.

Wrap-up

A .env file is a plain-text list of secrets that your runtime loads into environment variables — the code stays in git, the secrets stay out. .gitignore keeps it untracked, .env.example documents it, and if a secret ever lands in history, rotation is the fix, not deletion.

The secrets you're protecting are usually the same ones that sign your tokens — the JWT post shows what happens when an HS256 secret is weak, and the rebase-vs-merge post keeps the history you're protecting clean enough to audit. And when you do commit, the commit message generator makes sure the message is the one thing about your repo that isn't a mystery.