StackPractices
beginner By Mathias Paulenko

Setup SSL Certificates with Let's Encrypt

Step-by-step guide to getting free TLS certificates from Let's Encrypt with Certbot — Nginx, Apache, standalone, Docker, and Kubernetes setups.

Topics: devops

Overview

HTTPS needs a certificate from a trusted CA, and Let’s Encrypt hands them out for free via the ACME protocol. Certbot is the official client: it proves you own the domain, fetches the certificate, installs it, and — the part people forget — renews it before it expires.

This recipe covers the three ways Certbot validates ownership (web server plugins, standalone, and DNS-01 for wildcards), plus renewal automation, Docker and Kubernetes setups, and the operational details — rate limits, staging, monitoring — that keep certificates from becoming a 3am outage.

When to Use

Use this recipe when:

  • A new public site or API can’t go live over plain HTTP.
  • You’re replacing an expired or self-signed cert that makes browsers complain.
  • Renewals keep slipping through the cracks and certs expire in production.
  • TLS sits on a reverse proxy, load balancer, Docker container, or Kubernetes ingress.
  • You’d rather not pay a CA for something a timer can renew for free.

Prerequisites

Before running Certbot, make sure:

  • Your domain’s DNS A record (and AAAA if you use IPv6) points to this server’s public IP — Let’s Encrypt validates over the public internet.
  • Ports 80 and 443 are open in your firewall; HTTP-01 validation happens over port 80, including on renewals.
  • For --nginx / --apache mode, an HTTP site already answers on port 80 — Certbot reads the existing server block.
  • For anything beyond a one-off test, use --staging first. Let’s Encrypt’s production rate limit is 50 certificates per registered domain per week (5 duplicates per week) — staging lets you iterate freely without burning through it.

How It Works

Certbot asks Let’s Encrypt for a certificate, and the CA answers with a challenge: place a token at http://yourdomain.com/.well-known/acme-challenge/<token>. Let’s Encrypt fetches that URL from the public internet — if the token matches, you’ve proven you control the domain and the certificate is issued. The same challenge runs on every renewal, which is why port 80 must stay open.

flowchart diagram: Certbot on your server

When port 80 cannot be exposed — internal networks, wildcard certificates — the DNS-01 challenge proves ownership with a TXT record instead.

Step-by-Step Implementation

Install Certbot

# Ubuntu / Debian — snap is the officially recommended method
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

# Or via apt (works fine, may lag a version behind)
sudo apt update
sudo apt install -y certbot python3-certbot-nginx

# Apache setups need their own plugin
sudo apt install -y python3-certbot-apache

# Verify installation
certbot --version

Nginx (Automatic Plugin)

# Dry-run against staging first — avoids burning rate limits
sudo certbot --nginx --staging -d example.com -d www.example.com

# Then for real
sudo certbot --nginx -d example.com -d www.example.com

# Test automatic renewal
sudo certbot renew --dry-run

The --nginx plugin reads your server block, installs the certificate, and configures HTTPS redirection. The result looks like this:

# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

http2 on; is the current syntax — the old listen 443 ssl http2; form is deprecated since nginx 1.25 and logs a warning.

Apache (Automatic Plugin)

sudo certbot --apache -d example.com -d www.example.com
# /etc/apache2/sites-available/example.com.conf
<VirtualHost *:80>
    ServerName example.com
    ServerAlias www.example.com
    Redirect permanent / https://example.com/
</VirtualHost>

<VirtualHost *:443>
    ServerName example.com
    ServerAlias www.example.com

    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem

    SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
    SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
    SSLHonorCipherOrder on
</VirtualHost>

Standalone (No Web Server)

# Standalone spins up a temporary server on port 80 — nothing else can hold it
sudo certbot certonly --standalone -d example.com

# Certificates land in /etc/letsencrypt/live/example.com/
# Point your application at:
#   /etc/letsencrypt/live/example.com/fullchain.pem
#   /etc/letsencrypt/live/example.com/privkey.pem

DNS Challenge for Wildcard Certificates

Wildcard certificates (*.example.com) can only be validated via DNS-01:

# Manual DNS validation — you add the TXT record yourself
sudo certbot certonly --manual --preferred-challenges dns \
  -d "*.example.com" -d example.com \
  --agree-tos --email admin@example.com

# Automated with Cloudflare
pip install certbot-dns-cloudflare
sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
  -d "*.example.com" -d example.com
# ~/.secrets/cloudflare.ini — restrict it: sudo chmod 600 ~/.secrets/cloudflare.ini
dns_cloudflare_api_token = your-api-token-here

The credentials file holds an API token in plain text — lock it to 600 and treat it like a password, the same rule you’d apply to any secret.

Docker / Certbot

# docker-compose.yml
services:
  certbot:
    image: certbot/certbot
    volumes:
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
      - ./certbot/log:/var/log/letsencrypt
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
# nginx.conf
server {
    listen 80;
    server_name example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://app:3000;
    }
}

Kubernetes Ingress with cert-manager

# cert-manager.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            class: nginx
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  tls:
    - hosts:
        - example.com
        - www.example.com
      secretName: example-tls
  rules:
    - host: example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app
                port:
                  number: 80

