Spring Boot
Centralized Configuration
Configuration drift is one of the most common causes of 'works in staging, breaks in production'. Centralized configuration keeps every service's settings in one reviewable place and layers environment-specific overrides on top. This entry covers how Spring Boot resolves property sources, typed binding with @ConfigurationProperties, Spring Cloud Config Server, feature flags, Kubernetes ConfigMaps and Secrets, secrets handling, and failing fast on invalid configuration.
Configuration Precedence and Property Sources
Spring Boot merges many property sources and resolves each key from the highest-priority source that defines it. The practical rule: the closer a value is to the running process, the higher its priority. Command-line arguments beat system properties, which beat environment variables, which beat files. Profile-specific files beat the base file, and files outside the jar beat files packaged inside it.
Remote config from Spring Cloud Config sits above your packaged application.yml but below environment variables and system properties by default. That is deliberate: an operator can always override a single value on a running container without touching the config repo. Since Spring Boot 2.4, remote and extra sources are pulled in through spring.config.import rather than bootstrap.yml.
Two habits prevent most surprises. Keep application.yml limited to values that are safe and identical everywhere, and put anything environment-specific in a profile file or external source. When a value is not what you expect, check /actuator/env, which shows which source supplied each property.
yaml
# Precedence, highest to lowest (abridged)
# 1. Command-line arguments --server.port=9090
# 2. SPRING_APPLICATION_JSON {"server":{"port":9090}}
# 3. Java system properties -Dserver.port=9090
# 4. OS environment variables SERVER_PORT=9090
# 5. Config data outside the jar ./config/application-prod.yml
# 6. Config data inside the jar classpath:application-prod.yml
# 7. Remote config (config import) configserver:, kubernetes:
# 8. Packaged application.yml
# 9. @PropertySource, SpringApplication defaults
#
# Within each location, profile-specific files override the base file.
# Relaxed binding: one property, several spellings
# application.yml / .properties spring.datasource.max-pool-size (canonical, use this)
# Environment variable SPRING_DATASOURCE_MAXPOOLSIZE
# System property -Dspring.datasource.maxPoolSize
# Dots become underscores and everything is upper-cased in env vars.
# application.yml: shared by every environment
spring:
application:
name: order-service
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
app:
pagination:
default-page-size: 20
max-page-size: 100
---
# application-dev.yml: activated with SPRING_PROFILES_ACTIVE=dev
spring:
datasource:
url: jdbc:postgresql://localhost:5432/orders_dev
username: dev
password: dev
jpa:
show-sql: true
---
# application-prod.yml: no credentials here, only references
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USER}
password: ${DATABASE_PASSWORD}
# Activate several profiles at once; later profiles win on conflicts:
# SPRING_PROFILES_ACTIVE=prod,eu-west@ConfigurationProperties
Injecting settings one by one with @Value works for a single flag, but it scatters property names across the codebase and gives you no validation. @ConfigurationProperties binds a whole prefix to a typed object, so the structure of your configuration is documented in one class, your IDE can autocomplete it, and bad values are rejected at startup.
On Spring Boot 3, prefer immutable binding with records or constructor-bound classes. The values cannot change after startup, which is what you want for most settings. Use @ConfigurationPropertiesScan to register them instead of listing each class by hand. Note that the Duration type understands 15s, 200ms and 2m directly, so you never need a "timeoutMillis" suffix in property names.
Java
// Immutable, validated properties (Spring Boot 3)
@ConfigurationProperties(prefix = "app.payments")
@Validated
public record PaymentProperties(
@NotBlank String gatewayUrl,
@NotBlank String apiKey,
@DefaultValue("30s") Duration timeout,
@DefaultValue("false") boolean sandboxMode,
@Valid @DefaultValue Retry retry,
@NotEmpty @DefaultValue({"USD", "EUR", "GBP"}) List<String> supportedCurrencies) {
public record Retry(
@Min(0) @Max(10) @DefaultValue("3") int maxAttempts,
@DefaultValue("100ms") Duration initialDelay,
@DecimalMin("1.0") @DefaultValue("2.0") double multiplier) {
}
}
// Register every @ConfigurationProperties class under this package
@SpringBootApplication
@ConfigurationPropertiesScan
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
// Usage: depends on a typed object, not on property strings
@Service
public class PaymentService {
private final PaymentProperties config;
public PaymentService(PaymentProperties config) {
this.config = config;
}
public PaymentResponse charge(BigDecimal amount, String currency) {
if (!config.supportedCurrencies().contains(currency)) {
throw new UnsupportedCurrencyException(currency);
}
// config.gatewayUrl(), config.timeout(), config.retry().maxAttempts() ...
}
}
# application.yml
# app:
# payments:
# gateway-url: https://payments.example.com/v2
# api-key: ${PAYMENT_API_KEY}
# timeout: 15s
# retry:
# max-attempts: 3
# initial-delay: 200msSpring Cloud Config Server
Config Server serves configuration from a Git repository over HTTP, so changes are reviewed, versioned and auditable like any other code. Each client asks for its configuration by application name and active profile, and the server merges application.yml, {name}.yml and {name}-{profile}.yml from the repository.
Treat the config repo like production code. Protect the main branch, require reviews, and never commit plain-text secrets; use the server's encryption support or reference an external secret store instead. Also remember that a config server is a startup dependency: use fail-fast with retry on clients so they wait for it instead of crashing during a rolling restart.
Pushing a change to Git does not update running services by itself. Clients pick up new values when you call POST /actuator/refresh, or when Spring Cloud Bus broadcasts a refresh to every instance.
yaml
// Server: a tiny application
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
# Server application.yml
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: https://git.example.com/platform/config-repo.git
default-label: main
search-paths: "{application}" # one folder per service
clone-on-start: true
timeout: 10
# Repository layout
# order-service/
# application.yml shared by all profiles
# application-prod.yml production overrides
# payment-service/
# application.yml
# Client application.yml (Spring Boot 2.4+ style, no bootstrap.yml)
spring:
application:
name: order-service
config:
import: "configserver:http://config-server:8888"
cloud:
config:
fail-fast: true
retry:
max-attempts: 6
initial-interval: 1000
management:
endpoints:
web:
exposure:
include: health,refresh,envFeature Flags
A feature flag lets you deploy code in a disabled state and switch it on later without a new release. For simple cases, a configuration properties class is enough. Note that these must be mutable (a plain class with setters, not a record) because Spring rebinds the same bean when an EnvironmentChangeEvent fires, for example after /actuator/refresh. You do not need @RefreshScope on a @ConfigurationProperties bean; that annotation is for ordinary beans that must be recreated.
Keep percentage rollouts deterministic. Hashing the user id means the same user always gets the same answer, so people do not flip between the old and new experience on every request. Flags should also be temporary: put a removal date in the ticket, because a codebase full of stale flags is harder to reason about than one with none.
If you need per-user targeting, audit trails or a UI for non-engineers, a dedicated tool such as Unleash, LaunchDarkly or Togglz is a better fit than growing this class.
Java
// Mutable on purpose: the bean is rebound when the environment changes
@ConfigurationProperties(prefix = "app.features")
public class FeatureFlags {
private boolean newCheckoutFlow = false;
private int newCheckoutRollout = 0; // percentage, 0 to 100
private Set<String> betaUsers = new HashSet<>();
public boolean isNewCheckoutFlow() { return newCheckoutFlow; }
public void setNewCheckoutFlow(boolean v) { this.newCheckoutFlow = v; }
public int getNewCheckoutRollout() { return newCheckoutRollout; }
public void setNewCheckoutRollout(int v) { this.newCheckoutRollout = v; }
public Set<String> getBetaUsers() { return betaUsers; }
public void setBetaUsers(Set<String> v) { this.betaUsers = v; }
}
@Service
public class FeatureFlagService {
private final FeatureFlags flags;
public FeatureFlagService(FeatureFlags flags) {
this.flags = flags;
}
public boolean isNewCheckoutEnabled(long userId) {
if (flags.isNewCheckoutFlow()) {
return true; // fully released
}
if (flags.getBetaUsers().contains(String.valueOf(userId))) {
return true; // early-access allowlist
}
int rollout = flags.getNewCheckoutRollout();
return rollout > 0 && Math.floorMod(Long.hashCode(userId), 100) < rollout;
}
}
@RestController
@RequestMapping("/api/v1/checkout")
public class CheckoutController {
private final FeatureFlagService flags;
private final CheckoutService checkout;
public CheckoutController(FeatureFlagService flags, CheckoutService checkout) {
this.flags = flags;
this.checkout = checkout;
}
@PostMapping
public CheckoutResponse checkout(@RequestBody @Valid CheckoutRequest request,
@AuthenticationPrincipal AppUser user) {
return flags.isNewCheckoutEnabled(user.getId())
? checkout.processNew(request)
: checkout.processLegacy(request);
}
}
# Config repo, order-service/application-prod.yml
# app:
# features:
# new-checkout-rollout: 25
# beta-users: ["1001", "1002"]
# Commit, then POST /actuator/refresh on the instances (or publish via Spring Cloud Bus).Kubernetes ConfigMap and Secret Integration
On Kubernetes you can skip Config Server entirely: the platform already stores and versions configuration. Put non-sensitive settings in a ConfigMap and sensitive ones in a Secret, then expose them to the pod.
There are two ways to do it. Environment variables are the simplest, and relaxed binding maps DATABASE_URL to the matching property. Mounted volumes work better for secrets because env vars leak into crash dumps, child processes and /proc, and they do not update while the pod runs. Spring Boot's configtree import reads a mounted directory where each file name is a property key and each file's contents are its value, which matches how Kubernetes mounts a Secret.
Mounted ConfigMap and Secret volumes are updated by the kubelet eventually, but your beans will not see the change unless something triggers a refresh. Spring Cloud Kubernetes can watch for changes and reload, but a rolling restart is often the more predictable choice for anything that is not a feature flag.
yaml
# ConfigMap: non-sensitive settings
apiVersion: v1
kind: ConfigMap
metadata:
name: order-service-config
namespace: production
data:
application.yml: |
app:
features:
new-checkout-rollout: 10
payments:
gateway-url: https://payments.prod.example.com
timeout: 15s
---
# Secret: created by your pipeline or an external secrets operator,
# never committed to Git with real values
apiVersion: v1
kind: Secret
metadata:
name: order-service-secrets
namespace: production
type: Opaque
stringData:
spring.datasource.password: "<injected-by-pipeline>"
app.payments.api-key: "<injected-by-pipeline>"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
containers:
- name: order-service
image: registry.example.com/order-service:1.4.2
env:
- name: SPRING_PROFILES_ACTIVE
value: prod
- name: SPRING_CONFIG_IMPORT
value: "optional:file:/config/application.yml,optional:configtree:/secrets/"
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: secrets
mountPath: /secrets
readOnly: true
volumes:
- name: config
configMap:
name: order-service-config
- name: secrets
secret:
secretName: order-service-secrets
# Optional: live reload with Spring Cloud Kubernetes
# Dependency: org.springframework.cloud:spring-cloud-starter-kubernetes-client-config
# The service account needs RBAC permission to get/list/watch configmaps and secrets.
spring:
cloud:
kubernetes:
reload:
enabled: true
strategy: refresh # rebinds properties without restarting
mode: event # or "polling" if watch RBAC is not allowed
# period: 15000 # only used with mode: pollingSecrets Management
Configuration and secrets look the same to Spring, but they should not be handled the same way. A Git-backed config repo is a good home for timeouts and URLs and a bad home for passwords, since Git history is permanent and widely readable.
Common options, from lightest to heaviest: Kubernetes Secrets (base64, not encrypted by default, so enable encryption at rest and restrict RBAC), an external secrets operator that syncs from AWS Secrets Manager, Azure Key Vault or GCP Secret Manager, and HashiCorp Vault through Spring Cloud Vault, which also supports dynamic short-lived database credentials.
Whichever you choose, also stop secrets leaking through the application itself. Spring Boot 3 masks values in /actuator/env and /actuator/configprops by default, and you should keep it that way. Avoid logging configuration objects (a generated toString on a properties class will print the api key), and restrict the actuator endpoints to an internal port or authenticated role.
yaml
# application.yml: hardening the actuator
management:
endpoint:
env:
show-values: NEVER # default is NEVER in Boot 3; do not loosen it in prod
configprops:
show-values: NEVER
endpoints:
web:
exposure:
include: health,info # expose env/refresh only on a protected management port
server:
port: 9090 # separate port, not published outside the cluster
# Spring Cloud Vault: pull secrets at startup
spring:
config:
import: "vault://"
cloud:
vault:
uri: https://vault.example.com:8200
authentication: KUBERNETES
kubernetes:
role: order-service
kv:
enabled: true
backend: secret
default-context: order-service
# Dynamic credentials: a fresh DB user per instance, rotated automatically
database:
enabled: true
role: order-service-rw
backend: database
// Keep secrets out of logs
public record PaymentProperties(
@NotBlank String gatewayUrl,
@NotBlank String apiKey /* ... */) {
@Override
public String toString() {
return "PaymentProperties[gatewayUrl=" + gatewayUrl + ", apiKey=****]";
}
}Configuration Validation
A service that starts with a missing database URL and fails on the first request is worse than one that refuses to start. Adding @Validated to a @ConfigurationProperties class makes Spring Boot run Bean Validation during binding, so the application stops immediately and the error message names the exact property that is wrong.
Annotations cover single fields. For rules that involve several fields, or the active profile, put the rule inside the properties class itself with @AssertTrue, or implement Spring's Validator interface. Avoid reading System.getenv in a startup listener for this: the Environment abstraction already knows which profiles are active and works in tests too.
Validation only protects against bad values at startup. If you rebind properties at runtime through /actuator/refresh, the same constraints are re-checked and a failed rebind leaves the previous values in place, but you should still alert on refresh failures.
Java
@ConfigurationProperties(prefix = "app")
@Validated
public class AppProperties {
@Valid @NotNull private Database database = new Database();
@Valid @NotNull private Security security = new Security();
public static class Database {
@NotBlank(message = "app.database.url must be configured")
private String url;
@Min(value = 1, message = "Pool size must be at least 1")
@Max(value = 100, message = "Pool size must not exceed 100")
private int poolSize = 10;
@DurationMin(millis = 100)
private Duration connectionTimeout = Duration.ofSeconds(30);
// getters and setters omitted
}
public static class Security {
@NotBlank
@Size(min = 32, message = "JWT secret must be at least 32 characters")
private String jwtSecret;
@DurationMin(minutes = 1)
@DurationMax(hours = 24)
private Duration accessTokenExpiry = Duration.ofMinutes(15);
// getters and setters omitted
}
// getters and setters omitted
}
// Cross-field and profile-aware rule: validate against the Environment
@Component
public class ProductionSafetyCheck implements InitializingBean {
private final AppProperties properties;
private final Environment environment;
public ProductionSafetyCheck(AppProperties properties, Environment environment) {
this.properties = properties;
this.environment = environment;
}
@Override
public void afterPropertiesSet() {
if (environment.acceptsProfiles(Profiles.of("prod"))
&& properties.getSecurity().getAccessTokenExpiry().compareTo(Duration.ofHours(1)) >= 0) {
throw new IllegalStateException(
"app.security.access-token-expiry must be under 1 hour in the prod profile");
}
}
}
// What a failed binding looks like at startup:
//
// Binding to target AppProperties failed:
// Property: app.security.jwtSecret
// Value: "short"
// Reason: JWT secret must be at least 32 characters
//
// The context never finishes starting, so the readiness probe never passes
// and Kubernetes keeps the bad version out of rotation.