Environment Variables: Stop Hardcoding Configuration

TL;DR

Never commit secrets to Git. Use .env files locally, environment variables in production. Validate config on startup. Use dotenv for Node.js, never hardcode credentials.

I pushed my AWS credentials to GitHub. Within 10 minutes, someone used them to spin up $5,000 worth of crypto mining instances. GitHub emailed me. AWS billed me. I learned about environment variables the hard way.

Most developers hardcode configuration or commit secrets to Git. Then they get hacked, leak API keys, or can't deploy to different environments.

Here's how to manage configuration properly, with complete examples and the patterns that actually work in production.

The Problem: Hardcoded Configuration

// BAD: Hardcoded secrets
const express = require('express');
const mysql = require('mysql2');

const db = mysql.createConnection({
    host: 'prod-db.example.com',
    user: 'admin',
    password: 'SuperSecret123!',  // Committed to Git!
    database: 'production'
});

const stripe = require('stripe')('sk_live_abc123xyz');  // Live key in code!

const app = express();
app.listen(3000);

Problems:

  • Secrets visible in Git history
  • Can't change without code deploy
  • Same config for all environments
  • Easy to accidentally push secrets

The Solution: Environment Variables

// GOOD: Configuration from environment
require('dotenv').config();

const express = require('express');
const mysql = require('mysql2');

const db = mysql.createConnection({
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME
});

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();
app.listen(process.env.PORT || 3000);

Configuration lives in .env file (not committed to Git):

# .env
DB_HOST=localhost
DB_USER=dev_user
DB_PASSWORD=dev_password
DB_NAME=development
STRIPE_SECRET_KEY=sk_test_abc123xyz
PORT=3000

Setting Up Environment Variables

Node.js with dotenv

npm install dotenv
// Load at the very start of your app
require('dotenv').config();

// Or with ES modules
import 'dotenv/config';

// Now use process.env
console.log(process.env.DATABASE_URL);

Create .env file

# .env - Local development config
NODE_ENV=development
PORT=3000

# Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=myapp_dev

# Redis
REDIS_HOST=localhost
REDIS_PORT=6379

# External APIs
STRIPE_SECRET_KEY=sk_test_abc123
SENDGRID_API_KEY=SG.test123
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

# App config
JWT_SECRET=dev-jwt-secret-change-in-production
SESSION_SECRET=dev-session-secret

Add .env to .gitignore

# .gitignore
.env
.env.local
.env.*.local

Never commit .env files to Git!

Create .env.example

# .env.example - Template for other developers
NODE_ENV=development
PORT=3000

DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=

STRIPE_SECRET_KEY=
SENDGRID_API_KEY=

JWT_SECRET=
SESSION_SECRET=

Commit .env.example so other developers know what variables are needed.

Different Environments

# .env.development
NODE_ENV=development
DB_HOST=localhost
STRIPE_SECRET_KEY=sk_test_abc123

# .env.staging
NODE_ENV=staging
DB_HOST=staging-db.example.com
STRIPE_SECRET_KEY=sk_test_xyz789

# .env.production
NODE_ENV=production
DB_HOST=prod-db.example.com
STRIPE_SECRET_KEY=sk_live_real_key

Load based on environment:

const envFile = `.env.${process.env.NODE_ENV || 'development'}`;
require('dotenv').config({ path: envFile });

Configuration Validation

Fail fast if required config is missing:

// config.js
require('dotenv').config();

function requireEnv(name) {
    const value = process.env[name];

    if (!value) {
        throw new Error(`Missing required environment variable: ${name}`);
    }

    return value;
}

function getEnv(name, defaultValue) {
    return process.env[name] || defaultValue;
}

const config = {
    env: getEnv('NODE_ENV', 'development'),
    port: parseInt(getEnv('PORT', '3000')),

    database: {
        host: requireEnv('DB_HOST'),
        port: parseInt(requireEnv('DB_PORT')),
        user: requireEnv('DB_USER'),
        password: requireEnv('DB_PASSWORD'),
        name: requireEnv('DB_NAME')
    },

    redis: {
        host: requireEnv('REDIS_HOST'),
        port: parseInt(requireEnv('REDIS_PORT'))
    },

    stripe: {
        secretKey: requireEnv('STRIPE_SECRET_KEY')
    },

    jwt: {
        secret: requireEnv('JWT_SECRET'),
        expiresIn: getEnv('JWT_EXPIRES_IN', '7d')
    }
};

// Validate on startup
if (config.env === 'production' && config.jwt.secret.includes('dev')) {
    throw new Error('Production JWT secret still contains "dev"!');
}

module.exports = config;

Usage:

const config = require('./config');

const db = mysql.createConnection(config.database);

