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

Automating SSL Certificate Renewal for Self-Hosted Services with 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 — and preventable — failures in self-hosting. I've been there: Jellyfin throwing browser security warnings at 2am, Nextcloud users panicking, and a frantic SSH session to manually renew a cert I forgot about. Let's Encrypt certificates only last 90 days by design, which forces you to actually automate renewal rather than relying on annual calendar reminders. In this tutorial I'll walk through three solid approaches to fully automated renewal: raw Certbot with a systemd timer, Caddy's built-in ACME handling, and a wildcard DNS-challenge setup for services that aren't publicly exposed.

Why 90-Day Certificates Demand Automation

Let's Encrypt deliberately issues short-lived certificates because it pushes the ecosystem toward automation. If you're renewing manually every 90 days, you're going to slip up sooner or later. The good news is that every major tool in the self-hosting ecosystem has solid automation built in — you just need to wire it up correctly once and then trust it.

Before diving in, decide which challenge type fits your setup:

If you're running services on a DigitalOcean Droplet with a public IP, HTTP-01 is the easiest path. If you're running entirely behind Tailscale or a private network, DNS-01 is your only real option.

Method 1: Certbot with a systemd Timer

Certbot is the reference ACME client and it's what I use on servers where I'm running Nginx directly (not in Docker). On Ubuntu/Debian, install it and the Nginx plugin with:

sudo apt update
sudo apt install -y certbot python3-certbot-nginx

# Obtain a cert and auto-configure Nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com \
  --non-interactive --agree-tos --email [email protected]

# Test the renewal process without actually renewing
sudo certbot renew --dry-run

Modern Certbot installations on systemd-based distros automatically create a timer unit — check it with:

systemctl status certbot.timer
systemctl list-timers | grep certbot

You should see certbot.timer listed as active. By default it fires twice daily and renews any cert within 30 days of expiry. If for some reason the timer isn't installed (older Certbot packages), create it manually:

# /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Twice daily renewal of Let's Encrypt certificates

