| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | kubectl v1.37.0Envoy Gateway v1.9.2cert-manager v1.21.2Gateway API CRDs v1.6.1 (standard channel) |
| Applies to | Any host with kubectl configured; any Kubernetes cluster that already runs Envoy Gateway and cert-manager |
| Assumes | Envoy Gateway and cert-manager are already installed, with Gateway API support enabled in cert-manager |
| Privilege | Normal user |
| Scope | How to serve local HTTPS on Envoy Gateway that curl can verify: one SelfSigned Issuer bootstraps a CA, a CA Issuer signs the Gateway certificate, and the client trusts that CA. Does not cover Let's Encrypt, HTTP-01, DNS-01, or rotating a CA through client trust stores. |
| Related guides | Issue Let's Encrypt certificates for Envoy Gateway Migrate ingress-nginx to Envoy Gateway Kubernetes Gateway API Kubernetes Secrets Kubernetes Services |
Envoy Gateway's basic self-signed TLS example can create a certificate quickly, and examples commonly test it with curl -k. You could explicitly trust that individual self-signed certificate, but if you want one reusable trust anchor for multiple or renewed leaf certificates, use cert-manager's SelfSigned Issuer once to bootstrap a local CA. A CA Issuer can then sign the Gateway certificate, and curl trusts the CA with --cacert.
A SelfSigned Issuer can sign the Gateway certificate directly, but every such certificate is its own trust anchor. This page uses SelfSigned only to create one local CA. A CA Issuer then signs the Gateway certificate, so the client trusts the CA once rather than trusting each site certificate.
A direct self-signed leaf and a local CA do not ask the client to trust the same thing:
Direct self-signed leaf
certificate A → trust A
certificate B → trust B
Local CA
|
+-- certificate A
+-- certificate B
+-- renewed A
Client trusts the CA onceThe hostname does not have to exist in public DNS:
app.example.comis the name on the Gateway listener and the name curl sends- A browser rejects the certificate until that CA is in the browser trust store
SelfSigned Issuer
↓
Local CA certificate
↓
CA Issuer
↓
Gateway leaf certificate
↓
curl --cacert local-ca.crtYou need Envoy Gateway and cert-manager already installed. The ingress-nginx to Envoy Gateway migration is that install. This page also assumes:
- Gateway API support in cert-manager is on. The install used here set
config.gatewayAPI.enabled=true - The Gateway API CRDs are already present
- The hostname can be any name you choose, such as
app.example.com
SelfSigned Issuer vs CA Issuer
A self-signed issuer and a CA issuer are not the same object. The first signs a certificate with that certificate's own key. Use it once, with isCA: true, and you get a CA. The second signs other certificates with that CA.
| Resource | Purpose |
|---|---|
Issuer selfsigned |
Bootstraps the root CA certificate |
Certificate local-ca |
The CA certificate and its private key |
Issuer local-ca |
Uses that CA to sign leaf certificates |
Certificate edge-tls |
Leaf certificate cert-manager creates for the Gateway |
Secret edge-tls |
Certificate and key Envoy serves |
Both Issuers live in default, next to the Gateway:
- A namespaced Issuer signs only in its own namespace
- A ClusterIssuer is the cluster-scoped form, and this setup does not need one
Create a local CA with cert-manager
The CA Certificate sets the fields the Gateway certificate does not:
isCA: truemarks it as a trust anchorcommonName: local-cais the issuer name on the site certificateduration: 8760his 365 days, so the CA outlives the default site certificatesecretName: local-cais the TLS Secret cert-manager fills. You do not create that Secret yourself
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: selfsigned
namespace: default
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: local-ca
namespace: default
spec:
isCA: true
commonName: local-ca
secretName: local-ca
duration: 8760h
issuerRef:
name: selfsigned
kind: Issuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: local-ca
namespace: default
spec:
ca:
secretName: local-caApply those three resources before you annotate the Gateway. The CA Issuer stays unready until Secret local-ca exists. Wait until the CA Certificate is Ready:
kubectl wait --for=condition=Ready certificate/local-ca --timeout=120scertificate.cert-manager.io/local-ca condition metReady on local-ca means the trust anchor is in the Secret. The CA Issuer can then sign the Gateway certificate.
Configure the Envoy Gateway HTTPS listener
The sections below assume a GatewayClass and an application HTTPRoute already exist. Add the issuer annotation and the TLS certificate reference to your Gateway. The annotation is cert-manager.io/issuer, not cert-manager.io/cluster-issuer, because Issuer local-ca is namespaced.
A Gateway API HTTPS listener needs three TLS fields:
- A
hostname, which cert-manager copies into the CertificatednsNames tls.mode: Terminate- One
certificateRefsentry
There is no HTTP listener on this Gateway, and nothing is fetched from the internet to prove the name.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: eg
namespace: default
annotations:
cert-manager.io/issuer: local-ca
spec:
gatewayClassName: eg
listeners:
- name: https
protocol: HTTPS
hostname: app.example.com
port: 443
allowedRoutes:
namespaces:
from: Same
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: edge-tlsAn existing HTTPRoute attaches to that listener with sectionName: https. No certificate configuration belongs in the HTTPRoute. Its backendRefs still point at your application Service.
parentRefs:
- name: eg
sectionName: httpsWait until the Certificate the annotation created is Ready:
kubectl wait --for=condition=Ready certificate/edge-tls --timeout=180scertificate.cert-manager.io/edge-tls condition metReady=True on edge-tls means cert-manager successfully issued the certificate and populated Secret edge-tls, which the Gateway listener references. The Certificate and its Secret stay in the Gateway's namespace. That is not the same as Envoy already serving the certificate:
Certificate Ready=True
↓
TLS Secret exists
↓
Gateway listener accepted
↓
Envoy can use that Secret
↓
successful curl
↓
certificate is actually being servedThe curl check later in this page is what shows Envoy served that certificate.
Two Secrets are involved. They are not the same object, and curl must not receive the CA private key:
| Secret | Contains | Who uses it |
|---|---|---|
local-ca |
CA certificate and private key | cert-manager uses both for signing. Export only the CA certificate for client trust |
edge-tls |
site certificate and private key | Envoy, to terminate HTTPS |
Verify the certificate chain
Read the CA certificate from Secret local-ca. The subject and the issuer are the same name, and CA:TRUE is the trust-anchor flag:
kubectl get secret local-ca -o jsonpath='{.data.tls\.crt}' \
| base64 -d | openssl x509 -noout -subject -issuer -ext basicConstraintssubject=CN=local-ca
issuer=CN=local-ca
X509v3 Basic Constraints: critical
CA:TRUERead the Gateway certificate from Secret edge-tls. The issuer is CN=local-ca, and the hostname is the Subject Alternative Name:
kubectl get secret edge-tls -o jsonpath='{.data.tls\.crt}' \
| base64 -d | openssl x509 -noout -issuer -ext subjectAltNameissuer=CN=local-ca
X509v3 Subject Alternative Name: critical
DNS:app.example.comDNS:app.example.com is what clients check:
- cert-manager copied that name from the Gateway listener
- Trust
local-ca, notedge-tls
cert-manager renews the Gateway leaf certificate before it expires. Rotating the CA itself is a separate operation, because every client has to be updated to trust the new CA.
Trust the local CA with curl
If the Gateway has no external address, forward its Envoy Service and leave that process running:
kubectl -n envoy-gateway-system port-forward svc/envoy-default-eg-e41e7b31 8443:443Forwarding from 127.0.0.1:8443 -> 10443The Service name is generated from the Gateway. Use the name on your cluster. Port 8443 on this machine is the local side of listener port 443.
Export tls.crt from the local-ca Secret because that Secret represents the CA Certificate itself. Do not export the private key. This also avoids relying on the derived ca.crt field of an issued leaf certificate as your long-lived trust source. The certificate you export is the one that showed CA:TRUE.
The command prints nothing:
kubectl get secret local-ca -o jsonpath='{.data.tls\.crt}' | base64 -d > local-ca.crtCall the hostname through the forward. --resolve supplies the address, and --cacert supplies the CA. Verification stays on:
curl -sS -o /dev/null -w 'http=%{http_code} verify=%{ssl_verify_result}\n' \
--resolve app.example.com:8443:127.0.0.1 --cacert local-ca.crt \
https://app.example.com:8443/http=200 verify=0ssl_verify_result=0 means curl accepted the chain. The same response also shows:
- The hostname check used
DNS:app.example.com - HTTP 200 means the request reached the application
That is a verified request: signature, hostname, and the CA you chose to trust.
The same call without --cacert fails, because the CA is not in the system trust store:
curl -sS -o /dev/null -w 'http=%{http_code} verify=%{ssl_verify_result}\n' \
--resolve app.example.com:8443:127.0.0.1 https://app.example.com:8443/curl: (60) SSL certificate problem: unable to get local issuer certificate
http=000 verify=20verify=20 means the client could not find a trusted issuer. The two curl flags are not interchangeable:
-konly proves that Envoy can finish a TLS handshake while the client ignores certificate trust--cacertkeeps verification on and proves the certificate chains to the CA you intended to trust
Do not use -k as the successful check.
Do you need public DNS or port 80?
No. A private CA does not ask the internet to prove the name:
- There is no ACME Order and no Challenge
- Port 80 is not part of issuance
Public DNS and port 80 matter when a public CA has to reach your hostname. That path is Let's Encrypt certificates for Envoy Gateway.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Certificate edge-tls is never created |
Gateway API support is off in cert-manager, or the Gateway annotation is missing | Enable config.gatewayAPI.enabled and set cert-manager.io/issuer: local-ca. If the Gateway API CRDs were installed after cert-manager started, restart the cert-manager Deployment |
Certificate edge-tls or Issuer local-ca stays not Ready |
The CA Issuer has no Secret yet, or the listener has no hostname | Wait until Certificate local-ca is Ready, then confirm the HTTPS listener sets hostname |
curl: (60) unable to get local issuer certificate |
The client is not trusting the local CA | Pass --cacert with tls.crt from Secret local-ca. Do not pass the site certificate, and do not use -k as the proof |
| Hostname mismatch during verification | The name curl calls is not the certificate SAN | Use the listener hostname. This certificate has DNS:app.example.com |
| A browser warns about the certificate | The private CA is not in that browser's trust store | Install the CA certificate there, or keep using curl --cacert |
Local CA or a publicly trusted certificate
Which CA to use depends on who the clients are:
- Use this CA when you control the clients and can give them the CA certificate. curl does that with
--cacert. A browser does it only after the CA is installed. - For a public website used by arbitrary clients on the internet, use a publicly trusted CA. Those clients will not have your private CA, and they should not be told to ignore verification.
Summary
A directly self-signed leaf certificate is its own trust anchor, so the client must either trust that individual certificate or disable verification. This setup instead creates one local CA and uses it to sign the Gateway leaf certificate in Secret edge-tls, allowing clients to trust the CA once. The listener hostname becomes DNS:app.example.com.
You do not need public DNS or port 80. Export tls.crt from Secret local-ca and pass it to curl --cacert. The checked request returned HTTP 200 and verify=0. Without that file, curl returned verify=20 because the issuer was unknown. -k is not that check.
cert-manager renews the leaf certificate in Secret edge-tls before it expires. Replacing the CA means distributing a new trust anchor. A name that arbitrary internet clients must trust belongs on a publicly trusted CA.
References
- cert-manager self-signed issuer: how a self-signed issuer signs a certificate with its own key, including a CA
- cert-manager CA issuer: how a CA issuer signs certificates from a CA Secret
- cert-manager Gateway certificates: annotations that create a Certificate from a Gateway
- Envoy Gateway cert-manager TLS (v1.9): Gateway listener and Secret reference
Frequently Asked Questions
What is the difference between a SelfSigned Issuer and a CA Issuer?
A SelfSigned Issuer signs a certificate with that certificate's own key. Used with isCA: true, it creates the local CA. A CA Issuer signs other certificates with that CA. Envoy serves the leaf. Clients trust the CA.
Why not just use curl -k?
-k disables certificate verification. It shows that Envoy completed a handshake while the client ignored trust. --cacert leaves verification on and tells curl which private CA to trust. The successful check on this page is verify=0 with --cacert, not -k.
Do I need a public DNS name for this?
No. The hostname is the hostname field on the Gateway listener and the name you pass to curl --resolve. Nothing looks that name up on the public internet.
Do I need port 80?
No. Port 80 is for an HTTP-01 challenge from a public CA. This signer does not create an Order or a Challenge. The HTTPS listener is the only listener on the Gateway.
Why does curl say unable to get local issuer certificate?
The CA is private. It is not in the system trust store, so curl reports verify=20. Pass --cacert with the certificate from Secret local-ca and the same curl reports verify=0.
Can I use this certificate for a public website?
Not for arbitrary internet clients. They will not trust a CA they were never given. A private CA fits where you control the clients and can install that CA. A public website needs a publicly trusted CA.

