Configuration & Secrets

Note

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).

Analogy: The Restaurant Kitchen

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"
Advanced Bash Syntax

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.
War Story: The Accidental Production Wipe

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.

Shell Env
export FOO=shell
.env File
FOO=file
Compose YAML
environment: FOO=yaml
Container Process
env | grep FOO
FOO=???

4. Best Practices

  1. Git Ignore .env: Never commit secrets to Git. Add .env to your .gitignore.
  2. Use .env.example: Commit a template file with dummy values so developers know what variables are needed.
  3. 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);
    }
}