Skip to content
xecret
Sign in

Stop committing .env files: how to get secrets out of your repository without slowing your team down

The .env file is a default, not a discipline problem. Why copies escape .gitignore, the four ways it actually goes wrong, and a migration you can do today.

The xecret teamPlayxoft8 min read
On this page

Nobody has ever sat down and decided that database passwords belong in a git repository. That is not how it happens. It happens because .env is the shortest distance between "this needs an API key" and "this runs on my machine", and every tool in the ecosystem has spent a decade making that distance shorter.

The file is a default, not a failure of discipline

Treating leaked .env files as a training problem is the reason the problem never goes away. The people involved already know secrets should not be in version control. They put the value in a file anyway, because the file is what the framework reads, the file is what the README tells a new hire to create, and the file is what a working application requires in the next four minutes.

Look at what the alternative asks for at the moment somebody needs the key. A vault means a login, a CLI, a permission grant and a concept to learn. A file means one line in an editor. Given a deadline, the file wins every time — and it should, because at that moment the file genuinely is the better tool for the job in front of them.

So the useful question is not "how do we make people stop", it is "what has to be true for the safe path to be the shortest one". That is a design question, and it has a real answer.

Why the copies multiply faster than you can ignore them

The single most misunderstood thing about .gitignore is what it protects. It protects a path. It does not protect a value, and it does not follow that value anywhere else it goes.

A secret enters a codebase once and then spreads by ordinary, well-intentioned work:

  • A new engineer joins. Somebody pastes the block into a Slack DM, because the alternative is a twenty-minute call. Slack is now a secret store with search enabled and no expiry.
  • Someone needs to test a migration against staging, so they copy .env to .env.staging.bak and edit two lines. .gitignore says .env. It does not say .env.staging.bak.
  • The Dockerfile does COPY . . and the build context contains everything the .dockerignore did not name. The value is now a layer in a registry, and docker history will show it to anybody who can pull the image.
  • Someone debugs a CI failure by adding a step that prints the environment. The build log is retained for ninety days and readable by every contributor.

None of these are careless. Each one is a reasonable act by a person solving the problem in front of them. The file is the thing that made all four possible.

A copy that lives…Covered by .gitignore?Readable by
.env in the repository rootUsually, yesAnyone with that laptop
.env.backup, .env~, .env.saveNo — .env alone matches none of themAnyone with that laptop
A Slack message from onboardingNot a file, so noEveryone in the workspace, via search, indefinitely
The Docker build contextIrrelevant — .dockerignore decidesAnyone who can pull the image
A CI job that echoed the environmentIrrelevantAnyone who can read a build log
A former colleague's disk imageNoThem

The patterns almost every repository misses

Here is a .gitignore block that covers the shapes people actually create, rather than only the one the framework documents:

bash
# Every dotenv variant, including the ones nobody plans to make
.env
.env.*
*.env

# Editor and shell leftovers that .env.* does not match
.env~
.env.swp
.envrc

# Re-admit the files that are supposed to be committed
!.env.example
!.env.*.example

# Credentials that are not called .env at all
*.pem
*.p12
*.pfx
service-account*.json
gha-creds-*.json

Two things about that block. First, .env on its own matches nothing else — not .env.local, not .env.production, not .env.backup. If your ignore file has one line, it is covering one file. Second, the negations have to come after the broad patterns, and they cannot re-admit a file whose parent directory is ignored. If you keep secrets under config/, ignoring config/ means !config/example.env will not work.

Getting the file out, without breaking anybody's afternoon

Start by finding out what is actually tracked, rather than what you believe is tracked:

bash
# Every tracked file that looks like an env file, anywhere in the tree
git ls-files | grep -E '(^|/)\.env($|\.)|\.env$|\.pem$'

# Is one specific file tracked? Exit code 0 means yes.
git ls-files --error-unmatch .env

# Env files that were tracked at some point and later deleted —
# still in history, still readable
git log --all --diff-filter=D --name-only -- '.env*' | sort -u

