Automating SSL Certificate Renewal for Self-Hosted Services with ACME and Let's Encrypt

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:

Tip: If you're hosting services internally (on a LAN or behind a VPN) and want valid HTTPS, DNS-01 is your only real option. Many DNS providers — Cloudflare, DigitalOcean, Hetzner — offer APIs that Certbot and other ACME clients can use to automate DNS record creation and deletion.

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.

Watch out: Let's Encrypt enforces rate limits. You can issue a maximum of 5 duplicate certificates per week and 50 certificates per registered domain per week. Use the --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.

Tip: If you use DigitalOcean for DNS, create a scoped API token with only Domain:Read and Domain:Write permissions for your Certbot credentials file. Never use a full-access token for automated tooling.

Common Gotchas and How to Avoid Them

A few things I've learned the hard way over the years:

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