Configuration Management: 12-Factor App Config

Every developer has done it. You hardcode a database connection string 'just for testing,' commit it, and three months later you're rotating credentials because someone found them in a public...

Key Insights

  • Configuration is anything that varies between deploys—credentials, resource handles, and environment-specific URLs belong in environment variables, not in your codebase
  • Environment variables provide a language-agnostic, secure, and deployment-friendly approach that prevents accidental commits and enables true build-once-deploy-anywhere workflows
  • Validate all configuration at application startup and fail fast—discovering a missing database URL in production at 3 AM is a nightmare you can avoid entirely

The Config Problem

Every developer has done it. You hardcode a database connection string “just for testing,” commit it, and three months later you’re rotating credentials because someone found them in a public repository. Or you’re maintaining four different configuration files—config.dev.json, config.staging.json, config.prod.json, config.prod-eu.json—and deployments have become a game of “did we update the right file?”

The 12-factor app methodology, published by Heroku engineers in 2011, addressed this chaos directly. Factor III states simply: “Store config in the environment.” This isn’t just a stylistic preference—it’s a fundamental architectural decision that determines whether your application is truly portable or secretly coupled to specific deployment contexts.

Configuration management seems trivial until it isn’t. The difference between a smooth deployment and a 2 AM incident often comes down to how your application handles the values that change between environments.

What Counts as Configuration?

Configuration is anything that varies between deploys. This definition is precise and intentional. Your staging environment connects to a different database than production. Your development environment uses a local Redis instance. These differences are configuration.

Specifically, configuration includes:

  • Credentials: Database passwords, API keys, OAuth secrets
  • Resource handles: Database URLs, cache endpoints, message queue connections
  • Per-deploy values: CDN URLs, feature flags, external service endpoints
  • Operational parameters: Timeout values, retry counts, rate limits

What is not configuration? Internal application wiring. Your route definitions, dependency injection bindings, and internal module paths don’t change between deploys—they’re part of your codebase. If you’re tempted to make your Express router configurable per environment, you’re solving the wrong problem.

The litmus test: could you open-source your codebase right now without exposing any credentials or environment-specific details? If not, you have configuration mixed into your code.

Environment Variables: The 12-Factor Way

Environment variables are the 12-factor approach to configuration, and for good reason. They’re language-agnostic—every programming language and operating system supports them. They’re difficult to accidentally commit to version control. They’re trivial to change between deploys without touching code.

Here’s how you read environment variables in common languages:

// Node.js
const databaseUrl = process.env.DATABASE_URL;
const port = parseInt(process.env.PORT, 10) || 3000;
const debugMode = process.env.DEBUG === 'true';
# Python
import os

database_url = os.environ.get('DATABASE_URL')
port = int(os.environ.get('PORT', 3000))
debug_mode = os.environ.get('DEBUG', 'false').lower() == 'true'
// Go
package main

import (
    "os"
    "strconv"
)

func main() {
    databaseURL := os.Getenv("DATABASE_URL")
    port, _ := strconv.Atoi(os.Getenv("PORT"))
    if port == 0 {
        port = 3000
    }
    debugMode := os.Getenv("DEBUG") == "true"
}

The pattern is consistent across languages: read from the environment, provide sensible defaults where appropriate, and convert types as needed.

Practical Implementation Patterns

Reading environment variables directly throughout your codebase creates its own problems. You end up with process.env.DATABASE_URL scattered across dozens of files, no validation, and no clear picture of what configuration your application requires.

For local development, use .env files with a library like dotenv. These files should be in .gitignore—they’re for local convenience, not version control.

# .env (never commit this)
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=dev-key-not-for-production
DEBUG=true
// Load .env in development only
if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config();
}

More importantly, centralize your configuration into a validated module that fails fast on startup:

// config.js
class ConfigurationError extends Error {
  constructor(missing) {
    super(`Missing required environment variables: ${missing.join(', ')}`);
    this.name = 'ConfigurationError';
  }
}

function requireEnv(name) {
  const value = process.env[name];
  if (!value) {
    return { name, missing: true };
  }
  return { name, value, missing: false };
}

function loadConfig() {
  const required = [
    'DATABASE_URL',
    'REDIS_URL',
    'JWT_SECRET',
  ];

  const results = required.map(requireEnv);
  const missing = results.filter(r => r.missing).map(r => r.name);

  if (missing.length > 0) {
    throw new ConfigurationError(missing);
  }

  return {
    database: {
      url: process.env.DATABASE_URL,
      poolSize: parseInt(process.env.DB_POOL_SIZE, 10) || 10,
    },
    redis: {
      url: process.env.REDIS_URL,
    },
    auth: {
      jwtSecret: process.env.JWT_SECRET,
      tokenExpiry: process.env.TOKEN_EXPIRY || '24h',
    },
    server: {
      port: parseInt(process.env.PORT, 10) || 3000,
      debug: process.env.DEBUG === 'true',
    },
  };
}