If something comes back, untrack it without deleting your working copy:

bash
git rm --cached .env .env.local
printf '\n.env\n.env.*\n*.env\n!.env.example\n' >> .gitignore
git add .gitignore
git commit -m "chore: stop tracking env files"

--cached is the important flag. It removes the file from the index while leaving it on disk, so your colleague who pulls this commit does not lose their working configuration — they simply stop sharing it.

Be clear with yourself about what that commit achieved. It stops future commits from carrying the file. Every value that was ever committed is still in the repository's history, still fetchable by anyone with a clone, and — this is the part people miss — still live at the provider. Removing a key from a file does not revoke it. If anything came back from that --diff-filter=D search, you have a rotation job on your hands, and we wrote a separate runbook for exactly that: finding a leaked API key in git history.

Replacing the file rather than banning it

Deleting the file only works if something takes over its job on the same day. The job is narrow and specific: put a set of key-value pairs into a process's environment before that process starts. Anything that does that job can replace the file without a single line of application code changing, because your code is reading process.env, not reading a file.

That is the whole design of the xecret CLI. You store the values once, and the CLI injects them into whatever you run:

bash
xecret login                     # browser approval; the credential goes to your OS keychain
xecret init                      # writes .xecret.yaml — a project slug and an environment slug
xecret run -- npm run dev        # your command, with the environment already populated

.xecret.yaml contains two slugs and no secrets, so you commit it. That single committed file is what makes the safe path the short path: a new engineer clones, runs xecret run -- npm run dev, and is working. Nobody pastes anything into Slack, because there is nothing to paste. The full sequence, including CI, is in the quickstart, and every flag is in the CLI command reference.

Migrating what you already have does not require retyping it. Drag the existing .env into the import dialog in the dashboard; it is parsed in your browser and shows you a dry-run of what would be created before anything is sent. A pasted blob of production credentials never becomes a request body.

The order that keeps the team moving

  1. Development first. Lowest stakes, and it is where everyone will notice whether the new flow is faster or slower than the old one.
  2. CI second, with a service token scoped to one project and one environment. This is where the biggest sprawl usually lives, because CI configuration accumulates copies nobody audits.
  3. Production last, and only after the first two have been boring for a week.
  4. Then rotate. Every value that lived in a file that was shared around is a value whose custody you cannot account for. Migrating it moves it; only rotation makes the old copies worthless.

Framework-specific notes matter mostly for build-time variables — a NEXT_PUBLIC_ value has to exist during next build, not during next start — and that is covered in the Next.js guide.

What this does and does not fix

Getting secrets out of files buys you three concrete things: one place a value is edited, a record of who read what, and revocation that actually takes effect because there is no file on a laptop that keeps working after you revoke.

It does not make the values unreadable by us. xecret uses server-side envelope encryption, which means the service can technically decrypt your secrets. That is the same model Doppler uses, and it is what makes team sharing, CI tokens and browser-side import work without a key exchange ceremony. If your threat model requires a provider that cannot read your data even in principle, you want a zero-knowledge product, and we would rather you knew that before migrating than after — the whole model is written out in the trust model.

It also does not stop your own code from leaking. If your application logs process.env or a crash reporter ships it, nothing upstream can help.

Where xecret fits

This is the product pitch, kept in its own section so you can skip it. xecret is open-source secret management built around one command: you store environment variables once, per environment, and xecret run injects them into whatever you were going to run anyway — locally, in CI, in production. The server is AGPL-3.0 and self-hosting holds nothing back; the CLI is MIT. It is pre-alpha, which we say plainly, and the pricing page sets out what each plan includes and what, if anything, it costs on the day you read this. If it turns out not to be for you, the .gitignore block above is still worth ten minutes of your afternoon.

Keep reading

Get your secrets out of version control.

Create a project, import the .env file you already have, and run your app through the CLI. It takes about a minute, and the free tier does not ask for a card.

No credit card · AGPL-3.0 server · MIT CLI · Self-host it for free