app.listen(config.port, () => {
    console.log(`Server running on port ${config.port}`);
});

App crashes on startup if required variables are missing. Good!

Type Conversion and Validation

// config.js with proper types
const config = {
    // Strings
    nodeEnv: process.env.NODE_ENV || 'development',

    // Numbers
    port: parseInt(process.env.PORT || '3000', 10),
    maxConnections: parseInt(process.env.MAX_CONNECTIONS || '100', 10),

    // Booleans
    enableCache: process.env.ENABLE_CACHE === 'true',
    debugMode: process.env.DEBUG === 'true',

    // Arrays
    allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],

    // URLs
    databaseUrl: new URL(process.env.DATABASE_URL || 'postgresql://localhost:5432/myapp'),

    // Enums
    logLevel: ['error', 'warn', 'info', 'debug'].includes(process.env.LOG_LEVEL)
        ? process.env.LOG_LEVEL
        : 'info'
};

// Validation
if (config.port < 1 || config.port > 65535) {
    throw new Error(`Invalid port: ${config.port}`);
}

if (config.maxConnections < 1) {
    throw new Error(`Invalid max connections: ${config.maxConnections}`);
}

module.exports = config;

Using Validation Libraries

With Joi

const Joi = require('joi');

const envSchema = Joi.object({
    NODE_ENV: Joi.string()
        .valid('development', 'staging', 'production')
        .default('development'),

    PORT: Joi.number()
        .port()
        .default(3000),

    DB_HOST: Joi.string()
        .required(),

    DB_PORT: Joi.number()
        .port()
        .default(5432),

    DB_USER: Joi.string()
        .required(),

    DB_PASSWORD: Joi.string()
        .required(),

    DB_NAME: Joi.string()
        .required(),

    STRIPE_SECRET_KEY: Joi.string()
        .pattern(/^sk_(test|live)_/)
        .required(),

    JWT_SECRET: Joi.string()
        .min(32)
        .required(),

    ALLOWED_ORIGINS: Joi.string()
        .default('http://localhost:3000')
}).unknown();

const { error, value: envVars } = envSchema.validate(process.env);

if (error) {
    throw new Error(`Config validation error: ${error.message}`);
}

const config = {
    env: envVars.NODE_ENV,
    port: envVars.PORT,
    database: {
        host: envVars.DB_HOST,
        port: envVars.DB_PORT,
        user: envVars.DB_USER,
        password: envVars.DB_PASSWORD,
        name: envVars.DB_NAME
    },
    stripe: {
        secretKey: envVars.STRIPE_SECRET_KEY
    },
    jwt: {
        secret: envVars.JWT_SECRET
    },
    allowedOrigins: envVars.ALLOWED_ORIGINS.split(',')
};

module.exports = config;

With envalid

const envalid = require('envalid');

const config = envalid.cleanEnv(process.env, {
    NODE_ENV: envalid.str({
        choices: ['development', 'staging', 'production'],
        default: 'development'
    }),

    PORT: envalid.port({ default: 3000 }),

    DB_HOST: envalid.host(),
    DB_PORT: envalid.port({ default: 5432 }),
    DB_USER: envalid.str(),
    DB_PASSWORD: envalid.str(),
    DB_NAME: envalid.str(),

    REDIS_HOST: envalid.host(),
    REDIS_PORT: envalid.port({ default: 6379 }),

    STRIPE_SECRET_KEY: envalid.str({
        example: 'sk_test_abc123'
    }),

    JWT_SECRET: envalid.str({ minLength: 32 }),

    ENABLE_CACHE: envalid.bool({ default: true }),

    ALLOWED_ORIGINS: envalid.str({
        default: 'http://localhost:3000',
        example: 'http://localhost:3000,https://app.example.com'
    })
});

module.exports = config;

Production Deployment

Docker

# Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --production

COPY . .

# Don't include .env in image
# Pass environment variables at runtime

CMD ["node", "server.js"]

Run with environment variables:

docker run -e NODE_ENV=production \
           -e DB_HOST=prod-db.example.com \
           -e DB_PASSWORD=secret \
           -e PORT=3000 \
           myapp

Or use env file:

docker run --env-file .env.production myapp

Docker Compose

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      DB_HOST: postgres
      DB_PORT: 5432
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: myapp
    env_file:
      - .env.production

  postgres:
    image: postgres:15
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: myapp

Kubernetes

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: app
        image: myapp:latest
        env:
        - name: NODE_ENV
          value: "production"
        - name: PORT
          value: "3000"
        - name: DB_HOST
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: db-host
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: db-password

Secrets Management

Never commit secrets. Use secret managers:

AWS Secrets Manager

