TestForge | 📊 Plogger ✍️ Blog 📚 Docs
TestForge Blog

AI DevOps Korea

A practical hub for operating and improving AI services

Aidevops.kr organizes LLMOps, RAG, agents, evaluation, observability, and cost-performance tuning for teams running AI in production.

← All Posts

Spring Cloud Gateway Architecture — Complete Production Setup Guide

How to build a microservices API Gateway with Spring Cloud Gateway. Routing, filters, JWT auth, rate limiting, circuit breaking, and load balancing — all with production-ready code.

TestForge Team ·

Version Reference

Versions used in this post:

LibraryVersionNotes
Java21LTS
Spring Boot3.3.x
Spring Cloud2023.0.3 (Leyton)Compatible with Boot 3.3.x
Spring Cloud Gateway4.1.xIncluded in Cloud 2023.0.x
Resilience4j2.2.xIncluded in Cloud 2023.0.x
Reactor Netty1.1.xDefault WebFlux server

Spring Boot ↔ Spring Cloud Compatibility

Spring BootSpring CloudCodename
3.3.x2023.0.xLeyton
3.2.x2023.0.xLeyton
3.1.x2022.0.xKilburn
3.0.x2022.0.xKilburn
2.7.x2021.0.xJubilee

Always verify compatibility at the Spring Cloud release page.
Mismatched Boot and Cloud versions cause bean conflicts and auto-configuration errors.


What Is Spring Cloud Gateway?

The successor to Netflix Zuul. A non-blocking async API Gateway built on Spring WebFlux (Reactor).

Legacy Zuul 1.x     → Servlet-based, blocking I/O
Spring Cloud Gateway → WebFlux-based, non-blocking I/O (2–3x throughput)

Three Core Concepts

Route     : Where to send the request (id, uri, predicate, filter)
Predicate : Request matching condition (Path, Header, Method, Query...)
Filter    : Request/response processing (auth, logging, rate limit, transformation...)

Flow:

Client Request

Gateway Handler Mapping (Predicate matching)

Gateway Web Handler

Pre Filters → Proxied Service → Post Filters

Client Response

1. Project Setup

<!-- pom.xml -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>
</parent>

<properties>
    <java.version>21</java.version>
    <spring-cloud.version>2023.0.3</spring-cloud.version>
</properties>

<dependencies>
    <!-- Spring Cloud Gateway (WebFlux-based; remove spring-boot-starter-web) -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>

    <!-- Eureka Client — required for lb://SERVICE-NAME routing -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>

    <!-- Resilience4j circuit breaker (Reactor-based) -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
    </dependency>

    <!-- Redis rate limiter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
    </dependency>

    <!-- JWT parsing -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.6</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Note: Spring Cloud Gateway is incompatible with Spring MVC (spring-boot-starter-web). Remove it.


2. Routing Configuration

