Knowledge Mark G
Hire Me

Next.js Loads .env.local During Production Builds - And It Overrides .env.production

A production build baked http://localhost:5080 into the live bundle. The cause was a .env.local file that Next.js loads during next build and ranks above .env.production. Here is the precedence order, a ten-line way to prove it on your own project, and the rename that fixes it permanently.

Kausar RazaBy Published on · 7 min read · 0 views
Share:

Web Development

Next.js Loads .env.local During Production Builds
  • Best Practices
  • DevOps
  • Web Development

Need help with this?

I build and fix business websites - fast, mobile-first and built to bring enquiries.

Get in touch

A deploy went out. The site loaded, the certificate was valid, the server was in a datacentre in London - and every API call in the browser console was going to http://localhost:5080.

Nothing in .env.production mentioned localhost. The server's own environment did not mention localhost. The value had been welded into the JavaScript bundle at build time, from a file I had forgotten was on my laptop: .env.local.

If you have ever searched "Next.js not reading .env.local", or wondered whether .env.local overrides .env.production, the answer is the one that catches people out:

Next.js loads .env.local during next build, not just next dev - and it outranks .env.production.

The precedence order, and the one line that matters

Next.js merges up to five sources. The first one that defines a variable wins; later sources cannot overwrite it:

process.env                 (real shell / service environment)
.env.$(NODE_ENV).local      (.env.production.local on a prod build)
.env.local                  (skipped ONLY when NODE_ENV=test)
.env.$(NODE_ENV)            (.env.production on a prod build)
.env

Read line three against line four. .env.local sits above .env.production. There is exactly one condition under which Next.js skips it, and it is not "production" - it is NODE_ENV=test.

That single ordering decision is the whole bug. The file most people think of as "my local development overrides" is, during a production build, a higher-priority source than the file literally named for production.

Prove it on your own project in ten lines

You do not have to trust an article about this, and you should not. Next.js publishes its env loader as a standalone package, @next/env - the same code next build calls. You can run it directly.

Three fixture files:

# .env
NEXT_PUBLIC_API_URL=https://api.example.com

# .env.production
NEXT_PUBLIC_API_URL=https://api.prod.example.com

# .env.local
NEXT_PUBLIC_API_URL=http://localhost:5080

And a script that asks the loader what it did:

const { loadEnvConfig } = require('@next/env');
process.env.NODE_ENV = 'production';

const r = loadEnvConfig(process.cwd(), false); // false = not dev
r.loadedEnvFiles.forEach(f => console.log('loaded:', f.path));
console.log('winner:', process.env.NEXT_PUBLIC_API_URL);

On Next.js 16.3.3 that prints:

loaded: .env.local
loaded: .env.production
loaded: .env
winner: http://localhost:5080

A production build, with a .env.production sitting right there, resolving a public API URL to localhost. No warning, no error, exit code 0.

Why NEXT_PUBLIC_ turns a slip into a permanent artifact

Next.js treats two kinds of variable very differently, and the difference decides whether this is a five-second fix or a rebuild.

  • Server-only variables (no NEXT_PUBLIC_ prefix), read in Server Components, route handlers and other server code, are read from the process environment at runtime.
  • NEXT_PUBLIC_ variables are inlined at build time. The compiler replaces process.env.NEXT_PUBLIC_API_URL with the literal string and ships it inside the chunk the browser downloads.

So after a bad build, the wrong value is not sitting in config anywhere. It is text inside a .js file:

grep -rl "localhost:5080" .next/static/chunks | head

If that returns anything, no amount of fixing the server's environment will help. This is why the follow-up question is always some version of "I corrected the variable and restarted the app and it still calls localhost". Of course it does. You changed the server; the string is in the browser's download. Only a rebuild moves it.

Why it breaks on your machine and not in CI

This bug has a very specific shape: it follows the person, not the project.

create-next-app adds .env*.local to .gitignore by default. In this repository the ignore rule is even broader - line 35 is just .env*. Which means:

  • A clean CI checkout has no .env.local. Builds there are correct, every time.
  • A developer machine has one, because that is what it is for. Builds there silently pick it up.

If your pipeline builds in CI, you may never meet this. If you build locally and ship the artifact - zip the output, upload it through a hosting panel, copy a folder to a VPS - you are exactly the person it happens to. That is how this site shipped it. The build ran on a laptop that had a perfectly reasonable .env.local pointing at a local API, and the resulting zip went to production with that value fossilised inside it.

It is the same class of problem as deploying in the wrong order, which I wrote about in choosing between a Windows and Linux VPS for ASP.NET Core: nothing throws, the artifact is valid, and the mistake only becomes visible to real users.

The fix is a rename, not a rule

The obvious reaction is "delete .env.local before building". That works exactly until the first time somebody forgets, which is to say it does not work.

Rename the file instead:

mv .env.local .env.development.local

Go back to the precedence list. .env.$(NODE_ENV).local is only consulted when NODE_ENV matches, so .env.development.local is invisible to a production build while still beating everything during next dev. Same fixture files, same loader, after the rename:

NODE_ENV=production
  loaded: .env.production, .env
  winner: https://api.prod.example.com

NODE_ENV=development
  loaded: .env.development.local, .env
  winner: http://localhost:5080

Development keeps its overrides. The production build cannot see them even if it wants to. No checklist, no discipline, nothing to remember at 11pm before a deploy - which is the only kind of fix worth having.

What you can still change without rebuilding

Because the two variable types behave differently, "can I change this without a rebuild?" has a clean answer:

