Configuration & Secrets: 12-Factor on the JVM
The staging deploy went out on a Tuesday. By Wednesday morning, two things had happened. First, the new build was writing test orders into the production database — because the database URL was baked into the jar at build time, and the "staging" artifact was just the prod artifact with a different name. Nobody had changed any code; they'd changed an environment, and the config hadn't come along. Second, while rotating the fallout, someone found the old AWS key in the repo history. It had been committed eight months earlier, "temporarily." It was still valid. It was still in every clone.
Both incidents are the same subject wearing two masks: configuration treated as code. The first is a failure of where config lives — values that vary between deploys were compiled in. The second is a failure of what config is allowed to be — a secret was stored where source lives. This post fixes both with one discipline, the 12-factor rule: config is everything likely to vary between deploys, and it lives in the environment — never in the artifact, never in the repo. We'll build a small type-safe config loader that demonstrates the precedence chain, select profiles with an env var, then run a real secret through a real git repo and watch a scanner catch it — and watch the "delete the file" fix fail. Every snippet below was compiled with javac and run on OpenJDK 21.0.3, every output block is the real output, and the fake credentials are AWS's own documented example keys, safe to print anywhere.
By the end, you should be able to answer two questions about any value in your service: where does this come from in prod? and which layer wins if two of them disagree?
The 12-factor rule in one paragraph
A 12-factor app stores config in the environment. Config is anything likely to vary between deploys: database URLs, credentials, ports, pool sizes, feature flags, the payment gateway's endpoint. Code is everything that doesn't vary. The test is brutal and simple: if you have to rebuild the jar to change a value, that value is code wearing a config costume. The staging-incident jar failed this test — the URL was a compile-time constant, so "deploying to staging" couldn't actually change it.
Keeping config in the environment buys three things. First, one artifact, many environments: the exact jar tested in staging is the jar that runs in prod; only the env vars differ. Second, no secret ever touches the repo: credentials arrive at runtime, so a cloned repo is worthless to an attacker. Third, ops can change behavior without a build: bump a pool size, flip a flag, rotate a key — restart the process, not the pipeline.
Principle: config varies between deploys; code doesn't. Anything you change per environment without changing behavior's definition is config, and it belongs outside the artifact.
The precedence chain: who wins when layers disagree
Real services don't have one source of config — they have a stack of them, and they need a deterministic answer to "which one wins." The standard chain, weakest to strongest:
The shape matters more than the exact order. CLI flags beat env vars so an operator can override one value for one process without touching the deployment. Env vars beat files so the deployment environment always has the last word over what's checked in. Files beat built-in defaults so the team shares sane values. Profiles sit low in the chain: they set the baseline per environment, and everything above them is an override for a specific deploy.
Building it: a type-safe config loader in pure JDK
Enough theory. Here is a single-file loader with no dependencies that implements the chain above, with three properties the toy examples usually skip: every key records which layer won (so "where did this value come from?" is answerable in prod), typed access fails fast at startup (a bad pool.size kills the process in the first second, not at 2 AM inside a catch block), and boolean parsing is strict (Boolean.parseBoolean silently turns typos like "ture" into false — a loader must not).
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
/**
* ConfigDemo — precedence chain:
* built-in defaults < profile values < app.properties < env vars < CLI flags
*
* Profile selection: APP_PROFILE env var (dev | staging | prod), defaults to dev.
* Env convention: APP_<NAME> maps to the dotted key, e.g. APP_DB_HOST -> db.host
* CLI convention: --key=value, e.g. --db.host=override --pool.size=99
*/
public class ConfigDemo {
public static void main(String[] args) throws Exception {
String profile = System.getenv().getOrDefault("APP_PROFILE", "dev");
Map<String, String> values = new LinkedHashMap<>();
Map<String, String> wonBy = new LinkedHashMap<>();
layer(values, wonBy, defaults(), "built-in default");
layer(values, wonBy, profileValues(profile), "profile:" + profile);
layer(values, wonBy, fileValues(Path.of("app.properties")), "app.properties");
layer(values, wonBy, envValues(), "env var");
layer(values, wonBy, cliValues(args), "--flag");
System.out.println("profile: " + profile + " (APP_PROFILE "
+ (System.getenv("APP_PROFILE") == null ? "unset -> defaulted to dev"
: "='" + System.getenv("APP_PROFILE") + "'") + ")");
System.out.printf("%-10s %-24s %s%n", "KEY", "VALUE", "WON BY");
System.out.println("-------------------------------------------------------");
for (String k : values.keySet()) {
System.out.printf("%-10s %-24s %s%n", k, values.get(k), wonBy.get(k));
}
// Type-safe access: fail fast at STARTUP, not at 2am inside a catch block.
String host = get(values, "db.host");
int port = getInt(values, "db.port");
int pool = getInt(values, "pool.size");
boolean debug = getBool(values, "debug");
System.out.println();
System.out.println("effective: jdbc:postgresql://" + host + ":" + port + "/app"
+ " pool=" + pool + " debug=" + debug);
}
static void layer(Map<String, String> values, Map<String, String> wonBy,
Map<String, String> incoming, String source) {
for (Map.Entry<String, String> e : incoming.entrySet()) {
values.put(e.getKey(), e.getValue());
wonBy.put(e.getKey(), source);
}
}
static Map<String, String> defaults() {
return Map.of("db.host", "localhost", "db.port", "5432",
"pool.size", "10", "debug", "false");
}
static Map<String, String> profileValues(String profile) {
return switch (profile) {
case "dev" -> Map.of("pool.size", "5", "debug", "true");
case "staging" -> Map.of("db.host", "staging-db.internal", "pool.size", "20");
case "prod" -> Map.of("db.host", "prod-db.internal", "pool.size", "50");
default -> throw new IllegalArgumentException(
"unknown profile '" + profile + "' (expected dev|staging|prod)");
};
}
static Map<String, String> fileValues(Path path) throws IOException {
if (!Files.exists(path)) return Map.of();
Properties p = new Properties();
try (InputStream in = Files.newInputStream(path)) { p.load(in); }
Map<String, String> m = new LinkedHashMap<>();
p.forEach((k, v) -> m.put(k.toString().trim(), v.toString().trim()));
return m;
}
static Map<String, String> envValues() {
Map<String, String> m = new LinkedHashMap<>();
for (Map.Entry<String, String> e : System.getenv().entrySet()) {
String name = e.getKey();
if (name.startsWith("APP_") && !name.equals("APP_PROFILE")) {
String key = name.substring(4).toLowerCase().replace('_', '.');
m.put(key, e.getValue());
}
}
return m;
}
static Map<String, String> cliValues(String[] args) {
Map<String, String> m = new LinkedHashMap<>();
for (String a : args) {
if (a.startsWith("--")) {
String[] kv = a.substring(2).split("=", 2);
if (kv.length == 2) m.put(kv[0], kv[1]);
}
}
return m;
}
static String get(Map<String, String> m, String key) {
String v = m.get(key);
if (v == null) throw new IllegalArgumentException("missing config key '" + key + "'");
return v;
}
static int getInt(Map<String, String> m, String key) {
try {
return Integer.parseInt(get(m, key));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(
"config key '" + key + "' must be an integer, got '" + m.get(key) + "'", e);
}
}
static boolean getBool(Map<String, String> m, String key) {
String v = get(m, key);
if (v.equalsIgnoreCase("true")) return true;
if (v.equalsIgnoreCase("false")) return false;
throw new IllegalArgumentException(
"config key '" + key + "' must be true|false, got '" + v + "'");
}
}
The checked-in properties file holds only non-secret, team-shared values. Note what's deliberately absent: db.host isn't here, because a hostname varies per environment — it belongs to the profile layer or higher, never to a shared file:
# app.properties — checked in; NON-secret defaults shared by the team.
# (db.host is intentionally NOT here: it varies per environment, so it
# comes from the profile layer or higher.)
db.port=5433
pool.size=25
Now run it three times — same jar, same file, different environments. Watch the "WON BY" column move right as stronger layers engage:
$ javac ConfigDemo.java && java ConfigDemo
profile: dev (APP_PROFILE unset -> defaulted to dev)
KEY VALUE WON BY
-------------------------------------------------------
db.host localhost built-in default
debug true profile:dev
pool.size 25 app.properties
db.port 5433 app.properties
effective: jdbc:postgresql://localhost:5433/app pool=25 debug=true
$ APP_PROFILE=prod APP_POOL_SIZE=60 java ConfigDemo
profile: prod (APP_PROFILE ='prod')
KEY VALUE WON BY
-------------------------------------------------------
db.host prod-db.internal profile:prod
db.port 5433 app.properties
pool.size 60 env var
debug false built-in default
effective: jdbc:postgresql://prod-db.internal:5433/app pool=60 debug=false
$ APP_PROFILE=prod APP_DB_HOST=db.acme.internal java ConfigDemo --db.host=cli-wins.example --pool.size=99
profile: prod (APP_PROFILE ='prod')
KEY VALUE WON BY
-------------------------------------------------------
db.port 5433 app.properties
pool.size 99 --flag
debug false built-in default
db.host cli-wins.example --flag
effective: jdbc:postgresql://cli-wins.example:5433/app pool=99 debug=false
Read the third run carefully — it's the whole post in six lines. APP_DB_HOST=db.acme.internal was set in the environment, and it lost: --db.host=cli-wins.example beat it because CLI flags sit above env vars. The env var wasn't ignored by accident; it was overridden by rule. That's what a precedence chain is for — when two layers disagree, there is no meeting to schedule. And notice what never appears in any run: no password, no API key, no token. Those don't ride the config chain at all. They're secrets, and secrets get their own discipline next.
Fail fast: bad config should kill the process in second one
A config loader's second job is to refuse bad values loudly at startup. Two failure modes, both demonstrated for real:
$ APP_POOL_SIZE=lots java ConfigDemo 2>&1 | head -2
Exception in thread "main" java.lang.IllegalArgumentException: config key 'pool.size' must be an integer, got 'lots'
at ConfigDemo.getInt(ConfigDemo.java:115)
$ APP_PROFILE=qa java ConfigDemo
Exception in thread "main" java.lang.IllegalArgumentException: unknown profile 'qa' (expected dev|staging|prod)
at ConfigDemo.profileValues(ConfigDemo.java:67)
(Both exited with status 1 — real failures, not warnings.) The alternative is the production classic: the bad value parses fine as a string, the pool is created with size 0 or the profile silently falls back to dev defaults, and you learn about it from the 2 AM page. Validate every value at startup, with the key name and the offending value in the message. Strict getBool exists for the same reason — a debug=ture typo must be an error, not a silent false.
Secrets: the other half of the discipline
Config rides the precedence chain. Secrets don't. A password, an API key, a private key, a token — these are config-shaped but they obey a stricter rule: never in the repo, never in the artifact, never in a log. Not "encrypted in the repo." Not "in a private repo." Never. Repos get cloned to laptops, forked, mirrored, backed up, and eventually leaked; the 2025 state of credential theft is automated scanners watching every public push within seconds. And as the next demo proves, deleting the file afterwards doesn't help — git never forgets.
Watching it happen: a real secret in a real repo
Time to stop asserting and start demonstrating. In a scratch repo I commit a class with a hardcoded credential — the keys are AWS's documented example keys, fake by design — and run the kind of pattern scan every serious team runs in CI:
$ git init -q secret-demo && cd secret-demo
$ cat BadConfig.java
public class BadConfig {
// staging AWS credentials — TODO: move to env vars
static final String AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE";
static final String AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
public static void main(String[] args) {
System.out.println("connecting with key " + AWS_ACCESS_KEY_ID.substring(0, 8) + "...");
}
}
$ grep -rEn --exclude-dir=.git 'AKIA[0-9A-Z]{16}' .
./BadConfig.java:3: static final String AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE";
Caught — by a one-line grep, before anything was even committed. (It also matched BadConfig.class as a binary match: the secret survives compilation, which matters in a moment.) This is the cheapest security control in the industry: a pre-commit or CI scan for known secret patterns — AWS key IDs, private-key headers, high-entropy tokens. Tools like gitleaks and trufflehog do this properly; the grep above is the 80% version you can run today.
Now the part teams get wrong. The key is committed, someone notices, and the "fix" is to delete the file:
$ git add BadConfig.java && git commit -qm "add staging config"
$ git rm -q BadConfig.java && git commit -qm "remove hardcoded creds (NOT ENOUGH)"
$ git log --oneline
6c126f5 remove hardcoded creds (NOT ENOUGH)
7acdb9a add staging config
$ git grep -n 'AKIA[0-9A-Z]\{16\}' $(git rev-list --all) -- '*.java'
<sha>:BadConfig.java:3: static final String AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE";
(The real output shows the full 40-character commit SHA where I've written <sha>; everything else is verbatim.) The file is gone from the working tree. The secret is still in history — reachable from commit 7acdb9a in every clone ever made. Deleting a committed secret is not remediation; rotation is. The moment a real credential touches git, you must assume it's compromised: revoke it, issue a new one, and only then clean the history (with git filter-repo or BFG — and even that can't reach clones you don't control).
The fix: names in code, values in the environment
The repaired class contains zero secrets. It contains names — and fails fast if the environment doesn't supply the values:
public class GoodConfig {
// No secrets in source. Ever. Values come from the environment.
static final String AWS_ACCESS_KEY_ID = requireEnv("AWS_ACCESS_KEY_ID");
static final String AWS_SECRET_ACCESS_KEY = requireEnv("AWS_SECRET_ACCESS_KEY");
static String requireEnv(String name) {
String v = System.getenv(name);
if (v == null || v.isBlank())
throw new IllegalStateException("missing required env var: " + name);
return v;
}
public static void main(String[] args) {
System.out.println("connecting with key " + AWS_ACCESS_KEY_ID.substring(0, 8) + "...");
}
}
Plus a .gitignore that keeps the usual suspects out of the repo — and, since the scan taught us build artifacts carry secrets too, compiled classes as well:
# secrets — never commit these
.env
*.pem
*.key
secrets/
# build artifacts
*.class
$ git add GoodConfig.java .gitignore && git commit -qm "read creds from env; ignore secret files"
$ grep -rEn --exclude-dir=.git 'AKIA[0-9A-Z]{16}' .; echo "grep exit=$? (1 = clean)"
grep exit=1 (1 = clean)
$ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY java GoodConfig
connecting with key AKIAIOSF...
The scan is clean, the program runs, and the credential existed only in the environment of that one process. Rotation is now a value change, not a code change: update the secret manager, restart the process, done. That's the operational payoff of the right-hand column in the diagram.
Break it: what hardcoded secrets actually leak
Two exhibits for the "it's just a staging key / it's a private repo / nobody will look" defense. Exhibit A — the secret survives compilation. BadConfig.class was compiled before the file was deleted, and the JVM's strings equivalent reads the constant pool straight out of the artifact:
$ strings BadConfig.class | grep -E 'AKIA|EXAMPLEKEY'
AKIAIOSFODNN7EXAMPLE
(wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Real output, from the real class file. Anyone with the jar — from a build cache, an artifact registry, a Docker layer — has the key. "We don't commit secrets, they're only in the built artifact" is the same bug with extra steps: artifacts are just as leaky as repos, and harder to rotate.
Exhibit B — even if the value is fake, the shape of the mistake teaches the scanner's limits. My grep caught AKIA… because AWS key IDs have a recognizable prefix. A database password like dbPass = "s3cr3t!" has no pattern to match — no scanner catches it reliably. That's why the rule is never commit credentials, not "never commit credentials that match a regex." The scanner is a safety net, not the policy. The policy is: values live in the environment (or a secret manager that injects them there); the repo holds only names.
Principle: a secret in source is a secret already shared. Assume every clone, every build artifact, and every backup is a copy an attacker can read — because eventually, one of them is.
Where secrets should actually live
Three tiers, in order of preference. Tier 1: a secret manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager) — the service fetches the secret at startup or it's injected as an env var by the orchestrator; rotation is centralized and auditable. Tier 2: the platform's secret store — Kubernetes Secrets (mounted as files or env), Docker Swarm secrets, your CI system's masked variables. Tier 3: env vars set by hand — acceptable for local dev and tiny deploys, with one caveat stated honestly: env vars are visible in /proc and process listings on a shared box, so they're wiring, not a vault. What never appears in any tier: a properties file in git, a constant in a class, a default password in a README, a key in a chat log that someone "will delete later."
One more habit: scan in CI, not just in your head. Add a secret scan to the pipeline so the BadConfig commit above fails the build instead of shipping. And keep a .env.example (names and dummy values, committed) next to the gitignored .env (real values, never committed) — new developers get the shape of the config without ever seeing a real credential.
Cheat sheet: the whole post in one table
| Question | One-line answer |
|---|---|
| Is this value config or code? | If it varies between deploys, it's config — it lives outside the artifact. |
| Same key set in two places — who wins? | Rightmost in the chain: defaults < profile < file < env < CLI flag. |
| How do I pick dev / staging / prod? | One env var (APP_PROFILE); the profile layer sets the per-environment baseline. |
| Where did this prod value come from? | Your loader should record the winning layer per key — "WON BY" is a feature. |
| Bad value in config — when do I find out? | At startup, second one: strict typed parsing, key name + value in the error. |
| Where do secrets go? | Secret manager → environment → requireEnv. Never the repo, never the artifact. |
| Someone committed a real key. Now what? | Rotate first (assume compromised), then clean history. Deleting the file is not a fix. |
| How do I stop it happening again? | .gitignore the secret files, .env.example for the shape, a secret scan in CI. |
Interview one-liners, if that's why you're here: "Config varies between deploys; code doesn't — if you rebuild the jar to change it, it's code." "Env beats files, flags beat env — and the loader should tell you which layer won." "Validate config at startup; a bad value should kill the process in second one, not page you at 2 AM." "Deleting a committed secret doesn't remove it from history — rotation is the fix."
What's next
You can now ship one artifact to every environment with confidence about where each value comes from — and you know why the credential never rides along in the jar. But config only covers your own values. The moment your service calls someone else's — a database, a payment API, another team's service — you're exposed to their failures: the slow response, the flaky endpoint, the dependency that's down. Knowing the timeout is 30 seconds doesn't help if you never set one.
The next post in this track, Resilience: Timeouts, Retries, Circuit Breakers & Bulkheads, builds the failure-handling toolkit: timeouts that bound every wait, retries with backoff and jitter (and the one question you must ask first — is this operation idempotent?), circuit breakers that stop hammering a dead dependency, and bulkheads that keep one slow call from sinking the whole pool.
Field check before you move on: (1) Take ConfigDemo and add a fifth layer — a second properties file app-local.properties that is gitignored and sits between app.properties and env vars; verify the "WON BY" column shows it winning over the checked-in file and losing to env. (2) Add a db.password key wired through requireEnv, commit the change, and confirm your scan stays clean — then deliberately commit a fake key in a scratch repo and practice the full incident response: rotate (revoke the fake), clean history, write the postmortem note. (3) In your own project, run a secret-pattern scan over the full git history (git grep across git rev-list --all, as above) and record what it finds — bring the list to the resilience post, where we'll talk about what a leaked credential costs when the attacker, not you, is the one retrying. Continue: Java Learning Roadmap 2026
Comments
Post a Comment