Build-time vs runtime environment variables

Build-time variables are read while the project is built and copied into the output. Runtime variables are read from the environment each time the app starts, so changing them needs a restart, not a rebuild.

Updated 5 min read

Learning objectives

After reading this article you will be able to:

  • Tell build-time and runtime variables apart in Next.js, Vite and Docker
  • Decide which values can be public and which must stay on the server
  • Keep secrets out of bundles and Docker image layers
On this page

You set API_URL in your dashboard, restart the app, and the site still calls the old address. Or a secret you thought was private turns up in the browser's network tab. Both bugs share one cause: the variable was read at a different moment than you assumed.

A variable can be consumed at two moments. At build time, a tool such as Next.js, Vite or Docker reads it while producing the output. At runtime, the running process reads it from its environment. When the read happens decides whether a change needs a restart or a rebuild, and whether a value can leak.

Build timeVariablesNEXT_PUBLIC_, VITE_Bundlevalue baked inNew valueneeds a rebuildRuntimeVariablesprocess.envProcessreads at startNew valueon next restart
A build-time variable stays written in the bundle until the next build; a runtime variable is read again on every start.

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

A build-time variable is read once, during the build, and usually written into the output as plain text. After that, the file is the source of truth, not the environment.

A runtime variable is looked up every time the process starts, through process.env in Node.js or os.environ in Python. Change the value, restart, and the app sees the new one. This is the model of the twelve-factor config rule: config lives in environment variables, because config varies between deploys and code doesn't.

Build time Runtime
Read by Bundler, compiler, Docker build Your running process
Change needs Rebuild Restart
Where the value ends up Inside the output files Only in process memory
Safe for secrets Only if it never reaches the output Yes, if you never log or expose it

Which variables are build-time in Next.js?#

In Next.js, anything prefixed with NEXT_PUBLIC_ is build time. The docs say Next.js inlines the value into the JavaScript bundle during next build, replacing each process.env.NEXT_PUBLIC_* reference with a hard-coded string. After the build, the app no longer responds to changes in those variables, and a single Docker image promoted to several environments keeps the value from the build.

Unprefixed variables stay on the server. Read them during dynamic rendering and they are evaluated at runtime:

tsx
// app/page.tsx
import { connection } from "next/server";

export default async function Page() {
  await connection(); // opt into dynamic rendering: evaluated per request
  const apiUrl = process.env.API_URL; // runtime, server only
  return <p>Using {apiUrl}</p>;
}

Dynamic lookups such as process.env[name] are not inlined, so they never reach the browser. If the browser needs a runtime value, expose it from your own API route instead of a NEXT_PUBLIC_ variable.

Which variables are build-time in Vite and other bundlers?#

Vite replaces import.meta.env values statically at build time. Only variables starting with VITE_ reach client code, and the docs warn they should not contain sensitive information such as API keys, because they are bundled into the source.

js
// src/main.js
const api = import.meta.env.VITE_API_URL; // frozen into the bundle at build
const dbPassword = import.meta.env.DB_PASSWORD; // undefined in client code

Vite also gives variables already set in the environment priority over .env files, so VITE_API_URL=https://api.example.com npm run build wins over a .env file. Other bundlers use the same idea with their own prefix: a prefix means "copy this into the browser bundle at build time".

A plain Node.js or Python server has no build step, so everything it reads is runtime.

How do build and runtime variables work in Docker?#

Docker adds a third question: does the value end up in an image layer? ARG exists during the build and ENV persists in the image, but Docker's documentation says build arguments and environment variables should not carry secrets because they stay in the final image.

For a secret the build truly needs (a private package registry token, for example), use a secret mount. It is mounted as a file at /run/secrets/<id> (or exposed as an environment variable with env=) for that single instruction only and is not saved in a layer:

dockerfile
#syntax=docker/dockerfile:1
FROM node:lts-slim AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN npm ci
COPY . .
ARG VITE_API_URL
RUN npm run build

FROM node:lts-slim
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

Here npm_token exists only during that one RUN (your .npmrc must read ${NPM_TOKEN}), VITE_API_URL is a public value baked in on purpose, and DATABASE_URL is deliberately absent from the image: it is passed when the container starts.

bash
docker build --secret id=npm_token,env=NPM_TOKEN \
  --build-arg VITE_API_URL=https://api.example.com -t my-app .
docker run -e DATABASE_URL="$DATABASE_URL" my-app

The multi-stage layout also helps: the final image copies only the files you choose from the build stage, not its layers or build arguments. More on this in how to dockerize an app.

How do you decide where a value belongs?#

Ask three questions, in order:

  1. Would it hurt if any visitor saw it? If yes (database URL, API secret, bot token), it must be a runtime, server-only variable. Never prefix it.
  2. Does the browser need it? Then it is public by definition. A build-time prefix is fine for values like an analytics ID or a public API address.
  3. Do you need to change it without rebuilding? Then don't inline it. Read it on the server at startup, or serve it from a small config endpoint.

A frequent trap: a secret works locally because your .env holds it, then leaks because someone renamed it VITE_SECRET to make an undefined error go away. If a framework says a variable is undefined in the browser, that is the protection working.

Why doesn't a changed variable take effect?#

Work through this checklist:

  • Process started before the change. Runtime variables are read at startup; restart the app.
  • Value inlined at build. Search the build output with grep -r "old-value" .next dist build. If it appears, rebuild.
  • The build ran without the variable. Check that it exists where the build runs, which may differ from where the app runs. See how to read build errors and deploying with GitHub Actions, where build variables live in the CI settings.
  • Everything is a string. "false" is truthy in a condition; convert it in code.

How to do this on Square Cloud#

On Square Cloud, you set environment variables per application in the dashboard, API, CLI or VS Code extension. The dashboard also imports a .env file and exports KEY=VALUE. A change applies on the next restart, with no rebuild, which matches the runtime model above. Your app can read SQUARECLOUD_APP_ID at runtime to get its own application ID.

So secrets such as database URLs and tokens belong in these runtime variables, not in your repository or bundle. There is no build step on the platform: on start it only installs dependencies (npm install when there is no node_modules), and your variables already exist at that point. Values your bundler inlines (NEXT_PUBLIC_, VITE_) have to be right in the CI job where you run the build, such as GitHub Actions, or you can put the build in the start command, where the variables are already set.

Details are in the environment variables guide.

Deploy

Push from GitHub, the CLI or a ZIP and go live in seconds

Learn more

FAQs

Why does my variable show the old value after I changed it in the dashboard?

If the framework inlined it at build time (NEXT_PUBLIC_ in Next.js, VITE_ in Vite), the old value is already written into the JavaScript files. Changing the environment does nothing until you rebuild.

Can I put an API secret in a NEXT_PUBLIC_ or VITE_ variable?

No. Those variables are copied into the JavaScript sent to every visitor, and anyone can read them in the browser. Keep secrets in unprefixed variables read only on the server.

Is Docker ARG safe for passwords?

No. Docker documents that build arguments and environment variables stay in the final image. Use a secret mount (RUN --mount=type=secret) for build-time secrets and pass runtime secrets when the container starts.

Do plain Node.js and Python apps have build-time variables?

Only if you add a build step. An interpreted app with no bundler reads process.env or os.environ when it starts, so every variable is a runtime variable.

Next articleDeploying a monorepoDeploying a monorepo means shipping one app out of a repo that holds many. Learn to prune dependencies, build one package and deploy it with pnpm and Turborepo.

More in Deploy

See all 33 articles