The Problem
You create .env with your database password. You add node_modules/ to .gitignore but forget .env.
Months later, someone clones the repo and finds credentials in git history. Too late.
Even if you delete the file now, it’s there forever. Any fork, any copy, any snapshot has it.
Dotenv itself isn’t the problem. It’s how you use it.
The Rule
Never commit .env files. Ever.
Commit .env.example:
# .env.example (in git)DATABASE_URL=postgresql://user:password@localhost/mydbAPI_KEY=your-api-key-hereDEBUG=falseJWT_SECRET=your-secret-keyDevelopers copy it:
cp .env.example .envThen fill in their local values. .env is in .gitignore, so they can’t accidentally commit it.
Proper .gitignore
# Environment files (ALWAYS).env.env.local.env.development.local.env.production.local.env.test.local.env.*.local
# Alternate naming.env.secret.env.*.secretCover all variations. Your team will create .env.production.local sometimes. Block it.
For Production
Never use .env files in production. Load secrets from environment variables:
# On the serverexport DATABASE_URL="postgresql://prod-user:secure-password@prod-host/mydb"export API_KEY="production-api-key"
# Run the appnode app.jsOr use a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.):
# app.jsconst mysql = require('mysql');const {SecretsManager} = require('aws-sdk');
const sm = new SecretsManager();
async function connectToDatabase() { const secret = await sm.getSecretValue({SecretId: 'prod/db'}).promise(); const {username, password, host} = JSON.parse(secret.SecretString);
return mysql.createConnection({ host, user: username, password });}Docker example:
FROM node:20
WORKDIR /app
COPY package*.json ./RUN npm ci --only=production
COPY . .
# Don't copy .env — inject at runtimeCMD ["node", "app.js"]Run with secrets injected:
docker run \ -e DATABASE_URL="postgresql://..." \ -e API_KEY="..." \ my-app:latestIf You Already Leaked Secrets
It’s fixable but painful. You have to:
- Rotate the secret (issue new credentials)
- Rewrite git history (remove the leaked version)
- Force-push
Step 1: Rotate the Secret
Change your database password. Issue a new API key. Do this first, before history rewriting.
Step 2: Remove from Git History
Option A: Remove the file (cleanest):
git filter-repo --path .env --invert-pathsThis rewrites history, removing .env from all commits.
Option B: Redact the secrets (keeps the file, blanks the values):
# Manually edit the file to blank out secretsecho "" > .env
git add .envgit commit --amendgit filter-repo --path .envStep 3: Force-Push
Warning: This rewrites history. Notify your team.
git push --force-with-lease origin mainAll developers must pull the rewritten history:
git reset --hard origin/mainBest Practices by Environment
Local Development
# .env (in .gitignore)DATABASE_URL=postgresql://dev:dev@localhost/mydbAPI_KEY=dev-key-12345DEBUG=trueRun the app:
node app.jsStaging
Load from environment:
# GitLab CIvariables: DATABASE_URL: $STAGING_DATABASE_URL # Set in CI/CD variables API_KEY: $STAGING_API_KEY
script: - npm startOr Docker:
docker run \ -e DATABASE_URL="$STAGING_DATABASE_URL" \ -e API_KEY="$STAGING_API_KEY" \ staging-app:latestProduction
Same approach, with stricter controls:
# Kubernetes secretkubectl create secret generic app-secrets \ --from-literal=DATABASE_URL="postgresql://..." \ --from-literal=API_KEY="..."
# Pod mounts itenv: - name: DATABASE_URL valueFrom: secretKeyRef: name: app-secrets key: DATABASE_URLTesting with .env
For tests, use a .env.test:
# .env.test (in git, safe values)DATABASE_URL=postgresql://test:test@localhost/test_dbAPI_KEY=test-keyDEBUG=falseBut don’t use .env for tests. Instead:
// test setupprocess.env.DATABASE_URL = 'postgresql://test:test@localhost/test_db';process.env.API_KEY = 'test-key';
// Now run testsOr use a test library:
module.exports = { testEnvironment: 'node', setupFilesAfterEnv: ['<rootDir>/test-setup.js']};
// test-setup.jsprocess.env.DATABASE_URL = 'postgresql://test:test@localhost/test_db';process.env.API_KEY = 'test-key';This way, .env.test doesn’t exist and tests are explicit about what they need.
Auditing: Check Your Repo
Scan your git history for secrets:
# Using truffleHogpip install truffleHogtruffleHog git file:///path/to/repo
# Or using git grepgit log -p -S 'password' | head -100Check if anything’s been leaked. If yes, rotate secrets immediately and rewrite history.
Common Mistakes
Mistake 1: Committing .env “just for now.”
Don’t. It’s never “just for now.”
Mistake 2: .env in Docker image.
# BadCOPY .env .Secrets are baked into the image. Anyone with the image has the secrets.
Mistake 3: Printing environment variables in logs.
console.log(process.env); // Leaks all secretsBe selective. Log only what you need to debug.
The Checklist
-
.env*is in.gitignore -
.env.exampleis committed (with dummy values) -
.envis never committed - Production loads secrets from environment, not
.envfiles - You ran
truffleHogon git history - Developers know to
cp .env.example .env - Tests don’t rely on
.envfiles
Do all of this, and you’re safe.
Skipping even one? You’re one forked repo away from a security incident.
Take 30 minutes. Set it up right. Never leak secrets again.