Developer Tools

.env Management Across Local, Staging, and Production Without Leaking Secrets

The exact moment a real API key ended up in git history through a careless .env commit, the cleanup that followed, and the process that replaced "just be careful."

By Aissam Ait Ahmed Developer Tools 0 comments

A payment provider's live secret key sat in our git history for eleven days before anyone noticed, committed accidentally as part of a routine .env update that skipped review because "it's just an env file." Nothing malicious happened in that window as far as we could determine, and we rotated the key immediately once we found it — but "just be careful with your .env file" turned out to be exactly the kind of advice that works right up until the one time it doesn't, and it's worth walking through both the incident and the process that replaced relying on carefulness alone.

How it happened, mechanically

The actual commit was mundane: a developer added a new required environment variable for a payment integration, updated their local .env to test it, and — because .env wasn't in .gitignore on a freshly cloned deployment branch where the ignore file itself had been accidentally overwritten during an unrelated merge — the file got swept up in a broader git add . along with legitimate code changes. Nobody reviewing the pull request specifically scrutinized the .env diff, because a large PR with many changed files makes a single unexpected file easy to skim past.

# What should have been ignored, and briefly wasn't
.env
.env.local
.env.*.local

The cleanup: rotating first, cleaning history second

The order of operations here matters and is worth being explicit about, because it's easy to get backwards under pressure: rotate the exposed credential immediately, before doing anything else, including before cleaning up git history. A credential that's been exposed is compromised the moment it's exposed, regardless of whether anyone has actually misused it yet or whether it's still visible in the repository — cleaning history doesn't undo the exposure that already happened, it only prevents future exposure to people who clone the repo going forward.

  1. Rotate the credential immediately through the payment provider's dashboard, generating a new key and revoking the old one, before anything else.
  2. Update the new key in every environment that actually needs it — production, staging, any CI pipeline that runs integration tests against the provider.
  3. Only then, clean the git history using a tool built for the purpose:
# Using git-filter-repo (the currently recommended tool, replacing
# the older, slower filter-branch approach for this task)
git filter-repo --path .env --invert-paths

Rewriting history is disruptive — it changes commit hashes for every commit after the one being cleaned, which means every team member needs to re-clone or carefully rebase their local work, and any open pull requests based on the old history need to be recreated. That disruption is a real cost, and it's worth doing anyway, specifically to prevent the exposed key from remaining trivially visible to anyone who clones the repository in the future, even though the already-rotated key itself is no longer valid.

What replaced "just be careful"

The actual process change wasn't a lecture about carefulness — it was removing the specific conditions that let this happen at all, one at a time.

1. A committed .env.example with no real values

# .env.example — committed to the repo, contains structure but no secrets
APP_KEY=
DATABASE_URL=
PAYMENT_PROVIDER_SECRET_KEY=
PAYMENT_PROVIDER_PUBLISHABLE_KEY=

This gives every developer a clear, version-controlled template of every variable the application expects, without a single real value ever needing to exist in a file that's a candidate for accidental commit. New environment variables get added here first, as an empty placeholder, which is also a natural point to ask "does this actually need to be a secret, or is it safe to hardcode as a config default" before it ever reaches a real .env file at all.

2. A pre-commit hook that specifically blocks committing .env

#!/bin/sh
# .husky/pre-commit — an explicit, redundant guard beyond .gitignore
if git diff --cached --name-only | grep -qE '^\.env$|^\.env\.[a-z]+$'; then
    echo "ERROR: Attempting to commit a .env file. This is almost certainly a mistake."
    exit 1
fi

This is deliberately redundant with .gitignore — the incident happened specifically because the .gitignore entry was accidentally lost during a merge, and a second, independent check that doesn't depend on the ignore file being intact catches exactly that failure mode. Defense in depth matters more for secrets than almost anything else in a codebase, because the cost of a single miss is disproportionate to the cost of every other kind of bug.

3. A secret-scanning check in CI, as a final backstop

# .github/workflows/secret-scan.yml
- uses: trufflesecurity/trufflehog@main
  with:
    path: ./
    extra_args: --only-verified

