Environment variables are one of those things that seem trivially simple until you spend three hours debugging why your API calls work locally but return 401 in production. Or until you accidentally commit a .env file with your Stripe secret key and have to rotate every credential in your system at 10pm on a Thursday. We have done both.
Here is everything we have learned about environment variables after shipping dozens of projects, distilled into nine rules — the guide we wish someone had given us on day one.
What Environment Variables Actually Do
Environment variables are key-value pairs your process reads at startup, living outside your code. They exist so the same codebase can run against different databases, API keys, and feature flags in local dev, staging, and production — without a single hardcoded value.
That separation of config from code is the whole point. Get it right and deploys are boring. Get it wrong and you leak secrets, break production, or both. The nine rules below cover the failure modes we’ve actually hit.
Rule 1: The .env File Is Not Your Config System
Your .env file is a convenience for local development. It is not a deployment config, not a secrets manager, and not a substitute for proper environment configuration. The .env file loads variables into your local process so you can develop without hardcoding values. That is its only job.
In production, your environment variables should come from your hosting platform’s settings — Netlify’s dashboard, Vercel’s project settings, your cloud provider’s secrets manager. Never from a file deployed alongside your code.
Rule 2: Learn the .env File Hierarchy
Most frameworks support multiple .env files with a specific loading order, and knowing it saves you from “why is this variable the wrong value” sessions:
{`# lowest → highest priority
.env # base defaults — committed
.env.local # local overrides — git-ignored
.env.development # mode-specific defaults — committed
.env.production
.env.development.local # mode-specific secrets — git-ignored
.env.production.local
# actual system environment variables ALWAYS win`}
The rule is simple: files without .local in the name get committed and contain non-sensitive defaults. Files with .local contain your actual secrets for local development and never touch the repo.
Rule 3: Anything With “.local” Goes in .gitignore
If you only remember one thing from this post, remember this one. Your .gitignore needs these lines before your first commit, not after your first leak:
{`.env.local
.env.*.local`}
Git history is forever. Once a secret is committed and pushed, deleting the file doesn’t un-leak it — you’re into rotation territory (see Rule 9), and that’s a much worse evening.
Rule 4: Treat the Public Prefix as a Security Boundary
Astro uses PUBLIC_. Vite uses VITE_. Next.js uses NEXT_PUBLIC_. Create React App used REACT_APP_. The naming differs, but the concept is the same: variables with the public prefix get bundled into your client-side JavaScript. Variables without it are only available server-side.
{`# ❌ client code can't read this in Astro/Vite — no prefix
SUPABASE_ANON_KEY=eyJhbGciOi...
# ✅ bundled into the client — fine, the anon key is public by design
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...
# 🚨 NEVER — this ships admin credentials to every browser
PUBLIC_SUPABASE_SERVICE_KEY=eyJhbGciOi...`}
This matters enormously in both directions. Put your Supabase anon key in SUPABASE_ANON_KEY and your client-side code can’t access it. Put your service role key in PUBLIC_SUPABASE_SERVICE_KEY and congratulations — you just shipped your admin credentials to every browser that loads your site.
The public prefix is a security boundary. Treat it that way.
Rule 5: Know What Goes Where
Safe to expose in the browser
Your Supabase anon key, your Stripe publishable key, your Google Maps API key (with domain restrictions), your site URL, and feature flags. These are designed to be public or are harmless when public.
Server-side only, always
Your Supabase service role key, your Stripe secret key, database connection strings, third-party API keys, JWT signing secrets, and SMTP credentials. None of these should ever carry a public prefix.
If you are unsure whether a variable should be public or private, it should be private. You can always move it to public later. You cannot un-expose a secret that has been bundled into client JavaScript and cached by CDNs and browsers worldwide.
Rule 6: Ship a .env.example
Every project should have a .env.example file committed to the repo — every variable the project needs, with placeholders instead of real values:
{`PUBLIC_SUPABASE_URL=your_supabase_url_here
PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
SUPABASE_SERVICE_ROLE_KEY=your_service_key_here
STRIPE_SECRET_KEY=sk_test_replace_me
SMTP_PASSWORD=replace_me`}
When a new developer clones the repo, they copy .env.example to .env.local and fill in real values from the shared password manager. No guessing which variables are needed, no grepping for every import.meta.env reference, no Slack messages asking “what env vars do I need?”
Rule 7: Know Build-Time vs Runtime Variables
This one catches people constantly. In static site generators like Astro in SSG mode — or any Vite project — environment variables are replaced at build time. They are literally string-replaced into your JavaScript bundle.
That means if you change a variable in Netlify’s dashboard and don’t trigger a rebuild, your site still uses the old value. Rotate an API key? Redeploy. Flip a feature flag? Redeploy. The value is baked into the built files — nothing reads the environment at request time. (Our Netlify deployment guide covers the rebuild triggers.)
Server-side rendered code is different: API routes, SSR page functions, and edge functions read variables at runtime, so changes take effect immediately without a rebuild. Know which type each of your variables is — it will save you from the “I updated the env var but nothing changed” debugging session.
Rule 8: Validate Environment Variables at Startup
We validate environment variables at application startup using Zod. If a variable is missing or malformed, the app fails immediately with a clear error instead of silently breaking at some random point during a user interaction:
{`import { z } from "zod";
const envSchema = z.object({
PUBLIC_SUPABASE_URL: z.string().url(),
PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
});
// Fails fast at boot with a readable error —
// not at 2am inside a user's checkout flow
export const env = envSchema.parse(process.env);`}
This takes five minutes to set up and catches misconfiguration before it reaches users. Every project should do it. No exceptions.
Rule 9: Have a Rotation Protocol Before You Need One
When a secret is compromised — and eventually, one will be — you need to rotate it without breaking production. Our protocol, in order:
- Generate the new key in the service’s dashboard.
- Update the variable in your hosting platform.
- Trigger a redeploy if it’s a build-time variable (Rule 7).
- Verify the application works with the new key.
- Revoke the old key — last, so production never breaks mid-swap.
That order matters. Revoking first is how a bad evening becomes a bad weekend.
The Verdict
Environment variable security is boring right up until it’s the most expensive thing that ever happened to your project. The nine rules above are cheap insurance: a hierarchy you understand, a prefix boundary you respect, an example file you ship, validation that fails fast, and a rotation plan you never want to use.
Set it up once per project and move on. And if your env vars differ across environments — they should — our staging environments guide covers how to structure the rest of the pipeline.