How Spring Boot configuration works: one Environment, many ordered property sources
Spring Boot configuration is many property sources merged into a single Environment. When code asks for server.port, Spring checks the sources in order and returns the first match. Configuration is never merged per file. Each key is resolved independently, so one file can win for server.port and lose for spring.datasource.url.
From lowest to highest precedence, the order is roughly:
- Defaults set in code with
SpringApplication.setDefaultProperties application.propertiesorapplication.ymlpackaged inside the jar- Profile-specific files inside the jar, such as
application-prod.yml - Config files outside the jar, with external profile-specific files above external plain ones
- OS environment variables
- Java system properties passed with
-D - Command-line arguments passed as
--key=value
The order reflects how close each source sits to the deployment. A value baked into the artifact is the most general. A flag typed at launch is the most specific, so it should win.
Take server.port as an example. Suppose the packaged application.yml sets it to 8080, application-prod.yml sets 8081, the container exports SERVER_PORT=9000, and the start script adds --server.port=9090. The application listens on 9090. If you remove the argument, the port becomes 9000. When a value surprises you, work down this list from the top before you edit files.
Spring Boot 2.4 reworked how config files are loaded and how profiles activate them. Older guides describe behaviour that no longer applies. Check the reference documentation for the exact version you run, including the full source list, which includes several entries omitted here.
# Assumed packaged config inside app.jar:
# application.yml -> server.port: 8080
# application-prod.yml -> server.port: 8081
# 1. Packaged default file only: listens on 8080
java -jar app.jar
# 2. Activate the prod profile: the profile-specific file wins, so 8081
java -jar app.jar --spring.profiles.active=prod
# 3. OS environment variable beats both packaged files: 9000
# (relaxed binding maps SERVER_PORT to server.port)
SERVER_PORT=9000 java -jar app.jar --spring.profiles.active=prod
# 4. Java system property beats the environment variable: 9500
# (-D options must come before -jar so the JVM reads them)
SERVER_PORT=9000 java -Dserver.port=9500 -jar app.jar \
--spring.profiles.active=prod
# 5. Command-line argument beats everything above: 9090
SERVER_PORT=9000 java -Dserver.port=9500 -jar app.jar \
--spring.profiles.active=prod \
--server.port=9090
# Debugging rule: when the port surprises you, check the sources from the
# highest-precedence one down (arguments, -D, env) before editing files.
Each launch adds one higher-precedence property source, and the effective server.port changes to the value from the highest-precedence source that sets it.
application.yml vs application.properties
Both files are parsed into property sources and flattened to the same dotted keys, so server.port behaves identically wherever it comes from. The choice affects how errors creep in, not what Spring can express. If both formats sit in the same location, .properties wins, which is easy to forget during a migration.
| Aspect | application.yml |
application.properties |
|---|---|---|
| Hierarchy | Nesting by indentation | Repeated dotted prefixes |
| Lists | - item entries |
Indexed keys such as app.hosts[0] |
| Multi-document | --- separator |
#--- comment separator |
| Value typing | Parser infers types | Everything is a string until binding |
| Main failure mode | Indentation and type inference | Verbosity, typos in long prefixes |
For example, app.datasource.url=jdbc:... and app.datasource.pool-size=10 in .properties become a single app: block in YAML with datasource: nested under it. YAML reads better as the key tree grows. The cost is that one misplaced indent silently moves a key under the wrong parent. The app still starts, but the property is ignored.
Type inference causes the quieter bugs. Spring Boot parses YAML with YAML 1.1 rules, so these unquoted values change type:
feature-mode: onbecomes the booleantrue. A string property then receives"true", not"on".file-mode: 0755can be read as an octal integer and arrive as493.
Quoting both values fixes this, but only if someone remembers to.
In multi-document files, later documents override earlier ones. Putting a profile-specific document above the default document reverses the intended override.
Teams that value predictability over brevity stay with .properties. Every value stays a literal string until binding converts it, and a bad line affects only itself.
# application.yml: nesting replaces repeated dotted prefixes
app:
datasource:
url: jdbc:postgresql://db.example.internal:5432/orders
pool-size: 10
hosts:
- api-1.example.internal
- api-2.example.internal
# Pitfall: unquoted YAML 1.1 scalars are typed by the parser before Spring binds them
feature-mode: on # becomes boolean true; a String property receives "true"
file-mode: 0755 # leading zero is read as octal; arrives as 493
# Fix: quote values that must stay literal strings
safe-feature-mode: "on"
safe-file-mode: "0755"
---
# Profile document goes AFTER the default one, so its values override it
spring:
config:
activate:
on-profile: prod
app:
datasource:
pool-size: 30
# Same config as application.properties (every value stays a string until binding):
# app.datasource.url=jdbc:postgresql://db.example.internal:5432/orders
# app.datasource.pool-size=10
# app.hosts[0]=api-1.example.internal
# app.hosts[1]=api-2.example.internal
# app.feature-mode=on
# app.file-mode=0755
# #---
# spring.config.activate.on-profile=prod
# app.datasource.pool-size=30
The same configuration in YAML and .properties form, showing how unquoted on and 0755 silently change type in YAML and why the profile document must come after the default one.
Spring profiles: activation, profile files, multi-document YAML and groups
A profile is a named switch that decides which configuration layers take part in the Environment. It does not replace the base configuration. When prod is active, Spring Boot loads application-prod.yml on top of application.yml. Keys in the profile file override matching keys in the base file, and every other key falls through unchanged. Keep shared defaults in the base file and put only the differences in each profile file, so the profile files stay short.
You can activate profiles with the SPRING_PROFILES_ACTIVE environment variable, the -Dspring.profiles.active=prod JVM system property, or the --spring.profiles.active=prod command-line argument. These follow the normal precedence order, so a command-line argument beats an environment variable. Container platforms usually set the environment variable, which keeps the image identical across environments.
You can also keep profile-specific values in a single file. Split it into documents with ---, and scope a document with spring.config.activate.on-profile: prod. This only works in the base file, not inside a profile-specific file.
Since 2.4, spring.config.activate.on-profile replaces the old in-document spring.profiles key. Many older blog posts and Stack Overflow answers still use the old key, and current versions reject it. Check any copied snippet against this before using it.
Profile groups bundle related profiles. spring.profiles.group.prod=proddb,prodmq activates all three when you set only prod.
To confirm the result, check the startup log. Spring Boot logs the active profiles early, or says it fell back to the default profile.
# application.yml (base file, always loaded)
# Activate with: SPRING_PROFILES_ACTIVE=prod java -jar app.jar
# or: java -jar app.jar --spring.profiles.active=prod
server:
port: 8080
app:
greeting: "Hello from defaults"
datasource-url: jdbc:postgresql://localhost:5432/orders
queue-host: localhost
spring:
profiles:
# Setting only 'prod' also activates proddb and prodmq.
# Groups must be declared in a document that is not profile-specific.
group:
prod:
- proddb
- prodmq
---
# Applies only when 'prod' is active; other keys fall through from above
spring:
config:
activate:
on-profile: prod
server:
port: 8443
---
spring:
config:
activate:
on-profile: proddb
app:
datasource-url: jdbc:postgresql://db.internal.example:5432/orders
---
spring:
config:
activate:
on-profile: prodmq
app:
queue-host: mq.internal.example
A multi-document application.yml where a prod profile group activates proddb and prodmq, and each document scoped with spring.config.activate.on-profile overrides only the keys it changes.
Externalised config: environment variables, mounted files and configtree
Once profiles are settled, the remaining question is where deploy-time values come from. The goal is one artifact with no environment-specific values built into it.
Environment variables use relaxed binding. Uppercase the property name, replace dots with underscores and drop dashes, so spring.datasource.url becomes SPRING_DATASOURCE_URL. Put list indices between underscores: my.service.hosts[0] becomes MY_SERVICE_HOSTS_0_. This works well for a few scalar values. It gets awkward for nested structures.
Mounted files come in through spring.config.import. A value such as optional:file:/etc/app/ loads properties or YAML from that location. The optional: prefix lets startup continue when the location is missing. Without it, Spring Boot fails at startup, which is what you want in production. spring.config.location replaces the default search locations. spring.config.additional-location adds to them. Set both before configuration loading starts: as an environment variable, a system property or a command-line argument.
configtree: handles one-file-per-key mounts, such as Kubernetes ConfigMaps and Secrets mounted as volumes. Each file name becomes a property key and the file contents become its value. With configtree:/etc/secrets/, the file /etc/secrets/db/password maps to db.password. Every value arrives as a string, and @ConfigurationProperties converts it to the target type.
| Mechanism | Structure | Typing | Secret handling | Leak risk | Deployment plumbing |
|---|---|---|---|---|---|
| Environment variables | Flat, awkward for lists | Strings, converted at bind time | Poor | Process environment, container inspection, crash dumps, debug endpoints | Minimal |
| Mounted YAML/properties | Full nesting | Strings, converted at bind time | Moderate (depends on file permissions) | File readable by anything that can read the mount | Volume mount plus spring.config.import |
configtree: |
One key per file | Strings, converted at bind time | Good fit for orchestrator-managed secrets | Lower: values are not in the process environment | Native Kubernetes volume mounts |
Prefer configtree: for secrets. Use environment variables for a small number of non-sensitive switches.
# Kubernetes Deployment: one image, all deploy-time config supplied from outside
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders
spec:
replicas: 2
selector:
matchLabels: { app: orders }
template:
metadata:
labels: { app: orders }
spec:
containers:
- name: orders
image: registry.example.com/orders:1.0.0 # same artifact in every environment
# Image ENTRYPOINT is assumed to be ["java", "-jar", "/app.jar"], so these become app arguments.
# Nested YAML is optional; the secret tree is required, so startup fails if it is missing.
args:
- "--spring.config.import=optional:file:/etc/app/,configtree:/etc/secrets/"
env:
# Relaxed binding: spring.datasource.url -> SPRING_DATASOURCE_URL
- name: SPRING_DATASOURCE_URL
value: "jdbc:postgresql://db.staging.internal:5432/orders"
# List index between underscores: my.service.hosts[0] -> MY_SERVICE_HOSTS_0_
- name: MY_SERVICE_HOSTS_0_
value: "inventory.staging.internal"
volumeMounts:
- { name: app-config, mountPath: /etc/app, readOnly: true } # ConfigMap holding application.yaml
- { name: db-secret, mountPath: /etc/secrets, readOnly: true } # one file per key
volumes:
- name: app-config
configMap: { name: orders-config }
- name: db-secret
secret:
secretName: orders-db
items:
- key: password
path: db/password # file /etc/secrets/db/password binds to db.password
A Kubernetes Deployment that runs one unchanged image by supplying scalar settings as relaxed-binding environment variables (including a list index), nested settings as a mounted application.yaml, and the database password as a configtree secret file that binds to db.password.
Type-safe binding and fail-fast validation with @ConfigurationProperties
@Value("${...}") is fine for one or two settings. Once a codebase has dozens of them across services, controllers and clients, nobody can say which properties the application needs, which have defaults, or what counts as a valid value. An unresolved placeholder without a default does fail when the bean is created. A value that resolves but is wrong, such as a timeout of 0, an empty URL or a pool size of -1, still passes, and the problem only appears when that code path first runs under real traffic.
A @ConfigurationProperties class fixes this by grouping a prefix into one typed object, such as a record bound to payments.client. You register it through @ConfigurationPropertiesScan or @EnableConfigurationProperties. Add @Validated and Jakarta Bean Validation constraints like @NotBlank, @Min and @NotNull, with a validation provider such as spring-boot-starter-validation on the classpath. Boot then checks the bound values during context startup. If any check fails, the application refuses to start and reports which property is wrong and why. Nested objects are validated only when the field is annotated with @Valid.
The trade-off is intentional. Each constraint turns a missing or bad setting into a failed deployment. That is correct for a database URL or signing key, but it is overkill for a cache TTL that has a sensible default. Mark a property as required only when no safe fallback exists, and put defaults for everything else in one place, either @DefaultValue or application.yml.
This is where most of the engineering time is saved. A misconfigured release fails in the deploy step, the orchestrator's readiness check never passes, and the previous version keeps serving traffic. You fix it from a startup log, not from an incident.
Inspecting the effective configuration and debugging precedence surprises
Start with the startup log. Spring Boot prints the active profiles near the top, for example a line stating which profiles are active, or that none are and it fell back to default. A missing profile accounts for a surprising share of "wrong value" reports, and checking for it takes seconds.
Asking the running application
The Actuator env endpoint lists every property source in precedence order. Requesting a single property, as in /actuator/env/spring.datasource.url, shows which source supplied the winning value and which sources it shadowed. The configprops endpoint shows what each @ConfigurationProperties bean actually bound, after relaxed binding and conversion. Together they answer "where did this come from?" without guesswork.
Common incident causes
- Host-level overrides. An environment variable such as
SPRING_DATASOURCE_URL, left over from debugging or baked into a base image, outranksapplication.yml. A forgotten--server.portin a launch script does the same. The file looks correct, but it is not the source of the value the application uses. - Several spellings of one property. Relaxed binding maps
my.api-timeout,my.apiTimeoutandMY_APITIMEOUTto the same property. When two of these spellings sit in sources of different precedence, the higher-precedence source wins, so a value set in one place can be silently replaced by a value set somewhere else. - YAML that parses but is wrong. A mis-indented key nests under the wrong parent and binds to nothing. YAML 1.1 parsing also turns unquoted
offintofalse. Neither case raises an error.
Securing the endpoints
Since Spring Boot 3.0, env and configprops mask values by default (show-values: never). Setting show-values to always, or exposing all endpoints with * on a public port, can leak credentials. Use when-authorized, put Actuator on a separate management port, and require authentication.
Recommended layout, when to avoid profiles, and impact on cost and engineering time
A layout that survives promotion
Put only non-secret defaults that are valid everywhere in the packaged application.yml, such as timeouts, pool sizes and feature defaults. Add at most a few profiles that describe how the application runs, not where. A local profile for laptops is one example, and a cloud profile for container settings is another. Inject environment-specific values at deploy time through environment variables or mounted files. This covers hostnames, endpoints, quotas and every secret. Never commit credentials, including "dev only" ones. They persist in git history and end up inside the built JAR.
When to avoid profiles
One profile per environment (dev, qa, staging, prod, prod-eu) bakes deployment topology into the artifact. A new region requires a rebuild, and the profile files drift apart. The question "what actually runs in prod?" then has no single answer, because the value is split across defaults, a profile file and runtime overrides.
| Approach | Strength | Cost |
|---|---|---|
| Profile per environment | Every value is reviewed in pull requests | Rebuild per topology change; drift; prod config hidden inside the artifact |
| All config in the environment | One image promoted unchanged | Lives outside the repo, so it is harder to review and audit |
| Few shape profiles plus deploy-time injection | Small, stable artifact with explicit overrides | Needs deployment plumbing and version-controlled, reviewed deploy config |
Backend and platform teams that run the same image across several environments benefit most. The engineering-time effect is qualitative but real. You rebuild less, promotion between environments becomes a configuration change instead of a new build, and fewer incidents trace back to a stale profile. In return, you pay for pipeline plumbing. You also need the discipline to review deployment configuration as carefully as application code.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring boot configuration, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.