Automatic HTTPS (ACME)
Get and auto-renew Let's Encrypt certificates with one config section — no certbot, no cron, no restarts.
rws obtains certificates from Let's Encrypt (or any ACME CA) by itself and
keeps them renewed. Point a domain's DNS at the server, then:
# rws.config.toml
port = 443
http_redirect_port = 80 # HTTP → HTTPS redirects; also answers ACME challenges
[acme]
domains = ["example.com", "www.example.com"]
email = "you@example.com"
rwsOn first start rws requests a certificate, stores it in .rws/acme/
(cert.pem, key.pem, account.key — keys 0600, the directory 0700,
and never served: the working directory is the web root), and starts serving HTTPS, HTTP/2, and
HTTP/3 with it. Later starts reuse the stored certificate. Renewal happens in
the background and is hot-swapped without dropping connections.
The prebuilt binaries (Homebrew, cargo binstall, Docker, GitHub Releases)
include it. When building from source, enable the acme feature:
cargo install rust-web-server --features acme,auth. A binary built without
it prints a warning if [acme] is configured instead of silently serving
plain HTTP.
Requirements
- DNS for every domain in
domainspoints at this server. - One way for the CA to check you control the domains: port 80 reachable from the internet (HTTP-01, the default), or port 443 (TLS-ALPN-01), or write access to the domain's DNS (DNS-01, also for wildcards) — see Challenge types.
- Binding ports 80/443 on Linux needs root or the capability:
sudo setcap cap_net_bind_service=+ep "$(which rws)".
IPv6 is handled: when a domain has an AAAA record, Let's Encrypt validates over IPv6, so the challenge and redirect listeners accept both IPv4 and IPv6.
How it works
- Startup. If no certificate exists, or it is due for renewal (see 4),
rwsruns the ACME flow before opening the HTTPS port: account (key saved to.rws/acme/account.key), order, HTTP-01 challenge, ECDSA P-256 key + CSR, finalize, download. - Challenges. At startup a temporary listener on the challenge port (default 80) answers them. Once the HTTP → HTTPS redirect listener is running on that port, it answers challenges itself — so renewals never compete with it for port 80.
- First-time failures are retried, e.g. while DNS for a new domain is still propagating: after 30 s, doubling up to every 15 minutes, until a certificate is issued.
- Renewal (since 18.0.0). A certificate is renewed at two-thirds of its
lifetime — day 60 of 90, day 30 of 45, day 4 of 6 — or earlier when the
CA's ACME Renewal Information (RFC 9773)
suggests a window that opens sooner, e.g. after a revocation.
rwsasks for it at every check (every 12 hours at most, every 6 when the CA has renewal information, sooner when renewal is due earlier or the CA'sRetry-Aftersays so) and picks a point in the window that's fixed per certificate. The renewal order names the certificate it replaces, so the CA can exempt it from rate limits. A failed renewal is retried within an hour. After renewing it reloads the TLS certificate in place (on Unix, via the sameSIGHUPreload used for config).renew_before_days, if set, replaces the two-thirds point (a CA window that opens earlier still wins). - Profiles.
profileorders a certificate profile the CA offers — Let's Encrypt hasclassic,tlsserverandshortlived(6-day certificates). A name the CA doesn't list stops provisioning with the list it offers. Unset, the CA picks its default.
The ACME client (RFC 8555) is implemented in-crate. It has been tested end to
end against Pebble, Let's Encrypt's
test CA, including a shortlived profile and renewal on Pebble's renewal
information through the redirect listener.
Testing with the staging CA
Let's Encrypt production has strict rate limits. Try your setup against staging first — its certificates aren't browser-trusted, but the limits are generous:
[acme]
domains = ["example.com"]
staging = trueDelete .rws/acme/ before switching back to production.
Before 17.113.1 the defaults were cert.pem, key.pem and acme_account.key
in the working directory — the web root, so the keys could be downloaded.
On upgrade, files found there are moved into .rws/acme/ (when the default
paths are in use), and the static server no longer serves *.pem, *.key
or rws.config.toml anywhere. Rotate a key that was exposed.
Private ACME CAs
To use step-ca, an internal Boulder, or Pebble, set the directory URL and trust the CA's root certificate (in addition to the public roots):
[acme]
domains = ["internal.example"]
directory = "https://ca.internal.example:9000/acme/acme/directory"
ca_file = "/etc/rws/internal-root.pem"Challenge types
challenge picks how the CA checks that you control the domains (since
18.4.0; HTTP-01 before):
challenge |
The CA checks | Needs | Wildcards |
|---|---|---|---|
http-01 (default) |
http://<domain>/.well-known/acme-challenge/… |
port 80 reachable | no |
tls-alpn-01 |
a TLS handshake on port 443 with ALPN acme-tls/1 (RFC 8737) |
port 443 reachable — nothing else | no |
dns-01 |
a TXT record _acme-challenge.<domain> |
a way to change the domain's DNS | yes |
dns-persist-01 |
a TXT record _validation-persist.<domain> you publish once |
nothing on the server: no port, no DNS credentials | yes |
TLS-ALPN-01
For a server that only listens on 443 — no port 80 at all:
port = 443
[acme]
domains = ["example.com"]
challenge = "tls-alpn-01"rws answers on its HTTPS port: at startup, before it has a certificate, a
temporary acceptor on the same socket serves the challenge; afterwards the
TLS listener does, beside the normal traffic. A challenge handshake gets a
special self-signed certificate and is then closed; other clients never
see it. If a port mapping forwards 443 elsewhere, set port to where it
lands.
DNS-01
The CA looks up a TXT record, so nothing needs to reach the server — and it
is the only way to get a wildcard certificate (*.example.com). rws adds
the record, waits until the zone's server answers with it, lets the CA check,
and removes it.
[acme]
domains = ["example.com", "*.example.com"]
challenge = "dns-01"
dns_provider = "rfc2136" # DNS UPDATE (RFC 2136) to the primary server
dns_server = "ns1.example.com" # host[:port], port 53
dns_tsig_key = "rws-acme" # TSIG key name the server knows
dns_tsig_secret = "base64secret==" # better in RWS_CONFIG_ACME_DNS_TSIG_SECRET
# dns_tsig_algorithm = "hmac-sha256" # or hmac-sha512
# dns_zone = "example.com" # found by SOA lookup when not set
# dns_propagation_secs = 120 # how long to wait for the recorddns_provider |
Adds and removes the record by |
|---|---|
rfc2136 |
a DNS UPDATE to dns_server, signed with the TSIG key — BIND, Knot, PowerDNS, Windows DNS, and many DNS hosts' "dynamic DNS" endpoints |
cloudflare |
Cloudflare's API, with an API token (dns_api_token) |
route53 |
Amazon Route 53's API, signed with the credentials in AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN) |
exec |
running dns_exec as <program> present <fqdn> <value> and <program> cleanup <fqdn> <value>: any DNS API, in a script (exit status 0 = done) |
A BIND key and the zone permission it needs:
key "rws-acme" { algorithm hmac-sha256; secret "base64secret=="; };
zone "example.com" { type primary; file "example.com.zone";
update-policy { grant rws-acme name _acme-challenge.example.com. TXT; }; };(tsig-keygen rws-acme prints a new key.) The secret is never printed in
rws's startup log.
Cloudflare
[acme]
domains = ["example.com", "*.example.com"]
challenge = "dns-01"
dns_provider = "cloudflare"
# dns_api_token = "..." # better in RWS_CONFIG_ACME_DNS_API_TOKEN; never printed
# dns_zone_id = "023e105f4ecef8ad9ca31a8372d0c353"Create the token under My Profile → API Tokens with Zone → DNS → Edit
for the zone. rws finds the zone by asking for example.com's parents in
turn, which also needs Zone → Zone → Read — or set dns_zone_id (the
zone's overview page shows it) and the DNS permission is enough. rws
replaces the _acme-challenge TXT records and deletes its own afterwards.
Route 53
[acme]
domains = ["example.com", "*.example.com"]
challenge = "dns-01"
dns_provider = "route53"
# dns_zone_id = "Z0123456789ABCDEFGHIJ"Credentials come from the environment the AWS tools use —
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and, for temporary ones,
AWS_SESSION_TOKEN (instance roles and profiles in ~/.aws aren't read:
export them, e.g. eval "$(aws configure export-credentials --format env)").
The IAM policy needs route53:ChangeResourceRecordSets and
route53:GetChange, plus route53:ListHostedZonesByName unless
dns_zone_id is set:
{"Version": "2012-10-17", "Statement": [
{"Effect": "Allow", "Action": ["route53:ChangeResourceRecordSets"], "Resource": "arn:aws:route53:::hostedzone/Z0123456789ABCDEFGHIJ"},
{"Effect": "Allow", "Action": ["route53:GetChange", "route53:ListHostedZonesByName"], "Resource": "*"}]}rws waits for Route 53 to report the change INSYNC, then for the record
to be visible, as with every provider (dns_server, or the system resolver
— set dns_server to one of the zone's name servers to skip resolver
caches).
CI tests both against a stand-in API that writes into BIND, with the
request signatures checked independently, and Pebble validating
(ci/e2e/acme_dns_api.sh).
DNS-PERSIST-01
The CA looks up one TXT record that you publish once and leave in place: it names the CA and your ACME account, so nothing on the server needs DNS credentials or an open port, and renewals need no DNS change at all (draft-ietf-acme-dns-persist; Let's Encrypt is rolling it out — Pebble has it).
[acme]
domains = ["example.com", "*.example.com"]
challenge = "dns-persist-01"
# dns_server = "ns1.example.com" # where to look for the record (else the system resolver)The first time, rws creates its ACME account and logs the record to publish (one line):
[ACME] Provisioning failed: dns-persist-01: publish this TXT record once (it stays; rws retries until it's visible): _validation-persist.example.com. TXT "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123456; policy=wildcard"Publish it at your DNS host; rws retries on its own and issues as soon as
it sees the record. policy=wildcard is there because *.example.com is
in domains. The record belongs to the ACME account, so keep
account_key_path's file (.rws/acme/account.key) — with a new key comes a
new account, and a new record to publish. Before asking the CA, rws checks
that the record is visible, so a missing one never costs a failed
validation. dns_provider is ignored: rws never changes this record.
On-demand TLS
For a SaaS whose customers point their own domains at it: rws orders a certificate at the first TLS handshake for a name nobody configured, once your ask endpoint allows that name (since 18.4.0).
http_redirect_port = 80 # answers HTTP-01
[acme]
domains = ["saas.example.com"] # rws's own certificate, the default
on_demand = true # RWS_CONFIG_ACME_ON_DEMAND
on_demand_ask = "http://127.0.0.1:9000/allowed" # RWS_CONFIG_ACME_ON_DEMAND_ASK- Before each order rws asks
GET <on_demand_ask>?domain=<name>; a 2xx answer allows it, anything else (or no answer in five seconds) refuses it. Answer from your list of customer domains.on_demandwithouton_demand_askis refused: anyone could make rws order certificates for every name they point at it. - The first handshake for a new name waits for its certificate (up to 90 seconds; with Let's Encrypt usually a few). A refused name gets the default certificate and isn't asked about again for a minute; a failed order isn't retried for ten. At most four orders run at once, and one per name however many clients arrive together.
- Certificates are kept in
.rws/acme/on-demand/<name>/(next to thecert_path), loaded at startup and renewed like the main one; withredisreplicas share them too. - HTTP-01 or TLS-ALPN-01 (DNS-01 would need every customer's zone). Over HTTP/3 a name is served once a TCP handshake has ordered it.
CI checks it against Pebble with an ask endpoint (ci/e2e/acme_on_demand.sh):
an allowed name is served its certificate on the first request, a refused
one the default, five simultaneous first requests make one order, and a
restart orders nothing.
Several replicas
Replicas of one site behind a load balancer each run ACME for the same domains. On their own they would each order a certificate — hitting the CA's rate limits — and each answer only the challenges they presented, while the CA's request may reach another replica. Give them one Redis (since 18.4.0):
[acme]
domains = ["example.com"]
redis = "10.0.0.20:6379" # RWS_CONFIG_ACME_REDIS
# redis_password = "..." # RWS_CONFIG_ACME_REDIS_PASSWORD; never printed
# redis_prefix = "rws:acme" # several sites in one Redis- One replica orders each certificate (a lock in Redis, held up to five minutes); the others wait for it and install it.
- A renewal by any replica reaches the rest at their next renewal check, which they hot-swap like their own.
- HTTP-01 and TLS-ALPN-01 challenges are answered by every replica, from Redis, wherever the CA's request lands.
- Redis down: each replica carries on alone with its own files, and says so in its log.
The certificate's private key is stored in Redis: keep Redis on a private
network (and set a password). CI runs two replicas against one Redis and
Pebble (ci/e2e/acme_shared.sh): one order per certificate, both serving
the same one before and after a renewal.
Configuration reference
Every setting can go in the [acme] section of rws.config.toml, on the
command line (--acme-domains=...; not the last three), or in the environment.
[acme] key |
Environment variable | Default | Description |
|---|---|---|---|
domains |
RWS_CONFIG_ACME_DOMAINS |
— | Domains for the certificate. Setting this enables ACME. |
email |
RWS_CONFIG_ACME_EMAIL |
— | Contact email for expiry notices from the CA |
staging |
RWS_CONFIG_ACME_STAGING |
false |
Use the Let's Encrypt staging CA |
directory |
RWS_CONFIG_ACME_DIRECTORY |
Let's Encrypt | ACME directory URL of another CA |
ca_file |
RWS_CONFIG_ACME_CA_FILE |
— | Extra root CA(s) to trust for the directory (private CAs) |
challenge |
RWS_CONFIG_ACME_CHALLENGE |
http-01 |
http-01, tls-alpn-01, dns-01 or dns-persist-01 — see Challenge types |
challenge_port |
RWS_CONFIG_ACME_CHALLENGE_PORT |
80 |
Port the CA connects to for HTTP-01 |
dns_provider |
RWS_CONFIG_ACME_DNS_PROVIDER |
— | DNS-01: rfc2136, cloudflare, route53 or exec |
dns_server |
RWS_CONFIG_ACME_DNS_SERVER |
— | DNS-01: rfc2136's primary server, host[:port]; otherwise optional: where to look for the record (else the system resolver) |
dns_api_token |
RWS_CONFIG_ACME_DNS_API_TOKEN |
— | DNS-01 cloudflare: the API token (no command-line flag; never printed) |
dns_zone_id |
RWS_CONFIG_ACME_DNS_ZONE_ID |
looked up | DNS-01 cloudflare / route53: the zone's id |
dns_api_url |
RWS_CONFIG_ACME_DNS_API_URL |
the provider's | DNS-01 cloudflare / route53: another API base URL (a proxy, a test double) |
dns_zone |
RWS_CONFIG_ACME_DNS_ZONE |
SOA lookup | DNS-01: the zone of the records |
dns_tsig_key |
RWS_CONFIG_ACME_DNS_TSIG_KEY |
— | DNS-01 rfc2136: TSIG key name |
dns_tsig_secret |
RWS_CONFIG_ACME_DNS_TSIG_SECRET |
— | DNS-01 rfc2136: TSIG secret, base64 (no command-line flag; never printed) |
dns_tsig_algorithm |
RWS_CONFIG_ACME_DNS_TSIG_ALGORITHM |
hmac-sha256 |
DNS-01 rfc2136: hmac-sha256 or hmac-sha512 |
dns_exec |
RWS_CONFIG_ACME_DNS_EXEC |
— | DNS-01 exec: the program |
dns_propagation_secs |
RWS_CONFIG_ACME_DNS_PROPAGATION_SECS |
120 |
DNS-01: how long to wait for the record to be visible |
redis |
RWS_CONFIG_ACME_REDIS |
— | Redis host:port shared by replicas — see Several replicas |
redis_password |
RWS_CONFIG_ACME_REDIS_PASSWORD |
— | Its password (no command-line flag; never printed) |
redis_prefix |
RWS_CONFIG_ACME_REDIS_PREFIX |
rws:acme |
Key prefix in that Redis |
on_demand |
RWS_CONFIG_ACME_ON_DEMAND |
false |
Order certificates at the first handshake — see On-demand TLS |
on_demand_ask |
RWS_CONFIG_ACME_ON_DEMAND_ASK |
— | URL asked ?domain=<name> before each such order; 2xx allows it |
cert_path |
RWS_CONFIG_ACME_CERT_PATH |
tls_cert_file, else .rws/acme/cert.pem |
Where the certificate chain is stored |
key_path |
RWS_CONFIG_ACME_KEY_PATH |
tls_key_file, else .rws/acme/key.pem |
Where the certificate key is stored (0600) |
renew_before_days |
RWS_CONFIG_ACME_RENEW_BEFORE_DAYS |
— | Renew this many days before expiry instead of at two-thirds of the lifetime (was 30 before 18.0.0) |
profile |
RWS_CONFIG_ACME_PROFILE |
— | Certificate profile to order (classic, tlsserver, shortlived, … as the CA offers) |
renew_check_interval_secs |
RWS_CONFIG_ACME_RENEW_CHECK_INTERVAL_SECS |
43200 |
Longest time between renewal checks |
account_key_path |
RWS_CONFIG_ACME_ACCOUNT_KEY_PATH |
.rws/acme/account.key |
Where the ACME account key is stored (0600) |
Top-level http_redirect_port (RWS_CONFIG_HTTP_REDIRECT_PORT) starts the
HTTP → HTTPS redirect listener; set it to the challenge port (80) in
production.
Using ACME from Rust
The rws binary does all of the above. In your own binary built on the
library, the equivalent is:
use rust_web_server::acme::{AcmeConfig, AcmeManager};
use rust_web_server::prelude::*;
#[tokio::main]
async fn main() {
let (listener, pool) = Server::setup().unwrap();
if let Some(cfg) = AcmeConfig::from_env() {
// Serve the issued certificate unless TLS paths were set explicitly.
cfg.install_as_tls_certificate();
let mgr = AcmeManager::new(cfg);
mgr.provision_until_ready().await; // retries until issued
tokio::spawn(mgr.run_renewal_loop());
}
let app = App::new();
tokio::join!(
Server::run_tls(listener, pool, app.clone()),
Server::run_quic(app),
Server::run_redirect(), // answers renewal challenges
);
}AcmeManager::provision_if_needed() (single attempt, returns the error) is
also available if you want to handle failures yourself.