Configuration in Full: Properties, YAML, Binding and Profiles
No Spring Boot magic in this article — just a plain @Component and a handful of annotations — so that you can answer one question confidently: where does a configuration value come from, and why did my file edit do nothing?
When a Spring Boot app starts it reads configuration from many places at once: the application.yml inside the jar, a second copy you dropped next to the jar, environment variables on the server, -D flags, and finally --server.port=9090 typed at the end of the command line. When the same key (say server.port) appears in several of them, Spring Boot does not complain and does not "merge" anything — it asks a fixed list of sources in order, and the first one that answers wins. This article covers four things: where config can live, which layer shadows which, how to get a value into a Java field (binding), and how Profiles let one codebase carry a different value set per environment.
configuration precedence is a chain of authority. Your household rules (the application.yml inside the jar) govern daily life; company policy (an external config/ file and environment variables on the server) outranks the household; national law (command-line arguments) outranks everyone. For a rule as ordinary as "what time do you get home", the more specific and the closer to the present moment, the more it wins — which is why --server.port=9090 on the command line makes every file edit irrelevant: law beats household rules.
Profiles are one apartment with three sets of furniture. The bare shell (application.yml) is the fixed skeleton — wiring and layout never move; home mode (application-dev.yml) adds a rug and warm lights; rental mode (application-prod.yml) swaps in durable furniture and locks the storage room. The structure is identical; only "the one set of values for this mode" changes. Activating prod simply switches the apartment into its rental arrangement.
After this article you should be able to answer three questions:
- I edited the
application.ymlsitting next to the jar — why does the service still come up on the old port? Which layer shadows it? @Value("${bee.max-size}")and@ConfigurationProperties(prefix = "bee")— does the same environment variable work for both? Why do the answers differ?- If
spring.profiles.activeis off by one letter in production, what happens, and how do I prove for myself that the profile really took effect?
Spring Boot lets you carry configuration in three file types, all of which are ultimately parsed into the same PropertySource. Choosing among them is mostly a team habit, but the differences are real:
| Carrier | Readability | Hierarchy | Lists | Best for |
|---|---|---|---|---|
application.properties | Flat; long lists read well | a.b.c= dot notation gets verbose when deep | list[0]=x is awkward | Simple, small configs |
application.yml | Indentation shows structure | Native tree | - x is obvious | The mainstream choice |
application.yaml | Identical to yml | Same | Same | Only the file name differs |
The same config in two styles:
# application.propertiessms.endpoint=https://sms.example.com/apisms.sign-name=Acme Inc.sms.retry.attempts=3sms.retry.enabled=true# application.ymlsms: endpoint: https://sms.example.com/api sign-name: Acme Inc. retry: attempts: 3 enabled: true- yml expresses hierarchy with indentation, so you see at a glance which keys belong to which prefix; properties joins keys with dots, which gets hard to read as the depth grows
- yml expresses lists more naturally:
hosts:\n - a\n - b, versus the clunkyhosts[0]=ain properties
Trap: YAML indentation allows spaces only, never tabs. Copying config from a web page is the classic way to smuggle a Tab character in, and startup fails with mapping values are not allowed here or found character '\t' that cannot start any token — errors that never mention "tab", leaving you baffled. Remember too: a colon must be followed by a space (key: value); key:value is treated as a plain string.
This is the table most worth internalizing. Spring Boot gathers configuration from many places, and when the same key appears in several, the highest-priority layer wins (note: not "the later overrides the earlier" but "the first match by priority is taken"):

