Let’s Encrypt, Certbot, Nginx and cert-manager on Kubernetes

There’s a specific order you learn TLS automation in, and once you’ve done it, the whole subject stops being mysterious. Let’s Encrypt gives you a free certificate authority. Certbot is the client that talks to it. Nginx is what most people plug it into first. Then you hit renewal, which is where the automation either works or you find out the hard way it doesn’t. And once you move workloads into Kubernetes, the whole thing gets reinvented as cert-manager, watching Ingress objects instead of a cron job watching a calendar.

This walks that progression start to finish, including the two failures I actually hit running this on a real box a few minutes ago, because those failures are more useful than a screenshot of success. Everything below ran on Ubuntu 24.04 and Red Hat Enterprise Linux 9.7 for the OS-level half, and a real k3s cluster for the Kubernetes half.

Let’s Encrypt: what it actually is

Let’s Encrypt is a certificate authority. It issues free, browser-trusted TLS certificates, and it does it entirely by API, no forms, no waiting, no credit card. The catch, and it’s a reasonable one, is that certificates are short-lived: 90 days. That’s deliberate. Short lifetimes force automation, and automation is safer than a human remembering to renew a cert once a year and inevitably forgetting.

The protocol behind it is ACME (Automatic Certificate Management Environment). You prove you control a domain, Let’s Encrypt hands you a certificate for it. Proving control happens one of two ways: put a specific file at a specific URL on port 80 (HTTP-01), or put a specific TXT record in your DNS (DNS-01). Almost everything below uses HTTP-01, because it’s simpler to set up and it’s what Certbot defaults to.

Certbot: the client that does the talking

Certbot is the official ACME client for exactly this job: request certificates, install them into your web server config, and set up renewal so you never touch it again. It’s not installed by default anywhere, and how you get it differs a bit by distro:

# Ubuntu 24.04
sudo apt install certbot python3-certbot-nginx
certbot --version
certbot 2.9.0

# RHEL 9 / Rocky / AlmaLinux -- needs EPEL first
sudo dnf install epel-release
sudo dnf install certbot python3-certbot-nginx
certbot --version
certbot 3.1.0

Two full major versions apart, same command syntax for everything in this article. RHEL’s EPEL build tends to run newer; that’s just a timing accident of each distro’s release cadence, not a compatibility issue. The python3-certbot-nginx package on both is the plugin that lets Certbot edit your nginx config directly instead of you doing it by hand, which is the whole point of the next section.

Wiring it into Nginx: two ways, and why one usually wins

Certbot has two relevant modes here. --standalone spins up its own tiny web server just to answer the ACME challenge, then shuts it down. --nginx works through nginx that’s already running, editing the config live. If nginx is already on and bound to port 80, and it almost always is, standalone doesn’t get a chance:

sudo certbot certonly --standalone --staging -d certbot-demo.linuxpathfinder.com
...
Could not bind TCP port 80 because it is already in use by another
process on this system (such as a web server). Please stop the
program in question and then try again.

That’s not a bug, it’s the expected outcome, and it’s the first thing to check if standalone mode ever refuses to start on a box that’s already serving traffic: something else already owns port 80. The fix isn’t to stop nginx, it’s to stop using standalone and let the nginx plugin do it instead, since it can share the port nginx already has open:

sudo certbot --nginx --staging -d certbot-demo.linuxpathfinder.com
Requesting a certificate for certbot-demo.linuxpathfinder.com

Certbot failed to authenticate some domains (authenticator: nginx).
The Certificate Authority reported these problems:
  Domain: certbot-demo.linuxpathfinder.com
  Type:   unauthorized
  Detail: 2a07:7800::186: Invalid response from
  https://linuxpathfinder.com/.well-known/acme-challenge/Dutb8v8m...: 404

Hint: The Certificate Authority failed to verify the temporary nginx
configuration changes made by Certbot. Ensure the listed domains
point to this nginx server and that it is accessible from the internet.

That second failure is the genuinely instructive one, and it’s real: I ran this against a subdomain of a site I actually own, from a box that isn’t the one actually hosting that site. Let’s Encrypt’s validation server went and looked, found that linuxpathfinder.com resolves to a real production server somewhere else entirely, and got a 404 there because that server has never heard of this challenge. The certificate request fails correctly, because it’s supposed to: DNS for the domain you’re requesting has to point at the exact box running Certbot, and port 80 (or 443 for TLS-ALPN) has to be reachable from the public internet, not just from your LAN. This is the single most common reason a first Certbot run fails, and neither error message above is a problem with Certbot itself.

