All articles

// Knowledge.log — 技術記事

External configuration in Spring Boot 4.1: which value won

See why an environment variable, YAML, or CLI value won in Spring Boot 4.1, and find the value's origin with a minimal application.

You set demo.who-won in application.yml, changed the value in application-prod.yml, and also passed DEMO_WHOWON through the environment. The application started, but not with the value you expected.

The result we want is less mysterious: look at the process output and find both the winning value and its origin. Before that, we need to separate two orders that are often mixed up: the general precedence of PropertySource entries and the order of files within Config Data.

One important point up front: a valid environment variable beats any application-prod.yml. If the application seems to contradict that, the investigation should look for an incorrect name, an even stronger source, or a variable that never reached the process—not a secret profile rule.

Versions and prerequisites

The examples use Spring Boot 4.1.1 with Java 25 LTS. Boot 4.1.1 requires Java 17 and supports versions up to Java 26, according to the official system requirements. That makes Java 25 the newest LTS within the supported range.

You need Maven and a JDK 25 available on your PATH. The minimal pom.xml looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>academy.devdojo</groupId>
    <artifactId>config-winner</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>25</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

The 15 PropertySource layers

The Spring Boot 4.1 external configuration documentation defines 15 layers. The rule is simple: later sources can override earlier ones.

  1. Default properties defined with SpringApplication.setDefaultProperties(Map).
  2. @PropertySource on @Configuration classes. This source is added only during context refresh, too late to configure properties such as logging.* and spring.main.*.
  3. Config Data, including application.properties and YAML files.
  4. RandomValuePropertySource, used only by random.* properties.
  5. Operating system environment variables.
  6. Java system properties, such as those passed with -D.
  7. JNDI attributes in java:comp/env.
  8. ServletContext initialization parameters.
  9. ServletConfig initialization parameters.
  10. SPRING_APPLICATION_JSON, supplied as an environment variable or system property.
  11. Command-line arguments.
  12. The properties attribute in @SpringBootTest and slice tests.
  13. @DynamicPropertySource in tests.
  14. @TestPropertySource in tests.
  15. Global Devtools settings in $HOME/.config/spring-boot when Devtools is active.

For a typical production application, the most useful part of that order is:

Config Data < variável de ambiente < -D < SPRING_APPLICATION_JSON < CLI

So DEMO_WHOWON=from-env beats the value in application-prod.yml, but loses to -Ddemo.who-won=from-system, inline JSON, and --demo.who-won=from-cli.

The order of four files within Config Data

The third layer has its own order. Here too, the later item wins:

  1. application.properties or YAML packaged in the jar.
  2. A packaged profile-specific file, such as application-prod.yml.
  3. application.properties or YAML outside the jar.
  4. An external profile-specific file, such as application-prod.yml.

This list compares configuration files with one another; it does not raise a profile YAML above an environment variable. Mixing the two lists is a remarkably efficient way to spend half an hour arguing about precedence that does not exist.

If .properties and YAML files are in the same location, .properties takes precedence. Choosing one format per location avoids adding another unnecessary dimension to the diagnosis.

location, additional-location, import, and document activation

All four features take part in external configuration, but they solve different problems.

FeatureWhere to declare itWhat it does
spring.config.locationEnvironment, -D, or CLIReplaces the default search locations
spring.config.additional-locationEnvironment, -D, or CLIAdds locations whose files can override the defaults
spring.config.importInside Config DataImports additional documents at the declaration point; the imported document beats the importing document
spring.config.activate.on-profileIn a configuration documentActivates that document when the profile expression matches

spring.config.location and spring.config.additional-location are read very early. Putting them only inside application.yml is too late because Boot has already had to decide which files to load. It is the override that never made it to its own meeting.

Use location when you want to control the entire set of searched locations:

java -jar target/config-winner-0.0.1-SNAPSHOT.jar \
  --spring.config.location=optional:classpath:/custom-config/,optional:file:./custom-config/

In this case, the default locations no longer participate. additional-location, on the other hand, keeps the defaults and adds another location with precedence over them:

java -jar target/config-winner-0.0.1-SNAPSHOT.jar \
  --spring.config.additional-location=optional:file:./config-extra/

The same option can come from the operating system or a Java property:

SPRING_CONFIG_ADDITIONALLOCATION=optional:file:/etc/config-winner/ \
  java -jar target/config-winner-0.0.1-SNAPSHOT.jar

java -Dspring.config.additional-location=optional:file:/etc/config-winner/ \
  -jar target/config-winner-0.0.1-SNAPSHOT.jar

For directories, keep the trailing /. The optional: prefix prevents the application from failing when the location does not exist.

Prefer file: for configuration external to the artifact. An additional classpath: still points to something packaged with the application; changing the option name does not teleport the secret out of the jar.

spring.config.import, on the other hand, belongs in a file that has already been loaded:

spring:
  config:
    import: optional:file:./local-overrides.yml

The imported document is inserted immediately below the importing document, and its values take precedence over values in the file that performed the import. Each imported resource is loaded only once.

For secrets mounted as files in Kubernetes, the documented alternative is a config tree:

