Skip to content

c. Gateway

The gateway is the API's front door: every request that passes the edge goes through it. It is built on Spring Cloud Gateway, which runs on Spring WebFlux and Netty — non-blocking, so a few threads proxy thousands of concurrent requests — on port 8080.

Responsibility How
Routing /auth/** → auth; /catalog/** → lb://catalog
Load balancing over service instances Spring Cloud LoadBalancer over catalog-1..3, skipping unhealthy ones
Authentication validates the JWT: signature (JWKS), exp, iss, aud
Authorization (coarse) open / any token / scope admin, per path and method
Identity propagation X-User-Id, X-User-Email, X-User-Scope for the services
Timeouts and retries connect 1 s, response 5 s; one retry for idempotent GETs

1. Project

Create services/gateway (same Dockerfile with EXPOSE 8080, same .dockerignore).

services/gateway/Dockerfile and .dockerignore (same as catalog, only EXPOSE 8080 changes)
services/gateway/Dockerfile
# syntax=docker/dockerfile:1

# ---- build stage: compile with Maven inside a JDK image -------------------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
# the cache mount keeps ~/.m2 between builds: dependencies are downloaded once
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests

# ---- runtime stage: only a JRE and the jar --------------------------------
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8080
# use up to 75% of the container's memory limit for the heap
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
services/gateway/.dockerignore
target/
.idea/
.vscode/
*.iml
services/gateway/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>boxoffice</groupId>
    <artifactId>gateway</artifactId>
    <version>1.0.0</version>
    <name>gateway</name>
    <description>BoxOffice API gateway: routing, JWT validation, identity headers</description>

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

    <dependencies>
        <!-- Spring Cloud Gateway on WebFlux (non-blocking, Netty) -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
        </dependency>
        <!-- client-side load balancing across catalog-1..3 (lb://catalog) -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
        <!-- WebClient.Builder: the load balancer's health checks use it (Boot 4 moved it to its own starter) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webclient</artifactId>
        </dependency>
        <!-- validates JWTs: signature (JWKS), expiry, issuer, audience -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </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>

    <build>
        <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
services/gateway/src/main/java/boxoffice/gateway/GatewayApplication.java
package boxoffice.gateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class GatewayApplication {

    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

2. Routes and load balancing

services/gateway/src/main/resources/application.yaml
server:
  port: 8080

spring:
  application:
    name: gateway
  cloud:
    gateway:
      server:
        webflux:
          httpclient:
            connect-timeout: 1000          # ms to open a connection to a service
            response-timeout: 5s           # never wait forever for a service
          default-filters:
            # retry idempotent GETs once on another instance when a connection fails
            - name: Retry
              args:
                retries: 1
                methods: GET
                series: SERVER_ERROR
                exceptions: java.io.IOException, java.util.concurrent.TimeoutException
          routes:
            - id: auth
              uri: ${AUTH_URL:http://auth:8081}
              predicates:
                - Path=/auth/**
            - id: catalog
              uri: lb://catalog              # "lb": pick one instance of "catalog" per request
              predicates:
                - Path=/catalog/**
    # the instances behind lb://catalog (a static list; Kubernetes replaces it in lab 10)
    discovery:
      client:
        simple:
          instances:
            catalog:
              - uri: http://catalog-1:8082
              - uri: http://catalog-2:8082
              - uri: http://catalog-3:8082
    loadbalancer:
      configurations: health-check         # skip instances that are not ready
      health-check:
        interval: 5s
        path:
          default: /actuator/health/readiness   # this instance, not db/redis that every replica shares
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: ${AUTH_JWKS_URI:http://auth:8081/auth/.well-known/jwks.json}

jwt:
  issuer: ${JWT_ISSUER:boxoffice-auth}
  audience: boxoffice

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, gateway
  endpoint:
    health:
      probes:
        enabled: true
    gateway:
      access: read-only      # GET /actuator/gateway/routes lists the routes
  • A route = id + predicates (when it matches) + URI (where it goes) + filters.
  • uri: lb://catalog asks Spring Cloud LoadBalancer for an instance of catalog on each request (round robin). The instance list here is static (discovery.client.simple); with Kubernetes in lab 10 a Service takes over this job.
  • configurations: health-check makes the balancer poll each instance's /actuator/health/readiness every 5 s and skip those that are not UP — an active health check, the thing open-source Nginx could not do in lab 3. It uses a WebClient to probe, which in Spring Boot 4 lives in its own starter: that is why the pom has spring-boot-starter-webclient. Until the first round of probes completes (a few seconds after startup) the balancer knows no healthy instance and the gateway answers 503. It probes readiness, not /actuator/health: the full health includes db and redis, which every replica shares, so one stopped dependency would mark all three down at once and the gateway would answer 503 for everything — even for requests that the cache, or a fallback, could serve.
  • Retry re-sends a failed GET once (to another instance); never POSTs, which might not be idempotent.

Two load balancers?

Yes, on purpose: the edge balances across gateway replicas (add gateway-2 to the Nginx upstream when one gateway is not enough), and the gateway balances across service instances. That mirrors the cloud: a public L7 load balancer in front of a fleet of API gateways, which call services through internal load balancing.

3. Security: who may call what

services/gateway/src/main/java/boxoffice/gateway/SecurityConfig.java
package boxoffice.gateway;

import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    /** Which routes are open, which need a valid token, which need the admin scope. */
    @Bean
    SecurityWebFilterChain security(ServerHttpSecurity http) {
        return http
                .csrf(ServerHttpSecurity.CsrfSpec::disable)        // stateless API, no cookies
                .authorizeExchange(ex -> ex
                        // open: getting a token, and the public keys to check one
                        .pathMatchers("/auth/register", "/auth/login", "/auth/.well-known/**").permitAll()
                        // open: browsing the catalog (HEAD is a GET without the body)
                        .pathMatchers(HttpMethod.GET, "/catalog/**").permitAll()
                        .pathMatchers(HttpMethod.HEAD, "/catalog/**").permitAll()
                        // admin only: changing the catalog (scope claim "user admin")
                        .pathMatchers("/catalog/**").hasAuthority("SCOPE_admin")
                        // health, metrics, routes: the edge never forwards /actuator from outside
                        .pathMatchers("/actuator/**").permitAll()
                        // everything else (and every future route): any valid token
                        .anyExchange().authenticated())
                .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
                .build();
    }

    /** Signature from the JWKS of auth, plus expiry, issuer and audience checks. */
    @Bean
    ReactiveJwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwksUri,
                                  @Value("${jwt.issuer}") String issuer,
                                  @Value("${jwt.audience}") String audience) {
        var decoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwksUri).build();
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
                JwtValidators.createDefaultWithIssuer(issuer),     // exp, nbf, iss
                new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(audience))));
        return decoder;
    }
}
  • Rules are evaluated in order; the first match wins. anyExchange().authenticated() is the safe default: a new route added next month is protected unless someone opens it explicitly.
  • oauth2ResourceServer().jwt() turns the gateway into an OAuth 2.0 resource server: it reads Authorization: Bearer …, validates the token with the decoder below and maps the scope claim to authorities (SCOPE_user, SCOPE_admin).
  • The decoder downloads the JWKS from auth once and caches it; it refetches only when a token arrives with an unknown kid. That is why validation is local and cheap.
  • A missing or invalid token gets 401 Unauthorized (with a WWW-Authenticate header); a valid token without the needed scope gets 403 Forbidden.

