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.
Which path do I need?
Section titled “Which path do I need?”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 domain | What to do |
|---|---|
<your-tenant>.kestrel.arbutus.cloud | Use the platform ClusterIssuer — quick path below |
*.<your-tenant>.kestrel.arbutus.cloud | Use the platform ClusterIssuer — quick path below |
myapp.example.com or any other domain | Create your own Issuer |
Platform-managed certificates
Section titled “Platform-managed certificates”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/v1kind: Ingressmetadata: 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: 80cert-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.
Creating an Issuer for custom domains
Section titled “Creating an Issuer for custom domains”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.
ACME challenge types
Section titled “ACME challenge types”cert-manager supports two ACME challenge types for proving domain ownership:
| Challenge | How it works | When to use |
|---|---|---|
| HTTP01 | cert-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. |
| DNS01 | cert-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). |
Cloudflare DNS01 Issuer
Section titled “Cloudflare DNS01 Issuer”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-challengeTXT 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: v1kind: Secretmetadata: name: cloudflare-api-token namespace: <your-namespace>type: OpaquestringData: api-token: <your-cloudflare-api-token>Step 3: Create the Issuer
apiVersion: cert-manager.io/v1kind: Issuermetadata: 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-tokenThe 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-tokenHTTP01 Issuer
Section titled “HTTP01 Issuer”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/v1kind: Issuermetadata: 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: traefikThe ingressClassName must match the cluster’s ingress controller. On Kestrel, this is traefik.
Staging vs production
Section titled “Staging vs production”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/directoryStaging certificates are not trusted by browsers but are functionally identical. Switch to the production URL once you’ve confirmed issuance works.
Creating a Certificate resource
Section titled “Creating a Certificate resource”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.
Basic Certificate
Section titled “Basic Certificate”apiVersion: cert-manager.io/v1kind: Certificatemetadata: 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.comcert-manager requests a certificate covering both DNS names, stores it in the my-app-tls Secret, and renews it automatically.
Certificate with custom options
Section titled “Certificate with custom options”apiVersion: cert-manager.io/v1kind: Certificatemetadata: 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 DNS01Wildcard certificates (*.example.com) require a DNS01 solver. HTTP01 cannot prove ownership of a wildcard domain.
Using the Certificate with an Ingress
Section titled “Using the Certificate with an Ingress”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/v1kind: Ingressmetadata: 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: 80Choosing between annotation and Certificate resource
Section titled “Choosing between annotation and Certificate resource”| Ingress annotation | Manual Certificate | |
|---|---|---|
| Setup | One annotation + tls block | Separate Certificate YAML |
| DNS names | Derived from tls.hosts | Explicit dnsNames list — can include wildcards or names not on any Ingress |
| Algorithm | cert-manager defaults (RSA 2048) | Configurable (ECDSA 256, RSA 4096, etc.) |
| Duration | cert-manager defaults | Configurable |
| Multiple Ingresses | Each Ingress manages its own cert | One Certificate, multiple Ingresses share the Secret |
| Non-Ingress use | Not applicable | Works 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.
Troubleshooting
Section titled “Troubleshooting”Check certificate status
Section titled “Check certificate status”kubectl get certificate -n <your-namespace>The READY column shows True when the certificate is valid and stored. False means issuance or renewal failed.
Inspect the Certificate resource
Section titled “Inspect the Certificate resource”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.
Inspect the CertificateRequest
Section titled “Inspect the CertificateRequest”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.
Inspect the ACME Order and Challenge
Section titled “Inspect the ACME Order and Challenge”For deeper debugging, check the ACME Order and Challenge resources that cert-manager creates during issuance:
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-challengeTXT 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.
Common failure modes
Section titled “Common failure modes”| Failure | Symptom | Resolution |
|---|---|---|
| Wrong ClusterIssuer for domain | CertificateRequest denied or stuck pending | Platform ClusterIssuers only work for *.kestrel.arbutus.cloud. For other domains, create your own Issuer. |
| Rate limit hit | CertificateRequest shows too many certificates already issued | Wait for the rate limit window to reset (1 week). Use the staging server while testing. |
| Hostname mismatch | Certificate issued but browser shows warning | Ensure tls.hosts / dnsNames match the actual hostname exactly. |
| Challenge solver timeout | CertificateRequest shows context deadline exceeded | HTTP01: 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 created | Traefik serves default certificate (browser warning) | Check kubectl describe certificate — if no Certificate resource exists, the annotation is missing or misspelled. |
| Renewal failure | Certificate expires, browser shows expired cert | cert-manager renews ~30 days before expiry. If renewal failed, check CertificateRequest events. Usually transient — cert-manager retries automatically. |
| DNS propagation delay | Challenge fails with DNS record not yet propagated | DNS01 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 invalid | CertificateRequest shows Cloudflare API error | Verify the Secret exists, the key name matches the apiTokenSecretRef, and the token has not been revoked. |
End-to-end verification
Section titled “End-to-end verification”After creating or updating a Certificate or Ingress with TLS:
# Check certificate status (~30 seconds for initial issuance)kubectl get certificate -n <your-namespace># READY should be True
# Verify the live certificatecurl -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.