Configuration that changes per environment does not belong in the file
Your database URL is different on your laptop and in production. Your Stripe key is different in test and live. So they are not code. They are inputs.
const key = process.env.OPENROUTER_API_KEY;
if (!key) throw new Error("OPENROUTER_API_KEY is not set");Check for the value at startup, not at use. A missing secret that crashes the process on boot is a failed deploy you notice in thirty seconds. A missing secret discovered by a 500 error on the one route that uses it is a Saturday.
.env is a local convenience, not a deployment mechanism
.envgoes in.gitignore. Put it there before you create the file, not after..env.examplegets committed, with the variable names and no values. It is documentation.- On the host, you set the real values through the platform:
wrangler secret put BETTER_AUTH_SECRET
fly secrets set DATABASE_URL=postgres://...Those live encrypted on the platform and are injected into the process. Nobody copies a .env to a server over SSH.
Anything prefixed for the browser is public
NEXT_PUBLIC_, VITE_, REACT_APP_ — these prefixes exist to tell the build tool "substitute this value into the JavaScript bundle". That bundle is downloaded by every visitor. View source, search, done.
This is the most common leak I see, and it has a predictable cause: the build tool said the variable was undefined in the browser, someone added the prefix, the error went away, and the key shipped. If the browser needs to call a paid API, the fix is a route on your own server that holds the key and calls the API on the user's behalf. That route also gives you somewhere to put a rate limit, which you are going to need anyway.
When you commit a key — and you will
Suppose the key is now in a public repository. Someone deletes the line and commits the fix. Nothing has been fixed.
The old commit is still in the history. It is in every clone anyone made. It is in every fork. GitHub keeps unreachable commits addressable for a while. And most importantly: automated scanners read the public events stream continuously. Keys posted to public repositories are routinely used within minutes, without anyone cloning anything.
The only action that ends the exposure is rotating the credential. Go to the provider, revoke the old key, issue a new one, set it on the host.
Order matters more than people expect. Rotate first. Clean the history second, if at all. The common mistake is spending ninety minutes on git filter-repo while the live key stays live the entire time.
If it is a database password, rotate it. If it is a signing secret, rotate it and accept that every existing session is invalidated — that is the correct outcome, not a reason to delay.
Prevention that costs nothing
- Turn on push protection or secret scanning if your host offers it. It refuses the push.
- Run
gitleaks detectin CI so a leak fails the build. - Never
console.log(config). Logs are copied into a search index, retained for thirty days, and readable by more people than the database is. - Give each environment its own key. Then a leak from staging is a staging problem.
The uncomfortable part
Everyone does this eventually — experienced people included. The difference between a small incident and a real one is entirely how fast you rotate, and whether you had the reflex to rotate at all rather than to tidy.
Before you move on