const AWS = require('aws-sdk');
const secretsManager = new AWS.SecretsManager();

async function loadSecrets() {
    const { SecretString } = await secretsManager.getSecretValue({
        SecretId: process.env.SECRETS_NAME || 'myapp/production'
    }).promise();

    const secrets = JSON.parse(SecretString);

    // Override process.env with secrets
    process.env.DB_PASSWORD = secrets.DB_PASSWORD;
    process.env.JWT_SECRET = secrets.JWT_SECRET;
    process.env.STRIPE_SECRET_KEY = secrets.STRIPE_SECRET_KEY;
}

// Load secrets on startup
await loadSecrets();
require('./app');

HashiCorp Vault

const vault = require('node-vault')({
    endpoint: process.env.VAULT_ADDR,
    token: process.env.VAULT_TOKEN
});

async function loadSecrets() {
    const { data } = await vault.read('secret/data/myapp/production');

    process.env.DB_PASSWORD = data.data.DB_PASSWORD;
    process.env.JWT_SECRET = data.data.JWT_SECRET;
}

await loadSecrets();

Environment-Specific Secrets

// Load secrets based on environment
async function loadSecrets() {
    const env = process.env.NODE_ENV || 'development';

    if (env === 'production') {
        // Load from AWS Secrets Manager
        await loadFromAWS();
    } else if (env === 'staging') {
        // Load from .env.staging
        require('dotenv').config({ path: '.env.staging' });
    } else {
        // Load from .env
        require('dotenv').config();
    }
}

Common Patterns

Database URL

// Single DATABASE_URL instead of multiple variables
// DATABASE_URL=postgresql://user:pass@host:5432/dbname

const { parse } = require('pg-connection-string');

const dbConfig = parse(process.env.DATABASE_URL);

const pool = new Pool({
    host: dbConfig.host,
    port: dbConfig.port,
    user: dbConfig.user,
    password: dbConfig.password,
    database: dbConfig.database
});

Feature Flags

const config = {
    features: {
        enableNewUI: process.env.FEATURE_NEW_UI === 'true',
        enableBetaFeatures: process.env.FEATURE_BETA === 'true',
        enablePayments: process.env.FEATURE_PAYMENTS !== 'false', // Default true
        maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || '10485760') // 10MB
    }
};

// Usage
if (config.features.enableNewUI) {
    app.use('/ui', newUIRouter);
} else {
    app.use('/ui', oldUIRouter);
}

Timeouts and Limits

const config = {
    timeouts: {
        database: parseInt(process.env.DB_TIMEOUT || '5000'),
        redis: parseInt(process.env.REDIS_TIMEOUT || '2000'),
        externalAPI: parseInt(process.env.API_TIMEOUT || '10000')
    },

    limits: {
        maxRequestSize: process.env.MAX_REQUEST_SIZE || '10mb',
        maxConnections: parseInt(process.env.MAX_CONNECTIONS || '100'),
        rateLimit: parseInt(process.env.RATE_LIMIT || '100')
    }
};

Testing Configuration

// config.test.js
describe('Configuration', () => {
    beforeEach(() => {
        // Clear environment
        delete process.env.DB_HOST;
        delete process.env.DB_PASSWORD;
    });

    it('throws error for missing required variables', () => {
        expect(() => {
            require('./config');
        }).toThrow('Missing required environment variable: DB_HOST');
    });

    it('uses default values for optional variables', () => {
        process.env.DB_HOST = 'localhost';
        process.env.DB_PASSWORD = 'password';

        const config = require('./config');

        expect(config.port).toBe(3000); // Default
    });

    it('validates port range', () => {
        process.env.PORT = '99999';

        expect(() => {
            require('./config');
        }).toThrow('Invalid port');
    });

    it('parses boolean values correctly', () => {
        process.env.ENABLE_CACHE = 'true';

        const config = require('./config');

        expect(config.enableCache).toBe(true);
    });
});

The 12-Factor App Approach

From 12factor.net:

Store config in the environment

// GOOD: Environment variables
const port = process.env.PORT;
const dbUrl = process.env.DATABASE_URL;

// BAD: Config files
const config = require('./config/production.json');

Strict separation between config and code

Config varies between environments, code doesn't:

  • Development: Local database
  • Staging: Staging database
  • Production: Production database

Never commit config to source control

Use .env locally, environment variables in production.

Debugging Configuration Issues

// Print all config on startup (development only)
if (process.env.NODE_ENV !== 'production') {
    console.log('Configuration loaded:');
    console.log(JSON.stringify({
        env: config.env,
        port: config.port,
        database: {
            ...config.database,
            password: '***' // Mask sensitive values
        },
        redis: config.redis,
        features: config.features
    }, null, 2));
}

