Hardcoded credentials in source code are one of the most common causes of accidental credential exposure. A database password in
config.js, an API key in a utility file, a token checked into a commit — any of these can reach a public repository and be scraped by automated scanners within minutes of being pushed.Environment variables are the standard solution. They keep sensitive values out of the codebase, make it straightforward to maintain different configurations for development, staging, and production, and are natively supported by every deployment platform and container runtime.
This guide covers the complete setup: installing and configuring
dotenv, organizing variables across environments, validating required variables at startup, and the practices that matter most for production deployments.What this covers:
Installing
dotenvand loading a.envfileAccessing environment variables in application code
Organizing variables across multiple environments
Validating required variables at startup
Securing
.envfiles and using secret managers in productionCommon issues and how to resolve them
Step 1: Install dotenv and Create a .env File
dotenv reads a .env file in the project root and loads its values into process.env when the application starts.
Install it:
npm install dotenvCreate a .env file in the project root:
PORT=4000
DB_HOST=localhost
DB_USER=admin
DB_PASS=supersecretpassword
API_KEY=abcd1234Add .env to .gitignore immediately. This file should never be committed:
.env
.env.*
!.env.exampleThe !.env.example exception commits a template file (with placeholder values, no real secrets) so other developers know which variables are required. This is a common and useful convention.
Step 2: Load Environment Variables in the Application
Call require('dotenv').config() at the top of the application entry point, before any other code that reads from process.env:
require('dotenv').config();
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
const DB_HOST = process.env.DB_HOST;
const DB_USER = process.env.DB_USER;
app.get('/', (req, res) => {
res.send(`App running on port ${PORT}`);
});
app.listen(PORT, () => {
console.log(`Server started on port ${PORT}`);
});In an ES module project using import syntax:
import 'dotenv/config';
import express from 'express';dotenv only reads the .env file. In production environments where variables are injected by the platform (Heroku config vars, Vercel environment settings, Kubernetes secrets), dotenv simply does nothing — it does not override variables that are already set in process.env. This is the correct behavior and means the same code works in both local development and production without modification.
Step 3: Validate Required Variables at Startup
Missing environment variables cause runtime errors at unpredictable points during execution. A database connection that fails because DB_HOST was not set in a new environment is much harder to diagnose than a startup check that reports exactly which variable is missing.
Add validation at the top of the entry file, after loading dotenv but before anything else runs:
require('dotenv').config();
const required = ['DB_HOST', 'DB_USER', 'DB_PASS', 'API_KEY'];
for (const name of required) {
if (!process.env[name]) {
console.error(`Missing required environment variable: ${name}`);
process.exit(1);
}
}Exiting immediately with a clear error message is preferable to letting the application start and fail mysteriously later. In containerized deployments, a clean exit with a descriptive message makes the problem immediately visible in the container logs.
For TypeScript projects or applications with many variables, a schema validation library like zod makes the validation more structured:
const { z } = require('zod');
const envSchema = z.object({
PORT: z.string().default('3000'),
DB_HOST: z.string(),
DB_USER: z.string(),
DB_PASS: z.string(),
API_KEY: z.string().min(10),
});
const env = envSchema.parse(process.env);
// env.DB_HOST is now typed as string and validatedStep 4: Organize Variables Across Environments
Different environments typically need different configuration: a local database for development, a staging database for testing, a production database with stricter access controls.
Create separate files for each environment:
.env.development
.env.staging
.env.production.env.development:
PORT=4000
DB_HOST=localhost
DB_USER=dev_user
DB_PASS=dev_password.env.production:
PORT=8080
DB_HOST=prod-db.company.com
DB_USER=prod_user
DB_PASS=injected_by_secret_managerThe dotenv-flow package handles loading the correct file based on NODE_ENV automatically:
npm install dotenv-flowrequire('dotenv-flow').config();
// Loads .env, then .env.{NODE_ENV}, with later files overriding earlier onesRun with the appropriate environment:
NODE_ENV=production node server.js
NODE_ENV=development node server.jsStep 5: Use Secret Managers in Production
.env files are suitable for local development. In production, storing secrets in files on the server creates security and operational risks: the file can be read by anyone with server access, it is not audited, and rotating a secret requires manual file editing.
Production environments should use a dedicated secret manager:
AWS Secrets Manager — integrates with IAM roles so EC2 instances and Lambda functions can fetch secrets without storing them anywhere
Google Secret Manager — similar IAM-based access model for GCP workloads
HashiCorp Vault — platform-agnostic, supports dynamic secrets that expire automatically
Doppler — developer-friendly interface for managing secrets across environments with team access controls
With a secret manager, the application fetches the secret at startup (or at request time for particularly sensitive values) rather than reading from a file. Most managed deployment platforms (Vercel, Railway, Render, Heroku) also support injecting secrets as environment variables through their dashboards, without requiring a .env file on the server at all.
Common Issues and Fixes
process.env.VARIABLE_NAME is undefined. The most common cause is that require('dotenv').config() is not at the top of the entry file, or the .env file is not in the project root. Check that the file exists, that the variable name matches exactly (environment variables are case-sensitive), and that dotenv is loaded before the variable is accessed.
Variables are not updating after a change. Node.js reads process.env once at startup. After changing a .env file, restart the process. In development, tools like nodemon restart automatically; in production, a process restart or redeployment is required.
Different values on different machines. If a variable is set in the system environment on one machine but not in .env, the values will differ. The .env.example file should document every required variable so developers can create a local .env with the correct names.
Sensitive values appearing in logs. Never log process.env directly — it dumps every environment variable including secrets. Log specific values with placeholders when debugging:
console.log(`DB_HOST: ${process.env.DB_HOST}`); // acceptable for non-secret config
console.log(`API_KEY: ${process.env.API_KEY ? '[set]' : '[missing]'}`); // never log the valueKey Takeaways
Add
.envto.gitignoreimmediately and commit a.env.examplewith placeholder values instead.Call
require('dotenv').config()orimport 'dotenv/config'at the very top of the entry file, before any code that readsprocess.env.Validate required variables at startup and exit with a clear error message if any are missing. Runtime failures from missing config are harder to diagnose than startup failures.
dotenvdoes not override variables already set in the environment. The same code works in local development and production without modification.Use separate
.env.developmentand.env.productionfiles for environment-specific configuration, managed withdotenv-flowor the deployment platform's built-in secrets management.In production, use a secret manager rather than a
.envfile on the server.Never log sensitive environment variable values. Log a
[set]or[missing]indicator instead.
Conclusion
Environment variables are the established solution for keeping secrets out of source code and making application configuration portable across environments. The setup is lightweight — a .env file, the dotenv package, and a startup validation check — and the security benefit is immediate.
The most important practice is the simplest: add .env to .gitignore before the first commit. Everything else follows from there.
Managing environment variables in a specific deployment setup and running into a problem? Describe the setup in the comments.




