Spring Boot Profiles Done Right: Config Import, Config Trees and Kubernetes ConfigMaps
You set a value in application-prod.yaml, deployed with prod active, and the old value is still in effect. The reason is a single line of the documented precedence list: config data is item 3 and environment variables are item 5. A measured walk through the precedence stack, profile groups and multi-document files, config trees and Kubernetes ConfigMaps, and the spring.config.import ordering that overrides you rather than the other way round.
Spring Boot 4.1.1 · Spring Framework 7.0.9 · JDK 25. Every transcript below is the output of a real process, and the rankings come from asking the running application which sources held the property.
You set a value in application-prod.yaml. You deployed with prod active. The old value is still in effect. The file is definitely being loaded, because another key in it took effect. Nothing is logged.
That bug has one cause and it is not subtle once you can see it — the difficulty is that Spring Boot never volunteers the information. This article is about making it visible, and then about the two configuration mechanisms most people reach for next, spring.config.import and config trees, one of which behaves in exactly the opposite direction from what its name suggests.
Part
For you if
Covers
1 — Beginner
you use profiles and mostly trust them
the precedence list, profile-specific files, multi-document files, profile groups
2 — Intermediate
a config change had no effect
seeing the precedence stack, and the exact reason your profile file lost
3 — Advanced
you deploy to Kubernetes
config trees and ConfigMaps, spring.config.import ordering, the activation Boot refuses
Versions this was verified against. Spring Boot 4.1.1 (GA), Spring Framework 7.0.9, Eclipse Temurin JDK 25.0.4.1 LTS. Version currency checked against maven-metadata.xml on Maven Central rather than release announcements.
Companion code: spring-boot-demo, directory profiles-and-config/. Six contract tests, five captured transcripts, regenerated by scripts/run-all.sh.
Part 1 — The precedence list, and what profiles actually are
Spring Boot’s documented order, lowest precedence first. Later entries win.
The configurationProperties source Spring Boot attaches ahead of all of these, and what it does for @Value, is covered in the binding article.
Two rows carry the whole article. Item 3 is every file you write — application.yaml, application-prod.yaml, an imported config tree, a mounted ConfigMap, all of it. Item 5 is above it.
Profile-specific files
application-<profile>.yaml, loaded from the same locations as application.yaml and always overriding it. With several profiles active, last one wins: --spring.profiles.active=prod,live means application-live.yaml beats application-prod.yaml.
That ordering is real, and it is an ordering within item 3. Nothing inside item 3 can reach item 5.
Multi-document files
The same effect without multiplying files. Documents separated by ---, activated by condition:
--spring.profiles.active=prod reports all three as active and loads all three application-<name>.yaml files. Groups are resolved before config data is processed, which is why a group declared in application.yaml can still change which files get loaded.
@Profile is a different mechanism from all of this.@Profile("prod") on a bean is evaluated when the application context is built, long after config data has been resolved. It decides which beans exist; config activation decides which properties are set. They share profile names and nothing else, which is why a bean can be missing while its properties are present, and vice versa.
Part 2 — Why your profile-specific file lost
Stop reasoning, start looking
The diagnostic is about fifteen lines, and it is the single most useful thing in the companion project:
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
ConfigurationProperty property =
source.getConfigurationProperty(ConfigurationPropertyName.of(name));
if (property != null) {
// rank = position in the iteration
// property.getValue(), property.getOrigin(), source.getUnderlyingSource()
}
}
ConfigurationPropertySources.get(...) returns sources in precedence order. Collect every hit; the first is the winner and everything after it is a value that exists and lost.
Set one property from five places at once and ask:
Five sources hold the property. Four lose. Each one from a file names its line.
The bug, in two runs that differ by one variable
--- 1. prod profile active, no environment variable ---
$ java -jar profiles-and-config-1.0.0.jar --spring.profiles.active=prod
active profiles : prod, prod-db, prod-metrics
effective value : jdbc:postgresql://prod-db:5432/orders
1. jdbc:postgresql://prod-db:5432/orders <- application-prod.yaml
2. jdbc:h2:mem:default <- application.yaml
holders that lost: 1
Working exactly as intended. Now add one leftover variable and change nothing else:
--- 2. identical, plus one leftover environment variable ---
$ DEMO_DATASOURCE_URL=jdbc:postgresql://leftover:5432/orders \
java -jar profiles-and-config-1.0.0.jar --spring.profiles.active=prod
active profiles : prod, prod-db, prod-metrics
effective value : jdbc:postgresql://leftover:5432/orders
1. jdbc:postgresql://leftover:5432/orders <- systemEnvironment
2. jdbc:postgresql://prod-db:5432/orders <- application-prod.yaml
3. jdbc:h2:mem:default <- application.yaml
holders that lost: 2
The profile file was loaded. It still holds the right value. It is at rank 2.
Why the mental model feels right and is incomplete
Profile-specific files do override. They override other config data. The rule people carry — “the profile file wins” — is true within its scope and says nothing about a scope that contains it. Config data as a whole is item 3; environment variables are item 5; so the strongest file loses to the weakest variable.
Where the leftover variables come from
Every one of these is real:
A Kubernetes Deployment with an env: block that predates the ConfigMap and was never removed. envFrom a ConfigMap produces environment variables, not config data — so a migration from envFrom to a mounted volume leaves both in place and the old one wins.
A docker-compose.ymlenvironment: entry copied from a colleague.
Spring Cloud Kubernetes or a service mesh injecting SPRING_DATASOURCE_URL.
A CI runner exporting variables for a different service on the same host.
SPRING_APPLICATION_JSON, which is item 10 and beats almost everything.
The one-command diagnosis. If a property is not what the file says, go looking for a variable before you go looking for a bug: kubectl exec deploy/my-app -- env | grep -i datasource. In my experience that finds it more often than reading the config does, because the file is the thing you have already read three times.
Living with the ordering rather than fighting it
Prefer environment variables in containers and files for defaults. The precedence order was designed for exactly this shape: the image carries defaults, the deployment overrides them.
Do not set the same key in both places. If a value is per-environment, keep it out of the profile files entirely so there is only ever one source to check.
Name your variables specifically.DEMO_DATASOURCE_URL collides with nothing. SPRING_DATASOURCE_URL collides with every Spring application on the host.
Part 3 — Config trees, ConfigMaps, and an import that overrides you
What Kubernetes actually mounts
A ConfigMap mounted as a volume is not a properties file. Kubernetes writes one file per key, named after the key, containing only the value with no trailing newline:
A directory under the mount becomes a nested property, so demo/nested/value is demo.nested.value. Secrets mount identically; only the file permissions differ, which is why the same mechanism reads both and nothing in your application needs to know which it got.
optional:configtree:/etc/config/*/ reads every immediate subdirectory, which is the shape you get when several ConfigMaps are mounted under one parent.
Why this beats mounting a properties file
Per-key updates. Changing one key rewrites one file rather than replacing a document.
No parse step, so no chance of one malformed line taking out the whole file.
Values can contain anything. No escaping, no quoting, and no YAML surprises — a value of yes stays the string yes, and a version number like 1.10 stays a string rather than becoming a float.
An imported config tree outranks application.yaml and still loses to an environment variable. If you mount a ConfigMap and keep envFrom on the same Deployment — which is exactly what a half-finished migration looks like — the variables win and the ConfigMap appears to be ignored.
There is no such thing as a profile-specific config tree. No <mount>-prod convention exists. Per-environment configuration is a different ConfigMap chosen by the Deployment, not a file selected by spring.profiles.active. That is a feature rather than a gap: the environment is decided by what you deploy, not by a string baked into the image.
The import that overrides the file that imported it
This is the one that surprises people, and it surprises them in the opposite direction from the one they brace for.
The importing file declared the import and then lost to it. An imported document is processed after the document that declared it, and later documents win.
#include semantics would give the opposite. So would treating an import as a set of defaults, which is what people almost always intend. Import a company-wide common.yaml expecting your own file to override it, and every key common.yaml sets will quietly beat yours.
To get defaults-style behaviour, put your overrides somewhere that outranks config data entirely — an environment variable or a command-line argument — or declare the import from an earlier document in your own file so the ordering is explicit and visible.
The activation Spring Boot refuses
InvalidConfigDataPropertyException: Property 'spring.profiles.active' imported from
location 'class path resource [application-badactivation.yaml]' is invalid in a profile
specific resource [origin: ... application-badactivation.yaml - 12:13]
A profile that activates itself would change which files are loaded after the set of files had already been decided, so Boot refuses rather than half-applying it. spring.profiles.include carries the same restriction; spring.config.activate.on-profile is how you express the condition instead.
The long tail
Import prefixes and how they compose (optional:, file:, classpath:, configtree:), and where spring.config.import is legal at all: chapter 5
The full live property-source stack, including the configurationProperties source Boot attaches: chapter 1
Using Actuator’s /actuator/env instead of a hand-rolled endpoint, and the org.springframework.boot.context.config=TRACE log that answers “was my file read at all”: chapter 3
Should you add a precedence endpoint to your application? Probably not, and certainly not the one in the companion project — it will happily print whatever a mounted secret contains. Actuator’s /actuator/env already does this job with sanitisation, behind the management port, with authentication. Turn that on instead.
The version in the companion repository exists because it prints the losing sources too. If you copy anything from this article into a real service, copy the habit of asking the application rather than the file.
Further reading
Companion project — runnable, with every transcript above under docs/output/
No Comments yet!