ARC2 Proxy
Rust Programming Language · By Milen Denev

Automatic HTTPS in Rust: Let's Encrypt / ACME with a Dynamic SNI Cert Resolver

Never renew a certificate by hand again. This article shows how to automate the full TLS lifecycle in Rust — driving ACME (Let's Encrypt) with an HTTP-01 challenge, and a dynamic rustls SNI certificate resolver that swaps in freshly issued certs for many domains without ever restarting the server.

Automatic HTTPS in Rust: Let's Encrypt / ACME with a Dynamic SNI Cert Resolver

Introduction

Manually obtaining, installing, and renewing TLS certificates is tedious and error-prone — and a forgotten renewal is one of the most common causes of embarrassing outages. ACME (Automatic Certificate Management Environment, the protocol behind Let's Encrypt) automates the whole lifecycle, and combined with a dynamic SNI certificate resolver in rustls you can serve TLS for many domains from a single server, issuing and renewing certificates on the fly without ever restarting the process. This article walks through how those pieces fit together in Rust.

How ACME works

ACME is a protocol between your server (the "ACME client") and a certificate authority such as Let's Encrypt. In outline:

  • You create an account key and register with the CA.
  • You request a certificate for one or more domains — this creates an order.
  • For each domain the CA issues a challenge that proves you control it.
  • You satisfy the challenge, the CA validates it, and you finalise the order with a CSR.
  • The CA issues the signed certificate, which you install and later renew before it expires.

There are three challenge types, and which you pick shapes your whole design:

ChallengeHow you prove controlBest for
HTTP-01 Serve a token at /.well-known/acme-challenge/<token> over port 80. Single hosts you serve HTTP for; simplest to run.
TLS-ALPN-01 Answer a special TLS handshake on port 443 using the acme-tls/1 ALPN protocol. Servers that only expose 443.
DNS-01 Publish a TXT record under _acme-challenge. Wildcards and hosts not directly reachable.

For a reverse proxy that already terminates HTTP on port 80, HTTP-01 is the natural fit, so that is the path we follow here. In the Rust ecosystem you can drive ACME with crates such as instant-acme, rustls-acme (which bundles the flow with rustls), or a lower-level RFC 8555 client like acme-rfc8555.

The SNI certificate resolver

Server Name Indication (SNI) is the part of the TLS handshake where the client tells the server which hostname it wants. That is the hook that lets one server present a different certificate per domain. In rustls you implement the ResolvesServerCert trait and look up the right CertifiedKey from the SNI value in the ClientHello. Keeping the certificates in a concurrent map means new ones can be inserted while the server is running, and the very next handshake for that domain picks them up:

use std::sync::Arc;
use dashmap::DashMap;
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;

#[derive(Debug, Default)]
pub struct SniResolver {
    // domain -> the certificate + private key currently serving it
    certs: DashMap<String, Arc<CertifiedKey>>,
}

impl SniResolver {
    /// Called by the ACME flow whenever a cert is issued or renewed.
    pub fn install(&self, domain: &str, key: Arc<CertifiedKey>) {
        self.certs.insert(domain.to_string(), key);
    }
}

impl ResolvesServerCert for SniResolver {
    fn resolve(&self, hello: ClientHello) -> Option<Arc<CertifiedKey>> {
        let sni = hello.server_name()?;
        self.certs.get(sni).map(|entry| entry.clone())
    }
}

Because install just writes into the DashMap, renewals are seamless: the background task swaps in a fresh certificate and no connection is dropped. This is exactly the design the SNI story builds on — see our companion article on setting up an Axum server with SNI for the full server-config wiring.

Serving the HTTP-01 challenge

To answer an HTTP-01 challenge, the CA fetches http://your-domain/.well-known/acme-challenge/<token> and expects a specific key-authorization string back. You stash the pending tokens in a shared map and serve them from a route on port 80:

use std::sync::Arc;
use axum::{extract::{Path, State}, http::StatusCode, routing::get, Router};
use dashmap::DashMap;

type Challenges = Arc<DashMap<String, String>>; // token -> key authorization

async fn serve_challenge(
    State(challenges): State<Challenges>,
    Path(token): Path<String>,
) -> Result<String, StatusCode> {
    challenges
        .get(&token)
        .map(|v| v.clone())
        .ok_or(StatusCode::NOT_FOUND)
}

pub fn acme_router(challenges: Challenges) -> Router {
    Router::new()
        .route("/.well-known/acme-challenge/{token}", get(serve_challenge))
        .with_state(challenges)
}

The ACME flow, before it asks the CA to validate, inserts the token and its key authorization into that map; once validation succeeds it removes them. Keeping port 80 open purely for this (and for redirecting everything else to HTTPS) is a common and clean arrangement.

The issuance and renewal loop

Tie it together with a background Tokio task that, per domain, checks whether a valid certificate exists and issues or renews one when needed. The high-level shape:

use std::time::Duration;

async fn maintain_certs(resolver: Arc<SniResolver>, challenges: Challenges, domains: Vec<String>) {
    let mut tick = tokio::time::interval(Duration::from_secs(60 * 60)); // hourly
    loop {
        tick.tick().await;
        for domain in &domains {
            if cert_needs_renewal(domain) {
                match issue_certificate(domain, &challenges).await {
                    Ok(certified_key) => {
                        resolver.install(domain, Arc::new(certified_key));
                        tracing::info!("issued/renewed certificate for {domain}");
                    }
                    Err(e) => tracing::error!("ACME failed for {domain}: {e}"),
                }
            }
        }
    }
}

Inside issue_certificate you run the ACME order: create the order, read back the HTTP-01 challenge, publish the token into the challenges map, tell the CA it is ready, poll until valid, finalise with a CSR (generate the keypair with a crate like rcgen), and download the certificate chain. The result is turned into a rustls CertifiedKey and installed into the resolver.

Practical tips

  • Renew early. Let's Encrypt certificates last 90 days; renew at around 30 days remaining so a transient failure has weeks of runway before it becomes an outage.
  • Persist certificates. Cache issued certs on disk (or a shared store) so a restart doesn't re-request everything and risk hitting the CA's rate limits.
  • Use the staging environment first. Let's Encrypt's staging endpoint has generous limits and untrusted certs — perfect for testing your flow without burning production quota.
  • Install a crypto provider. rustls 0.23 needs a provider (aws_lc_rs or ring) installed before any TLS work, or construction fails at runtime.
  • Handle many domains gracefully. Issue lazily and stagger renewals so you never try to validate hundreds of domains in the same tick.

Conclusion

Automatic HTTPS in Rust comes down to two cooperating pieces: an ACME client that proves domain control and fetches certificates, and a dynamic SNI resolver that lets you swap those certificates in without a restart. Keep the certs in a concurrent map, serve the HTTP-01 challenge from a small route, and run issuance and renewal from a background task. This is precisely how ARC2 Proxy provisions TLS for every domain it fronts — certificates appear and renew themselves invisibly, so "set up HTTPS" stops being a recurring chore and becomes something that just happens.

Keep reading