4. Identity headers

services/gateway/src/main/java/boxoffice/gateway/UserHeadersFilter.java
package boxoffice.gateway;

import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
 * Tells the services who the user is: X-User-Id, X-User-Email, X-User-Scope.
 * Headers with these names coming from the client are always removed first,
 * otherwise anyone could claim to be anyone.
 */
@Component
public class UserHeadersFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        var cleaned = exchange.mutate()
                .request(r -> r.headers(h -> {
                    h.remove("X-User-Id");
                    h.remove("X-User-Email");
                    h.remove("X-User-Scope");
                }))
                .build();
        return ReactiveSecurityContextHolder.getContext()
                .map(SecurityContext::getAuthentication)
                .filter(JwtAuthenticationToken.class::isInstance)
                .cast(JwtAuthenticationToken.class)
                .map(auth -> cleaned.mutate()
                        .request(r -> r.headers(h -> {
                            var jwt = auth.getToken();
                            h.set("X-User-Id", jwt.getSubject());
                            h.set("X-User-Email", jwt.getClaimAsString("email"));
                            h.set("X-User-Scope", jwt.getClaimAsString("scope"));
                        }))
                        .build())
                .defaultIfEmpty(cleaned)          // anonymous request on an open route
                .flatMap(chain::filter);
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

A GlobalFilter runs for every route. It first strips any X-User-* header sent by the client, then, if the request carries a validated JWT, adds the identity taken from the token. Services read a plain header instead of parsing tokens — simple, but it only holds as long as nobody can reach the services without crossing the gateway.

5. The new edge

The edge now forwards everything to the gateway; routing by path moved one hop in. It also hides /actuator/ from the internet.

infra/nginx/nginx.conf
# BoxOffice edge (lab 4): everything goes to the gateway, which routes to the services
user  nginx;
worker_processes  auto;

events {
    worker_connections  4096;
}

