Remote Work Tools

Expired SSL certificates take down production services and destroy user trust. Automating renewal removes the human from the loop entirely — certificates rotate before they expire, logs confirm each renewal, and your on-call rotation isn’t woken up at 3am because someone forgot to add a calendar reminder.

This guide covers Certbot systemd timers, acme.sh, DNS-01 challenges for wildcard certs, and pipeline-based renewal for non-standard deployments.


Certbot with Systemd Timer (Standard Setup)

When Certbot installs on Debian/Ubuntu via the system package, it creates a systemd timer automatically:

apt install certbot python3-certbot-nginx

# Issue a certificate
certbot --nginx -d yourcompany.com -d www.yourcompany.com \
  --email ops@yourcompany.com \
  --agree-tos \
  --no-eff-email

# Check the timer
systemctl status certbot.timer

Verify the timer is active:

systemctl list-timers certbot.timer
# Output:
# NEXT                         LEFT          LAST                         PASSED       UNIT
# Sun 2026-03-29 08:34:21 UTC  6 days left   Sun 2026-03-22 08:34:21 UTC  2h ago       certbot.timer

The default timer runs twice daily and only renews certificates within 30 days of expiry. That’s usually fine. But you also want to validate the renewal works before trusting it:

# Dry run (no actual renewal)
certbot renew --dry-run

# Force renewal regardless of expiry window (for testing)
certbot renew --force-renewal

Add a post-renewal hook to reload your web server:

# /etc/letsencrypt/renewal-hooks/post/reload-nginx.sh
#!/bin/bash
systemctl reload nginx
chmod +x /etc/letsencrypt/renewal-hooks/post/reload-nginx.sh

Certbot DNS-01 for Wildcard Certificates

HTTP-01 challenges don’t work for wildcard certs (*.yourcompany.com). Use DNS-01 instead.

For Route 53:

pip install certbot-dns-route53

certbot certonly \
  --dns-route53 \
  -d "*.yourcompany.com" \
  -d yourcompany.com \
  --email ops@yourcompany.com \
  --agree-tos \
  --no-eff-email

The Route 53 plugin needs IAM credentials. Create a policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "route53:ListHostedZones",
        "route53:GetChange"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "route53:ChangeResourceRecordSets",
      "Resource": "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID"
    }
  ]
}

Set credentials via environment or ~/.aws/credentials:

export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

For Cloudflare DNS:

pip install certbot-dns-cloudflare

# /etc/letsencrypt/cloudflare.ini
dns_cloudflare_api_token = your_cloudflare_api_token

chmod 600 /etc/letsencrypt/cloudflare.ini

certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "*.yourcompany.com"

acme.sh as a Certbot Alternative

acme.sh is a pure shell script with no Python dependency, useful for embedded systems or minimal containers:

curl https://get.acme.sh | sh -s email=ops@yourcompany.com

# Issue via DNS-01 with Cloudflare
export CF_Token="your_cloudflare_token"
export CF_Account_ID="your_account_id"

~/.acme.sh/acme.sh --issue \
  --dns dns_cf \
  -d yourcompany.com \
  -d "*.yourcompany.com"

# Install certificate to nginx path
~/.acme.sh/acme.sh --install-cert \
  -d yourcompany.com \
  --cert-file      /etc/nginx/ssl/yourcompany.com.crt \
  --key-file       /etc/nginx/ssl/yourcompany.com.key \
  --fullchain-file /etc/nginx/ssl/yourcompany.com.fullchain.crt \
  --reloadcmd      "systemctl reload nginx"

acme.sh installs a cron job automatically:

crontab -l | grep acme
# 24 0 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null

Monitor Expiry with a Script

Add proactive monitoring so you’re alerted before expiry even if renewal fails:

#!/bin/bash
# /usr/local/bin/check-ssl-expiry.sh
# Sends Slack alert if cert expires within 14 days

DOMAINS=(
  "yourcompany.com"
  "api.yourcompany.com"
  "app.yourcompany.com"
)

