Skip to main content

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.KEY always returns a string. 'false' is truthy. '0' is truthy. No exceptions.
  • Load dotenv as the very first line of your entry file, before any other require.
  • Never commit .env to git. Add it to .gitignore before the first commit.
  • Parse types explicitly: parseInt(...) for numbers, === 'true' for booleans.
  • Node 20.6+ has built-in .env loading: node --env-file=.env server.js, no package needed.

Quick example

js
// .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):

bash
PORT=4000 node server.js # inline, one command only export PORT=4000 && node server.js # for the current session

Windows:

bash
# cmd set PORT=4000 && node server.js # PowerShell $env:PORT="4000"; node server.js

npm scripts (cross-platform with the cross-env package):

json
{ "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.

bash
npm install dotenv
ini
# .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
js
// 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-specific
js
dotenv.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:

bash
node --env-file=.env server.js

Node 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:

js
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 deploy

Joi 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:

js
// 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:

js
// Wrong - 'false' is a truthy string if (process.env.DEBUG) { enableLogging(); } // Right if (process.env.DEBUG === 'true') { enableLogging(); }

Committing .env to git:

bash
# .gitignore - add before the first commit .env .env.local .env.*.local

In 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:

js
// 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:

js
// 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() wraps process.env with a typed service
  • Prisma: reads DATABASE_URL directly for migrations and queries
  • Docker: ENV PORT=3000 in Dockerfile, or --env-file .env in docker 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

js
// 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 3000

Always 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

js
// 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/mydb

Note 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

js
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 startup

This 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?