· 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:
- Issue certs for anything — apex, subdomain, wildcard.
- Work against multiple CAs — not just Let's Encrypt. Rate limits hit, and vendor lock-in on certs is a silly problem to have.
- Do DNS-01 validation against my actual DNS providers (DigitalOcean, Cloudflare, Azure) without installing a separate plugin per provider.
- Auto-renew without me writing a cron job.
- Deploy the renewed cert file into nginx / Docker / wherever, without a second script.
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.

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:
--dns dns_dgon— use the DigitalOcean DNS API for the DNS-01 challenge. No plugin installed;dns_dgon.shis just a function shipped with the script. It readsDO_API_KEYfrom the environment.-d dnsif.ca -d '*.dnsif.ca'— both apex and wildcard on the same cert.--server letsencrypt— the CA to use. Could beletsencrypt,zerossl,buypass,google—acme.shsupports all four out of the box.
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

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:
- Internal-only services with no public web path (
*.nginx.dnsif.carouting to LAN docker containers). - Services that only exist over WireGuard.
- Multiple services on one box behind nginx — each wants its own cert, each needs its own HTTP-01 path.
- A wildcard cert — CAs won't issue wildcards via HTTP-01 at all; DNS-01 is mandatory.
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:
- Set the relevant env vars (e.g.
DO_API_KEY,CF_Token,AZUREDNS_TENANTID,AZUREDNS_APPID). - Pass
--dns dns_<provider>toacme.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

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:
--deploy-hook docker— copies into a specific Docker container and reloads it.--deploy-hook haproxy— appends the cert to an HAProxy PEM bundle.--deploy-hook ssh— rsync the cert over SSH to another host and run a remote reload.
What this runs on, concretely
My lab has one acme.sh installation, on the nginx reverse-proxy host. That single installation holds:
- A wildcard cert for
*.dnsif.ca— covers every new service I stand up. - A wildcard cert for
*.nginx.dnsif.ca— a secondary wildcard used by the reverse-proxy specifically. - A smattering of individual certs for the public-facing bits (
awkto.devis on Cloudflare, butbox.dnsif.caandhooks.dnsif.caare terminated here).
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
- One tool issues certs for every TLS surface in the lab.
- Zero plugins to install or keep up to date.
- Wildcard certs make new services trivial — DNS entry + nginx vhost, the TLS side is already solved.
- Multiple CAs mean a LE rate-limit day doesn't block me.
- Auto-renew + deploy hooks means the whole thing is genuinely set-and-forget.
- The script itself is readable, which, when the TLS stack of everything you run depends on it, is not a small thing.
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.