Troubleshooting guide

Works locally, fails in production: missing env vars and secrets

The app runs on your machine. Once it is deployed, every page returns 500, sign-in redirects to the wrong URL, browser requests fail on CORS, or an AI call returns 403 with a key that works locally. This is one of the most frequent patterns in our data (16 verified builder reports). In nearly every case, your laptop has a value that production does not.

16 verified cases12 min read Updated 27 Sep 2026How we collect cases

What you'll see

  • The hosted app returns 500 on every page or API route, while npm run dev works with the same code.
  • Social sign-in sends people to a URL containing undefined, back to localhost, or to a mock account picker instead of the real OAuth screen.
  • Browser requests to your API fail with a CORS error in the console, while the same request from curl or Postman succeeds.
  • A function keeps failing with the old credential after you rotated a secret and saved the new value in the dashboard.
  • AI gateway or provider calls return 401 or 403 in production with a key that works locally.
  • Visitors or uptime checks get a login or deployment-protection screen instead of your page.

Why it happens

Cause 1

Your .env file never leaves your machine

Frameworks load .env* files from the project root during local runs. Those files are normally in .gitignore, so the host never sees them. You have to add each variable again in the host's settings, for each environment. On Vercel you choose Production, Preview and Development for every variable. On Netlify each deploy context (production, deploy previews, branch deploys, local development) can hold a different value or none.

Cause 2

Public variables are frozen at build time

Next.js inlines NEXT_PUBLIC_ variables into the browser bundle during next build, and Vite statically replaces VITE_ variables at build time. If the value was missing when the build ran, the bundle contains undefined, and adding the variable afterwards changes nothing until you rebuild. Dynamic lookups such as process.env[name] are not inlined, so they are always undefined in the browser. Server-only variables are read when the code runs, so a value can exist at build time and be absent at runtime, or the other way around.

Cause 3

Deployments keep the values they were created with

On Vercel, environment variable changes only apply to new deployments. The running deployment keeps the old credential until you redeploy, so if you invalidate the old key first, production fails immediately. On Netlify, secrets read through the UI, CLI or API outside the dev context come back masked, so a script that copies them gets the masked string. Only code running on Netlify receives the real value.

Cause 4

Redirect URLs and CORS origins are declared per domain

OAuth redirect URLs are usually built from a variable such as NEXT_PUBLIC_SITE_URL. If it is missing, the URL is literally undefined/auth/callback. Supabase falls back to the Site URL when redirectTo is not in the allow list, and the Site URL defaults to localhost until you change it. For CORS, the browser only accepts a response whose Access-Control-Allow-Origin matches the calling origin, and it refuses * for credentialed requests.

Cause 5

Deployment protection or a stale key rejects the request

Vercel's Standard Protection covers every deployment except production domains. That includes the generated *.vercel.app production URL, so a monitor pointed at that URL gets an authentication screen. The All Deployments scope protects the production domain too. For Vercel AI Gateway, an explicit AI_GATEWAY_API_KEY takes precedence over the deployment's VERCEL_OIDC_TOKEN, so every request uses an old key left in the Production environment.

How to fix it

  1. Validate required variables at server startup

    Fail at startup with the missing variable's name, instead of letting a request crash later. In Next.js, run the check from the register function in instrumentation.ts, which runs when the server starts.

    const required = ["DATABASE_URL", "STRIPE_SECRET_KEY", "NEXT_PUBLIC_SITE_URL"];
    const missing = required.filter((name) => !process.env[name]);
    if (missing.length > 0) {
      throw new Error(`Missing environment variables: ${missing.join(", ")}`);
    }
  2. Remove mock and empty-string fallbacks

    A fallback such as process.env.X ?? "" or a mock sign-in provider hides a missing variable: the app appears to work but runs on an empty or fake value. In client code, reference public variables by their full literal name so the bundler can inline them, and throw if the result is undefined.

    const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
    if (!siteUrl) throw new Error("NEXT_PUBLIC_SITE_URL is not set for this build");
  3. Keep one list of variables per environment and rebuild after changes

    Record every variable with the environments it must exist in (Production, Preview, branch deploys, Development), and compare the list with the host's settings on each deploy. After adding or changing a NEXT_PUBLIC_ or VITE_ value, trigger a new build. The old bundle keeps the old value.

  4. Rotate secrets in this order

    Create the new credential, save it for the right environments, and redeploy. Confirm the new deployment works, and only then invalidate the old credential. If Preview deployments use the same secret, redeploy them too.

  5. Register production redirect URLs and CORS origins explicitly

    In your auth provider, set the Site URL and redirect allow list to the production domain (Supabase: Authentication, URL Configuration), and keep localhost as a separate entry. On your API, echo back only origins from an allow list and send Vary: Origin.

    const allowed = new Set(["https://app.example.com", "http://localhost:3000"]);
    const origin = request.headers.get("origin");
    if (origin && allowed.has(origin)) {
      headers.set("Access-Control-Allow-Origin", origin);
      headers.set("Vary", "Origin");
    }
  6. Point uptime checks at the production domain

    Check the Deployment Protection scope under Settings, Deployment Protection. Point uptime monitors at the production domain, not a generated *.vercel.app URL. If automation must reach a protected URL, use Protection Bypass for Automation instead of turning protection off.