| Priority | Source | Notes |
|---|---|---|
| 1 | Command-line arguments | java -jar app.jar --server.port=9090, the highest |
| 2 | SPRING_APPLICATION_JSON | A JSON blob placed in an environment variable |
| 3 | ServletConfig / ServletContext init params | Container level |
| 4 | JNDI (java:comp/env) | Traditional app servers |
| 5 | Java system properties | System.getProperties(), i.e. -Dkey=value |
| 6 | OS environment variables | SERVER_PORT=9090 |
| 7 | random.* | Random value source |
| 8 | External profile-specific file under config/ | ./config/application-prod.yml |
| 9 | External profile-specific file | ./application-prod.yml |
| 10 | Packaged profile-specific file | classpath:/application-prod.yml |
| 11 | External generic file under config/ | ./config/application.yml |
| 12 | External generic file | ./application.yml |
| 13 | Packaged generic file | classpath:/application.yml |
| 14 | Default properties | SpringApplication.setDefaultProperties(...) |
- Command line > environment variables > external files > packaged files > defaults is the memory spine
- "External" means the directory holding the jar or a
config/beneath it, used to override config without repackaging - Profile-specific files (
application-prod.yml) outrank generic files (application.yml), and both come after the packaged/external split
this explains a constant ops question — "I edited application.yml inside the container, why did nothing change?" If the start command carried --server.port=8081, that command-line layer always wins and the file edit is moot. The first step in troubleshooting config is always to ask "is it shadowed by a higher-priority layer?"
Fourteen rows is a lot to hold in your head, but a real investigation only ever walks three or four of them. Here is the same chain as boxes you can click through; box ③ carries the only idea in this section worth memorising: it is not merging, it is being asked first.

Both read config into Java, but they differ deeply:
| Aspect | @Value | @ConfigurationProperties |
|---|---|---|
| Binding | exact single-key match | loose binding, batch binding of a whole prefix |
| Complex types | needs hand-written SpEL | native List / Map / nested objects / Duration |
| Validation | none | supported via @Validated |
| SpEL | supported (it is an expression) | not supported (use @Value if you need SpEL) |
| Refresh | refreshable with @RefreshScope | needs a refresh mechanism |
| Best for | stray single values (@Value("${app.name}")) | grouped business config (strongly recommended) |
// Case one: just grabbing one stray value — @Value is more direct@Componentpublic class HealthController { @Value("${app.version:unknown}") // text after the colon is the fallback private String version;}// Case two: grouped config — always @ConfigurationProperties@Component@ConfigurationProperties(prefix = "sms")public class SmsProperties { private String endpoint; private int timeout = 3000; private Retry retry = new Retry(); public static class Retry { private boolean enabled; private int attempts; // getters / setters }}@Value("${app.version:unknown}"): text after the colon is the default; but once a key is misspelled only runtime reveals it, and loose binding is not supported@ConfigurationProperties: binds a whole prefix at once, with IDE hints, validation and testability — the default choice in engineering- The criterion is simple: one value uses
@Value, a group of values uses@ConfigurationProperties
"Relaxed binding" means a property's written form can vary while still binding to the same Java field. This reconciles three naming habits — kebab-case in yml, underscores in constants, camelCase in fields:

