On this page
  1. Why does my app work locally but not in production?
  2. The environment variable problem
  3. What else differs between local and production?
  4. How do I systematically find the divergence?
  5. Where AI coding assistants get this wrong
  6. Checklist
  7. FAQ
    1. Why are my environment variables undefined in production?
    2. What is the difference between a build-time and a run-time environment variable?
    3. Should I commit my .env file?
    4. How do I find the real cause quickly?
    5. Why does it only break on Linux and not my Mac?
    6. Should I just add retries to make it more resilient?
  8. Related topics
  9. Sources
concept

Why Does My App Work Locally but Not in Production?

It works locally because your machine supplies what production lacks: env vars, a real database, permissive CORS. A checklist of every divergence.

Quick answer

  • Your machine is not the same environment as production. It quietly supplies a .env file, a real database, permissive CORS, and case-insensitive filenames.
  • The most common single cause is environment variables: .env is a development convention, and a .env file is never deployed.
  • The second is build time versus run time — a variable inlined during the build is frozen, so changing it and redeploying without rebuilding does nothing.
  • Diagnose by logging which variables are missing by name, never their values, then work down the checklist.

Why does my app work locally but not in production?

An app that works locally and fails in production is not broken in a random way. It is running in an environment that differs from yours in a small number of specific, enumerable ways — and your local machine is quietly supplying things production will not.

There are four categories of difference, and they account for nearly every case:

  1. Configuration — environment variables that exist locally and are absent or renamed in production.
  2. Data — a development database with three rows behaves differently from production with three million, and different rows trigger different code.
  3. Case sensitivity — macOS and Windows filesystems are case-insensitive by default; Linux is not. A file created as utils.py and imported as Utils.py works locally and fails on deploy.
  4. Network and permissions — CORS origins, firewall rules, binding to localhost, and file permissions that differ between your user account and a service account.

The reason this is hard is that the failure message almost never points at the cause. The symptom is usually “API calls fail” or “it 500s”, and the real cause might be a variable name, a filename’s capitalisation, or a database that ran out of connections.

Where this bites vibecoders

The specific way this bites vibecoders is that the fix is generated before the diagnosis. An assistant sees a 500 on deploy and adds a retry, or sees a CORS error and adds a wildcard header, or sees a missing variable and hardcodes a default. Each patch silences one symptom and leaves the actual divergence in place, and the app now has three layers of workaround that will confuse the next person. The habit worth building is: find which environment variable is missing before changing any code.

The environment variable problem

This is the single most common cause, and it has three distinct failure modes worth separating.

The .env file was never deployed. Most frameworks load .env in development as a convenience. In production, nothing reads it — your platform injects real environment variables into the process instead. If your code does require('dotenv').config() and relies on the file, the variables are simply absent. The fix is to set them in the platform’s settings, and to read them from process.env (or os.environ) rather than from the file.

The variable is read at build time, not run time. This is the subtle one. Frameworks like Next.js inline NEXT_PUBLIC_* variables into the JavaScript bundle during the build. That means the value is frozen into the output. Changing the variable in your platform dashboard and redeploying without rebuilding changes nothing, because the new value was never read.

// Read at RUN time — correct for secrets and per-environment values.
const apiUrl = process.env.API_URL;

// Inlined at BUILD time by many frameworks — frozen into the bundle.
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

The variable name differs. Trailing whitespace, a different case, or a plural that does not match the code are all invisible until the code runs. This is why you log names, not values.

Diagnose it by printing the environment and the names of the variables you expect:

console.log("NODE_ENV:", process.env.NODE_ENV);
console.log("API_URL set:", Boolean(process.env.API_URL));
console.log("DATABASE_URL set:", Boolean(process.env.DATABASE_URL));

// Fail loudly and early if something required is missing.
for (const name of ["DATABASE_URL", "API_URL"]) {
  if (!process.env[name]) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
}

The for loop at the end is the pattern worth keeping permanently. It converts an undefined value discovered deep in a request into a clear message at startup, which is the difference between a five-minute and a five-hour debug.

What else differs between local and production?

Case sensitivity. This one catches nearly everyone, because it cannot happen on macOS or Windows. Linux filesystems are case-sensitive, so import { helper } from './Utils' fails in production when the file is utils.ts.

# Verify every import matches the real filename exactly.
git ls-files | grep -i '\.py\|\.ts\|\.js' | sort -f | uniq -di

The database. Locally you have a handful of rows. In production: different data means different code paths, nullability that never occurs in your sample, case-sensitive string matching, and rows that break assumptions about uniqueness. This also includes the connection itself — see Connection Pool Exhausted for the version where production has more traffic than local rather than more data.

CORS and network. Localhost is often treated as a special case by tooling. Your API may allow http://localhost:3000 explicitly and nothing else, so the identical frontend code fails the moment it is served from a real domain — see What Is CORS.

