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

*Originally published on* [*knowledgemarkg.com*](https://knowledgemarkg.com/blog/nextjs-env-local-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:

```plaintext
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:

```plaintext
# .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:

```plaintext
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:

```plaintext
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:

```plaintext
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](https://knowledgemarkg.com/blog/windows-vps-vs-linux-vps-aspnet-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:

```plaintext
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:

```plaintext
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:

```plaintext
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
```

```plaintext
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:

```plaintext
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:

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](https://knowledgemarkg.com/blog/aspnet-mvc-to-react-migration).

## 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.

## Related on [knowledgemarkg.com](http://knowledgemarkg.com)

- [Deploying Next.js on IIS and Plesk: The Windows Traps Nobody Documents](https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows)
- [ChunkLoadError After a Next.js Deploy: Your CDN Cached the HTML](https://knowledgemarkg.com/blog/chunkloaderror-nextjs-after-deploy-cdn-cached-html)
- [Your Next.js Page Is Not Updating: Read the x-nextjs-cache Header Before You Debug Anything](https://knowledgemarkg.com/blog/nextjs-page-not-updating-x-nextjs-cache)
