Skip to main content

3 posts tagged with "Environment Variables"

View all tags

.env Edits Do Nothing? DB Config Overrides Env Variables

Β· 6 min read

An AI model config in .env was changed β€” provider, key, and model name all swapped. Service restarted. The application's behavior did not move by an inch: still the old provider, the old model. The new config sits right there in the file, looking ready to apply at any moment. It never does.

Encountered this while maintaining a client-delivered AI content-processing application β€” recording the detour and the final diagnosis.

TL;DR​

When an app supports editing config through an admin UI, the database fully overrides .env β€” same-named entries in .env are a fallback at best, dead config at worst.

One sentence for the triage order: check the app's runtime config store (the DB config table) before checking .env. This investigation wasted two steps on .env and the process environment snapshot before the DB config table revealed the truth: it held a complete set of values from a different provider, shadowing .env in full.

Symptoms​

The server's .env:

# /path/to/backend/.env β€” looks "in use"
AI_API_KEY=sk-xxxx...xxxx
AI_BASE_URL=https://old-provider-compatible-endpoint
AI_MODEL=old-model-name

Actual behavior: model calls go to a different provider entirely (a real OpenAI-protocol endpoint, different key, different model). Edit .env, restart, no effect. Edit again, restart again, still no effect.

The Detour: Two Checks That Paid Nothing​

The investigation itself is worth recording β€” two checks that looked professional and accomplished nothing.

Detour 1: staring at the .env file. The config was complete, well-formatted, in the right directory (the systemd unit genuinely points there). Everything about it says "in use". But a config being in a file and a config being in effect are different things β€” the app's loader has a precedence chain, and .env is only one candidate source.

Detour 2: checking /proc/<pid>/environ. The standard move to see what environment a process actually received:

cat /proc/<pid>/environ | tr '\0' '\n' | grep AI_

Empty. And the check itself was flawed: dotenv injects into os.environ at runtime, after the process started β€” /proc/<pid>/environ is a snapshot from launch time and can never show dotenv-injected values. Absence here proves nothing about the process, and presence here proves nothing about effectiveness. For a dotenv app, this snapshot supports no conclusion in either direction.

The third step finally landed: querying the DB config table. The app supports editing AI config through an admin UI, and its loader is "DB first, env as fallback" β€” the DB config table held a full set of the other provider's values, outranking .env. Case closed.

Root Cause: A Config UI Requires DB > env​

Why must this class of app rank DB above env? Reverse-engineer from the feature requirement:

The app promises "admin edits AI config in the backend; it applies on save". If .env outranked the DB, every UI edit would be crushed by .env and the feature would be dead on arrival. So any app with this feature has a loader shaped like:

def _ensure_client():
cfg = load_db_config() # 1. DB config table first
if cfg is None: # 2. fall back to env only when DB is empty
cfg = from_env()
return build_client(cfg)

DB wins whenever it has values β€” and the moment anyone saves a config in the backend once, the DB has values forever. From that day on, .env is dead config: nothing written there gets read, unless the DB config is cleared.

The trap is its invisibility: .env sits right there on the server, complete and tidy, and the natural operator reflex is "edit this". Nothing ever warns "your edit was overridden by the DB" β€” the config system silently follows its precedence chain, and only someone who knows the chain can predict the outcome.

The Fix: Check the DB First, Then Clean Up​

Step 1: confirm the real source of effective config. Query the app's config table (an app_config-style key-value table in this case):

SELECT key, value FROM app_config;

Match the DB values against observed behavior (e.g., which endpoint requests actually hit). Once they line up, the conclusion is nailed: DB fully overrides; the same-named .env block is leftover dead config.

Step 2: before deleting dead config, verify the DB really covers everything. Compare DB entries against .env item by item: if DB covers all critical fields, removing the .env leftovers should be harmless in theory β€” but restart and verify before deleting, in case some field in the loader still falls back to env. Verify, then delete: that is the safe order for dead-config cleanup.

Step 3: write the precedence chain into the ops doc. The two wasted steps trace back to one gap: nobody knew this app had config precedence at all. One line in the handover doc saves the next person two hours: "config lives in the admin UI (DB); .env is fallback only β€” check the DB before touching .env."

Watch out

"A config file exists and looks correct" never equals "this config is in effect" β€” effectiveness depends on the loading logic and its precedence chain. Likewise, /proc/<pid>/environ proves neither presence nor effectiveness for a dotenv app. The right starting point for any config investigation is the app's config-loading code β€” read the precedence chain out of the code, then walk it.

FAQ​

What is the config precedence between database and environment variables?​

In apps with an admin config UI, from lowest to highest: system-level env vars β†’ process startup env β†’ dotenv-injected values β†’ the app's runtime config store (a DB or config file). The DB layer wins β€” UI edits must take effect immediately, which is impossible if .env outranks it. When troubleshooting, check the app's config store before the env files and you skip most of the detour.

Do environment variable changes require a restart?​