Once DNS genuinely points here and the port is reachable, this exact same command succeeds: it obtains the certificate, writes it under /etc/letsencrypt/live/<domain>/, and inserts a working listen 443 ssl; block into your nginx config automatically, offering to redirect port 80 to 443 while it’s at it. --staging is worth keeping in every example above until you’re sure the setup is right; it points at Let’s Encrypt’s staging environment, which issues certificates browsers don’t trust, but doesn’t count against the production rate limits (currently 5 duplicate certificates per domain per week) while you’re still debugging. Drop the flag once it works.

Automatic renewal: the part that has to actually run

A 90-day certificate is worthless if nothing renews it. Certbot’s package installs a systemd timer for exactly this, no cron entry required:

systemctl list-timers | grep certbot
Sat 2026-02-14 22:27:24 PKT   17h  -  -  certbot.timer  certbot.service

systemctl cat certbot.timer
[Unit]
Description=Run certbot twice daily

[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=43200
Persistent=true

[Install]
WantedBy=timers.target

Twice a day, with up to twelve hours of random jitter so every Certbot install on the internet isn’t hammering Let’s Encrypt’s servers at exactly midnight. Certbot itself only actually renews a certificate once it’s within 30 days of expiry, so this runs constantly but does nothing most of the time. Test that the renewal logic works without waiting three months, and without burning a real renewal, using dry-run mode:

sudo certbot certificates
No certificates found.

sudo certbot renew --dry-run
No simulated renewals were attempted.

Nothing to renew yet on this box, which is exactly what you’d expect before a first certificate exists; once you have one, this same command simulates the full renewal against the staging environment and tells you honestly whether it would have worked. Run it after any change to your nginx config, your firewall, or your DNS, and definitely right after the first successful issuance, so you find out now rather than in 90 days when the real deadline hits and the timer quietly fails at 2am.

cert-manager: the same job, run by Kubernetes instead of a timer

Move the workload into Kubernetes and the systemd timer stops making sense, because there’s no single fixed box to run it on. cert-manager replaces it with a controller: a pod that watches your cluster, notices when something needs a certificate, and reissues it on its own schedule using the same ACME protocol underneath. Install it with Helm:

helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
    --namespace cert-manager --create-namespace \
    --set crds.enabled=true --wait

kubectl get pods -n cert-manager
NAME                                        READY   STATUS    RESTARTS   AGE
cert-manager-bc44b5799-2kn8c                1/1     Running   0          41s
cert-manager-cainjector-784846b495-2htr2    1/1     Running   0          41s
cert-manager-webhook-6c6d98f866-j5275       1/1     Running   0          41s

Three pods: the controller itself, a webhook that validates the CRDs you’re about to create, and cainjector, which handles internal certificate plumbing for cert-manager’s own webhook. This ran against a real k3s cluster (v1.34.3) that already had actual workloads on it, and installed clean in well under a minute.

Kubernetes Ingress: where the certificate request actually comes from

cert-manager on its own does nothing. It needs an Issuer (or ClusterIssuer, the cluster-wide version) telling it where to get certificates from, and something asking for one. That something is normally an Ingress resource with one specific annotation.

For Let’s Encrypt in production you’d configure an ACME ClusterIssuer with an HTTP-01 or DNS-01 solver, same requirements as the Certbot section above: a real domain, real DNS, reachable from the internet. To demonstrate the actual mechanics honestly without needing a public domain for this article, here’s a self-signed ClusterIssuer instead, which needs none of that and exercises the exact same reconciliation loop:

cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned-issuer
spec:
  selfSigned: {}
EOF
clusterissuer.cert-manager.io/selfsigned-issuer created

cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: test-nginx
  annotations:
    cert-manager.io/cluster-issuer: selfsigned-issuer
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - certbot-demo.linuxpathfinder.com
    secretName: test-nginx-tls
  rules:
  - host: certbot-demo.linuxpathfinder.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: test-nginx
            port:
              number: 80
EOF
ingress.networking.k8s.io/test-nginx created

That annotation is the entire integration. cert-manager’s ingress-shim component watches every Ingress in the cluster; the moment it sees cert-manager.io/cluster-issuer, it creates a matching Certificate resource for you, no separate step required. Swap the ClusterIssuer for a real ACME one later and nothing else about this Ingress changes.

kubectl get certificate test-nginx-tls
NAME             READY   SECRET           AGE
test-nginx-tls   True    test-nginx-tls   8s

kubectl get certificaterequest
NAME               APPROVED   READY   ISSUER              AGE
test-nginx-tls-1   True       True    selfsigned-issuer   9s

READY True in 8 seconds, genuinely, on the first try. Under the hood cert-manager created a CertificateRequest, got it approved, generated a private key, and issued the certificate, all as a normal Kubernetes reconciliation loop rather than a shell script running in sequence. The events on the Certificate object show each of those steps:

kubectl describe certificate test-nginx-tls
...
Status:
  Conditions:
    Message:  Certificate is up to date and has not expired
    Reason:   Ready
    Status:   True
  Not Before:     2026-02-14T00:31:01Z
  Not After:      2026-05-15T00:31:01Z
  Renewal Time:   2026-04-15T00:31:01Z
Events:
  Normal  Issuing    18s   cert-manager-certificates-trigger
  Normal  Generated  18s   cert-manager-certificates-key-manager    Stored new private key in temporary Secret resource
  Normal  Requested  18s   cert-manager-certificates-request-manager   Created new CertificateRequest resource "test-nginx-tls-1"
  Normal  Issuing    18s   cert-manager-certificates-issuing        The certificate has been successfully issued

That Renewal Time line is the answer to “how does cert-manager know when to renew.” It’s computed automatically the moment the certificate is issued: 90 days of validity here (matching Let’s Encrypt’s own default), and cert-manager schedules the renewal roughly a third of the way before expiry, the same 30-day-out convention Certbot uses. There’s no timer to check, no cron entry to lose track of. cert-manager simply reconciles the Certificate object again when that time arrives, requests a fresh one, and updates the Secret in place, and any Ingress or pod referencing that Secret picks up the new certificate automatically. The certificate itself lands as a standard TLS secret, which is what nginx-ingress, or any other controller, actually reads at request time:

kubectl get secret test-nginx-tls
NAME             TYPE                DATA   AGE
test-nginx-tls   kubernetes.io/tls   3      9s

Where this actually goes from here

The progression in one line: Let’s Encrypt issues, Certbot or cert-manager requests and installs, nginx or nginx-ingress serves, and renewal is either a systemd timer polling a calendar or a controller reconciling an object, depending on which side of the container boundary you’re standing on. Same protocol underneath both times.

The practical next steps once this clicks: swap the self-signed ClusterIssuer above for a real ACME one (staging first, same as Certbot) once you’ve got a domain and DNS you control pointing at your cluster’s ingress-nginx load balancer. If you’re still building the cluster this runs on, setting up Kubernetes with kubeadm and the core concepts are worth reading first if Ingress and Secrets aren’t already familiar ground. On the plain-server side, nginx as a reverse proxy in front of an app is the natural thing to put a Certbot-managed certificate in front of, and understanding systemd services and timers properly explains the mechanism certbot.timer itself is built on.

Frequently asked questions

Why did my Certbot request fail with a 404 on .well-known/acme-challenge?

Let’s Encrypt’s server reached your domain but the challenge file wasn’t there, which almost always means the domain’s DNS doesn’t actually point at the box you ran Certbot on. Confirm with a plain dig yourdomain.com from outside your own network before trying again.

Why does “port 80 already in use” happen with –standalone but not –nginx?

--standalone starts its own listener on port 80 and needs it free; nginx (or any other web server) already sitting on that port blocks it. --nginx mode doesn’t need the port for itself, it edits the running nginx’s config instead, so it works exactly when standalone doesn’t.

Do I need cert-manager if I’m already using Certbot somewhere?

Only inside Kubernetes. Certbot manages certificates for a specific host’s filesystem and web server config; it has no concept of pods, Ingress objects, or Secrets. cert-manager is the equivalent built for that environment. Plenty of setups run both: Certbot for a traditional VM, cert-manager for what’s in the cluster.

What’s the difference between an Issuer and a ClusterIssuer?

An Issuer is namespaced and can only issue certificates for resources in that same namespace. A ClusterIssuer is cluster-wide and any namespace can reference it. Most setups use one ClusterIssuer for Let’s Encrypt production and one for staging, shared across everything.

How do I know when cert-manager will actually renew a certificate?

kubectl describe certificate <name> shows a Renewal Time field directly in the status, computed automatically from the certificate’s own validity window. No separate schedule to track.

Can I use DNS-01 instead of HTTP-01 with either tool?

Yes, and it’s the better option if your server isn’t reachable on port 80 from the internet, or you want a wildcard certificate, which HTTP-01 can’t issue. Certbot has DNS plugins per provider (python3-certbot-dns-cloudflare, and similar for most major DNS hosts); cert-manager configures the same thing as a solvers block on the Issuer, with provider-specific API credentials in a Secret.

Asif Khan

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.