Project Overview
Guardian Gateway provides a resilient, circuit-breaking API gateway built on Spring Cloud Gateway. It centralizes HTTP routing, load balancing and security for microservices, while automating SSL certificate issuance via ACME (Let’s Encrypt).
Problems It Solves
- Unified entry point for downstream services
- Fault isolation with Resilience4j circuit breakers
- Automated TLS provisioning and renewal using Acme4j
- Health monitoring and metrics via Spring Boot Actuator
When to Use
- You need a centralized HTTP router and load balancer for microservices
- You require built-in fault tolerance and automatic circuit breaking
- You want hands-free SSL certificate management (Let’s Encrypt)
- You must expose health checks and metrics for production monitoring
Core Components
- pom.xml:
- Spring Boot Starter WebFlux, Spring Cloud Gateway
- Spring Cloud Circuit Breaker (Resilience4j)
- Spring Boot Actuator
- Acme4j for ACME protocol support
- GuardianGatewayApplication.java:
- Main entry point (
@SpringBootApplication
) - Launches the gateway service
- Main entry point (
- application.yaml:
spring.application.name: guardian-gateway
server.port: 8080
Quick Start
Build and run the gateway locally:
# Build the JAR
mvn clean package
# Run on port 8080
java -jar target/guardian-gateway-0.0.1-SNAPSHOT.jar
## Getting Started
This guide walks you through running the Guardian Gateway locally or on a simple cloud VM. You’ll install the correct Java version, build the project with the Maven Wrapper, and launch the Spring Boot application secured by a self-signed SSL context.
### Prerequisites
- Git
- SDKMAN (https://sdkman.io)
- Linux/macOS or Windows Subsystem for Linux
### 1. Clone the Repository
```bash
git clone https://github.com/nicholasM95/guardian-gateway.git
cd guardian-gateway
2. Install & Select Java
The project requires Java 24.0.1-librca. SDKMAN automates installation and switching:
# Install SDKMAN (if not already)
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
# Install and use the specified Java version
sdk install java 24.0.1-librca
sdk use java 24.0.1-librca
# Verify
java -version
# output: openjdk version "24.0.1" 2025-xx-xx
3. Build with Maven Wrapper
The Maven Wrapper (mvnw
) ensures consistent Maven 3.3.2 usage without a global install.
# Grant execute permission (Linux/macOS)
chmod +x mvnw
# Clean, compile and package
./mvnw clean package -DskipTests
A runnable JAR appears in target/
, e.g. guardian-gateway-0.0.1-SNAPSHOT.jar
.
4. Run Locally
You can start the gateway either via Maven or by running the JAR directly.
a. Using Maven
./mvnw spring-boot:run
b. Using the Packaged JAR
java -jar target/guardian-gateway-*.jar
The application launches on HTTPS port 8443 with a self-signed certificate generated by DummySslContextGenerator
. For local testing, bypass certificate checks:
curl -k https://localhost:8443/actuator/health
5. Running on a Cloud VM
- Provision a VM (e.g. Ubuntu 22.04).
- Install Git, curl, and SDKMAN as above.
- Clone repo and set Java version.
- Build with
./mvnw clean package -DskipTests
. - Open port 8443 in your firewall/SG.
- Start the gateway in the background:
nohup java -jar target/guardian-gateway-*.jar \
> gateway.log 2>&1 &
- Verify health endpoint externally:
curl -k https://YOUR_VM_IP:8443/actuator/health
Next Steps
- Review
src/main/java/.../https/DummySslContextGenerator.java
for customizing SSL. - Configure Spring Cloud settings in
application.yml
or environment variables. - Integrate real certificates via Acme4j for production environments.
Configuration Guide
This guide shows how to customize gateway routes, ports, SSL and certificate handling in guardian-gateway. You’ll learn to:
- Define and reload routes without code changes
- Hot-swap TLS certificates at runtime
- Serve ACME HTTP-01 challenges for Let’s Encrypt integration
Dynamic Route Configuration via ApplicationProperties
Let guardian-gateway pick up new routes from application.yaml
without recompiling.
1. Define your route properties in application.yaml
spring:
application:
name: guardian-gateway
server:
port: 8080
application:
config:
- name: service1-route
host: "*.service1.example.com"
service: lb://SERVICE1
- name: orders-route
host: "orders.internal.local"
service: http://localhost:9002
Fields per entry:
name
: unique route IDhost
: incoming request host predicateservice
: target URI (lb://…
for load-balanced,http://…
orhttps://…
for direct)
2. Property binding classes
ApplicationConfig
holds each route:
package be.nicholasmeyers.guardiangateway.config;
public class ApplicationConfig {
private String name;
private String service;
private String host;
// getters & setters
}
ApplicationProperties
collects the list:
package be.nicholasmeyers.guardiangateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "application")
public class ApplicationProperties {
private List<ApplicationConfig> config;
public List<ApplicationConfig> getConfig() {
return config == null ? List.of() : config;
}
public void setConfig(List<ApplicationConfig> config) {
this.config = config;
}
}
3. Dynamic Route registration
RouteConfig
converts properties into live routes:
package be.nicholasmeyers.guardiangateway.config;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RouteConfig {
@Bean
public RouteLocator routes(RouteLocatorBuilder builder,
ApplicationProperties props) {
var routes = builder.routes();
props.getConfig().forEach(cfg ->
routes.route(cfg.getName(), r -> r
.host(cfg.getHost())
.filters(f -> f.addRequestHeader("X-From-Gateway", "true"))
.uri(cfg.getService())
)
);
return routes.build();
}
}
Key points:
- Uses
.host(...)
to match incoming host - Adds
X-From-Gateway
header - Forwards to
service
URI
4. Practical guidance
- To add a route, append under
application.config
inapplication.yaml
. - Use
lb://SERVICE_NAME
for Spring Cloud services or full URLs for external targets. - Extend predicates by replacing
.host(...)
with.path()
,.method()
, etc., inRouteConfig
.
Dynamic SSL Certificate Reloading
Enable on-the-fly TLS certificate updates in Reactor Netty without downtime.
1. ReloadingSslContextSupplier
A thread-safe Supplier<SslContext>
that always hands out the latest context:
package be.nicholasmeyers.guardiangateway.https;
import io.netty.handler.ssl.SslContext;
import java.util.function.Supplier;
public class ReloadingSslContextSupplier implements Supplier<SslContext> {
private volatile SslContext sslContext;
public void updateSslContext(SslContext newContext) {
this.sslContext = newContext;
}
@Override
public SslContext get() {
return sslContext != null
? sslContext
: DummySslContextGenerator.create();
}
}
2. Integrating with Reactor Netty
Register the supplier so each new handshake uses the latest context:
@Bean
@Primary
public NettyReactiveWebServerFactory webServerFactory(HttpHandler httpHandler,
ReloadingSslContextSupplier supplier) {
var factory = new NettyReactiveWebServerFactory();
factory.setPort(443);
factory.addServerCustomizers(httpServer ->
httpServer.secure(spec ->
spec.sslContext(supplier.get())
)
);
// HTTP on port 80
HttpServer.create()
.port(80)
.handle(new ReactorHttpHandlerAdapter(httpHandler))
.bindNow();
return factory;
}
3. Listening for Certificate Updates
Rebuild SslContext
when CertUpdateEvent
fires:
@EventListener
public void handleCertificatesReloaded(CertUpdateEvent event) {
var certs = certStore.getAll();
if (certs.isEmpty()) return;
var builder = SslContextBuilder.forServer(
certs.get(0).keyPair().getPrivate(),
certs.get(0).certificate()
);
for (int i = 1; i < certs.size(); i++) {
builder.keyManager(
certs.get(i).keyPair().getPrivate(),
certs.get(i).certificate()
);
}
try {
var newContext = builder.build();
supplier.updateSslContext(newContext);
} catch (SSLException e) {
throw new RuntimeException("Failed to build SSL context", e);
}
}
4. Practical guidance
- Ensure
CertStore
loads certificates on startup and emitsCertUpdateEvent
on change. - Replace
DummySslContextGenerator
with a valid fallback if needed. - Reactor Netty picks up new contexts for new connections; existing ones remain.
- No restart required—certificate updates propagate immediately.
Serving ACME HTTP-01 Challenges
Expose /.well-known/acme-challenge/{token}
so Let’s Encrypt can validate your domains.
1. ChallengeStore
Thread-safe in-memory store for pending challenges:
package be.nicholasmeyers.guardiangateway.acme;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Optional;
@Component
public class ChallengeStore {
private final ConcurrentHashMap<String,String> pending = new ConcurrentHashMap<>();
public void add(String token, String authorization) {
pending.put(token, authorization);
}
public void remove(String token) {
pending.remove(token);
}
public Optional<String> get(String token) {
return Optional.ofNullable(pending.get(token));
}
}
2. CertController
Serve the authorization value for incoming HTTP-01 requests:
package be.nicholasmeyers.guardiangateway.acme;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/.well-known/acme-challenge")
public class CertController {
private final ChallengeStore store;
public CertController(ChallengeStore store) {
this.store = store;
}
@GetMapping("/{token}")
public String getChallenge(@PathVariable String token) {
return store.get(token)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "ACME token not found"
));
}
}
3. Injecting challenges
During certificate issuance, add and remove tokens:
private void processAuthorization(Authorization auth) throws Exception {
var challenge = auth.findChallenge(Http01Challenge.TYPE)
.orElseThrow(() -> new RuntimeException("No HTTP-01 challenge"));
var token = challenge.getToken();
var authz = challenge.getAuthorization();
challengeStore.add(token, authz);
challenge.trigger();
while (challenge.getStatus() != Status.VALID) {
Thread.sleep(3000);
challenge.update();
}
challengeStore.remove(token);
}
4. Practical guidance
- Route
/.well-known/acme-challenge/*
to this service in your gateway or proxy. - For multi-instance setups, replace the in-memory store with a shared cache (Redis, etc.).
- To test,
GET /.well-known/acme-challenge/{token}
immediately afteradd()
and beforetrigger()
. - Challenges are short-lived and auto-removed once validated.
Architecture & Core Components
This section details Guardian Gateway’s internal building blocks. You’ll learn how incoming traffic is secured and routed, how certificates are managed in-memory, and how the gateway serves HTTP and HTTPS on multiple ports with live SSL reloads.
HTTPS Redirection Filter
Purpose
Ensure all HTTP traffic redirects to HTTPS (301), exempt ACME challenge requests, and add HSTS headers on HTTPS responses.
How It Works
- Paths under
/.well-known/acme-challenge/**
pass through untouched. - Requests with scheme
http
receive a 301 redirect to the same URI underhttps
. - HTTPS requests gain
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
.
Relevant Code
package be.nicholasmeyers.guardiangateway.https;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.WebFilter;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.logging.Logger;
@Component
public class RedirectToHttps {
private static final Logger logger = Logger.getLogger(RedirectToHttps.class.getName());
@Bean
public WebFilter httpsRedirectFilter() {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String path = request.getURI().getPath();
if (path.startsWith("/.well-known/acme-challenge")) {
logger.fine("ACME challenge – skipping redirect");
return chain.filter(exchange);
}
if ("http".equals(request.getURI().getScheme())) {
logger.info("Redirecting to HTTPS");
String httpsUrl = UriComponentsBuilder.fromUri(request.getURI())
.scheme("https")
.build()
.toString();
response.setStatusCode(HttpStatus.MOVED_PERMANENTLY);
response.getHeaders().setLocation(URI.create(httpsUrl));
return response.setComplete();
}
// Already HTTPS – add HSTS header
response.getHeaders().add(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload"
);
return chain.filter(exchange);
};
}
}
Usage
- Place this class under a scanned package. Spring Boot auto-detects
@Component
. - No extra properties required.
- Certbot and other ACME clients can still renew via the exempt path.
Testing with cURL
- HTTP → HTTPS redirect:
curl -I http://your-domain/api/status
ExpectHTTP/1.1 301 Moved Permanently
andLocation: https://your-domain/api/status
- HSTS on HTTPS:
curl -I https://your-domain/api/status
Expect headerStrict-Transport-Security: max-age=31536000; includeSubDomains; preload
Customization
- Change the ACME challenge path in the
path.startsWith(...)
check. - Modify HSTS parameters by editing the header value.
CertStore: In-Memory Certificate Cache
Purpose
Store and retrieve CertificateInfo
objects keyed by domain. Enables rapid SSL context lookup without repeated I/O or ACME calls.
Key API
• Optional<CertificateInfo> get(String host)
• void put(String host, CertificateInfo info)
• boolean contains(String host)
• boolean isEmpty()
• List<CertificateInfo> getAll()
Example Usage
@Component
public class MyHttpsServer {
private final CertStore certStore;
private final CertManager certManager;
public MyHttpsServer(CertStore certStore, CertManager certManager) {
this.certStore = certStore;
this.certManager = certManager;
}
public SslContext getSslContextFor(String host) {
if (!certStore.contains(host)) {
certManager.requestCertificateAsync(host);
}
return certStore.get(host)
.map(CertificateInfo::sslContext)
.map(NettySslContextConverter::toNetty)
.orElse(DummySslContextGenerator.create());
}
public void printAllCerts() {
certStore.getAll().forEach(info ->
System.out.println(info.domain() + " expires at " + info.expiryDate())
);
}
}
Practical Tips
- On startup,
CertManager.loadExistingCertificates()
populates the store from disk. - After issuance or renewal,
certManager
callscertStore.put(...)
and firesCertUpdateEvent
. - Other components listen for
CertUpdateEvent
to swap SSL contexts at runtime. domainCertificates
usesConcurrentHashMap
for lock-free, thread-safe access under high concurrency.
Multi-Port HTTP/HTTPS Configuration with Dynamic SSL Reloading
Purpose
Serve HTTPS on port 443 with live certificate updates while also listening on port 80 for ACME challenges or redirects.
1. Configure Netty for Port 443 & Port 80
@Bean
@Primary
public NettyReactiveWebServerFactory webServerFactory(
HttpHandler httpHandler,
ReloadingSslContextSupplier supplier) {
NettyReactiveWebServerFactory factory = new NettyReactiveWebServerFactory();
factory.setPort(443);
factory.addServerCustomizers(httpServer ->
httpServer.secure(sslSpec ->
sslSpec.sslContext(supplier.get())
)
);
// HTTP server on port 80 for ACME or redirect filter
HttpServer.create()
.port(80)
.handle(new ReactorHttpHandlerAdapter(httpHandler))
.bindNow();
return factory;
}
supplier.get()
returns the current NettySslContext
.- The manual HTTP server on port 80 handles ACME challenges and the redirect filter.
2. Handling Certificate Updates
Listen for CertUpdateEvent
to rebuild and swap the SSL context:
@EventListener
public void onCertsUpdated(CertUpdateEvent event) {
if (certStore.isEmpty()) {
return;
}
List<CertificateInfo> certs = certStore.getAll();
SslContextBuilder builder = SslContextBuilder.forServer(
certs.get(0).keyPair().getPrivate(),
certs.get(0).certificate()
);
for (int i = 1; i < certs.size(); i++) {
builder.keyManager(
certs.get(i).keyPair().getPrivate(),
certs.get(i).certificate()
);
}
try {
SslContext newCtx = builder.build();
supplier.updateSslContext(newCtx);
} catch (SSLException e) {
throw new RuntimeException("Failed to rebuild SSL context", e);
}
}
Key Points
- Rebuild with the full certificate chain (
keyManager
). supplier.updateSslContext(...)
swaps contexts for new connections without a restart.
3. Initial Dummy SSL Context (Local Development)
public class DummySslContextGenerator {
public static SslContext create() throws SSLException {
SelfSignedCertificate ssc = new SelfSignedCertificate("localhost");
return SslContextBuilder.forServer(
ssc.certificate(),
ssc.privateKey()
).build();
}
}
Initialize ReloadingSslContextSupplier
with this dummy context to start before real certificates arrive.
4. Integrating HTTP→HTTPS Redirection
Reuse the httpsRedirectFilter()
bean to redirect HTTP traffic on port 80 and add HSTS on HTTPS. The same filter applies to both servers thanks to shared HttpHandler
.
With these components in place, Guardian Gateway routes, secures, and manages certificates dynamically—enabling seamless HTTPS even when certificates rotate at runtime.
Development & Release
This section outlines coding conventions, testing procedures, dependency updates, and automated release workflows for the nicholasM95/guardian-gateway repository.
1. Prerequisites
- Java 17 (Temurin or OpenJDK)
- Maven 3.6+
- Node.js 14+
- Git CLI
2. Coding & Commit Conventions
Follow Angular-style commits to drive semantic-release:
- feat: New feature (MINOR bump)
- fix: Bug fix (PATCH bump)
- perf: Performance improvement (PATCH bump)
- refactor: Code change without feature or fix (no release)
- chore: Maintenance (no release)
- BREAKING CHANGE: Any change requiring MAJOR bump
Example:
git commit -m "feat(gateway): add circuit breaker support"
git commit -m "fix(security): renew SSL certificate handling"
git commit -m "refactor(flow): simplify request filters"
git commit -m "feat!: migrate to Spring Cloud 2022.0 (BREAKING CHANGE)"
3. Running Tests
Java Tests
# Compile & run unit and integration tests
mvn clean verify
Node.js Tests
# Install dependencies and run JS tests (e.g., API mocks, scripts)
npm ci
npm test
4. Dependency Management
4.1 Automated Updates with Renovate
The renovate.json
config assigns PRs to you on the develop
branch:
{
"extends": ["config:base"],
"assignees": ["nicholasM95"],
"baseBranches": ["develop"]
}
Merge Renovate PRs after validation and drop stale branches.
4.2 Manual Maven Updates
Display available updates:
mvn versions:display-dependency-updates
Set new version for a dependency or Spring Cloud BOM:
<!-- In pom.xml --> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2022.0.3</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
Commit changes and bump project version:
mvn versions:set -DnewVersion=1.5.0-SNAPSHOT mvn versions:commit git commit -am "chore(deps): upgrade Spring Cloud to 2022.0.3"
5. Release Process
5.1 Semantic-Release Configuration
release.config.cjs
drives automated versioning and changelog generation:
module.exports = {
branches: [
'main',
{ name: 'develop', prerelease: true }
],
plugins: [
['@semantic-release/commit-analyzer', {
releaseRules: [
{type: 'feat', release: 'minor'},
{type: 'fix', release: 'patch'},
{breaking: true, release: 'major'}
]
}],
['@semantic-release/release-notes-generator', {
writerOpts: {commitsSort: ['scope', 'subject']}
}],
'@semantic-release/changelog',
['@semantic-release/git', {
assets: ['CHANGELOG.md', 'pom.xml', 'package.json'],
message: 'chore(release): ${nextRelease.version} [skip ci]'
}],
'@semantic-release/github'
]
};
5.2 GitHub Actions Workflow
.github/workflows/release.yaml
triggers on pushes to main
and develop
:
name: Release
on:
push:
branches: [main, develop]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Java
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: '17'
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '14'
- name: Install JS dependencies
run: npm ci
- name: Run tests
run: |
mvn clean verify
npm test
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release
This workflow:
- Calculates the next version
- Updates
CHANGELOG.md
- Commits version bump to
main
ordevelop
- Creates GitHub release
5.3 Manual Version Bump Script
Use update-version.sh
to override or preview version changes:
# Bump to version 1.6.0 and update pom.xml
./update-version.sh 1.6.0
# Create tag without altering pom.xml
./update-version.sh 1.6.0 true
Script behavior:
- Updates
pom.xml
(unless skipped) - Commits
chore(release): bump version to X.Y.Z
- Tags
vX.Y.Z
6. Best Practices
- Develop on
develop
; merge intomain
only via PR after CI passes. - Keep commit messages concise and follow conventions.
- Let Renovate handle routine dependency bumps.
- Review generated CHANGELOG and PR in
develop
after prerelease. - For urgent patches, push a
fix:
commit directly tomain
; GitHub Actions handles the rest.