Skip to content
intermediate Phase 16 · Deployment and Production Capstone

Managing Secrets Across Environments

Separate development, staging, and production configuration.

40m
0 problems
Topic Progress 0%

Who Wins When Two Files Define the Same Key: The Env Loading Order

Your CI job dies with TypeError: Cannot read properties of undefined (reading 'apiKey') while the same commit runs on your laptop without complaint. Nothing is wrong with the code: your laptop has a gitignored .env.local holding STRIPE_SECRET_KEY, and the CI container never saw that file. Configuration failures in Next.js are almost always precedence failures, and the order is short enough to memorize cold.

When next dev, next build, or next start boots, Next.js runs loadEnvConfig() from its internal @next/env package and reads dotenv files in a fixed order, stopping at the first place the key is found:

Rank Source Example What belongs there
1 Already-set process environment DATABASE_URL=... next build, platform secret store CI secrets, container -e vars
2 .env.$(NODE_ENV).local .env.production.local one-off machine overrides
3 .env.local .env.local per-developer secrets, never committed
4 .env.$(NODE_ENV) .env.production non-secret per-environment defaults
5 .env .env shared defaults, safe to commit

Three rules explain most bugs you will hit:

  • A variable already present in process.env beats every file. That is why a platform's injected secret or STRIPE_KEY=sk_live_... next build outranks anything on disk — and why a forgotten shell export can silently override your .env.local and send you debugging the wrong value.
  • You do not set NODE_ENV. next dev forces development; next build and next start force production. Under NODE_ENV=test, .env.local is skipped entirely so one developer's overrides cannot skew test runs; commit .env.test for the defaults everyone should share.
  • $VARS inside a value are expanded from what already loaded, so TWITTER_URL=https://x.com/$TWITTER_USER style composition works, multiline quoted values survive (that is how private keys are stored), and a literal dollar is escaped as \$. There is no nested interpolation syntax beyond that.

File discipline follows directly from the table. Commit .env, .env.production, and .env.test; keep every .env*.local file out of git (the create-next-app template already ignores them); ship a .env.example listing each key with a placeholder so a new teammate knows what to fill in. If your project uses a src/ folder, the .env* files still belong at the project root — Next.js does not read them from src/.

Two consequences are worth stating plainly in an interview. First, tools outside the next CLI need the same loading behavior: a test runner or an ORM config should call loadEnvConfig(process.cwd()) from @next/env instead of hand-rolling a dotenv import, or it will disagree with the app about what DATABASE_URL is. Second, secrets are never validated for you — a missing key surfaces at the exact line that reads it, often deep inside a prerender. Fail fast in one place at boot rather than in fifty call sites.

When you are debugging a load, print key names rather than values, so the diagnostic itself does not become a leak:

node -e "const {loadEnvConfig}=require('@next/env');loadEnvConfig(process.cwd());\
console.log(Object.keys(process.env).filter(k=>/^(DATABASE|STRIPE|SESSION)/.test(k)).sort())"
# [ 'DATABASE_URL', 'SESSION_SECRET', 'STRIPE_SECRET_KEY' ]

