Exposes a {@code GET /relay?path=...} endpoint that forwards the request to
+ * the upstream resolved by the xDS listener and returns the response.
+ */
+@SpringBootApplication
+@RestController
+public class ClientMain {
+
+ private final WebClient xdsWebClient;
+
+ public ClientMain(WebClient xdsWebClient) {
+ this.xdsWebClient = xdsWebClient;
+ }
+
+ public static void main(String[] args) {
+ createApplication().run(args);
+ }
+
+ public static SpringApplication createApplication() {
+ final SpringApplication app = new SpringApplication(ClientMain.class);
+ app.setAdditionalProfiles("client");
+ // spring-cloud-config-server on the classpath disables the config client
+ // via ConfigServerBootstrapApplicationListener; re-enable it explicitly.
+ app.setDefaultProperties(Map.of("spring.cloud.config.enabled", "true"));
+ return app;
+ }
+
+ @GetMapping("/relay")
+ String relay(@RequestParam(defaultValue = "/actuator/health") String path) {
+ final AggregatedHttpResponse response = xdsWebClient.get(path).aggregate().join();
+ return response.status() + "\n" + response.contentUtf8();
+ }
+}
diff --git a/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/XdsClientConfig.java b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/XdsClientConfig.java
new file mode 100644
index 00000000000..55393e54c61
--- /dev/null
+++ b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/client/XdsClientConfig.java
@@ -0,0 +1,22 @@
+package example.springframework.boot.xds.cloudconfig.client;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.xds.XdsBootstrap;
+import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor;
+
+@Configuration
+class XdsClientConfig {
+
+ @Bean
+ XdsHttpPreprocessor xdsHttpPreprocessor(XdsBootstrap xdsBootstrap) {
+ return XdsHttpPreprocessor.ofListener("test-listener", xdsBootstrap);
+ }
+
+ @Bean
+ WebClient xdsWebClient(XdsHttpPreprocessor xdsHttpPreprocessor) {
+ return WebClient.of(xdsHttpPreprocessor);
+ }
+}
diff --git a/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/server/ConfigServerMain.java b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/server/ConfigServerMain.java
new file mode 100644
index 00000000000..243a8b7c295
--- /dev/null
+++ b/examples/spring-cloud-config-xds/src/main/java/example/springframework/boot/xds/cloudconfig/server/ConfigServerMain.java
@@ -0,0 +1,26 @@
+package example.springframework.boot.xds.cloudconfig.server;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.config.server.EnableConfigServer;
+
+import com.linecorp.armeria.spring.xds.SpringXdsAutoConfiguration;
+
+/**
+ * A Spring Cloud Config Server that serves xDS cluster definitions from a native
+ * (file-based) repository.
+ */
+@EnableConfigServer
+@SpringBootApplication(exclude = SpringXdsAutoConfiguration.class)
+public class ConfigServerMain {
+
+ public static void main(String[] args) {
+ createApplication().run(args);
+ }
+
+ public static SpringApplication createApplication() {
+ final SpringApplication app = new SpringApplication(ConfigServerMain.class);
+ app.setAdditionalProfiles("server", "native");
+ return app;
+ }
+}
diff --git a/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml b/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml
new file mode 100644
index 00000000000..15d1f513336
--- /dev/null
+++ b/examples/spring-cloud-config-xds/src/main/resources/config-repo/application.yml
@@ -0,0 +1,37 @@
+armeria:
+ xds:
+ listener:
+ test-listener: |
+ name: test-listener
+ api_listener:
+ api_listener:
+ "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ codec_type: AUTO
+ stat_prefix: ingress_http
+ route_config:
+ name: local_route
+ virtual_hosts:
+ - name: local_service
+ domains: ["*"]
+ routes:
+ - match:
+ prefix: /
+ route:
+ cluster: test-cluster
+ http_filters:
+ - name: envoy.filters.http.router
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
+ cluster:
+ test-cluster: |
+ name: test-cluster
+ type: STATIC
+ load_assignment:
+ cluster_name: test-cluster
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: 127.0.0.1
+ port_value: 8888
diff --git a/examples/spring-cloud-config-xds/src/main/resources/config/application-client.yml b/examples/spring-cloud-config-xds/src/main/resources/config/application-client.yml
new file mode 100644
index 00000000000..7f05570ae5f
--- /dev/null
+++ b/examples/spring-cloud-config-xds/src/main/resources/config/application-client.yml
@@ -0,0 +1,6 @@
+spring:
+ config:
+ "import": "configserver:"
+ cloud:
+ config:
+ uri: http://localhost:8888
diff --git a/examples/spring-cloud-config-xds/src/main/resources/config/application-server.yml b/examples/spring-cloud-config-xds/src/main/resources/config/application-server.yml
new file mode 100644
index 00000000000..484b05b838c
--- /dev/null
+++ b/examples/spring-cloud-config-xds/src/main/resources/config/application-server.yml
@@ -0,0 +1,9 @@
+server:
+ port: 8888
+
+spring:
+ cloud:
+ config:
+ server:
+ native:
+ searchLocations: classpath:/config-repo/
diff --git a/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java b/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java
new file mode 100644
index 00000000000..d9debcc06b5
--- /dev/null
+++ b/examples/spring-cloud-config-xds/src/test/java/example/springframework/boot/xds/cloudconfig/SpringCloudConfigXdsExampleTest.java
@@ -0,0 +1,128 @@
+package example.springframework.boot.xds.cloudconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.web.server.context.WebServerApplicationContext;
+import org.springframework.cloud.context.environment.EnvironmentChangeEvent;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.MapPropertySource;
+
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.xds.ClusterSnapshot;
+import com.linecorp.armeria.xds.XdsBootstrap;
+
+import example.springframework.boot.xds.cloudconfig.client.ClientMain;
+import example.springframework.boot.xds.cloudconfig.server.ConfigServerMain;
+
+@SpringBootTest(
+ classes = ClientMain.class,
+ properties = {
+ "spring.config.import=configserver:",
+ "spring.cloud.config.enabled=true"
+ })
+class SpringCloudConfigXdsExampleTest {
+
+ private static ConfigurableApplicationContext server;
+ private static int configPort;
+
+ @BeforeAll
+ static void startConfigServer() {
+ server = ConfigServerMain.createApplication().run("--server.port=0");
+
+ configPort = ((WebServerApplicationContext) server).getWebServer().getPort();
+ System.setProperty("spring.cloud.config.uri", "http://localhost:" + configPort);
+ }
+
+ @AfterAll
+ static void stopConfigServer() {
+ System.clearProperty("spring.cloud.config.uri");
+ if (server != null) {
+ server.close();
+ }
+ }
+
+ @Autowired
+ WebClient xdsWebClient;
+
+ @Autowired
+ XdsBootstrap xdsBootstrap;
+
+ @Autowired
+ ConfigurableEnvironment environment;
+
+ @Autowired
+ ApplicationContext applicationContext;
+
+ @Test
+ void webClientViaXdsPreprocessor() {
+ // Verify the initial cluster snapshot loaded from config server (port 8888)
+ final AtomicReference snapshotRef = new AtomicReference<>();
+ xdsBootstrap.clusterRoot("test-cluster")
+ .addSnapshotWatcher((snapshot, error) -> {
+ if (snapshot != null) {
+ snapshotRef.set(snapshot);
+ }
+ });
+ await().untilAsserted(() -> {
+ assertThat(snapshotRef.get()).isNotNull();
+ assertThat(endpointPort(snapshotRef.get())).isEqualTo(8888);
+ });
+
+ // Override the cluster endpoint to point at the config server's actual port
+ environment.getPropertySources()
+ .addFirst(new MapPropertySource("test",
+ Map.of("armeria.xds.cluster.test-cluster", clusterYaml(configPort))));
+ applicationContext.publishEvent(
+ new EnvironmentChangeEvent(Set.of("armeria.xds.cluster.test-cluster")));
+
+ // Wait for the xDS update to propagate
+ await().untilAsserted(() ->
+ assertThat(endpointPort(snapshotRef.get())).isEqualTo(configPort));
+
+ // Call the config server's actuator health endpoint via the xDS-resolved client
+ final AggregatedHttpResponse response =
+ xdsWebClient.get("/actuator/health").aggregate().join();
+ assertThat(response.status()).isEqualTo(HttpStatus.OK);
+ }
+
+ private static int endpointPort(ClusterSnapshot snapshot) {
+ return snapshot.xdsResource().resource()
+ .getLoadAssignment()
+ .getEndpoints(0)
+ .getLbEndpoints(0)
+ .getEndpoint()
+ .getAddress()
+ .getSocketAddress()
+ .getPortValue();
+ }
+
+ private static String clusterYaml(int port) {
+ return """
+ name: test-cluster
+ type: STATIC
+ load_assignment:
+ cluster_name: test-cluster
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: 127.0.0.1
+ port_value: %d
+ """.formatted(port);
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index c7c2db93ca9..7fa943cc188 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -189,6 +189,7 @@ includeWithFlags ':spring:boot4-autoconfigure', 'java17', 'publish', 'r
includeWithFlags ':spring:boot4-starter', 'java17', 'publish', 'relocate', 'no_aggregation'
includeWithFlags ':spring:boot4-webflux-autoconfigure', 'java17', 'publish', 'relocate'
includeWithFlags ':spring:boot4-webflux-starter', 'java17', 'publish', 'relocate', 'no_aggregation'
+includeWithFlags ':spring:boot4-xds', 'java17', 'publish', 'relocate'
includeWithFlags ':spring:spring7', 'java17', 'publish', 'relocate'
includeWithFlags ':dropwizard2', 'java', 'publish', 'relocate'
@@ -332,6 +333,7 @@ includeWithFlags ':examples:spring-boot-minimal', 'java17'
includeWithFlags ':examples:spring-boot-minimal-kotlin', 'java17', 'kotlin'
includeWithFlags ':examples:spring-boot-tomcat', 'java17'
includeWithFlags ':examples:spring-boot-webflux', 'java17'
+includeWithFlags ':examples:spring-cloud-config-xds', 'java17'
includeWithFlags ':examples:static-files', 'java11'
includeWithFlags ':examples:thrift', 'java11'
includeWithFlags ':examples:tutorials:grpc-tutorial', 'java11'
diff --git a/spring/boot4-xds/build.gradle b/spring/boot4-xds/build.gradle
new file mode 100644
index 00000000000..07520e92c56
--- /dev/null
+++ b/spring/boot4-xds/build.gradle
@@ -0,0 +1,9 @@
+dependencies {
+ api project(':xds')
+ api libs.spring.boot4.autoconfigure
+ api libs.spring.cloud.context
+
+ annotationProcessor libs.spring.boot4.configuration.processor
+
+ testImplementation libs.spring.boot4.starter.test
+}
diff --git a/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java
new file mode 100644
index 00000000000..d92af8f3445
--- /dev/null
+++ b/spring/boot4-xds/src/main/java/com/linecorp/armeria/spring/xds/SpringConfigSourceFactory.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2026 LY Corporation
+ *
+ * LY Corporation licenses this file to you under the Apache License,
+ * version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.linecorp.armeria.spring.xds;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+import org.springframework.core.env.Environment;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.protobuf.Any;
+import com.google.protobuf.GeneratedMessageV3;
+
+import com.linecorp.armeria.common.annotation.UnstableApi;
+import com.linecorp.armeria.xds.SnapshotWatcher;
+import com.linecorp.armeria.xds.XdsResourceReader;
+import com.linecorp.armeria.xds.XdsType;
+import com.linecorp.armeria.xds.configsource.InterestedResources;
+import com.linecorp.armeria.xds.configsource.SotwConfigSourceSubscriptionFactory;
+import com.linecorp.armeria.xds.filter.FactoryContext;
+import com.linecorp.armeria.xds.spring.SpringConfigSource;
+import com.linecorp.armeria.xds.stream.SnapshotStream;
+import com.linecorp.armeria.xds.stream.Subscription;
+
+import io.envoyproxy.envoy.config.core.v3.ConfigSource;
+import io.envoyproxy.envoy.service.discovery.v3.DiscoveryResponse;
+
+/**
+ * A {@link SotwConfigSourceSubscriptionFactory} that reads xDS resources from
+ * Spring {@link Environment} properties. Each resource name is mapped to a
+ * property key of the form {@code .}, and the property
+ * value is parsed as the type corresponding to the subscribed {@link XdsType}.
+ *
+ *
The property key prefix is configured via a {@link SpringConfigSource}
+ * packed into the {@code typed_config} field of the bootstrap's
+ * {@code custom_config_source}. The default prefixes are
+ * {@code armeria.xds.listener.} for LDS and {@code armeria.xds.cluster.} for CDS.
+ *
+ *
Call {@link #refresh()} to re-read properties and push updated resources
+ * to subscribers (e.g. from a Spring Cloud Config {@code EnvironmentChangeEvent}).
+ *
+ * @see SpringXdsAutoConfiguration
+ */
+@UnstableApi
+public final class SpringConfigSourceFactory implements SotwConfigSourceSubscriptionFactory {
+
+ private static final String NAME = "armeria.config_source.spring";
+
+ private static final Object SIGNAL = new Object();
+
+ private final Environment environment;
+ private final RefreshSignal refreshSignal = new RefreshSignal();
+
+ /**
+ * Creates a new factory backed by the given Spring {@link Environment}.
+ */
+ public static SpringConfigSourceFactory of(Environment environment) {
+ return new SpringConfigSourceFactory(environment);
+ }
+
+ private SpringConfigSourceFactory(Environment environment) {
+ this.environment = requireNonNull(environment, "environment");
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public SnapshotStream create(ConfigSource configSource,
+ FactoryContext factoryContext,
+ SnapshotStream interestedResources) {
+ final SpringConfigSource springConfigSource =
+ factoryContext.validator().unpack(configSource.getCustomConfigSource().getTypedConfig(),
+ SpringConfigSource.class);
+ final String rawPrefix = springConfigSource.getPrefix().trim();
+ if (rawPrefix.isEmpty() || ".".equals(rawPrefix)) {
+ throw new IllegalArgumentException(
+ "SpringConfigSource 'prefix' must not be empty. " +
+ "Set a prefix such as 'armeria.xds.' in the bootstrap typed_config.");
+ }
+ final String prefix = rawPrefix.endsWith(".") ? rawPrefix : rawPrefix + '.';
+ final Map accumulated = new EnumMap<>(XdsType.class);
+ final SnapshotStream