July 16, 2026
What We’re Building
Imagine you have a simple REST API. You want API key authentication, rate limiting, and automatic failover across two clusters in different clouds — and you don’t want to touch a single line of application code to get any of it.
That’s what Red Hat Connectivity Link (RHCL) gives you. Built on the upstream Kuadrant project, RHCL attaches API management policies directly to standard Kubernetes Gateway API resources. Auth, rate limiting, and DNS-based traffic management all live at the infrastructure layer.
In this walkthrough, I’ll show how to:
- Deploy a sample API behind a Kubernetes Gateway
- Add API key auth and rate limiting with zero app changes
- Distribute the entire stack across two clusters with ACM
- Set up DNS-based traffic management across clouds
- Visualize it all with a live dashboard
I’ll also be honest about where the edges are — particularly around multi-cloud DNS failover, where the behavior is more nuanced than the architecture diagrams suggest.
The full demo repo is at ultraJeffOrg/connectivity-link-apicurio-demo.
Prerequisites
- Two OpenShift clusters (this demo uses
blueon Azure/centralus andaws-aion AWS/us-east-2) - Red Hat Advanced Cluster Management (ACM) 2.17 with both clusters registered as managed spokes
- An AWS Route53 hosted zone for your DNS domain
cert-managerinstalled (RHCL depends on it for TLS)ocandkubectlCLI access to the hub cluster
The Sample API
The Incident API is a minimal Node.js/Express app running on UBI 9. It has a handful of CRUD endpoints and an in-memory data store — nothing special on purpose. The point is that this is a plain HTTP service with no auth middleware, no rate limiting, no security logic whatsoever.
GET /api/incidents # list all
GET /api/incidents/:id # get one
POST /api/incidents # create
PATCH /api/incidents/:id # update
GET /healthz # health check
This is key to the pattern: the application stays simple, and the platform handles the rest.
Step 1: Install RHCL
RHCL ships as an operator. When you install it, it brings along three sub-operators automatically: Authorino (auth), Limitador (rate limiting), and the DNS Operator. As of RHCL 1.3+ on OCP 4.19+, it does NOT require OpenShift Service Mesh or Sail.
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
name: rhcl-operator
namespace: kuadrant-system
spec:
channel: stable
name: rhcl-operator
source: redhat-operators
sourceNamespace: openshift-marketplace
After the operator is ready, activate it with a Kuadrant CR:
apiVersion: kuadrant.io/v1beta1
kind: Kuadrant
metadata:
name: kuadrant
namespace: kuadrant-system
A gotcha worth knowing: the Kuadrant operator checks for available GatewayClasses on startup and caches the result. If your openshift-default GatewayClass isn’t created before the operator starts, it will permanently report MissingDependency even after the GatewayClass appears. The fix is to delete the kuadrant-system namespace entirely and let it recreate. This is a race condition, not a config error — sequence matters.
Step 2: Set Up the Gateway and Route
We use the standard Kubernetes Gateway API — no proprietary CRDs for traffic routing:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: incident-api-gateway
labels:
kuadrant.io/gateway: "true"
spec:
gatewayClassName: openshift-default
listeners:
- name: http
port: 80
protocol: HTTP
hostname: incidents.example.com
allowedRoutes:
namespaces:
from: Same
Note the kuadrant.io/gateway: "true" label — RHCL needs this to know which Gateways to manage. Then an HTTPRoute to wire traffic to the backend:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: incident-api-route
spec:
parentRefs:
- name: incident-api-gateway
rules:
- matches:
- path:
type: PathPrefix
value: /api
- path:
type: Exact
value: /healthz
backendRefs:
- name: incident-api
port: 3000
Nothing RHCL-specific so far — this is vanilla Gateway API.
Step 3: Add API Key Authentication
Here’s where RHCL starts earning its keep. An AuthPolicy attaches to the HTTPRoute via targetRef and enforces API key authentication:
apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
name: incident-api-auth
spec:
targetRef:
group: gateway.networking.k8s.io
kind: HTTPRoute
name: incident-api-route
rules:
defaults:
when:
- predicate: request.path != '/healthz'
authentication:
api-key:
apiKey:
selector:
matchLabels:
app.kubernetes.io/component: api-key
allNamespaces: true
credentials:
customHeader:
name: x-api-key
The policy says: for all requests (except /healthz), look for an x-api-key header and validate it against Kubernetes Secrets that have the label app.kubernetes.io/component: api-key. The /healthz exclusion matters — without it, the DNSPolicy health checks will fail auth and your DNS records will never go healthy.
Note allNamespaces: true — the default is false, and if your API key Secret isn’t in the exact same namespace as the AuthPolicy, authentication will silently fail. This one cost me some debugging time.
The API keys are just Secrets:
apiVersion: v1
kind: Secret
metadata:
name: api-key-demo
labels:
app.kubernetes.io/component: api-key
annotations:
api-consumer: demo-client
type: Opaque
stringData:
api_key: demo-key-12345
No code changes. No SDK. No middleware. The gateway handles it.
Step 4: Add Rate Limiting
A RateLimitPolicy attaches to the Gateway (not the HTTPRoute) and sets a global rate limit:
apiVersion: kuadrant.io/v1
kind: RateLimitPolicy
metadata:
name: incident-api-ratelimit
spec:
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: incident-api-gateway
defaults:
limits:
global:
rates:
- limit: 50
window: 10s
50 requests per 10-second window. Any request beyond that gets a 429 Too Many Requests. The application never sees the rejected requests.
One thing to watch out for: don’t use source.address as a rate limit counter. It includes ephemeral ports, so every request looks unique and the rate limit never triggers. No counters at all = global rate limit, which is what you want here.
Also, the RateLimitPolicy must target the Gateway, not the HTTPRoute. Each spoke runs its own Limitador instance, so rate limits are per-cluster — there’s no cross-cluster coordination.
At this point you have a fully managed API: authenticated, rate-limited, routed through a standard Gateway, and the app code hasn’t changed at all.
Step 5: Go Multi-Cluster with ACM
This is where it gets interesting. We want the exact same stack — app, gateway, policies — deployed to two spoke clusters in different clouds. ACM’s governance framework handles this.
The approach uses two ACM Policy resources on the hub:
Policy 1: Operators — Ensures each spoke has cert-manager, the RHCL operator, the GatewayClass, and a Kuadrant CR. It uses extraDependencies to sequence things properly — the Kuadrant CR isn’t created until the operator subscription reports AtLatestKnown:
extraDependencies:
- apiVersion: policy.open-cluster-management.io/v1
kind: ConfigurationPolicy
name: incident-api-rhcl-subscription
compliance: Compliant
This is critical. Without extraDependencies, ACM will try to create the Kuadrant CR before the operator is ready, and it’ll fail silently or trigger the GatewayClass race condition I mentioned earlier.
Policy 2: Application + Config — Depends on Policy 1 being Compliant, then pushes the namespace, Deployment, Service, Gateway, HTTPRoute, and all RHCL policies to each spoke.
A Placement resource targets the right clusters:
apiVersion: cluster.open-cluster-management.io/v1beta1
kind: Placement
metadata:
name: incident-api-placement
spec:
clusterSets:
- global
predicates:
- requiredClusterSelector:
labelSelector:
matchExpressions:
- key: name
operator: In
values: [blue, aws-ai]
An Important Detail About musthave
ACM’s ConfigurationPolicy uses musthave compliance by default, which means it merges fields rather than replacing the full object. If you had a field in a previous version of your policy that you’ve since removed, ACM won’t remove it from the spoke — it’ll persist as a stale field. This matters for iterating on RHCL policies during development.
Also, don’t enforce spec.replicas in your ConfigurationPolicy if you want the dashboard to scale deployments up and down for failover testing. ACM will fight you and reset the replicas.
Secure Secret Distribution
Route53 AWS credentials never appear in Git. ACM hub-templates handle this:
aws_access_key_id: '{{hub fromSecret "incident-api-policies"
"aws-route53-credentials" "aws_access_key_id" hub}}'
At apply time, ACM pulls the credential from a Secret on the hub and injects it into the spoke resources. The secret exists only on the hub cluster.
Step 6: DNS-Based Traffic Management
This is the centerpiece. A DNSPolicy attaches to the Gateway on each spoke and registers it with Route53:
apiVersion: kuadrant.io/v1
kind: DNSPolicy
metadata:
name: incident-api-dns
spec:
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: incident-api-gateway
providerRefs:
- name: aws-route53-credentials
loadBalancing:
weight: 120
geo: US
healthCheck:
endpoint: /healthz
port: 80
protocol: HTTP
interval: 20s
failureThreshold: 2
Both spokes share the same hostname. Each spoke’s DNSPolicy registers its Gateway address with Route53 and sets up health checks against /healthz. Under the hood, RHCL creates a CNAME chain in Route53: the hostname points to a klb. (Kuadrant load balancer) prefix, which points to a geo record, which points to per-cluster weighted endpoint records. Each spoke coordinates via TXT ownership records so they don’t step on each other’s DNS entries.
The A Record vs. CNAME Problem
Here’s where multi-cloud gets messy. Different cloud providers expose Gateway addresses differently:
- Azure gives you an IP address — which becomes an A record in DNS
- AWS gives you a hostname (an ELB DNS name) — which becomes a CNAME record
This isn’t something you can control. Azure’s load balancer natively provides IPs (ipMode: VIP). AWS’s load balancer natively provides hostnames. You can’t force either to behave like the other.
RHCL’s DNSPolicy has two modes: simple and loadBalancing. Simple mode gives you true failover — when health checks fail, the unhealthy record is actually removed from DNS. But simple mode can’t handle a mix of A records and CNAME records for the same hostname. You need loadBalancing mode for that.
The catch: loadBalancing mode intentionally does NOT remove unhealthy records. When a health check fails, it sets the Route53 weight to 0 to deprioritize the unhealthy endpoint, but the record stays in DNS. From the RHCL docs:
When a record has been published using the load balancing options (GEO and Weighting) via DNSPolicy, a failing health check will not remove the endpoint record.
This is by design to avoid NXDOMAIN — the DNS response you get when a domain doesn’t exist at all. If both spokes happened to be temporarily unhealthy and RHCL removed both records, clients would get NXDOMAIN errors. That’s worse than routing to an unhealthy endpoint, because many clients don’t handle DNS failures gracefully. At least with a 503, the client can retry.
So what does this mean in practice? In loadBalancing mode, you get “prefer healthy” routing, not true failover. Route53 will deprioritize the unhealthy spoke by dropping its weight to 0, but it can still route traffic there — especially during weight transitions or when both endpoints have issues. And there’s a validFor reconciliation interval (default 15 minutes) that controls how often RHCL re-evaluates DNS records, which adds latency to any weight changes.
Getting True Failover
If you need hard failover where unhealthy endpoints are actually removed from DNS, you need simple mode — which means both spokes need to publish the same record type. You have a few options:
- Use the same cloud provider for both spokes. Two clusters on Azure would both get A records. Two on AWS would both get CNAMEs. Either way, simple mode works.
- Use on-prem or bare-metal spokes. Clusters that aren’t behind a cloud load balancer will get direct IPs. Two on-prem OpenShift clusters with simple mode gives you real failover.
- Accept
loadBalancingmode. For many use cases, weight-based deprioritization is good enough. Traffic will strongly prefer the healthy spoke, even if the cutover isn’t absolute. - Build a weight controller. You could write a simple controller on each spoke that watches Deployment readiness and patches the DNSPolicy weight to 0 when pods are down. This fills the gap that RHCL’s health checks in
loadBalancingmode don’t actually change Route53 weights. If you go this route, make sure to remove the weight field from ACM’s ConfigurationPolicy so ACM doesn’t overwrite your controller’s changes.
This is the kind of thing you discover when you actually run a multi-cloud setup, not just diagram one. The tradeoff (weight-based deprioritization vs. NXDOMAIN risk) is a reasonable one. But it’s worth understanding before you promise “automatic failover” to stakeholders.
The Dashboard
The demo includes a live dashboard that runs on the ACM hub cluster and gives you a real-time view of the entire multi-cluster setup. It uses ACM’s ManagedClusterView API to read resources from spoke clusters and ManagedClusterAction to mutate them — all without direct kubeconfig access to the spokes.
For each spoke, the dashboard fires three parallel ManagedClusterView reads — Deployment (replica counts), Gateway (addresses), and DNSPolicy (health/enforcement status) — and maps everything together. For AWS spokes, it resolves ELB hostnames to actual IPs so you can see which IP belongs to which cluster.
The dashboard shows:
- Cluster health cards — real-time replica counts, gateway addresses, resolved IPs, and DNS health/enforcement status for each spoke, with pulsing red banners when a cluster is down
- DNS resolution panel — live DNS lookups showing which IPs the hostname resolves to and which cluster they map to
- Failover controls — take a cluster down (scales the deployment to 0 via ManagedClusterAction) or bring it back up, then watch the DNS weights shift
- Rate limit testing — a “Blast 60 Requests” button that fires 60 concurrent requests and shows how many get
200 OKvs429 Too Many Requests - Auth testing — send requests with and without an API key to see the AuthPolicy accept or reject
Every proxied request is annotated with the DNS resolution — you can see “DNS resolved to 1.2.3.4 → blue (Azure)” on each response, so you always know which cluster handled the traffic.
The ManagedClusterView/Action pattern is worth studying even outside this demo. Views are fire-and-forget: create, poll for result, delete. The dashboard cleans up orphaned views on startup in case of a previous crash. It’s a clean way to build multi-cluster operational UIs on top of ACM without needing kubeconfigs for every spoke.
Architectural Reality Check
It’s worth stepping back and being clear about what RHCL is and isn’t in a multi-cluster context. RHCL is not a coordinated multi-cluster system — it’s independent gateways that happen to share a DNS name. Each spoke runs its own Authorino, its own Limitador, and manages its own DNS records independently. ACM distributes the configuration. Route53 splits the traffic. RHCL does local policy enforcement and DNS automation.
This means:
- Rate limits are per-cluster, not global. If your limit is 50 req/10s and you have two spokes, the effective limit is 100 req/10s total.
- Auth is per-cluster. Each spoke has its own Authorino instance validating against its own API key Secrets. They’re identical because ACM pushed the same config, but they’re not coordinated.
- There’s no hub-as-gateway. Traffic goes directly to spoke clusters via DNS. The hub doesn’t proxy anything.
None of this is a problem if you understand it. It’s a simple, decoupled architecture. But if you’re expecting a single control plane that coordinates policy across clusters in real time, that’s not what this is.
Why This Pattern Matters
Gateway API as the control plane. RHCL policies attach to standard Gateway and HTTPRoute resources via targetRef — the Gateway API’s Policy Attachment pattern. There are no proprietary ingress controllers or custom routing CRDs. If you swap your gateway implementation, the policies still work.
Zero-app-change API management. Application teams ship plain HTTP services. The platform adds authentication, rate limiting, and traffic management declaratively. This is how platform engineering should work — the platform provides capabilities, not requirements.
DNS-level multi-cluster resilience — with caveats. The traffic management operates at the DNS layer, which is beautifully simple: no service mesh federation, no VPN tunnels, no shared control plane. But multi-cloud DNS has real edge cases around record types and failover semantics. Understanding the difference between simple mode (true failover, same record types required) and loadBalancing mode (mixed record types, weight-based deprioritization) is essential before you design your topology.
What’s Next
In future posts, I’ll dig into the Kustomize patterns used to structure these manifests (base/overlay/component) and the broader GitOps workflow for managing multi-cluster deployments with ACM. If you want to try this yourself, the full demo is at ultraJeffOrg/connectivity-link-apicurio-demo.
