Configuration & Secrets
This module explores the core principles of Configuration & Secrets, deriving solutions from first principles and hardware constraints to build world-class, production-ready expertise.
1. The 12-Factor App: Config
One of the core principles of modern software engineering is strictly separating config from code (a key tenet of the 12-Factor App methodology).
Imagine a restaurant kitchen (the Docker Image). The chefs, the ovens, and the cooking processes are identical every night. However, the exact ingredients (the Config/Environment Variables) change daily based on what the supplier delivered. You wouldn't rebuild the entire kitchen just because you swapped beef for chicken; you just hand the chef a different ingredient at the start of the shift.
You should never rebuild your Docker image just to change a database password or API URL. Instead, you inject these values at runtime using Environment Variables.
2. Injecting Variables
1. The environment Key
The most direct way. Good for non-sensitive defaults.
services:
web:
image: my-app
environment:
- DEBUG=true
- APP_ENV=production
2. The env_file Key
Load variables from a file. This is cleaner and keeps your docker-compose.yaml tidy.
services:
web:
image: my-app
env_file:
- .env
Content of .env:
DEBUG=false
DB_HOST=10.0.0.5
API_KEY=secret123
3. Variable Substitution
You can use variables inside your docker-compose.yaml file. Docker substitutes them from your shell environment or a .env file in the same directory.
services:
db:
image: "postgres:${POSTGRES_VERSION:-15}" # Default to 15 if unset
ports:
- "${HOST_PORT}:5432"
You can also use bash-like syntax to throw an error if a mandatory variable is missing:
image: "postgres:${POSTGRES_VERSION:?Error! POSTGRES_VERSION must be set}"
3. Interactive: Variable Flow
The Hierarchy of Precedence
Before seeing it in action, you must memorize the order of precedence. When the same variable is defined in multiple places, Docker Compose follows strict rules (highest precedence wins):
| Source | Precedence | Description |
|---|---|---|
docker run -e |
1 (Highest) | Directly passing variables to the container runtime overrules everything. |
Compose environment: |
2 | Variables explicitly defined in the docker-compose.yaml under the service. |
| Shell Environment | 3 | Variables exported in the terminal before running docker-compose up. |
Compose env_file: |
4 | Variables loaded from files specified in the env_file block. |
.env File |
5 (Lowest) | The default .env file read implicitly by Compose for substitution. |
A junior engineer once set APP_ENV=production in their local .env file. However, their docker-compose.yaml explicitly hardcoded APP_ENV=development under the environment: key. Believing the `.env` file had priority, they confidently ran a database truncation script. Because the YAML definition has higher precedence (Rank 2 vs Rank 5), the container booted in development mode, pointing to the dev database. Had it been the other way around, production data would have been wiped. Always know the precedence hierarchy.
See how variables travel from your machine to the container process.
4. Best Practices
- Git Ignore
.env: Never commit secrets to Git. Add.envto your.gitignore. - Use
.env.example: Commit a template file with dummy values so developers know what variables are needed. - Variable Expansion: Use
${VAR:-default}syntax in Compose to provide fallbacks.
5. Reading Env Vars in Code
Go
package main
import (
"fmt"
"os"
)
func main() {
// Read environment variable
dbHost := os.Getenv("DB_HOST")
// Set default if empty
if dbHost == "" {
dbHost = "localhost"
}
fmt.Printf("Connecting to database at %s\n", dbHost)
}
Java
public class Config {
public static void main(String[] args) {
// Read environment variable
String dbHost = System.getenv("DB_HOST");
// Handle null (default value)
if (dbHost == null) {
dbHost = "localhost";
}
System.out.println("Connecting to database at " + dbHost);
}
}