spring:
  config:
    import: optional:configtree:/run/secrets/

In this format, each filename becomes the key and its content becomes the value.

Finally, spring.config.activate.on-profile activates a document; it does not define a filename convention:

demo:
  who-won: from-base-document
---
spring:
  config:
    activate:
      on-profile: prod
demo:
  who-won: from-prod-document

This is different from creating application-prod.yml, although both can react to the prod profile. With several active profiles, declaration order matters: in prod,live, the last profile wins when both define the same key.

A minimal application that shows the winner

Create src/main/java/academy/devdojo/configwinner/ConfigWinnerApplication.java:

package academy.devdojo.configwinner;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;

@SpringBootApplication
public class ConfigWinnerApplication {

    public static void main(String[] args) {
        SpringApplication.run(ConfigWinnerApplication.class, args);
    }

    @Bean
    CommandLineRunner printConfigurationWinner(Environment environment) {
        return args -> {
            var name = ConfigurationPropertyName.of("demo.who-won");

            for (var source : ConfigurationPropertySources.get(environment)) {
                var property = source.getConfigurationProperty(name);
                if (property != null) {
                    System.out.printf("value=%s%n", property.getValue());
                    System.out.printf("origin=%s%n", property.getOrigin());
                    return;
                }
            }

            System.out.println("demo.who-won não foi definida");
        };
    }
}

Combining ConfigurationPropertyName with ConfigurationPropertySources.get(environment) respects Boot's naming model and lets you obtain the ConfigurationProperty. The getOrigin() method shows the tracked origin when it is available. The first result found is the winner.

Do not iterate through raw PropertySource entries looking literally for demo.who-won: a variable such as DEMO_WHOWON does not use that spelling. There is also no need to turn /actuator/env into the tutorial's diagnostic route and accidentally expand the surface that could reveal sensitive configuration.

Prove the precedence on screen

In src/main/resources/application.yml, add:

demo:
  who-won: from-jar

In src/main/resources/application-prod.yml, add:

demo:
  who-won: from-prod-file

Package the application:

mvn clean package

Now run the scenarios below and watch the value= and origin= lines.

First, activate prod without another source:

java -jar target/config-winner-0.0.1-SNAPSHOT.jar \
  --spring.profiles.active=prod

The relevant output will contain value=from-prod-file, because the profile-specific file beats the common file within Config Data.

Next, pass the correct environment variable:

DEMO_WHOWON=from-env \
  java -jar target/config-winner-0.0.1-SNAPSHOT.jar \
  --spring.profiles.active=prod

The result will now be value=from-env. The environment variable comes after Config Data in the official order.

Finally, add a command-line argument:

DEMO_WHOWON=from-env \
  java -jar target/config-winner-0.0.1-SNAPSHOT.jar \
  --spring.profiles.active=prod \
  --demo.who-won=from-cli

The result will be value=from-cli, because command-line arguments come after environment variables.

We can also test a file outside the jar. Create override.yml in the current directory:

demo:
  who-won: from-file-outside-jar

Run:

java -jar target/config-winner-0.0.1-SNAPSHOT.jar \
  --spring.config.additional-location=optional:file:./override.yml

The external file beats the packaged file. Even so, it would lose to DEMO_WHOWON or --demo.who-won because it is still part of the Config Data layer.

Why the variable seems not to have been applied

To convert a canonical name into an environment variable, replace dots with _, remove hyphens, and use uppercase. For example:

demo.who-won -> DEMO_WHOWON
spring.datasource.url -> SPRING_DATASOURCE_URL

DEMO_WHO_WON has an extra _ and does not represent demo.who-won. The environment is strict that way: flexible about binding, but not telepathic.

If the output still does not show the expected value, check these cases in order:

  1. Confirm the relaxed name. For spring.datasource.url, use SPRING_DATASOURCE_URL, not SPRING_DATA_SOURCE_URL.
  2. Look for a later source: --demo.who-won, SPRING_APPLICATION_JSON, or -Ddemo.who-won beats the operating system variable.
  3. Inspect the environment received by the process, not just the shell where someone believes they set the variable. systemd, Docker Compose, and Kubernetes can start the process without it.
  4. Restart the application after the change. Changing the environment or a Secret does not modify the Environment of a JVM that is already running.
  5. With @Value injection, use the canonical kebab-case name, such as ${demo.who-won}, or prefer @ConfigurationProperties for grouped configuration.

SPRING_APPLICATION_JSON deserves special attention: it ranks above environment variables. Also, a JSON null value is treated as absent and does not erase a value from a lower source.

Next step

Temporarily add the CommandLineRunner to the service that has the conflict, run it in the same deployment environment, and compare value= with origin=. Remove the diagnostic after identifying the source, especially if the real key could contain a secret.

DevDojo's recommendation is firm: if the value must vary by environment without being packaged, use spring.config.additional-location with a file: path outside the jar; in Kubernetes, prefer optional:configtree: when the secret is already mounted as a file. Use spring.config.location only when the explicit intent is to replace the entire default search.

javaspring-boot

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles