Troubleshooting guide

Environment variables missing or empty in production: fixes

The app runs on your machine, then the deployed version cannot find its database URL, its payment key or its own site address. This was the pattern in 38 verified builder reports in our data, and it showed up on nearly every hosting platform and framework we tracked. Nothing fails loudly, so the first sign is usually a broken login, an empty dashboard or a payment that never completes.

38 verified cases12 min read Updated 8 Oct 2026How we collect cases

What you'll see

  • A feature works locally and fails in production, and the logs show undefined, an empty string or a masked value where a key should be.
  • OAuth or email sign-in link redirects go to localhost, or the sitemap and robots file point at localhost.
  • The build fails every time because a public variable is undefined during the build.
  • Social sign-in shows a mock account picker, or the browser reports a CORS error after login, in production only.
  • You rotated a key, and the live app kept using the old one for hours.
  • A preview deployment crashes before rendering because a variable you marked as secret arrived empty.
  • An uptime monitor reports the site down all day, although it loads in your browser.

Why it happens

Cause 1

The variable is scoped to a different environment or was added after the deployment

Platforms keep separate values for Production, Preview and local development, and a variable exists only where you assigned it. On Vercel, a change applies to new deployments, not to previous ones. On Netlify, a variable can also be limited to the Builds, Functions or Runtime scope, so a value that exists at build time can be absent when a function runs.

Cause 2

Public variables are frozen into the bundle at build time

In Next.js, NEXT_PUBLIC_ values are inlined into the JavaScript bundle during next build, and the built app no longer responds to changes. Vite does the same for VITE_ values. If the variable was missing when the build ran, the browser code carries undefined forever, and a fallback branch such as a mock sign-in takes over. Dynamic lookups such as process.env[name] are not inlined at all.

Cause 3

A fallback turns a missing setting into a plausible wrong one

Code such as process.env.SITE_URL ?? 'http://localhost:3000' never throws. The app starts, builds redirect URLs, CORS allow-lists and robots files from the default, and fails somewhere far from the cause. A placeholder token written into an env file does the same.

Cause 4

Secret flags and masking change what code can read

Vercel Secret values are write-only after saving. Netlify variables marked as containing secret values can be read unmasked only by code running on Netlify's systems. A tool or script running elsewhere sees a masked value. A value copied from a masked display, or from a preview environment that has no value at all, ends up empty or wrong.

Cause 5

Public variables and secrets are mixed up

Anything with a NEXT_PUBLIC_ or VITE_ prefix is shipped to every visitor. Vite's docs say such variables should not contain sensitive information such as API keys. A server-side list of secret names or key formats that sits in a public variable, or is read by client code, ships with every page.

Cause 6

Deployment protection serves a login screen instead of your page

Vercel Deployment Protection can require authentication for preview URLs, and with the All Deployments scope it covers production domains too. A monitor that fetches the page receives the authentication screen, not your app.

How to fix it

  1. List every variable the app needs, by name and by environment

    Write the names (not values) in a checked-in file such as .env.example, with a column for build time, runtime and browser. Compare it against the platform's variable list for Production and Preview whenever you change platform, branch strategy or runtime.

  2. Validate required configuration at startup and refuse to boot

    Throw on a missing or empty value instead of defaulting. In Next.js, the register function runs code on server startup, which is a good place for this check.

    const required = ["DATABASE_URL", "SITE_URL", "STRIPE_SECRET_KEY"];
    const missing = required.filter((k) => !process.env[k]);
    if (missing.length) {
      throw new Error(`Missing config: ${missing.join(", ")}`);
    }
  3. Remove silent fallbacks for anything environment-specific

    Hosts, redirect URLs, allowed origins, database names and keys get no default. A default is fine for values that are the same everywhere, such as a page size.

  4. Set build-time values before the build, then rebuild

    For NEXT_PUBLIC_ and VITE_ variables, set the value for the right environment first, then trigger a new build. Redeploying an old build, or promoting one built elsewhere, keeps the old value. If one build must serve several environments, read server-only values at request time, not at module load.

  5. Check scope and environment for each variable

    On Vercel, confirm the variable is ticked for Production, Preview or both, and redeploy after any change. On Netlify, confirm the Functions scope and the deploy context (Production, Deploy Previews, Branch deploys) cover where the code runs.

  6. Rotate secrets in the right order

    Create the new credential, update the platform variable, redeploy every project that uses it, verify, and only then revoke the old credential. Vercel's own guidance is to update before you invalidate, because old deployments keep the old value.

  7. Scan the built output for secrets before release

    Search the client bundle for key formats and private-key headers, and fail the pipeline on a hit. Netlify does this for variables flagged as secret and fails the build when it finds one. Keep secrets out of build logs, images and ARG or ENV lines in Dockerfiles.

    grep -rlE "sk_live_|sk_test_|service_role|BEGIN (RSA )?PRIVATE KEY" .next/static dist build 2>/dev/null && exit 1 || true
  8. Send an explicit CORS origin from the deployed API

    The response needs Access-Control-Allow-Origin set to your real frontend origin, and Access-Control-Allow-Credentials: true if cookies are sent. A credentialed request cannot use the * wildcard. Build the allowed origin from a required variable, not from a localhost default.

    Access-Control-Allow-Origin: https://app.example.com
    Access-Control-Allow-Credentials: true
    Vary: Origin