module.exports = loadConfig();

This pattern gives you a single source of truth for configuration, immediate feedback when required values are missing, and typed access throughout your application.

Managing Secrets vs. Non-Secrets

Not all configuration is equally sensitive. Your JWT secret and database password require different handling than your pagination default or request timeout. Treating everything as a secret adds operational overhead; treating secrets casually creates security vulnerabilities.

For non-sensitive configuration, environment variables set directly in your deployment platform are fine. For secrets, consider dedicated secret management:

  • HashiCorp Vault: Self-hosted, powerful, complex
  • AWS Secrets Manager: Managed, integrates with AWS IAM
  • Doppler: Developer-focused, easy onboarding
  • 1Password Secrets Automation: If you’re already using 1Password

Here’s fetching secrets from AWS Secrets Manager at runtime:

const { 
  SecretsManagerClient, 
  GetSecretValueCommand 
} = require('@aws-sdk/client-secrets-manager');

async function getSecret(secretName) {
  const client = new SecretsManagerClient({ region: 'us-east-1' });
  
  const command = new GetSecretValueCommand({ SecretId: secretName });
  const response = await client.send(command);
  
  return JSON.parse(response.SecretString);
}

async function loadSecrets() {
  const dbSecrets = await getSecret('myapp/production/database');
  
  return {
    database: {
      host: dbSecrets.host,
      username: dbSecrets.username,
      password: dbSecrets.password,
    },
  };
}

The tradeoff: secret managers add latency at startup and create a runtime dependency. Cache secrets appropriately and handle secret manager unavailability gracefully.

Configuration in Containers and Orchestration

Containers and orchestration platforms are where 12-factor configuration truly shines. The principle is simple: build images once, configure at runtime.

Your Dockerfile should not contain environment-specific values:

FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# Declare expected environment variables (documentation, not values)
ENV NODE_ENV=production
ENV PORT=3000

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

Pass configuration at runtime:

docker run -d \
  -e DATABASE_URL="postgresql://prod-db:5432/app" \
  -e REDIS_URL="redis://prod-cache:6379" \
  -e JWT_SECRET="$JWT_SECRET" \
  -p 3000:3000 \
  myapp:latest

In Kubernetes, use ConfigMaps for non-sensitive configuration:

apiVersion: v1
kind: ConfigMap
metadata:
  name: myapp-config
data:
  LOG_LEVEL: "info"
  CACHE_TTL: "3600"
  FEATURE_NEW_CHECKOUT: "true"

And Secrets for sensitive values:

apiVersion: v1
kind: Secret
metadata:
  name: myapp-secrets
type: Opaque
stringData:
  DATABASE_URL: "postgresql://user:pass@db:5432/app"
  JWT_SECRET: "your-secret-key"

Reference them in your deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      containers:
        - name: myapp
          image: myapp:latest
          envFrom:
            - configMapRef:
                name: myapp-config
            - secretRef:
                name: myapp-secrets

Common Anti-Patterns and Migration Tips

The most common anti-pattern is environment-specific configuration files:

config/
  development.json
  staging.json
  production.json
  production-eu.json

This approach bakes environment knowledge into your codebase and makes adding new environments require code changes. It also tempts developers to commit secrets “just this once.”

Another anti-pattern: configuration in code comments or conditional logic:

// DON'T DO THIS
const apiUrl = process.env.NODE_ENV === 'production' 
  ? 'https://api.prod.example.com'
  : 'https://api.staging.example.com';

This hardcodes environment assumptions and makes your code lie about its dependencies.

For legacy applications, migrate incrementally:

  1. Identify all configuration touchpoints in your codebase
  2. Create a centralized config module that reads from both old config files AND environment variables, preferring environment variables
  3. Update deployment pipelines to set environment variables
  4. Remove config file references one by one
  5. Delete the config files

The goal isn’t purity—it’s reducing deployment friction and security risk. A hybrid approach during migration is acceptable; a permanent hybrid is technical debt.

Configuration management is unsexy infrastructure work. But getting it right means deployments become boring, new environments spin up in minutes, and you never again wake up to a Slack message about committed credentials. That’s worth the investment.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.