Build-time vs runtime environment variables
Here is a support conversation I have had more times than any other, in roughly this form: 'I set DATABASE_URL in the dashboard. I can see it. The build still says it's undefined. Is your platform broken?'
The platform is usually fine. What's happening is that there are two completely separate moments at which a program can read an environment variable, they happen on different machines at different times, and most deployment tooling presents them in one undifferentiated text box labelled 'Environment Variables'. That single UI decision is responsible for an enormous amount of wasted debugging.
The two moments
A deploy has a build phase and a run phase. During the build phase something like `npm run build` executes: it compiles TypeScript, bundles JavaScript, renders static pages, and writes output to disk. During the run phase your server process starts and begins answering requests.
A build-time variable is read while that build command runs. Whatever value it has is either baked into the output or used to decide what the output looks like. A runtime variable is read when your server process starts or while it handles a request. Changing it means restarting the process, not rebuilding.
The consequences diverge sharply. Change a runtime variable and a restart picks it up. Change a build-time variable and nothing happens until you rebuild, because the old value is already compiled into files sitting on disk.
// runtime: read when the request arrives, on the server
export async function GET() {
const db = await connect(process.env.DATABASE_URL);
return Response.json(await db.query("select 1"));
}
// build-time: this value is substituted into the bundle during `next build`
// and shipped to the browser. Changing it later changes nothing.
const analyticsKey = process.env.NEXT_PUBLIC_ANALYTICS_KEY;Why frontend frameworks make this worse
Code that runs in a browser has no environment. There is no `process.env` in a browser tab — there's no process. So every frontend framework solves this by doing a find-and-replace at build time: it scans your source for a specific prefix and literally substitutes the string value into the bundle.
- Next.js substitutes anything prefixed `NEXT_PUBLIC_`.
- Vite substitutes anything prefixed `VITE_`, exposed as `import.meta.env.VITE_FOO`.
- Create React App used `REACT_APP_`.
- Nuxt exposes `runtimeConfig.public`, which despite the name is fixed at build for the client bundle.
- SvelteKit splits it explicitly into `$env/static/*` and `$env/dynamic/*`, which is the clearest naming of the bunch.
So a `NEXT_PUBLIC_` variable is not really an environment variable at all. It is a compile-time constant with an environment-variable-shaped way of setting it. If it isn't present when the build runs, you get `undefined` baked into your JavaScript, and the failure shows up in a browser hours later rather than in your build logs.
Diagnosing it in ninety seconds
When a variable appears to be missing, the question to answer first is which phase failed. The logs tell you if you know what to look for.
- Find where the error appears. If it's in the build log, it's a build-time problem. If the build succeeded and the error is in the app's runtime log or the browser console, it's a runtime problem — different fix.
- Print the keys, never the values, at the top of your build. `node -e "console.log(Object.keys(process.env).filter(k => k.startsWith('NEXT_PUBLIC')))"` tells you whether the variable reached the build environment at all.
- If the key is absent, the variable was set for the run phase but not the build phase. That's a platform configuration issue and it's the common case.
- If the key is present but the value is wrong, you probably have a stale build. A framework that caches build output will happily reuse a bundle compiled with the old value.
- For frontend variables, grep the built output. `grep -r "your-value" .next/static` proves whether the substitution actually happened.
On PandaStack, app environment variables are supplied to both phases, so `npm run build` and your start command see the same set. That removes the most common version of this bug, but not the second most common one: a variable that only exists on your laptop, in a `.env.local` file that is correctly gitignored and therefore has never been anywhere near the build machine.
# Set on the app so both the build and the running process see it
pandastack apps env set my-app DATABASE_URL='postgres://...'
pandastack apps env set my-app NEXT_PUBLIC_SITE_URL='https://myapp.example.com'
# Changing a NEXT_PUBLIC_ value requires a rebuild, not just a restart
pandastack apps deploy my-appThe DATABASE_URL trap
There's a particularly annoying instance of this that catches teams using an ORM. Prisma, Drizzle and friends run a code-generation step during install or build. Some of those steps want a database URL, and a few want to actually connect.
`prisma generate` reads the schema file and does not need a live database — it needs `DATABASE_URL` to be syntactically present in some configurations, but it isn't connecting. `prisma migrate deploy` genuinely connects and applies migrations, and that must not run during your build phase. Build machines are ephemeral, builds run in parallel, and builds get retried. Applying schema migrations from a place with all three of those properties is how you end up with two concurrent migrations racing on the same database.
Run generation at build, run migrations as a distinct release step before the new version starts taking traffic. If you're doing blue-green deploys, that ordering matters even more, and it's worth reading about handling schema changes on deploy separately.
Rules that keep this from recurring
- Validate at startup, loudly. Parse your environment into a typed config object the moment the process boots and crash with a clear message listing every missing key. Failing at boot beats failing on a user's request three hours later.
- Never read `process.env` scattered through your codebase. One config module, read once, imported everywhere. It makes the full set of required variables greppable in one place.
- Treat every public-prefixed variable as a build input. If it changes, you rebuild. Write that down somewhere your future self will find it.
- Keep a checked-in `.env.example` with every key and a fake value. It's the only documentation of your required configuration that people actually update.
- Prefer runtime configuration where you have the choice. A value read at runtime can be rotated with a restart; one baked into a bundle requires a full rebuild and redeploy, which is a bad property for anything resembling a credential.
None of this is deep. It's just genuinely obscured by tooling that presents two very different mechanisms through one identical-looking input field. Once you're asking 'which phase reads this?' before you're asking 'why is this undefined?', the whole class of bug stops taking an afternoon.
Frequently asked questions
Why is my NEXT_PUBLIC_ environment variable undefined in the browser?
Because NEXT_PUBLIC_ variables are substituted into the JavaScript bundle during the build, not read at runtime. If the variable was not present in the environment when next build ran, the literal string undefined is compiled into your bundle and no amount of restarting will fix it. Either the variable is only configured for the run phase and not the build phase, or you changed the value and did not rebuild. Confirm by grepping your built output for the expected value — if it is not in .next/static, the substitution never happened.
What is the difference between a build-time and a runtime environment variable?
A build-time variable is read while your build command runs and its value is baked into the compiled output, so changing it requires a full rebuild. A runtime variable is read when your server process starts or handles a request, so changing it only requires a restart. Server-side secrets like a database URL should be runtime. Anything prefixed NEXT_PUBLIC_, VITE_, or REACT_APP_ is build-time by definition, because browsers have no environment to read from and the framework substitutes the value as a string constant instead.
Can I put a secret API key in a NEXT_PUBLIC_ variable?
No. The prefix is a declaration that the value will be shipped to the browser as a plain string inside your JavaScript bundle, where anyone can read it with view-source or devtools. There is no configuration that makes it private. If a value must stay secret, it has to be read server-side at runtime and used from a server route or API handler, with the browser calling that endpoint rather than holding the key itself.
Should database migrations run during the build?
No. Build environments are ephemeral, builds can run in parallel, and builds get retried automatically — all three are bad properties for something that mutates shared state. Code generation such as prisma generate belongs in the build because it only reads your schema file. Applying migrations belongs in a separate release step that runs once, before the new version starts taking traffic, so two concurrent builds cannot race each other against the same database.
How do I check which environment variables a build actually received?
Print the keys, not the values, at the start of your build script — something like node -e "console.log(Object.keys(process.env).sort())" in a prebuild step. This tells you whether the variable reached the build environment at all, which distinguishes a platform configuration problem from a code problem. Never print values: build logs are frequently stored, shared in support threads, and retained far longer than anyone expects.
Keep reading
- App hosting on PandaStack — environment variables are supplied to both the build and the running process
- Zero-downtime schema migrations on deploy — where migrations belong if not in the build
- Why your JavaScript build runs out of memory — the other build failure everyone hits
- Deploy a Next.js app with git push
- Wiring an app to managed Postgres with Prisma or Drizzle
49ms p50 cold start. Fork, snapshot, and scale to zero.