10 Things Nobody Tells You About process.env
I've burned myself on most of these so you don't have to. Here's what I wish someone had told me early on.
1. Keys are case-sensitive on Linux, case-insensitive on Windows
process.env.PORT = "3000"
console.log(process.env.port) // undefined on Linux, "3000" on Windows
This one got me during a "works on my machine" incident. My Windows dev box ran fine. The Linux CI server crashed because a teammate typed env.port instead of env.PORT. Your CI runs Linux. Your dev box probably runs macOS or Windows. Case-sensitivity differences will bite you.
How to handle it: Use a validation layer that throws on missing keys. A simple getEnv("PORT") will catch typos at startup.
2. Values are always strings
console.log(typeof process.env.PORT) // "string" even if you set PORT=3000
Number(process.env.PORT) can return NaN without throwing. Boolean values like "false" are truthy strings.
How to handle it: Always parse. If you use a schema library like CtroEnv, it coerces types and throws on invalid input.
3. process.env is NOT the same as .env
This confused me for way too long. process.env is whatever the shell gave the process. A .env file is just a text file dotenv reads to populate process.env. Node doesn't touch .env files on its own.
// This won't read .env automatically
console.log(process.env.MY_VAR) // undefined
How to handle it: Call dotenv.config() at entry, or use @ctroenv/node which loads .env files automatically.
4. You can set env vars per-command
PORT=4000 node app.js
This sets PORT only for that single process. It doesn't pollute your shell session. Super useful for one-off runs or testing different configurations without editing files.
console.log(process.env.PORT) // "4000"
5. process.env is mutable at runtime
process.env.DATABASE_URL = "postgres://hacker:gotme@evil.com/db"
I've seen code that modifies process.env to "fix" config at runtime. Don't do this. If something is wrong, fail fast and fix the source. Mutating process.env makes debugging a nightmare — you can't trust what you see anymore.
If you're using CtroEnv, the returned object is frozen. You literally can't mutate it.
6. Next.js inlines NEXT_PUBLIC_ vars at build time
// This gets replaced at BUILD time, not runtime
console.log(process.env.NEXT_PUBLIC_API_URL)
Next.js replaces process.env.NEXT_PUBLIC_* references with their actual values during next build. After that, changing the env var on the server does nothing. You have to rebuild.
What this means: If you change NEXT_PUBLIC_API_URL on your production server, your app still uses the old value. Found this out the hard way during a hotfix.
7. process.env is not available in the browser
Browsers don't have process. If you're using Webpack or Vite, they emulate process.env at build time for variables you specifically expose.
// In the browser with Vite:
console.log(import.meta.env.VITE_API_URL) // works
console.log(process.env.PORT) // ReferenceError
How to handle it: Use Vite's import.meta.env with the VITE_ prefix, or reach for a framework adapter that handles this split automatically.
8. NODE_ENV is not set by default
I used to assume NODE_ENV was always there. It's not. Node.js doesn't set it. Your framework probably does — Express sets it to "development" by default, Next.js sets it during build — but if you're writing a bare Node script, you need to set it yourself.
if (process.env.NODE_ENV === "production") {
// This might never run if you forgot to set it
}
How to handle it: Always provide a default. Or validate it exists if your app can't run without it.
9. Env var values are limited to ~32KB on some systems
This one's rare but brutal when it hits. Some Unix systems cap a single environment variable value at 32KB (or even smaller on older kernels). Windows has a 32,767 character limit per variable. If you're stuffing a PEM-encoded certificate or a large JSON blob into an env var, you might hit invisible truncation.
How to handle it: Use files for large config values. Read them from disk instead of env vars.
10. Env vars are inherited by child processes
const child = spawn("node", ["worker.js"])
// child inherits ALL of parent's env vars
This means every child process gets your secrets, your database credentials, your API keys — even if the child doesn't need them. If the child is a third-party CLI tool or something you don't fully control, those secrets are now in its memory space.
How to handle it: Be explicit. Pass only what's needed:
spawn("node", ["worker.js"], {
env: { ONLY_WHAT_THEY_NEED: "value" }
})
Or use CtroEnv's secret masking to at least prevent accidental logging of sensitive values.
Top comments (0)