Check it's fixed

  • Open the production domain in a private window while signed out: the page loads without a login or protection screen, and sign-in completes and returns to the production domain.
  • In the browser's Sources panel, search the deployed JavaScript for undefined/ and for your public variable names: no unresolved values remain.
  • Send a preflight from outside the browser and confirm the response names your production origin: curl -si -X OPTIONS https://api.example.com/v1/chat -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: POST" | grep -i access-control.
  • After rotating a secret, invalidate the old credential and confirm the production AI or payment call still succeeds on the new deployment.

Fix it with Gemmein

The browser uses a public app key (pk_…) that is locked to your domains and is safe to commit, for example in .env.production. The AI provider key is pasted on Gemmein's AI page and never reaches the browser. A page served from an address that is not listed is refused with origin_not_allowed, and a tool whose provider has no key is refused with ai_not_configured.

  1. Commit the app key with your code

    The app key (pk_…) is public and locked to your domains, so it belongs in the repo, for example in .env.production. Its prefix names the environment it drives: local, test or live.

    const g = gemmein("pk_live_…");
  2. List your deploy URL on the Domains page before the first deploy

    Before go-live, a listed domain works without verification. At go-live, the keys lock to domains proven by a DNS record. A page served from an address that is not listed has every call refused with origin_not_allowed, and the refusal carries a reason that your code can branch on.

  3. Paste the AI provider key in each environment

    Provider keys are written directly in the dashboard, and promotion and sync do not carry them, so each environment needs its own. Until a key is pasted on the AI page, runs are refused with ai_not_configured. Locally, gemmein dev answers with a fake provider when no key is set, so a call that works on your machine does not prove the cloud has a key.

    try {
      const answer = await g.ai.runText("deep-research", { question: text })
    } catch (err) {
      if (err.code === "ai_not_configured") { /* paste the provider key on the AI page for this environment */ }
    }
  4. Set the server key in your host's environment

    A server key from the Secret keys page (sk_dev_… in development) is for your server code. It lives in your server's environment and never goes in the repo or the app. npx gemmein check flags a build whose output contains a secret key, and gemmeinServer() refuses to run in a browser.

    const g = gemmeinServer(process.env.GEMMEIN_SECRET_KEY);

Questions

I added the variable in the dashboard. Why is it still undefined in the browser?

NEXT_PUBLIC_ and VITE_ values are written into the bundle when the build runs. Trigger a new build after adding the variable, and reference it by its full literal name, not through process.env[name].


Why does production still use a secret I already rotated?

On Vercel, environment variable changes only apply to new deployments. Redeploy, check that the new deployment works, and only then invalidate the old credential.


Why does OAuth send people back to localhost after deploying?

Supabase falls back to the Site URL when redirectTo is not in the allow list, and the Site URL stays at localhost until you change it. Set it to the production domain and add your production callback URL to the allow list.


Can I set Access-Control-Allow-Origin to * to make the CORS error go away?

Not for requests that send cookies or other credentials: the browser blocks the response. Return the specific allowed origin and add Vary: Origin.


Can I put the API key in a VITE_ or NEXT_PUBLIC_ variable so the client can see it?

No. Those values are bundled into JavaScript that anyone can download, and Vite's docs say they should not contain API keys. Call the provider from a server or function that reads a server-only variable.


Sources

← Back to the full report