Your app needs a database password, a Stripe key and a Resend key to work. Those values cannot live in your code, because your code goes to GitHub and anyone who reads it there can spend your money.
So they live somewhere else, and every host has a slot for them. The mechanism is small. The rules around it are what people get wrong.
The idea
An A named value the operating system hands to your program when it starts, kept outside the code so it can differ per machine. is a labelled value your program reads at startup rather than one written into a file you commit. A A value that grants access. If someone else has it, they can do whatever it lets you do, and the only fix is replacing it. is any of those values that would let a stranger act as you: a database URL, an API key, a webhook signing secret, a session signing key.
.env.local .env.example
(on your machine only) (committed to git)
--------------------- --------------------
DATABASE_URL=postgres:// DATABASE_URL=
STRIPE_SECRET_KEY=sk_live_.. STRIPE_SECRET_KEY=
| |
| listed in .gitignore | names only, no values
X never reaches GitHub v
tells the next person
what they have to fill in
on the host: the same names, pasted into the
dashboard (Vercel, Railway) or a root-only file
read by systemd (a plain server)How it works
- A
.envfile is read at startup and never shipped. The name on the left becomes available to your code asprocess.env.DATABASE_URL. Nothing about the file is encrypted. Its safety comes entirely from where it is not. .gitignoreis what keeps it out of the repository. One line,.env*.local, and git stops offering the file. Check withgit statusthat it is genuinely not listed before your first commit..env.exampleis the version you do commit. Same variable names, empty values. It is how you and your assistant know what the app needs without exposing anything.- On a host you paste the same names into a different box. Vercel, Railway, Render and Netlify
each have an environment variables screen, usually with separate values per environment. On a
plain Linux server the equivalent is a root-owned file with
0600permissions that systemd loads when the service starts. - Anything the browser can read is public. In Next.js, a variable prefixed
NEXT_PUBLIC_is compiled into the JavaScript sent to every visitor. In Vite the prefix isVITE_. Nothing marked that way is a secret, whatever it is called.
What to do
- Check
.gitignorebefore the first commit, not after. Ask directly: "is.env.localignored in this repository?" A secret that has been committed once stays in the git history even after you delete the line. - Keep a
.env.examplein sync. Every time you add a variable, add the empty name there too. This is the file that makes a fresh clone runnable, and it is the file your AI assistant reads to understand what the app connects to. - Assume every key you paste into a chat window is exposed. Paste variable names when you need help, never values. Your assistant does not need the actual key to write code that reads it.
- Rotate rather than hope. If a key reached GitHub, a public log, a screenshot or a support ticket, go to the provider and issue a new one. GitHub scans public repositories for known key formats and notifies providers, so the leak may already have been noticed by someone else.
Where it breaks
The frontend rule catches almost everyone once. You add NEXT_PUBLIC_ to a variable because the
build complained it was undefined in a component, the error goes away, and your service key is now
in a file every visitor downloads. If a value is needed in the browser, the work belongs on the
server instead: the browser calls your own API route, and the route holds the key.
The second failure is quieter. Environment variables are read when the process starts, so changing one in a dashboard does nothing until the app restarts or redeploys. People change a value, see the old behaviour, and change something else that was never broken.