Using dotenv and process.env
Configuration is everything about your application that changes between the machine you develop on and the machine it runs on: which database to connect to, which port to listen on, which API keys to use, whether to log every query. Environment variables are how that information reaches a program without ever being written into it.
The reason to separate them is not tidiness. The same code has to run in three places against three different databases, and a secret written into a source file is a secret in your git history permanently. Keeping configuration in the environment means one build of your application runs anywhere, and the sensitive parts never enter the repository at all.
Node exposes them as process.env, a plain object of strings. That word matters far more than it looks. process.env.PORT is '3000', not 3000. process.env.DEBUG set to 'false' is a non-empty string, and therefore truthy — so if (process.env.DEBUG) is true at exactly the moment you thought you had switched it off. Coerce every value deliberately and never let JavaScript guess.
In production the platform sets these variables for you. In development you want the same behaviour without typing them before every command, and that is all dotenv does: it reads a .env file of KEY=VALUE lines and copies them into process.env. It is a development convenience rather than a deployment mechanism — on a real server there is usually no .env file at all, and that is correct rather than a mistake.
One ordering trap causes a surprising amount of confusion. dotenv only affects variables read after it has run. If a module reads process.env.DATABASE_URL at its top level and is imported before require('dotenv').config(), it sees undefined, and the error you get talks about your database rather than about your load order. Load configuration on the very first line of your entry file, before any of your own modules.
// npm install dotenv
// .env file (in project root)
// PORT=3000
// NODE_ENV=development
// DATABASE_URL=mongodb://localhost:27017/myapp
// JWT_SECRET=my-super-secret-key-change-this
// API_KEY=abc123def456
// The FIRST line of the entry file, before any module that reads process.env.
// dotenv only affects variables read AFTER it has run.
require('dotenv').config();
// Access variables
const PORT = process.env.PORT || 3000;
const DB_URL = process.env.DATABASE_URL;
const JWT_SECRET = process.env.JWT_SECRET;
console.log('Environment:', process.env.NODE_ENV);
console.log('Port:', PORT);
// Use in your app
const mongoose = require('mongoose');
mongoose.connect(DB_URL);
const app = require('express')();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
}); process.env— every environment variable, as strings and only strings'false'is a non-empty string and therefore truthy — coerce booleans yourselfdotenvreads a.envfile intoprocess.envfor local development- Production platforms set real environment variables; there is usually no
.envthere - Call
dotenv.config()on the first line, before importing anything of your own .envis never committed;.env.examplealways is
- The string-only rule catches everybody at least once.
process.env.MAX_UPLOAD_MBis'10', and'10' * 1024 * 1024happens to work while'10' + 5gives you'105'. Convert once, inside your config module, and let the rest of the application receive a number that is genuinely a number.
The Config Module Pattern
Scattering process.env.X through thirty files works, and then quietly fails. Nobody can list what the application actually needs, a typo like process.env.DATABSE_URL is undefined rather than an error, and a variable missing in production is discovered by the first user unlucky enough to hit the route that uses it.
A config module solves all three at once. One file reads every variable, converts each to the right type, applies defaults where a default is genuinely sensible, and throws immediately if something required is absent. The rest of the application imports config and never touches process.env again.
The throwing is the valuable part. A missing JWT_SECRET should stop the process at startup with a message naming that variable, rather than producing a confusing 500 two hours later when somebody tries to log in. A deployment that fails immediately and loudly is one you can fix in a minute; a deployment that starts successfully and breaks in one corner is a much longer evening.
Be deliberate about which values get a default. A port may default to 3000, because a wrong port announces itself instantly. A database URL or a signing secret must never have one, because a default that silently works is a default that quietly connects your production application to something it should not, or signs tokens with a value published in your repository.
Because the module is cached, it runs once and every file shares the same object — module caching from lesson 3 doing exactly what you want. Group the values by area, so config.db.url and config.jwt.secret read clearly, and a newcomer can see everything the project depends on in one screen. That file becomes the honest answer to "what does this need in order to run?"
// config/index.js
require('dotenv').config();
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
// 'false' is a truthy STRING — booleans must be coerced explicitly
function bool(name, fallback = false) {
const v = process.env[name];
if (v === undefined) return fallback;
return v === 'true' || v === '1';
}
const config = {
port: parseInt(process.env.PORT, 10) || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
isDev: process.env.NODE_ENV !== 'production',
verboseLogs: bool('VERBOSE_LOGS', false),
db: {
url: requireEnv('DATABASE_URL'),
},
jwt: {
secret: requireEnv('JWT_SECRET'),
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
},
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
},
};
module.exports = config;
// Usage in other files
const config = require('./config');
mongoose.connect(config.db.url);
app.listen(config.port);
const token = jwt.sign(payload, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
}); - One module reads
process.env; nothing else in the application does - Convert types there —
parseIntfor numbers, an explicit check for booleans - Throw at startup for anything required; a missing secret must not become a runtime 500
- Defaults for harmless values only — never for a database URL or a signing secret
- Group by area:
config.db.url,config.jwt.secret,config.cors.origin - Module caching means it runs once and every file shares the same object
- Read your own config file as documentation. If somebody joining the project cannot look at it and immediately list every external thing the application needs — database, cache, mail service, payment gateway — then that information lives only in somebody's memory, and it will be missing from the deployment on the day it matters most.
Secrets: What Never Goes into Git
A secret is anything that lets somebody else act as you: database passwords, API keys, signing secrets, payment gateway credentials. The rule for every one of them is identical and worth stating starkly — .env goes into .gitignore before your first commit, not after it.
"After" is the problem. Git keeps history, so deleting a file in a later commit does not remove it from the repository. Anybody who clones can read the earlier version, and automated scanners crawl public repositories looking for precisely this. Nothing you do afterwards makes a committed secret secret again.
So if you do leak one, rotate it. Generate a new key, set it in the environment, and revoke the old one at the provider. Rewriting the git history is worth doing as well, but it is the second step and not the fix — treat any pushed credential as public from that moment onward, because it may already have been read by something that never announces itself.
.env.example exists exactly because .env is invisible to git. It lists every variable name with an empty or obviously fake value, and it is committed, so somebody cloning the repository can see what they need to supply instead of discovering the list one crash at a time. Update it in the same commit that introduces a new variable, or it drifts out of date within a fortnight.
Two more habits. Use different secrets for development and production, so that a leak from somebody's laptop is not also a leak from your live system. And remember that anything shipped to the browser is public: an API key placed in a React component is visible in the bundle to anybody who opens the developer tools, whatever the variable is called. If a key must stay secret, only the server may ever hold it.
# .gitignore — written before the first commit
node_modules/
.env
.env.local
*.log
# .env — real values, never committed
PORT=3000
DATABASE_URL=mongodb+srv://appuser:realpassword@cluster.example/shop
JWT_SECRET=8f3c1d5e9a... # long and random: generated, not invented
PAYMENT_KEY_SECRET=...
# .env.example — committed; names only, no values
PORT=3000
DATABASE_URL=
JWT_SECRET=
PAYMENT_KEY_SECRET=
// Generate a real secret rather than thinking one up:
// node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
// If a secret has been pushed, ROTATE it. Deleting the file is not enough.
// 1. create a new key at the provider
// 2. set it in the deployment environment
// 3. revoke the old one
// 4. only then clean the git history
// Anything the browser receives is public, whatever you name it:
const paymentSecret = process.env.PAYMENT_KEY_SECRET; // fine on the server
// const key = "sk_live_..."; // in frontend code: visible to every visitor .envin.gitignorebefore the first commit, never after- Git keeps history — deleting the file later does not remove the secret
- A pushed credential is public: rotate it first, clean the history second
- Commit
.env.examplewith names and no values, and keep it current - Different secrets for development and production, always
- Anything sent to the browser is public, regardless of what the variable is called
- Generate secrets, do not invent them.
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"produces something no wordlist contains, and it takes less time than thinking of a memorable phrase — which is fortunate, because memorable phrases are exactly what an attacker tries first.
NODE_ENV and Per-Environment Behaviour
NODE_ENV is a convention rather than a Node feature, but it is one that libraries genuinely act on. Express and many packages check for the value 'production' and change behaviour accordingly — caching templates, skipping expensive development-only checks, printing less. Setting it correctly on your server is free performance and one fewer source of surprises.
Use it to vary behaviour, never identity. Verbose logging in development and terse structured logging in production. Full error detail locally and a generic message with a request id publicly. Template caching on where nothing changes and off while you are editing templates.
Do not use it to choose secrets or connection strings. const dbUrl = isDev ? 'mongodb://localhost/dev' : process.env.DATABASE_URL looks convenient and buries a real connection string inside a branch, and eventually somebody runs it with the flag set the wrong way. Every environment should supply its own values through its own variables; your code has no business knowing which environment it is in just to find its database.
Three environments is the usual arrangement: development on your machine, staging as a rehearsal that resembles production closely, and production itself. Staging is where you find the variable nobody set, and it is worth having even for a college project — it is the difference between discovering a missing key the day before a demo and discovering it during one.
One warning to carry with you: whatever differs between environments is where deployment bugs live. If staging has one setting and production another, the thing that works in one and fails in the other is almost always somewhere on that list of differences. Keep the list as short as you can, and write it down where somebody else can read it.
// NODE_ENV is a convention that libraries genuinely act on
const isProd = process.env.NODE_ENV === 'production';
// GOOD — behaviour varies
app.set('view cache', isProd);
app.use(morgan(isProd ? 'combined' : 'dev'));
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({
error: err.message,
stack: isProd ? undefined : err.stack // detail locally, nothing publicly
});
});
// BAD — identity varies, and a real connection string hides inside a branch
// const dbUrl = isProd ? process.env.DATABASE_URL : 'mongodb://localhost/dev';
// GOOD — every environment supplies its own value
const dbUrl = requireEnv('DATABASE_URL');
// Set it where the application RUNS, not in the code that reads it:
// NODE_ENV=production node server.js
// (on Windows use cross-env, or set it in the platform's dashboard) NODE_ENV=production— a convention libraries act on; set it on your server- Vary behaviour: log verbosity, error detail, template caching
- Never vary identity: no connection string or secret chosen by an
if - Development, staging, production — staging is where the missing variable turns up
- Whatever differs between environments is where deployment bugs live
- Set it in the environment, never inside the code that reads it
- Show a stack trace in development and never in production. It is the single most useful thing while you are working and one of the most damaging things to publish, because it names your file paths, your library versions and frequently your database structure. One
isProdcheck in the error handler covers the whole application.
Config Mistakes That Break Deployments
Hardcoded localhost and 3000 come first. Everything works on your machine, nothing works anywhere else, and the error is usually a refused connection that says nothing whatsoever about configuration. Read process.env.PORT with a local fallback, and never write a hostname into your source.
Next, the variable that exists in .env and not on the platform. Your local file is not deployed — that is the entire point of it — so every new variable has to be added to the deployment's own settings as well. This is the single most common cause of "but it worked locally", and a config module that throws at startup turns it from a mystery into one clear line in the deploy log.
Then quoting and whitespace. JWT_SECRET="abc123" may include the quotation marks in the value depending on how the file is parsed, and a trailing space at the end of a line is part of the value too. When a secret "is set but wrong", print its length before you print anything else — a value one or two characters longer than expected explains everything at once, and the length is safe to log where the value is not.
Type mistakes are the string rule arriving with consequences. if (process.env.ENABLE_PAYMENTS) is true when the value is 'false', and process.env.PORT + 1 gives you '30001'. Both vanish permanently if every value is coerced once inside the config module and the rest of the code never sees a raw environment variable.
And the reading-too-early problem returns in a new form after a refactor. You split server.js into modules, one of them reads process.env at its top level, and that import now happens before dotenv.config() runs. No logic changed and everything is undefined. Keep configuration loading on the very first line, and read the environment inside your config module rather than at the top of whichever file happened to need it.
// 1. Never a hardcoded host or port
const PORT = process.env.PORT || 3000; // platform first, local fallback
// app.listen(3000) // works only on your own laptop
// 2. Every new variable must be added to the platform's settings too.
// Fail loudly at startup rather than at two in the morning:
function requireEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing required environment variable: ${name}`);
return v;
}
// 3. "Set but wrong" — print the LENGTH, never the value
console.log('JWT_SECRET length:', (process.env.JWT_SECRET || '').length);
// 34 where you expected 32? Quotation marks, or a trailing space.
// 4. Coerce, always
const enablePayments = process.env.ENABLE_PAYMENTS === 'true'; // not truthiness
const maxUploadMb = Number(process.env.MAX_UPLOAD_MB) || 5;
// 5. Order still matters after a refactor
// server.js
require('dotenv').config(); // FIRST
const config = require('./config');
const app = require('./src/app'); // anything reading env now sees real values - Never hardcode a host or a port —
process.env.PORTwith a local fallback - A variable in
.envis not a variable on the platform; add it in both places - Quotes and trailing spaces become part of the value — log the length, never the value
- Coerce booleans and numbers;
'false'is truthy and'3000' + 1is a string dotenv.config()first, before importing anything that reads the environment- A config module that throws at startup turns a mystery outage into one log line
- When a deployment fails and the same code works locally, list every difference between the two environments before reading any code. Nine times out of ten the answer is already on that list: a variable nobody set, a port chosen for you, a filename whose case differs, or a package that only exists in
devDependencies.
