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/--apachemode, an HTTP site already answers on port 80 — Certbot reads the existing server block. - For anything beyond a one-off test, use
--stagingfirst. 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.
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
| Option | Cost | Validation | Best for |
|---|---|---|---|
| Let’s Encrypt + Certbot | Free | HTTP-01 / DNS-01 | Public sites, most use cases |
| ZeroSSL (ACME) | Free tier | HTTP / DNS | Same protocol, alternative CA |
| Cloudflare Origin CA | Free | Cloudflare-managed | Sites fully behind Cloudflare |
| Managed platform HTTPS | Included | Automatic | Vercel/Netlify/Cloud LB — no Certbot needed |
| Paid CA (DigiCert, etc.) | $$$ | Extended validation | Compliance requirements, longer terms |
| Self-signed | Free | None | Internal/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, notcert.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
--stagingdry runs first keep you well clear of the 50-certs-per-week rate limit. - Serving
cert.peminstead offullchain.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-runbefore 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
- 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;
- Prefer ECDSA over RSA — smaller keys mean faster handshakes and less CPU:
sudo certbot certonly --key-type ecdsa --elliptic-curve secp256r1 -d example.com
- 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
--stagingwhile 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 renewfails in Docker: the challenge path/.well-known/acme-challenge/isn’t served — verify the nginxlocationblock maps to the sharedcertbot/wwwvolume.
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.
Related Resources
Ansible Playbook for Server Configuration
How to write and run Ansible playbooks for provisioning, configuring, and managing servers with idempotent tasks, roles, and inventory files.
RecipeDocker Basics: Dockerfile, Compose & Commands
How to containerize an application, write a Dockerfile, and run containers with Docker Compose.
RecipeManage Application Secrets Securely
How to store, rotate, and inject API keys, database passwords, and certificates without hardcoding them in source code or environment files.
RecipeSlim Production Images with Multi-Stage Docker Builds
How to build minimal production Docker images using multi-stage builds with distroless base images, covering Go, Node.js, Python, and Java examples with image size reduction.
RecipeMount Configs and Secrets into Kubernetes Pods
How to mount ConfigMaps and Secrets into Kubernetes pods using env vars, volumes, projected volumes, and secret management with external secrets.
DocSSL Certificate Renewal Template
A template for tracking SSL certificate expiration and renewal workflows.