Configuration and Profiles in Spring Boot
A staff-engineer guide to externalized configuration, @ConfigurationProperties, environment profiles, and secrets handling in Spring Boot.
Configuration and Profiles
Hardcoded configuration is the fastest way to make a service unshippable — every environment (local, test, staging, production) needs different database URLs, thread pool sizes, and feature flags without a single line of code changing between them. This guide covers Spring Boot's externalized configuration model end to end: where values come from, how they're bound to type-safe objects, and how profiles switch behavior per environment.
1. The Externalized Configuration Principle
The twelve-factor app methodology states configuration should live in the environment, not in code. Spring Boot's Environment abstraction is built around exactly this idea — a unified view over configuration sourced from many places, resolved by strict precedence.
Property source precedence (highest wins)
| Priority | Source | Example |
|---|---|---|
| 1 (highest) | Command-line arguments | java -jar app.jar --server.port=8081 |
| 2 | SPRING_APPLICATION_JSON env var | SPRING_APPLICATION_JSON='{"server":{"port":8081}}' |
| 3 | JVM system properties | -Dserver.port=8081 |
| 4 | OS environment variables | SERVER_PORT=8081 |
| 5 | application-{profile}.yml (outside jar) | Config directory next to the jar |
| 6 | application-{profile}.yml (inside jar) | Bundled in src/main/resources |
| 7 | application.yml (outside jar) | Overrides bundled defaults |
| 8 | application.yml (inside jar) | The bundled base config |
| 9 (lowest) | @PropertySource annotations, defaults in code | Fallback values |
Environment variables and command-line args always win. This is deliberate — it's what makes container orchestrators (Kubernetes env: blocks, ECS task definitions) able to override any application.yml setting without rebuilding the image. Spring relaxes binding rules so SERVER_PORT (env var convention) automatically maps to server.port (property convention).
2. YAML and Properties Files
# application.yml — the base configuration, applies to all profiles
spring:
application:
name: order-service
server:
port: 8080
shutdown: graceful
logging:
level:
root: INFO
com.acme.orders: DEBUG
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
endpoint:
health:
show-details: when-authorized
order-service:
payment:
timeout: 5s
retry-attempts: 3
inventory:
reservation-hold-minutes: 15# Equivalent in .properties format (less common in modern Spring Boot, but still supported)
spring.application.name=order-service
server.port=8080
order-service.payment.timeout=5s
order-service.payment.retry-attempts=3YAML is preferred over .properties in modern Spring Boot codebases — it expresses hierarchical structure naturally, supports multiple profile documents in a single file via --- separators, and avoids the repetitive dotted-key noise of .properties. Reserve .properties for legacy modules or tools that specifically require it.
3. Type-Safe Configuration with @ConfigurationProperties
Reading individual values with @Value("${...}") scattered across classes doesn't scale. @ConfigurationProperties binds a whole configuration tree to a strongly-typed, validated object.
@ConfigurationProperties(prefix = "order-service.payment")
public record PaymentProperties(
@DurationUnit(ChronoUnit.SECONDS) Duration timeout,
int retryAttempts,
String defaultCurrency
) {
// Compact constructor for validation/defaults
public PaymentProperties {
if (retryAttempts < 0) {
throw new IllegalArgumentException("retryAttempts must not be negative");
}
if (defaultCurrency == null) {
defaultCurrency = "USD";
}
}
}@Configuration
@EnableConfigurationProperties(PaymentProperties.class)
public class PaymentConfig {
@Bean
public PaymentGateway paymentGateway(PaymentProperties properties, RestClient.Builder builder) {
RestClient client = builder
.requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(properties.timeout())
.withReadTimeout(properties.timeout()))
.build();
return new HttpPaymentGateway(client, properties.retryAttempts(), properties.defaultCurrency());
}
}order-service:
payment:
timeout: 5s
retry-attempts: 3
default-currency: USDUse a Java record for @ConfigurationProperties (Spring Boot 3.x / Spring 6+). It's immutable, requires no boilerplate getters/setters, and the compact constructor gives you a natural place for validation. This has replaced the older mutable-class-with-setters pattern in modern Spring Boot codebases.
@Value vs @ConfigurationProperties
| Aspect | @Value("${...}") | @ConfigurationProperties |
|---|---|---|
| Type safety | Per-field, manual | Whole tree, compile-time checked |
Relaxed binding (kebab-case, camelCase, env vars) | No | Yes |
| Validation | Manual | Native @Validated support |
| SpEL expressions | Yes | No |
| Testability | Requires @TestPropertySource per field | Bind directly with @EnableConfigurationProperties in a test slice |
| Best for | One-off, simple values | Structured, related configuration |
// @Value works but doesn't scale — no grouping, no validation, easy to typo the key
@Service
public class LegacyPaymentService {
@Value("${order-service.payment.retry-attempts:3}")
private int retryAttempts;
}@Value field injection has the same testability problems as @Autowired field injection — it requires reflection or a running Spring context to set in tests. Prefer binding related settings into a single @ConfigurationProperties record injected via the constructor.
Validating configuration at startup
@ConfigurationProperties(prefix = "order-service.inventory")
@Validated
public record InventoryProperties(
@Min(1) @Max(1440) int reservationHoldMinutes,
@NotBlank String warehouseRegion
) {}Validate configuration and fail fast at startup, not on the first request that touches a bad value. A missing or malformed warehouseRegion should crash the container during SpringApplication.run(), not three hours later during a checkout flow in production. This is one of the cheapest reliability wins available — Bean Validation on @ConfigurationProperties gives it to you for free.
4. Environment Profiles
Profiles let you swap configuration (and even entire beans) per environment without changing code.
# application.yml — shared defaults + profile-specific overrides using YAML documents
spring:
application:
name: order-service
---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:orders
logging:
level:
com.acme.orders: DEBUG
---
spring:
config:
activate:
on-profile: staging
datasource:
url: jdbc:postgresql://staging-db.internal:5432/orders
---
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:postgresql://prod-db.internal:5432/orders
logging:
level:
com.acme.orders: WARNOr as separate files (equally valid, often clearer for larger configs):
src/main/resources/
├── application.yml # shared, profile-agnostic config
├── application-dev.yml # local development overrides
├── application-staging.yml # staging environment
└── application-prod.yml # production environment
# Activating a profile
java -jar order-service.jar --spring.profiles.active=prod
# Or via environment variable (typical in containers)
export SPRING_PROFILES_ACTIVE=prod
java -jar order-service.jar
# Multiple profiles compose together
java -jar order-service.jar --spring.profiles.active=prod,metricsProfiles compose, they don't replace. Activating prod,metrics merges both profile documents on top of the base application.yml. If both define the same key, the last-listed active profile wins. This lets you build small, orthogonal profiles (metrics, tracing, feature-x) that combine freely instead of one giant profile per permutation.
Profile-specific beans
@Configuration
public class NotificationConfig {
@Bean
@Profile("prod")
public NotificationChannel productionEmailChannel(SesClient sesClient) {
return new SesEmailChannel(sesClient); // real AWS SES integration
}
@Bean
@Profile({"dev", "test"})
public NotificationChannel loggingOnlyChannel() {
return (to, message) -> log.info("Would send to {}: {}", to, message); // no real email sent
}
}Never let @Profile("dev") mock beans accidentally activate in production because a profile name was misspelled or the deployment script forgot to set SPRING_PROFILES_ACTIVE. A good safeguard: fail startup if no explicit profile is active, rather than silently falling back to default. You can enforce this with a custom ApplicationListener that checks environment.getActiveProfiles().length > 0.
5. application.yml vs Command-Line vs Environment Variables in Practice
| Environment | Preferred source | Why |
|---|---|---|
| Local development | application-dev.yml + IDE run config | Fast iteration, no external system needed |
| CI test runs | application-test.yml + Testcontainers | Isolated, reproducible, disposable |
| Kubernetes | Environment variables from ConfigMap/Secret | Native to the platform, no image rebuild to change config |
| Docker Compose (local integration) | .env file + environment: block | Matches production's env-var-driven model |
# Kubernetes ConfigMap example — same relaxed binding rules apply
apiVersion: v1
kind: ConfigMap
metadata:
name: order-service-config
data:
SPRING_PROFILES_ACTIVE: "prod"
ORDER_SERVICE_PAYMENT_TIMEOUT: "8s"
ORDER_SERVICE_PAYMENT_RETRY_ATTEMPTS: "5"Spring Boot's relaxed binding means ORDER_SERVICE_PAYMENT_RETRY_ATTEMPTS (env var, uppercase with underscores) automatically binds to order-service.payment.retry-attempts (kebab-case YAML key) without any extra mapping code. This is what makes the same @ConfigurationProperties class work identically whether the value comes from YAML locally or a Kubernetes ConfigMap in production.
6. Secrets: What Not to Do, and What to Do Instead
# NEVER do this — plaintext secret committed to source control
datasource:
url: jdbc:postgresql://prod-db.internal:5432/orders
username: admin
password: SuperSecret123! # DO NOT COMMITNever commit secrets to application.yml or any file in version control — not even in a "private" repo, not even temporarily. Once a secret is in git history, rotating it is the only real fix; deleting the line in a later commit does not remove it from history. This applies to database passwords, API keys, JWT signing secrets, and third-party credentials alike.
Practical secrets handling for a Spring Boot service
# application.yml references an environment variable — no secret value in the file
datasource:
url: jdbc:postgresql://prod-db.internal:5432/orders
username: ${DB_USERNAME}
password: ${DB_PASSWORD}# The actual secret is injected at deploy time, never stored in the repo
# Kubernetes: mounted from a Secret resource as env vars
# AWS: pulled from Secrets Manager / Parameter Store into ECS task definition
# Docker Compose (local only): from a gitignored .env file| Approach | Where secrets live | Notes |
|---|---|---|
| Environment variables from orchestrator secrets | Kubernetes Secret, ECS secrets, Docker secrets | Standard baseline; combine with encryption at rest |
| Spring Cloud Config with encrypted values | Config server + symmetric/asymmetric key | Centralized across many services |
| HashiCorp Vault / AWS Secrets Manager / GCP Secret Manager | Dedicated secrets store, fetched at startup or runtime | Best for rotation, auditing, fine-grained access control |
.gitignored local .env for development only | Developer's machine | Never used in staging/production |
For anything beyond a small service, a dedicated secrets manager (Vault, AWS Secrets Manager) is worth the integration cost — it gives you rotation without redeployment, audit logs of who accessed what secret when, and fine-grained per-service access policies that a flat environment variable can't provide.
7. Feature Flags via Configuration
A lightweight pattern for toggling behavior without a full feature-flag platform:
@ConfigurationProperties(prefix = "order-service.features")
public record FeatureFlags(
boolean newPricingEngine,
boolean asyncNotifications
) {}@Service
public class PricingService {
private final FeatureFlags featureFlags;
private final PricingStrategy legacyStrategy;
private final PricingStrategy newStrategy;
public Money calculatePrice(Order order) {
PricingStrategy strategy = featureFlags.newPricingEngine() ? newStrategy : legacyStrategy;
return strategy.compute(order);
}
}order-service:
features:
new-pricing-engine: false
async-notifications: trueStatic YAML feature flags are fine for deploy-time toggles (this environment has feature X enabled, that one doesn't). For runtime, per-request toggles (gradual rollout, A/B tests, kill switches without a redeploy), use a dedicated feature-flag service — Spring configuration isn't designed to change without a restart or a config refresh event.
8. Config Refresh Without Restart (Spring Cloud Config Preview)
@RestController
@RefreshScope // Spring Cloud Config — bean is recreated on a /actuator/refresh call
public class PricingController {
@Value("${order-service.discount.rate}")
private double discountRate;
}@RefreshScope requires spring-cloud-starter-config and is only relevant if you've adopted Spring Cloud Config Server for centralized configuration across multiple services. For a single-service setup, a plain restart on config change is simpler and has no hidden staleness risks — introduce @RefreshScope only when the operational need (zero-downtime config changes across a fleet) justifies the added complexity.
Key takeaways
- Spring resolves configuration from many sources with strict precedence; environment variables and command-line args always beat
application.yml, which is exactly what makes container-based deployment work without image rebuilds. - Prefer
@ConfigurationPropertiesrecords over scattered@Valuefields for anything beyond a single, one-off value — you get type safety, relaxed binding, and native validation. - Validate configuration with Bean Validation on your
@ConfigurationPropertiesclasses so bad config fails the deployment at startup, not a production request hours later. - Profiles compose (multiple can be active at once); the last-listed active profile wins on key conflicts.
- Never commit secrets to any file in version control, even briefly — reference them via environment variables or a dedicated secrets manager instead.
- Relaxed binding means the same
@ConfigurationPropertiesclass works identically whether values come from YAML, environment variables, or a KubernetesConfigMap/Secret. - Guard against a misconfigured or missing
SPRING_PROFILES_ACTIVEsilently falling back to dev-like defaults in production. - Use
@Profile-scoped beans to swap entire implementations (real vs. fake notification channels) per environment, notif (profile.equals("prod"))conditionals scattered through business logic.
Interview Questions
- What is Spring Boot's property source precedence order, and why do environment variables outrank
application.yml? - What is the difference between
@Valueand@ConfigurationProperties? When would you choose each? - Why is it good practice to validate
@ConfigurationPropertieswith Bean Validation annotations? - How does Spring Boot's relaxed binding let
DB_PASSWORD(env var) map todb.password(YAML key) automatically? - How do multiple active Spring profiles combine when they define overlapping keys?
- What's the risk of hardcoding secrets in
application.yml, even in a private repository? - How would you structure configuration for a service that needs different database URLs across dev, staging, and production?
- What is
@Profileused for beyond simple property overrides, and give an example where you'd swap an entire bean implementation per environment. - Why might you prefer a
recordover a mutable class for a@ConfigurationPropertiesbinding target? - What operational risk exists if a deployment forgets to set
SPRING_PROFILES_ACTIVE, and how would you defend against it? - What is
@RefreshScope, and what problem does it solve that a normal@ConfigurationPropertiesbean doesn't? - How would you handle a secret that needs to be rotated without redeploying the service?
- Why is failing fast at application startup on bad configuration preferable to discovering it on the first affected request?