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 is a labelled value your program reads at startup rather than one written into a file you commit. A 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.

code
  .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 .env file is read at startup and never shipped. The name on the left becomes available to your code as process.env.DATABASE_URL. Nothing about the file is encrypted. Its safety comes entirely from where it is not.
  • .gitignore is what keeps it out of the repository. One line, .env*.local, and git stops offering the file. Check with git status that it is genuinely not listed before your first commit.
  • .env.example is 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 0600 permissions 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 is VITE_. Nothing marked that way is a secret, whatever it is called.

What to do

  1. Check .gitignore before the first commit, not after. Ask directly: "is .env.local ignored in this repository?" A secret that has been committed once stays in the git history even after you delete the line.
  2. Keep a .env.example in 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.
  3. 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.
  4. 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.