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/.dockerignoretarget/
.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.javapackage 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.
| 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
catalogcurl -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.