Automatic HTTPS on Kubernetes with cert-manager and Let's Encrypt

Written by: Bagus Facsi Aginsa
Published at: 19 Jun 2026


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 ClusterIssuer works across every namespace, which is what we want. An Issuer is 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 kubectl configured. Confirm with kubectl get nodes.
  • The NGINX Ingress Controller installed and working, reachable on a public external IP.
  • Helm 3 installed. Check with helm version and confirm it reports v3.
  • A real, registered domain name whose DNS A record you can edit. Self-signed local domains and /etc/hosts tricks 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:

  • server points at the Let’s Encrypt staging ACME endpoint.
  • email is where Let’s Encrypt sends expiry warnings and important notices. Use a real address you check.
  • privateKeySecretRef names a Secret where cert-manager stores your ACME account key. cert-manager creates this Secret automatically; you do not make it yourself.
  • solvers tells cert-manager how to answer challenges. Here we use http01 through the nginx Ingress 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.