Spring Boot Profiles and Configuration, Properly
Most Spring Boot configuration confusion comes from one question: when the same property is set in three places, which one wins?
The order that decides everything
Spring Boot builds configuration from an ordered list of property sources. Later sources override earlier ones. Trimmed to what you actually meet day to day, highest priority first:
- Command line arguments, such as
--server.port=8081 SPRING_APPLICATION_JSON- OS environment variables, such as
SERVER_PORT=8081 - Profile-specific files, such as
application-prod.properties application.properties@PropertySource- Defaults set with
SpringApplication.setDefaultProperties
Two consequences worth internalising:
- An environment variable always beats anything in your
.propertiesfiles. This is what makes containers work: you ship one image and configure it per environment. - A profile-specific file beats the base file, but loses to an environment variable.
Environment variables use relaxed binding, so server.port and SERVER_PORT are the same property. Uppercase, dots to underscores.
Profiles
A profile is a named set of configuration that switches on together.
src/main/resources/
├── application.yml # shared by everything
├── application-dev.yml # only when 'dev' is active
├── application-prod.yml # only when 'prod' is active
└── application-test.yml
# application.yml
spring:
application:
name: order-service
jpa:
open-in-view: false # turn this off; it is on by default
---
spring:
config:
activate:
on-profile: dev
jpa:
hibernate:
ddl-auto: update
h2:
console:
enabled: true
---
spring:
config:
activate:
on-profile: prod
jpa:
hibernate:
ddl-auto: validate
The --- separators are multi-document YAML, so one file can hold every profile. Use spring.config.activate.on-profile. The older spring.profiles key was deprecated in Boot 2.4.
Activating one
java -jar app.jar --spring.profiles.active=prod # command line
SPRING_PROFILES_ACTIVE=prod java -jar app.jar # environment
@SpringBootTest
@ActiveProfiles("test") // in tests
class OrderServiceTest { }
Avoid setting spring.profiles.active in application.properties. Configuration that decides which configuration to load, baked into the artefact, is how you end up deploying a jar that thinks it is in dev.
Profile-specific beans
@Configuration
public class PaymentConfig {
@Bean
@Profile("!prod")
public PaymentGateway stubGateway() {
return new StubPaymentGateway();
}
@Bean
@Profile("prod")
public PaymentGateway stripeGateway(StripeProperties props) {
return new StripePaymentGateway(props.apiKey());
}
}
!prod means "any profile except prod", so a new staging profile gets the stub automatically. Useful, and occasionally a nasty surprise, so be deliberate about which way round you write the condition.
Type-safe configuration
Scattered @Value("${...}") annotations are hard to find and validate. Bind a group of related properties to a record instead:
@ConfigurationProperties(prefix = "app.payment")
@Validated
public record PaymentProperties(
@NotBlank String apiKey,
@DefaultValue("5s") Duration timeout,
@Min(0) @Max(10) int maxRetries
) { }
@SpringBootApplication
@EnableConfigurationProperties(PaymentProperties.class)
public class Application { }
app:
payment:
api-key: ${PAYMENT_API_KEY}
timeout: 10s
max-retries: 3
You get one place to look, IDE completion, Duration parsing for free, and (because of @Validated) a startup failure with a readable message if PAYMENT_API_KEY is missing. That beats a NullPointerException on the first payment attempt.
Secrets
Nothing secret goes in a properties file. Those files are committed, and a secret in git history stays there.
# wrong
app:
payment:
api-key: sk_live_51H8xY2abcdef
# right: resolved from the environment at startup
app:
payment:
api-key: ${PAYMENT_API_KEY}
The ${VAR} form fails fast if the variable is unset. For anything beyond a single service, graduate to a real secret store such as Vault, AWS Secrets Manager, or your platform's own, rather than growing the list of environment variables forever.
A pattern worth stealing
Give every non-secret property a working default in application.yml, then override only what changes per environment. A new developer clones the repo, runs ./mvnw spring-boot:run, and the application starts. No setup document, no missing variables.
Production then overrides those defaults with environment variables, which sit at the top of the precedence list and win every time.