Running a dedicated secret-scanning tool against every push catches what both the local pre-commit hook and .gitignore might still miss — someone committing from an environment where the hooks aren't installed, for instance, or a secret accidentally pasted directly into application code rather than an env file. --only-verified specifically limits alerts to credentials the tool can confirm are real and active against the actual provider, rather than flooding the team with false positives on every string that merely looks like it could be a key.

4. Separate credentials per environment, so a leak is contained

A quieter but equally important change: local, staging, and production now use entirely separate credentials for every third-party service, not shared keys scoped down by environment-aware code. Before this, a handful of services used the same key across staging and production "temporarily" during initial setup, and that temporary arrangement had quietly become permanent. Separate credentials per environment mean a leaked staging key — lower stakes, easier to rotate without a production outage — never doubles as a leaked production key too.

EnvironmentWhere secrets liveWho can access
LocalUntracked .env, gitignored, blocked by pre-commit hookIndividual developer only
StagingCI/CD platform's encrypted secrets storeDeploy pipeline, limited team access
ProductionEncrypted secrets store, separate from staging'sDeploy pipeline, restricted to senior team members

What we'd tell a team setting this up before their first incident, not after

  • Add the pre-commit block and the committed .env.example on day one — both are cheap, and the entire point is catching the mistake before it happens once, not after.
  • Never share credentials across environments "temporarily," because temporary arrangements around secrets tend to quietly become permanent the moment the initial setup pressure passes and nobody circles back to fix it properly.
  • When a real leak happens, rotate first, always, before touching git history — the rotation is what actually neutralizes the exposure; history cleanup is about preventing exposure to future clones, which is a real but secondary concern.
  • A secret-scanning CI check is worth adding even if you're confident in your other safeguards — it's the layer that catches the failure mode none of the other layers anticipated, which is exactly what happened here with the lost .gitignore entry.

If you're setting up a new developer's local environment from scratch and want the .env.example step folded into a broader onboarding checklist, setting up a productive local dev environment from a blank laptop covers the rest of that setup, and if the secrets in question include API keys you're generating for local testing specifically, generating and storing secure API keys during local development covers that narrower piece in more depth.

Deciding what actually belongs in staging vs. production config

Beyond the immediate leak, the audit that followed the incident surfaced a second, quieter problem: several config values that weren't secrets at all — feature flags, a third-party API's base URL, a cache TTL — were being managed through the same encrypted secrets store as genuine credentials, purely out of habit rather than necessity. Treating every configuration value as equally sensitive has a real cost: it makes the secrets store noisier and harder to audit, and it trains people to stop reading diffs carefully because "it's all just secrets anyway."

  • Genuine secrets — API keys, database passwords, signing keys — belong in the encrypted secrets store, access-restricted, never in version control.
  • Environment-specific but non-sensitive values — a staging API base URL, a feature flag default — are safe to keep as plain, committed config, differentiated per environment through Laravel's own config system rather than treated as secrets.
  • When genuinely unsure which category a value falls into, ask what actually happens if it leaks: if the answer is "nothing meaningful," it's config, not a secret; if the answer involves unauthorized access to something, it's a secret.

Separating these two categories explicitly made the secrets store smaller and easier to actually review — a shorter list of genuinely sensitive values is one a human can meaningfully audit periodically, where a long list mixing secrets and ordinary config in together tends to get skimmed rather than actually checked.

The recurring audit that replaced a one-time cleanup

The cleanup described above was thorough, and it was still a point-in-time fix — new environment variables get added regularly as the application grows, and nothing about the original incident prevented a slightly different version of the same mistake from happening again with a future credential nobody had thought to protect yet. A recurring quarterly review, checking every credential currently in use against a simple checklist — is it environment-specific, is it in the secrets store rather than a committed file, has it been rotated in the last year — turned the original incident response into an ongoing practice rather than a single remediation that fades from memory once the immediate crisis passes.

The review itself takes under an hour each quarter, which is a small, sustainable cost compared to the eleven days a real key sat exposed the one time this wasn't being checked at all. That asymmetry — cheap, regular prevention against an expensive, rare failure — is the actual argument for treating this as a recurring calendar item rather than a lesson learned once and then trusted to stick on its own, especially once the person who lived through the original incident eventually moves teams or leaves, taking the institutional memory of why the process exists along with them unless it's written down somewhere durable.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Developer Tools Free Resources Explore Tools