deploying post-quantum tls to a $6 vps without losing my mind
deploying post-quantum tls to a $6 vps without losing my mind
jamesmunsch.com now negotiates X25519MLKEM768 at the TLS
1.3 layer. hybrid ECDH plus ML-KEM-768, the NIST post-quantum lattice
KEM. if someone is hoovering up traffic today to crack it with a quantum
computer in ten years, my little static site is no longer a free
lunch.
it took 38 lines of nginx config, a Containerfile, and about three days of reading OpenSSL changelogs, most of which i didn’t need.
If you’d like see a working example of what i mean, checkout: https://github.com/allen-munsch/post-quantum-nginx
why i started
i’d known about “harvest now, decrypt later” for a bit now. encrypted traffic gets stored today. when a big enough quantum computer shows up, the classical key exchange gets broken retroactively. the symmetric session key drops out. everything you ever sent over TLS is plaintext.
the fix is post-quantum key exchange. the problem was the tooling.
until recently, deploying PQC meant compiling liboqs from source,
installing oqs-provider, setting OPENSSL_CONF, patching
systemd units, and praying nothing broke on the next
apt upgrade. heavy machinery for a personal site.
then Debian 13 shipped.
the thing that made it trivial
Debian 13 (Trixie) ships OpenSSL 3.5, which includes
NIST-standardized PQC algorithms in the default provider. not a
third-party engine. not a dynamically loaded shared object. the default
provider. you don’t need OPENSSL_CONF. you don’t need
liboqs. you don’t need oqs-provider.
the oqs-provider approach was the standard path the last couple
years, and it meant maintaining a parallel crypto stack. rebuild on
every OpenSSL minor. hope the ABI didn’t shift. keep liboqs patched
against the side-channel papers that drop every few months. Debian 13
collapsing all of that into apt install nginx openssl is
less scary.
the magic line in nginx:
ssl_ecdh_curve X25519MLKEM768:SecP256r1MLKEM768:X25519:prime256v1;
nginx reads the curve list, OpenSSL 3.5 recognizes the PQC group names, TLS 1.3 negotiates them during the handshake. no plugins. no config.d snippets. no environment variables.
the three days i didn’t need
the actual work took about 45 minutes. the three days of reading beforehand were the tax on not trusting the documentation.
i read the OpenSSL 3.5 release notes three times looking for caveats. ML-KEM-768 is NIST FIPS 203, finalized in August 2024, but was there a draft-version interop problem? no, OpenSSL uses the final standard. were there known side channels? lattice KEMs have had timing attacks against reference implementations, but OpenSSL’s is constant-time. did chrome and firefox support the named groups? not yet for most users, but TLS negotiates the strongest mutually-supported curve, so PQC-capable clients get quantum-safe key exchange and everyone else falls back to X25519. no regression.
i spent an afternoon convincing myself classical certificates were fine. TLS 1.3 has two layers: the KEM, which establishes the symmetric session key, and the signature, which proves the server owns the certificate. PQC only changes the KEM today. the certificate is still ECDSA, signed by Let’s Encrypt’s ECDSA chain.
this checks out. an attacker recording traffic can retroactively break the classical key exchange once a quantum computer exists. they can’t retroactively forge a certificate signature, that requires a quantum computer during the handshake, a much harder problem. so PQC KEM now, PQC certs later. Let’s Encrypt is targeting 2027 for Merkle Tree Certificates. the two layers are independent, but i had to trace through the handshake to believe it.
the Containerfile
when you build a container for something new, the temptation is to over-engineer. multi-stage builds, custom entrypoints, health checks, env vars for every tunable. i resisted.
FROM debian:13
RUN apt-get update && apt-get install -y --no-install-recommends \
nginx openssl ca-certificates certbot python3-certbot-nginx cron
COPY nginx-pqc.conf /etc/nginx/sites-available/default
EXPOSE 443 80
CMD ["nginx", "-g", "daemon off;"]
16 lines. the only thing it does that a normal nginx container
doesn’t is set ssl_ecdh_curve to include PQC groups. the
self-signed dev cert is classical ECDSA, same as production. certbot
works exactly as it always has. certs are mounted from the host, same as
any nginx deployment.
i included a Quadlet file so podman can run it as a systemd unit with
auto-update. i’ve learned the hard way that if a service isn’t managed
by systemd, i forget it exists until it breaks.
podman auto-update pulls new images and restarts the
container on a timer. not kubernetes, but honest.
testing it
echo Q | openssl s_client -groups X25519MLKEM768 -tls1_3 \
-connect localhost:443 2>&1 | grep 'Negotiated TLS1.3 group'
expected output:
Negotiated TLS1.3 group: X25519MLKEM768
this works from any host with OpenSSL >= 3.2, which means Debian
13, Ubuntu 24.10+, or a throwaway container. i tested inside a
debian:13 podman container so i didn’t need PQC-capable
openssl on my workstation. the inner container apt-get installs openssl
and connects to the outer container through podman’s host networking.
the test environment is the same as production, bootstrapped from
nothing in about 10 seconds.
the classical fallback also works:
curl -sk https://localhost/ just connects. your browser
doesn’t know about ML-KEM-768 and it doesn’t need to. the handshake
picks X25519, same as every other nginx server on the internet. life
goes on.
what i didn’t do
i didn’t set up a separate PQC endpoint. i didn’t add a “beta” subdomain with PQC enabled for testing. i didn’t write an ansible role or a helm chart. the point was to make PQC the default, not a feature flag.
i also didn’t touch the system openssl config. no
/etc/ssl/openssl.cnf edits, no OPENSSL_MODULES
paths, no provider_sect stanzas. OpenSSL 3.5 ships with the
PQC algorithms in the default provider. adding configuration would only
add things to break.
the one thing i’m deferring is PQC certificates. Let’s Encrypt announced Merkle Tree Certificates in June 2026, staging later this year, production in 2027. when those arrive, the server will need an OpenSSL update, and Debian 13’s OpenSSL 3.5 will get backported patches. the KEM protection deployed today remains valid regardless of future cert format changes. the threat model says KEM first, certs second. KEM is done.
what this means
if you’re reading this on a browser that speaks
X25519MLKEM768, your connection to this page is
quantum-safe at the key exchange layer. the symmetric session key can’t
be retroactively broken by a future quantum computer, because the key
exchange itself was post-quantum.
if you’re on a browser that doesn’t speak it yet, you fall back to classical ECDHE. no breakage. no “please upgrade your browser” banners. no TLS-terminating middleboxes melting down because they don’t recognize the curve ID.
this is the kind of security improvement that should be boring. a distro upgrade and a config line. with Debian 13 and OpenSSL 3.5, it finally is.
the part i’m still thinking about
three years ago, deploying PQC on a personal site meant compiling a research-grade C library, installing a third-party OpenSSL provider, and accepting that you were running experimental cryptography on your production server. hard to justify for a static site.
now it’s one config line on a stable Debian release. the cryptography didn’t get simpler. ML-KEM-768 is still a lattice-based KEM with a dense parameter set and a security proof that takes a semester to understand. but the packaging caught up. the infrastructure people did the hard work so the rest of us don’t have to think about it.
there’s a lesson here about how security improvements ship. it’s not the paper. it’s not the standard. it’s the distro. it’s the people.