# acme.sh: the single shell file that renews certs, and what its Docker image changes

> acme.sh is an ACME protocol client written entirely in Unix shell that issues, renews and installs certificates without root access, and the interesting decisions live in what the installer does with your home directory and in the Alpine image that splits one script into thirty commands. Read it before you pipe it into a shell.

**acmesh-official/acme.sh** — A pure Unix shell script ACME client for SSL / TLS certificate automation

- Repository: https://github.com/acmesh-official/acme.sh
- Website: https://acme.sh
- Stars: 47,764 · Forks: 5,678
- Language: Shell
- License: GPL-3.0
- Published: 2026-08-17 · Updated: 2026-08-18 · Language: en
- Canonical page: https://hysenlabs.com/projects/acmesh-official-acme-sh

## One file holds the client, three directories hold the hooks

acme.sh is an ACME protocol client written entirely in Unix shell, with no dependency on Python, and it runs under bash, dash and sh. The protocol side is complete: ECDSA keys, SAN certificates and wildcard certificates all come out of the same script, and that same script issues, renews and installs, which is why the project describes itself as one script rather than a client plus a separate renewal daemon you have to wire together. The repository root is small enough to read in one sitting. Alongside acme.sh there is acme.sh.completion for shell completion, an allowed_signers file, LICENSE.md, CONTRIBUTING.md, and three plugin directories named deploy, dnsapi and notify. One consequence for anyone deciding what runs on their server: the file you can read is not everything that runs, because deploy, dnsapi and notify each ship hooks that acme.sh invokes by name. Neither the README nor LICENSE.md says what allowed_signers is for.

## No root access means your account keys follow your home directory

The client states that it does not require root or sudoer access, and that one decision drives most of the layout. Certificates, account keys and domain keys are written under the home directory of whoever runs the command, so a renewal triggered from cron and one you start over ssh land in the same place only when both run as the same user. The image shows the arrangement the project expects, pointing both the config home and HOME at one path and giving it to a plain unprivileged account:

```bash
RUN addgroup -g 1000 acme && adduser -h $LE_CONFIG_HOME -s /bin/sh -G acme -D -H -u 1000 acme
```

The failure mode this creates is quiet. A root-owned cron job or a systemd timer running as a different user reads a different config directory, finds no account key, and has nothing to renew with. Passing the config home on every call is the way out, and the image takes that route in every wrapper it generates.

## Two ways in: the checked out script or the piped installer

The build prefers the copy in the build context and falls back to the network installer, both in one conditional RUN:

```dockerfile
RUN cd /install_acme.sh && ([ -f /install_acme.sh/acme.sh ] && /install_acme.sh/acme.sh --install || curl https://get.acme.sh | sh) && rm -rf /install_acme.sh/
```

Read the two branches apart. The first runs your checkout, so a fork installs its own code. The second is the piped installer, curl https://get.acme.sh piped into sh, which runs whatever the server returns at that moment and gives you no chance to look at it first. If you want to review before running, fetch the file, read it, then pass --install to your local copy. Either way the script links itself onto your PATH, which is how a bare acme.sh becomes callable, and in the image the target is $LE_WORKING_DIR, set to /acmebin:

```bash
RUN ln -s $LE_WORKING_DIR/acme.sh /usr/local/bin/acme.sh
```

## Thirty verbs become thirty executables with the config home baked in

The build loop turns the single script into one wrapper per subcommand under /usr/local/bin, all sharing one shape:

```dockerfile
printf -- "%b" "#!/usr/bin/env sh\n$LE_WORKING_DIR/acme.sh --${verb} --config-home $LE_CONFIG_HOME \"\$@\"" >/usr/local/bin/--${verb} && chmod +x /usr/local/bin/--${verb} \
```

The list runs from help and version through issue, renew, renew-all, install-cert and revoke, and on into the account and key commands, then set-default-ca and set-default-chain. This is convenient until you debug it. Because the wrapper appends the config home itself, running one with a different HOME or a different LE_CONFIG_HOME changes nothing, and the files you inspect are the ones under the baked-in path. Know which verb you need before you build, because the image has no way to add one you invented.

## supercronic replaces host cron, and the daemon argument writes the crontab

The base is alpine:3.23, and the package list tells you what a run needs at request time: openssl for the crypto, curl for HTTP and DNS API calls, bind-tools, socat, jq and yq-go for parsing provider responses, oath-toolkit-oathtool for the two-factor paths, and supercronic. That last package explains why the container does not expect your host cron. The entrypoint writes $LE_CONFIG_HOME/crontab itself when its first argument is daemon, so the scheduler travels inside the image. The consequence is that a container started with nothing but a subcommand never schedules anything. Renewal and error notifications are a listed feature, and notify/ is where those hook scripts live, which is also where you add your own.

