Twelve-Factor Configuration

Keep config out of the build, load it once into a validated record at startup, fail fast on bad values and never print a secret.

Syntaxrecord AppConfig(int port, String databaseUrl, String databasePassword) { static AppConfig from(Map<String, String> env) { /* parse, validate, collect errors */ } } AppConfig config = AppConfig.from(System.getenv()); // once, at startup

The Twelve-Factor App is a set of rules for services that run in many environments. Its third factor is about configuration: anything that differs between deploys (database URL, credentials, external service addresses, pool sizes, feature switches) lives in the environment, not in the code or the artefact. The same JAR or image then runs in test, staging and production, and only its environment changes.

A quick test: could you make the repository public right now without leaking a credential? If not, config is in the wrong place.

What counts as config

Config is what varies per deploy. Things that are the same everywhere, such as route definitions or the mapping of exceptions to status codes, are code. Default values for tuning settings can live in code as long as the environment can override them.

A shape that works in plain Java

  1. At startup, read every setting once from the environment (and optionally system properties) into a single immutable record.
  2. Validate everything, collect all the problems, and refuse to start if there are any. A service that fails at boot with "DATABASE_URL is missing; PORT must be a number" is fixed in a minute. One that starts and fails on the first request at 3 a.m. is not.
  3. Pass the record (or the parts each class needs) through constructors. No class calls System.getenv on its own.
  4. Log the effective config at startup with secrets masked, so you can see what a pod actually ran with.

Spring Boot's @ConfigurationProperties with validation is the same pattern with less code; knowing the plain version makes it obvious what that annotation does for you.

Mistakes people make

  • Branching on the environment name. if (env.equals("prod")) scattered through code means behaviour nobody can test outside production. Make each behaviour its own setting instead: PAYMENTS_SANDBOX=false.
  • Printing secrets. A record's generated toString prints every component, password included, the first time someone logs the config. Override it.
  • Silent defaults for required values. Defaulting the database URL to localhost means a misconfigured production pod quietly talks to nothing, or to the wrong thing.
  • Config files baked into the image per environment. You then build different artefacts for each stage, and what you tested is not what you ship.

Secrets follow the same path into the process, usually from a secret manager mounted as environment variables or files, and the code never contains them.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Promote the same container image from staging to production, changing only the environment variables set by the deployment.
  • Stop a misconfigured pod at startup with one message listing every missing or invalid variable, so the rollout fails before taking traffic.
  • Print the effective, masked configuration in the first log lines so support can confirm which database and limits a pod used.

More examples

A secret type that cannot leak through toString

Wrapping the value in its own type masks it everywhere it is printed, including the generated toString of any record that contains it. Code that really needs the value must ask for it explicitly with value().

Example · java
record Secret(String value) {
    @Override public String toString() { return "****"; }
}

record DbConfig(String url, String user, Secret password) {}

void main() {
    DbConfig db = new DbConfig("jdbc:postgresql://localhost:5432/shop", "app", new Secret("fake-pass-123"));
    IO.println(db);
    IO.println("Connecting as " + db.user() + " with " + db.password());
    IO.println("length used for the driver: " + db.password().value().length());
}
// Output:
// DbConfig[url=jdbc:postgresql://localhost:5432/shop, user=app, password=****]
// Connecting as app with ****
// length used for the driver: 13

Settings instead of environment names

Every difference between deploys is a named setting with a safe default, so staging can test real e-mail sending without also turning on live payments. Flags accept only true or false, because a typo read as false could switch the payments sandbox off.

Example · java
record Features(boolean paymentsSandbox, boolean sendEmails, int rateLimitPerMinute) {
    static Features from(Map<String, String> env) {
        return new Features(
                flag(env, "PAYMENTS_SANDBOX", true),
                flag(env, "SEND_EMAILS", false),
                Integer.parseInt(env.getOrDefault("RATE_LIMIT_PER_MINUTE", "60")));
    }

    // Only true or false; a typo fails at startup instead of silently meaning false.
    static boolean flag(Map<String, String> env, String name, boolean fallback) {
        String raw = env.get(name);
        if (raw == null) return fallback;
        return switch (raw.strip().toLowerCase(Locale.ROOT)) {
            case "true" -> true;
            case "false" -> false;
            default -> throw new IllegalArgumentException(name + " must be true or false, got '" + raw + "'");
        };
    }
}

void main() {
    // Each environment is just a different set of values; the code has no "if prod".
    Map<String, Map<String, String>> deploys = new LinkedHashMap<>();
    deploys.put("local", Map.of());
    deploys.put("staging", Map.of("SEND_EMAILS", "true"));
    deploys.put("production", Map.of("PAYMENTS_SANDBOX", "false", "SEND_EMAILS", "true",
            "RATE_LIMIT_PER_MINUTE", "600"));

    deploys.forEach((name, env) -> IO.println(name + ": " + Features.from(env)));
}
// Output:
// local: Features[paymentsSandbox=true, sendEmails=false, rateLimitPerMinute=60]
// staging: Features[paymentsSandbox=true, sendEmails=true, rateLimitPerMinute=60]
// production: Features[paymentsSandbox=false, sendEmails=true, rateLimitPerMinute=600]

Logging the effective config at startup

A sorted, masked dump of the settings at startup answers "what was this pod running with?" without exposing credentials. Masking by name is a safety net; an explicit Secret type is stronger.

Example · java
static final Set<String> SECRET_WORDS = Set.of("PASSWORD", "SECRET", "TOKEN", "KEY");

boolean looksSecret(String name) {
    for (String word : SECRET_WORDS) {
        if (name.contains(word)) return true;
    }
    return false;
}

void main() {
    Map<String, String> env = Map.of(
            "PORT", "8080",
            "DATABASE_URL", "jdbc:postgresql://db:5432/shop",
            "DATABASE_PASSWORD", "fake-db-pass",
            "STRIPE_API_KEY", "sk_test_fake",
            "JWT_SECRET", "fake-jwt-secret",
            "LOG_LEVEL", "INFO");

    // TreeMap gives a stable, sorted listing that is easy to compare between pods.
    Map<String, String> shown = new TreeMap<>();
    env.forEach((k, v) -> shown.put(k, looksSecret(k) ? "****" : v));
    shown.forEach((k, v) -> IO.println(k + "=" + v));
}
// Output:
// DATABASE_PASSWORD=****
// DATABASE_URL=jdbc:postgresql://db:5432/shop
// JWT_SECRET=****
// LOG_LEVEL=INFO
// PORT=8080
// STRIPE_API_KEY=****

Discussion

  • Be the first to comment on this lesson.