Skip to main content
spring internals

Config Server and Refresh Scope: Property Source Bootstrap, Encryption, and Live Configuration Without Restart

6 min read Chapter 61 of 78

Config Server and Refresh Scope

Hardcoded configuration kills multi-tenant SaaS. The tenant-service needs different database URLs per environment. The billing-service needs feature flags that change without redeployment. The order-service needs connection pool sizes tuned per load profile. Spring Cloud Config Server centralizes configuration. @RefreshScope makes it live-updatable. Together, they let you change behavior without restarting a single JVM.

Config Server with RefreshScope lifecycle showing property refresh, bean destruction, and re-creation on next access

Config Server Architecture

Config Server is a Spring Boot application that serves configuration over HTTP. It reads from a backend (Git, Vault, JDBC, file system, or composite) and exposes properties at predictable URLs:

GET /{application}/{profile}
GET /{application}/{profile}/{label}
GET /{application}-{profile}.yml
GET /{application}-{profile}.properties

For the SaaS backend, the Config Server serves:

  • order-service-dev.yml for development
  • order-service-prod.yml for production
  • application.yml for shared defaults

The Git backend is the most common. Config Server clones a Git repository and serves files from it:

# Config Server's application.yml
spring:
  cloud:
    config:
      server:
        git:
          uri: https://gitlab.example.com/config-repo.git
          default-label: main
          search-paths: "{application}"
          clone-on-start: true

clone-on-start: true clones the repository at startup instead of on the first request. Without this, the first client to request configuration waits for the clone. In production, that first request can time out.

The search-paths: '{application}' placeholder means Config Server looks for files in a subdirectory named after the application. order-service gets its config from the order-service/ directory in the Git repo.

When a client requests GET /order-service/prod, Config Server:

  1. Pulls the latest from the Git remote (if clone-on-start is true, it fetches; otherwise it clones).
  2. Reads order-service/application.yml, order-service/application-prod.yml, and the root application.yml.
  3. Merges them with profile-specific properties taking precedence.
  4. Returns the merged result as JSON.

The response is an Environment object:

{
  "name": "order-service",
  "profiles": ["prod"],
  "propertySources": [
    {
      "name": "order-service/application-prod.yml",
      "source": {
        "spring.datasource.url": "jdbc:postgresql://prod-db:5432/orders",
        "app.feature.bulk-orders": true
      }
    },
    {
      "name": "order-service/application.yml",
      "source": {
        "app.max-items-per-order": 100
      }
    }
  ]
}

Property sources are ordered. Profile-specific sources override general ones. This is the same ordering mechanism from CH1’s Environment and CH7’s property binding, applied remotely.

Client Bootstrap: spring.config.import

In Spring Boot 3 and Spring Cloud 2023.x, the bootstrap context is deprecated. The recommended approach uses spring.config.import:

# order-service's application.yml
spring:
  application:
    name: order-service
  config:
    import: "configserver:http://config-server:8888"
  cloud:
    config:
      fail-fast: true
      retry:
        initial-interval: 2000
        max-attempts: 5
        max-interval: 10000

During Environment preparation (the earliest phase of Spring Boot startup, covered in CH1-S1), Spring Boot processes spring.config.import. The configserver: prefix triggers ConfigServerConfigDataLocationResolver, which creates a ConfigServerConfigDataLoader.

The loader makes an HTTP call to the Config Server at http://config-server:8888/order-service/default (using the application name and active profiles). The returned properties are added to the Environment as a PropertySource with a precedence between system properties and local application properties.

This means Config Server properties override your local application.yml values. If Config Server has app.max-items-per-order: 50 and your local file has app.max-items-per-order: 100, the Config Server value wins.

To override Config Server values locally (useful for development), use system properties or environment variables, which have higher precedence.

Encryption and Decryption

Config Server can encrypt and decrypt sensitive values. Store encrypted values in Git with the {cipher} prefix:

# In the config repository
spring:
  datasource:
    password: "{cipher}AQBhY2...encrypted...base64"

Config Server decrypts these values before sending them to clients. The client receives the plaintext value. Encryption uses either symmetric keys or asymmetric (RSA) key pairs:

# Config Server's encryption configuration
encrypt:
  key-store:
    location: classpath:keystore.p12
    password: ${KEYSTORE_PASSWORD}
    alias: config-key
    type: PKCS12

The alternative: skip Config Server encryption and use Vault as a backend. Vault handles secrets management with lease-based access, rotation, and audit logging. For the SaaS backend, database credentials go in Vault; feature flags go in Git.

spring:
  cloud:
    config:
      server:
        vault:
          host: vault.internal
          port: 8200
          kvVersion: 2
        composite:
          - type: vault
          - type: git
            uri: https://gitlab.example.com/config-repo.git

The composite backend tries Vault first, then Git. Properties from Vault take precedence.

@RefreshScope: Live Configuration

@RefreshScope is a custom bean scope. Beans in this scope are created lazily and cached until a refresh event destroys them. On the next access, they are re-created with current property values.

@RefreshScope
@Component
public class FeatureFlagService {

    @Value("${app.feature.bulk-orders:false}")
    private boolean bulkOrdersEnabled;

    @Value("${app.feature.premium-support:false}")
    private boolean premiumSupportEnabled;

    public boolean isBulkOrdersEnabled() {
        return bulkOrdersEnabled;
    }

    public boolean isPremiumSupportEnabled() {
        return premiumSupportEnabled;
    }
}

When you POST to /actuator/refresh, the following sequence runs:

  1. ContextRefresher.refresh() is called.
  2. It re-fetches properties from all property sources (including Config Server).
  3. It compares old and new property values.
  4. It publishes an EnvironmentChangeEvent with the list of changed keys.
  5. RefreshScope.refreshAll() destroys all beans in the refresh scope.
  6. It publishes RefreshScopeRefreshedEvent.
  7. The next time any @RefreshScope bean is accessed, it is re-created with the new values.

The bean destruction and re-creation is the key mechanism. The old FeatureFlagService instance is discarded. A new one is created, and @Value injection reads the updated app.feature.bulk-orders value from the refreshed Environment.

Spring Cloud Bus

/actuator/refresh refreshes a single instance. In the SaaS backend with 10 instances of order-service, you would need to call refresh on all 10. Spring Cloud Bus solves this with a message broker:

spring:
  cloud:
    bus:
      enabled: true
  rabbitmq:
    host: rabbitmq.internal

POST to /actuator/busrefresh on any instance, and it broadcasts the refresh event over RabbitMQ (or Kafka). All instances in the cluster receive the event and refresh themselves. This is how you update a feature flag across the entire fleet in seconds.

The @RefreshScope Trap

Not all beans belong in @RefreshScope. The scope destroys and re-creates beans on refresh. If the bean holds expensive resources, those resources are destroyed and re-acquired.

// BROKEN: DataSource in @RefreshScope
// On every /actuator/refresh, the connection pool is destroyed.
// All active connections are closed. In-flight transactions fail.
@RefreshScope
@Bean
public DataSource dataSource(
        @Value("${spring.datasource.url}") String url,
        @Value("${spring.datasource.username}") String username,
        @Value("${spring.datasource.password}") String password) {
    HikariDataSource ds = new HikariDataSource();
    ds.setJdbcUrl(url);
    ds.setUsername(username);
    ds.setPassword(password);
    return ds;
}
// CORRECT: Only lightweight configuration beans in @RefreshScope.
// DataSource stays as a regular singleton.
// Feature flags go in @RefreshScope.
@RefreshScope
@Component
public class FeatureFlagService {
    @Value("${app.feature.bulk-orders:false}")
    private boolean bulkOrdersEnabled;
    // Lightweight. No resources. Safe to destroy and re-create.
}

@Bean
public DataSource dataSource(DataSourceProperties properties) {
    // Regular singleton. Never destroyed on refresh.
    return properties.initializeDataSourceBuilder()
        .type(HikariDataSource.class)
        .build();
}

If you genuinely need to update a DataSource URL at runtime (for database failover), use a RoutingDataSource or listen for EnvironmentChangeEvent and reconfigure the connection pool without destroying it.

The rule: @RefreshScope is for beans that are cheap to destroy and re-create. Configuration holders, feature flags, rate limit settings. Not connection pools, thread pools, or scheduled task registrations. If destroying the bean has side effects beyond garbage collection, it does not belong in @RefreshScope.