Every project I’ve worked on ends up with a .env file within the first week. Then a .env.local. Then someone adds a .env.staging and forgets to tell anyone which variables changed between it and production. Six months in, nobody is entirely sure which keys are actually still used, which ones are stale, and which ones would break the app if rotated. None of this is inevitable – it’s just what happens when secrets management is treated as an afterthought instead of a small, deliberate practice.

The baseline everyone should already have

Before anything more sophisticated: .env belongs in .gitignore, full stop. What belongs in the repo is a .env.example with every key present and placeholder or dummy values filled in – not because it’s a nice-to-have, but because it’s the only reliable documentation of what config a fresh checkout actually needs. If a new variable is added to the app, the pull request that introduces it should also update .env.example. This one habit prevents the most common onboarding failure: “it works on my machine” turning out to mean “I have three environment variables set that nobody told you about.”

The Twelve-Factor App’s config principle is still the clearest framing of why this matters: config that varies between environments (deploy-specific values, credentials) should live outside the codebase entirely, not in checked-in config files with per-environment sections.

Where things actually go wrong

The baseline above prevents accidental commits, but the real failures I see are process failures. A production API key gets pasted into a Slack thread to unblock someone quickly, and now it lives in Slack’s search index forever. A secret gets rotated in production but someone forgets staging, and staging silently starts failing auth calls a week later when a cache expires. Or the classic: a .env file does get committed, months pass, and by the time anyone notices, the key has been indexed by every automated scanner that crawls public GitHub repositories looking for exactly this.

None of these are solved by “be more careful.” They’re solved by removing the manual step where a human has to remember to be careful.

Separate config from secrets

Not everything in a .env file is equally sensitive. A feature flag, an API base URL, a log level – these are configuration, and it’s fine for them to sit in a plain file. A database password, a signing key, a third-party API secret – these need actual secret management: encrypted at rest, access-controlled, and auditable. Lumping both categories into one flat file makes it hard to reason about what’s actually sensitive.

For the second category, a dedicated secrets manager pays for itself quickly, whether that’s a cloud-native option like AWS Secrets Manager, a self-hosted HashiCorp Vault instance, or a lighter tool like Doppler or 1Password’s CLI integration. The specific tool matters less than the properties it gives you: centralized access control, an audit log of who read what and when, and the ability to rotate a value in one place instead of hunting through every environment’s config.

The OWASP Secrets Management Cheat Sheet is worth reading even if you don’t adopt every recommendation in it – the section on avoiding secrets baked into build artifacts is the one I see skipped most often. Building an API key into a Docker image at build time means that key lives in every layer of every image you ever push, including old ones sitting in a registry nobody’s looked at in a year. The safer pattern is injecting secrets at container start, through environment variables or a mounted file the entrypoint reads, so the value never touches the image itself.

Rotating without breaking things

Rotation is where most ad hoc setups fall apart, because swapping a credential atomically usually isn’t possible – there’s a window where the old value is invalid and the new one hasn’t propagated everywhere yet. The reliable pattern is dual credentials: create the new secret alongside the old one, deploy the new value everywhere it’s needed, confirm everything is using it, then revoke the old one. It’s more steps than editing one file, but it’s the difference between a rotation that’s invisible to users and one that causes an outage at 2am because a background worker still had the old database password cached.

Catching leaks before they ship

Even with good habits, mistakes happen – someone hardcodes a key while debugging and forgets to remove it before committing. A pre-commit hook running a secret scanner like gitleaks catches this before it ever reaches a shared branch, scanning the diff for patterns that look like API keys, private keys, or connection strings:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks

It takes a few minutes to wire into a repo’s pre-commit config, and it has saved me from at least two accidental commits that would have otherwise required rotating a production credential under time pressure. The same scanner running again in CI, against the full history rather than just the current diff, is cheap insurance against the hook being skipped locally with --no-verify.

None of this is complicated in isolation. The hard part is doing it consistently across a team and across the lifetime of a project, which is exactly why it’s worth automating the boring parts – the example file check, the leak scan, the rotation runbook – rather than relying on everyone remembering the rules every time.