Troubleshooting guide

App works locally but fails in production: config checklist

A value, key, origin or certificate that is right on your laptop is missing or wrong once the app is deployed. The failure traces to one setting and does not appear in development. 142 verified builder reports in our data describe it.

142 verified cases14 min read Updated 10 Nov 2026Last reviewed 10 Nov 2026How we collect cases

What you'll see

  • An environment variable is undefined in the deployed app, or a client-side value is missing from the browser bundle, although it works locally.
  • A key that worked is rejected with a 401 or 403 after a rotation, or a function keeps using the old value.
  • Browser requests fail with a CORS error only on the production origin, or sign-in sends people back to localhost.
  • The custom domain shows a redirect loop, a certificate warning or a 404, while the platform's default URL for the same deployment works.
  • Certificate renewal fails for a live domain, or the certificate is close to expiry and will not renew.
  • A public page returns a login or protection screen to visitors and to uptime monitors.

Why it happens

Cause 1

Values are scoped to a different environment or build

On Vercel, an environment variable applies only to new deployments, and each variable is assigned to Production, Preview or Development. Next.js inlines NEXT_PUBLIC_ variables into the client bundle at build time, so changing the value later does not change a build that already exists. Gitignored .env files are not part of the deploy, which is why the variable exists on your machine and nowhere else.

Cause 2

Secrets do not reach the process that needs them

In GitHub Actions a secret reaches a step only when you map it with the secrets context, and a secret that was never created evaluates to an empty string rather than an error. Secrets are not passed to workflows triggered from forks, and not automatically to reusable workflows. After a rotation, a running deployment keeps the values it was built with until you redeploy.

Cause 3

Allowlists contain only localhost

A CORS allowlist that omits the real production origin makes the browser block the response, and a credentialed request cannot use the wildcard for Access-Control-Allow-Origin. An auth provider's Site URL often defaults to a localhost address, and its redirect allowlist needs the production URL too.

Cause 4

Domain, proxy and certificate layers disagree

With Cloudflare in Flexible mode, requests reach your origin over HTTP, so an origin that redirects all HTTP to HTTPS produces a redirect loop. In Full or Full (strict) mode the opposite holds: an origin that redirects HTTPS to HTTP loops. If the custom domain loops but the platform URL does not, start with the extra layer.

Cause 5

Retries burn certificate rate limits

Let's Encrypt allows up to 5 authorization failures per identifier per account each hour, and up to 5 certificates per exact same set of identifiers every 7 days. Repeated retries of a broken renewal can exhaust both, and the domain can stay without a valid certificate after you fix the underlying fault.

Cause 6

A protection screen covers the production domain

Vercel's Deployment Protection requires authentication for all requests to a protected URL. The Standard Protection scope leaves production domains open, but the All Deployments scope protects them too. Under that scope, a public page returns the protection screen to visitors and to uptime checks.

How to fix it

  1. Validate configuration in one schema and fail the build

    List every required variable once, reject missing or empty values, and run the check before the build so a bad configuration fails the build instead of reaching production. Include the public site URL and every secret the server reads.

    import { z } from "zod";
    
    const schema = z.object({
      DATABASE_URL: z.string().url(),
      STRIPE_SECRET_KEY: z.string().min(1),
      NEXT_PUBLIC_SITE_URL: z.string().url(),
    });
    
    // run before the build: a missing or empty value throws
    schema.parse(process.env);
  2. Set each variable for the right environment, then redeploy

    Confirm the variable is assigned to Production and not only Preview or Development. Redeploy after any change, and rebuild for NEXT_PUBLIC_ values, because the running deployment keeps what it was built with. In CI, map each secret into the step that builds or deploys with the secrets context.

  3. Rotate keys with an overlap

    Add the new value, redeploy, check that production traffic works, and only then disable the old key at the provider. A function that was not redeployed still holds the old secret, so check every service that reads the key.

  4. Keep origins and hostnames in one list

    Derive the CORS allowlist, the auth redirect list and the site URL setting from the same set of production origins. Echo back an origin only if it is in the list, send Vary: Origin, and answer the OPTIONS preflight with the same headers.

    const ALLOWED = new Set([
      "https://www.example.com",
      "https://app.example.com",
    ]);
    
    export function corsHeaders(origin: string | null) {
      if (!origin || !ALLOWED.has(origin)) return {};
      return {
        "Access-Control-Allow-Origin": origin,
        "Access-Control-Allow-Credentials": "true",
        "Vary": "Origin",
      };
    }
  5. Make the redirect, proxy mode and protection scope agree

    If the domain loops behind Cloudflare, either remove the origin's HTTPS redirect under Flexible, or switch to Full (strict) with a certificate on the origin. Check that the origin does not redirect HTTPS back to HTTP. If monitors see a protection screen, check the Deployment Protection scope for the project.

    curl -sI http://www.example.com | grep -iE '^(HTTP|location)'
  6. Fix the cause of a failed renewal before retrying

    Find why validation fails (a stale or wrong validation record, a proxy in the way, a record pointing elsewhere), correct it, then retry once. Test against the CA's staging environment where you can, because revoking certificates does not reset Let's Encrypt limits.

  7. Run a smoke test against the real production domain after every deploy

    Fetch the page, check that the status is 200, and send a preflight from the real origin. A fuller test signs in, writes a record and reads it back through the production domain.

    BASE=https://www.example.com
    API=https://api.example.com
    test "$(curl -s -o /dev/null -w '%{http_code}' "$BASE")" = 200
    curl -si -X OPTIONS "$API/items" \
      -H "Origin: $BASE" \
      -H "Access-Control-Request-Method: POST" | grep -i '^access-control'