The comparison that diagram is making: @Value("${sms.template-id}") performs a literal exact match — write sms.templateId and it refuses; whereas @ConfigurationProperties(prefix = "sms") compares normalized names, stripping separators and lower-casing both the property name and the field name, so all five forms below land on the same field:
| Written form | Binds to field templateId? | Typical scenario |
|---|---|---|
sms.template-id | ✅ | yml (recommended) |
sms.templateId | ✅ | properties |
sms.templateid | ✅ | case is irrelevant |
SMS_TEMPLATE_ID | ✅ | environment variables (best) |
sms.template_id | ✅ | a slip in properties |
@ConfigurationProperties(prefix = "sms")public class SmsProperties { private String templateId; // all five forms above bind to it}- The prefix
prefix = "sms"also matches in relaxed form, soSMS_ENDPOINTmaps toendpointundersms - The recommended environment-variable form is all-caps with underscores:
SMS_ACCESS_KEYcorresponds tosms.access-key - One detail: environment variables match fine in all caps on Linux, but to bind a kebab-case key, underscores are safest —
SMS_ACCESS_KEYis translated tosms.access-keyautomatically
Key point: asked "why do environment variables and yml bind despite different spelling", the answer is that @ConfigurationProperties normalizes both the property name and the field name into "separators removed, lower-cased" before comparing. Understand this and you stop agonizing over "template-id or templateId" — kebab-case in yml, underscores in environment variables, both work.
The real power of @ConfigurationProperties is complex types, which @Value can hardly do:
# application.ymlapp: name: Demo hosts: # List<String> - 10.0.0.1 - 10.0.0.2 channels: # Map<String, Integer>: scalar keys and values wechat: 1 sms: 2 datasource: # nested object url: jdbc:mysql://localhost:3306/demo pool: max-size: 20 min-idle: 5 timeouts: connect: 2s # Duration read: 500ms upload: max-file-size: 10MB # DataSize@ConfigurationProperties(prefix = "app")public class AppProperties { private String name; private List<String> hosts; private Map<String, Integer> channels; private DataSource dataSource = new DataSource(); private Timeouts timeouts = new Timeouts(); public static class DataSource { private String url; private Pool pool = new Pool(); public static class Pool { private int maxSize; private int minIdle; } } public static class Timeouts { private Duration connect; // "2s" / "500ms" / "PT2S" all parse private Duration read; }}List<String>: a-list in yml,hosts[0]=with an index in propertiesMap<String, Integer>: listed directly as "key: value" pairs in ymlDataSize: parses10MB,1GBand friends intoorg.springframework.util.unit.DataSizeDuration: parses2s,500ms,PT2S; without a unit it is treated as milliseconds, so always include one
Trap: Duration and DataSize behave differently when unitless — read: 500 is taken as milliseconds, while max-file-size: 10 defaults to bytes. These "number without a unit" configs break most easily across environments; the convention is to always carry a unit, which is both safer and self-documenting.
When config is wrong, the ideal is to fail fast at startup and name the offending field. That takes a trio: @Validated + validation annotations + a validation implementation:
@Validated // 1) mandatory, or validation never runs@ConfigurationProperties(prefix = "sms")public class SmsProperties { @NotBlank // 2) non-null and not blank private String endpoint; @Min(500) @Max(10000) private int timeout = 3000; @Email private String alertEmail;}<!-- 3) add a validation implementation (explicit since Spring Boot 2.3) --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId></dependency>When config is invalid, startup fails with a message pinpointing the field:
Failed to bind properties under 'sms' to com.example.sms.autoconfigure.SmsProperties: Property: sms.endpoint Value: "" Origin: class path resource [application.yml] - 3:13 Reason: must not be blank@Validatedis the switch: without it,@NotBlank/@Minon fields simply do not run- The implementation (
hibernate-validator) has not been included by default since Spring Boot 2.3, so you must addspring-boot-starter-validation - The
Property/Value/Origin(which file, which line) in the error are gold — they locate the exact source
Tip: treat validation as the contract of your configuration. That endpoint is non-blank, that timeout lies in a sane range — write these constraints into the properties class and you set rules for the whole team: bad config is stopped at startup rather than discovered when messages fail at midnight.
Once the trio is in place, generating the properties class beats copying one — and pay particular attention to the last option: the same class can be admitted three different ways, and each emits different code.
package com.example.sms;
import java.time.Duration;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/** 配置前缀 sms:所有 sms.* 的键都绑进这个类 */
@ConfigurationProperties(prefix = "sms")
@Validated
public class SmsProperties {
/** 必填,空值启动即失败 */
@NotEmpty
private String name;
/** 配置里可以写 30s / 5m */
private Duration timeout = Duration.ofSeconds(3);
private String accessKeySecret;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getAccessKeySecret() { return accessKeySecret; }
public void setAccessKeySecret(String accessKeySecret) { this.accessKeySecret = accessKeySecret; }
public Duration getTimeout() { return timeout; }
public void setTimeout(Duration timeout) { this.timeout = timeout; }
}
# ---------------- 配套 application.yml ----------------
sms:
name: bee-order # 短横线/驼峰都能绑,推荐统一写短横线
timeout: 30s
access-key-secret: ${OSS_SECRET:} # 密钥从环境变量进来,别写进仓库Profiles are Spring's official answer to multiple environments: one codebase that loads different config and wires different beans per active profile.
First, the @Profile annotation — essentially a preset of conditional wiring:
@Configuration@Profile("dev")public class DevMailConfig { @Bean public MailSender mailSender() { return new ConsoleMailSender(); // dev: mail is only printed to the console }}@Configuration@Profile("prod")public class ProdMailConfig { @Bean public MailSender mailSender() { return new SmtpMailSender(); // prod: real SMTP sending }}A far more common approach uses profile-specific config files, folding the differences into files instead of code:
# application.yml — shared configspring: application: name: demosms: sign-name: Acme Inc.# application-dev.yml — developmentspring: datasource: url: jdbc:h2:mem:demo username: sa password: ""logging: level: com.example: debug# application-prod.yml — productionspring: datasource: url: jdbc:mysql://db.internal:3306/demo username: demo password: ${DB_PASSWORD} # injected from an environment variable, never hard-codedlogging: level: com.example: infospring.profiles.active has four common ways to activate:
| Way | Syntax | Best for |
|---|---|---|
| Config file | spring.profiles.active: prod | a safe default |
| Command-line arg | --spring.profiles.active=prod | temporary override (highest priority) |
| Environment variable | SPRING_PROFILES_ACTIVE=prod | containers / CI (recommended) |
| Code | SpringApplication.setAdditionalProfiles("prod") | programmatic setup |
Profile expressions also support logic:
# enabled in every environment except devspring: profiles: active: "!dev"@Profile("dev"): the configuration class is active only when dev is active; multiple profiles use an array@Profile({"dev", "test"})(any match wins)- Negation and combination:
@Profile("!prod")means "not prod",@Profile({"prod", "cloud"})is an "or"; complex combinations use expressions - Profile-specific files are loaded automatically with the active profile, no manual import needed
"Activate prod" fires two things at once: one file joins the configuration chain, and a batch of bean conditions turn green. This animation runs both lines together — look at frame 6, because @Profile and the profile-specific file are driven by one and the same switch:

when yml and properties coexist, both load and properties wins. If a project has both application.yml and application.properties, the same key resolves to .properties — many "I changed yml and nothing happened" cases start here, with an old .properties still lying around. Profile file names are also case-sensitive: application-Prod.yml does not match prod.
Which lines actually belong in a multi-environment setup is faster to generate than to copy. Tick "per-env blocks" alone first and watch how --- pairs with spring.config.activate.on-profile; then add datasource and logging and compare with the application-dev.yml / application-prod.yml split above; finally add the server block and see why server.port so often loses to an environment variable in production (lab prop order in Section 11 replays that chain).
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
---
spring:
config:
activate:
on-profile: prod
logging:
level: { root: WARN }
---
spring:
config:
activate:
on-profile: dev
spring:
jpa:
show-sql: trueProduction database passwords and keys must never sit in plaintext in the artifact. There are two mainstream routes:
- Jasypt in one line: add
jasypt-spring-boot-starter, writeENC(ciphertext)in config, and decrypt at startup with a key. Fits "the ciphertext must live in the config file" situations - Environment-variable injection (preferred): write only a placeholder
${DB_PASSWORD}in config and let the platform (K8s Secret, CI variable) inject the real value — the password never appears in the repository at all
spring: datasource: password: ${DB_PASSWORD} # injected at deploy time; the repo sees no real valueWriting ${DB_PASSWORD} like that is placeholder resolution: the container substitutes the real value during the binding phase, and the substituted result then goes through the precedence chain again — which means the name DB_PASSWORD itself competes on that chain.

Three rules govern resolution, and knowing them means never being stumped by "why can't my yml placeholder see the environment variable":
- The name is looked up on the precedence chain first. If
DB_PASSWORDhappens to be defined in someapplication.ymlas well, that value wins — not the OS environment variable - A colon supplies a fallback.
${DB_PASSWORD:guest}yieldsguestwhen nothing is found and never fails;${DB_PASSWORD}throws instead - Not found means an exception that names the key. The full text is
org.springframework.beans.factory.BeanCreationException: Could not resolve placeholder 'DB_PASSWORD' in value "${DB_PASSWORD}"— searching the first half finds it
a placeholder cannot reference itself into existence. Nesting like password: ${DB_PASSWORD:${DB_PASSWORD:123456}} is syntactically legal, but the moment the first level resolves the error message can collapse into an unreadable chain of ${...}. At most one level of fallback, and write the fallback value out explicitly.
prefer environment variables over config encryption whenever you can. Encryption merely swaps "plaintext" for "ciphertext that needs a key", and the key itself still has to live somewhere; environment-variable injection takes secrets out of the repository entirely and pairs cleanly with K8s Secret or Vault. Jasypt suits older systems whose deployment pipeline cannot be changed.
resolution happens before binding, so a placeholder can pull from an environment variable (${DB_PASSWORD}), from another config key (${server.port}), even from a system property. Precisely because of that, password: ${DB_PASSWORD} and password: ${spring.datasource.password} in the same yml file behave identically — yet when debugging, the former sends you to the environment while the latter sends you back inside yml to trace the reference chain.
"Before binding" is what gives these failures their particular shape, so unfold it as a single-step run: six lines of startup on the left, variables and call stack refreshing on the right. Step through and stop on beat ④ — that beat asks for a property name, not for an operating-system variable name:
// this line in application.yml: password: ${DB_PASSWORD:guest}// the first thing SpringApplication.run does: order six sources into the Environment// PropertySourcesPlaceholderConfigurer walks every ${...} it can find// down the queue it asks each source: do you hold a property named DB_PASSWORD// hit → substitute; nothing found → take guest after the colon// only the substituted string then goes to the Binder for relaxed binding and conversion| the raw line | password: ${DB_PASSWORD:guest} |
| what it is right now | one string |
| who has read it | nobody |
classpath:/application.ymlBeat ④ above is the single most frequent production-only startup failure in this chapter. Here is the real stack — do not read the analysis, just click the frame you think is guilty:
The IDE run configuration carried DB_PASSWORD, so development worked. On K8s the Secret injects SPRING_DATASOURCE_PASSWORD, startup fails in the first second, and the stack contains not one business class.
Everything above is "read once at startup, then fixed". In Spring Cloud there is sometimes a need to update config without a restart — that is where @RefreshScope comes in: it wraps the bean in a proxy, destroys and rebuilds it when config changes, so new values are read.
@RefreshScope@Component@ConfigurationProperties(prefix = "sms")public class SmsProperties { // after a config-center push, this picks up the new value}@RefreshScopemakes the bean a lazily created proxy, rebuilt only on refresh- It belongs to Spring Cloud and is rarely needed in a monolith; for now just know that "dynamic config refresh relies on it", covered later in the Spring Cloud series
| Symptom | Root cause | Fix |
|---|---|---|
| Edited yml has no effect | An application.properties sits alongside it and wins | Delete it or standardize on one carrier |
| Profile does not switch | Wrong file case (application-Prod.yml) / wrong activation | Use lowercase application-prod.yml, verify spring.profiles.active |
when yml and properties coexist, both load and .properties wins. This is not "merged into one" but two independent PropertySources ordered by priority, with properties first. The first step in cleaning up config is to search the whole project for how many application.* files exist.
profile file names are case-sensitive. application-dev.yml matches dev, application-Dev.yml does not; and spring.profiles.active=Dev is a different profile from dev. The convention is always lowercase, eliminating the possibility of a capital letter altogether.
Configuration errors share one temperament: the message never names the actual cause. mapping values are not allowed here never says "tab", Could not resolve placeholder never says "the names do not match". So rather than memorising, click once — exact wording on the left, what it is really complaining about on the right:
This chapter has nine kernel labs, and one pass in this order is enough: ① first see how "config read or not" changes the bean, then ②③④⑤ press precedence, relaxed binding, placeholders and profiles individually, ⑥ then climb up to the startup spine to find where configuration is already in place, and finally ⑦⑧⑨ reconnect it to the three underlying mechanisms — conditional wiring, how beans get admitted, and auto-configuration.
The first demo turns "config has been read" into a switch. Toggle it and watch the DataSource pool parameters (max connections, timeout) change with the config:
The second lab is the direct cure for "I edited the config and nothing happened": write the same server.port into the command line, an environment variable and a packaged file, then watch the precedence chain ask each one in turn.
The third lab turns the Section 4 table into something clickable: switch the written form of a property and see whether it still binds to the templateId field.
The fourth lab shows placeholder resolution in order, including the fallback and the failure case.
The fifth lab plays out Section 7's "one letter off" trap: the profile name has to match in two places, the activation setting and the file name. Miss either and the app falls back to the generic config — and still starts happily.
The sixth lab asks the question that comes earlier: at which step of startup is configuration actually read? The answer is before the container refreshes, and that is why Section 8's "resolution before binding" and Section 13's "the value you see in @PostConstruct is already final" are both the same fact:
The seventh lab puts @Profile back where it belongs — in conditional wiring. A configuration class that loses does not error, it just leaves a verdict line in the report, exactly like article 19:
The eighth lab untangles the two "no such bean" messages of this chapter: a properties class that declared itself but was never admitted, versus one that is not even in scan range. The wording is nearly identical:
The ninth lab answers "why does @Value work with nothing configured at all": the bean that resolves placeholders, PropertySourcesPlaceholderConfigurer, is itself the product of one auto-configuration, and here is which questions it had to pass:
Enough buttons — type the commands yourself. This console talks to the same in-browser kernel, and every reply is computed there:
cond configLoaded false means nothing until you also type restart — the switch only changes configuration, it does not refresh the Environment for you. That little sequence is the live version of the comparison table in Section 10.
final server.port = 8080why: the packaged generic file is the only layer holding a valuewhich is the default written in the application.yml of this very project
Earlier sections covered @Value and @ConfigurationProperties, but engineering actually has four roads to a value. The trade-off sits on two axes: type safety (do you get an int/Duration or a string you convert yourself) and granularity (one key at a time, or a whole prefix at once).

| Way | How it is written | Relaxed binding | Granularity | Validation | When to use it |
|---|---|---|---|---|---|
@Value("${sms.endpoint}") | field annotation | ❌ literal match only | one key | ❌ | one or two scattered values; SpEL needed |
@ConfigurationProperties(prefix = "sms") | properties class | ✅ | whole prefix | ✅ with @Validated | the default for grouped business config |
Environment env; env.getProperty("sms.endpoint") | inject the container object, read by hand | ❌ | any key, decided at runtime | ❌ | the key name is only known at runtime, or you must enumerate sources |
System.getProperty("...") / System.getenv("...") | plain JDK, bypasses Spring | ❌ | none | ❌ | only before the framework initialises (logging config, etc.) |
The third row is the one beginners never discover, because nobody shows them the code:
@Componentpublic class ConfigProbe { private final Environment env; public ConfigProbe(Environment env) { // Environment is the master switch for all configuration this.env = env; } @PostConstruct public void report() { // 1) one value: asked around the sources, the first hit wins System.out.println("server.port = " + env.getProperty("server.port")); // 2) prove whether the profile actually took effect — the first line of config debugging System.out.println("active profiles = " + Arrays.toString(env.getActiveProfiles())); // 3) how many configuration layers exist, and in which order ((AbstractEnvironment) env).getPropertySources() .forEach(ps -> System.out.println("source: " + ps.getName())); }}Trap: System.getProperty("server.port") most likely returns null in Spring Boot. It reads the JVM’s own system-property table, which only receives values written as -Dserver.port=9090; a value from application.yml is simply not in that table. Confusing the two produces the classic wrong conclusion “the value is configured but cannot be read” — use Environment or an annotation, not System.getProperty. For the same reason System.getenv("SERVER_PORT") sees only real OS environment variables, never yml.
Decision: three styles coexist in one team (@Value, a properties class, raw env.getProperty). Should they be unified?
- Unify on @Value — most obvious, smallest diff
- Business configuration goes into @ConfigurationProperties classes; only scattered framework values keep @Value
- Ban annotation-based config injection entirely and always call Environment.getProperty, so everyone "sees the truth"
Conclusion: B. A properties class can be validated with @Validated, completed by the IDE and unit-tested (just new it and set a few fields) — Environment gives you none of those three. A’s problem is that @Value does not participate in relaxed binding, so the moment one key must be overridden by an environment variable it fails silently (the comparison table in section 4 is that scene reproduced). C looks transparent but pushes conversion, defaults and validation back into hand-written code, which costs more, not less. Keep one exception: when the key name itself is only known at runtime (multi-tenant lookup by tenantId), Environment is the only option.
Analogy: the four ways are like four ways to ask for a price. @ConfigurationProperties photographs the whole menu into your phone (everything at once, and you can check for missing lines); @Value memorises one dish name and asks for it (one wrong character and the dish does not exist); Environment walks to the front desk and asks about anything on the spot (flexible, but you listen and convert the units yourself); System.getProperty rummages through an old receipt in your own pocket — it has no idea what the shop charges today.
Key point: one test decides it — is this configuration a group, and does it need constraints? If yes, properties class plus @Validated. A single scattered value is fine with @Value. Reach for Environment only when the key is chosen at runtime.
the configuration skeleton has three layers — carrier (properties / yml / yaml, all becoming PropertySources), precedence (command line > environment variables > external files > packaged files > defaults, the higher winning on the same key), and binding (@Value for single values, @ConfigurationProperties for groups with loose binding and validation). Multiple environments rely on Profiles: @Profile controls beans, application-{profile}.yml controls config, and there are four activation ways. Two iron rules to finish: always pair @ConfigurationProperties with @Validated and a validation implementation so bad config fails at startup, and inject secrets from environment variables to keep passwords out of the repository.