Automating SSL Certificate Renewal for Self-Hosted Services with ACME and Let's Encrypt
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
An expired SSL certificate is one of the most embarrassing things that can happen to a self-hoster. Your browser screams "Your connection is not private," your users panic, and you're scrambling to remember how you set up that certificate six months ago. I've been there, and once is enough. The good news is that with Let's Encrypt and the ACME protocol, full certificate automation is not only possible — it's straightforward, and there's really no excuse not to set it up properly from day one.
In this tutorial I'll walk through two proven approaches: using Certbot for bare-metal or standalone setups, and using Caddy, which handles ACME entirely on its own without any extra tooling. I'll also cover the DNS-01 challenge, which is essential if your services aren't publicly reachable — a common scenario for homelab setups behind NAT or Cloudflare Tunnels.
Understanding the ACME Protocol and Let's Encrypt
Let's Encrypt is a free, automated Certificate Authority (CA) that issues 90-day TLS certificates. The short lifespan is intentional — it forces automation and limits the damage window if a private key is ever compromised. The protocol they use is called ACME (Automatic Certificate Management Environment), standardised as RFC 8555.
ACME works by issuing a challenge to prove you control the domain. There are two challenges you'll use most often:
- HTTP-01: Let's Encrypt hits a well-known URL on your server over port 80. Fast and easy, but requires your server to be publicly reachable on port 80.
- DNS-01: You create a TXT record in your DNS zone. Works even for internal services, wildcard certificates, and servers behind firewalls. This is my preferred method for homelab use.
Method 1: Certbot with Automatic Renewal
Certbot is the classic ACME client, maintained by the EFF. It works well for VPS setups where you're running Nginx or Apache directly. Here's how I set it up on a fresh Ubuntu 24.04 Droplet:
# Install Certbot and the Nginx plugin (or use --apache if that's your stack)
sudo apt update
sudo apt install -y certbot python3-certbot-nginx
# Obtain and install a certificate for a domain using HTTP-01
# (port 80 must be open and Nginx must be running)
sudo certbot --nginx -d example.com -d www.example.com \
--non-interactive --agree-tos --email [email protected]
# Verify the auto-renewal timer is active
sudo systemctl status certbot.timer
# Do a dry run to confirm renewal works without issuing a real cert
sudo certbot renew --dry-run
When you install Certbot via apt on modern Ubuntu, it sets up a systemd timer at /etc/systemd/system/snap.certbot.renew.timer (or certbot.timer if installed via apt instead of snap) that runs twice daily. It will only actually renew when a certificate is within 30 days of expiry, so it's safe to leave running permanently.
For the DNS-01 challenge with Cloudflare (my go-to DNS provider for its API), you need the Cloudflare plugin:
# Install the Cloudflare DNS plugin
sudo apt install -y python3-certbot-dns-cloudflare
# Create a credentials file — store this securely, not in a public directory
sudo mkdir -p /etc/letsencrypt/cloudflare
sudo tee /etc/letsencrypt/cloudflare/credentials.ini > /dev/null <<'EOF'
# Cloudflare API token with Zone:DNS:Edit permission
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN_HERE
EOF
sudo chmod 600 /etc/letsencrypt/cloudflare/credentials.ini
# Issue a wildcard certificate using DNS-01
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare/credentials.ini \
-d "*.example.com" -d "example.com" \
--non-interactive --agree-tos --email [email protected]
# List issued certificates
sudo certbot certificates
Wildcard certificates like *.example.com cover all subdomains — so jellyfin.example.com, nextcloud.example.com, and vaultwarden.example.com all use the same cert. I find this much easier to manage than issuing individual certs per subdomain.
--staging flag while testing to hit Let's Encrypt's staging environment, which has much higher limits and won't burn your production quota.Method 2: Caddy — Zero-Config ACME Automation
I prefer Caddy for any new self-hosted setup because it handles TLS entirely automatically. You don't install Certbot, configure timers, or think about renewal at all. Caddy's ACME client is built in, and it renews certificates well before they expire — typically at the 2/3 mark of their validity period.
Here's a typical Docker Compose setup for Caddy as a reverse proxy in front of multiple self-hosted services, with automatic HTTPS:
# docker-compose.yml for Caddy with automatic SSL
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 QUIC support
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # Stores certificates — persist this!
- caddy_config:/config
environment:
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
volumes:
- /media:/media:ro
- jellyfin_config:/config
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
volumes:
- vaultwarden_data:/data
volumes:
caddy_data:
caddy_config:
jellyfin_config:
vaultwarden_data:
And the corresponding Caddyfile for DNS-01 challenge via Cloudflare (using the caddy:2-alpine image with the Cloudflare DNS module baked in, or build a custom image):
# Caddyfile
{
email [email protected]
# Use Let's Encrypt staging while testing
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
jellyfin.example.com {
reverse_proxy jellyfin:8096
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
}
vaultwarden.example.com {
reverse_proxy vaultwarden:80
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# Vaultwarden security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
}
}
Caddy stores all certificates in the named caddy_data volume. This is important: if you delete that volume, Caddy will request fresh certificates on startup, and you might hit rate limits if you do it repeatedly. Always back up your caddy_data volume alongside your other container volumes.
Monitoring Certificate Expiry
Even with automation, I like to have a second layer of monitoring in place. Uptime Kuma has a built-in SSL certificate monitor that alerts you if a cert is expiring within a configurable window. I set mine to warn at 14 days — by which point Certbot and Caddy should have already renewed, so an alert here means something is genuinely broken.
You can also check expiry directly from the command line:
# Check expiry date of a live certificate
echo | openssl s_client -servername jellyfin.example.com \
-connect jellyfin.example.com:443 2>/dev/null \
| openssl x509 -noout -dates
# Check a local certificate file
sudo openssl x509 -noout -dates \
-in /etc/letsencrypt/live/example.com/fullchain.pem
# List all Certbot-managed certs with expiry
sudo certbot certificates
Hosting on a VPS? DigitalOcean Makes This Easy
If you're running these services on a cloud VPS rather than home hardware, the setup is identical — and you get the added benefit of a static IP and reliable uptime. Start spending more time on your projects and less time managing your infrastructure. Create your DigitalOcean account today. DigitalOcean's Droplets start at $6/month and their DNS API works natively with Certbot's Cloudflare plugin — or you can use the
python3-certbot-dns-digitalocean plugin directly if you're managing DNS through DigitalOcean itself.
Common Gotchas and How to Avoid Them
A few things I've learned the hard way over the years:
- Port 80 must be open for HTTP-01: Even if you only serve HTTPS, Certbot's Nginx plugin needs port 80 accessible during renewal. UFW rule:
sudo ufw allow 80/tcp. - DNS propagation delays with DNS-01: Certbot waits for DNS propagation by default, but on some slow DNS providers this can time out. The
--dns-cloudflare-propagation-seconds 30flag reduces waiting time for Cloudflare since its API changes are near-instant. - Caddy needs a custom build for DNS plugins: The standard
caddy:2Docker image doesn't include DNS provider plugins. Usecaddy:2-builderto compile your own, or grab a community image likeslothcroissant/caddy-cloudflarednsthat bundles the Cloudflare module. - Persist your certificate storage: Both Certbot's
/etc/letsencryptdirectory and Caddy's data volume contain your private keys and certificates. Back them up as part of your regular backup routine.
Next Steps
With automatic certificate renewal in place, your self-hosted services will stay HTTPS-secure indefinitely without any manual intervention. From here, I'd suggest looking at two natural next steps: first, pairing your reverse proxy with Authelia or Authentik to add single sign-on and MFA in front of your services — valid HTTPS is a prerequisite for those auth layers anyway. Second, set up Uptime Kuma with SSL monitoring so you get an alert if renewal ever fails for any reason. Automation is great, but a safety net costs nothing.
Discussion