Introduction
In 2026, Let's Encrypt remains the gold standard for free, automated SSL/TLS certificates via the ACME v2 protocol. Managed by the ISRG, it issues 90-day certificates that renew without manual intervention. For advanced production deployments, go beyond basic setups: handle rate limits (50 certs/week/domain), use DNS-01 challenges for wildcards (*.example.com), add hooks for reloads without downtime, and automate with systemd timers instead of outdated cron.
This tutorial walks you through it step by step on an Ubuntu 24.04 server with Nginx: Certbot installation, secure config, multi-domain wildcards, and renewal monitoring. Result: robust, scalable HTTPS that's PCI-DSS compliant. Pros bookmark this for critical deployments—zero generic advice, all concrete and tested.
Prerequisites
- Ubuntu 24.04 LTS server (or Debian 12+).
- Domain with editable DNS (A/AAAA records pointing to server IP, ports 80/443 open).
- Nginx 1.26+ installed (
sudo apt install nginx). - Root/sudo access.
- DNS provider with API support (Cloudflare, Route53 for wildcards).
- Optional: TypeScript/Go knowledge for advanced hooks.
Install Certbot and plugins
# Update and install Certbot with Nginx and DNS plugins
sudo apt update
sudo apt install certbot python3-certbot-nginx python3-certbot-dns-cloudflare -y
# Check version (must be 2.10+ for ACME v2)
certbot --version
# Create directory for renew hooks
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo chown -R root:www-data /etc/letsencrypt/renewal-hooksThese commands install the official Certbot from Ubuntu repos, including plugins for Nginx (auto-config) and Cloudflare (DNS-01 wildcard). The hooks directory sets up post-renewal scripts for reloading Nginx without downtime. Avoid Snap: it's slower and problematic in containers.
Configure Nginx for initial HTTP-01 challenge
Before getting any certificates, Nginx must serve /.well-known/acme-challenge/ on port 80. Think of it as a 'verification door': Let's Encrypt drops a temporary token there to validate the domain.
Initial Nginx HTTP config
server {
listen 80;
server_name example.com www.example.com;
root /var/www/html;
index index.html;
# ACME Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
allow all;
}
location / {
try_files $uri $uri/ =404;
}
}
# Create webroot directory for challenges
sudo mkdir -p /var/www/letsencrypt
sudo chown -R www-data:www-data /var/www/letsencrypt
sudo nginx -t && sudo systemctl reload nginxThis server block listens on port 80 and exposes /.well-known/acme-challenge/ via a dedicated webroot to avoid app conflicts. Test with nginx -t. Pitfall: forgetting chown blocks Certbot (needs 755 permissions).
Get initial HTTP-01 certificate
# Use webroot for full control (not --nginx which auto-modifies)
sudo certbot certonly \
--webroot -w /var/www/letsencrypt \
-d example.com -d www.example.com \
--email admin@example.com \
--agree-tos --no-eff-email \
--staging # Remove --staging in prod after testing
# Verify
sudo ls /etc/letsencrypt/live/example.com/Webroot challenge gives precise control in production: Certbot places tokens in /var/www/letsencrypt without stopping Nginx. --staging avoids rate limits (unlimited staging quotas). Pitfall: forget --webroot -w and it fails with 'no server block'.
Enable HTTPS with HSTS and OCSP
Now migrate to SSL: redirect HTTP to HTTPS, enable HSTS (HTTP Strict Transport Security) for browsers, and OCSP stapling for privacy. Analogy: HTTPS is an encrypted tunnel, HSTS is a permanent padlock.
Full Nginx HTTPS config
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
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.3;
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:le_nginx_SSL:10m;
# HSTS (1 year)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
root /var/www/html;
location / {
try_files $uri $uri/ =404;
}
}
sudo nginx -t && sudo systemctl reload nginxfullchain.pem = cert + intermediate chain; privkey.pem = private key. TLSv1.3 only for 2026 security. HSTS preload-ready; OCSP stapling caches revocation responses. Test with curl -I https://example.com. Pitfall: relative paths break everything.
Wildcard via DNS-01 (Cloudflare)
# Create Cloudflare credentials (API token with Zone:DNS:Edit)
sudo mkdir -p /etc/letsencrypt/cloudflare
cat > /etc/letsencrypt/cloudflare/cloudflare.ini << EOF
# Cloudflare API token
# Format: dns_cloudflare_api_token = YOUR_GLOBAL_API_TOKEN
EOF
sudo chmod 600 /etc/letsencrypt/cloudflare/cloudflare.ini
# Get wildcard
sudo certbot certonly \
--dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/cloudflare/cloudflare.ini \
-d '*.example.com' -d example.com \
--email admin@example.com --agree-tos --no-eff-email --staging
# DNS propagation ~60sDNS-01 enables wildcards/unlimited subdomains: Certbot adds TXT records via Cloudflare API. Token > key (2026 security). Slow propagation? Add --dns-cloudflare-propagation-seconds 120. Pitfall: bad perms on .ini = fatal 'auth error'.
Post-renew hook for Nginx reload
#!/bin/bash
# Hook run after successful renew
if [ "$RENEW_REASON" = "renewed" ]; then
systemctl reload nginx
logger "Let's Encrypt renewed: reloaded Nginx"
fi
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.shThis deploy hook runs post-renew if changed: reload (not restart) = zero downtime. RENEW_REASON condition avoids unnecessary runs. Log for monitoring. Pitfall: restart kills active connections.
Automation with systemd timer
[Unit]
Description=Run certbot twice daily
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
---
[Unit]
Description=Certbot Renewal
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet
---
sudo systemctl daemon-reload
sudo systemctl enable --now certbot.timerSystemd timer > cron: precise, randomized (anti-thundering herd), persistent. Checks renew twice daily (90-day certs / 2). --quiet suppresses log spam. Check with systemctl status certbot.timer. Pitfall: no daemon-reload ignores the unit.
Best practices
- Staging first: Always use
--stagingfor tests—unlimited rate limits. - Separate keys: 700 perms on privkey, never commit to git.
- Monitoring: Alerts on
/var/log/letsencrypt/letsencrypt.logvia Logrotate + Prometheus. - Backup:
certbot certificates --cert-name example.com --dump-allto S3. - Rate limits: <5 failed/domain/week; use staging.
Common errors to avoid
- Port 80 blocked: Firewall/Cloud blocks challenge → 'Connection refused'.
- DNS not propagated: Wait 5min after A record; use
dig. - Webroot permissions: Needs 755/www-data, or 'Permission denied'.
- Wildcard without DNS-01: HTTP-01 impossible on * → staging + API creds.
Next steps
Dive deeper with ACME v3 drafts, integrate Traefik/Docker (Certbot companion). Check out our DevOps training at Learni for Kubernetes + Istio auto-TLS.