An empty list means you are looking in the wrong directory (the loader uses process.cwd(), not the file's location), and a key present but pointing at localhost in production means the wrong file won the precedence contest. Also note that Next.js itself supplies a few variables — NODE_ENV, NEXT_RUNTIME, and the telemetry flag — so a grep of your own files will not show where every name in process.env came from.

The order above tells you which file wins locally. Throttling Abuse at the Edge already covered protecting the request path; here the question is which values every request runs with — and the next chapter's trap is what happens the moment a value crosses into browser JavaScript.

NEXT_PUBLIC_ and the Inlining Trap: A Key in the Bundle Is Not a Secret

Prefixing a variable with NEXT_PUBLIC_ changes three things at once: who may read it, when its value is frozen, and whether changing it needs a rebuild. Non-prefixed variables live only in the Node runtime. Prefixed ones are replaced with a hard-coded string during next build, not read at runtime by the browser.

Prove it in thirty seconds:

echo 'NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk' >> .env
npm run build
grep -R "abcdefghijk" .next/static | head -3
# .next/static/chunks/8f3c21...js:setupAnalytics("abcdefghijk")

That literal string ships to every visitor. It is the correct mechanism for publishable values — analytics IDs, public API bases, feature-flag names — and it is the reason a secret must never carry the NEXT_PUBLIC_ prefix. Access control in components, if (process.env.NODE_ENV !== 'production') guards, and "only used in one place" do not hide anything: anything inlined is published with the app.

Three consequences decide real bugs:

  1. Build-frozen. After next build, editing the value does nothing. Build one Docker image and promote it through staging and production, and every NEXT_PUBLIC_ value is whatever the build machine saw. When a public value genuinely differs per environment, fetch it at runtime from an API instead of faking it with build args.
  2. Only static lookups are inlined. process.env.NEXT_PUBLIC_ANALYTICS_ID is replaced. A computed key — const k = 'NEXT_PUBLIC_X'; process.env[k] — or destructuring the whole object first (const env = process.env; env.NEXT_PUBLIC_X) cannot be analyzed by the bundler, so the browser receives undefined and you get a runtime failure that no type checker catches.
  3. Server-side reads follow different rules. In the Node runtime, process.env.STRIPE_SECRET_KEY is read from the real environment when the request executes, so dynamic rendering, route handlers, server actions, and middleware all pick up the current environment's value with no rebuild. The exception is a page prerendered at next build: that module code runs during the build, and anything it renders into HTML comes from the build machine. Reading a secret inside a statically generated page is how a key ends up in a cached HTML file.

A third path exists in next.config.mjs:

const nextConfig = {
  env: { PUBLIC_BUCKET: 'my-bucket' }, // inlined into server AND client output
}
export default nextConfig

It does not bypass the rule — those values are baked into both bundles at build time, so treat them as build inputs, not as secrets, and never place a token there "because it is not prefixed".

A mixed case comes up constantly: the client needs a value that does differ per environment. Solve it with a server route that reads at runtime and let the client ask for it — no rebuild, no secret shipped:

// app/api/config/route.ts — runs on the server, reads live env
export const dynamic = 'force-dynamic'
export async function GET() {
  return Response.json({ apiBase: process.env.PUBLIC_API_BASE })
}

The test an interviewer may ask you to perform on the spot is the grep from the first half of this chapter: build, search .next/static for the value, and show it in the chunk. If you cannot find it there, the browser does not have it — that is the only reliable check, because reading your own source tells you nothing about what the bundler substituted.

The working rule to carry forward: NEXT_PUBLIC_* = public and frozen at build; plain process.env = server-side and read at request time; prerendered HTML = whatever the build machine had. Where each environment keeps its own set of those values is the operational half of this topic, and it is exactly what Deploying to a Node Platform puts side by side.

Rotating a Leaked Key Without Shipping New Code: Runtime Secrets and Per-Environment Sets

Security reports that a Stripe key appeared in a CI log. You revoke it in the dashboard, deploy nothing, and production keeps working — or dies, depending on where that key lived. Rotation is the fastest way to discover whether a value was build-time or runtime, so design for it before the incident.

Kind of variable Value fixed when Where it must be stored Rotation action
NEXT_PUBLIC_* next build platform build environment update value, then rebuild
Server var rendered by a prerendered page next build build environment rebuild (or make the route dynamic)
Server var read during a request process start / request platform runtime secrets update secret, restart or redeploy
Secret fetched from a manager by your code each fetch or at boot AWS Secrets Manager, Doppler, Vault update in the manager; no deploy

The third row is the one to aim for: a value injected at runtime means rotating a secret is an environment change plus a rolling restart, with no new build, no new artifact, and no window where old code runs with the wrong key. Every platform gives you a place to put these; they differ only in shape:

Platform Where values live Scoping
Vercel Project → Environment Variables, or vercel env pull writes .env.local Production / Preview / Development
Netlify Site settings or netlify env:set KEY val --context production production / preview / development contexts
Fly.io fly secrets set STRIPE_SECRET_KEY=sk_live_... (triggers a restart) per app, separate apps per stage
Railway railway variables KEY=value --environment production environment + service
Your VM systemd EnvironmentFile=/etc/store/env, or docker run --env-file per host

The failure this prevents: each environment gets its own variable set, so a dev key can never touch production. Written out, a storefront's sets look like this:

Variable development staging production
DATABASE_URL local Postgres separate staging cluster production cluster
STRIPE_SECRET_KEY sk_test_... sk_test_... (own account) sk_live_...
STRIPE_WEBHOOK_SECRET CLI-forwarded endpoint staging endpoint secret live endpoint secret
SESSION_SECRET random per machine one shared secret one shared secret, rotated yearly
LOG_LEVEL debug debug info

Note the pattern: pointers (DATABASE_URL) must differ outright between staging and production, credentials differ by value and kind, and only genuinely public values are allowed to repeat. Two concrete guards make that real. Staging must have a different DATABASE_URL than production — separate databases, not separate prefixes on the same cluster — and a live-versus-test mismatch should refuse to boot:

// instrumentation.ts — register() runs once, before traffic is accepted
export async function register() {
  if (process.env.NODE_ENV === 'production' && !process.env.STRIPE_SECRET_KEY?.startsWith('sk_live_')) {
    throw new Error('STRIPE_SECRET_KEY is a test key — refusing to start in production')
  }
}

A throw inside register() keeps the server from ever becoming ready, so your supervisor or load balancer leaves the previous healthy instance in service instead of routing buyers to a store that will decline every card.

Two hygiene rules round it out. Log configuration names, never values: an allowlisted redaction (pino's redact option) beats grepping output for sk_live_, and dumping process.env inside an error handler ships your whole secret set to an error tracker. And keep .env*.local out of git forever — platform stores and secret managers are the only homes for real keys.

You now know which values freeze at build and which survive to request time. Deploying to a Node Platform shows how Vercel, Netlify, Fly, and Railway each decide that for you.

Quiz

1. You set LOG_LEVEL=debug in .env, LOG_LEVEL=info in .env.local, and export LOG_LEVEL=warn in your shell before running next build. Which value does the app see?

Question 1 options

2. You build one Docker image and promote it to staging and production, changing NEXT_PUBLIC_API_BASE between environments. What value does the browser get?

Question 2 options

3. With `const varName = 'NEXT_PUBLIC_ID'`, `const env = process.env`, and `const key = 'NEXT_PUBLIC_ID'` already declared, which call inlines the value into the client bundle?

Question 3 options

4. A plain server-side variable (no NEXT_PUBLIC_ prefix) read inside a route handler must be rotated. What is required?

Question 4 options

Flashcards

Question

In what order does Next.js resolve an environment variable, and what always wins?

Answer

process.env first, then .env.$(NODE_ENV).local, .env.local (skipped when NODE_ENV=test), .env.$(NODE_ENV), and .env — stopping at the first hit. An already-set process environment variable beats every file, so platform injection and shell exports override everything on disk.

Question

What does the NEXT_PUBLIC_ prefix actually do to a variable?

Answer

It marks the variable as public: next build replaces every statically-analyzable process.env.NEXT_PUBLIC_X reference with the literal value, shipping it to the browser in the JavaScript bundle and freezing it at build time. Dynamic lookups are not inlined and return undefined.

Question

Why does rotating a NEXT_PUBLIC_ value require a rebuild while a server variable does not?

Answer

The public value is textually embedded in the emitted chunks, so only a new next build writes the new value. A non-prefixed server variable is read from process.env when the request executes, so changing it is an environment edit plus a process restart.

Question

With NODE_ENV=production, which .env file overrides which?

Answer

Highest first: .env.production.local, then .env.local, then .env.production, then .env — with real process environment variables above all of them. The $(NODE_ENV) files swap to .env.development* under next dev and are ignored entirely under NODE_ENV=test.

Question

How do you keep development credentials from ever touching production?

Answer

Separate variable sets per environment on the platform (Vercel scopes, Netlify contexts, fly secrets, railway variables), gitignored .env*.local files for local secrets, a different DATABASE_URL pointing at a different database for staging, and a boot-time assertion in instrumentation.ts register() that refuses to start when a test credential is present in production.

Revision Notes

Key Takeaways

  • 1. Lookup stops at the first source that defines a key, and the real process environment is checked before any .env file — shell exports and platform injection always win
  • 2. NEXT_PUBLIC_ values are inlined as literals during next build: public by construction, frozen at build, invisible to dynamic lookups
  • 3. Server-side variables survive to request time, so rotation is an environment change plus a restart; public ones force a rebuild
  • 4. Each environment must own a variable set — including its own DATABASE_URL — so a dev key or dev data can never reach production
  • 5. Fail-fast validation in instrumentation.ts register() converts a misconfigured deploy into a refused boot instead of a live incident

Interview Tips

  • • "What is the env loading order in Next.js?" — recite the five steps and add that an already-set process env wins and .env.local is skipped under NODE_ENV=test; it shows you have read the behavior, not just used it
  • • "How do you keep secrets out of the client bundle?" — NEXT_PUBLIC_ is the only door to the browser, values are inlined at build, and dynamic lookups return undefined; offer to grep .next/static as proof
  • • "How would you rotate a compromised API key with zero downtime?" — separate build-time from runtime values, update the platform secret, rolling restart, and note that NEXT_PUBLIC_ values additionally require a rebuild
  • • "How do you prevent someone deploying a dev config to production?" — per-environment variable sets, separate databases, and a boot-time assertion that throws in register() so the bad instance never goes ready

Cheat Sheet

Managing Secrets Across Environments

Loading order (first hit wins):

Rank Source Typical contents
1 process.env (real environment) platform secrets, CI vars, shell exports
2 .env.$(NODE_ENV).local machine-local overrides
3 .env.local (skipped when NODE_ENV=test) per-developer secrets, never committed
4 .env.$(NODE_ENV) non-secret env defaults, committable
5 .env shared defaults, committable

Three classifications:

Kind Read when Rotation
NEXT_PUBLIC_* textually inlined at next build update + rebuild
Server var on a prerendered page at build, baked into HTML rebuild (or force dynamic)
Server var in dynamic rendering / route handler / action at request time update runtime secret + restart
// instrumentation.ts — fail fast before traffic is accepted
export async function register() {
  if (process.env.NODE_ENV === 'production' && !process.env.STRIPE_SECRET_KEY?.startsWith('sk_live_')) {
    throw new Error('test key in production — refusing to boot')
  }
}

Per-environment storage: vercel env pull → .env.local · netlify env:set KEY val --context production · fly secrets set KEY=val (restarts) · railway variables KEY=value --environment production · systemd EnvironmentFile=.

Never: prefix a secret with NEXT_PUBLIC_, commit .env*.local, print process.env inside an error handler.

Continue Learning