Spring Boot Configuration Management — Profiles, @ConfigurationProperties, and Secrets
by Eric Hanson, Backend Developer at Clean Systems Consulting
Property source precedence — the complete ordering
Spring Boot loads properties from multiple sources, evaluated in a fixed precedence order. Higher-priority sources override lower-priority ones. The full ordering (highest to lowest):
- Command-line arguments (
--server.port=9090) SPRING_APPLICATION_JSONenvironment variable (JSON blob of properties)- Servlet init parameters
- JNDI attributes
- Java system properties (
-Dserver.port=9090) - OS environment variables (
SERVER_PORT=9090) - Profile-specific properties outside the JAR (
application-{profile}.properties) - Application properties outside the JAR (
application.properties) - Profile-specific properties inside the JAR
- Application properties inside the JAR (
application.yml) @PropertySourceannotations on@Configurationclasses- Default properties (
SpringApplication.setDefaultProperties)
The practical implications:
Environment variables override everything in the JAR. DATABASE_URL set in the container environment overrides spring.datasource.url in application.yml. This is the intended behavior for containerized deployments — the JAR carries defaults, the environment carries production values.
@PropertySource is low-priority. Properties loaded via @PropertySource cannot override application.properties or environment variables. Teams that use @PropertySource for environment-specific configuration are surprised when environment variables don't override as expected — they do, @PropertySource just can't override application.properties, not the other way around.
Profile-specific files override the base file. application-production.yml overrides application.yml when the production profile is active. This is the correct mechanism for environment-specific overrides.
@ConfigurationProperties — typed, validated configuration
@Value("${some.property}") is the simple approach. It doesn't validate, doesn't provide IDE completion in many IDEs without additional setup, and scatters property usage throughout the codebase. For any non-trivial configuration, @ConfigurationProperties is better:
@ConfigurationProperties(prefix = "app.payment")
@Validated
public class PaymentConfig {
@NotBlank
private String apiKey;
@NotNull
@PositiveOrZero
private Integer maxRetries = 3;
@NotNull
@DurationMin(seconds = 1)
private Duration timeout = Duration.ofSeconds(30);
@NotBlank
private String webhookSecret;
@Valid
private CircuitBreaker circuitBreaker = new CircuitBreaker();
// Getters and setters (or use records with Spring Boot 3.x)
public static class CircuitBreaker {
@Positive
private int failureThreshold = 5;
@NotNull
private Duration waitDuration = Duration.ofSeconds(60);
// Getters and setters
}
}
app:
payment:
api-key: ${PAYMENT_API_KEY}
max-retries: 5
timeout: 45s
webhook-secret: ${PAYMENT_WEBHOOK_SECRET}
circuit-breaker:
failure-threshold: 10
wait-duration: 30s
Register the configuration class:
@Configuration
@EnableConfigurationProperties(PaymentConfig.class)
public class PaymentModuleConfig {
// or use @ConfigurationPropertiesScan on the main class
}
Or add @Component to the @ConfigurationProperties class (simpler but couples the configuration to Spring component scanning).
Validation. @Validated on the class triggers Bean Validation on the properties when the context starts. A missing apiKey or an invalid timeout causes the application to fail at startup with a clear message — not a NullPointerException when the code first tries to use the value.
Type conversion. Spring Boot converts property strings to typed values automatically: "45s" to Duration.ofSeconds(45), "10MB" to DataSize.ofMegabytes(10), "true" to boolean. Use these types in @ConfigurationProperties classes rather than storing strings and parsing manually.
Relaxed binding. app.payment.api-key, app.payment.apiKey, APP_PAYMENT_API_KEY, and APP_PAYMENT_APIKEY all map to the apiKey field. Spring Boot's relaxed binding handles camelCase, kebab-case, and SCREAMING_SNAKE_CASE — use kebab-case in property files (api-key) and SCREAMING_SNAKE_CASE for environment variables (API_KEY).
Profiles — environment-specific configuration
Spring profiles enable loading different configuration for different environments. The recommended structure:
src/main/resources/
application.yml # shared across all environments
application-dev.yml # development overrides
application-staging.yml # staging overrides
application-prod.yml # production overrides
# application.yml — base configuration, environment-neutral
spring:
application:
name: order-service
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
server:
port: 8080
shutdown: graceful
management:
server:
port: 8081
# application-dev.yml — development overrides
spring:
datasource:
url: jdbc:postgresql://localhost:5432/orderdb_dev
username: devuser
password: devpassword
jpa:
show-sql: true
hibernate:
ddl-auto: update # more permissive in development
logging:
level:
com.example: DEBUG
org.hibernate.SQL: DEBUG
# application-prod.yml — production overrides (no secrets — those come from environment)
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
jpa:
show-sql: false
logging:
level:
root: WARN
com.example: INFO
The production profile never contains secrets. Property values for DATABASE_URL, API keys, and credentials reference environment variables (${VARIABLE_NAME}). The environment (Kubernetes Secret, AWS SSM, Vault) provides the actual values.
Activate profiles:
# Environment variable (recommended for containers)
SPRING_PROFILES_ACTIVE=prod
# Command-line argument
java -jar app.jar --spring.profiles.active=prod
# In application.yml (for always-on profiles)
spring:
profiles:
active: prod
Multiple profiles. SPRING_PROFILES_ACTIVE=prod,feature-flags-v2 activates both profiles. Profile-specific files are processed in order — application-feature-flags-v2.yml properties override application-prod.yml properties for overlapping keys.
@Value vs @ConfigurationProperties — when to use each
@Value is appropriate for a single property used in one place:
@Service
public class EmailService {
@Value("${app.email.from-address}")
private String fromAddress;
// ...
}
@ConfigurationProperties is appropriate when:
- Multiple related properties belong together
- Properties are used in more than one class (bind once, inject the config class)
- Validation of the configuration is required
- IDE support and documentation generation matter
The signal to refactor from @Value to @ConfigurationProperties: a class with more than two @Value fields, or the same property referenced in multiple classes.
Secrets — what not to do and what to do instead
Never commit secrets to version control. application-prod.yml with actual credentials is a security incident. Even if the repository is private, secrets in version control create risk: repository access grants, CI logs, git history after credential rotation.
Never log configuration properties at startup. spring.boot.admin.logging.enabled=true and similar logging of environment details may expose secrets in log aggregators. Spring Boot's Sanitizer redacts common secret patterns (keys containing password, secret, token, key) from actuator /env output. Add custom sanitizing patterns for application-specific secret names:
@Bean
public SanitizingFunction sanitizingFunction() {
return data -> {
String key = data.getKey().toLowerCase();
if (key.contains("apikey") || key.contains("webhook") || key.contains("credential")) {
return data.withValue("**REDACTED**");
}
return data;
};
}
The options for secret management:
Environment variables (from Kubernetes Secrets, Docker secrets, ECS task definitions): the simplest approach. The secret is injected into the container environment; Spring Boot reads it as a property. No additional library required. The downside: environment variables are visible to all processes in the container and show up in /proc in some configurations.
Spring Cloud Config Server: a dedicated configuration server that serves properties to applications. Secrets are stored in the server (backed by Git, Vault, or a database); the application fetches them at startup. Adds infrastructure complexity; appropriate for large organizations with many services.
HashiCorp Vault with Spring Cloud Vault: fetches secrets from Vault at startup. Secrets are never stored on disk or in environment variables. Supports dynamic credentials (database credentials that expire and rotate automatically):
spring:
cloud:
vault:
uri: https://vault.internal:8200
authentication: kubernetes
kubernetes:
role: order-service
generic:
default-context: order-service
Properties from Vault are available as Spring Boot properties with the same relaxed binding.
AWS Parameter Store / Secrets Manager via spring-cloud-aws:
spring:
config:
import: aws-parameterstore:
aws:
parameterstore:
prefix: /order-service/prod
All parameters under /order-service/prod/ are loaded as Spring Boot properties. /order-service/prod/database/password maps to database.password.
Configuration validation at startup
Every @ConfigurationProperties class with @Validated validates at context startup. For properties that fail validation, the application refuses to start with a clear message:
APPLICATION FAILED TO START
Description:
Binding to target org.springframework.boot.context.properties.bind.BindException:
Failed to bind properties under 'app.payment' to com.example.PaymentConfig
Reason: app.payment.api-key must not be blank
This fast-fail at startup — rather than at runtime when the property is first used — is the primary advantage of @ConfigurationProperties with @Validated over @Value. A misconfigured production deployment fails immediately and visibly rather than failing silently on the first request that needs the misconfigured value.
For custom validation logic:
@ConfigurationProperties(prefix = "app.database")
@Validated
public class DatabaseConfig implements InitializingBean {
private String readReplicaUrl;
private String primaryUrl;
@Override
public void afterPropertiesSet() {
if (readReplicaUrl != null && readReplicaUrl.equals(primaryUrl)) {
throw new BeanCreationException(
"read-replica-url must differ from primary-url");
}
}
}
InitializingBean.afterPropertiesSet() runs after the properties are bound — use it for cross-field validation that Bean Validation annotations can't express.
The configuration structure that works at scale
application.yml # base config, environment-neutral, no secrets
application-dev.yml # dev database, debug logging, relaxed constraints
application-test.yml # test database (H2 or Testcontainers config)
application-staging.yml # staging-specific timeouts, staging service URLs
application-prod.yml # prod pool sizes, prod logging — NO SECRETS
Secrets arrive via environment variables or a secret manager, never in property files. Profile-specific files contain behavior configuration (log levels, pool sizes, feature flags) but never credentials. The JAR is identical across environments; only the environment configuration changes.
This structure means any developer can read the production profile without gaining access to any credentials. Security teams can audit the property files without worrying about credential exposure. Credential rotation doesn't require a code change or redeploy — update the Kubernetes Secret, restart the pods.