// Verify critical config
function verifyConfig() {
    const checks = [
        { name: 'Database connection', test: () => db.query('SELECT 1') },
        { name: 'Redis connection', test: () => redis.ping() },
        { name: 'Stripe API key', test: () => stripe.customers.list({ limit: 1 }) }
    ];

    for (const check of checks) {
        try {
            await check.test();
            console.log(`✓ ${check.name}`);
        } catch (error) {
            console.error(`✗ ${check.name}: ${error.message}`);
            process.exit(1);
        }
    }
}

await verifyConfig();

Common Mistakes

Mistake 1: Committing .env to Git

# BAD - .env in repository
git add .env
git commit -m "Add config"

# GOOD - .env in .gitignore
echo ".env" >> .gitignore

Mistake 2: Using .env in production

# BAD - Deploying .env file to production
scp .env.production server:/app/.env

# GOOD - Setting environment variables directly
# Via Docker, Kubernetes, or hosting platform

Mistake 3: Not validating config

// BAD - App starts with missing config
const dbPassword = process.env.DB_PASSWORD;
// Runtime error later when trying to connect

// GOOD - Fail fast on startup
if (!process.env.DB_PASSWORD) {
    throw new Error('DB_PASSWORD is required');
}

Mistake 4: String instead of number

// BAD - Port is string "3000"
const port = process.env.PORT;
app.listen(port); // Might work, but wrong type

// GOOD - Parse to number
const port = parseInt(process.env.PORT || '3000', 10);
app.listen(port);

Mistake 5: Exposing secrets in logs

// BAD - Secrets in logs
console.log('Config:', process.env);

// GOOD - Mask sensitive values
const safeConfig = {
    ...config,
    database: {
        ...config.database,
        password: '***'
    },
    jwt: {
        ...config.jwt,
        secret: '***'
    }
};
console.log('Config:', safeConfig);

Environment Variable Checklist

Before going to production:

- [ ] .env file in .gitignore
- [ ] .env.example committed (without values)
- [ ] All required variables documented
- [ ] Configuration validated on startup
- [ ] Secrets not in code or Git
- [ ] Different config for each environment
- [ ] Sensitive values masked in logs
- [ ] Team knows how to set variables
- [ ] Production secrets in secret manager
- [ ] Backup plan if config is wrong

Real Production Setup

My current approach:

// config/index.js
const envalid = require('envalid');
const dotenv = require('dotenv');

// Load .env file in development
if (process.env.NODE_ENV !== 'production') {
    const envFile = `.env.${process.env.NODE_ENV || 'development'}`;
    dotenv.config({ path: envFile });
}

// Validate environment variables
const env = envalid.cleanEnv(process.env, {
    NODE_ENV: envalid.str({
        choices: ['development', 'staging', 'production'],
        default: 'development'
    }),

    PORT: envalid.port({ default: 3000 }),

    // Database
    DATABASE_URL: envalid.url(),

    // Redis
    REDIS_URL: envalid.url(),

    // External services
    STRIPE_SECRET_KEY: envalid.str(),
    SENDGRID_API_KEY: envalid.str(),

    // Security
    JWT_SECRET: envalid.str({ minLength: 32 }),
    SESSION_SECRET: envalid.str({ minLength: 32 }),

    // Features
    ENABLE_CACHE: envalid.bool({ default: true }),
    LOG_LEVEL: envalid.str({
        choices: ['error', 'warn', 'info', 'debug'],
        default: 'info'
    })
});

module.exports = {
    env: env.NODE_ENV,
    port: env.PORT,
    isDevelopment: env.NODE_ENV === 'development',
    isProduction: env.NODE_ENV === 'production',

    database: {
        url: env.DATABASE_URL
    },

    redis: {
        url: env.REDIS_URL
    },

    stripe: {
        secretKey: env.STRIPE_SECRET_KEY
    },

    sendgrid: {
        apiKey: env.SENDGRID_API_KEY
    },

    security: {
        jwtSecret: env.JWT_SECRET,
        sessionSecret: env.SESSION_SECRET
    },

    features: {
        enableCache: env.ENABLE_CACHE
    },

    logging: {
        level: env.LOG_LEVEL
    }
};

The Bottom Line

Configuration management is essential:

Never commit secrets to Git. Use environment variables and secret managers.

Use .env files for local development. Keep them out of Git with .gitignore.

Validate config on startup. Fail fast if required variables are missing.

Use different config for each environment. Development, staging, production.

Keep production secrets in secret managers. AWS Secrets Manager, HashiCorp Vault, etc.

I learned this the hard way with a $5,000 AWS bill. Don't make the same mistake.

Set up environment variables properly from day one. Future you will thank you.