Suggest an editImprove this articleRefine the answer for “How to work with environment variables in Node.js?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Environment variables** in Node.js are read from `process.env`, a plain object populated at process startup. All values are strings, so parse them before use. ```js require('dotenv').config(); // load .env first const port = parseInt(process.env.PORT || '3000', 10); const debug = process.env.DEBUG === 'true'; ``` **Key:** load `dotenv` as the first line of your entry file, and never commit `.env` to git.Shown above the full answer for quick recall.Answer (EN)Image**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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.