env.dev

Build-Time vs Runtime Environment Variables

Build-time env vars are inlined into your bundle and frozen until you rebuild; runtime vars are read from process.env on each start. When to use each.

By env.dev Updated

A build-time environment variable is read once — when your bundler or compiler runs — and its value is frozen into the artifact; a runtime variable is read fresh from process.env every time the program starts. Confuse the two and a VITE_ or NEXT_PUBLIC_ value meant for staging ships hard-coded into production, sitting in plain text inside every JavaScript bundle and surviving in CDN caches until the next rebuild. Next.js states the trap in its own docs: after next build, "your app will no longer respond to changes to these environment variables." The mechanism is the same across Vite, webpack, esbuild, and Docker ARG — only the prefix and the flag change.

TL;DR

  • Build-time vars are inlined into the artifact at compile time and cannot change without a rebuild. Runtime vars are read from the process environment on each start.
  • Anything injected at build time into client code — Vite VITE_, Next.js NEXT_PUBLIC_, webpack DefinePlugin — is public. Grep the built bundle and it is there in plain text. Never put a secret behind one.
  • Docker ARG and ENV both persist in image layers. Only BuildKit --mount=type=secret (the default builder since Docker 23, February 2023) keeps a build secret out of the final image.
  • "Build once, deploy to N environments" needs runtime config — an entrypoint script, a fetched config.json, or server-read process.env — not build-time injection.
  • Detection is one command: grep -rF "your-value" dist/ after a build tells you instantly whether a value got baked in.

What is the difference between build-time and runtime environment variables?

It comes down to when the value is resolved. A build-time variable is consumed by a tool that produces an artifact — a JavaScript bundle, a container image, a compiled binary. The tool reads the variable from its own environment, substitutes the value directly into the output, and the variable name is gone by the time the artifact exists. A runtime variable is never touched by the build; it is read by the running program from the environment it was launched in, so the same artifact behaves differently depending on where you start it.

The 12-Factor App calls this the build/release/run separation: the build stage turns code into an executable bundle, and config is supposed to enter at the release or run stage so one build can serve every environment. Build-time injection deliberately breaks that rule — it trades flexibility for the ability to tree-shake and inline constants. That trade is correct for genuinely public, build-invariant values and wrong for everything else.

How do you inject environment variables into the build process?

You set the variable in the environment the build command runs in. The detail that trips people up: bundlers only expose variables that carry a public prefix, while Docker needs an explicit ARG declaration. Three mechanisms cover almost every stack.

Bundlers read prefixed variables present at build time and inline them. The Vite env guide and the Next.js env guide cover the prefix rules in depth:

bash
# Vite — exposes VITE_* on import.meta.env, inlined at build
VITE_API_URL=https://api.example.com vite build

# Next.js — inlines NEXT_PUBLIC_* into the client bundle at next build
NEXT_PUBLIC_API_URL=https://api.example.com next build

# esbuild — explicit text substitution via --define
esbuild app.js --bundle --define:process.env.NODE_ENV='"production"'

webpack does the same thing through DefinePlugin, which performs a literal text replacement — wrap values in JSON.stringify or the substitution produces a bare identifier instead of a string:

javascript
// webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.API_URL': JSON.stringify(process.env.API_URL),
    }),
  ],
};

Docker passes values in with --build-arg, received by an ARG in the Dockerfile. The Docker env guide details the ARG vs ENV layering:

dockerfile
# Dockerfile
ARG VITE_API_URL
RUN VITE_API_URL=$VITE_API_URL npm run build

# build command
docker build --build-arg VITE_API_URL=https://api.example.com -t web .

In CI, the same idea is just an env: block on the build step — the value lives only for the duration of the build:

yaml
# GitHub Actions
- name: Build
  run: npm run build
  env:
    VITE_API_URL: ${{ vars.API_URL }}

Which tools freeze env vars at build time vs read them at runtime?

The single most useful thing to memorize is which side of the line each tool sits on. Anything in the build-time column ships its value inside the artifact; anything in the runtime column reads fresh on each start.

MechanismResolvedWhat you get
Vite VITE_Build timeInlined into the bundle, public
Next.js NEXT_PUBLIC_Build time (next build)Inlined into the bundle, public
CRA REACT_APP_Build timeInlined into the bundle, public
webpack DefinePlugin / esbuild --defineBuild timeText substitution in the output
Docker ARGBuild timeBaked into image history/layers
Docker ENVRuntime (container start)Present in the running container
Node server process.envRuntimeRead fresh on each start
Next.js server + connection()Runtime (request time)Read per request, never inlined

Why did my secret end up in the JavaScript bundle?

Because it was referenced in code the bundler inlines. The prefix on a client variable — VITE_, NEXT_PUBLIC_, REACT_APP_ — is a publication marker, not a protection: it tells the bundler "copy this value verbatim into the shipped JavaScript." Once that happens, the value is in every user's browser and your CDN cache, and a rebuild is the only way to remove it. Rotate any key that leaked this way; it is already compromised.

Detection is one command. Build, then search the output directory for the literal value:

bash
npm run build
grep -rF "sk_live_" dist/   # or build/, .next/, out/ — your output dir

# anything printed is baked into the artifact and shipped to users

The Docker side has the same hazard with a different surface. ARG and ENV values are stored in the image and visible to anyone who runs docker history. Docker's own docs are blunt: "Build arguments and environment variables are inappropriate for passing secrets to your build, because they persist in the final image." The fix is BuildKit secret mounts, which expose the value only for one RUN and leave nothing behind:

dockerfile
# syntax=docker/dockerfile:1
FROM node:24
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci

# build command
docker build --secret id=npmtoken,src=$HOME/.npm_token -t web .

For the full threat model — what counts as a secret, where leaked keys surface, and how to rotate — see the environment variables security guide.

When should you read env vars at runtime instead?

Build-time injection is the wrong default whenever the value is not both public and fixed for the life of the artifact. Reach for runtime config in these cases:

  • Build once, deploy to many environments — promoting one immutable image or bundle through staging and production. Build-time values freeze to whatever was set during that single build, so you would have to rebuild per environment. Inject at container start instead (an entrypoint script that writes window.__CONFIG__ or a config.json the app fetches on boot).
  • Secrets and rotating credentials — API keys, database URLs, signing keys. Read them at runtime on a server you control so a rotation does not require a rebuild and the value never reaches client code.
  • Per-tenant or per-request configuration — anything that varies by user, region, or request cannot be a single build-time constant. It must be read at runtime.

Opinionated rule of thumb: prefer runtime config for anything that differs per environment or rotates, and reserve build-time inlining for values that are genuinely public and identical everywhere — a feature-flag default, a public analytics ID, a git SHA stamped into the build. The Docker, CI & Kubernetes env tips guide covers the runtime-injection idioms per platform.

Frequently Asked Questions

What is a build-time environment variable?

A value read once when your bundler or compiler runs (vite build, next build, webpack, esbuild, docker build) and inlined directly into the output artifact. The variable name is gone by the time the artifact exists, and the value cannot change without rebuilding. A runtime variable, by contrast, is read from process.env each time the program starts.

How do I inject an environment variable into the build process?

Set it in the environment the build command runs in. For bundlers, give it the public prefix (VITE_, NEXT_PUBLIC_, REACT_APP_) and run the build with the variable set, e.g. VITE_API_URL=https://api.example.com vite build. For Docker, declare ARG NAME in the Dockerfile and pass docker build --build-arg NAME=value. In CI, put the variable under the build step env block.

Why did my API key show up in the browser?

Because it was referenced in client code that the bundler inlines. Any value behind VITE_, NEXT_PUBLIC_, or REACT_APP_, or substituted by webpack DefinePlugin, becomes plain text in the shipped JavaScript. The prefix marks a variable as public; it does not protect it. Move secrets to a server and rotate the leaked key — it is already compromised.

How do I check if a secret got baked into my build?

Build, then grep the output directory for the literal value: grep -rF "your-value" dist/ (or build/, .next/, out/). Anything it prints is inside the artifact and readable by users. For Docker images, run docker history --no-trunc to see ARG and ENV values stored in the layers.

Can I change a build-time variable without rebuilding?

No. The value is frozen into the artifact at build time. To change config without rebuilding, read it at runtime instead — an entrypoint script that writes a config.json or window.__CONFIG__ at container start, or server code that reads process.env per request.

Is Docker ARG build-time or runtime?

ARG is build-time and persists in the image build history, so anyone who pulls the image can read it — unsafe for secrets. ENV is available at container runtime. For build secrets, use BuildKit --mount=type=secret, which exposes the value only during a single RUN and leaves no trace in the final image.

References

Validate your .env files with the env validator, or read the complete .env guide for syntax and cross-language usage.

Was this helpful?

Frequently Asked Questions

What is a build-time environment variable?

A value read once when your bundler or compiler runs (vite build, next build, webpack, esbuild, docker build) and inlined directly into the output artifact. The variable name is gone by the time the artifact exists, and the value cannot change without rebuilding. A runtime variable, by contrast, is read from process.env each time the program starts.

How do I inject an environment variable into the build process?

Set it in the environment the build command runs in. For bundlers, give it the public prefix (VITE_, NEXT_PUBLIC_, REACT_APP_) and run the build with the variable set, e.g. VITE_API_URL=https://api.example.com vite build. For Docker, declare ARG NAME in the Dockerfile and pass docker build --build-arg NAME=value. In CI, put the variable under the build step env block.

Why did my API key show up in the browser?

Because it was referenced in client code that the bundler inlines. Any value behind VITE_, NEXT_PUBLIC_, or REACT_APP_, or substituted by webpack DefinePlugin, becomes plain text in the shipped JavaScript. The prefix marks a variable as public; it does not protect it. Move secrets to a server and rotate the leaked key — it is already compromised.

How do I check if a secret got baked into my build?

Build, then grep the output directory for the literal value: grep -rF "your-value" dist/ (or build/, .next/, out/). Anything it prints is inside the artifact and readable by users. For Docker images, run docker history --no-trunc to see ARG and ENV values stored in the layers.

Can I change a build-time variable without rebuilding?

No. The value is frozen into the artifact at build time. To change config without rebuilding, read it at runtime instead — an entrypoint script that writes a config.json or window.__CONFIG__ at container start, or server code that reads process.env per request.

Is Docker ARG build-time or runtime?

ARG is build-time and persists in the image build history, so anyone who pulls the image can read it — unsafe for secrets. ENV is available at container runtime. For build secrets, use BuildKit --mount=type=secret, which exposes the value only during a single RUN and leaves no trace in the final image.

Stay up to date

Get notified about new guides, tools, and cheatsheets.