Environment Variables and Configuration Management: 12-Factor and .env
An application's config is everything that is likely to vary between deploys: database connection strings, credentials for external services, and per-deploy values such as the canonical hostname. The core claim of 12-Factor factor III is that config must be strictly separated from code and stored in environment variables. The litmus test is simple: "Could this codebase be made open source at any moment without compromising any credentials?"
1. Why Environment Variables
The 12-Factor docs point out that storing config as constants in code violates the principles: config varies across deploys while code does not. Storing config in environment variables has three benefits:
- Switch config between deploys without changing code (staging / production / local);
- Almost no chance of being accidentally committed to the repository;
- Environment variables are a language- and OS-agnostic standard.
# Example: inject config for one deploy
export DATABASE_URL="postgres://user:pass@db-host:5432/app"
export SMTP_HOST="smtp.example.com"
./bin/app
2. Using .env Files Correctly
Typing environment variables on the command line is tedious, hence the .env convention: key-value pairs live in a file that a framework or tool loads into the environment. The Node.js, Docker Compose and Laravel ecosystems all support it widely.
# .env (never committed to version control)
DATABASE_URL=postgres://user:pass@db-host:5432/app
SMTP_HOST=smtp.example.com
APP_DEBUG=false
Key discipline:
- Add
.envto.gitignoreand never commit it; - Provide a
.env.exampletemplate in the repository containing only keys and placeholder values; - Prefer real environment variables or secret management in production;
.envis best for local and CI.
Config Layering and Precedence
In real projects, config rarely comes from a single source. Following a "later wins" order, the common sources from lowest to highest are: in-code defaults, the .env file, shell environment variables, and deployment-platform injection (Docker Compose, Kubernetes ConfigMap/Secret, cloud platform variables). With this layering, you use .env for local values during development, let CI override the test database address with platform variables, and inject real credentials in production - all without touching code or build artifacts.
| Source | Precedence | Typical use |
|---|---|---|
| In-code defaults | Lowest | Fallback only; must not hold secrets |
.env file |
Medium | Local development and testing |
| Shell / platform variables | High | CI, staging, production injection |
| Secret manager | Highest | Database passwords, API keys |
A familiar failure is "it works locally but cannot reach the database in production". Most of the time, stale values in the local .env are overriding variables freshly injected by the platform. Node's dotenv does not overwrite existing environment variables by default, and Laravel's env() only falls back to a default when both are missing - both behaviors exist to keep "platform variables win". Understanding this precedence saves a lot of debugging.
4. The 12-Factor Warning About "Environment Groups"
12-Factor explicitly warns against batching config into named environment groups (the three-way development / staging / production split). As deploys multiply, you end up with combinatorial explosions like staging-2 and joes-staging, making config management brittle. The right model: each environment variable is an independent, orthogonal control, managed per deploy, scaling naturally as the app grows.
4. Secret Management
The most sensitive part of config is keys and credentials. Different scenarios call for different approaches:
Docker Compose production uses the top-level secrets block to mount files read-only into containers instead of baking them into images:
services:
web:
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
See Docker Compose production deployment.
CI/CD pipelines store secrets in the platform's secure storage (e.g. GitHub Actions Secrets) instead of hard-coding them in workflow files:
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
HOST: ${{ secrets.HOST }}
A further step is replacing long-lived keys with short-lived OIDC tokens; see Docker OIDC support for GitHub Actions.
Local development: .env plus .env.example is usually enough; for higher-compliance scenarios, bring in a secret manager such as Vault and fold key rotation into the process.
5. Connecting to the Deployment Pipeline
Configuration management is a key part of the CI/CD pipeline: build artifacts should be "environment-agnostic", with config injected at runtime. That way the same image can be promoted to staging and production without rebuilding. Related practices also appear in GitHub Actions CI/CD setup and GitLab CI/CD best practices.
A Real Migration Story
Consider a team maintaining both a Node.js API and a Laravel admin panel. Early on they hard-coded database passwords in config/database.js and config/database.php; two months after launch the source repository was accidentally made public, every credential leaked, and they had to rotate databases and reissue all keys overnight. The refactor looked like this: the repository keeps only a .env.example with complete keys and placeholders; local development uses .env; CI injects DATABASE_URL and APP_KEY via GitHub Actions Secrets; and production credentials are injected by the deployment platform as Secrets. After the change, the same image promotes from staging to production without a rebuild, rollback is just a tag switch, and the credential blast radius narrows from "the whole codebase" to "one platform permission".
6. Common Pitfalls
- Committing keys in a config file then deleting them: even after removal they remain in history;
- Stuffing all variables into one file: this violates the "independent granular control" principle;
- Logging environment variables: credentials leak with the logs; always mask them;
- Baking secrets into container images: images get shared and pulled, so the secrets are effectively public.
Pre-Launch Checklist
Running through the following few items catches most configuration incidents before they happen: does .gitignore cover .env, *.pem, config/*.local.* and similar sensitive files; does the repository contain only .env.example with no real values; do production credentials come from environment variables or a secret manager rather than code constants; does the logging framework mask password, token, Authorization and the like; and is key rotation scheduled rather than "when you remember". These checks need no extra tooling and take a few minutes, yet the payoff is that an entire set of credentials stops traveling in the open.
16IDC Note
Config management may seem trivial, but it is the foundation of environment consistency. Separate config from code, inject it via environment variables, and route secrets through dedicated channels: once these three are right, one codebase can move reliably across development, test and production, which is also what makes containerization and CI/CD possible. For independent sites and small teams, starting with .env plus platform secrets is more than enough; there is no need to adopt heavyweight secret-management infrastructure from day one.
References: https://12factor.net/config, https://github.com/motdotla/dotenv, https://docs.docker.com/compose/how-tos/use-secrets/
Source: https://12factor.net/config