Once you have an Ingress controller routing traffic into your cluster, the next thing everyone wants is HTTPS. Browsers flag plain HTTP as “Not Secure”, APIs expect TLS, and you simply should not be shipping login forms over an unencrypted connection in 2026. The problem is that doing TLS by hand is tedious and easy to get wrong. You have to generate a certificate, prove you own the domain, install the certificate as a Kubernetes Secret, and then remember to repeat the whole dance every ninety days before it expires. Miss the renewal and your site goes dark with a scary certificate warning.
cert-manager removes that entire chore. It is a Kubernetes add-on that requests certificates from a Certificate Authority on your behalf, proves domain ownership automatically, stores the result as a Secret, and renews it before it expires. You describe what you want in YAML once, and cert-manager keeps it valid forever. Combined with Let’s Encrypt, a free and trusted Certificate Authority, you get production-grade HTTPS at zero cost and with almost no ongoing maintenance.
This tutorial is for developers, sysadmins, and DevOps engineers running self-hosted Kubernetes who want real, browser-trusted certificates on their Ingress hosts. It builds directly on my earlier post on installing the NGINX Ingress Controller, which in turn relies on MetalLB for an external IP and Helm for installation. If you have not set those up yet, do that first, because cert-manager needs a working Ingress to complete the challenge you are about to learn.
Conceptual Overview
Before touching the command line, let us get the vocabulary straight, because cert-manager introduces a few new objects.
A Certificate Authority (CA) is an organization browsers trust to vouch for the identity of a website. Let’s Encrypt is a free, automated CA. Instead of paying and filling in forms, software talks to it over a protocol called ACME (Automated Certificate Management Environment) and gets a certificate in seconds, as long as it can prove it controls the domain.
That proof is called a challenge. The most common type is HTTP-01. Let’s Encrypt says, in effect, “if you really own app.example.com, put this specific token at http://app.example.com/.well-known/acme-challenge/<token> and I will fetch it.” cert-manager creates a temporary pod and Ingress rule to serve exactly that token, Let’s Encrypt fetches it, and ownership is proven. This is why your domain must already point at your cluster’s public IP before any of this works.
cert-manager adds two main custom objects you will care about:
- An Issuer or ClusterIssuer represents a connection to a CA. A
ClusterIssuerworks across every namespace, which is what we want. AnIssueris scoped to a single namespace. - A Certificate describes a certificate you want: which domains it covers, which Issuer to use, and which Secret to store the result in.
In practice you rarely create Certificate objects by hand. Instead you add a couple of annotations to your existing Ingress, and cert-manager notices them, creates the Certificate for you, runs the challenge, and drops a TLS Secret into place. The Ingress controller then serves HTTPS automatically.
Prerequisites
Before starting, make sure you have:
- A running Kubernetes cluster with
kubectlconfigured. Confirm withkubectl get nodes. - The NGINX Ingress Controller installed and working, reachable on a public external IP.
- Helm 3 installed. Check with
helm versionand confirm it reportsv3. - A real, registered domain name whose DNS A record you can edit. Self-signed local domains and
/etc/hoststricks will not work here, because Let’s Encrypt must reach your cluster from the public internet. - Ports 80 and 443 open from the internet to your Ingress controller’s IP. Port 80 specifically must be reachable, because the HTTP-01 challenge runs over plain HTTP.
In this guide I will use the domain demo.facsiaginsa.com pointed at the example public IP 203.0.113.45. Replace both with your own throughout.
A quick sanity check that your DNS is ready:
dig +short demo.facsiaginsa.com
This should print your cluster’s public IP. If it prints nothing or the wrong address, fix DNS first and wait for it to propagate. Everything below depends on it.
Step 1: Install cert-manager with Helm
cert-manager ships as a Helm chart from its own repository. Add the repo and update your local cache:
helm repo add jetstack https://charts.jetstack.io
helm repo update
Now install it into a dedicated namespace. The --set crds.enabled=true flag tells the chart to install the Custom Resource Definitions (the Issuer, Certificate, and friends) as part of the release, so you do not have to apply them separately:
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version v1.15.3 \
--set crds.enabled=true
Give it a minute, then check that all three cert-manager components are running:
kubectl get pods --namespace cert-manager
You should see something like this:
NAME READY STATUS RESTARTS AGE
cert-manager-5c5f7d8c9d-4xk2p 1/1 Running 0 61s
cert-manager-cainjector-7d8b6c4f9-9wq7r 1/1 Running 0 61s
cert-manager-webhook-6b9c7f5d8-2lm4k 1/1 Running 0 61s
Three pods, all Running. The main controller does the work, the cainjector handles internal CA certificates, and the webhook validates your cert-manager YAML before it is accepted. If any pod is stuck in Pending or CrashLoopBackOff, see the troubleshooting section later.
Step 2: Create a staging ClusterIssuer first
Here is the single most important habit when working with Let’s Encrypt: test against staging before production. Let’s Encrypt enforces strict rate limits on its production environment (currently around 5 certificates per exact domain set per week). If you make a typo and burn through them, you are locked out for days. The staging environment has far looser limits and is perfect for getting your configuration right. The only downside is that staging certificates are not trusted by browsers, which is fine for testing.
Create a file called clusterissuer-staging.yaml:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: [email protected]
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
class: nginx
Let us walk through what each part does:
serverpoints at the Let’s Encrypt staging ACME endpoint.emailis where Let’s Encrypt sends expiry warnings and important notices. Use a real address you check.privateKeySecretRefnames a Secret where cert-manager stores your ACME account key. cert-manager creates this Secret automatically; you do not make it yourself.solverstells cert-manager how to answer challenges. Here we usehttp01through thenginxIngress class, which means cert-manager will route challenge requests through your existing NGINX Ingress controller.
Apply it:
kubectl apply -f clusterissuer-staging.yaml
Confirm it is ready:
kubectl get clusterissuer
NAME READY AGE
letsencrypt-staging True 8s
READY should be True. If it shows False, run kubectl describe clusterissuer letsencrypt-staging and read the events at the bottom; the message is usually explicit about what went wrong, such as a malformed email or an unreachable ACME server.
Step 3: Deploy a test app with an Ingress
You need something for the certificate to protect. Deploy a tiny web server and expose it:
kubectl create deployment hello --image=nginxdemos/hello:plain-text
kubectl expose deployment hello --port=80
Now create the Ingress that ties your domain to this service and requests a certificate. Save this as hello-ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
ingressClassName: nginx
tls:
- hosts:
- demo.facsiaginsa.com
secretName: hello-tls
rules:
- host: demo.facsiaginsa.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello
port:
number: 80
The magic is in two places. The annotation cert-manager.io/cluster-issuer: letsencrypt-staging tells cert-manager which Issuer to use. The tls block names the host to secure and the Secret (hello-tls) where the certificate should land. cert-manager watches for exactly this combination and springs into action.
Apply it:
kubectl apply -f hello-ingress.yaml
Step 4: Watch the certificate get issued
cert-manager now goes to work. You can watch the process unfold by inspecting the Certificate object it created:
kubectl get certificate
NAME READY SECRET AGE
hello-tls False hello-tls 12s
It starts as False while the challenge runs. To see the live progress, describe it:
kubectl describe certificate hello-tls
You can also watch the temporary challenge resource appear and then disappear once solved:
kubectl get challenges
After anywhere from a few seconds to a minute or two, check again:
kubectl get certificate
NAME READY SECRET AGE
hello-tls True hello-tls 73s
READY is now True. cert-manager completed the HTTP-01 challenge, received the certificate, and stored it in the hello-tls Secret. Your Ingress controller picked it up automatically.
Because this is a staging certificate, your browser will still warn that it is untrusted. That is expected. Verify it from the command line instead:
curl -v https://demo.facsiaginsa.com 2>&1 | grep -i issuer
You should see an issuer mentioning “(STAGING) Let’s Encrypt” or a fake issuer name like “(STAGING) Pretend Pear”. That confirms the whole pipeline works end to end.
Step 5: Switch to production certificates
Now that the flow is proven, swap in a production issuer. Create clusterissuer-prod.yaml, identical to the staging one except for the name and server URL:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: [email protected]
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: nginx
Apply it:
kubectl apply -f clusterissuer-prod.yaml
Point your Ingress at the production issuer by editing the annotation:
kubectl annotate ingress hello \
cert-manager.io/cluster-issuer=letsencrypt-prod --overwrite
cert-manager notices the issuer changed, but it will not always re-issue automatically just from the annotation flip. The cleanest way to force a fresh production certificate is to delete the old staging Secret so cert-manager has to rebuild it:
kubectl delete secret hello-tls
Within a minute, watch the certificate become ready again:
kubectl get certificate hello-tls
This time it is a real, browser-trusted certificate. Open https://demo.facsiaginsa.com in a browser and you will see the padlock with no warnings. From now on, cert-manager will renew this certificate automatically, typically 30 days before the 90-day expiry, with no action from you.
Common Mistakes and Troubleshooting
The certificate stays False forever. This is almost always a reachability problem. Walk the chain: run kubectl describe certificate hello-tls, then kubectl describe order and kubectl describe challenge. The challenge events usually tell you plainly that Let’s Encrypt could not reach http://your-domain/.well-known/acme-challenge/.... The fixes are: confirm your DNS A record points at the right IP, confirm port 80 is open from the public internet (not just 443), and confirm the Ingress controller is actually serving that host.
dig returns the wrong IP. DNS changes take time to propagate. Wait, then re-check. cert-manager will keep retrying on its own, so once DNS is correct the certificate often issues without any further action.
Rate-limit errors in production. If you see a message about “too many certificates already issued”, you hit Let’s Encrypt’s weekly limit, usually from repeatedly testing against production. This is exactly why Step 2 uses staging first. Wait for the window to reset, and do your experimenting on staging.
The webhook rejects your YAML. Errors mentioning cert-manager-webhook and connection timeouts usually mean the webhook pod was not fully ready when you applied your resource. Wait until all three cert-manager pods are Running and 1/1, then re-apply.
Browser still shows untrusted after switching to prod. You are probably still serving the old staging Secret. Confirm the Ingress annotation reads letsencrypt-prod, delete the hello-tls Secret to force a re-issue, and clear your browser cache.
Best Practices
Always test on staging first. It cannot be said too often. Get your ClusterIssuer, DNS, and Ingress correct against staging, then flip to production once. This single habit saves you from rate-limit lockouts.
Use a real, monitored email. Let’s Encrypt emails you when something is wrong, for example if a renewal repeatedly fails. A dead inbox means a silent outage when a certificate finally lapses.
Let cert-manager own renewals. Do not script your own renewal cron jobs or manually edit certificate Secrets. cert-manager renews well ahead of expiry and reconciles continuously. Manual tampering only causes confusion.
Group domains thoughtfully. A single Certificate can cover multiple hosts via several entries under tls.hosts. Keeping related hostnames on one certificate is convenient, but remember that every unique set of names counts separately against rate limits, so avoid churning them.
Keep cert-manager updated. It moves quickly and occasionally deprecates old APIs. Upgrade with helm upgrade periodically, and read the release notes before jumping major versions, since CRD changes sometimes need an extra step.
Consider DNS-01 for wildcards. The HTTP-01 method shown here cannot issue wildcard certificates like *.example.com. If you need those, or if port 80 is not reachable, switch the solver to DNS-01, which proves ownership by creating a TXT record through your DNS provider’s API instead. The rest of your setup stays the same.
Conclusion
You now have fully automated, browser-trusted HTTPS on your Kubernetes cluster. You installed cert-manager with Helm, created a staging ClusterIssuer to test safely, issued a certificate for a real app through the NGINX Ingress controller, and then promoted the whole thing to production Let’s Encrypt certificates. From here on, renewals happen by themselves, and adding TLS to a new app is as simple as one annotation and a tls block on its Ingress.
For next steps, try securing a second hostname to see how effortless it becomes once the issuers exist, or explore the DNS-01 solver if you want wildcard certificates. If you are running multiple apps behind your Ingress, revisit my NGINX Ingress Controller guide and add a cert-manager annotation to each one. With cert-manager in place, HTTPS stops being a recurring chore and simply becomes the default for everything you deploy.