01-spring-foundations

Configuration and Profiles: Precedence, Binding and Secrets

Why @ConfigurationProperties beats @Value, how profiles separate environments, and the precedence order that explains why your value isn't the one that won.

October 8, 2026
spring-bootconfigurationprofilesConfigurationPropertiesexternalized-configsecretsyaml

One Artifact, Every Environment

The goal of externalised configuration is a single rule: the JAR you tested is the JAR you deploy. Nothing environment-specific is compiled in. Dev points at localhost, production points at a managed database, and the bytes are identical.

That rule is what the rest of this guide serves. It rules out hardcoded URLs, and it also rules out the subtler version — building a separate artifact per environment, which means the thing you tested is not the thing you shipped.

@Value Is Fine Until It Isn't

The direct way to read a property:

java
@Service
public class PaymentService {
 
    private final String apiUrl;
    private final int timeoutMs;
 
    public PaymentService(
            @Value("${payments.api-url}") String apiUrl,
            @Value("${payments.timeout-ms:5000}") int timeoutMs) {
        this.apiUrl = apiUrl;
        this.timeoutMs = timeoutMs;
    }
}

${...} is the placeholder, and :5000 is a default used when the property is absent. Without a default, a missing property fails startup — which is the right behaviour, and better than a silent null.

This works. It stops being pleasant at about the fourth property:

  • Property names are strings scattered across classes. Rename one in YAML and nothing tells you which Java files you broke.
  • There is no validation beyond type conversion.
  • Related settings aren't grouped, so there is no single place that documents what this feature needs.
  • The names only exist at runtime — your IDE can't complete them and can't find usages.
✅

@Value remains the right tool for a genuine one-off — a single flag in a single class. The moment a feature has two or three related settings, the type-safe approach below is less code and considerably more durable.

@ConfigurationProperties: The Default Choice

Bind a whole group of properties to a typed object:

java
@ConfigurationProperties(prefix = "payments")
@Validated
public record PaymentProperties(
        @NotBlank String apiUrl,
        @NotBlank String apiKey,
        @DefaultValue("5000") int timeoutMs,
        @DefaultValue("3") @Min(0) @Max(10) int maxRetries) {
}

Matched by:

yaml
payments:
  api-url: https://api.stripe.com
  api-key: ${PAYMENTS_API_KEY}
  timeout-ms: 3000
  max-retries: 5

Register it once — on a @Configuration class or the main class:

java
@SpringBootApplication
@EnableConfigurationProperties(PaymentProperties.class)
public class ShopApplication { }

Then inject it like any other bean:

java
@Service
public class PaymentService {
    private final PaymentProperties props;
 
    public PaymentService(PaymentProperties props) {
        this.props = props;
    }
}

What this buys over scattered @Value:

  • Type safety and IDE support. props.timeoutMs() is a real method. Rename it and the compiler finds every use; with spring-boot-configuration-processor on the build path, your IDE even autocompletes the YAML keys.
  • Validation at startup. @Validated with Jakarta constraints means a missing API key or a maxRetries of 50 fails at boot, with a message naming the property — not at 3am on the first request that needs it.
  • One documented place for everything the feature needs.
  • Records work directly, so the result is immutable with no boilerplate.
💡

Relaxed binding means max-retries, maxRetries, max_retries and MAX_RETRIES all bind to maxRetries. This is what makes environment variables work: PAYMENTS_API_KEY binds to payments.api-key without ceremony. Kebab-case is the documented convention for YAML and properties files — use it, and let the uppercase form belong to environment variables.

Profiles: Keeping Environments Apart

A profile is a named set of configuration that can be switched on or off.

The convention is file-per-profile. application.yml holds what is common; application-{profile}.yml holds the differences, and is merged over the base rather than replacing it:

yaml
# application.yml — shared
spring:
  application:
    name: shop
server:
  port: 8080
payments:
  timeout-ms: 5000
  max-retries: 3
yaml
# application-dev.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/shop_dev
    username: shop
    password: localdev
  jpa:
    show-sql: true
logging:
  level:
    com.example.shop: DEBUG
yaml
# application-prod.yml
spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USER}
    password: ${DATABASE_PASSWORD}
payments:
  timeout-ms: 2000

Activate with a property — not a code change:

bash
java -jar shop.jar --spring.profiles.active=prod
# or
SPRING_PROFILES_ACTIVE=prod java -jar shop.jar

In production, the environment variable is usually the better choice: it's what container platforms set naturally, and it keeps the launch command identical everywhere.

Profile-specific beans

@Profile makes a bean conditional on an active profile:

java
@Bean
@Profile("!prod")       // every profile except prod
public CommandLineRunner seedData(OrderRepository repository) {
    return args -> repository.saveAll(DemoData.orders());
}

The expression syntax supports !, & and |. Use it sparingly — property differences are easier to follow than structural ones, and a bean that exists in only some environments is a bean whose absence will surprise someone.

⚠️

A profile named default is active only when no profile is explicitly set — making application-default.yml a quietly confusing file, since local runs load it and nothing else does. Prefer an explicit dev profile and set it in your IDE run configuration. Relatedly: spring.profiles.active in application.yml sets a default that an environment variable can override, but putting it there at all means someone will eventually deploy with the wrong profile because the file said so.