spring:
  cloud:
    gateway:
      routes:
        # User service
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/api/v1/users/**
          filters:
            - StripPrefix=2
            - name: CircuitBreaker
              args:
                name: userServiceCB
                fallbackUri: forward:/fallback/user

        # Order service
        - id: order-service
          uri: lb://ORDER-SERVICE
          predicates:
            - Path=/api/v1/orders/**
            - Method=GET,POST
          filters:
            - StripPrefix=2
            - AddRequestHeader=X-Gateway-Source, spring-cloud-gateway

        # Payment service (rate limited + required header)
        - id: payment-service
          uri: lb://PAYMENT-SERVICE
          predicates:
            - Path=/api/v1/pay/**
            - Header=X-Request-ID, .+
          filters:
            - StripPrefix=2
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
                key-resolver: "#{@userKeyResolver}"

        # Internal-only route (block external access)
        - id: internal-block
          uri: no://op
          predicates:
            - Path=/api/internal/**
          filters:
            - name: SetStatus
              args:
                status: 403

Java Config

@Configuration
public class GatewayConfig {

    @Bean
    public RouteLocator routeLocator(RouteLocatorBuilder builder) {
        return builder.routes()
            .route("user-service", r -> r
                .path("/api/v1/users/**")
                .filters(f -> f
                    .stripPrefix(2)
                    .addRequestHeader("X-Gateway-Source", "spring-cloud-gateway")
                    .circuitBreaker(c -> c
                        .setName("userServiceCB")
                        .setFallbackUri("forward:/fallback/user")
                    )
                )
                .uri("lb://USER-SERVICE")
            )
            .build();
    }
}

3. JWT Auth Filter

Validate JWTs at the Gateway to eliminate duplicated auth logic in backend services.

@Component
@RequiredArgsConstructor
public class JwtAuthFilter implements GlobalFilter, Ordered {

    private final JwtTokenProvider jwtTokenProvider;

    private static final List<String> WHITELIST = List.of(
        "/api/v1/auth/login",
        "/api/v1/auth/signup",
        "/api/v1/auth/refresh",
        "/actuator/health"
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getPath().value();

        if (WHITELIST.stream().anyMatch(path::startsWith)) {
            return chain.filter(exchange);
        }

        String authHeader = exchange.getRequest()
            .getHeaders()
            .getFirst(HttpHeaders.AUTHORIZATION);

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return unauthorized(exchange, "Authorization header missing");
        }

        String token = authHeader.substring(7);

        try {
            Claims claims = jwtTokenProvider.validateAndGetClaims(token);

            ServerHttpRequest mutatedRequest = exchange.getRequest()
                .mutate()
                .header("X-User-Id", claims.getSubject())
                .header("X-User-Role", claims.get("role", String.class))
                .build();

            return chain.filter(exchange.mutate().request(mutatedRequest).build());

        } catch (ExpiredJwtException e) {
            return unauthorized(exchange, "Token expired");
        } catch (JwtException e) {
            return unauthorized(exchange, "Invalid token");
        }
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String body = """
            {"error": "UNAUTHORIZED", "message": "%s"}
            """.formatted(message);
        DataBuffer buffer = response.bufferFactory()
            .wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

    @Override
    public int getOrder() {
        return -100;
    }
}

4. Rate Limiting (Redis-backed)

spring:
  data:
    redis:
      host: redis
      port: 6379
  cloud:
    gateway:
      routes:
        - id: api-route
          uri: lb://API-SERVICE
          predicates:
            - Path=/api/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100
                redis-rate-limiter.burstCapacity: 200
                redis-rate-limiter.requestedTokens: 1
                key-resolver: "#{@userKeyResolver}"
@Configuration
public class RateLimiterConfig {

    @Bean
    public KeyResolver userKeyResolver() {
        return exchange -> {
            String userId = exchange.getRequest()
                .getHeaders()
                .getFirst("X-User-Id");
            return Mono.just(userId != null ? userId : "anonymous");
        };
    }

    @Bean
    public KeyResolver ipKeyResolver() {
        return exchange -> Mono.just(
            Objects.requireNonNull(
                exchange.getRequest().getRemoteAddress()
            ).getAddress().getHostAddress()
        );
    }
}

When rate limit is exceeded:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Remaining: 0
X-RateLimit-Replenish-Rate: 100
X-RateLimit-Burst-Capacity: 200

5. Circuit Breaker (Resilience4j)

resilience4j:
  circuitbreaker:
    instances:
      userServiceCB:
        slidingWindowSize: 10
        failureRateThreshold: 50
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 3
        minimumNumberOfCalls: 5
      orderServiceCB:
        slidingWindowSize: 20
        failureRateThreshold: 30
        waitDurationInOpenState: 30s
  timelimiter:
    instances:
      userServiceCB:
        timeoutDuration: 3s
      orderServiceCB:
        timeoutDuration: 5s
@RestController
@RequestMapping("/fallback")
public class FallbackController {

    @GetMapping("/user")
    public ResponseEntity<Map<String, String>> userFallback() {
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(Map.of(
                "error", "USER_SERVICE_UNAVAILABLE",
                "message", "User service is temporarily unavailable. Please try again later."
            ));
    }
}

6. Request/Response Logging Filter

@Component
@Slf4j
public class LoggingFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String requestId = UUID.randomUUID().toString().substring(0, 8);
        long startTime = System.currentTimeMillis();

        log.info("[{}] {} {} | IP: {} | User: {}",
            requestId,
            request.getMethod(),
            request.getPath(),
            getClientIp(request),
            request.getHeaders().getFirst("X-User-Id")
        );

        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            long duration = System.currentTimeMillis() - startTime;
            log.info("[{}] {} {} → {} ({}ms)",
                requestId,
                request.getMethod(),
                request.getPath(),
                exchange.getResponse().getStatusCode(),
                duration
            );
        }));
    }

    @Override
    public int getOrder() {
        return -200;
    }
}

7. CORS Configuration

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOriginPatterns:
              - "https://testforge.kr"
              - "https://*.testforge.kr"
              - "http://localhost:3000"
            allowedMethods: [GET, POST, PUT, DELETE, OPTIONS]
            allowedHeaders: [Authorization, Content-Type, X-Request-ID]
            exposedHeaders: [X-RateLimit-Remaining]
            allowCredentials: true
            maxAge: 3600

8. Full Architecture Diagram

                    ┌─────────────────────────────────────┐
                    │        Spring Cloud Gateway          │
                    │                                      │
  Client ──────────│──→ LoggingFilter (order: -200)       │
                    │──→ JwtAuthFilter (order: -100)       │
                    │──→ RateLimitFilter                   │
                    │──→ CircuitBreakerFilter               │
                    │──→ Route Matching                    │
                    └──────────────┬──────────────────────┘
                                   │ lb://SERVICE-NAME
                          ┌────────┴────────┐
                          │  Eureka / K8s   │  Service Discovery
                          └────────┬────────┘
                 ┌─────────────────┼──────────────────┐
                 ↓                 ↓                  ↓
         [USER-SERVICE]    [ORDER-SERVICE]    [PAYMENT-SERVICE]

Kong vs Spring Cloud Gateway

SituationRecommendation
Spring Boot microservices teamSpring Cloud Gateway
Mixed language/framework environmentKong / Nginx
Rich plugin ecosystem neededKong
Reactive streamingSpring Cloud Gateway
Code-level customizationSpring Cloud Gateway
Managed by a separate infra teamKong / AWS API Gateway

Within the Spring ecosystem, Spring Cloud Gateway is the most natural fit.
JWT filters, Rate Limiters, and Circuit Breakers are all managed as code using Spring standards, resulting in high maintainability.