Configuration properties
@ConfigurationProperties with validation, relaxed binding, profiles per environment, and secrets that stay out of the repository.
Every outage post-mortem has a line like "the timeout was 30 seconds in staging and 3 in production". Configuration is code that nobody reviews, tests or types, and Spring Boot's answer is to make it all three: bind it to a typed object, validate it at startup, and have one well-defined order in which sources override each other. This lesson is that machinery, and the habits that keep a secret out of a git history.
Binding to a typed object
@Value("${payments.timeout}") scattered across twenty classes is the thing to stop doing. A @ConfigurationProperties type gathers one feature's settings into one place, with types and defaults:
@ConfigurationProperties(prefix = "payments")
@Validated
public record PaymentsProperties(
@NotBlank String baseUrl,
@DurationMin(millis = 100) Duration timeout,
@Min(0) @Max(10) int retries,
Fallback fallback
) {
public PaymentsProperties {
if (timeout == null) timeout = Duration.ofSeconds(3); // defaults live in the type
}
public record Fallback(boolean enabled, String provider) {}
}payments:
base-url: https://pay.internal
timeout: 3s
retries: 2
fallback:
enabled: true
provider: backupA record with one constructor binds through that constructor, so the object is immutable once built. Register it with @ConfigurationPropertiesScan on the main class (or @EnableConfigurationProperties(PaymentsProperties.class) on a configuration), then inject it like any bean. Duration binds from 3s, 250ms, PT1M; DataSize binds from 10MB; enums bind case-insensitively; nested records bind nested keys. With spring-boot-configuration-processor on the annotation processor path, the IDE completes every key and shows the Javadoc.
@Validated matters more than it looks. Without it, a typo in retries gives you 0 at runtime and a support ticket a week later. With it, the application refuses to start and prints the property, the value and the constraint. Configuration errors belong at startup, where the deploy fails and rolls back, not at the first request.
Relaxed binding
The key payments.base-url can be written several ways and they all bind to baseUrl:
| Where | Form |
|---|---|
| YAML / properties | payments.base-url (kebab, the canonical form), payments.baseUrl, payments.BASE_URL |
| Environment variable | PAYMENTS_BASEURL — upper case, dots to underscores, dashes dropped |
| System property | -Dpayments.base-url=… |
The environment-variable rule catches people: PAYMENTS_BASE_URL also works because Boot strips the underscore when it cannot match otherwise, but a list index does not: payments.hosts[0] becomes PAYMENTS_HOSTS_0. Write kebab-case in files, upper snake in the environment, and never mix forms for the same key in one repository, because grep is how the next person finds where a value is set.
Profiles
A profile is a name that activates extra configuration. application-prod.yml loads on top of application.yml when prod is active; inside one YAML file, --- separates documents and spring.config.activate.on-profile: prod scopes one to a profile:
server:
port: 8080
logging.level.com.acme: DEBUG
---
spring.config.activate.on-profile: prod
logging.level.com.acme: INFO
server.tomcat.threads.max: 400Activate with SPRING_PROFILES_ACTIVE=prod from the environment, never with spring.profiles.active inside application.yml. A profile hard-coded in the jar is a profile every environment gets, and the file that activates prod is exactly the one that should not exist on a laptop. Use profiles for environment differences only; a profile per feature flag turns into a combinatorial explosion that nobody can reason about. A feature flag is a property.
Profile groups keep the list short: spring.profiles.group.prod=prod-db,prod-kafka lets the environment say prod while the repository decides what that expands to.
Precedence
When the same key is set in several places, Boot has one fixed order. From highest to lowest, the ones that matter in a service:
- Command-line arguments (
--server.port=9000) - Java system properties (
-D) - OS environment variables
- Profile-specific files outside the jar (
./config/application-prod.yml) - Non-profile files outside the jar
- Profile-specific files inside the jar
- Non-profile files inside the jar (
application.yml) @PropertySourceon configuration classes
Two rules cover most surprises. A profile-specific file beats its non-profile sibling at the same location. And anything from the environment beats anything in the jar, which is how a container platform overrides a default without rebuilding. When a value is not what you expect, /actuator/env shows every property source in order and which one won; the answer is usually an environment variable set in a Helm chart that nobody remembered.
spring.config.import extends the list: optional:file:/etc/acme/overrides.yml pulls in a mounted file if it exists, configtree:/run/secrets/ turns a directory of files into properties, and configserver: reaches a Spring Cloud Config Server. Imported files are processed after the one that imported them, so they override it.
Secrets stay out of the repository
A password in application.yml is a password in every clone of the repository, forever, in the history. Rules that hold:
- Secrets arrive through the environment (
DATABASE_PASSWORD) or a mounted file read withconfigtree:, never through a committed file, including "encrypted" ones whose key is in the same repository. application.ymlreferences them by placeholder:password: ${DATABASE_PASSWORD}, so the file documents that a secret is needed without containing it. A missing placeholder fails startup with the name of the missing variable, which is the failure you want./actuator/envand/actuator/configpropsmask values by default in Boot 3 and later; leavemanagement.endpoint.env.show-valuesatneverunless the endpoint is behind authentication and you have a reason.- Logs must not print the bound properties object. A record's generated
toStringincludes every field; override it or keep secrets out of the record and inject them separately.
Configuration as code review
Treat application.yml as source. It has a schema (the metadata the processor generates), a validator (@Validated), and a test: a @SpringBootTest with the prod profile and properties overriding only the secrets proves the production file still binds. Boot prints unknown keys nowhere, so a typo like payments.retires: 2 is silent unless something checks; ignoreUnknownFields = false on the annotation turns unknown keys under that prefix into startup failures.
Diff configuration in review the way you diff code, and ask the same questions: what does this default become in production, who else reads this key, and what happens when the environment does not set it.
Under the hood: the environment, the binder, and why Duration works
Every source in the precedence list is a PropertySource inside one ConfigurableEnvironment, held in an ordered MutablePropertySources list. environment.getProperty("payments.timeout") walks that list from the front and returns the first hit; that walk is the precedence order, and /actuator/env prints the list in exactly that sequence. Command-line arguments become a SimpleCommandLinePropertySource at the front; System.getProperties() and System.getenv() are wrapped next; the files are loaded by ConfigDataEnvironmentPostProcessor into OriginTrackedMapPropertySources, one per file and per profile document, appended in load order so that a later profile document sits in front of the base one. Each value carries an Origin (file, line, column), which is where the Property: payments.retries / Origin: class path resource [application.yml] - 12:13 in a binding failure comes from.
Binding is a separate machine: the Binder. It takes a prefix, a Bindable target (the record's constructor, discovered through -parameters metadata, which is why Boot's parent POM turns that compiler flag on), and walks the target's components. For each it builds a ConfigurationPropertyName in canonical kebab form, then asks every source through a ConfigurationPropertySource adapter that implements relaxed binding: the adapter maps PAYMENTS_BASEURL, payments.baseUrl and payments.base-url onto the same canonical name, which is why the rules in the table above are consistent across sources. Values are strings until the ConversionService converts them: DurationStyle parses 3s and PT3S, DataSizeStyle parses 10MB, enums match case-insensitively, and a @ConfigurationPropertiesBinding converter bean adds your own types. Validation is a BindHandler that runs after the object is built, which is why @Validated failures name the bound object and every violated constraint at once rather than stopping at the first.
The binder runs when the properties bean is created, once. Nothing re-binds when a file changes; Spring Cloud's @RefreshScope re-creates the bean on a /actuator/refresh, and Boot alone does not. That is why a configtree mount that changes underneath a running service has no effect until restart, and why that is the safe default.
Walkthrough: the timeout that was 3 in production
The post-mortem line from the opening.
payments:
timeout: 30s
---
spring.config.activate.on-profile: prod
payments:
timeout: 3- Someone tuned the production timeout down after a downstream started answering fast, and wrote
3, meaning seconds. The property type wasDuration. - Boot's
DurationStyleparses a bare number as milliseconds by default (@DurationUnitchanges that per field). Production ran with a 3 ms timeout; nearly every payment call timed out; the retry policy tripled the load on the downstream. - Nothing had validated it: the record had
@DurationMin(millis = 100)on paper but@Validatedwas missing from the class, so the constraint was decoration. The service started,/actuator/healthwas green, and the first failed request was the alarm. /actuator/envshowed the winning source, the prod document, with originapplication.yml - 6:12, and/actuator/configpropsshowed the bound valuePT0.003S. Thirty seconds of reading, once someone knew where to look.- Fixes, in order:
@Validatedon the class so the deploy would have failed;@DurationUnit(ChronoUnit.SECONDS)on the field so a bare number means what humans mean; a@SpringBootTest(properties = "spring.profiles.active=prod")that binds the production file in CI; and a review rule that aDurationvalue always carries a unit.
The failure had three layers of missing defence, and a typed configuration system had all three available. The tooling was there; it was not turned on.
Try it yourself
Which value binds?
application.yml sets server.port: 8080; application-prod.yml inside the jar sets 9090; the environment has SERVER_PORT=7070; the JVM is started with --server.port=6060 and SPRING_PROFILES_ACTIVE=prod. Which port, and what is the order of the other three?
Answer
- Command-line arguments are the first property source, then the environment (7070), then the profile-specific file in the jar (9090), then the base file (8080). Remove the argument and it is 7070; unset the variable and it is 9090; deactivate the profile and it is 8080.
/actuator/envlists all four sources with the same key, in this order, and marks the first as active.
Why does the environment variable not bind?
A list property acme.hosts is set in the environment as ACME_HOSTS=a,b and works; a colleague sets ACME_HOSTS_0=a and ACME_HOSTS_1=b on another service and it also works; a third sets ACME.HOSTS[0]=a and it does not. Why?
Answer
Relaxed binding maps an environment variable to a canonical name by lowercasing and turning _ into ., with a trailing _N read as index [N]; a comma-separated single value is split for List targets by the conversion service. Dots and brackets are not legal in most shells' variable names, and Boot does not try to parse them from the environment, so the third form is simply a variable nobody reads. Use the underscore-index form, or the comma form for a simple list.
Where did the secret go?
A record AcmeProperties(String apiKey) is bound from ACME_APIKEY. A log line log.info("config: {}", props) prints AcmeProperties[apiKey=sk-live-…]. /actuator/env masks it. What masked one and not the other, and what is the fix?
Answer
Actuator's env endpoint sanitises by key name (anything matching key, secret, password, token, credentials patterns, and since Boot 3, everything unless show-values says otherwise); a record's generated toString knows nothing about that and prints every component. Fix at the source: keep secrets out of the properties record (inject the key separately, or hold it in a small type whose toString prints ****), and never log a bound properties object. A SanitizingFunction bean extends Actuator's masking; it does not reach your log statements.
Misconceptions
- "Precedence is a Boot rule about files." It is the order of
PropertySources in theEnvironment; every source, including ones you add, slots into that list, and the first match wins. - "A bare number is seconds." For
Duration, it is milliseconds unless@DurationUnitsays otherwise. Always write the unit. - "Constraints on the record validate it." Only with
@Validatedon the class (or aValidatorbean and@Validatedon the enabling configuration); without it the annotations do nothing. - "Changing a mounted config file reconfigures the service." Binding runs once at bean creation. A change needs a restart, or Spring Cloud's refresh scope.
- "
@Valueand@ConfigurationPropertiesare interchangeable."@Valueis a single placeholder with SpEL and no relaxed binding for environment variables in some forms, no metadata, no validation, no grouping. Use it for one-offs at most.
Going deeper
- Spring Boot reference, "Externalized Configuration", including the full precedence list and "Type-safe Configuration Properties".
Binder,ConfigurationPropertyNameandConfigurationPropertySourcesinorg.springframework.boot.context.properties: relaxed binding, in code.DurationStyleand@DurationUnitJavadoc;DataSizefor the byte analogue.- Spring Boot reference, "Configuration Metadata" and the annotation processor, for what the IDE completion is reading.
- Spring Cloud Config and
@RefreshScopedocumentation, for when configuration must change without a restart.