Skip to content

TLS certificates

cert-manager runs cluster-wide on Kestrel. It watches for Certificate resources (created directly or triggered by Ingress annotations), requests certificates from an ACME provider like Let’s Encrypt, proves domain ownership via a challenge, and stores the resulting certificate in a Kubernetes Secret. Renewal happens automatically before expiry.

The platform provides letsencrypt-prod and letsencrypt-staging ClusterIssuers, but they are restricted to Kestrel-managed subdomains:

  • <tenant>.kestrel.arbutus.cloud
  • *.<tenant>.kestrel.arbutus.cloud

If your application uses a custom domain (e.g., myapp.example.com), you must create your own namespace-scoped Issuer. Most production applications will follow this path.

Your domainWhat to do
<your-tenant>.kestrel.arbutus.cloudUse the platform ClusterIssuer — quick path below
*.<your-tenant>.kestrel.arbutus.cloudUse the platform ClusterIssuer — quick path below
myapp.example.com or any other domainCreate your own Issuer

For hosts under your tenant’s kestrel.arbutus.cloud subdomain, add an annotation to your Ingress and cert-manager handles everything:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-service
namespace: <your-tenant>-prod
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- my-app.<your-tenant>.kestrel.arbutus.cloud
secretName: my-app-tls
rules:
- host: my-app.<your-tenant>.kestrel.arbutus.cloud
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80

cert-manager sees the annotation, creates a Certificate and CertificateRequest, solves the ACME challenge, and stores the signed certificate in the my-app-tls Secret. Traefik picks it up and serves TLS. Renewal happens ~30 days before expiry with no action on your part.

Use letsencrypt-staging instead while testing to avoid Let’s Encrypt rate limits. Staging certificates are not trusted by browsers but are functionally identical.

See Ingress on Kestrel for the full Ingress spec.

When your application uses its own domain, you need a namespace-scoped Issuer that tells cert-manager how to prove you control that domain. The Issuer lives in your namespace and uses credentials you provide.

cert-manager supports two ACME challenge types for proving domain ownership:

ChallengeHow it worksWhen to use
HTTP01cert-manager creates a temporary Ingress that serves a token at http://<domain>/.well-known/acme-challenge/<token>. The ACME server fetches the token to prove you control the domain.Domain already points to the cluster. Simplest setup — no DNS provider credentials needed. Cannot issue wildcard certificates.
DNS01cert-manager creates a TXT record (_acme-challenge.<domain>) via a DNS provider API. The ACME server queries DNS to prove you control the domain.Domain is behind a CDN, DNS-only validation is required, or you need wildcard certificates (*.example.com).

DNS01 with Cloudflare is the most flexible option. It works for any domain managed in Cloudflare, including wildcard certificates, and does not require the domain to resolve to the cluster.

Step 1: Create a Cloudflare API token

In the Cloudflare dashboard, create an API token with the following permissions:

  • Zone / Zone / Read — allows cert-manager to look up the zone ID.
  • Zone / DNS / Edit — allows cert-manager to create and clean up the _acme-challenge TXT record.

Scope the token to the specific zone(s) you need certificates for. Avoid using a global API key.

Step 2: Store the token in a Secret

apiVersion: v1
kind: Secret
metadata:
name: cloudflare-api-token
namespace: <your-namespace>
type: Opaque
stringData:
api-token: <your-cloudflare-api-token>

Step 3: Create the Issuer

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: letsencrypt-cloudflare
namespace: <your-namespace>
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: your-email@example.com # replace with your real email — Let's Encrypt uses it for expiry notices
privateKeySecretRef:
name: letsencrypt-cloudflare-account-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token

The privateKeySecretRef is created automatically by cert-manager to store the ACME account private key — you do not need to pre-create it.

Optional: Restrict the solver to specific domains

If your Issuer should only handle certain domains, add a selector:

solvers:
- selector:
dnsZones:
- "example.com"
dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token

HTTP01 is simpler to set up — no DNS provider credentials are needed. cert-manager creates a temporary Ingress to serve the challenge token. The domain must already resolve to Kestrel’s ingress endpoint (external traffic reaches the cluster through Cloudflare) so the ACME server can fetch the token.

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: letsencrypt-http01
namespace: <your-namespace>
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: your-email@example.com # replace with your real email — Let's Encrypt uses it for expiry notices
privateKeySecretRef:
name: letsencrypt-http01-account-key
solvers:
- http01:
ingress:
ingressClassName: traefik

The ingressClassName must match the cluster’s ingress controller. On Kestrel, this is traefik.

Let’s Encrypt enforces rate limits on the production endpoint (50 certificates per registered domain per week). While testing your Issuer configuration, use the staging server:

server: https://acme-staging-v02.api.letsencrypt.org/directory

Staging certificates are not trusted by browsers but are functionally identical. Switch to the production URL once you’ve confirmed issuance works.

For more control — custom DNS names, specific algorithms, or certificates not tied to an Ingress — create a Certificate resource directly instead of using the Ingress annotation.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-app-cert
namespace: <your-namespace>
spec:
secretName: my-app-tls
issuerRef:
name: letsencrypt-cloudflare
kind: Issuer
dnsNames:
- my-app.example.com
- api.example.com