Keep a second ClusterIssuer pointed at the staging endpoint (https://acme-staging-v02.api.letsencrypt.org/directory) and switch to it while debugging — same rate-limit logic as --staging.

Companion files: the setup-ssl-certificates companion repo has the docker-compose stack, the nginx.conf, the cert-manager manifests and a credentials template ready to run.

Auto-Renewal

# Certbot installs a systemd timer on most systems — verify it's active
sudo systemctl status certbot.timer

# Cron fallback, with a hook that reloads the web server
sudo crontab -e
# Add:
# 0 3 * * * certbot renew --quiet --deploy-hook "systemctl reload nginx"

The --deploy-hook matters more than it looks: certbot renew swaps the certificate files on disk, but nginx keeps serving the old one until it reloads. For a structured operational runbook around this, see the certificate renewal template.

Variants

OptionCostValidationBest for
Let’s Encrypt + CertbotFreeHTTP-01 / DNS-01Public sites, most use cases
ZeroSSL (ACME)Free tierHTTP / DNSSame protocol, alternative CA
Cloudflare Origin CAFreeCloudflare-managedSites fully behind Cloudflare
Managed platform HTTPSIncludedAutomaticVercel/Netlify/Cloud LB — no Certbot needed
Paid CA (DigiCert, etc.)$$$Extended validationCompliance requirements, longer terms
Self-signedFreeNoneInternal/dev only — browsers will warn

Security Headers

# Add to your HTTPS server block
server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Session resumption — skips full handshake on returning clients
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
}

A note on OCSP stapling: you’ll see ssl_stapling on; recommended in older tutorials, but Let’s Encrypt retired its OCSP service in 2025. With LE certs the directive does nothing now — skip it unless your CA still runs its own OCSP responders.

What Works

  • Use the web server plugin (--nginx, --apache) when possible — it edits the config for you and removes a whole class of mistakes.
  • Set up auto-renewal from day one. Certificates last 90 days; a forgotten cron job becomes a production outage.
  • Request domain and www in a single cert — otherwise half your visitors land on a certificate warning.
  • Redirect HTTP to HTTPS at the web server, not in application code — faster and one less thing to maintain.
  • Use fullchain.pem, not cert.pem. The full chain includes the intermediates browsers need to validate trust.

Common Mistakes

  • Closing port 80 after setup. HTTP-01 validation runs on every renewal — not just the first issuance.
  • Requesting certs for names the internet can’t see (app.local, a staging box on a LAN). Let’s Encrypt only signs publicly resolvable domains.
  • Testing against production. A few --staging dry runs first keep you well clear of the 50-certs-per-week rate limit.
  • Serving cert.pem instead of fullchain.pem — missing intermediates cause “certificate not trusted” errors.
  • Renewing without a deploy hook. The files change on disk; the web server keeps serving the old cert until reloaded.
  • Not testing with --dry-run before the renewal window — a working first issuance proves nothing about renewal.

Monitoring Certificate Expiry

# Check expiry remotely
echo | openssl s_client -connect example.com:443 2>/dev/null \
  | openssl x509 -noout -dates

For fleets, alert through Prometheus’ blackbox exporter instead of checking manually:

# Alert if the cert expires within 7 days
- alert: SslCertificateExpiringSoon
  expr: probe_ssl_earliest_cert_expiry - time() < 7 * 24 * 3600
  for: 10m
  labels:
    severity: warning

Performance Tips

  1. Use session resumption — a shared session cache skips the full TLS handshake for returning clients:
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
  1. Prefer ECDSA over RSA — smaller keys mean faster handshakes and less CPU:
sudo certbot certonly --key-type ecdsa --elliptic-curve secp256r1 -d example.com
  1. Keep HTTP/2 enabled — http2 on; multiplexes requests over the single TLS connection, which is where most of the real speedup lives.

Troubleshooting

  • “Connection refused” during issuance: port 80 isn’t reachable from the internet — check the firewall, security groups, and that DNS actually resolves to this box.
  • “Too many failed authorizations”: you hit a Let’s Encrypt rate limit — retry on --staging while debugging, and wait out the production window (usually an hour for failed validations, a week for certificates).
  • Renewal succeeds but the old cert still serves: missing --deploy-hook — the files renewed but nginx never reloaded.
  • certbot renew fails in Docker: the challenge path /.well-known/acme-challenge/ isn’t served — verify the nginx location block maps to the shared certbot/www volume.

Further Reading

Frequently Asked Questions

What is the difference between a self-signed and a CA-signed certificate?

A self-signed cert is one you generated yourself — nothing vouches for it, so browsers warn. A CA-signed cert carries a trusted authority's attestation that you control the domain, so browsers accept it by default.

How does Let's Encrypt validate domain ownership?

Through ACME challenges — HTTP-01 places a token under /.well-known/acme-challenge/ that the CA fetches publicly, while DNS-01 requires a TXT record. Wildcard certs need DNS-01.

What happens if a certificate renewal fails?

The cert on disk still has runway left — it stays valid until its 90-day expiry. Certbot logs the error and retries on the next timer run; monitoring is what turns that silent failure into a page.

Why use --staging when testing Certbot commands?

Staging hands out untrusted certs but with much looser limits — hammer it while you debug instead of spending the production quota (50 certs per domain per week).

How do I revoke or delete a certificate?

certbot revoke --cert-path /etc/letsencrypt/live/example.com/cert.pem revokes it publicly; certbot delete --cert-name example.com removes the local files and renewal config.