acme-proxy
An ACME (RFC 8555) server in Rust, built on axum. It sits between your internal clients โ certbot, acme.sh, lego, Traefik, Caddy โ and the certificate authority that actually signs, whether that is an embedded local CA, an upstream public CA, or a legacy PKI reached through a script.
๐ Full documentation โ start with the Quick Start.
Current release: 0.5.0. Before 1.0.0 the database schema is the only compatibility guarantee:
migrations/is append-only, so upgrading is a matter of starting the new binary against the existing database. Everything else โ configuration keys, profile names, the JSON admin API, log event names, the CLI โ may still be renamed or removed, and every such change is listed under### Breakingin CHANGELOG.md. Read that section before an upgrade.
Why
Internal hosts need TLS certificates, but they cannot easily satisfy a public CA: they are unreachable from the Internet, and handing every one of them DNS API credentials โ or a scarce commercial EAB credential โ is not an option.
acme-proxy terminates ACME locally. Clients prove control to it, under
whatever policy you configure, and it decides how the certificate is actually
produced.
Signer backends
| Backend | What it does |
|---|---|
local_ca |
Signs directly with an embedded CA (self-generated, or your own intermediate). Publishes a CRL. The issuing key can live in a PKCS#11 token (YubiKey, HSM) instead of a file โ --features hsm. |
relay |
Relays to a real upstream ACME CA, solving the upstream's DNS-01 challenges itself with a single centrally held RFC 2136 TSIG key. One upstream account multiplexed across all your clients. |
custom |
Shells out to a script โ for a legacy PKI, an HSM, or an internal API that does not speak ACME. |
Features
- Full RFC 8555 flow โ account, order, authorization, challenge, finalize,
certificate, plus revocation and
POST-as-GET. - All three challenge types โ
http-01,dns-01,tls-alpn-01, with wildcard support viadns-01. Validation is on by default. - Profiles โ several independent ACME endpoints in one process, over one listener and one database, each with its own signer, filters, challenges and EAB policy. Accounts and orders are isolated per profile.
- Access control โ a policy engine of named checks combined by boolean rules: IP allowlists, forward-confirmed reverse DNS, identifier allow/deny rules, request paths, EAB, an IPAM lookup (NetBox or phpIPAM) asking your inventory whether the client's own address owns the names, and custom script hooks. Checks answer pass/fail/undecided, so an inventory outage degrades to a retryable 500 rather than failing open.
- Extensions โ External Account Binding (ยง7.3.4), key rollover (ยง7.3.5), Renewal Information / ARI (RFC 9773).
- Notifications โ email, an HTTP webhook (Slack, Mattermost, Teams, Telegram and Matrix are configuration, not four backends), or a custom script, on issuance, revocation, account and challenge events.
- Audit trail โ one row per issuance and per refusal, naming the actor, the address it came from, that address's reverse name, the identifiers and the request id. Accounts and orders additionally record where they were created and last seen from. Nothing is ever compared against these; they answer "who asked for this certificate, and from where".
- Admin CLI in the same binary โ accounts, orders, the audit trail, EAB credentials, upstream registration, revocation.
- Web admin (optional, off by default) โ a second listener serving both HTML pages and a JSON API over the same operations, behind password authentication, a TOTP second factor with recovery codes, and a session cookie. Loopback by default; refuses to bind elsewhere without TLS.
- Optional TLS termination, or run it behind a reverse proxy.
- Prometheus metrics (optional) on a third listener of their own โ request, issuance, failure and pool-connection series, plus a shipped Grafana dashboard. The separate port is the design: reaching it is the permission, so your firewall is the control.
- Configuration reload on
SIGHUPโ a rebuild and a swap, not a mutation. Listeners, TLS certificates, logging, profiles, signers and job pacing all move without dropping a connection;database.urlis the only key left that needs a restart. - A durable job queue โ one row per unit of work the server owes itself, so a five-second upstream blip is retried rather than terminally invalidating a client's order, and a delivery outlives the process that queued it.
- Revocation โ
POST /revokeCertby either the account key or the certificate's own key pair, with an RFC 5280 CRL served atGET /crl. - Structured logging โ one
event = "..."field per line, JSON on request, and a request id threaded through every line of a request. See Monitoring.
Quick start
# config.toml
[]
= true # testing only โ see the docs before deploying
[]
The ACME directory is then at
http://localhost:3000/profile/default/directory. Point a client at it:
serve is the default subcommand. Configuration comes from config.toml in the
working directory (or ACME_PROXY_CONFIG), overridden by ACME_PROXY_*
environment variables; config.toml.example documents every key, and the
Configuration Reference
is the same list with the reasoning.
The same binary carries the admin subcommands, so a deployment never needs a second tool:
The full tree is in the Admin CLI chapter.
Installing
From crates.io:
That builds and installs the acme-proxy binary โ server and admin CLI in one
โ into ~/.cargo/bin. It needs the same Rust 1.97 toolchain as a source build,
since it compiles the crate locally; there are no prebuilt binaries yet.
Or from a clone, which is what you want if you intend to change anything:
Or build the container image โ the repository ships a Containerfile:
The image runs as a non-root user, so the mounted directory has to be writable
by it โ :U above is the rootless-Podman shortcut; the deployment guide covers
Docker.
See Installation and Deployment for systemd units, reverse-proxy configuration and where each socket belongs.
Feature flags
One non-default feature: hsm puts the local CA's issuing key in a PKCS#11
token (a YubiKey, an enterprise HSM, or SoftHSM2 for development) instead of a
file on disk, so it can be used but never copied.
Building & testing
Requires Rust 1.97 or newer (edition 2024); the MSRV is rust-version in
Cargo.toml and CI verifies it.
cargo nextest is required rather than preferred: several tests execute script
files they have just written, which fails intermittently with ETXTBSY under
cargo test's thread-per-test model.
Coverage
CI enforces a hard floor of 97% of lines, so a change that adds a branch
generally has to add the test that covers it. main.rs is excluded โ it is
socket and exit wiring, and counting it would move the number without anyone
being able to act on it. The same command locally:
Every CI run publishes the per-file table on its own summary page and attaches
the full report โ lcov.info plus the browsable HTML tree โ as the
coverage-report artifact, including the runs that miss the floor, since those
are the ones worth reading.
A handler carrying
#[instrument]reports far lower coverage than it actually has: the attribute moves the body into a generatedasyncblock, so the body lines carry no region at all. Check the file incargo llvm-cov report --textbefore writing tests against a percentage. See Testing & Coverage.
The end-to-end suite runs real ACME clients against a real server in containers
and is #[ignore]d by default:
Documentation
Two surfaces, for two audiences:
- The book โ the operator documentation: configuration, deployment, every signer and filter, the CLI. Start here.
- docs.rs/acme-proxy โ the Rust API, for embedding the crate or reading the internals. The library exists so the binary and the tests can reach it; it is not a stable published API before 1.0.0.
The book under doc/ is built with mdBook
and published on every push to main:
Contributions welcome โ start with CONTRIBUTING.md, which points at the Contributing chapter.
To report a security issue, see SECURITY.md โ please do not open a public issue.
License
MIT