cert-manager requests a certificate covering both DNS names, stores it in the my-app-tls Secret, and renews it automatically.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-app-cert
namespace: <your-namespace>
spec:
secretName: my-app-tls
issuerRef:
name: letsencrypt-cloudflare
kind: Issuer
duration: 2160h # 90 days (Let's Encrypt default)
renewBefore: 720h # renew 30 days before expiry
privateKey:
algorithm: ECDSA
size: 256
dnsNames:
- my-app.example.com
- "*.my-app.example.com" # wildcard — requires DNS01

Wildcard certificates (*.example.com) require a DNS01 solver. HTTP01 cannot prove ownership of a wildcard domain.

Once the Certificate creates the Secret, reference it in your Ingress tls block without the cert-manager annotation (the Certificate manages issuance, not the Ingress):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-service
namespace: <your-namespace>
spec:
ingressClassName: traefik
tls:
- hosts:
- my-app.example.com
secretName: my-app-tls # matches the Certificate's secretName
rules:
- host: my-app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80

Choosing between annotation and Certificate resource

Section titled “Choosing between annotation and Certificate resource”
Ingress annotationManual Certificate
SetupOne annotation + tls blockSeparate Certificate YAML
DNS namesDerived from tls.hostsExplicit dnsNames list — can include wildcards or names not on any Ingress
Algorithmcert-manager defaults (RSA 2048)Configurable (ECDSA 256, RSA 4096, etc.)
Durationcert-manager defaultsConfigurable
Multiple IngressesEach Ingress manages its own certOne Certificate, multiple Ingresses share the Secret
Non-Ingress useNot applicableWorks for any TLS consumer (gRPC, internal mTLS, etc.)

For most web services behind a single Ingress, the annotation is sufficient. Use a manual Certificate when you need wildcards, custom crypto, or a certificate shared across multiple consumers.

Terminal window
kubectl get certificate -n <your-namespace>

The READY column shows True when the certificate is valid and stored. False means issuance or renewal failed.

Terminal window
kubectl describe certificate <name> -n <your-namespace>

The Events section shows the issuance timeline. Look for Issuing, Requested, and Issued events. An event with reason: Failed tells you what went wrong.

Terminal window
kubectl get certificaterequest -n <your-namespace>
kubectl describe certificaterequest <name> -n <your-namespace>

If the Certificate is stuck, the CertificateRequest events show whether the ACME challenge failed or whether cert-manager could not reach the ACME server.

For deeper debugging, check the ACME Order and Challenge resources that cert-manager creates during issuance:

Terminal window
kubectl get order -n <your-namespace>
kubectl describe order <name> -n <your-namespace>
kubectl get challenge -n <your-namespace>
kubectl describe challenge <name> -n <your-namespace>

The Challenge resource shows the exact domain, challenge type (HTTP01 or DNS01), and whether the solver is working. Common issues:

  • DNS01: _acme-challenge TXT record not created — check the Cloudflare API token permissions and the Secret reference.
  • HTTP01: challenge endpoint not reachable — verify the domain resolves to the cluster and the temporary Ingress is created.
FailureSymptomResolution
Wrong ClusterIssuer for domainCertificateRequest denied or stuck pendingPlatform ClusterIssuers only work for *.kestrel.arbutus.cloud. For other domains, create your own Issuer.
Rate limit hitCertificateRequest shows too many certificates already issuedWait for the rate limit window to reset (1 week). Use the staging server while testing.
Hostname mismatchCertificate issued but browser shows warningEnsure tls.hosts / dnsNames match the actual hostname exactly.
Challenge solver timeoutCertificateRequest shows context deadline exceededHTTP01: verify the hostname resolves to Kestrel’s ingress endpoint (traffic enters via Cloudflare). DNS01: verify the API token has Zone Read + DNS Edit permissions.
Secret not createdTraefik serves default certificate (browser warning)Check kubectl describe certificate — if no Certificate resource exists, the annotation is missing or misspelled.
Renewal failureCertificate expires, browser shows expired certcert-manager renews ~30 days before expiry. If renewal failed, check CertificateRequest events. Usually transient — cert-manager retries automatically.
DNS propagation delayChallenge fails with DNS record not yet propagatedDNS01 requires the TXT record to be visible to the ACME server. Wait a few minutes and cert-manager will retry. If persistent, check that the Cloudflare zone ID is correct.
API token invalidCertificateRequest shows Cloudflare API errorVerify the Secret exists, the key name matches the apiTokenSecretRef, and the token has not been revoked.

After creating or updating a Certificate or Ingress with TLS:

Terminal window
# Check certificate status (~30 seconds for initial issuance)
kubectl get certificate -n <your-namespace>
# READY should be True
# Verify the live certificate
curl -v https://my-app.example.com/ 2>&1 | grep 'subject:'
# Check certificate details (expiry, issuer, SANs)
openssl s_client -connect my-app.example.com:443 -servername my-app.example.com </dev/null 2>/dev/null | openssl x509 -noout -text | grep -A2 'Validity\|Subject Alternative'

If READY is False after two minutes, start with kubectl describe certificate and work down through certificaterequest, order, and challenge.