By layer: system-level variables need a shell or service restart; dotenv reads .env once at process start, so file edits need a process restart too; apps with a DB config layer pick up backend edits instantly β€” the loader reads the DB on every client build. Mixing the three timing models is how you end up concluding "changes do nothing".

Why can't I see dotenv variables in /proc/pid/environ?​

Because /proc/pid/environ is a snapshot from the instant the process started, while dotenv injects into os.environ after the process is already running. To verify dotenv's values, print them inside the process or read the .env file directly β€” and remember that a value being in the file still doesn't mean it's in effect; a higher-precedence layer may be overriding it.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

process.env Not Propagating? It's the ESM Import Cache

Β· 5 min read

A login script successfully refreshed the credential at runtime and wrote it into process.env. Business requests still return 401 β€” the constant they imported is still the stale value from process startup.

Encountered this while building a data-collection tool for a client β€” recording the root cause and the fix.

TL;DR​

An ESM module evaluates exactly once; what you import is a read-only snapshot from load time β€” no later write to process.env ever reaches an already-imported constant.

// ❌ cached at load time, stale forever after
import { AUTH_TOKEN } from './config.js'

// βœ… read the current value on every use
function getToken() {
return process.env.AUTH_TOKEN || ''
}

One-sentence rule: a value that updates at runtime must be read dynamically from process.env at the call site β€” never imported as a module-level constant.

Symptoms​

Three files, three jobs: config.js exports config constants, login.js logs in and refreshes the credential, runtime.js sends business requests with it:

// config.js β€” central export
export const AUTH_TOKEN = process.env.AUTH_TOKEN || ''
// login.js β€” refresh after login
process.env.AUTH_TOKEN = newToken // runtime update
console.log('[login] token refreshed')
// runtime.js β€” business requests
import { AUTH_TOKEN } from './config.js'

fetch(url, { headers: { Authorization: `Bearer ${AUTH_TOKEN}` } })
// β†’ 401: AUTH_TOKEN is still the startup-time value (or an empty string)

Logs say the token was refreshed; the request header carries the old credential. Print AUTH_TOKEN and process.env.AUTH_TOKEN side by side and they differ β€” process.env is current, the imported constant is not.

Root Cause: An ESM Module Evaluates Only Once​

Per the ESM specification, a module's code executes exactly once β€” from its first import until the process exits; every subsequent import receives the same cached module instance.

So import { AUTH_TOKEN } from './config.js' in runtime.js actually means: load config.js (evaluating export const AUTH_TOKEN = process.env.AUTH_TOKEN || '', which freezes the then-current process.env value into the constant) and bind that value into runtime.js's scope.

Two properties of that binding create the trap:

  1. Read-only: the imported binding in the consumer cannot be reassigned (ESM import bindings do point at the export's live binding β€” but config.js exports a const, which will never hold a new value)
  2. Decoupled from process.env: process.env.AUTH_TOKEN = newToken executed later by login.js merely sets a property on the process.env object β€” the evaluation in config.js finished long ago, and no mechanism propagates that write back into the exported constant

In one sentence: process.env is a mutable runtime object, and export const X = process.env.Y is a one-time snapshot of one of its rows. The snapshot does not track the original.

The Fix: Read Dynamically at the Call Site​

Option 1 (smallest change): read the current value on use. Replace the imported constant with a process.env read:

// runtime.js
// import { AUTH_TOKEN } from './config.js' ← remove

fetch(url, {
headers: { Authorization: `Bearer ${process.env.AUTH_TOKEN || ''}` },
})

Option 2 (many call sites): centralize in a getter. When usage is scattered, consolidate into a function that evaluates on call:

// config.js β€” export a function, not a constant
export const getToken = () => process.env.AUTH_TOKEN || ''
// runtime.js β€” evaluated at call time, always current
import { getToken } from './config.js'

fetch(url, { headers: { Authorization: `Bearer ${getToken()}` } })

Option 3 (many config keys): export an object, read by property. Property access is dynamic by nature:

// config.js
const env = {
get token() { return process.env.AUTH_TOKEN || '' },
}
export default env

// runtime.js
import env from './config.js'
env.token // reads process.env on every access

All three share one principle: defer evaluation from module load to every use.

The same tool hits a second env-file trap after packaging (userData directory, not cwd) β€” a sibling problem about when and where config is read, covered in Packaged Electron App Can't Read .env? It Lives in userData.

Watch out

dotenv behaves identically: dotenv.config() reads the .env file exactly once, at call time. Editing the file afterwards, or calling plain config again, does not update already-injected values (override: true does overwrite, but only for code that reads process.env afterwards β€” anything imported as a constant stays dead). For "I changed .env but nothing happened": first check whether the process restarted, then whether the value was imported as a constant.

FAQ​

How should Node.js environment variables be configured and read?​

Configure via a .env file with dotenv or system-level exports; read them dynamically as process.env.XXX everywhere in code. The key takeaway: process.env is the only channel that runtime updates propagate through β€” a constant imported at module top level is fixed at first load and never follows later changes.

Why do other modules still see the old value after process.env is updated?​

Because the consuming module says import { TOKEN } from './config.js' β€” that line evaluates once at first module load, copying the value of that moment into a local constant. Nothing afterwards, inside config.js or on process.env, propagates back into it. Read process.env.TOKEN at the call site instead to always get the current value.

Do I need to restart the process after updating the .env file?​

Restarting is the reliable answer: dotenv.config() reads the file exactly once, at call time. For in-process hot reloads you must call dotenv.config({ override: true }) again AND have every consumer read process.env dynamically β€” a single import-as-constant anywhere breaks the propagation chain.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Vite Env Variable Undefined? It Needs the VITE_ Prefix

Β· 5 min read

You set API_URL=http://localhost:3005 in a Vite project's .env, but import.meta.env.API_URL logs undefined in the browser, and every API request goes to the wrong address.

Encountered this while building an AI Agent SaaS platform for a client β€” recording the root cause and the fix.

TL;DR​

Vite exposes only VITE_-prefixed environment variables to client code β€” a security design that keeps server secrets out of the browser bundle.

# ❌ never exposed to the frontend
API_URL=http://localhost:3005

# βœ… exposed to the frontend
VITE_API_URL=http://localhost:3005

Access it as import.meta.env.VITE_API_URL. TypeScript projects should add one more step: an env.d.ts declaration for proper autocomplete.

Symptoms​

Two typical presentations:

Symptom 1: the variable is undefined

// .env: API_URL=http://localhost:3005
console.log(import.meta.env.API_URL) // undefined
console.log(import.meta.env) // BASE_URL, MODE, PROD... are all there β€” just not yours

The .env file clearly loaded (MODE, PROD and the other built-ins are present), so this is not a loading failure β€” the exposure rule filtered your variable out.

Symptom 2: prefix added, access name mistyped

// .env: VITE_API_URL=...
const url = import.meta.env.VITE_APIURL // undefined β€” case must match exactly

A separate note: "environment variable reads undefined" on the Node.js server side has a different common cause (dotenv load order) β€” see Node.js env loaded undefined: dotenv import order. Don't mix the two investigations.

Root Cause: The Prefix Rule Is a Security Boundary​

Vite's design problem: .env files typically hold two kinds of values β€” public config the frontend needs (an API base URL) and values that must never reach a browser (database URLs, third-party API keys). Exposing everything means one oversight puts a secret into a publicly served static bundle.

So Vite draws a hard line: only VITE_-prefixed variables appear in import.meta.env. Everything else is visible only in vite.config.ts (the Node side) via loadEnv.

The exposure mechanism matters too: static replacement at build time. Vite inlines import.meta.env.VITE_API_URL as a string literal during the build; at runtime there is no "read the environment" step. Two consequences:

  1. The value appears in plain text inside the shipped JS β€” a VITE_ variable is public by nature
  2. Changing environment at runtime (container env, system variables) never affects an already-built bundle β€” switching environments requires a rebuild, or a runtime-injected config (e.g. window.__CONFIG__)

The Fix: Prefix + Type Declaration​

Step 1: add the VITE_ prefix in .env. Keep the name semantic; the prefix is only an exposure marker:

# .env
VITE_API_URL=http://localhost:3005

Step 2: read it through one config module, not scattered import.meta.env calls in components:

// src/config.ts
export const API_URL = import.meta.env.VITE_API_URL

Step 3 (TypeScript projects): declare the types in src/env.d.ts for full autocomplete:

/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_URL: string
}

interface ImportMeta {
readonly env: ImportMetaEnv
}

Separate dev and production values with mode files β€” Vite picks the right one automatically:

.env.development    # dev server
.env.production # npm run build
.env # loaded in both β€” shared config goes here

After editing any .env file, restart the dev server β€” env files load once at startup, and hot reload does not refresh them. This is the second most common "I changed it but nothing happened" cause.

Watch out

A VITE_ variable is public information: its value is inlined into the client bundle in plain text. API base URLs and feature flags are fine; database connection strings and third-party secrets are not β€” those stay in server-side environment variables. When the frontend needs protected config, serve it from an API endpoint instead of .env.

FAQ​

Where do Vite environment variables live?​

In the .env family at the project root: .env loads in both modes, .env.development only for the dev server, .env.production only during builds. After editing any of them you must restart the dev server β€” Vite loads env files once at startup, and hot reload does not refresh them.

Can I put secrets behind the VITE_ prefix?​

No. VITE_ variables are statically inlined into the client bundle at build time β€” anyone can read them in the page source. The prefix exists to separate publishable config (API base URLs, feature flags) from server-only secrets. Secrets belong in server-side environment variables only.

What is the difference between process.env and import.meta.env in Vite?​

import.meta.env is the browser-side API β€” Vite statically replaces VITE_-prefixed keys into it. process.env is a Node.js API, available only inside vite.config.ts and SSR contexts. Writing process.env.XXX in frontend code is always undefined after build.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me