Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild
Back to Blog Explainer

.env vs .env.local: Which File Wins, and Which One Gets Committed

Sean

Platform Writer

Aug 27, 2026
8 min read

The plain env file holds defaults shared by everyone and belongs in git. The local variant holds your machine’s overrides, wins over the shared one, and must never be committed. Every other rule follows from those two.

.env vs .env.local: Which File Wins, and Which One Gets Committed

The convention came from Create React App, spread through Next.js and Vite, and is now everywhere - which means plenty of projects use the filenames without the loading rules that make them mean anything. The result is a committed env file with a live database password in it, sitting in a public repository.

Table of contents

The precedence chain

Frameworks that support this convention load several files and let later ones override earlier ones. The typical order, from lowest to highest priority:

  1. The plain env file - defaults for every environment, committed.
  2. The per-environment file for development or production - committed. Which one loads depends on the current mode.
  3. The local file - machine-specific overrides, never committed. Loaded in every environment except test.
  4. The environment-specific local file - machine-specific and mode-specific, never committed.
  5. Real process environment variables - always win over any file.

That last line is the one people forget, and it is the most important. A variable exported in your shell or injected by your hosting platform beats every file on disk. This is why a value works locally and mysteriously does not in production, or the reverse: the file is being read, and then overridden.

# .env (committed)
DATABASE_URL=postgres://localhost:5432/app_dev
LOG_LEVEL=info

# .env.local (gitignored)
DATABASE_URL=postgres://localhost:5433/my_branch_db

# Result: DATABASE_URL points at 5433, LOG_LEVEL is info

The local file is skipped in the test environment on purpose. Tests should produce the same result on every machine, and a developer’s local overrides leaking into a test run is exactly the flakiness that convention prevents.

Which files go in git

The split is not about the filename, it is about whether the contents are the same for everyone.

# .gitignore
.env.local
.env.*.local

# Explicitly NOT ignored - these are committed:
# .env
# .env.development
# .env.production

Some teams reverse this and gitignore the plain env file entirely, keeping a committed example file instead. That is also defensible, and it is the safer default for a repository that might go public. What is not defensible is committing an env file while treating it as the place secrets live.

The rule that resolves it: the committed file contains no secrets. Defaults, feature flags, public API base URLs, log levels - fine. Database passwords, signing keys, and third-party tokens - never.

# .env.example (committed, no real values)
DATABASE_URL=postgres://user:password@localhost:5432/dbname
STRIPE_SECRET_KEY=
SESSION_SECRET=

# Developers copy it once
cp .env.example .env.local

The example file doubles as documentation. A new developer copying it gets the full list of variables the app needs, with blanks where they have to supply their own, which is considerably better than discovering them one crash at a time.

Framework differences that actually matter

The precedence chain is broadly shared, but three details vary and each one has caught people out.

Client-side exposure. Next.js only exposes variables with a public prefix to browser code. Vite requires its own prefix. Create React App used a third. This is a security boundary, not a naming convention: anything with the prefix is inlined into the JavaScript bundle and is readable by anyone who opens devtools.

# Safe - server-side only
DATABASE_URL=postgres://...
STRIPE_SECRET_KEY=sk_live_...

# Shipped to the browser. Public forever.
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_ANALYTICS_ID=UA-12345

Putting a secret behind a public prefix is the single most common way credentials leak from a frontend project. The value is not just visible at runtime - it is baked into a static file, cached by CDNs, and present in every copy of the bundle ever served.

Build time versus runtime. In statically built frontends, these values are substituted at build time. Changing an environment variable on the host and restarting does nothing; you have to rebuild. Server-side runtimes read them at process start, so a restart is enough.

Plain dotenv does none of this. The dotenv package loads exactly one file, with no precedence chain and no local handling. If your Node or Express app uses dotenv directly, the multi-file convention does not exist unless you build it.

// Explicit chain with plain dotenv
import dotenv from 'dotenv'

dotenv.config({ path: '.env' })
dotenv.config({ path: '.env.local', override: true })

Debugging what actually loaded

When a variable is not what you expect, resist the urge to guess. Print it.

// Node - is it even defined?
console.log('DATABASE_URL:', process.env.DATABASE_URL ?? '(undefined)')

// Everything the process can see, filtered
console.log(Object.keys(process.env).filter(k => k.startsWith('APP_')))

The usual causes, roughly in order of frequency:

  • The dev server was not restarted. Env files are read at process start, not watched.
  • A real environment variable is shadowing the file. Check the shell environment directly.
  • The file is in the wrong directory - loading is relative to the project root, not the file that reads the variable.
  • A frontend variable is missing its public prefix, so it is server-only by design.
  • The value has quotes that became part of the string, depending on the parser.
  • Trailing whitespace. A port with a trailing space is not the number you think, and the resulting error is rarely obvious.

On multi-line values such as private keys, use quoted escape sequences rather than literal line breaks. Most parsers accept the escaped form and mangle a raw multi-line value.

What to do in production instead

None of this applies once the app is deployed. Production should not read an env file at all - the values should come from the platform’s environment variable configuration, injected into the process at start.

The reasons are practical rather than dogmatic. A file on disk has to get there somehow, which means either baking it into an image or copying it at deploy time. Baking it in means the secret is in every layer of the image and in the registry. Copying it in means a separate secret-delivery path you now maintain.

Platform-managed variables avoid both. The value lives in the platform, is injected at process start, and rotating it is an edit plus a restart rather than a rebuild. On RunxBuild, web services, tools, and WordPress all take environment variables in the dashboard, and the same values are available across deploys without touching the repository.

Two habits worth keeping regardless of platform. Fail fast on missing variables rather than defaulting to something plausible - a service that silently connects to the wrong database is worse than one that refuses to start. And never log the whole environment on error, because that is how secrets end up in a log aggregator with a different access policy than the platform.

const required = ['DATABASE_URL', 'SESSION_SECRET']
const missing = required.filter(k => !process.env[k])
if (missing.length) {
  throw new Error(`Missing required env vars: ${missing.join(', ')}`)
}

How this fits the rest of the stack

The moment env files stop being a local concern is the moment the app is deployed, and that is where the platform should be holding the values rather than the repository. Environment variables, deploy logs, and rollback sit together on RunxBuild, so a bad config change is a restart rather than an archaeology exercise. The RunxBuild hosting calculator shows what the service and its database cost side by side before you commit to either.

Useful related references:

FAQ

Which file takes priority, the plain env file or the local one?

The local one wins. The load order is the shared file, then environment-specific files, then the local file, then environment-specific local files, with real process environment variables overriding all of them. Later files override earlier ones for the same key.

Should the env file be committed to git?

Yes, provided it contains no secrets - it is meant to hold shared defaults. Secrets belong in the local file, which is gitignored, or in your platform’s environment variable configuration. Many teams gitignore both and commit an example file with blank values instead, which is the safer default for a repository that may become public.

Why is my environment variable undefined?

Most often the dev server was not restarted - env files are read once at process start. Other frequent causes: a real shell variable shadowing the file, the file sitting outside the project root, or a frontend variable missing the public prefix its framework requires, which makes it server-only by design.

Is the local env file loaded during tests?

No. Frameworks following this convention deliberately skip it in the test environment so that test runs are reproducible across machines. Use a test-specific env file instead, which is committed and shared.

Can I use env files in production?

You can, but you should not. Production values should be injected by the platform as real environment variables, which avoids baking secrets into an image or maintaining a separate file-delivery path. It also makes rotating a credential an edit and a restart rather than a rebuild and redeploy.

#env vs env local#environment variables#dotenv#next.js env#gitignore