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:

  1. Pin all actions to full commit SHAs (not tags, which are mutable)
  2. 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.