Check it's fixed

  • A clean checkout with no local .env builds in CI, and removing one required variable from a preview deployment makes the build fail.
  • http:// on the custom domain returns a 301 to https, the final URL returns 200 with no loop, and the custom domain and the platform URL serve the same page.
  • From the production origin, the browser network tab shows Access-Control-Allow-Origin equal to that origin, and sign-in returns to the production URL, not localhost.
  • The certificate's expiry date (openssl s_client -connect host:443 -servername host, piped to openssl x509 -noout -dates) is weeks away, and the smoke test passes after each deploy.

Specific fixes

Stuck on one exact error? These pages go deeper on one symptom each.

Fix it with Gemmein

Gemmein's app keys check the page's address by rail: the Development app key works from any secure (https) address with nothing to list, and over plain http only on localhost and 127.0.0.1. For a web app the Live app key works only on the domains verified on the Domains page, and a page it refuses has every call refused origin_not_allowed, with a reason code for code to branch on.

  1. Add your domain from the start and check its records

    Run npx gemmein domains add <domain> (your human approves it in the browser), or use the Domains page, even in Development: domains are per app, so the records and proof carry to Live. npx gemmein domains then prints each domain, whether it is verified, and every DNS record with its status, then how many records are still missing; a web domain is one record.

    npx gemmein domains add <domain>
    npx gemmein domains
  2. Read the refusal's reason, not its sentence

    When the Live key refuses a page, every call from it is refused origin_not_allowed, and the body carries reason (no_key, secret_key, unknown_key, local, address, domain or insecure) and, once the key names an app, live and environment (development or production, whose rule refused the page). A refused https address shows up on the dashboard's Errors page.

  3. Switch the live build to the Live app key

    Once the app is live, the live site must use the Live key: a page on one of its verified domains that still calls with the Development key is answered as normal, but Live's Errors page reports dev_key_on_live_site (once a day per domain). Run npx gemmein keys app and switch the live build to pk_live_.

    npx gemmein keys app
  4. Have your human approve the Live server key

    A Live server key is available only once the app is live; npx gemmein keys create --env live asks your human to approve it in the browser with an emailed code, and their Approve makes it and shows it there, once, on that page. Your human copies it from the approval page into the production host's secret settings as GEMMEIN_SECRET_KEY (for example Vercel › Settings › Environment Variables).

    npx gemmein keys create --env live

Questions

Why is my environment variable undefined in production but fine locally?

The variable is probably not assigned to the Production environment, or it was added after the build that is running. Vercel applies changes only to new deployments, and Next.js inlines NEXT_PUBLIC_ values at build time, so set the value and redeploy.


Why does the custom domain loop while the platform URL works?

The custom domain has an extra layer, often a proxy, whose connection mode disagrees with your origin's redirect. With Cloudflare Flexible, an origin that redirects HTTP to HTTPS loops; with Full or Full (strict), an origin that redirects HTTPS to HTTP loops.


Can I keep retrying a failed certificate renewal?

Not safely. Let's Encrypt allows up to 5 authorization failures per identifier per account each hour and up to 5 certificates per exact set of identifiers every 7 days. Fix the cause first, then retry once.


Why do I get a CORS error only in production?

Production serves from a different origin than localhost, and the API's allowlist probably lacks it. The server must send Access-Control-Allow-Origin with that exact origin, and for credentialed requests it cannot use the wildcard.


Do I need to redeploy after rotating a key?

Yes, on platforms where environment changes apply only to new deployments, such as Vercel. Redeploy, confirm traffic works, then disable the old key.


Sources

Last reviewed 10 Nov 2026. What changed: 1 line(s) reworded. Checked against: nextjs.org, vercel.com, vercel.com, developers.cloudflare.com, letsencrypt.org, developer.mozilla.org, supabase.com, docs.github.com. How we check this

← Back to the full report