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

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:

Variable Resolved Change it by
NEXT_PUBLIC_* Build time, inlined into client JS Rebuilding. Nothing else.
Everything else, used in server code Runtime, from the process environment Updating 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:

File Commit it? What it is for
.env Usually yes Defaults shared by every environment. No secrets.
.env.production Yes Non-secret production values, such as a public API URL.
.env.development.local No Your personal dev overrides. Invisible to production builds.
.env.local No - and prefer not to have one Overrides every environment, including production builds.
.env.example Yes Documentation. 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:

  • 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.
  • Run the @next/env snippet above with NODE_ENV=production and read which files it loads. Takes five seconds and removes all doubt.
  • After your next build, grep the output for values that should never reach production: grep -rl "localhost" .next/static/chunks.
  • 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
.

FAQ

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.

Originally published on knowledgemarkg.com.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论