Check it's fixed

  • Start the production build with one required variable removed and confirm it refuses to boot with the variable named in the error.
  • Open the deployed site, view the page source and network responses, and search for localhost and your key prefixes. Neither should appear.
  • Request the live URL from outside your network, or from your uptime monitor, and confirm it returns your page and not an authentication screen.
  • After a rotation, redeploy, revoke the old credential, and confirm sign-in, payments and the database still work.

Fix it with Gemmein

A Gemmein web app's browser code needs one public app key, which the docs say is safe to commit, for example in .env.production, and whose prefix names the rail it drives. A missing, secret or unrecognised key, or an unlisted address, is refused, and the refusal's body carries a reason for code to branch on.

  1. Commit the public app key with the code

    The pk_… app key is public and locked to your domains, so it can live in the repo, for example in .env.production, while a secret key (sk_…) does not go in the repo or the app. Its prefix (pk_local_, pk_test_, pk_live_) names the rail a build points at: local, test or live.

  2. Read the refusal's reason when calls fail

    The refusal's body carries a reason (no_key, secret_key, unknown_key, local, address or domain) for code to branch on, and a message such as "This request has no app key." A page served from an unlisted address has every call refused origin_not_allowed (localhost works for local work), and once the app is live a refused https address shows up on the dashboard's Problems page.

  3. Let go-live check the built site for secret keys

    If the built site carries a secret key, npx gemmein go-live stops before any cloud call and names the file, without printing the key. If the app is mobile-only and no built bundle is given, go-live stops and names both ways on: --build <dir> or --confirm-no-secret-key.

    npx gemmein go-live
  4. Connect Stripe in each environment

    Until an environment sells something through Stripe, its checkout answers payments_not_configured, and Live answers it until Stripe is connected in Live. In Development the response also carries the owner's fix as ownerDetail; Live never does.

    try {
      await g.subscriptions.checkout("pro");
    } catch (err) {
      if (err.code === "payments_not_configured") {
        // err.message is written for the buyer and safe to show as-is
      }
    }

Questions

Why is my NEXT_PUBLIC_ variable undefined in production after I added it?

Next.js inlines these values into the bundle when next build runs, and the built app does not respond to later changes. Set the variable for the right environment and trigger a new build.


I changed a variable on Vercel and nothing changed. Why?

Vercel applies variable changes only to new deployments, not to previous ones. Redeploy after every change, including a rotation.


Why did Netlify fail my build with a secret found in the output?

Netlify scans the repository and build output for the values of variables you flagged as secret, and fails the build when it finds one. Remove the value from the output, or, if it is public by design, list its key in SECRETS_SCAN_OMIT_KEYS.


Why does my uptime monitor say the site is down when it loads for me?

If Vercel Deployment Protection covers that URL, the monitor receives an authentication screen. Check the protection scope, and note that All Deployments also covers production domains.


Can I put an API key in a VITE_ or NEXT_PUBLIC_ variable if the repository is private?

No. The value is bundled into the code sent to every visitor, and a private repository does not change that. Keep the key on a server and call that server from the browser.


Sources

← Back to the full report