What you'll learn
  • Which values in a project are secrets and which are ordinary configuration
  • Why a key committed to git stays readable in history even after you delete the file
  • How .env and .env.example split the work between your machine and your team
  • Where production secrets go on hosts like Cloudflare Pages, Vercel, and Railway
  • Why rotating a leaked key comes before any history cleanup
on this page

Somewhere in your project right now there is probably a string that can spend money on your behalf. A Stripe key, an OpenAI key, a database password. It works the same whether it runs on your laptop or on a stranger’s, and that is the whole problem: the string is the credential. There is no second factor, no “are you sure,” no check that the person holding it is you.

So before you ship anything real, you need an answer to one question: where do these strings live, if not in the code?

What actually counts as a secret

A secret is any value that lets someone act as you or as your app. The usual suspects:

  • API keys: the string a service hands you so your code can call it, and bill you. Stripe, OpenAI, SendGrid, AWS, all of them.
  • Tokens: short or long-lived proof of identity, like a GitHub personal access token or an OAuth refresh token.
  • Database credentials: usually bundled into one connection string, postgres://user:password@host:5432/dbname. The password is right there in the URL.
  • Signing secrets: the random string your app uses to sign sessions, JWTs (JSON Web Tokens, signed blobs that prove who a request came from), or webhook payloads. Anyone who has it can forge valid-looking requests.

And the flip side, because beginners overcorrect: not everything in your config is a secret. A port number, your site’s public URL, a feature flag, Stripe’s publishable key (the one starting pk_): all fine in the open. The test is simple. If a stranger reading the value could cost you money, read your data, or impersonate your app, it is a secret. Otherwise it is just configuration.

Why secrets never go in the repo

The tempting move is to paste the key right where you use it:

// please no
const stripe = new Stripe("sk_live_51AbCdEf...");

It works, which is exactly what makes it dangerous. The moment you commit and push, that key is published. If the repo is public, it is published to the entire internet. If it is private, it is published to everyone who ever gets access to the repo, including future collaborators, CI systems, and whoever ends up with a laptop backup.

Here is the part that catches almost everyone at least once: deleting the file in a later commit does not unpublish it. Git’s whole job is remembering every version of everything. The delete is a new chapter; the old chapter with the key in it is still on the shelf. (If commits and history feel fuzzy, Git beyond add, commit, push covers the mental model.)

This is what the discovery usually looks like, months later:

[ the bad discovery ]
git log --all --oneline -- .env
9c4e7b2 chore: remove .env from repo
3f2a91c chore: add env config

The file was committed once, three months ago, and deleted a week later. Both commits are still here, and the older one still contains the file.

git show 3f2a91c:.env
DATABASE_URL=postgres://admin:[email protected]:5432/prod
STRIPE_SECRET_KEY=sk_live_51AbC...

Anyone who can read the repo can run this exact command. If the repo is public, assume someone already has.

GitHub’s push protection now blocks many well-known key formats before they land in a public repo, which is a nice safety net. But it only recognizes formats it knows about, it can be bypassed, and it does nothing for keys that are already in history. Treat it as a smoke detector, not a fire department.

Local: .env and its public twin

The standard home for secrets during development is a file named .env in your project root. One variable per line:

# .env
DATABASE_URL=postgres://postgres:devpassword@localhost:5432/myapp
STRIPE_SECRET_KEY=sk_test_51AbC...
SESSION_SECRET=some-long-random-string

Your code never contains the values, only the names. In Node you read them off process.env:

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

Frameworks like Astro, Vite, and Next.js load .env automatically when the dev server starts. Plain Node can do it too: recent versions accept node --env-file=.env app.js, and the older route is the dotenv package.

The one non-negotiable line of setup is in .gitignore:

# .gitignore
.env

That single line is what keeps the file on your machine and out of every commit forever. Add it before you write your first secret, not after.

But now your teammate clones the repo, runs the app, and it explodes because none of those variables exist on their machine. The fix is a second file, .env.example, which you do commit:

# .env.example
DATABASE_URL=postgres://postgres:CHANGE_ME@localhost:5432/myapp
STRIPE_SECRET_KEY=sk_test_get_yours_from_the_stripe_dashboard
SESSION_SECRET=generate-a-long-random-string

Same names, fake values. It is documentation shaped like a config file: copy it to .env, fill in the blanks, and you are running. The names are public, the values never are.

Production: the host’s dashboard

Your .env file never leaves your laptop, so production needs its own copy of the values. Do not solve this by uploading the file. Every serious host has a place in its dashboard to set environment variables for your deployment:

  • Cloudflare Pages: your project’s settings, under environment variables, with separate values for production and preview deploys.
  • Vercel: project settings, environment variables, scoped to production, preview, or development.
  • Railway: the variables tab on each service.

The pattern is identical everywhere: you type the name and value into the dashboard, the platform stores them encrypted, and injects them into your app’s environment at build or run time. Your code keeps reading process.env.STRIPE_SECRET_KEY and never knows the difference. One thing that trips people up: on most platforms, changing a variable does not touch the running deployment, so redeploy after you change one.

This setup also buys you something subtle: different values per environment. Test keys locally, live keys in production, and no way to mix them up by copying the wrong file around.

