Lesson 33 of 66 · 18 min read · Updated
HTTPS with Gateway API and cert-manager
On this page
In the previous article you looked at the Gateway API resource model: who owns the GatewayClass, who owns the Gateway, how listeners decide which routes may attach, and how to read the status conditions. Everything so far has been plain HTTP on port 80. No real application ships like that.
This article adds HTTPS. In Gateway API, TLS termination is not an annotation and not a controller-specific setting. It is a field on the listener, which makes it portable across implementations. You will first terminate TLS with a certificate you create by hand, so you can see every moving part. Then you will hand the job to cert-manager, which watches the Gateway and issues and renews certificates for you.
We will cover the following topics:
- How an HTTPS listener terminates TLS, and what
tls.mode: Terminateactually means - Where the certificate lives, and why the Secret must sit next to the Gateway
- Creating a local CA and a server certificate with
openssl - Installing cert-manager with Gateway API support switched on
- Letting cert-manager issue certificates from a single Gateway annotation
- The Let's Encrypt HTTP-01 solver shape for production
- Redirecting HTTP to HTTPS with the
RequestRedirectfilter - Testing HTTPS against a local cluster with
curl --resolve,--cacertand-k
Technical Requirements
- The minikube cluster from the previous articles, with Envoy Gateway v1.9.2 installed in
envoy-gateway-systemand theegGatewayClass accepted. - The
storefrontnamespace with thekiosk-api:1.0Deployment (3 replicas) and its ClusterIP Servicekiosk-api(port 80 → 8080). kubectl, Helm 3 andopensslon your machine.curl. Every test in this article goes throughkubectl port-forward, so you do not need a LoadBalancer or a public DNS name.
Core Concepts
What "Terminate" Means
A Gateway listener with protocol: HTTPS accepts TLS connections. The tls.mode field decides what happens to them.
| Mode | What the Gateway does | Who holds the certificate |
|---|---|---|
| Terminate | Decrypts the TLS session, reads the HTTP request, routes it with HTTPRoute rules, and sends plain HTTP (or new TLS) to the backend | The Gateway, via certificateRefs |
| Passthrough | Forwards the encrypted bytes untouched, routing only on the SNI host name. Used with TLSRoute, not HTTPRoute | The backend application |
Terminate is the default and the one you want for almost every web API. Because the Gateway decrypts the request, it can see the path, the headers and the method. That is what makes all of the HTTPRoute matching in the next article possible. With Passthrough the Gateway is blind to everything except the host name.
Terminating at the Gateway also means your .NET application keeps listening on plain HTTP on port 8080. You do not mount certificates into every Pod, and you do not configure Kestrel for TLS. The certificate lives in exactly one place.
The HTTPS Listener
An HTTPS listener has four parts that matter:
port: 443becomes a port on the Envoy Service that Envoy Gateway creates for this Gateway.hostnamerestricts the listener to one host name. It is optional for a hand-made certificate, but cert-manager requires it, as you will see.certificateRefspoints at a Secret of typekubernetes.io/tls, with the keystls.crtandtls.key.kind: Secretandgroup: ""are the defaults. Writing them out makes the YAML easier to read.
Why the Secret Must Live Next to the Gateway
A certificateRefs entry can name a Secret in another namespace. But a Gateway reaching into another namespace is a security boundary crossing, so Gateway API only allows it when the owner of that namespace creates a ReferenceGrant that permits it. Without the grant, the listener reports ResolvedRefs: False with the reason RefNotPermitted, and it serves nothing.
The simplest rule is the one this series follows: the TLS Secret lives in the same namespace as the Gateway, here storefront. ReferenceGrant gets a full article later in the series.
Two Ways to Get a Certificate
| Approach | Good for | Renewal |
|---|---|---|
openssl + kubectl create secret tls | Learning, air-gapped labs, a certificate issued by another team | You, by hand, before it expires |
| cert-manager | Everything else | Automatic, about 30 days before expiry by default |
You will do both. The manual version shows that there is no magic: a Gateway simply reads a TLS Secret. The cert-manager version shows how to stop thinking about it.
How cert-manager Uses a Gateway
cert-manager has a component called the gateway-shim. When Gateway API support is enabled, it watches Gateway resources. When it sees a Gateway with the annotation cert-manager.io/cluster-issuer (or cert-manager.io/issuer for a namespaced Issuer), it reads every listener and creates a Certificate resource for each one that qualifies. The Certificate is then issued into the Secret named in certificateRefs.
A listener qualifies only when all of these hold:
| Listener field | Requirement |
|---|---|
hostname | Must not be empty. It becomes the DNS name on the certificate |
tls.mode | Must be Terminate. Passthrough is not supported |
tls.certificateRefs[].name | Must not be empty. It is the Secret cert-manager writes |
tls.certificateRefs[].kind | If set, must be Secret |
tls.certificateRefs[].namespace | If set, must be the Gateway's own namespace |
Listeners that fail these checks are skipped silently. If cert-manager does not create a Certificate, check these five fields first.
Two details are worth remembering. First, since cert-manager 1.15 Gateway API support no longer needs a feature gate, but it is still off by default and must be enabled with the Helm value config.gatewayAPI.enabled=true. Second, the Gateway API CRDs must exist before cert-manager starts. If you install the CRDs afterwards, restart the cert-manager Deployment. In this series Envoy Gateway installed the CRDs in article two, so the order is already right.
Issuers for Local and Production Use
An Issuer or ClusterIssuer tells cert-manager where certificates come from. A ClusterIssuer works for every namespace, which suits a shared Gateway.
| Issuer type | Trusted by browsers | Needs the cluster to be reachable from the internet |
|---|---|---|
selfSigned | No | No |
ca (your own CA) | Only by machines that trust your CA | No |
acme with Let's Encrypt | Yes | Yes, for HTTP-01 |
On minikube you cannot complete a Let's Encrypt challenge, because Let's Encrypt must reach http://<your-host>/.well-known/acme-challenge/... from the internet. So the hands-on part uses a small CA issuer built inside the cluster. The production shape is shown alongside it, so the only change later is the issuer name in one annotation.
The HTTP-01 Solver for Gateway API
For Ingress, cert-manager solves HTTP-01 challenges by creating a temporary Ingress. For Gateway API it creates a temporary HTTPRoute instead. You tell it which Gateway to attach that route to with gatewayHTTPRoute.parentRefs:
The temporary route matches the exact path /.well-known/acme-challenge/<token> on port 80. cert-manager deletes it once the certificate is issued. cert-manager never edits your Gateway, so the Gateway must already have an HTTP listener on port 80. If it does not, the challenge has nowhere to attach.
Redirecting HTTP to HTTPS
Once HTTPS works, plain HTTP should do nothing except send the client to HTTPS. In Gateway API that is a route rule with a RequestRedirect filter and no backend:
The allowed status codes are 301, 302, 303, 307 and 308. Only 301 and 302 are Core and guaranteed on every implementation; the others are Extended. The default is 302. Use 301 for a permanent redirect.
Attach this route only to the http listener, using sectionName: http in its parentRefs. Attach the real application route only to the https listener. If you attach the application route to both, plain HTTP keeps working and the redirect becomes a coin toss decided by route precedence.
Does the redirect break the ACME challenge? No. The challenge route uses an Exact path match, and an exact path always wins over the redirect rule's implicit / prefix match. Precedence rules are covered in detail in the next article.
Hands-On: Kubernetes Commands
Find the Envoy Service for the Gateway
Envoy Gateway creates one Service per Gateway in envoy-gateway-system. These labels identify it.
Forward both HTTP and HTTPS ports
One port-forward can carry several ports. Port 443 only exists on the Service after you add the HTTPS listener.
Create a TLS Secret from files
The Secret type is kubernetes.io/tls, with the keys tls.crt and tls.key.
Inspect a certificate inside a Secret
Decodes the certificate and prints its subject, issuer, SAN and expiry. This form works with both OpenSSL and the LibreSSL that ships with macOS, which does not know the newer -ext option.
Check the listener status
Each listener reports its own conditions. ResolvedRefs tells you whether the certificate Secret was found and allowed.
Call HTTPS on a host name that does not resolve
--resolve pins storefront.local:8443 to 127.0.0.1, so curl sends the right SNI and Host without editing /etc/hosts.
Skip verification when you only care about routing
-k accepts any certificate. Useful for a quick check, never as proof that TLS is set up correctly.
List cert-manager resources
Each step of issuance is a resource you can inspect.
Explain why a Certificate is not ready
The Events and conditions at the bottom usually name the problem.
Step-by-Step Example
The Scenario
The storefront team wants kiosk-api to be reachable only over HTTPS at storefront.local. Plain HTTP requests must be redirected permanently. The platform team wants certificates to renew themselves. You will get there in two stages: first with a certificate made by hand, then with cert-manager.
All files are created in one working directory. The numbers in the file names give the order to apply them.
Step 1 — Start from a Clean Route Table
Earlier articles attached routes to the http listener. This article redefines the routing for storefront.local from scratch, so remove the old routes first. The application keeps running; only routes are deleted.
Step 2 — Create a Local CA and a Server Certificate
A self-signed server certificate works, but curl cannot verify it without -k. A tiny CA is only two extra commands and lets you verify properly with --cacert.
Create the CA:
Create a key and a signing request for storefront.local:
Sign it with the CA. Modern clients ignore the CN and check only the Subject Alternative Name, so the SAN is not optional.
Store the certificate in the Gateway's namespace:
Step 3 — Add the HTTPS Listener
This is the full Gateway. The http listener stays, because it will carry the redirect. Note that hostname is now set on both listeners, so this Gateway only answers for storefront.local.
ResolvedRefs=True on https means the Secret was found and is a valid TLS Secret. Delete the Secret and this flips to False, which is the quickest way to diagnose a broken HTTPS listener.
Step 4 — Route the Application on HTTPS Only
sectionName: https attaches the route to one listener only.
Step 5 — Test HTTPS with the CA
Find the Envoy Service and start the port-forward in a second terminal. The Service picked up port 443 when the listener was added.
On Docker Desktop's built-in kind cluster the Envoy LoadBalancer Service is also published on localhost, so --resolve storefront.local:443:127.0.0.1 https://storefront.local/ works without the port-forward, as long as nothing else on your machine holds ports 80 and 443.
Back in the first terminal:
Three things were proven at once: Envoy presented the certificate from the Secret, curl trusted it through your CA, and the decrypted request reached a kiosk-api Pod on plain HTTP.
Now try the same request without --cacert. curl uses the system trust store, which does not know your CA:
That failure is correct behaviour, not a broken setup. -k would hide it, which is exactly why -k is not a test of TLS.
Step 6 — Redirect HTTP to HTTPS
At this point plain HTTP gets a 404, because no route is attached to the http listener. Add the redirect route:
The path and the query string are kept. The port is gone, because when the scheme changes to https and no port is set, the redirect uses the well-known port 443. Through the port-forward that address is not reachable, but on a real cluster with a LoadBalancer it is exactly what you want. You could set port: 8443 in the filter for local testing, but then the manifest no longer matches production, so leave it out.
Step 7 — Install cert-manager with Gateway API Support
Manual certificates expire in a year and nobody remembers to renew them. Install cert-manager from its OCI chart. crds.enabled=true installs cert-manager's own CRDs, and config.gatewayAPI.enabled=true turns on the gateway-shim.
If you ever install the Gateway API CRDs after cert-manager, run kubectl rollout restart deployment cert-manager -n cert-manager so it notices them.
Step 8 — Build a CA Issuer Inside the Cluster
This is the cert-manager equivalent of Step 2. A selfSigned ClusterIssuer creates a CA certificate, and a ca ClusterIssuer signs server certificates with it. A ClusterIssuer reads its CA Secret from the cert-manager namespace, so the CA Certificate goes there.
Step 9 — Let the Gateway Ask for Its Own Certificate
Add one annotation to the Gateway and point the HTTPS listener at a new Secret name, storefront-cm-tls. You do not create that Secret. cert-manager does.
The Certificate is named after the Secret and owned by the Gateway. Look at what cert-manager put on it:
The DNS name came from the listener's hostname. The default certificate lasts 90 days and is renewed at two thirds of its lifetime. You never touch it again.
Step 10 — Verify the New Certificate
Extract the cluster CA so curl can trust it, then call the same URL as before. The port-forward from Step 5 is still running.
The issuer changed and the routes did not. The HTTPRoutes never mention certificates, so swapping how the certificate is produced has no effect on routing. Delete the old hand-made Secret, which nothing references any more:
Step 11 — The Production Issuer
On a cluster that is reachable from the internet, with storefront.example.com pointing at the Gateway's external address, only the issuer changes. This ClusterIssuer uses Let's Encrypt with the Gateway API HTTP-01 solver:
Then the Gateway annotation becomes cert-manager.io/cluster-issuer: letsencrypt-prod and both listener host names become storefront.example.com. When the Certificate is requested, follow it through the ACME resources:
While the challenge is pending you will see a temporary HTTPRoute named cm-acme-http-solver-... attached to the http listener. It disappears when the Order is valid. Use the Let's Encrypt staging server, https://acme-staging-v02.api.letsencrypt.org/directory, while you experiment; the production server rate-limits failed attempts.
Do not apply this file on minikube. The challenge will stay pending forever, because Let's Encrypt cannot reach your laptop.
Step 12 — Clean Up
Remove the routes and restore the plain HTTP Gateway that the next article starts from. cert-manager itself can stay installed; later articles do not depend on it, and it costs nothing while idle.
Remove the issuer annotation from the Gateway before deleting the Certificate. While the annotation is there, the gateway-shim notices the missing Certificate and creates it again within seconds.
Re-apply the HTTP-only Gateway from the earlier articles (one http listener on port 80, no annotation) and the routes you need. To remove cert-manager completely, run helm uninstall cert-manager -n cert-manager.
Summary
- An HTTPS listener with
tls.mode: Terminatedecrypts traffic at the Gateway. Your .NET Pods keep serving plain HTTP on 8080 and never see a certificate. certificateRefspoints at akubernetes.io/tlsSecret. Keep it in the Gateway's namespace; another namespace needs a ReferenceGrant.ResolvedRefs=Falseon a listener almost always means the Secret is missing, malformed, or in a namespace the Gateway may not read.- Test with
curl --resolve host:port:127.0.0.1 --cacert ca.crt.-kproves routing, not TLS. - cert-manager watches Gateways once
config.gatewayAPI.enabled=trueis set. Onecert-manager.io/cluster-issuerannotation creates a Certificate for every qualifying listener. - A listener qualifies only with a
hostname,tls.mode: Terminateand a Secret name in its own namespace. Anything else is skipped silently. - For Let's Encrypt, the HTTP-01
gatewayHTTPRoutesolver attaches a temporary HTTPRoute to an existing port 80 listener throughparentRefs. - An HTTP to HTTPS redirect is an HTTPRoute on the
httplistener with aRequestRedirectfilter,scheme: httpsandstatusCode: 301. Attach the application route tohttpsonly.
In the next article we stop routing everything to one backend and look at what HTTPRoute can really match on: paths, headers, query parameters and methods, plus the filters that rewrite, redirect and mirror requests on the way through.