.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.
- What a
.envfile actually is — and what loads it - Why secrets in code are permanent (git history never forgets)
- The
.env.exampleconvention and the one.gitignoreline that matters - What to do if you've already committed a secret — rotate first
- When
.envstops 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 — 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 -pand 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.
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:
# add these on day one — BEFORE the .env file exists
.env
.env.local
.env.*.local
# 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.
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:
# 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
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
.envdrifts 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.gitignorerule goes in on day one. - Believing
git rmremoved it. It removed the file, not the history.filter-repoor 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.