← all posts

· Infra · 8 min read

acme.sh quietly runs my entire lab's TLS

The goal

Every service I run in the home lab sits behind HTTPS: internal wikis, GitLab, Claude-backed tools, the MCP tunnels. I wanted one tool that could:

Certbot does most of this, but with a lot more Python, virtualenvs, and "you need this plugin for that provider". I wanted something more UNIX.

Meet acme.sh

acme.sh is a single shell script. No Python. No plugins. No compiled binary. You can literally curl | bash the installer, and it drops a ~/.acme.sh/ directory with the script and a systemd / cron timer. That's it.

curl https://get.acme.sh | sh -s email=me@awkto.dev

The whole project philosophy is "be the minimum possible surface area to issue and renew ACME certificates". When you read the script — which is very feasible, it's readable shell — the design choices make sense. DNS provider support is a set of shell functions that use curl against each provider's API. CA support is a config variable.

acme.sh installed

Issuing a wildcard cert in one command

Here's my actual command for the lab's wildcard:

acme.sh --issue \
  --dns dns_dgon \
  -d dnsif.ca -d '*.dnsif.ca' \
  --server letsencrypt

Breaking that down:

Fifteen seconds later I have a cert covering every current and future subdomain in the zone. No nginx restart dance required to serve the challenge, because DNS-01 doesn't touch the web server at all.

[Fri Apr 17 23:47 UTC 2026] Your cert is in: ~/.acme.sh/dnsif.ca_ecc/dnsif.ca.cer
[Fri Apr 17 23:47 UTC 2026] Your cert key is in: ~/.acme.sh/dnsif.ca_ecc/dnsif.ca.key
[Fri Apr 17 23:47 UTC 2026] The intermediate CA cert is in: ~/.acme.sh/dnsif.ca_ecc/ca.cer
[Fri Apr 17 23:47 UTC 2026] And the full-chain cert is in: ~/.acme.sh/dnsif.ca_ecc/fullchain.cer

Wildcard cert issued via DNS-01

Why DNS-01 matters for a home lab

HTTP-01 validation — the default in most ACME tutorials — requires the CA to hit a public URL on port 80 of your server. That's fine for a single public web server. It falls over the moment you have:

DNS-01 sidesteps all of that. acme.sh just calls the DigitalOcean API, drops a _acme-challenge.dnsif.ca TXT record, waits for Let's Encrypt to read it, deletes it, done. The service being certified doesn't have to be reachable from the internet.

I issue certs for stuff that literally isn't on the public web.

"Many providers, no plugins" is the quiet win

Certbot's model is: install certbot, then additionally install certbot-dns-digitalocean, certbot-dns-cloudflare, certbot-dns-azure, each as its own Python package. That's fine until you want to issue against one of the exotic providers and realise the plugin hasn't shipped a wheel in two years.

acme.sh ships over 170 DNS providers baked into the single script. DigitalOcean, Cloudflare, Azure, Route 53, GoDaddy, Gandi, Hetzner, Porkbun, Namecheap, Hurricane Electric, even internal things like nsupdate — all there. Provider-specific logic is implemented as shell functions named dns_<provider>. To use one, you just:

  1. Set the relevant env vars (e.g. DO_API_KEY, CF_Token, AZUREDNS_TENANTID, AZUREDNS_APPID).
  2. Pass --dns dns_<provider> to acme.sh --issue.

That's all the integration. There's nothing to install.

Multiple CAs — insurance worth having

One of the annoying things about the Let's Encrypt monoculture is rate limits. Standard LE limits are generous for a single domain, but the moment you run a lab with many subdomains and you're iterating, you'll hit them — and the "try again in a week" experience is demoralising. Also, CA outages do happen; it's not worth having everything in one bucket.

acme.sh lets you flip CAs per-issue. All four of these work the same way:

acme.sh --issue --server letsencrypt -d example.com  ...
acme.sh --issue --server zerossl     -d example.com  ...
acme.sh --issue --server buypass     -d example.com  ...
acme.sh --issue --server google      -d example.com  ...

When LE rate-limits me during a bad day, I flip to ZeroSSL and keep going. The rest of the pipeline — renewal timer, deploy hook, nginx — doesn't care who signed it.

Auto-renew is configured by default

When the installer runs, it drops a cron entry that triggers acme.sh --cron every day at a randomised time. That command iterates every issued cert, checks expiry, and renews anything within its renew window (default: 60 days in for LE's 90-day certs). No additional setup. You just stop thinking about cert renewal.

You can see the work it's doing any time:

acme.sh --list

acme.sh list of tracked certs

Deploy hooks are where it really pays off

Issuing a cert is only half the job. The other half is getting the shiny new .cer and .key into wherever your TLS terminator expects them — nginx, HAProxy, a Docker container, a Kubernetes secret — and then reloading the service.

acme.sh has a --deploy mechanism that runs a hook script after every successful renewal. There are about fifty built-in hooks. My nginx-on-this-host command is:

acme.sh --deploy \
  -d dnsif.ca \
  --deploy-hook nginx

That sets nginx as the deploy target for this cert. From then on, every time the cert renews, the hook copies the new files into /etc/nginx/ssl/, reloads nginx, and logs the result. I don't have to write an nginx-reload script. I don't have to systemctl restart anything by hand.

Other deploy hooks I use or have used:

What this runs on, concretely

My lab has one acme.sh installation, on the nginx reverse-proxy host. That single installation holds:

All renewed by the daily cron. All deployed by the nginx hook. I haven't touched a cert file manually in over a year.

Results

If you've been stuck with a rickety certbot + pile-of-plugins setup for a while, acme.sh is a genuine "why didn't I do this sooner" moment. It's not flashy. It's just right.