VariableResolvedChange it by
NEXT_PUBLIC_*Build time, inlined into client JSRebuilding. Nothing else.
Everything else, used in server codeRuntime, from the process environmentUpdating the service env and restarting.

With output: "standalone" the server reads its environment when the Node process boots, so database connection strings, API keys and third-party secrets really are runtime configuration. You can rotate a key and restart. You cannot rotate a NEXT_PUBLIC_ value that way, ever.

The practical rule: anything the browser can see is a build input, not a config knob. If you genuinely need a browser-visible value to differ per environment without rebuilding, do not reach for an env var - expose it from a small API route and fetch it at runtime. That is a deliberate design decision with a cost, and it is better than discovering the constraint during an incident.

The Docker version of the same trap

Containerised builds hit this twice, and the second one surprises people who thought they had understood the first.

The first is timing. In a Dockerfile, next build runs while the image is being built. Anything you pass at docker run - -e, --env-file, a Compose environment: block, a Kubernetes ConfigMap - arrives long after the bundle was written. Server-side variables are fine, because the server reads them when the process starts. NEXT_PUBLIC_ values are not, so they have to be build arguments:

ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com .

The practical consequence is one people dislike: a NEXT_PUBLIC_ value that differs between staging and production means two different images. One image promoted through environments cannot carry two different public API URLs. If that matters to you, serve the value from an API route instead and keep the image environment-agnostic.

The second trap is the one that actually bites. .gitignore and .dockerignore are separate files, and the Next.js starter gives you a thorough .gitignore and a thin .dockerignore. So this very ordinary line:

COPY . .

happily copies your local .env.local into the build context, where next build finds it and ranks it above .env.production - exactly as it did on my laptop. You get the localhost bug inside a container that was supposed to be reproducible, and because the file is invisible in Git nobody looking at the repository can see why. Add .env*.local to .dockerignore, or apply the rename and stop worrying about which ignore file is which.

Which env files should actually be committed

Since none of this is obvious from the filenames, here is the whole set in one table:

FileCommit it?What it is for
.envUsually yesDefaults shared by every environment. No secrets.
.env.productionYesNon-secret production values, such as a public API URL.
.env.development.localNoYour personal dev overrides. Invisible to production builds.
.env.localNo - and prefer not to have oneOverrides every environment, including production builds.
.env.exampleYesDocumentation. Every key, no real values.

Real secrets - database passwords, payment keys, tokens - should not be in any committed file. They belong in the hosting platform's environment settings, where they are read at runtime and can be rotated without a build. The committed files exist to answer "which variables does this app need?", which is what .env.example is for, and the question new developers waste their first morning on.

A sixty-second audit

Worth doing once on any Next.js project you deploy:

  1. ls -a | grep env - list every env file that exists locally. Anything named .env.local or .env.production.local is a live hazard on a local build.
  2. Run the @next/env snippet above with NODE_ENV=production and read which files it loads. Takes five seconds and removes all doubt.
  3. After your next build, grep the output for values that should never reach production: grep -rl "localhost" .next/static/chunks.
  4. Make step 3 a build step rather than a habit. A grep that exits non-zero is a better teammate than a wiki page.

That last point is the general lesson. The staging script for this site asserts that a handful of required files are present before it will produce a deployable folder, because every one of those assertions exists thanks to something that went wrong silently once. Build-time checks are cheap. Finding out from a user that your production site is calling localhost is not.

If you are putting a Next.js front end over an existing .NET backend, the same build-versus- runtime split shapes a lot of the architecture - I went through that in detail in migrating an ASP.NET MVC monolith to a React front end.

Frequently asked questions

Does .env.local override .env.production in Next.js?+
Yes. Next.js resolves variables in the order process.env, .env.$(NODE_ENV).local, .env.local, .env.$(NODE_ENV), .env - and the first source that defines a variable wins. Because .env.local is checked before .env.production, a leftover .env.local silently overrides your production values during next build.
Is .env.local loaded during next build, or only next dev?+
It is loaded during both. The only time Next.js skips .env.local is when NODE_ENV is test. A production build reads it like any other env file, which is what makes this such a common and quiet failure.
I changed a NEXT_PUBLIC_ variable on the server and restarted. Why is it still the old value?+
Because NEXT_PUBLIC_ variables are inlined into the JavaScript bundle at build time. The old value is a literal string inside a file in .next/static/chunks that the browser downloads. Changing the server environment cannot alter a file that was written during the build - only a rebuild can.
Why does the build work correctly in CI but not on my machine?+
Because .env*.local is gitignored by default, so it exists on developer machines and never in a clean CI checkout. CI builds get the correct values; local builds pick up the local override. Projects that build locally and upload the artifact are the ones exposed.
Should I just delete .env.local before building?+
It works, but it depends on a human remembering every single time. Renaming it to .env.development.local is better: that file is only loaded when NODE_ENV is development, so a production build cannot see it, while next dev still gets your local overrides.
Can I change environment variables at runtime with output: standalone?+
For server-side variables, yes - the standalone server reads the process environment when the Node process starts, so you can update a connection string or API key and restart. For NEXT_PUBLIC_ variables, no. Those are fixed at build time regardless of output mode.
Does .env.production.local override .env.local?+
Yes. .env.$(NODE_ENV).local is the highest-priority file, above .env.local, which is above .env.production, which is above .env. Only real process environment variables outrank all of them.

Join the conversation

No comments yet — be the first to share what you think.

Leave a comment

Never published.

Optional.

Keep reading

Related articles

View all