SLACK_WEBHOOK="https://hooks.slack.com/services/T.../B.../..."
ALERT_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
    msg="SSL CHECK FAILED: Cannot connect to $domain"
    curl -s -X POST -H 'Content-type: application/json' \
      --data "{\"text\":\"$msg\"}" "$SLACK_WEBHOOK"
    continue
  fi

  expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry" +%s)
  now_epoch=$(date +%s)
  days_left=$(( (expiry_epoch - now_epoch) / 86400 ))

  if [ "$days_left" -le "$ALERT_DAYS" ]; then
    msg="SSL EXPIRY WARNING: $domain expires in $days_left days ($expiry)"
    curl -s -X POST -H 'Content-type: application/json' \
      --data "{\"text\":\"$msg\"}" "$SLACK_WEBHOOK"
  fi
done

Add to cron:

# /etc/cron.d/ssl-expiry-check
0 8 * * * root /usr/local/bin/check-ssl-expiry.sh

GitHub Actions for Certificate Distribution

When you issue certs on one server but need them on multiple (CDN edge nodes, load balancers), use GitHub Actions to distribute:

# .github/workflows/ssl-renewal.yml
name: SSL Certificate Renewal

on:
  schedule:
    - cron: '0 3 * * 1'  # Every Monday at 3am UTC
  workflow_dispatch:

jobs:
  renew:
    runs-on: ubuntu-latest
    steps:
      - name: Install acme.sh
        run: curl https://get.acme.sh | sh -s email=ops@yourcompany.com

      - name: Renew certificates
        env:
          CF_Token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CF_Account_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: |
          ~/.acme.sh/acme.sh --renew \
            --dns dns_cf \
            -d yourcompany.com \
            -d "*.yourcompany.com" \
            --force

      - name: Upload cert to S3
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          aws s3 cp ~/.acme.sh/yourcompany.com/fullchain.cer \
            s3://your-certs-bucket/yourcompany.com/fullchain.pem \
            --sse aws:kms
          aws s3 cp ~/.acme.sh/yourcompany.com/yourcompany.com.key \
            s3://your-certs-bucket/yourcompany.com/privkey.pem \
            --sse aws:kms

      - name: Notify deployment servers
        run: |
          curl -X POST \
            -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
            https://deploy.yourcompany.com/api/reload-ssl

Each server polls the S3 bucket and reloads Nginx when the cert changes:

#!/bin/bash
# /usr/local/bin/sync-ssl-cert.sh

BUCKET="s3://your-certs-bucket/yourcompany.com"
LOCAL_PATH="/etc/nginx/ssl"
CERT_CHANGED=false

aws s3 cp "$BUCKET/fullchain.pem" /tmp/fullchain.pem.new
aws s3 cp "$BUCKET/privkey.pem" /tmp/privkey.pem.new

if ! diff -q /tmp/fullchain.pem.new "$LOCAL_PATH/fullchain.pem" > /dev/null 2>&1; then
  cp /tmp/fullchain.pem.new "$LOCAL_PATH/fullchain.pem"
  cp /tmp/privkey.pem.new "$LOCAL_PATH/privkey.pem"
  CERT_CHANGED=true
fi

if $CERT_CHANGED; then
  nginx -t && systemctl reload nginx
  echo "$(date): Certificate updated and Nginx reloaded"
fi

Validate Your Renewal Chain Works

Before trusting automation, test the full chain:

# Check cert details
openssl x509 -in /etc/letsencrypt/live/yourcompany.com/fullchain.pem \
  -noout -text | grep -E "(Subject:|Not After)"

# Test renewal dry run
certbot renew --dry-run --cert-name yourcompany.com

# Verify cert served by Nginx matches the file on disk
echo | openssl s_client -connect yourcompany.com:443 2>/dev/null \
  | openssl x509 -noout -fingerprint

openssl x509 -in /etc/letsencrypt/live/yourcompany.com/cert.pem \
  -noout -fingerprint

Both fingerprints should match. If they differ, Nginx is serving a cached or different certificate — reload it:

nginx -t && systemctl reload nginx