http {
    # one line per request: who served it, and how long the upstream took
    log_format  edge  '$remote_addr "$request" $status rt=$request_time '
                      'upstream=$upstream_addr ut=$upstream_response_time id=$request_id';
    access_log  /var/log/nginx/access.log  edge;

    # Docker's embedded DNS: lets nginx start even if a replica is down, and
    # notice replicas that come back (the "resolve" flag below, nginx >= 1.27.3)
    resolver  127.0.0.11  valid=10s  ipv6=off;

    upstream gateway {
        zone gateway 64k;                 # shared memory, required by "resolve"
        server gateway:8080 resolve max_fails=3 fail_timeout=10s;
        # more gateway replicas go here: gateway-2:8080, ...
        keepalive 64;
    }

    # rate limiting per API key: 20 req/s per key, bursts of up to 40.
    # Requests WITHOUT an X-Api-Key header have an empty key and are not limited
    # (in production you would also limit them per client IP).
    limit_req_zone  $http_x_api_key  zone=per_key:10m  rate=20r/s;
    limit_req_status  429;

    server {
        listen  80;

        # the edge's own liveness: does not touch any backend
        location = /healthz {
            access_log  off;
            return  200  "ok\n";
        }

        # internal endpoints are never public
        location /actuator/ {
            return  404;
        }

        location / {
            limit_req  zone=per_key  burst=40  nodelay;

            proxy_pass  http://gateway;
            proxy_http_version  1.1;
            proxy_set_header  Connection         "";      # keep upstream connections alive
            proxy_set_header  Host               $host;
            proxy_set_header  X-Real-IP          $remote_addr;
            proxy_set_header  X-Forwarded-For    $proxy_add_x_forwarded_for;
            proxy_set_header  X-Forwarded-Proto  $scheme;
            proxy_set_header  X-Request-Id       $request_id;
            add_header        X-Request-Id       $request_id always;

            # passive health checking: a failed attempt is retried on another replica
            proxy_connect_timeout  1s;
            proxy_read_timeout     5s;
            proxy_next_upstream    error timeout http_502 http_503 http_504;
            proxy_next_upstream_tries  2;
        }
    }
}

6. Compose

docker-compose.yaml
name: boxoffice

# settings shared by the three catalog replicas (a YAML anchor)
x-catalog: &catalog
  build: ./services/catalog
  image: boxoffice-catalog
  environment:
    DB_HOST: db
    DB_PORT: 5432
    DB_NAME: ${DB_NAME:-boxoffice}
    DB_USER: ${DB_USER:-boxoffice}
    DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
    DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
  depends_on:
    db:
      condition: service_healthy
  deploy:
    resources:
      limits:
        cpus: "1.0"
        memory: 768M
  healthcheck:
    test: ["CMD-SHELL", "wget -qO- http://localhost:8082/actuator/health/readiness || exit 1"]
    interval: 10s
    timeout: 3s
    start_period: 40s
    retries: 3

services:

  db:
    image: postgres:17
    environment:
      POSTGRES_DB: ${DB_NAME:-boxoffice}
      POSTGRES_USER: ${DB_USER:-boxoffice}
      POSTGRES_PASSWORD: ${DB_PASSWORD:-boxoffice}
    ports:
      - "5432:5432"
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10

  # three identical replicas; no "ports:" — only the edge is reachable from outside
  catalog-1:
    <<: *catalog
    hostname: catalog-1
  catalog-2:
    <<: *catalog
    hostname: catalog-2
  catalog-3:
    <<: *catalog
    hostname: catalog-3

  auth:
    build: ./services/auth
    environment:
      DB_HOST: db
      DB_PORT: 5432
      DB_NAME: ${DB_NAME:-boxoffice}
      DB_USER: ${DB_USER:-boxoffice}
      DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
      JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
      JWT_TTL: ${JWT_TTL:-PT1H}
      ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@boxoffice.dev}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-boxoffice-admin}
    depends_on:
      db:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 768M
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health/readiness || exit 1"]
      interval: 10s
      timeout: 3s
      start_period: 40s
      retries: 3

  gateway:
    build: ./services/gateway
    environment:
      AUTH_URL: http://auth:8081
      AUTH_JWKS_URI: http://auth:8081/auth/.well-known/jwks.json
      JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
    ports:
      - "8080:8080"          # published for experiments; clients use the edge (port 80)
    depends_on:
      - auth
      - catalog-1
      - catalog-2
      - catalog-3
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 768M
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health/readiness || exit 1"]
      interval: 10s
      timeout: 3s
      start_period: 40s
      retries: 3

  edge:
    image: nginx:1.30-alpine
    ports:
      - "80:80"
    volumes:
      - ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - gateway

volumes:
  db-data:

The gateway publishes port 8080 only so that you can measure it directly; in production only the edge would be exposed.

docker compose up --build -dcurl -s localhost:8080/actuator/gateway/routes | jq -r '.[].route_id'auth
catalog
curl -s -o /dev/null -w "%{http_code}\n" localhost/catalog/events/1200curl -s -o /dev/null -w "%{http_code}\n" localhost/actuator/health404

Pitfalls

  • 401 on every protected route right after startup: the gateway fetches the JWKS lazily; if auth was down at that moment, retry — and check AUTH_JWKS_URI.
  • 401 after restarting auth: the old tokens were signed with the old key. Log in again.
  • Clock skew: exp/iat are compared with the gateway's clock; containers share the host clock, but servers in production need NTP (class 3).
  • CORS: a browser front end on another origin needs CORS configured at the gateway (spring.cloud.gateway.server.webflux.globalcors) — not needed for k6 or curl.

Back to the lab overview to try it and measure it.