Environment Management with .env Files
A comprehensive guide to managing environment variables with .env files covering multi-environment setups, Docker integration, CI/CD secrets, and validation patterns.
Environment Management with .env Files
Every production incident I have investigated that traced back to configuration started the same way: someone hardcoded a database connection string, an API key ended up in version control, or a deployment targeted the wrong environment because a variable was missing. Environment variables solve all of these problems, and .env files make working with them practical during development.
This guide covers everything you need to manage environment variables properly across development, testing, staging, and production. We will walk through the .env file format, multi-environment hierarchies, validation at startup, Docker integration, CI/CD secrets, and the patterns that keep your configuration secure and consistent.
The .env File Format
A .env file is a plain text file containing key-value pairs. There is no formal specification, but the format established by the Ruby dotenv gem has become the de facto standard across languages.
# Database configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_development
DB_USER=appuser
DB_PASSWORD=secretpassword
# API keys
STRIPE_SECRET_KEY=sk_test_abc123
SENDGRID_API_KEY=SG.xxxxxxxxxxxx
# Application settings
PORT=3000
NODE_ENV=development
LOG_LEVEL=debug
# Multiline values use quotes
RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"
# Variable interpolation (requires dotenv-expand)
BASE_URL=http://localhost:${PORT}
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
Key rules for the format:
- One variable per line, using
KEY=VALUEsyntax. - Lines starting with
#are comments. - Values with spaces, special characters, or newlines must be wrapped in double quotes.
- No spaces around the
=sign.DB_HOST = localhostwill not parse correctly in every implementation. - Empty lines are ignored.
Variable Naming Conventions
Use SCREAMING_SNAKE_CASE for all environment variable names. This is not just convention; it is a universal standard across operating systems and programming languages. Environment variables in uppercase stand out clearly in code and are immediately recognizable as external configuration rather than local variables.
Group related variables with a common prefix:
# Good: grouped and scannable
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_USER=admin
DB_PASSWORD=secret
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=SG.xxxx
# Bad: inconsistent and hard to scan
database_host=localhost
DbPort=5432
name=myapp
The dotenv Package
The dotenv package loads variables from a .env file into process.env. Install it as a production dependency since your application needs it to start:
npm install dotenv
Load it as early as possible in your application entry point:
// app.js
require("dotenv").config();
var express = require("express");
var app = express();
var port = process.env.PORT || 3000;
app.listen(port, function () {
console.log("Server running on port " + port);
});
The config() call reads .env from the current working directory and merges the variables into process.env. Importantly, dotenv does not overwrite existing environment variables. If PORT is already set in the shell environment, the value from .env is ignored. This is the correct behavior because it means deployment platforms can override development defaults without code changes.
Custom File Paths
You can specify a different file path:
require("dotenv").config({ path: "/opt/app/.env.production" });
Or load multiple files by calling config() more than once:
require("dotenv").config({ path: ".env.local" });
require("dotenv").config({ path: ".env" });
Since dotenv does not overwrite existing values, the first file loaded wins. This gives you a natural override hierarchy.
Environment File Hierarchy
A well-structured project uses multiple .env files to handle different environments:
.env # Shared defaults for all environments
.env.local # Local overrides (never committed)
.env.development # Development-specific defaults
.env.test # Test-specific defaults
.env.production # Production-specific defaults (committed but safe values only)
.env.development.local # Local development overrides
.env.test.local # Local test overrides
.env.production.local # Local production overrides
The loading order should be:
.env.{NODE_ENV}.local(highest priority, never committed).env.local(never committed, skipped for test environment).env.{NODE_ENV}(committed, environment-specific defaults).env(committed, shared defaults)
Here is a loader that implements this hierarchy:
// config/env-loader.js
var dotenv = require("dotenv");
var path = require("path");
var fs = require("fs");
function loadEnvFiles() {
var nodeEnv = process.env.NODE_ENV || "development";
var rootDir = path.resolve(__dirname, "..");
// Files in priority order (first loaded wins because dotenv won't overwrite)
var files = [
".env." + nodeEnv + ".local",
nodeEnv !== "test" ? ".env.local" : null,
".env." + nodeEnv,
".env"
].filter(Boolean);
var loaded = [];
files.forEach(function (file) {
var filePath = path.join(rootDir, file);
if (fs.existsSync(filePath)) {
dotenv.config({ path: filePath });
loaded.push(file);
}
});
if (process.env.LOG_LEVEL === "debug") {
console.log("Loaded env files:", loaded.join(", "));
}
}
module.exports = loadEnvFiles;
Use it as the first line in your entry point:
// app.js
require("./config/env-loader")();
// Now process.env is fully populated
var app = require("./server");
Notice that .env.local is skipped when NODE_ENV is test. This prevents your local development overrides from interfering with test assertions.
dotenv-expand for Variable References
The base dotenv package does not support variable interpolation. Install dotenv-expand to reference other variables:
npm install dotenv-expand
var dotenv = require("dotenv");
var dotenvExpand = require("dotenv-expand");
var env = dotenv.config();
dotenvExpand.expand(env);
// Now DATABASE_URL is fully resolved
console.log(process.env.DATABASE_URL);
// postgres://appuser:secret@localhost:5432/myapp
This is invaluable for constructing compound connection strings from individual components.
The .env.example File
Every project should include a .env.example file committed to version control. It documents every environment variable the application expects, with placeholder values:
# .env.example
# Copy this file to .env and fill in real values
# cp .env.example .env
# Server
PORT=3000
NODE_ENV=development
# Database (PostgreSQL)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_development
DB_USER=
DB_PASSWORD=
# Redis
REDIS_URL=redis://localhost:6379
# External APIs
STRIPE_SECRET_KEY=sk_test_...
SENDGRID_API_KEY=SG....
# Authentication
JWT_SECRET=generate-a-random-string-here
SESSION_SECRET=generate-another-random-string
# Feature flags
ENABLE_SIGNUP=true
ENABLE_BETA_FEATURES=false
This file serves three purposes: it documents what variables are needed, provides sensible defaults where possible, and gives new developers a starting point. Your README should reference it with setup instructions.
Validating Environment Variables at Startup
Never let your application run with missing or malformed configuration. Validate every required variable at startup and fail fast with a clear error message.
// config/validate-env.js
function validateEnv() {
var errors = [];
var required = [
{ name: "DB_HOST", type: "string" },
{ name: "DB_PORT", type: "port" },
{ name: "DB_NAME", type: "string" },
{ name: "DB_USER", type: "string" },
{ name: "DB_PASSWORD", type: "string" },
{ name: "JWT_SECRET", type: "string", minLength: 32 },
{ name: "PORT", type: "port", default: "3000" },
{ name: "NODE_ENV", type: "enum", values: ["development", "test", "staging", "production"] }
];
required.forEach(function (spec) {
var value = process.env[spec.name];
// Apply default if not set
if (!value && spec.default) {
process.env[spec.name] = spec.default;
value = spec.default;
}
// Check presence
if (!value) {
errors.push(spec.name + " is required but not set");
return;
}
// Type checks
if (spec.type === "port") {
var port = parseInt(value, 10);
if (isNaN(port) || port < 1 || port > 65535) {
errors.push(spec.name + " must be a valid port (1-65535), got: " + value);
}
}
if (spec.type === "enum" && spec.values.indexOf(value) === -1) {
errors.push(spec.name + " must be one of [" + spec.values.join(", ") + "], got: " + value);
}
if (spec.minLength && value.length < spec.minLength) {
errors.push(spec.name + " must be at least " + spec.minLength + " characters");
}
});
if (errors.length > 0) {
console.error("\n=== Environment Validation Failed ===");
errors.forEach(function (err) {
console.error(" - " + err);
});
console.error("\nSee .env.example for required variables.\n");
process.exit(1);
}
}
module.exports = validateEnv;
// app.js
require("./config/env-loader")();
require("./config/validate-env")();
// Application is safe to start
var server = require("./server");
This pattern catches configuration problems immediately. A missing variable at startup is far better than a crash at 3 AM when a code path finally tries to read an unset value.
Type Coercion Patterns
Environment variables are always strings. You need explicit coercion for booleans, numbers, and arrays:
// config/index.js
require("./env-loader")();
require("./validate-env")();
var config = {
port: parseInt(process.env.PORT, 10) || 3000,
db: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10) || 5432,
name: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
pool: {
min: parseInt(process.env.DB_POOL_MIN, 10) || 2,
max: parseInt(process.env.DB_POOL_MAX, 10) || 10
}
},
features: {
signup: process.env.ENABLE_SIGNUP === "true",
beta: process.env.ENABLE_BETA_FEATURES === "true"
},
cors: {
origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean)
},
logLevel: process.env.LOG_LEVEL || "info",
isProduction: process.env.NODE_ENV === "production",
isDevelopment: process.env.NODE_ENV === "development"
};
module.exports = config;
Import this config module everywhere instead of reading process.env directly. This gives you one place to manage defaults, coercion, and documentation.
Node.js 20+ Built-in --env-file Flag
Starting with Node.js 20.6.0, you can load .env files without any package:
node --env-file=.env app.js
node --env-file=.env --env-file=.env.local app.js
This loads the files before your application code runs, so process.env is populated by the time your first require() executes. If you are on Node.js 20 or later and want to eliminate the dotenv dependency, this is the way to do it. The trade-off is that you handle the file hierarchy in your start scripts rather than in code:
{
"scripts": {
"start": "node --env-file=.env --env-file=.env.production app.js",
"dev": "node --env-file=.env --env-file=.env.development app.js",
"test": "node --env-file=.env --env-file=.env.test app.js"
}
}
Frontend Environment Variables
Webpack DefinePlugin
Webpack replaces references to process.env.VARIABLE_NAME at build time using DefinePlugin:
// webpack.config.js
var webpack = require("webpack");
var dotenv = require("dotenv");
var env = dotenv.config().parsed || {};
// Only expose variables prefixed with APP_
var envKeys = Object.keys(env).reduce(function (acc, key) {
if (key.indexOf("APP_") === 0) {
acc["process.env." + key] = JSON.stringify(env[key]);
}
return acc;
}, {});
module.exports = {
plugins: [
new webpack.DefinePlugin(envKeys)
]
};
Use a prefix like APP_ or REACT_APP_ to explicitly mark which variables are safe to expose in the browser bundle. Never expose server-side secrets to the frontend.
Vite's import.meta.env
Vite automatically loads .env files and exposes variables prefixed with VITE_ through import.meta.env:
# .env
VITE_API_URL=http://localhost:3000/api
VITE_APP_TITLE=My Application
SECRET_KEY=never-exposed # Not prefixed, not exposed
Vite handles the file hierarchy natively: .env, .env.local, .env.{mode}, .env.{mode}.local.
Docker and .env Files
Docker Compose reads .env files natively:
# docker-compose.yml
version: "3.8"
services:
app:
build: .
ports:
- "${PORT:-3000}:3000"
env_file:
- .env
- .env.${NODE_ENV:-development}
environment:
- NODE_ENV=${NODE_ENV:-development}
depends_on:
- db
- redis
db:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
pgdata:
Docker Compose also reads the .env file in the project root automatically for variable substitution in the compose file itself. The env_file directive injects variables into the container's environment.
Your Dockerfile should never contain secrets:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]
Pass environment variables at runtime, never bake them into the image.
CI/CD Environment Injection
GitHub Actions
Store secrets in the repository settings and inject them as environment variables:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
env:
NODE_ENV: test
DB_HOST: localhost
DB_PORT: 5432
DB_NAME: myapp_test
DB_USER: postgres
DB_PASSWORD: postgres
JWT_SECRET: ${{ secrets.JWT_SECRET }}
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: myapp_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to production
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
# Your deployment script here
./scripts/deploy.sh
GitLab CI
GitLab CI variables are set in the project settings and automatically available in jobs:
# .gitlab-ci.yml
stages:
- test
- deploy
test:
stage: test
image: node:20
variables:
NODE_ENV: test
DB_HOST: postgres
DB_NAME: myapp_test
services:
- postgres:16
script:
- npm ci
- npm test
deploy:
stage: deploy
script:
- ./scripts/deploy.sh
environment:
name: production
only:
- main
Both platforms mask secret values in build logs automatically.
Complete Working Example
Here is a full multi-environment setup. The project structure:
myapp/
.env
.env.example
.env.development
.env.test
.env.production
.gitignore
config/
env-loader.js
validate-env.js
index.js
docker-compose.yml
.github/workflows/deploy.yml
app.js
package.json
.env (committed, shared defaults):
PORT=3000
LOG_LEVEL=info
CORS_ORIGINS=http://localhost:3000
ENABLE_SIGNUP=true
ENABLE_BETA_FEATURES=false
.env.development (committed):
NODE_ENV=development
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_development
LOG_LEVEL=debug
.env.test (committed):
NODE_ENV=test
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_test
LOG_LEVEL=error
ENABLE_SIGNUP=true
ENABLE_BETA_FEATURES=true
.env.production (committed, no secrets):
NODE_ENV=production
DB_PORT=5432
LOG_LEVEL=warn
ENABLE_BETA_FEATURES=false
.env.local (NOT committed, developer fills from .env.example):
DB_USER=shane
DB_PASSWORD=localdevpassword
JWT_SECRET=a-long-random-string-at-least-32-chars-long
STRIPE_SECRET_KEY=sk_test_abc123
config/index.js (the unified config):
var envLoader = require("./env-loader");
var validateEnv = require("./validate-env");
envLoader();
validateEnv();
var config = {
port: parseInt(process.env.PORT, 10) || 3000,
nodeEnv: process.env.NODE_ENV || "development",
isProduction: process.env.NODE_ENV === "production",
db: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10) || 5432,
name: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
},
jwt: {
secret: process.env.JWT_SECRET
},
features: {
signup: process.env.ENABLE_SIGNUP === "true",
beta: process.env.ENABLE_BETA_FEATURES === "true"
},
cors: {
origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean)
},
logLevel: process.env.LOG_LEVEL || "info"
};
module.exports = config;
app.js:
var config = require("./config");
var express = require("express");
var app = express();
app.get("/health", function (req, res) {
res.json({
status: "ok",
environment: config.nodeEnv,
features: config.features
});
});
app.listen(config.port, function () {
console.log(
"Server running on port " + config.port +
" in " + config.nodeEnv + " mode"
);
});
Security: What Goes Where
| Variable Type | .env file | Secrets Manager |
|---|---|---|
| Port numbers | Yes | No |
| Feature flags | Yes | No |
| Log levels | Yes | No |
| Database passwords | Dev only | Production |
| API keys | Test keys only | Production keys |
| JWT secrets | Dev only | Production |
| Encryption keys | Never | Always |
| OAuth client secrets | Never | Always |
For production, use your platform's secrets management: AWS Secrets Manager, HashiCorp Vault, DigitalOcean App Platform environment variables, or your cloud provider's equivalent. The .env file is a development convenience, not a production secrets store.
.gitignore Patterns
# Environment files with secrets
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local
# Never commit the main .env if it contains secrets
# .env
# DO commit these (they should not contain secrets)
# .env.example
# .env.development
# .env.test
# .env.production
The .env.example file must always be committed. The files without .local in their name can be committed if they contain only non-sensitive defaults. Any file with .local in the name must be gitignored.
Multi-Service Environment Management
When your project has multiple services (API, worker, frontend), share common variables and separate service-specific ones:
project/
.env # Shared: DB connection, Redis, etc.
services/
api/
.env # API-specific: PORT=3000, API_RATE_LIMIT=100
worker/
.env # Worker-specific: CONCURRENCY=5, QUEUE_NAME=default
frontend/
.env # Frontend-specific: VITE_API_URL=http://localhost:3000
In your Docker Compose, merge the shared and service-specific files:
services:
api:
env_file:
- .env
- ./services/api/.env
worker:
env_file:
- .env
- ./services/worker/.env
Common Issues and Troubleshooting
Variables not loading: The most common cause is loading dotenv too late. If you require("./config/database") before require("dotenv").config(), the database module reads process.env before it is populated. Always load dotenv as the very first operation.
Values include surrounding quotes: If your .env has DB_HOST="localhost" and your code receives the literal string "localhost" (with quotes), your parser is not stripping quotes. The dotenv package handles this correctly, but some minimal parsers do not. Use the dotenv package and avoid wrapping simple values in quotes.
Variables overwriting each other: Remember that dotenv does not overwrite existing variables. If you set PORT=8080 in your shell and PORT=3000 in .env, the app uses 8080. This is usually the correct behavior, but it confuses developers who do not realize they exported a variable in their .bashrc months ago. Run env | grep PORT to check.
Docker Compose variable substitution not working: Docker Compose reads the .env file in the project root for its own variable substitution (the ${VAR} syntax in the YAML file), but this is separate from the env_file directive that passes variables into containers. If substitution in the YAML file is not working, make sure your .env is in the same directory as docker-compose.yml.
Line ending issues on Windows: If you create .env files on Windows with CRLF line endings and then mount them into a Linux Docker container, the \r carriage return becomes part of the variable value. Your DB_HOST becomes localhost\r and connections fail with cryptic errors. Configure your editor to use LF line endings for .env files, or add *.env text eol=lf to your .gitattributes.
Best Practices
Fail fast on missing variables. Validate all required environment variables at application startup. A clear error message saying "JWT_SECRET is required but not set" is infinitely better than a
TypeError: Cannot read property of undefineddeep in your authentication middleware thirty minutes later.Use a single config module. Never scatter
process.env.SOME_VARcalls throughout your codebase. Centralize all environment variable access in one config module that handles defaults, coercion, and validation. This gives you one place to see every external dependency your application has.Never commit real secrets. Development and test defaults can go in committed
.env.developmentand.env.testfiles. Real API keys, database passwords, and signing secrets belong in.env.local(gitignored) during development and in your platform's secrets manager in production.Maintain
.env.examplereligiously. Every time you add a new environment variable, update.env.examplein the same commit. Treat it like a schema file. New developers should be able to copy it, fill in a few values, and have a working application.Use prefixes for frontend exposure. When building frontend bundles, only expose variables with an explicit prefix (
VITE_,REACT_APP_,NEXT_PUBLIC_, or a customAPP_prefix). This prevents accidental leakage of server-side secrets into client-side JavaScript bundles.Keep environment parity. Your development, staging, and production environments should use the same variable names with different values. If production uses
DATABASE_URLand development usesDB_CONNECTION_STRING, you are creating unnecessary divergence that will cause deployment bugs.Document variable dependencies. When a variable has constraints (minimum length, must be a URL, requires a specific format), document these in
.env.examplewith comments. Your validation module should enforce the same constraints programmatically.
References
- dotenv GitHub repository - The original Node.js dotenv package
- dotenv-expand - Variable expansion plugin for dotenv
- Node.js --env-file documentation - Built-in .env file support
- Docker Compose environment variables - Official Docker documentation
- GitHub Actions encrypted secrets - CI/CD secret management
- The Twelve-Factor App: Config - The foundational methodology for environment-based configuration