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"
rws

On 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.

Which binaries include ACME

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 domains points 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

  1. Startup. If no certificate exists, or it is due for renewal (see 4), rws runs 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.
  2. 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.
  3. 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.
  4. 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. rws asks 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's Retry-After says 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 same SIGHUP reload used for config). renew_before_days, if set, replaces the two-thirds point (a CA window that opens earlier still wins).
  5. New names. A name added to domains that the stored certificate doesn't cover gets a new order at the next start or check, for all the names (since 18.8.3); before that the old certificate was used until it was due.
  6. Profiles. profile orders a certificate profile the CA offers — Let's Encrypt has classic, tlsserver and shortlived (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 = true

Delete .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 record
dns_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_demand without on_demand_ask is 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 the cert_path), loaded at startup and renewed like the main one; with redis replicas 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.

Edit this page on GitHub