Precedence: Why Your Value Isn't Winning

Spring Boot reads properties from many sources and applies a defined order. Later sources override earlier ones. This is the abridged list, highest priority first:

#Source
1Devtools global settings (~/.config/spring-boot, dev only)
2@TestPropertySource and @SpringBootTest(properties=...)
3Command-line arguments (--server.port=9000)
4SPRING_APPLICATION_JSON
5OS environment variables
6Java system properties (-Dserver.port=9000)
7application-{profile}.yml outside the JAR
8application-{profile}.yml inside the JAR
9application.yml outside the JAR
10application.yml inside the JAR
11@PropertySource on a @Configuration class
12Default properties (SpringApplication.setDefaultProperties)

Four points that cover most real confusion:

  1. Profile files beat the base file. application-prod.yml overrides application.yml. That's the merge behaviour you want.
  2. Outside the JAR beats inside. Drop an application.yml beside the JAR and it overrides the packaged one — handy for an ops-owned override without a rebuild.
  3. Environment variables beat both. Which is what makes the container model work: ship one image, inject per-environment values.
  4. Command-line arguments beat everything short of test annotations — ideal for a one-off, and a reason to check the launch command when a value is inexplicable.
✅

With Actuator on the classpath, /actuator/env shows every property source in precedence order and which one supplied the winning value for each key. It answers "where is this value coming from?" definitively. Guard it — it exposes configuration, and should never be publicly reachable.

Check yourself

application-prod.yml sets server.port: 8080. The deployment platform also sets an environment variable SERVER_PORT=9000. The app is started with --spring.profiles.active=prod and no other arguments. Which port does it bind?

Secrets Do Not Belong in the Repository

Everything so far has been about mechanism. This part is about consequence, and it is the one that causes real incidents.

A database password or API key committed to Git is compromised. Not "bad practice" — compromised. Git history is permanent, repositories get cloned and forked, and automated scanners find credentials in public repos within minutes. Rotating the secret is the only remedy; deleting the line does nothing.

The pattern to use is a placeholder resolved from the environment:

yaml
# application-prod.yml — safe to commit
spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USER}
    password: ${DATABASE_PASSWORD}
payments:
  api-key: ${PAYMENTS_API_KEY}

The file names what the application needs and contains no values. Startup fails immediately if a variable is missing — notice this is strictly better than a default, which would let the app start and fail later in a confusing way.

Where those variables come from, roughly in order of maturity:

  • Local development — an untracked .env file, or your IDE's run configuration. Ensure .env is in .gitignore.
  • Containers and orchestrators — Kubernetes Secrets, or your platform's environment configuration.
  • A secrets manager — HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager. Rotation and audit come with it, which the simpler options don't offer.
🚨

If a secret has already been committed, rotate it. A rewrite of history with git filter-repo or BFG is worth doing to clean the repository, but it is not a substitute: assume anything ever pushed has been read. Rotate first, then clean up.

Two habits that catch mistakes early: a secret-scanning hook (gitleaks, or GitHub's push protection) so a credential is blocked before it leaves your machine, and a review rule that any diff adding a literal under a password: or key: is rejected on sight.

Configuration Smells

Signals that configuration has drifted from the model above:

  • An if on an environment name in Java. if (env.equals("prod")) belongs in a property or a @Profile, not a branch.
  • More than a handful of profiles. dev, test, prod plus maybe local is healthy. Nine profiles usually means profiles are doing the job of properties.
  • A profile that only exists on one developer's machine. Configuration no one else can reproduce.
  • @Value appearing in a dozen classes with the same prefix. That prefix wants to be a @ConfigurationProperties record.
  • Commented-out blocks for other environments. The uncommenting will be forgotten exactly once.

Check yourself

A service needs a feature flag that operations must flip without a redeploy. Which approach fits best?

The Mental Model, Restated

  1. One artifact, every environment. Configuration comes from outside the JAR.
  2. @ConfigurationProperties over @Value for anything beyond a single flag — typed, validated at startup, refactorable.
  3. Profiles layer over the base file. application.yml for shared, application-{profile}.yml for differences.
  4. Later sources win, and environment variables beat every YAML file. /actuator/env shows you which source won.
  5. Secrets come from the environment, never the repository — and a committed secret must be rotated, not deleted.

Phase 1 in Four Sentences

The container owns object creation, and you declare dependencies in constructors so your classes stay testable and proxyable. Auto-configuration supplies framework defaults from what's on the classpath, and backs off the moment you define a bean yourself. Starters are how you put things on that classpath, and your main class's package is where component scanning begins. Configuration stays outside the artifact, bound to typed records and layered by profile.

Those four ideas are not introductory material you move past. They are what you reason with when a bean doesn't exist, when a property doesn't take effect, and when a feature you never configured is somehow running.

What's Next

With the foundations in place, phase 2 turns to what Spring Boot is most often used for: HTTP APIs. Request mapping and the DispatcherServlet chain, validation with the starter from guide 3, error handling that returns useful responses instead of stack traces, and the API design decisions — versioning, pagination, idempotency — that outlive any particular framework.