How to work with environment variables in Node.js?
Environment variables are key-value pairs passed to a Node.js process at startup, letting you configure apps without hardcoding secrets, ports, or database URLs into source code.
Theory
TL;DR
process.env.KEYalways returns a string.'false'is truthy.'0'is truthy. No exceptions.- Load
dotenvas the very first line of your entry file, before any otherrequire. - Never commit
.envto git. Add it to.gitignorebefore the first commit. - Parse types explicitly:
parseInt(...)for numbers,=== 'true'for booleans. - Node 20.6+ has built-in
.envloading:node --env-file=.env server.js, no package needed.
Quick example
// .env file:
// PORT=4000
// DEBUG=true
require('dotenv').config(); // must be the first line
const port = parseInt(process.env.PORT || '3000', 10);
const debug = process.env.DEBUG === 'true'; // NOT just if (process.env.DEBUG)
console.log(port); // 4000
console.log(debug); // true (boolean, not string)Two things happen here. dotenv reads .env and copies each pair into process.env. After that, your code reads them as strings and converts to the right type manually.
How Node loads env vars
Node.js reads env vars from the OS at process creation via C++ bindings to getenv(). They get copied into process.env (a plain JS object) before your main module loads. This means one thing: it is a snapshot. If you change process.env.KEY mid-run, the parent shell sees nothing. If another process sets a var after Node starts, Node sees nothing either.
All values are strings. The OS has no concept of numbers or booleans here. That is why if (process.env.DEBUG) evaluates to true even when the value is the string 'false'.
Setting variables
Three ways, depending on context.
Terminal (Linux/macOS):
PORT=4000 node server.js # inline, one command only
export PORT=4000 && node server.js # for the current sessionWindows:
# cmd
set PORT=4000 && node server.js
# PowerShell
$env:PORT="4000"; node server.jsnpm scripts (cross-platform with the cross-env package):
{
"scripts": {
"start": "cross-env NODE_ENV=production node server.js"
}
}.env files with dotenv
For local development, put your vars in a .env file at the project root and load it with the dotenv package.
npm install dotenv# .env - never commit this file
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=super-secret-key-32-chars-minimum// Entry file: app.js, index.js, or server.js
require('dotenv').config(); // first line, no exceptions
const express = require('express');
const app = express();
app.listen(process.env.PORT || 3000);The most common mistake I see in pull requests: require('dotenv').config() sitting below a require('./db') that already tried to read DATABASE_URL. The var was undefined. The fix is always just moving dotenv to the top.
Multiple env files let you separate concerns:
.env # defaults, safe non-secrets
.env.local # local overrides, gitignored
.env.development # dev-specific
.env.production # prod-specificdotenv.config({ path: `.env.${process.env.NODE_ENV}` });Node 20.6+ built-in .env support
Since Node 20.6, you do not need the dotenv package for basic use:
node --env-file=.env server.jsNode reads the file directly. The syntax is the same as dotenv. For most projects this is enough. For multiple files or variable expansion, dotenv still gives you more control.
Validating env vars at startup
Reading process.env.KEY and hoping it exists leads to crashes at runtime, not at startup. Validate once, early:
const { z } = require('zod');
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
PORT: z.string().transform(Number).default('3000'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
const env = envSchema.parse(process.env);
// Throws at startup if DATABASE_URL is missing
// Not 3 hours into a production deployJoi works the same way. The point is catching missing vars at the moment the app starts, not when a user hits a route that needs the database.
Common mistakes
No fallback on process.env.PORT:
// Wrong
app.listen(process.env.PORT); // undefined in local dev → TypeError
// Right
app.listen(parseInt(process.env.PORT || '3000', 10));Treating the string 'false' as a boolean:
// Wrong - 'false' is a truthy string
if (process.env.DEBUG) { enableLogging(); }
// Right
if (process.env.DEBUG === 'true') { enableLogging(); }Committing .env to git:
# .gitignore - add before the first commit
.env
.env.local
.env.*.localIn 2023, a breach related to Vercel came from a leaked .env in a public repo. The fix takes five seconds: add the file to .gitignore before git init.
Loading dotenv after other modules:
// Wrong - db.js reads DATABASE_URL as undefined
const db = require('./db');
require('dotenv').config(); // too late
// Right
require('dotenv').config();
const db = require('./db');Mutating env vars for worker threads:
// Changes to process.env in main do not reach workers
process.env.TEST = 'value';
const { Worker } = require('worker_threads');
new Worker('./worker.js'); // worker gets a snapshot from startup
// Pass config via workerData instead
new Worker('./worker.js', { workerData: { test: 'value' } });Real-world usage
- Express:
app.listen(process.env.PORT || 3000)- standard on Heroku and Render - Next.js:
NEXT_PUBLIC_prefix exposes vars to the client; others stay server-only - NestJS:
ConfigModule.forRoot()wrapsprocess.envwith a typed service - Prisma: reads
DATABASE_URLdirectly for migrations and queries - Docker:
ENV PORT=3000in Dockerfile, or--env-file .envindocker run - GitHub Actions:
env:block in workflow files, or repository secrets
Follow-up questions
Q: Why are all environment variables strings?
A: The OS passes them as strings. Node copies them as-is into process.env. Nothing in the runtime converts "3000" to 3000 automatically.
Q: What happens if you call require('dotenv').config() twice?
A: By default, dotenv does not overwrite vars that are already set. The second call is a no-op for existing values. You can change this with { override: true }.
Q: How do env vars behave in cluster mode?
A: cluster.fork() copies the env snapshot at the moment of the call. If you update process.env after forking, the worker does not see the change. Pass config via the env option in cluster.fork({ PORT: '4001' }) instead.
Q: In worker threads, does updating process.env in main propagate to running workers?
A: No. Worker threads get a copy of the environment at creation time. Changes to process.env in the main thread do not reach running workers. Use workerData or a message channel for that.
Q: Is there a size limit for env vars?
A: The OS sets a limit, around 1MB total on Linux. Large configs belong in a config file or a secrets manager (AWS Secrets Manager, HashiCorp Vault), not in env vars.
Examples
Basic: reading a variable with a fallback
// Run: PORT=4000 node server.js
// Or: node server.js (falls back to 3000)
const port = parseInt(process.env.PORT || '3000', 10);
console.log(`Server on port ${port}`);
// Output: Server on port 4000
// Output (no PORT set): Server on port 3000Always provide a fallback for vars that have a sensible default. Only throw at startup for vars with no default, like DATABASE_URL.
Intermediate: Express app with dotenv
// npm install express dotenv
require('dotenv').config(); // first
const express = require('express');
const app = express();
const port = parseInt(process.env.PORT || '3000', 10);
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error('DATABASE_URL is required');
process.exit(1);
}
app.get('/health', (req, res) => {
res.json({ status: 'ok', port });
});
app.listen(port, () => console.log(`Listening on ${port}`));
// .env:
// PORT=3000
// DATABASE_URL=postgres://user:pass@localhost/mydbNote the early exit when DATABASE_URL is missing. Better to crash at startup with a clear message than to fail on the first database query with a confusing stack trace.
Advanced: startup validation with Zod
require('dotenv').config();
const { z } = require('zod');
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().regex(/^\d+$/).transform(Number).default('3000'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
let env;
try {
env = envSchema.parse(process.env);
} catch (err) {
console.error('Invalid environment variables:');
console.error(err.flatten().fieldErrors);
process.exit(1);
}
module.exports = env;
// Import this module everywhere instead of accessing process.env directly
// All vars are typed and validated once at startupThis pattern gives you typed config across the whole app. Any file that imports env knows the validation already ran, and TypeScript can infer the types from the Zod schema.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.