## Two rows of the platform table carry no CI badge

The tested OS table is the useful part of the README. Green CI covers Mac OS X, Ubuntu, Debian, openSUSE, Alpine Linux with curl, Archlinux, fedora, Kali Linux, Oracle Linux, Mageia, Gentoo Linux, FreeBSD, OpenBSD, NetBSD, DragonFlyBSD, MidnightBSD, OmniOS, OpenIndiana, Solaris and Haiku, a spread unusual for a shell script. Two rows are not covered by CI. pfsense is marked NA, and Cloud Linux has no badge at all and points at issue 111 instead. OpenWRT and Proxmox are marked as working by hand, with a wiki page for the first and a stated version range of 4.x, 5.0 and 5.1 for the second. Windows is tested through Cygwin with curl, openssl and crontab included, so treat it as POSIX emulation rather than native support when you budget the work.

## Where acme.sh differs from a packaged client

Searches around this project are mostly comparisons, and the real difference is packaging rather than certificate features. A distribution-packaged client such as certbot arrives as a package with a Python runtime and a plugin set around it, which is comfortable until you need the same client on a machine where that runtime is not already installed. acme.sh is a single file asking for openssl and curl, and the platform table is the evidence that this is not theory: the BSDs, Solaris, OmniOS and OpenWRT all appear in it. The trade-off runs the other way. DNS credentials reach the client as shell environment variables read by dnsapi scripts, and every hook runs with the privileges of the account that scheduled it, so a careless hook has the reach of that account. Against dehydrated, another shell client, the practical difference is again about what you have to assemble yourself, since acme.sh ships its DNS API scripts and install hooks in the same repository.

## The image upgrades itself unless the build turns that off

upgrade is one of the verbs in that wrapper list, and the image enables self-upgrade by default through a build argument:

```dockerfile
ARG AUTO_UPGRADE=1
```

What that costs is reproducibility. A container built without overriding the argument can replace its own client on the next run, so two hosts started a week apart may end up on different protocol behaviour with no change in your manifests. Build with AUTO_UPGRADE=0 and move upgrades into your own release process. The source tree moves fast as well: the last push to master was on 2026-09-27, with tags 3.1.6 on 2026-09-20, 3.1.5 on 2026-09-19 and 3.1.4 on 2026-07-17, so expect small frequent releases instead of a slow major line. The licence is GPL-3.0, which matters to distributions that vendor the script into their own packages.

## Conclusion

Choose acme.sh when the host is a BSD, a Solaris derivative, an OpenWRT box or a container where a Python client is a second runtime to install, and when you are willing to read the installer before running it. Choose a distribution-packaged client instead when you want the distribution to own upgrades and support. Verify two things first: that your renewal runs as the same user that issued the certificate, since account keys sit under that home directory, and whether the container you build leaves AUTO_UPGRADE at 1, because then the client rewrites itself between runs.

## FAQ

### What is acme.sh?

It is an ACME protocol client written purely in Unix shell that issues, renews and installs SSL/TLS certificates automatically, with no dependency on Python. It supports ECDSA keys, SAN and wildcard certificates, and runs under bash, dash and sh.

### How to install acme.sh?

The project's own Docker build runs the script from the checkout with --install, and falls back to piping the installer into a shell otherwise. The script then symlinks itself onto your PATH, so acme.sh becomes callable from your shell.

### How do you use acme.sh with Let's Encrypt?

It speaks the full ACME protocol, so certificate issuance and renewal run through the same shell script rather than a separate tool. The README describes it as a single script that issues, renews and installs certificates automatically.

### Is acme.sh safe to run on a server?

The client is GPL-3.0 licensed, written in shell so you can read it before running it, and does not require root or sudoer access. It does write certificates and account keys under the home directory of the user who runs it, and runs DNS and install hooks from its dnsapi, notify and deploy directories.

### acme.sh vs certbot: which one is different in approach?

certbot is a distribution package built around a Python runtime and a plugin set, while acme.sh is a single shell file that asks for openssl and curl and ships its own DNS API and install hooks. The project also states that it does not require root or sudoer access.

## Sources

- [Official documentation](https://acme.sh)
- [Official README](https://github.com/acmesh-official/acme.sh#readme)
- [Project repository](https://github.com/acmesh-official/acme.sh)
- [Release notes](https://github.com/acmesh-official/acme.sh/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/acmesh-official-acme-sh