Bind address and file paths. An app that binds to 127.0.0.1 works when you run it directly and is unreachable from a reverse proxy or container network. Absolute paths that exist on your laptop, like /Users/you/project/data.db, do not exist on a server — the same code needs a path relative to the application or an injected configuration value.

Permissions. Development often runs as your user, who owns everything. Production runs as a service account that may not be able to write to the directory, which produces permission errors that never appear locally.

Dependencies. package-lock.json or requirements.txt is respected locally and sometimes not in production, or the build resolves floating versions. A lockfile that is not committed, or not used, is a common source of “identical code, different behaviour”.

How do I systematically find the divergence?

Work outside-in, cheapest check first, and change nothing until you have evidence.

1. Compare the environment names, not the values. Print every variable name the app expects and whether it is set. This immediately separates “configuration problem” from “everything else”.

2. Compare the runtimes. Check the language and framework versions, the OS, and the architecture. uname -m in both places is a fast check if you built for the wrong architecture.

3. Compare the code. Confirm the deployed commit is the one you think. A surprising share of “works locally” reports are actually “works on a different branch than the one deployed”.

4. Read the logs from the failing environment. Not your terminal — the deployed one. See What Is Structured Logging? for making that practical, and What Is a 502 Bad Gateway? if requests are not reaching your app at all.

5. Reproduce the production environment locally. Run your app in a container with a production-mode environment variable set and no .env file present. If it breaks locally, you have a fast reproduction loop.

Where AI coding assistants get this wrong

  • Adding a hardcoded default like const url = process.env.API_URL || "http://localhost:3000", which converts a loud failure into a silent one that points at the wrong server.
  • Patching the symptom — widening CORS, adding a retry, bumping a timeout — without identifying which environment variable is missing.
  • Suggesting you commit .env, which fixes the local-to-production gap by leaking every secret into version control.
  • Confusing build-time and run-time variables, and advising a redeploy when only a rebuild would help.
  • Ignoring case sensitivity entirely, so it never suggests checking filename capitalisation.
  • Adding dotenv to production, where the file does not exist, making the failure quieter but not fixed.
  • Assuming the deployed commit is current, and debugging a version that is not the one running.

Checklist

  • Print environment variable names, never values, and log which are set.
  • Add a startup check that throws if a required variable is missing.
  • Confirm whether each variable is read at build time or run time, and rebuild when it is build time.
  • Set secrets in the platform’s environment settings or a secret manager, never in a committed file.
  • Commit a .env.example with names and no values.
  • Check filename and import capitalisation — Linux is case-sensitive.
  • Compare runtime versions, OS, and CPU architecture between environments.
  • Confirm the deployed commit is the branch you tested.
  • Replace hardcoded localhost fallbacks with an explicit startup failure.
  • Run locally in a container with NODE_ENV=production and no .env to reproduce the failure fast.
  • Keep a lockfile committed and actually use it in the build.

FAQ

Why are my environment variables undefined in production?

Usually because they are only defined in a .env file, which is a development convention and is never deployed. A .env file is not read automatically in production — the platform injects real environment variables instead, and your code needs to read them from the process environment. The second common cause is build-time versus run-time: a framework that inlines variables during the build bakes in whatever existed at build time, not at runtime.

What is the difference between a build-time and a run-time environment variable?

A build-time variable is read while the app is being compiled or bundled, and its value gets hardcoded into the output. A run-time variable is read when the app starts. Values that differ per environment — API URLs, database URLs, secrets — must be run-time, or you will ship production code containing development values. This is why changing a variable and redeploying without rebuilding changes nothing.

Should I commit my .env file?

No. Commit a .env.example with the variable names and no values, and keep the real .env file out of version control and out of your image. Secrets belong in your platform’s environment settings or a secret manager, which is covered in How to Manage Secrets and Environment Variables Properly.

How do I find the real cause quickly?

Log the environment your code believes it is running in, and print the name of each variable you expect rather than its value. If the name is missing, it is a configuration or build-time problem. If the name is present but the behaviour is wrong, it is something else — usually CORS, the database, or a case-sensitivity difference between macOS and Linux.

Why does it only break on Linux and not my Mac?

The most likely reason is case sensitivity. macOS uses a case-insensitive filesystem by default and Linux does not, so an import or filename that differs only in capitalisation works locally and fails on a server. The second most likely reason is path assumptions — absolute paths from your machine do not exist on a server.

Should I just add retries to make it more resilient?

Not until you have identified the cause. Retries do not fix a missing environment variable or a case mismatch; they just delay the error and add load. The exception is a genuine transient failure such as a database briefly rejecting a connection, which is what retry logic with backoff is for.

Sources

Share: