One sentence in the SDK 58 page "Environment variables in Expo" made me stop reading.
In SDK 58 and later, Expo commands don't load other .env files, such as .env.staging or .env.test.
Keeping staging values in .env.staging and switching with NODE_ENV=staging is the first setup I reach for as an indie developer when a server URL has to differ per environment. If that sentence is accurate, the app will still start after the upgrade and look perfectly healthy. It may just be talking to a different API than you think.
So I wrote a small script that audits every .env file in a project and ran it against a sample. It does nothing clever: it applies the rules in the official docs (page dated 2026-10-12) mechanically.
The three rules the docs set
Here are the rules the audit needs, and nothing else.
- Each mode (development or production) loads a fixed list of files, and the file listed first wins.
- From SDK 58, files outside that list, such as
.env.stagingand.env.test, are not loaded. - From SDK 58, each command sets
NODE_ENVto its own mode and replaces whatever you pass in.NODE_ENV=production npx expo startruns in development mode and loads.env.development.
| Mode | Files loaded (highest priority first) |
|---|---|
| Development | .env.development.local → .env.local → .env.development → .env |
| Production | .env.production.local → .env.local → .env.production → .env |
The third rule reaches eas update too. It calls npx expo export underneath, so the docs say NODE_ENV=test eas update will run as production, not test.
The audit script
It does four things. It classifies each .env file as loaded or not in each mode. It prints which file's value wins in production when a key appears more than once. It looks in package.json scripts for NODE_ENV= next to an expo or eas update command. And it searches the source for process.env['…'] and const { … } = process.env, because according to the docs a variable is only inlined into the bundle when it is written statically as process.env.EXPO_PUBLIC_X.
// env-audit.mjs — audit a project's .env files and env references against the Expo SDK 58 loading rules
// usage: node env-audit.mjs <project root>
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
const root = process.argv[2] ?? '.';
// From the official docs (Environment variables in Expo). Earlier = higher priority
const LOADED = {
development: ['.env.development.local', '.env.local', '.env.development', '.env'],
production: ['.env.production.local', '.env.local', '.env.production', '.env'],
};
const envFiles = readdirSync(root).filter((f) => /^\.env(\..+)?$/.test(f));
console.log('== .env files ==');
for (const f of envFiles) {
const dev = LOADED.development.includes(f);
const prod = LOADED.production.includes(f);
const verdict = dev || prod ? `loaded (dev: ${dev ? 'yes' : 'no'} / prod: ${prod ? 'yes' : 'no'})` : 'NOT loaded (SDK 58+)';
console.log(`${f.padEnd(26)} ${verdict}`);
}
// when a key appears in several files, which one wins in production mode
const parse = (f) =>
Object.fromEntries(
readFileSync(join(root, f), 'utf8')
.split('\n')
.map((l) => l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/))
.filter(Boolean)
.map((m) => [m[1], m[2].trim()]),
);
console.log('\n== Values that actually apply in production mode ==');
const winner = {};
for (const f of [...LOADED.production].reverse()) {
if (!envFiles.includes(f)) continue;
for (const [k, v] of Object.entries(parse(f))) winner[k] = { v, f };
}
for (const [k, { v, f }] of Object.entries(winner)) console.log(`${k} = ${v} <- ${f}`);
// From SDK 58, expo commands overwrite NODE_ENV= from package.json
console.log('\n== NODE_ENV in package.json ==');
const scripts = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts ?? {};
let hit = false;
for (const [name, cmd] of Object.entries(scripts)) {
if (/NODE_ENV=/.test(cmd) && /\bexpo\b|eas update/.test(cmd)) {
hit = true;
console.log(`scripts.${name}: ${cmd} <- the NODE_ENV you set is ignored`);
}
}
if (!hit) console.log('none');
// forms that are not inlined
console.log('\n== process.env forms that are not inlined ==');
const walk = (d) =>
readdirSync(d).flatMap((n) => {
if (n === 'node_modules' || n.startsWith('.')) return [];
const p = join(d, n);
return statSync(p).isDirectory() ? walk(p) : ['.ts', '.tsx', '.js', '.jsx'].includes(extname(p)) ? [p] : [];
});
let bad = 0;
for (const p of walk(root)) {
readFileSync(p, 'utf8').split('\n').forEach((line, i) => {
if (/process\.env\[/.test(line) || /\}\s*=\s*process\.env\s*;?\s*$/.test(line)) {
bad++;
console.log(`${p.replace(root + '/', '')}:${i + 1} ${line.trim()}`);
}
});
}
if (!bad) console.log('none');
process.exitCode = envFiles.some((f) => !LOADED.development.includes(f) && !LOADED.production.includes(f)) || bad || hit ? 1 : 0;It needs only Node. Run it as node env-audit.mjs ./my-app. It exits with 1 when it finds a problem, so it can sit in CI.
What it printed on a sample project
The sample has four files: .env, .env.local, .env.production, and .env.staging, with a LAN address in .env.local. Its package.json has NODE_ENV=staging eas update --branch preview, and the source uses all three ways of reading process.env, once each.
== .env files ==
.env loaded (dev: yes / prod: yes)
.env.local loaded (dev: yes / prod: yes)
.env.production loaded (dev: no / prod: yes)
.env.staging NOT loaded (SDK 58+)
== Values that actually apply in production mode ==
EXPO_PUBLIC_API_URL = http://192.168.0.12:3000 <- .env.local
EXPO_PUBLIC_FLAG = off <- .env
== NODE_ENV in package.json ==
scripts.build:stg: NODE_ENV=staging eas update --branch preview <- the NODE_ENV you set is ignored
== process.env forms that are not inlined ==
app/api.ts:1 const { EXPO_PUBLIC_API_URL } = process.env;
app/api.ts:2 export const url = process.env['EXPO_PUBLIC_API_URL'];Two lines matter.
The first is .env.staging. The file is there, its contents are right, and no mode reads it. Nothing throws. The value comes back undefined or is silently replaced by another file's value, so you notice late.
The second is the production result. For EXPO_PUBLIC_API_URL, .env.local beats .env.production. By the table, .env.local loads in production mode and sits ahead of .env.production. If a .env.local with your dev machine's address is still around, it overrides the production value whenever you run npx expo export or eas update from your own machine.
A file existing is not the same as its value being used. The audit has to answer "in production mode, which line wins", not only "which files exist".
The order I would fix things in
- Move what's in
.env.stagingto somewhere that is loaded. With EAS, the docs recommendeas env:pull, which replaces your.env.localwith the environment you pick, so you no longer depend onNODE_ENV. Without EAS, they suggest writing a small script that overwrites.env.localor.env. - Delete
NODE_ENV=stagingfrompackage.json. It does no harm, but it looks as though it works, and that misleads the next reader. - Put
.env*.localin.gitignore. The docs show exactly this line. - Rewrite
process.env['…']and destructuring intoprocess.env.EXPO_PUBLIC_X.
The fourth point isn't specific to SDK 58. Destructuring just looks tidy, so it tends to survive unnoticed.
What this audit does not prove
What I confirmed is that the script applies the documented rules correctly. I did not run the real Expo CLI on SDK 58 and compare the loaded values. Docs get revised, so if the table changes, update LOADED in the script.
Variables you keep on EAS servers with eas env are outside its reach. It only sees what is in the repository.
Also remember that EXPO_PUBLIC_ values are plain text inside the built app, and the docs warn against putting secrets there. For the source side, see A 40-Line Script to Check Whether Rork Wrote an API Key Into Your Code. For another SDK 58 audit of what the build adds on its own, see My plugins Array Was Empty, and Info.plist Still Had Nine Permission Strings.
Next time you upgrade, run this first and check only two things: the files that are not read, and the lines that win.