Envoy Gateway Local HTTPS with cert-manager and a Self-Signed CA

Tested on RHEL 10.2 (Coughlan)
Package kubectl v1.37.0
Envoy Gateway v1.9.2
cert-manager v1.21.2
Gateway 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
Short on time?Beta Get the gist of this 11-minute read in seconds, then ask follow-up questions

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 once

The hostname does not have to exist in public DNS:

  • app.example.com is 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.crt

You 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: true marks it as a trust anchor
  • commonName: local-ca is the issuer name on the site certificate
  • duration: 8760h is 365 days, so the CA outlives the default site certificate
  • secretName: local-ca is the TLS Secret cert-manager fills. You do not create that Secret yourself
yaml
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-ca

Apply 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:

bash
kubectl wait --for=condition=Ready certificate/local-ca --timeout=120s
output
certificate.cert-manager.io/local-ca condition met

Ready 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 Certificate dnsNames
  • tls.mode: Terminate
  • One certificateRefs entry

There is no HTTP listener on this Gateway, and nothing is fetched from the internet to prove the name.

yaml
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-tls

An existing HTTPRoute attaches to that listener with sectionName: https. No certificate configuration belongs in the HTTPRoute. Its backendRefs still point at your application Service.

yaml
parentRefs:
- name: eg
  sectionName: https

Wait until the Certificate the annotation created is Ready:

bash
kubectl wait --for=condition=Ready certificate/edge-tls --timeout=180s
output
certificate.cert-manager.io/edge-tls condition met

Ready=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 served

The 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:

bash
kubectl get secret local-ca -o jsonpath='{.data.tls\.crt}' \
  | base64 -d | openssl x509 -noout -subject -issuer -ext basicConstraints
output
subject=CN=local-ca
issuer=CN=local-ca
X509v3 Basic Constraints: critical
    CA:TRUE

Read the Gateway certificate from Secret edge-tls. The issuer is CN=local-ca, and the hostname is the Subject Alternative Name:

bash
kubectl get secret edge-tls -o jsonpath='{.data.tls\.crt}' \
  | base64 -d | openssl x509 -noout -issuer -ext subjectAltName
output
issuer=CN=local-ca
X509v3 Subject Alternative Name: critical
    DNS:app.example.com

DNS:app.example.com is what clients check:

  • cert-manager copied that name from the Gateway listener
  • Trust local-ca, not edge-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:

bash
kubectl -n envoy-gateway-system port-forward svc/envoy-default-eg-e41e7b31 8443:443
output
Forwarding from 127.0.0.1:8443 -> 10443

The 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:

bash
kubectl get secret local-ca -o jsonpath='{.data.tls\.crt}' | base64 -d > local-ca.crt

Call the hostname through the forward. --resolve supplies the address, and --cacert supplies the CA. Verification stays on:

bash
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/
output
http=200 verify=0

ssl_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:

bash
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/
output
curl: (60) SSL certificate problem: unable to get local issuer certificate
http=000 verify=20

verify=20 means the client could not find a trusted issuer. The two curl flags are not interchangeable:

  • -k only proves that Envoy can finish a TLS handshake while the client ignores certificate trust
  • --cacert keeps 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


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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)