Automating SSL Renewals in GitHub Actions with acme.sh
When our infrastructure team inherited a legacy deployment pipeline that required manual SSL certificate renewals every 90 days, the operational debt became unsustainable. After evaluating several ACME clients, we chose acme.sh for its pure shell implementation and minimal dependencies—critical for containerized CI environments.
The Problem: Certificate Expiry as a Production Incident
Our initial approach used cron jobs on individual VMs, but this created blind spots:
- No centralized audit trail of renewal attempts
- Failed renewals went unnoticed until expiry warnings
- Inconsistent acme.sh versions across environments
- Manual intervention required during DNS validation
We needed certificate lifecycle management integrated directly into our GitHub Actions deployment pipeline.
Deliverable 1: Hardened GitHub Actions Workflow
This workflow runs on a schedule and can be triggered manually. Key hardening techniques:
name: SSL Certificate Renewal
on:
schedule:
- cron: '0 2 * * 0' # Weekly Sunday 2 AM UTC
workflow_dispatch:
concurrency:
group: ssl-renewal
cancel-in-progress: false # Never cancel mid-renewal
jobs:
renew:
runs-on: ubuntu-22.04
timeout-minutes: 15
strategy:
matrix:
domain:
- api.example.com
- app.example.com
fail-fast: false # Continue other domains on failure
steps:
- name: Checkout acme.sh pinned version
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
repository: acmesh-official/acme.sh
ref: '3.0.7' # Pin exact release
path: acme.sh
- name: Cache acme.sh account
uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0
with:
path: ~/.acme.sh
key: acme-account-${{ runner.os }}-v2
- name: Install acme.sh
run: |
cd acme.sh
./acme.sh --install --nocron --home ~/.acme.sh \
--accountemail ${{ secrets.ACME_EMAIL }}
- name: Issue certificate with DNS validation
env:
CF_Token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_Zone_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
run: |
~/.acme.sh/acme.sh --issue \
--dns dns_cf \
--domain ${{ matrix.domain }} \
--keylength ec-256 \
--server letsencrypt \
--log ~/.acme.sh/acme.log
- name: Upload certificate to secrets manager
if: success()
run: |
# Example: AWS Secrets Manager
CERT=$(cat ~/.acme.sh/${{ matrix.domain }}_ecc/fullchain.cer | base64 -w0)
KEY=$(cat ~/.acme.sh/${{ matrix.domain }}_ecc/${{ matrix.domain }}.key | base64 -w0)
aws secretsmanager update-secret \
--secret-id "ssl/${{ matrix.domain }}/cert" \
--secret-string "$CERT"
aws secretsmanager update-secret \
--secret-id "ssl/${{ matrix.domain }}/key" \
--secret-string "$KEY"
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
with:
name: acme-logs-${{ matrix.domain }}
path: ~/.acme.sh/acme.log
retention-days: 30
Deliverable 2: Pre-Deployment Validation Script
This bash script runs in CI before deploying certificate updates to production:
#!/usr/bin/env bash
set -euo pipefail
# validate-cert.sh - Run in CI to enforce certificate quality gates
DOMAIN="$1"
CERT_FILE="$2"
# Gate 1: Certificate expiry must be > 30 days
EXPIRY=$(openssl x509 -enddate -noout -in "$CERT_FILE" | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_REMAINING=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
if [[ $DAYS_REMAINING -lt 30 ]]; then
echo "FAIL: Certificate expires in $DAYS_REMAINING days (minimum 30)"
exit 1
fi
# Gate 2: Subject Alternative Name must match domain
if ! openssl x509 -noout -text -in "$CERT_FILE" | grep -q "DNS:$DOMAIN"; then
echo "FAIL: Certificate SAN does not include $DOMAIN"
exit 1
fi
# Gate 3: Certificate chain must be complete
CHAIN_DEPTH=$(openssl crl2pkcs7 -nocrl -certfile "$CERT_FILE" | \
openssl pkcs7 -print_certs -noout | grep -c "subject=")
if [[ $CHAIN_DEPTH -lt 2 ]]; then
echo "FAIL: Certificate chain incomplete (depth: $CHAIN_DEPTH)"
exit 1
fi
echo "PASS: Certificate valid for $DAYS_REMAINING days with complete chain"
War Story: The Unpinned Action Incident
In March 2025, a supply chain compromise in a popular GitHub Action inserted credential exfiltration code. We discovered our runner logs were being uploaded to an external S3 bucket—logs that contained temporary AWS credentials.
The root cause: unpinned action versions using @main tags. After incident response, we implemented two policies:
- Pin all actions to full commit SHAs (not tags, which are mutable)
- Weekly Dependabot PRs to update pinned SHAs with security review
Lessons from Production
Concurrency control matters. Early versions allowed parallel renewal jobs, which triggered Let's Encrypt rate limits. The cancel-in-progress: false directive prevents workflow cancellations mid-validation.
Cache keys must version-bump on breaking changes. When we migrated from RSA to ECDSA certificates, stale cached account data caused validation failures. Explicit cache versioning (-v2 suffix) gives us a manual invalidation lever.
Matrix builds isolate blast radius. With fail-fast: false, a failure renewing one domain doesn't block others. The tradeoff: longer wall-clock time, but acceptable for weekly runs.
Conclusion
Integrating acme.sh into GitHub Actions eliminated an entire class of operational incidents. The workflow runs unattended, with Slack notifications on failure and certificate metrics exported to our observability stack.
The full validation script and workflow are production-tested across 40+ domains handling 2M+ requests daily. Certificate expiry hasn't triggered a page in 18 months.
Top comments (1)
Nice gates.
If the certificate metrics you export come from the file rather than the live endpoint, one check we would add after the upload step: read the certificate the endpoint actually serves. openssl s_client -connect $DOMAIN:443 -servername $DOMAIN </dev/null | openssl x509 -noout -enddate should show the new expiry date. A renewal can pass every CI gate while the endpoint still serves the old cert because nothing reloaded it and only a check from outside sees that.