Managing SSL certificates across multiple servers is tedious and error-prone. A missed renewal can cause outages. This guide covers automating the entire SSL lifecycle — from issuance with Let's Encrypt to distribution across your fleet — using Ansible, acme.sh, or centralized certificate management.
Architecture Options
- Centralized issuance: One server obtains certs, distributes to all servers
- Per-server issuance: Each server runs its own certbot/acme.sh
- DNS-based validation: Works for servers behind firewalls or load balancers
Centralized SSL with Ansible
# playbooks/ssl-deploy.yml
---
- name: Issue and Deploy SSL Certificates
hosts: cert_manager
become: yes
vars:
domains:
- name: example.com
san: ["www.example.com", "api.example.com"]
targets: ["web1", "web2"]
- name: app.example.com
san: []
targets: ["app1", "app2", "app3"]
tasks:
- name: Install acme.sh
shell: curl https://get.acme.sh | sh
args:
creates: /root/.acme.sh/acme.sh
- name: Issue certificates via DNS
shell: |
/root/.acme.sh/acme.sh --issue \
-d {{ item.name }} \
{% for san in item.san %}-d {{ san }} {% endfor %}\
--dns dns_cf \
--keylength ec-256
environment:
CF_Token: "{{ vault_cloudflare_token }}"
CF_Zone_ID: "{{ vault_cloudflare_zone_id }}"
loop: "{{ domains }}"
register: cert_results
changed_when: "'Cert success' in cert_results.stdout"
failed_when: false
- name: Distribute Certificates
hosts: webservers
become: yes
vars:
cert_source: "/root/.acme.sh"
tasks:
- name: Create SSL directory
file:
path: /etc/ssl/custom
state: directory
mode: '0700'
- name: Copy certificate files
copy:
src: "{{ cert_source }}/{{ domain }}_ecc/{{ item.src }}"
dest: "/etc/ssl/custom/{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- { src: "fullchain.cer", dest: "{{ domain }}.crt", mode: "0644" }
- { src: "{{ domain }}.key", dest: "{{ domain }}.key", mode: "0600" }
notify: reload nginx
delegate_to: cert_manager
handlers:
- name: reload nginx
systemd:
name: nginx
state: reloaded
Automated Renewal Script
#!/bin/bash
# /opt/scripts/ssl-renew-and-deploy.sh
set -euo pipefail
LOG="/var/log/ssl-renewal.log"
CERT_DIR="/root/.acme.sh"
SERVERS=("web1.example.com" "web2.example.com" "app1.example.com")
DOMAINS=("example.com" "app.example.com")
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG"; }
for domain in "${DOMAINS[@]}"; do
log "Renewing certificate for $domain"
if $CERT_DIR/acme.sh --renew -d "$domain" --ecc 2>&1 | tee -a "$LOG"; then
log "Certificate renewed for $domain — deploying"
for server in "${SERVERS[@]}"; do
log " Deploying to $server"
scp -q "$CERT_DIR/${domain}_ecc/fullchain.cer" "$server:/etc/ssl/custom/${domain}.crt"
scp -q "$CERT_DIR/${domain}_ecc/${domain}.key" "$server:/etc/ssl/custom/${domain}.key"
ssh "$server" "nginx -t && systemctl reload nginx" 2>&1 | tee -a "$LOG"
if [ $? -eq 0 ]; then
log " Deployed to $server successfully"
else
log " ERROR: Failed to deploy to $server"
fi
done
else
log "Certificate for $domain not due for renewal (or error occurred)"
fi
done
log "SSL renewal run complete"
# Crontab: daily at 2 AM
# 0 2 * * * /opt/scripts/ssl-renew-and-deploy.sh
Wildcard Certificates with DNS Validation
# Issue wildcard certificate
/root/.acme.sh/acme.sh --issue \
-d "example.com" \
-d "*.example.com" \
--dns dns_cf \
--keylength ec-256
# This single cert covers:
# example.com, www.example.com, api.example.com, *.example.com
# Deploy to all servers — one cert covers everything
Certificate Monitoring
#!/bin/bash
# /opt/scripts/check-ssl-expiry.sh
# Alert if any certificate expires within 14 days
DOMAINS=("example.com" "api.example.com" "app.example.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: Cannot check $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 expires in $days_left days ($expiry)"
curl -X POST "https://hooks.slack.com/services/YOUR/WEBHOOK" \
-H 'Content-type: application/json' \
-d "{\"text\": \"SSL ALERT: $domain expires in $days_left days\"}"
else
echo "OK: $domain expires in $days_left days"
fi
done
Best Practices
- Use DNS validation for wildcard certs and servers behind load balancers
- Automate everything: Never rely on manual certificate renewal
- Monitor expiry dates independently of the renewal process
- Use ECC certificates (ec-256) for faster TLS handshakes and smaller certificates
- Test Nginx config before reloading to prevent outages from bad certificates
- Keep backup copies of certificates and keys in a secure, encrypted location
- Use separate keys per environment — don't share production keys with staging