| Tested on | Ubuntu 24.04.5 LTS |
|---|---|
| Package | kubectl v1.36.5Envoy Gateway v1.9.2cert-manager v1.21.2cmctl v2.6.1Gateway 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 |
| Lab environment | Both controllers already installed, GatewayClass eg, and a public hostname whose A record reaches the Gateway |
| Privilege | Normal user |
| Scope | How cert-manager issues a Let's Encrypt certificate for an Envoy Gateway HTTPS listener, why HTTP-01 needs that hostname on port 80, how the listener Secret is filled, and how to read renewal time. Does not cover DNS-01, a private certificate authority, or migrating ingress-nginx. |
| Related guides | Migrate ingress-nginx to Envoy Gateway Migrate rewrite-target and regex paths Kubernetes Gateway API Kubernetes Secrets Kubernetes Services |
Envoy Gateway serves the certificate. cert-manager is the piece that obtains it from Let's Encrypt and writes it into the Secret the HTTPS listener already names.
Let's Encrypt checks that you control the hostname before it issues a certificate:
- ACME is the protocol for that check
- HTTP-01 is the validation method this page uses
- cert-manager serves a one-time token at
/.well-known/acme-challenge/... - Let's Encrypt requests that URL on public port 80
The examples on this page follow three rules:
- The hostname is
app.example.com - Start with the staging directory so a bad order does not spend production rate limit
- Path rewrites are a separate change, covered in Migrate rewrite-target and regex paths
Client requests https://app.example.com
↓
Gateway HTTPS listener terminates TLS
↓
Secret edge-tls holds the certificate
↓
cert-manager filled that Secret
↓
HTTP-01 proved the name on port 80
↓
Service web receives the requestThose objects show up in order. The Gateway annotation and certificateRefs are the start. The Secret edge-tls is the end:
Gateway annotation + certificateRefs
↓
cert-manager sees the Gateway
↓
Certificate
↓
CertificateRequest
↓
ACME Order
↓
Challenge
↓
Temporary HTTPRoute, Service, and solver Pod
↓
Let's Encrypt fetches the HTTP-01 token on port 80
↓
Challenge succeeds
↓
Secret edge-tls receives tls.crt and tls.key
↓
Envoy Gateway serves that certificatecert-manager deletes that temporary HTTPRoute, Service, and solver Pod after the Challenge succeeds.
Check DNS and port 80 for Let's Encrypt HTTP-01
This page starts after the controllers are installed. The ingress-nginx to Envoy Gateway migration is the install walkthrough.
The cluster already had:
- Envoy Gateway
v1.9.2with GatewayClasseg - cert-manager
v1.21.2with Gateway API support enabled - Gateway API CRDs
v1.6.1from the standard channel
cert-manager creates a Certificate from a Gateway only when that support is on:
- The Helm value used here was
config.gatewayAPI.enabled=true - Install the Gateway API CRDs before cert-manager starts
- If those CRDs appear later, restart the cert-manager Deployment
Issuance and the finished HTTPS site are separate checks:
- For issuance, DNS for
app.example.commust reach this Gateway, and public TCP port 80 must reach its HTTP listener - For the site itself, public TCP port 443 must reach the HTTPS listener
- Let's Encrypt performs HTTP-01 on port 80
Check every public A and AAAA record:
- If the hostname publishes IPv6, that AAAA address must also reach this Gateway
- A stale AAAA record can break validation even when the A record is correct. Let's Encrypt tries IPv6 first; some network-level IPv6 failures can fall back to IPv4, but an IPv6 server that responds incorrectly can cause the challenge to fail without that fallback.
Ask for the AAAA record first. This name had none, so the lookup printed nothing:
dig +short AAAA app.example.comdig prints nothing when the name has no AAAA record. That is the result you want unless you really do serve this Gateway on IPv6.
Then ask for the A record:
dig +short A app.example.comThis printed one IPv4 address. That address has to be the Gateway. A second A record, or an AAAA record aimed at a different host, can send the challenge somewhere else.
Port 80 must remain reachable for HTTP-01:
- cert-manager creates a temporary HTTPRoute for the exact
/.well-known/acme-challenge/<token>path - That route should handle the challenge even when normal HTTP traffic redirects to HTTPS
- Let's Encrypt follows HTTP-to-HTTPS redirects
- If issuance fails after you add redirects, inspect the solver HTTPRoute and test the challenge URL
- Incorrect routing, another proxy in front of the Gateway, or a broken IPv6 path can still send the challenge somewhere else
The account contact and the certificate name are different:
- The email does not have to be an address at
app.example.com. Let's Encrypt uses it for account notices - The name on the certificate comes from the Gateway listener hostname
DNS-01 would prove the name through a DNS provider API instead of port 80. That is a different solver and a different page. A cluster with no public name can use a local CA instead, covered in Envoy Gateway local HTTPS with a CA.
How cert-manager fills the Envoy Gateway certificate
The same Gateway is in two flows. Port 80 carries the ACME challenge. Port 443 carries normal traffic to the backend Service.
HTTP port 80
↓
ACME challenge
↓
temporary solver HTTPRoute HTTPS port 443
↓
application traffic
↓
HTTPRoute edge to Service webThe issuer and the Gateway point at each other for different reasons:
- The Gateway annotation
cert-manager.io/cluster-issuertells cert-manager which issuer should create the TLS certificate - The ClusterIssuer field
gatewayHTTPRoute.parentRefstells cert-manager which Gateway's port 80 listener should carry the challenge
This guide uses a ClusterIssuer:
- It is cluster-scoped, so Gateways in more than one namespace can reference it
- A normal Issuer is namespaced and can issue only for resources in its own namespace
Two Secrets show up in the YAML. They are not the same object:
| Secret | Contains | Who uses it |
|---|---|---|
letsencrypt-staging-account-key |
ACME account private key | the ClusterIssuer, to sign in to Let's Encrypt |
edge-tls |
site certificate and private key | Envoy, to terminate HTTPS for clients |
For this annotation flow, the Certificate and Secret stay with the Gateway:
- cert-manager creates both in the Gateway's namespace
edge-tlsappears indefault, beside Gatewayeg- You do not create that TLS Secret yourself
Create a staging Let's Encrypt ClusterIssuer
Use the staging directory until HTTP-01 succeeds:
- The directory is
acme-staging-v02.api.letsencrypt.org - The certificate is a real ACME result
- Public clients do not trust it
- A failed order against the production directory still counts toward Let's Encrypt rate limits
The fields that matter:
serverselects staging or productionemailis the ACME account contactprivateKeySecretRefstores the account key, not the site certificategatewayHTTPRoute.parentRefsselects Gatewayegindefaultas the port 80 parent for the challenge route
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: you@example.com
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- kind: Gateway
name: eg
namespace: defaultThis issuer is applied with the Gateway in the next section. cert-manager creates and manages the ACME account-key Secret referenced by privateKeySecretRef. Once the ClusterIssuer is Ready, the ACME account is registered and the issuer can process certificate requests.
Attach cert-manager to the Envoy Gateway HTTPS listener
A Gateway API HTTPS listener needs three settings. cert-manager copies the hostname into the Certificate dnsNames, and Envoy Gateway serves the one Secret named there:
hostnameon the listenertls.mode: Terminate- one
certificateRefsentry
The HTTP listener is where the challenge attaches:
- It has no hostname, so it can accept the challenge route for
app.example.com allowedRoutesisSame- The solver HTTPRoute is created in the Challenge namespace, which here is
default, next to the Gateway - Use
Allonly when solver routes are supposed to come from other namespaces
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: eg
namespace: default
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same
- name: https
protocol: HTTPS
hostname: app.example.com
port: 443
allowedRoutes:
namespaces:
from: Same
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: edge-tlsNormal application traffic is a separate HTTPRoute. It attaches to both listeners and sends app.example.com to Service web:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: edge
namespace: default
spec:
parentRefs:
- name: eg
sectionName: http
- name: eg
sectionName: https
hostnames:
- app.example.com
rules:
- backendRefs:
- name: web
port: 80Save the issuer, the Gateway, and HTTPRoute edge with GatewayClass eg and the web backend, which were already installed.
The HTTP listener in that file:
- It is set to
Same - The solver route in this test was created in
default, soSameis enough - An earlier copy used
Allon that listener, which also accepted the route
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:1.27-alpine
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: web
namespace: default
spec:
selector:
app: web
ports:
- name: http
port: 80
targetPort: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: eg
namespace: default
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same
- name: https
protocol: HTTPS
hostname: app.example.com
port: 443
allowedRoutes:
namespaces:
from: Same
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: edge-tls
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: edge
namespace: default
spec:
parentRefs:
- name: eg
sectionName: http
- name: eg
sectionName: https
hostnames:
- app.example.com
rules:
- backendRefs:
- name: web
port: 80
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: you@example.com
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- kind: Gateway
name: eg
namespace: defaultApply the file. On this cluster the objects already existed, so Kubernetes reported configured and unchanged. A first apply of the same file prints created:
kubectl apply -f edge-tls.yamlgatewayclass.gateway.networking.k8s.io/eg unchanged
deployment.apps/web unchanged
service/web unchanged
gateway.gateway.networking.k8s.io/eg configured
httproute.gateway.networking.k8s.io/edge configured
clusterissuer.cert-manager.io/letsencrypt-staging configuredUntil the Challenge finishes, Secret edge-tls does not exist and the HTTPS listener has nothing to serve.
Follow the Let's Encrypt HTTP-01 challenge
cert-manager creates Certificate edge-tls from the annotation and the Secret reference. It stays not Ready while the ACME order is waiting.
List the Certificate, the Order, and the Challenge together:
kubectl get certificate,order,challenge -ANAMESPACE NAME READY SECRET AGE
default certificate.cert-manager.io/edge-tls False edge-tls 26s
NAMESPACE NAME STATE AGE
default order.acme.cert-manager.io/edge-tls-1-1246097667 pending 23s
NAMESPACE NAME STATE DOMAIN AGE
default challenge.acme.cert-manager.io/edge-tls-1-1246097667-1385337141 pending app.example.com 11sREADY is False and the Challenge is pending for the first half minute. Describe the Challenge if it stays there. On this run the reason was HTTP 503:
kubectl describe challenge -n default edge-tls-1-1246097667-1385337141Status:
Presented: true
Presented At: 2026-10-03T17:25:59Z
Processing: true
Reason: Waiting for HTTP-01 challenge propagation: wrong status code '503', expected '200'
State: pendingOn this run the 503 was temporary:
- The challenge route existed before its solver backend was ready
- The same Challenge succeeded a few seconds later
- A 503 means the Gateway could not hand the request to a usable backend
- If it persists, inspect the temporary solver Service, Pod, endpoints, and HTTPRoute
While the Challenge is pending, a route named cm-acme-http-solver- should be attached to Gateway eg. List HTTPRoutes and look for that prefix:
kubectl get httproute -ANAMESPACE NAME HOSTNAMES AGE
default cm-acme-http-solver-s7xgh ["app.example.com"] 16s
default edge ["app.example.com"] 33sThe solver objects share one prefix:
- The Service and Pod names start with
cm-acme-http-solver - If the route is missing, or
kubectl describe httprouteshows it was not accepted, the Challenge cannot finish - After success, cert-manager deletes the route, Service, and Pod
The solver Pod then started, Let's Encrypt fetched the token, and the Challenge completed. These are the later events:
kubectl get events -n default31s Normal Started challenge/edge-tls-1-1246097667-1385337141 Challenge scheduled for processing
29s Normal Presented challenge/edge-tls-1-1246097667-1385337141 Presented challenge using HTTP-01 challenge mechanism
9s Normal DomainVerified challenge/edge-tls-1-1246097667-1385337141 Domain "app.example.com" verified with "HTTP-01" validation
5s Normal CertificateIssued certificaterequest/edge-tls-1 Certificate fetched from issuer successfully
5s Normal Issuing certificate/edge-tls The certificate has been successfully issuedDomainVerified means Let's Encrypt accepted the token. The Certificate then becomes Ready:
kubectl get certificate edge-tlsNAME READY SECRET AGE
edge-tls True edge-tls 53sVerify the staging Let's Encrypt certificate
Read the issuer and the dates from Secret edge-tls:
- A staging success still says
STAGINGin the issuer name - Clients that trust only public roots reject this certificate
- Keep this issuer until that check looks right, then move to production
kubectl get secret edge-tls -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -issuer -subject -datesissuer=C = US, O = Let's Encrypt, CN = (STAGING) Ersatz Emmer YR2
subject=CN = app.example.com
notBefore=Oct 3 16:27:49 2026 GMT
notAfter=Jan 1 16:27:48 2027 GMTRead these fields before you switch directories:
notAfteris 90 days afternotBefore- The issuer name changes when you leave the staging directory
- Clients match the hostname against the Subject Alternative Name, not the Common Name
- The production read below prints that extension with
-ext subjectAltName
Switch Envoy Gateway to Let's Encrypt production
Production changes three things and leaves the staging issuer in place, so a test hostname can point back at staging later:
- The same issuer fields
- The production ACME directory
- A different account-key Secret,
letsencrypt-account-key
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: you@example.com
privateKeySecretRef:
name: letsencrypt-account-key
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- kind: Gateway
name: eg
namespace: defaultApply that issuer. Ready means the ACME account was registered:
kubectl apply -f letsencrypt.yamlclusterissuer.cert-manager.io/letsencrypt createdWait for Ready before you treat the annotation change as the new signer:
kubectl wait --for=condition=Ready clusterissuer/letsencrypt --timeout=120sclusterissuer.cert-manager.io/letsencrypt condition metPoint the Gateway at the production issuer. cert-manager updates the Certificate issuerRef to match:
kubectl annotate gateway eg cert-manager.io/cluster-issuer=letsencrypt --overwritegateway.gateway.networking.k8s.io/eg annotatedThe Certificate goes Ready=False while the production order runs, then returns to True with a new notBefore:
kubectl get certificate edge-tls -o jsonpath='issuer={.spec.issuerRef.name} ready={.status.conditions[?(@.type=="Ready")].status} notBefore={.status.notBefore} renewal={.status.renewalTime} notAfter={.status.notAfter}{"\n"}'issuer=letsencrypt ready=True notBefore=2026-10-03T16:29:53Z renewal=2026-12-02T16:29:52Z notAfter=2027-01-01T16:29:52Zissuer=letsencrypt is the production ClusterIssuer. Read the Secret again, including the SAN:
kubectl get secret edge-tls -o jsonpath='{.data.tls\.crt}' \
| base64 -d | openssl x509 -noout -issuer -subject -dates -ext subjectAltNameissuer=C = US, O = Let's Encrypt, CN = YR1
subject=CN = app.example.com
notBefore=Oct 3 16:29:53 2026 GMT
notAfter=Jan 1 16:29:52 2027 GMT
X509v3 Subject Alternative Name:
DNS:app.example.comCheck the name clients actually verify:
- Look for
DNS:app.example.comunderX509v3 Subject Alternative Name - That SAN is what modern TLS clients use for hostname verification
- A Common Name may also be present
- Do not use the CN as the hostname check
Confirm you left staging:
- The issuer line no longer contains
STAGING YR1was the production intermediate in this test- Intermediate names change, so do not require a particular CN
- The real check is a missing
STAGINGstring plus a trusted handshake
Call the hostname directly. That checks the name on the certificate, not only that some certificate was presented:
curl -sS -o /dev/null -w 'http=%{http_code} verify=%{ssl_verify_result}\n' https://app.example.com/http=200 verify=0http=200 and verify=0 together mean the request worked:
ssl_verify_result=0is the OpenSSL success code curl uses for chain verification- Calling the hostname also checks that the certificate name matches
- HTTP 200 means the request reached the application
If verify is not 0:
- the Secret may still hold the staging certificate
- or the client is not talking to this Gateway
Read the issuer line before you request another production certificate.
Renew the Let's Encrypt certificate with cmctl
With no custom renewal setting, cert-manager picks the time from the certificate lifetime:
- Renewal is scheduled about two-thirds of the way through that lifetime
- This roughly 90-day certificate leaves about 30 days before expiry
- That is why
renewalTimeis 2 December, not the JanuarynotAfter renewBeforeorrenewBeforePercentagechanges the schedule
These fields are from the first staging certificate, before the production switch:
kubectl get certificate edge-tls -o jsonpath='notBefore={.status.notBefore} renewal={.status.renewalTime}{"\n"}'notBefore=2026-10-03T16:27:49Z renewal=2026-12-02T16:27:48Zcmctl renew queues a new issuance and returns before the certificate is finished:
- cert-manager creates a new CertificateRequest and runs the normal issuer flow
- This run used the staging certificate, before the Gateway annotation moved to production
- The same command works on the production Certificate
cmctl renew edge-tls -n defaultManually triggered issuance of Certificate default/edge-tlsThe command returns as soon as the request is queued. Read the Certificate again after the new order finishes. notBefore moved from 16:27:49Z to 16:28:58Z, and renewalTime moved with it:
kubectl get certificate edge-tls -o jsonpath='notBefore={.status.notBefore} renewal={.status.renewalTime}{"\n"}'notBefore=2026-10-03T16:28:58Z renewal=2026-12-02T16:28:57Zedge-tls-2 is that new request, still signed by letsencrypt-staging:
kubectl get certificaterequest -n defaultNAME APPROVED DENIED READY ISSUER REQUESTER AGE
edge-tls-2 True True letsencrypt-staging system:serviceaccount:cert-manager:cert-manager 5sPort 80 has to stay open for the next HTTP-01 challenge, whether it is the December renewal or another cmctl renew.
Troubleshoot Let's Encrypt HTTP-01 failures
| Symptom | Likely cause | Fix |
|---|---|---|
Challenge stays pending with HTTP 503 |
The solver backend had no ready endpoints, or port 80 does not reach this Gateway | Inspect the cm-acme-http-solver HTTPRoute, Service, Pod, and endpoints. Confirm A and AAAA both reach this Gateway |
Challenge never becomes DomainVerified |
DNS, IPv6, or routing sends the token somewhere else | dig the A and AAAA records. Open the solver HTTPRoute and request the challenge URL yourself |
READY stays False and no Challenge appears |
Gateway API support is off, the listener has no hostname, or the annotation names a missing issuer | Set config.gatewayAPI.enabled=true, set hostname on the HTTPS listener, and confirm the ClusterIssuer is Ready |
openssl issuer contains STAGING |
The Gateway annotation still selects letsencrypt-staging |
Annotate cert-manager.io/cluster-issuer=letsencrypt and wait for a new notBefore |
curl only succeeds with -k |
The presented certificate is still the staging one | Confirm the issuer does not contain STAGING, then check the live certificate with curl or openssl. Do not require a specific intermediate CN |
| Renewal does not happen on the expiry date | Renewal is about two-thirds of the way through the lifetime | Read status.renewalTime. Use cmctl renew when you need a new certificate now |
Summary
cert-manager talks to Let's Encrypt. The Gateway HTTPS listener only names the hostname and Secret edge-tls. HTTP-01 proves that hostname on port 80 through a temporary solver route, and cert-manager writes tls.crt and tls.key into the Secret. Envoy serves that certificate on port 443.
Start with the staging directory. An issuer name that contains STAGING means the solver path works and that clients should not trust it yet. Switching the Gateway annotation to the production issuer causes cert-manager to issue a production certificate and update the certificate data in the same edge-tls Secret. Trust the SAN DNS:app.example.com and a curl result of verify=0, not a particular intermediate name.
Renewal is already scheduled when the Certificate is Ready. With the default settings it lands about two-thirds of the way through a 90-day certificate, which is why this one shows 2 December. cmctl renew queues that same issuance immediately. Port 80 stays part of the setup, because the next challenge uses it too.
References
- Envoy Gateway cert-manager TLS (v1.9): Gateway listener, Secret reference, and Let's Encrypt HTTP-01
- cert-manager Gateway certificates: annotations that create a Certificate from a Gateway
- cert-manager ACME HTTP-01: solver configuration, including
gatewayHTTPRoute - Let's Encrypt challenge types: what HTTP-01 asks the public internet to fetch
Frequently Asked Questions
Does the contact email have to match the domain?
No.
- Let's Encrypt stores the email on the ACME account and uses it for expiry notices
- The name on the certificate is the hostname on the Gateway HTTPS listener
- Clients check that name in the Subject Alternative Name
Why does curl reject the staging certificate?
The staging certificate is not publicly trusted:
- The issuer name contains
STAGING - It is signed by the Let's Encrypt staging CA
- Use it to prove HTTP-01, then point the Gateway annotation at the production issuer
Why did the challenge report HTTP 503?
On this run the 503 cleared by itself:
- The challenge route existed before the solver Pod was ready
- The same Challenge succeeded a few seconds later
- If 503 persists, check the temporary HTTPRoute, Service, Pod, and endpoints
Do I have to keep port 80 open after HTTPS works?
Yes, for this HTTP-01 issuer.
- Renewal presents another challenge on port 80
- Closing port 80 leaves the current certificate in place until
notAfter - The next renewal then fails
When does cert-manager renew the certificate?
With no custom renewal setting:
- cert-manager schedules renewal about two-thirds of the way through the certificate lifetime
- For a 90-day Let's Encrypt certificate, that is about 30 days before
notAfter - That time is the
status.renewalTimefield cmctl renewqueues the issuance and returns before it finishes