When projects grow past one team and a handful of keys, there are dedicated secret managers: HashiCorp Vault, AWS Secrets Manager, Doppler, 1Password’s developer tools. They add central storage, per-person access control, audit logs of who read what, and scheduled rotation. You do not need one for your first deploy. You should know they exist, because “the grown-up version of the dashboard tab” is exactly what they are.

What a leak looks like in practice

It helps to know what you are defending against, because it is faster and dumber than most people expect.

Bots watch GitHub’s public activity feed around the clock, pattern-matching every pushed commit for key formats: sk_live_ for Stripe, AKIA for AWS, and hundreds more. Security researchers who plant decoy keys in public repos routinely see the first unauthorized use within minutes of pushing. Not days. Minutes.

What happens next depends on the key. A leaked cloud key typically gets used to spin up as many compute instances as your account allows, mining cryptocurrency on your bill until you or the provider notices. The classic version of this story ends with a four or five figure invoice and a stressful support ticket. (Providers often forgive a first offense. Often is doing a lot of work in that sentence.) A leaked payment or email key gets used for fraud, which is its own special headache.

Some providers partner with GitHub’s secret scanning and will automatically revoke or quarantine their own key formats when they show up in public code. That is genuinely useful, and you still should not lean on it, because it only covers public repos and recognized formats.

Rotate first, clean up second

So you found a key in your history, or got the scary automated email. The order of operations matters, and people get it backwards constantly.

Rotate the key first. Rotating means going to the provider’s dashboard, generating a new key, and revoking the old one. The instant the old key is revoked, the leak is over: the string in your git history is now a string that opens nothing. Stripe, OpenAI, GitHub, AWS, every provider has a roll, regenerate, or revoke button for exactly this moment. Update the new value in your local .env and your host’s dashboard, redeploy, done.

People stall here because revoking breaks the running app until the new key is in place. Accept the two minutes of downtime. The alternative is leaving a live key published while you fiddle with git.

Then clean up the repo. With the old key dead, the cleanup is about hygiene, not safety:

[ after rotating ]
git rm --cached .env
rm '.env'

Removes the file from tracking without deleting your local copy.

echo ".env" >> .gitignore
git add .gitignore
git commit -m "stop tracking .env"

Future commits are clean. The old key is still in history, which is why rotation came first.

If you want the key gone from history entirely, tools like git filter-repo and BFG Repo-Cleaner can rewrite the whole history without the file, after which you force-push the rewritten version. It is fussy, it breaks every existing clone, and it still cannot reach the forks and scraped copies already out in the world. Which is the whole argument in one sentence: rotation is the fix, history rewriting is cosmetics.

Frontend secrets are not secrets

One last trap, and it snags people who did everything else right.

Environment variables feel private, so it seems reasonable to put an API key in one and use it in your frontend code. But think about where frontend code runs: in the visitor’s browser. To run there, it has to be sent there. Every byte of your client-side JavaScript, including any key baked into it, is downloadable by anyone who opens DevTools and looks at the network tab. There is no such thing as a hidden value in a shipped bundle.

Frameworks make this boundary explicit with a naming convention. In Astro, only variables prefixed with PUBLIC_ are available to client-side code; Vite uses VITE_, Next.js uses NEXT_PUBLIC_. The prefix is not protection. It is the opposite: a deliberate label saying “I know this ships to the browser and I am fine with that.” Site URLs, analytics IDs, publishable keys: prefix away. The moment you find yourself typing PUBLIC_STRIPE_SECRET_KEY, the name itself is telling you no.

The real pattern for using a secret from a frontend is to not use it from the frontend. The browser calls your own backend (an API route, a serverless function), and that server-side code holds the key and makes the privileged call. The key stays on machines you control.

Wiring it into your next deploy

Here is the whole system, project-sized: a .env in your project root with .env in .gitignore from the very first commit, a committed .env.example so the setup survives a fresh clone, and the same variable names typed into your host’s dashboard when you connect the repo. That is maybe ten minutes of setup, and it is the difference between “I deployed my project” and “I deployed my project and then wrote an apologetic email to my cloud provider.”

If you followed the localhost-to-production guide and your deploy works but features that need keys are mysteriously broken, this is almost always why: the values exist in your local .env and were never set on the host. Add them in the dashboard, redeploy, and the app finds them the same way it did on your laptop. Same names, different vault, and nothing secret in the repo where git would remember it forever.

signal check

Can you answer these without scrolling back up?

  1. You committed an API key, deleted the file in the very next commit, and pushed both. Is the key safe?

    No. Both commits are in the repo's history, so anyone can run git show on the older one and read the key. Treat it as leaked: rotate the key in the provider's dashboard first, then clean up the repo.

  2. What is the difference between .env and .env.example?

    .env holds your real values and is listed in .gitignore so it never gets committed. .env.example holds only the variable names with placeholder values, and it is committed on purpose so teammates know which variables they need to fill in.

  3. Your frontend needs an API key for a maps widget. Can you hide it in an environment variable?

    No. Anything the browser uses ships to the browser, so the key is readable in the bundle no matter how it got there. Prefixes like PUBLIC_ exist to mark a variable as deliberately exposed. A key that must stay secret has to live on a server that makes the call on the browser's behalf.

progress is stored in this browser only