[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=43200
Persistent=true

[Install]
WantedBy=timers.target
# /etc/systemd/system/certbot-renew.service
[Unit]
Description=Certbot Renewal

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renew.timer

The --deploy-hook flag is important — it only runs the reload command when a certificate is actually renewed, not on every check. I've seen people skip this and then wonder why their services keep serving the old cert.

Watch out: If you're running Nginx inside Docker but using a host Certbot, your deploy hook needs to reload the container, not the host service. Use --deploy-hook "docker exec nginx-container nginx -s reload" instead of the systemctl command.

Method 2: Caddy (Zero-Config Automation)

I prefer Caddy for new deployments specifically because SSL is automatic and there's genuinely nothing to configure. Caddy handles certificate issuance, storage, and renewal entirely on its own. Here's a complete Docker Compose setup for Caddy as a reverse proxy that handles SSL for multiple services:

# docker-compose.yml
services:
  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - proxy

  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    restart: unless-stopped
    networks:
      - proxy

  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    networks:
      - proxy

networks:
  proxy:
    driver: bridge

volumes:
  caddy_data:
  caddy_config:
# Caddyfile
jellyfin.yourdomain.com {
    reverse_proxy jellyfin:8096
}

vault.yourdomain.com {
    reverse_proxy vaultwarden:80
}

# Optional: redirect HTTP to HTTPS (Caddy does this automatically)
# and enable HSTS
jellyfin.yourdomain.com {
    header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    reverse_proxy jellyfin:8096
}

That's it. Bring the stack up with docker compose up -d and Caddy will request certificates from Let's Encrypt on first connection, store them in the caddy_data volume, and renew them automatically well before expiry. The /data volume is critical — mount it as a named volume, not a bind mount to a temporary directory, or you'll hit Let's Encrypt rate limits when the container restarts and requests fresh certs each time.

Tip: Caddy also supports the ZeroSSL CA as a fallback. If Let's Encrypt is rate-limiting you during testing, Caddy will try ZeroSSL automatically. You can also force it with acme_ca https://acme.zerossl.com/v2/DV90 in your global options block.

Method 3: Wildcard Certs via DNS-01 Challenge

If your services are internal-only — Gitea, Uptime Kuma, Immich behind Tailscale — you can't use HTTP-01 because Let's Encrypt can't reach your server. DNS-01 solves this. You'll get a wildcard cert for *.yourdomain.com that covers every subdomain without exposing any port to the internet.

I use this with Cloudflare DNS. Install the Certbot Cloudflare plugin and set up credentials:

sudo apt install -y python3-certbot-dns-cloudflare

# Create credentials file (use a scoped API token, not your global key)
sudo mkdir -p /etc/letsencrypt/cloudflare
sudo nano /etc/letsencrypt/cloudflare/credentials.ini
# Contents:
# dns_cloudflare_api_token = your_cloudflare_api_token_here
sudo chmod 600 /etc/letsencrypt/cloudflare/credentials.ini

# Request wildcard certificate
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare/credentials.ini \
  --dns-cloudflare-propagation-seconds 60 \
  -d "yourdomain.com" \
  -d "*.yourdomain.com" \
  --agree-tos \
  --email [email protected] \
  --non-interactive

Renewal works the same way — the systemd timer picks it up, runs Certbot, which re-does the DNS challenge automatically using your stored credentials. The wildcard cert then gets used by Nginx or any other service that needs it. When I set this up for my internal Gitea instance, I pointed git.home.yourdomain.com to my Tailscale IP in Cloudflare DNS and everything just worked.

Monitoring Certificate Expiry

Even with automation, I add a belt-and-suspenders monitoring step using a simple script that Uptime Kuma's SSL monitoring can trigger, or you can run it as a cron job that emails you if any cert has fewer than 14 days remaining:

#!/bin/bash
# /usr/local/bin/check-certs.sh
DOMAINS=("yourdomain.com" "git.yourdomain.com" "vault.yourdomain.com")
WARN_DAYS=14

for domain in "${DOMAINS[@]}"; do
  expiry=$(echo | openssl s_client -servername "$domain" \
    -connect "$domain:443" 2>/dev/null \
    | openssl x509 -noout -enddate 2>/dev/null \
    | cut -d= -f2)

  if [ -z "$expiry" ]; then
    echo "WARNING: Could not check cert for $domain"
    continue
  fi

  expiry_epoch=$(date -d "$expiry" +%s)
  now_epoch=$(date +%s)
  days_left=$(( (expiry_epoch - now_epoch) / 86400 ))

  if [ "$days_left" -lt "$WARN_DAYS" ]; then
    echo "ALERT: $domain cert expires in $days_left days ($expiry)"
  else
    echo "OK: $domain expires in $days_left days"
  fi
done
chmod +x /usr/local/bin/check-certs.sh

# Add to crontab to run daily at 8am
(crontab -l 2>/dev/null; echo "0 8 * * * /usr/local/bin/check-certs.sh | mail -s 'Cert Check' [email protected]") | crontab -

Common Gotchas I've Hit

Rate limits during testing: Let's Encrypt limits you to 5 duplicate certificates per week per domain. Always use the staging environment (--staging flag with Certbot) when testing. Caddy has an equivalent: set acme_ca https://acme-staging-v02.api.letsencrypt.org/directory in the global options block.

Port 80 blocked by UFW: If you've hardened your firewall and forgotten to allow port 80, HTTP-01 challenges will silently fail. Run sudo ufw allow 80/tcp and remember that Caddy needs 80 open even if it redirects everything to HTTPS — that's how the ACME challenge works.

Clock skew: ACME validation is time-sensitive. If your server clock drifts, certificate requests fail with cryptic errors. Install and enable systemd-timesyncd or chrony and verify with timedatectl status.

Which Approach Should You Use?

For new projects, I default to Caddy — the automation is genuinely invisible and I've never had a renewal failure. For existing setups already running Nginx, Certbot with a systemd timer is the path of least resistance. For anything internal-only (homelab services behind Tailscale or WireGuard), DNS-01 with a wildcard cert is the cleanest solution.

If you're spinning up a fresh VPS for self-hosting, create a DigitalOcean account — their $6/month Droplet is more than enough to run Caddy with a handful of services, and the $4 Basic Droplet handles the lighter workloads fine. Start spending more time on your projects and less time managing infrastructure.

The next steps from here: wire up Uptime Kuma to monitor your SSL expiry dates visually, and if you're running multiple services consider moving to Traefik with its built-in Let's Encrypt integration and dashboard — that's a full tutorial in itself, but the cert automation concepts are identical to what we've covered here.

Discussion