DEV Community

Cover image for Automating SSL Renewals in GitHub Actions with acme.sh
Maya Patel
Maya Patel

Posted on

Automating SSL Renewals in GitHub Actions with acme.sh

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
guardr profile image
Guardr •

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.