Netflix Zuul 1 has been dead in the Spring ecosystem for years. Spring Cloud dropped it along with Ribbon and Hystrix, and nothing in the Netflix stack except Eureka survived the cut. If your gateway still says @EnableZuulProxy, you’re pinned to Spring Boot 2.x and a Spring Cloud train that stopped getting patches a long time ago.
The replacement is Spring Cloud Gateway. It’s not a port of Zuul. It’s a different architecture with a different programming model, and the migration is more than a dependency swap.
The good news: Gateway is genuinely better. Routes are declarative, filters compose cleanly, and rate limiting and circuit breaking are built in instead of bolted on.
This guide covers what a gateway actually buys you, how routing and filters work, the WebFlux vs MVC variant decision, and a concrete Zuul migration path with the gotchas that bite in practice.
What an API Gateway Buys You (and When It Doesn’t)
A gateway is a single entry point that handles cross-cutting concerns so your services don’t have to. Authentication, rate limiting, request logging, path rewriting, canary routing. Write it once at the edge instead of in every service.
The less obvious value: a gateway decouples your public API from your internal topology. Clients see /api/orders. Whether that’s served by the monolith or a new service is your business, not theirs. That’s why a gateway is usually the first piece of a strangler-fig migration. You put the gateway in front of the monolith on day one, then peel routes off to new services one at a time. Clients never notice.
When you don’t need one: two services behind an ALB, internal tooling, or a single monolith with no extraction plans. A gateway is another deployable, another network hop, another thing that pages you at 3am. Don’t add it for architecture-diagram aesthetics.
The Reactive Model, and the Newer MVC Variant
The original Spring Cloud Gateway is built on Spring WebFlux and Netty. Requests are handled on a small number of event-loop threads, which is exactly what you want for a proxy: most of a gateway’s life is waiting on downstream I/O, and the reactive model does that waiting without parking a thread per request.
The catch is that event-loop threads must never block. A JDBC call, a blocking HTTP client, or Thread.sleep() inside a filter will stall an event loop and tank throughput for every request on it. This is the single most common way teams hurt themselves with Gateway.
Spring Cloud now ships a second variant: Spring Cloud Gateway Server WebMVC, a servlet-based gateway that runs on Spring MVC. Same route and filter concepts, blocking programming model. In the 2025.x release trains the starters are split explicitly:
spring-cloud-starter-gateway-server-webfluxfor the reactive gatewayspring-cloud-starter-gateway-server-webmvcfor the servlet-based one
Pick WebFlux when the gateway is a mostly-pure proxy and throughput matters. Pick WebMVC when your filters need blocking libraries (JDBC-backed auth lookups, non-reactive SDKs) or your team doesn’t work in reactive code day to day. A correct MVC gateway beats a subtly-blocking WebFlux gateway every time.
The rest of this guide uses the WebFlux variant, since that’s what most existing gateways run and what Zuul migrations typically target.
Setup
One dependency, plus your Spring Cloud BOM:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
</dependencies>
Don’t add spring-boot-starter-web alongside the WebFlux gateway. A servlet container on the classpath conflicts with the reactive runtime, and the failure modes are confusing.
Routes in application.yml
A route has three parts: an ID, a destination URI, and predicates that decide whether a request matches. Filters mutate the request or response on the way through.
spring:
cloud:
gateway:
routes:
- id: orders-service
uri: lb://orders-service
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- AddRequestHeader=X-Gateway-Source, edge
- id: legacy-monolith
uri: http://monolith.internal:8080
predicates:
- Path=/api/**
filters:
- RewritePath=/api/(?<segment>.*), /$\{segment}
default-filters:
- AddResponseHeader=X-Frame-Options, DENY
Things worth knowing here:
- Route order matters. The orders route must sort before the catch-all monolith route. Predicates are evaluated in order, and you can force ordering with an explicit
orderproperty per route. lb://hands resolution to Spring Cloud LoadBalancer, pulling instances from your discovery mechanism (Eureka still works fine for this).StripPrefix=1drops the first path segment before forwarding.RewritePathgives you full regex control when stripping isn’t enough.default-filtersapply to every route. Good for security headers and correlation IDs.
Predicates go well beyond Path: Method, Header, Query, Host, Cookie, After/Before/Between for time-based routing, and Weight for percentage-based traffic splitting between two routes. That last one is how you do canary releases at the gateway with zero extra infrastructure.
Routes in Java
Everything in YAML is available as a fluent DSL. Use Java when routes need logic, or when you want them under test.
@Configuration
public class GatewayRoutes {
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("orders-service", r -> r
.path("/api/orders/**")
.filters(f -> f
.stripPrefix(1)
.addRequestHeader("X-Gateway-Source", "edge")
.retry(retry -> retry
.setRetries(3)
.setMethods(HttpMethod.GET)
.setBackoff(Duration.ofMillis(50),
Duration.ofMillis(500), 2, true)))
.uri("lb://orders-service"))
.route("legacy-monolith", r -> r
.path("/api/**")
.filters(f -> f.rewritePath(
"/api/(?<segment>.*)", "/${segment}"))
.uri("http://monolith.internal:8080"))
.build();
}
}
Note the retry config: GET only. Retrying POSTs through a gateway is how you get duplicate orders. Keep retries on idempotent methods unless you’ve built idempotency keys downstream.
Filters That Do Real Work
Rate Limiting with Redis
Gateway ships a token-bucket rate limiter backed by Redis. Add spring-boot-starter-data-redis-reactive, then:
spring:
cloud:
gateway:
routes:
- id: orders-service
uri: lb://orders-service
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
redis-rate-limiter.requestedTokens: 1
replenishRate is sustained requests per second, burstCapacity is the bucket size. You also need a KeyResolver bean to decide what a “user” is:
@Bean
public KeyResolver apiKeyResolver() {
return exchange -> Mono.just(
Optional.ofNullable(exchange.getRequest()
.getHeaders().getFirst("X-Api-Key"))
.orElse("anonymous"));
}
Rate limiting by IP is the naive default and falls apart behind CDNs and corporate NAT. Key on an API key or authenticated principal when you can.
Circuit Breaker with Resilience4j
Add spring-cloud-starter-circuitbreaker-reactor-resilience4j, then attach the filter:
spring:
cloud:
gateway:
routes:
- id: orders-service
uri: lb://orders-service
predicates:
- Path=/api/orders/**
filters:
- name: CircuitBreaker
args:
name: ordersBreaker
fallbackUri: forward:/fallback/orders
resilience4j:
circuitbreaker:
instances:
ordersBreaker:
slidingWindowSize: 20
failureRateThreshold: 50
waitDurationInOpenState: 10s
timelimiter:
instances:
ordersBreaker:
timeoutDuration: 2s
The fallbackUri forwards to a controller inside the gateway itself, where you return a degraded response instead of a 502. The time limiter matters as much as the breaker: without it, a slow downstream holds connections open and the breaker never sees a failure to count.
Auth Header Propagation
By default Gateway forwards request headers downstream, so a bearer token from the client passes through untouched. If the gateway is an OAuth2 client doing login itself, the TokenRelay filter forwards the access token to downstream services. And when the gateway validates auth at the edge, a common pattern is translating it for internal consumption:
default-filters:
- RemoveRequestHeader=Cookie
- AddRequestHeader=X-Internal-Gateway, "true"
Strip what downstream services shouldn’t see, add what they need.
Global Filters and Custom Filters
A GlobalFilter runs on every route. Correlation IDs are the classic case:
@Component
public class CorrelationIdFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
String correlationId = UUID.randomUUID().toString();
ServerWebExchange mutated = exchange.mutate()
.request(r -> r.header("X-Correlation-Id", correlationId))
.build();
// Anything after chain.filter is your "post" phase
return chain.filter(mutated).then(Mono.fromRunnable(() ->
mutated.getResponse().getHeaders()
.add("X-Correlation-Id", correlationId)));
}
@Override
public int getOrder() {
return -1;
}
}
For per-route filters configurable from YAML, extend AbstractGatewayFilterFactory:
@Component
public class ApiVersionGatewayFilterFactory
extends AbstractGatewayFilterFactory<ApiVersionGatewayFilterFactory.Config> {
public ApiVersionGatewayFilterFactory() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String version = exchange.getRequest().getHeaders()
.getFirst("X-Api-Version");
if (version == null || version.compareTo(config.getMinVersion()) < 0) {
exchange.getResponse().setStatusCode(HttpStatus.GONE);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
};
}
public static class Config {
private String minVersion;
public String getMinVersion() { return minVersion; }
public void setMinVersion(String minVersion) {
this.minVersion = minVersion;
}
}
}
The naming convention matters: a factory called ApiVersionGatewayFilterFactory is referenced in YAML as ApiVersion:
filters:
- name: ApiVersion
args:
minVersion: "2"
Migrating Off Zuul
Zuul 1 is a servlet-based, thread-per-request proxy. Spring Cloud removed spring-cloud-starter-netflix-zuul when the Netflix stack (minus Eureka) went into maintenance, so there is no Zuul on Spring Boot 3. The migration is mandatory if you want current Spring Cloud.
The concepts map cleanly. The code does not port; it gets rewritten against the mapping:
| Zuul 1 | Spring Cloud Gateway |
|---|---|
@EnableZuulProxy | No annotation, just the gateway starter |
zuul.routes.orders.path=/orders/** | Route with Path=/orders/** predicate |
zuul.routes.orders.serviceId | uri: lb://orders-service |
zuul.prefix + stripPrefix | StripPrefix / RewritePath filters |
ZuulFilter with filterType() = "pre" | GatewayFilter logic before chain.filter() |
ZuulFilter with filterType() = "post" | Logic in .then(...) after chain.filter() |
filterOrder() | Ordered interface or @Order |
RequestContext.getCurrentContext() | ServerWebExchange (passed in, not thread-local) |
zuul.sensitiveHeaders | Headers forwarded by default; use RemoveRequestHeader |
| Ribbon load balancing | Spring Cloud LoadBalancer |
| Hystrix fallbacks | CircuitBreaker filter + Resilience4j |
Three gotchas account for most migration pain:
Blocking code in filters. Zuul filters ran on servlet threads, so blocking calls were fine. Port that same filter logic to the WebFlux gateway and you’re blocking an event loop. Audit every pre/post filter for JDBC, RestTemplate, and synchronous SDK calls. Rewrite them reactively (WebClient, reactive Redis), or choose the MVC gateway variant and keep the blocking model deliberately.
Ribbon and Eureka era config. Properties like ribbon.ReadTimeout, hystrix.command.default.*, and per-route Ribbon tweaks silently do nothing on the new stack. Timeouts move to spring.cloud.gateway.httpclient.connect-timeout and response-timeout (globally or per route), and resilience config moves to Resilience4j. Eureka itself still works; everything that sat between Eureka and the wire is replaced.
Sensitive headers flipped defaults. Zuul stripped Cookie, Set-Cookie, and Authorization from downstream requests unless you configured sensitiveHeaders otherwise. Gateway forwards everything by default. If your Zuul setup relied on the stripping, a naive migration leaks cookies and credentials to internal services. Recreate the old behavior explicitly with RemoveRequestHeader in default-filters and decide, header by header, what should actually pass through.
Run the migration strangler-style, the same way you’d carve up a monolith. Stand the new gateway up next to Zuul, move one route, compare behavior under real traffic, move the next. Route-by-route cutover means any single mistake has a one-route blast radius.
When to Get Help
If you’re standing up a fresh gateway in front of a handful of services, this guide plus the reference docs will get you there in a few days. The route model is small and the filters are well documented.
The harder version is the one we usually see: a Zuul gateway with years of accumulated custom filters, auth logic that’s load-bearing and undocumented, Ribbon timeouts nobody remembers tuning, and a monolith behind it that’s supposed to be broken up “at some point.” That’s not a dependency upgrade, it’s an edge-layer rewrite tangled up with a modernization decision, and the sequencing matters more than the code.
We do this work regularly. If your gateway migration is tangled up with a larger monolith-to-services effort, reach out and we can walk through your specific situation.
Related Articles
- Spring Cloud Overview — Where Gateway fits in the wider Spring Cloud toolbox.
- Spring Boot Microservices Architecture Patterns — The patterns a gateway sits in front of.
- Monolith to Microservices with Spring Boot — The strangler-fig migration a gateway makes possible.
- Microservices Development Services — How we help teams build and untangle service architectures.
Frequently Asked Questions
Do I really need an API gateway for my microservices?
Not always. If you have one or two services behind a load balancer, a gateway adds a network hop and an extra deployable for little gain. A gateway earns its place once you need cross-cutting behavior in one spot: authentication, rate limiting, request routing across many services, or a stable public API while you restructure what’s behind it.
What replaced Netflix Zuul in the Spring ecosystem?
Spring Cloud Gateway is the official replacement. Netflix put Zuul 1 into maintenance mode years ago, and Spring Cloud removed its Zuul support along with the rest of the Netflix stack apart from Eureka. Any Spring Boot 3.x application still routing through Zuul is running on a dependency line that no longer exists in current Spring Cloud release trains.
Can I use Spring Cloud Gateway without WebFlux?
Yes. Spring Cloud now ships two variants: the original reactive gateway built on WebFlux and Netty, and a servlet-based gateway that runs on Spring MVC. The MVC variant supports the same route and filter configuration model, and it’s the safer choice if your team writes blocking code in filters or your gateway needs libraries that assume a servlet stack.
How do I map Zuul routes to Spring Cloud Gateway routes?
Each zuul.routes entry becomes a route with a Path predicate and a uri. Zuul’s default prefix stripping maps to the StripPrefix filter, and serviceId-based routing maps to lb:// URIs with Spring Cloud LoadBalancer. Pre and post ZuulFilters become GatewayFilters or global filters, with post logic moving into the reactive chain after chain.filter runs.
Java Modernization Readiness Assessment
15 questions your team should answer before starting a migration. Takes 10 minutes. Could save you months.