acme.sh
acme.sh is a pure shell client — useful when Python (certbot) or Go (lego) aren't available, or simply when minimum runtime footprint matters. This page covers the acme.sh-specific bits; for the general onboarding flow and trust-store setup see First Certificate.
neilpang/acme.sh:latest Docker image)acme.sh's CLI has been stable for years — --issue / --renew / --revoke and their flags work identically across 2.x and 3.x. If you are on an older version and a flag is missing, upgrade is the recommended path.
What changes vs certbot
-
Key type: acme.sh generates RSA 2048 by default. This is refused by Hortval under the default
signature.min-rsa-bits: 3072policy — always pass--keylengthexplicitly:--keylength 3072or4096for RSA--keylength ec-256orec-384for ECDSA- See FAQ → RSA-only templates.
-
CSR EKU — read this carefully: acme.sh's built-in OpenSSL template declares both
serverAuthandclientAuthin the CSR's Extended Key Usage, regardless of intended purpose. By default Hortval rejects this combination, returningbadCSR: EKU 1.3.6.1.5.5.7.3.2 in CSR not allowed by policy.Two paths to make it work:
- Strict (preferred from June 2026): drop
clientAuthfrom acme.sh's CSR template. The CA/B Forum baseline forbids theserverAuth + clientAuthcombination on publicly-trusted server certificates from June 2026 onwards — production deployments should align even when fronted by an internal ADCS. The cleanest way is to maintain a private fork ofacme.shor to override its OpenSSL config file (~/.acme.sh/openssl.cnfif you use--certhome). - Pragmatic (existing fleet): add
clientAuthto the policy'scsr.allowed-extra-ekuon the Hortval side. See Configuration → Issuance policies → EKU. This unblocks acme.sh as-is; plan a migration before June 2026.
Still true — verified 2026-08-21Measured on a CSR produced by
neilpang/acme.sh:latestin our ADCS lab. The request carries the extension verbatim:2.5.29.37 = 30 14 06 08 2b 06 01 05 05 07 03 01 06 08 2b 06 01 05 05 07 03 02└ SEQUENCE ─ 1.3.6.1.5.5.7.3.1 (serverAuth) ─ …3.2 (clientAuth)Worth re-checking when you upgrade acme.sh: the day its template drops
clientAuth,allowed-extra-ekubecomes dead configuration you can remove — and nothing will tell you, because a CSR asking for less than the policy allows is accepted in silence.It asks, it does not receive, and it does not mind. On the same run, the issued certificate came back with
Server Authenticationonly, markedOrigin=Policy— the ADCS template built the EKU and ignored the request. acme.sh completed normally. So allowingclientAuthhere decides whether Hortval accepts the request, not what the certificate contains. See Issuance policies → clientAuth and the CA/B Forum baseline. - Strict (preferred from June 2026): drop
-
Trust store: acme.sh uses curl under the hood. Point both
--ca-bundleand theCA_BUNDLE/CURL_CA_BUNDLEenv vars at your OS bundle:
# Debian / Ubuntu
export CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
# RHEL / CentOS / Rocky
export CA_BUNDLE=/etc/pki/tls/certs/ca-bundle.crt
export CURL_CA_BUNDLE=/etc/pki/tls/certs/ca-bundle.crt
--ca-bundle is for the ACME server's TLS cert; CURL_CA_BUNDLE covers the underlying curl invocations acme.sh uses for some HTTP calls. Setting both is belt-and-braces.
- Built-in scheduler: acme.sh installs its own daily cron entry via
--install-cronjob, no systemd timer to write.
HTTP-01 (standalone)
acme.sh --issue \
--server https://acme.corp.internal/acme/directory \
--ca-bundle /etc/ssl/certs/ca-certificates.crt \
--standalone \
--keylength 3072 \
-d app.corp.internal
Cert + key land under ~/.acme.sh/app.corp.internal/:
fullchain.cer— leaf + chain.app.corp.internal.cer— leaf only.app.corp.internal.key— private key.ca.cer— chain only.
acme.sh's --standalone binds port 80 with a tiny built-in server for the duration of the challenge.
HTTP-01 (webroot)
acme.sh --issue \
--server https://acme.corp.internal/acme/directory \
--ca-bundle /etc/ssl/certs/ca-certificates.crt \
--webroot /var/www/html \
--keylength 3072 \
-d app.corp.internal
acme.sh writes the challenge token under /var/www/html/.well-known/acme-challenge/. The web server must serve that path over HTTP without authentication.
TLS-ALPN-01
acme.sh --issue \
--server https://acme.corp.internal/acme/directory \
--ca-bundle /etc/ssl/certs/ca-certificates.crt \
--alpn \
--keylength 3072 \
-d app.corp.internal
Same constraint as HTTP-01 standalone but on port 443.
DNS-01 (for wildcards)
acme.sh has its own catalogue of DNS plugins under ~/.acme.sh/dnsapi/. Configure with env vars and pass --dns dns_<provider>:
By default, acme.sh resolves the just-installed _acme-challenge.<domain> TXT
record through public DoH resolvers (Cloudflare, Google) before notifying
Hortval. On an internal-only deployment this check will always fail —
the names do not exist publicly — and worse, the internal domain name is
leaked in cleartext to the public resolvers.
Pass --dnssleep <N> to skip the pre-check and wait N seconds before
notifying the server. A short value such as --dnssleep 1 is enough when
the DNS plugin updates the authoritative server synchronously
(dns_nsupdate, BIND/Knot RFC 2136, MS DNS via dns_namesilo etc.);
larger values may be required for providers with slow API propagation.
# Example: RFC 2136 / nsupdate
export NSUPDATE_SERVER='10.0.0.53'
export NSUPDATE_KEY='/etc/acme.sh/keyfile'
acme.sh --issue \
--server https://acme.corp.internal/acme/directory \
--ca-bundle /etc/ssl/certs/ca-certificates.crt \
--dns dns_nsupdate \
--dnssleep 1 \
--keylength 3072 \
-d '*.corp.internal'
The full list of plugins is in acme.sh's DNS API documentation.
Renewal
acme.sh installs its own daily cron entry. Enable it once after the first issuance:
acme.sh --install-cronjob
The cron job runs acme.sh --cron daily; certs within 30 days of expiration are renewed automatically. To force a one-off renewal:
acme.sh --renew -d app.corp.internal --force
Revocation
acme.sh --revoke -d app.corp.internal \
--server https://acme.corp.internal/acme/directory \
--ca-bundle /etc/ssl/certs/ca-certificates.crt

