Centralized Configuration in Spring Boot Microservices Is Not Optional
by Eric Hanson, Backend Developer at Clean Systems Consulting
How Configuration Debt Accumulates
It starts reasonably. One Spring Boot service, an application.yml per environment, secrets injected as environment variables by the deployment pipeline. The setup is simple because the system is simple.
Then you add services. Each one gets its own application.yml, its own application-prod.yml, its own set of environment variables defined somewhere in a CI/CD pipeline YAML that four people have edit access to. A database connection string changes. You update it in three places, miss the fourth, and spend an hour on an incident that boils down to one service pointing at the wrong host.
Or worse: a credential rotates, you update it in the secrets manager, but three services are still reading the old value from environment variables baked into their container definitions at deploy time — and won't pick up the change until someone manually redeploys each one.
At five services, this is annoying. At fifteen, it is a recurring source of production incidents. Centralized configuration is the fix, and the right time to implement it was before the fifth service, not after the fifteenth.
What Centralized Configuration Actually Means
Not a shared application.yml committed to a monorepo. That solves the co-location problem but not the runtime problem — services still need to be redeployed to pick up changes, and secrets still live in version control.
A real centralized configuration system has three properties:
Single source of truth — one place to define a configuration value, no matter how many services consume it. Changing it once propagates everywhere without touching individual service deployments.
Environment awareness — the same key resolves to different values per environment without duplication of the service-level config structure.
Secret separation — non-sensitive config and sensitive credentials are managed differently, with credentials stored in a secrets manager and never in version control or environment variable definitions.
Spring Boot gives you two mature paths: Spring Cloud Config Server backed by a Git repository, and native integration with HashiCorp Vault or cloud-native secrets managers (AWS Secrets Manager, GCP Secret Manager). In practice, most production systems end up using both — Config Server for application properties, Vault or a cloud secrets manager for credentials.
Spring Cloud Config Server: The Baseline
Config Server is a Spring Boot application that serves configuration over HTTP from a backing Git repository. Each client service bootstraps by calling the Config Server on startup, receives its resolved properties, and merges them with local values.
The server setup is minimal:
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
# application.yml for the Config Server itself
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: https://github.com/your-org/service-config
default-label: main
search-paths: '{application}'
clone-on-start: true
The Git repository structure mirrors the Spring profile convention. For a service named payment-service:
service-config/
payment-service/
application.yml # shared across all environments
application-dev.yml
application-staging.yml
application-prod.yml
order-service/
application.yml
application-prod.yml
application.yml # global defaults, all services
On the client side, each service declares its Config Server endpoint in bootstrap.yml — which loads before application.yml and before the application context initializes:
# bootstrap.yml in each client service
spring:
application:
name: payment-service
cloud:
config:
uri: http://config-server:8888
fail-fast: true
retry:
max-attempts: 6
initial-interval: 1000
multiplier: 1.5
fail-fast: true is important. Without it, a service will start successfully even if it cannot reach the Config Server, falling back to local properties. In production, that means a service silently running with stale or missing configuration. Fail fast, restart, alert — that's the correct behavior when config is unavailable.
Live Refresh Without Redeployment
The first thing teams want after setting up Config Server is live config refresh — changing a value in the Git repo and having running services pick it up without restarting. Spring Cloud Bus with Kafka or RabbitMQ handles this.
When a config change is pushed to the Git repository, a webhook triggers a /actuator/busrefresh call on the Config Server. Spring Cloud Bus broadcasts a refresh event to all connected services over the message broker. Each service re-fetches its configuration and rebinds @RefreshScope beans.
@RestController
@RefreshScope
public class FeatureFlagController {
@Value("${features.new-checkout-flow.enabled:false}")
private boolean newCheckoutFlowEnabled;
@GetMapping("/checkout")
public ResponseEntity<?> checkout() {
if (newCheckoutFlowEnabled) {
return newCheckoutHandler.handle();
}
return legacyCheckoutHandler.handle();
}
}
The @RefreshScope annotation means this bean is destroyed and recreated on a refresh event, picking up the new property value. Beans without @RefreshScope are not refreshed — they hold their initial values until the service restarts.
This is a meaningful operational capability for feature flags and non-sensitive tuning parameters. It is not the right mechanism for credential rotation — that belongs in Vault.
Secrets Belong in Vault, Not Git
Storing secrets in the Config Server's Git repository — even a private one, even encrypted — is the wrong model. Git history is permanent, access controls are coarse, and rotation requires a commit.
HashiCorp Vault integrates directly with Spring Boot via Spring Cloud Vault. Services authenticate to Vault using Kubernetes service account tokens (in a Kubernetes deployment) or AppRole credentials, and receive secrets scoped to their identity:
# bootstrap.yml addition for Vault integration
spring:
cloud:
vault:
uri: https://vault.internal:8200
authentication: KUBERNETES
kubernetes:
role: payment-service
kv:
enabled: true
backend: secret
default-context: payment-service
Vault serves secrets at secret/payment-service as Spring-compatible properties. A database credential at secret/payment-service/database.password becomes available as ${database.password} in the application context, indistinguishable from any other property — but sourced from Vault, rotated in Vault, audited in Vault.
Dynamic secrets are Vault's strongest capability for databases: instead of a static password shared across all instances, Vault generates a unique credential per service instance with a TTL, and rotates it automatically. The service never handles a long-lived credential. If a credential leaks, its blast radius is bounded by the TTL.
Spring Cloud Vault's database secrets backend with the Postgres plugin:
spring:
cloud:
vault:
database:
enabled: true
role: payment-service-db-role
backend: database
The datasource URL and username come from application.yml. The password is injected by Vault at startup and renewed before expiry. The service code sees a standard Spring datasource and has no awareness of the credential lifecycle.
The Kubernetes-Native Alternative
If you're running on Kubernetes and not ready to operate Vault, Kubernetes Secrets surfaced as environment variables or mounted volumes, combined with External Secrets Operator pulling from AWS Secrets Manager or GCP Secret Manager, covers most of the same ground with less operational overhead.
External Secrets Operator syncs a SecretStore (pointing at AWS Secrets Manager, for example) into native Kubernetes Secrets on a configurable refresh interval:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: payment-service-db-credentials
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: payment-service-db-credentials
data:
- secretKey: database.password
remoteRef:
key: prod/payment-service/database
property: password
The Spring Boot service mounts the resulting Kubernetes Secret as environment variables or a volume, and reads them as standard Spring properties. Rotation happens in AWS Secrets Manager; ESO syncs the change into the cluster; the pod picks it up on its next restart or via Spring's config refresh if you've wired it that way.
This is operationally simpler than self-managed Vault for teams that are already deep in AWS or GCP and don't want another stateful system to run.
What You Need Before the Sixth Service
You do not need all of this on day one. You do need it before the complexity of managing configuration per-service manually starts causing incidents.
The minimum viable centralized config setup: Spring Cloud Config Server backed by a private Git repository, fail-fast enabled on all clients, and credentials sourced from your cloud provider's secrets manager rather than environment variables in your pipeline definition. That covers the failure modes that actually happen — stale config after a credential rotation, config drift between environments, secrets committed to version control by accident.
Vault and dynamic credentials are the next step when the team has the operational capacity to run a stateful secrets system. External Secrets Operator is the right choice if you're on Kubernetes and already in a cloud provider's ecosystem.
The configuration you scatter across a dozen services today is the incident you investigate at 2am next quarter. Centralize it before that happens, not after.