← All posts

Environment Variables at Build Time vs Runtime, Explained

October 21, 2026

An environment variable is read either when your app is built or when it starts, and the two behave very differently: build-time values get baked into the output, while runtime values are read fresh each time the process boots. Most “I changed the variable and nothing happened” bugs, and most accidentally public API keys, come from mixing the two up.

This guide explains the difference, shows how to tell which kind a variable is, and gives a short checklist for handling both on a git-push host.

What is the difference between build time and runtime?

A deploy has two separate moments.

Build time is when your source code is turned into something runnable: dependencies installed, TypeScript compiled, a frontend bundled, a container image produced. Anything that reads a variable during this step captures its value at that instant and writes it into the result.

Runtime is when that result is started as a process and begins serving requests. Code that reads process.env.DATABASE_URL, os.environ["SECRET_KEY"] or os.Getenv("PORT") while it’s running sees whatever the environment holds when the process starts.

The practical consequence: a build-time value is frozen into an artifact, and only a new build changes it. A runtime value only needs a restart.

Which variables are read at build time?

The usual suspects are frontend frameworks that inline values into JavaScript bundles:

  • Variables with a public prefix, such as NEXT_PUBLIC_*, VITE_* or REACT_APP_*, are replaced with their literal values when the bundle is built. The browser never sees an environment; it sees a string that was pasted into the code.
  • Static site generators that fetch content or call APIs during the build, then write HTML files.
  • Build tooling flags like NODE_ENV=production, which change how dependencies are installed and how code is optimized.

A quick test: if the variable is used in code that runs in the browser, or in a file that only executes during npm run build, it’s build time.

Which variables are read at runtime?

Server code reads its configuration when it boots. Typical runtime variables:

  • PORT, the port your server must listen on
  • DATABASE_URL, REDIS_URL and other connection strings
  • API keys and signing secrets used on the server
  • Feature flags your server checks per request

These should never be baked into an artifact. If you build the same image for staging and production, runtime configuration is what makes them different.

Why didn’t my change take effect?

There are two common reasons, one for each kind.

You changed a build-time variable without rebuilding. If VITE_API_URL was https://old.example.com when the bundle was built, the bundle contains that string. Changing the variable afterwards does nothing until the frontend is built again.

You changed a runtime variable without restarting. Most processes read their environment once at startup. Editing the value in a dashboard doesn’t reach a process that’s already running.

On Candor Deploy, saving the Environment tab doesn’t restart anything; environment changes apply on the next deploy. You push a new commit, or use Deploy a commit with the same SHA, and the portal reminds you with “Redeploy to apply” when you save (builds and configuration). Every deploy there is a full build from source at that commit, so a redeploy covers both cases: the app is rebuilt and then restarted with the current environment.

Why do public frontend variables leak secrets?

Because “public prefix” means exactly that. Anything named NEXT_PUBLIC_STRIPE_SECRET or VITE_ADMIN_TOKEN is written into JavaScript that every visitor downloads. Minification doesn’t hide it; anyone can open the bundle and search for it.

Rules that prevent this:

  1. Only put values in a public-prefixed variable that you’d be happy to print on your homepage. Publishable keys, public API base URLs and analytics IDs are fine. Secret keys never are.
  2. Keep secrets on the server. If the browser needs data from a secret-protected API, call your own backend route, and let the backend use the secret at runtime.
  3. Grep your build output before a launch: search the built files for the first few characters of each real secret. If one shows up, rotate it.

How should a server read its configuration?

Read everything once at startup, validate it, and fail loudly when something is missing. A crash with a clear message on boot is far easier to debug than a request that fails an hour later.

A minimal Node pattern:

function required(name) {
  const value = process.env[name];
  if (!value) {
    console.error(`Missing required environment variable: ${name}`);
    process.exit(1);
  }
  return value;
}

const config = {
  port: Number(process.env.PORT ?? 8080),
  databaseUrl: required("DATABASE_URL"),
  sessionSecret: required("SESSION_SECRET"),
};

The same idea in Python:

import os, sys

def required(name):
    value = os.environ.get(name)
    if not value:
        sys.exit(f"Missing required environment variable: {name}")
    return value

DATABASE_URL = required("DATABASE_URL")
PORT = int(os.environ.get("PORT", "8080"))

On Candor Deploy the platform sets PORT in your app’s environment (defaulting to 8080) and health-checks the app on it, so reading PORT rather than hard-coding a number is what lets the deploy go live (builds and configuration). When an app crashes on boot, the troubleshooting guide lists a missing environment variable as one of the usual suspects, and the Logs tab is where the reason shows up (troubleshooting). The validation pattern above makes that log line say exactly which one.

Where should secrets live?

Not in git. A secret committed once stays in the repository history even after you delete the file, so treat any committed secret as exposed and rotate it.

Better places:

  • Your host’s environment settings. On Candor Deploy, values on the Environment tab are stored as a secret and never shown in logs (builds and configuration).
  • Generated secrets. Some platforms can mint a random value for you so nobody ever types or stores it. A Candor Deploy .candorfile can set SESSION_SECRET: { generate: hex32 }, which creates 32 random bytes on the platform side and injects them as the value, so the secret never lives in your repository (.candorfile reference).
  • A local .env file that’s in .gitignore, for development only, plus a committed .env.example listing every variable name with a placeholder value. New teammates (and your future self) can see what’s required without seeing the values.

What about connection strings for databases?

Treat them as runtime secrets, and avoid copying them by hand when the platform can wire them for you. Hand-copied URLs drift: a rotated password or a changed host leaves the app pointing at the old value.

On Candor Deploy, Attach a database on the Environment tab writes the database’s connection URI into your app as DATABASE_URL (REDIS_URL for Redis), and app-to-app addresses are injected as <DIR>_URL variables, with any value you set yourself taking priority (builds and configuration, .candorfile reference). As with any environment change there, redeploy to apply it.

A pre-deploy checklist

Before your next deploy, run through this list:

  • Every variable your app needs is listed in .env.example, with no real values.
  • No real secret is committed anywhere in the repository history.
  • Public-prefixed frontend variables hold only values that are safe to publish.
  • Server code reads PORT from the environment and listens on 0.0.0.0.
  • Required variables are validated at startup with a clear error message.
  • You know which variables are baked in at build time, so you know a change to them needs a rebuild.
  • After changing any variable, you redeploy and check the logs for the new value taking effect.

What’s the next step?

If you’re setting up a new app, the Candor Deploy quickstart walks through a first deploy from a repository.