nginx-autocert-module: Automatic TLS Certs, No Certbot

Let’s be honest about how most of us do TLS certificates on NGINX. You install certbot. You wire up a cron job — or a systemd timer, if you’re feeling fancy. You pick a challenge plugin, point it at a webroot, cross your fingers, and bolt on a deploy hook that runs nginx -s reload afterwards. Then you spend the next two years quietly hoping none of those four moving parts ever drifts out of alignment, because when a renewal silently fails you usually find out from a customer, at 2 a.m., via a browser screaming about an expired certificate.

nginx-autocert-module deletes that entire apparatus. It’s an open-source dynamic module — one we wrote ourselves — that builds a complete ACME client into NGINX itself. You write one directive, autocert on;, and the server obtains, serves, and renews its own Let’s Encrypt certificates. No certbot. No cron. No reload. This is the complete guide to what it is, how it pulls that off, every directive it understands, and why a handful of its design choices are genuinely interesting rather than merely convenient.

It’s a long read, because this is the page we want to be the reference for the module. Grab a coffee. We’ll start from absolute zero and end somewhere only the people who hold the private keys usually get to stand.

What is ACME, and what is nginx-autocert-module?

Quick refresher, because we promised to start from nothing. ACME — Automatic Certificate Management Environment — is the protocol Let’s Encrypt uses to hand out TLS certificates. Instead of filling in a form and paying a certificate authority, your server proves it controls a domain by answering a challenge, and the CA signs a certificate in return. That’s the whole idea: control the domain, get the cert.

Certbot is just one program that speaks ACME. It’s not the protocol — it’s a client. A perfectly good one, but a separate, standalone thing that lives next to your web server and has to be told, repeatedly, what your web server is doing.

nginx-autocert-module is a different ACME client — one that happens to live inside your web server. It ships as two small .so files you load with load_module. Once loaded, it reads the server_names you already declared in your vhosts, asks Let’s Encrypt for a certificate for each, serves them, and renews them on a schedule it runs internally. You don’t maintain a list of domains anywhere. Your existing config is the list. Add a vhost, get a certificate. Delete it, stop renewing. The config is the single source of truth, the way it always should have been.

If you’ve read our guide to the Angie web server, you’ll know Angie — and commercial NGINX Plus — already ship a native acme directive. Open-source mainline NGINX has nothing of the sort. This module fills exactly that gap: it gives plain, free, upstream NGINX the one feature people most often reach for a paid fork or a sidecar tool to get.

The one line that does everything

Here’s a complete, working TLS server. Read it twice, because the thing that’s missing is the entire point:

http {
    resolver 1.1.1.1;                 # the helper needs DNS to reach the CA
    autocert on admin@example.com;    # global ACME contact

    server {
        listen 443 ssl;
        server_name example.com www.example.com;
        autocert on;                  # both names get a certificate
    }
}

Notice there is no ssl_certificate line. None. And here’s the thing every NGINX admin’s brain just flagged: normally NGINX flatly refuses to start a listen ssl; server without one. It’s a hard, boot-stopping error. So how does this even come up?

The module seeds the server with a throwaway self-signed bootstrap certificate the instant NGINX starts. The listener comes up immediately, handshakes succeed (your browser will grumble about the self-signed cert for a few seconds, that’s expected), and the very first time a real certificate lands on disk it gets swapped in transparently. Your server is never down waiting for issuance. And if you do already have an ssl_certificate line — say you’re migrating from certbot — the module keeps it as the pre-issuance fallback and overrides it per-connection once the real Let’s Encrypt cert arrives. Zero-downtime by construction.

Why put the ACME client inside the server at all?

Reasonable question. Certbot works. Millions of servers run it. Why reinvent the wheel and stuff it inside NGINX?

Three reasons, and they compound.

One: the config is already the source of truth. An external client has to be told your domains — a CLI flag, a config file, an Ansible template, something that has to be kept in sync with your actual vhosts by hand. Every “I forgot to add the cert for the new subdomain” outage traces back to that gap. When the client lives in the server, the gap can’t exist. The names it provisions are literally the names the server is configured to answer for.

Two: no reload on renewal. We’ll dedicate a whole section to this below, because it’s the headline trick — but the short version is that an in-server client can swap a renewed certificate into a live listener without an nginx -s reload. An external client fundamentally cannot; it can only rewrite a file and ask NGINX to re-read it.

Three: privilege separation done properly. Certbot runs as root (or close to it), writes keys to disk, and trusts a deploy hook to do the reload. The module instead isolates all the dangerous bits — network chatter with the CA, private keys, the renewal clock — into a single dedicated helper process, while the workers that actually face the internet never touch a key. More on that next, because it’s the part security people will care about most.

How it actually works under the hood

This is where the module gets opinionated in a good way. All the ACME machinery — talking to Let’s Encrypt, holding private keys, running the renewal clock — lives in one dedicated helper process that the NGINX master spawns alongside your workers. That separation is deliberate and it matters more than it sounds.

The crucial property: workers never touch a private key. They serve TLS, sure, but the account key and every certificate’s private key live only in the helper. If a worker is ever compromised through some handler bug — a buffer overrun in a third-party module, say — the attacker finds no key material to steal in that process. The keys simply aren’t there. This is the same isolation principle behind OpenSSH’s privilege-separated child or Postfix’s per-service users: keep the thing that holds the secret away from the thing that eats untrusted bytes off the wire.

And the helper isn’t a hacked-up background thread or a forked shell script. It runs NGINX’s own event loop. It registers with the master over the same channel the workers use, it answers the same QUIT / TERMINATE / REOPEN signals, and if it crashes the master respawns it exactly the way it respawns a dead worker. It behaves like a first-class NGINX process because, structurally, it is one. (Getting that right was the single hardest part of building this thing — NGINX really does not expect a non-worker process on its event loop. The gory details are in the project’s commit history if you enjoy that sort of pain.)

When a name needs a certificate, the helper walks the full RFC 8555 dance, end to end:

  1. Register (or reuse) an ACME account, keyed by an ECDSA account key.
  2. Open an order listing the domains it wants certified.
  3. Fetch the authorization for each domain and answer its domain-control challenge.
  4. Poll the CA until validation flips to valid.
  5. Send a certificate signing request (CSR) built fresh for that domain.
  6. Poll the order until it’s valid, then download the signed chain.

It writes the result atomically into a per-domain directory: private key at 0600, full chain at 0644, all under a root-only 0700 store. Workers read only the certificate and chain, only at handshake time, never the account key. Atomic write means a renewal half-finishing — power loss mid-download — never leaves a worker reading a truncated certificate; it sees the old complete one until the new complete one is fully in place.

nginx-autocert-module architecture: a privileged helper process runs the ACME order flow and holds the private keys while NGINX workers serve certificates per-SNI at the TLS handshake

The headline trick: no reload on renewal

Here’s the feature that makes this more than a certbot reskin, and the reason an in-server client is worth the trouble.

With the traditional stack, renewing a certificate means rewriting a file and then telling NGINX to reload so it re-reads that file. At one or two domains, who cares. At a few hundred, that reload becomes a recurring, slightly racy event you’d really rather not fire off a timer: every reload spins up a fresh set of workers, drains the old ones, and for a beat your connection-handling capacity wobbles. Do it for a cert renewal and you’re paying a whole-server hiccup to update one file.

nginx-autocert-module doesn’t reload at all. Certificates are loaded per-SNI, at the TLS handshake, straight from disk — and a worker only re-reads a certificate file when its modification time changes. So when the helper renews a certificate, it just writes the new file. The next client to connect for that hostname triggers a fresh read; everyone already connected keeps using the cached one. The renewal takes effect on the next handshake, with zero reloads, zero dropped connections, and zero capacity wobble.

The clock that drives all this lives in the helper. It sweeps periodically, checks each certificate’s expiry, marks one “due” once it enters the autocert_renew_before window — 7 days by default — and reissues it. One order at a time, so a server with hundreds of domains doesn’t open hundreds of simultaneous ACME orders and trip a rate limit. Quiet, paced, and invisible until the day you notice you haven’t thought about certificate expiry in months.

Two ways to prove you own the domain

ACME needs you to demonstrate control of each domain before it’ll sign anything. The module supports two challenge types, and you pick with a single directive.

HTTP-01 — the default

HTTP-01 serves a magic token at /.well-known/acme-challenge/<token> on port 80. The CA fetches that URL; if it gets the right token back, you’ve proven control. The module answers it with a built-in handler — no location block, no webroot directory, no separate listener to babysit. The token lives in shared memory and vanishes the instant validation passes. It’s the path of least resistance and it works for the overwhelming majority of setups.

TLS-ALPN-01 — for the port-80 refuseniks

TLS-ALPN-01 (RFC 8737), set with autocert_challenge tls-alpn-01;, is the cooler one. It validates entirely inside the TLS handshake. When Let’s Encrypt connects negotiating the special acme-tls/1 ALPN protocol, the module presents a one-off certificate carrying a magic extension, and that is the proof — no HTTP request, no port 80 involved at all.

The payoff: you never have to open port 80. For anyone running a locked-down, HTTPS-only edge — where 80 is firewalled shut on principle — that’s a genuine win, not a party trick. It was also surprisingly fiddly to implement correctly, because NGINX doesn’t give you a polite way to chain its ALPN selection callback; you have to thread your handshake-time certificate in without breaking the normal path. Worth it, though.

ECDSA only — and the CA is treated as the enemy

Two security decisions worth calling out, because they’re the kind of thing that separates “a tool that works” from “a tool you’d trust with your private keys.”

First: there is no RSA anywhere. Account keys and certificate keys are ECDSA P-384 by default (P-256 if you ask via autocert_key_type secp256r1;). Smaller keys, faster signatures, less work on the handshake hot path — and it’s squarely where the CAs and the wider ecosystem are heading. If you care about modern crypto on your edge, you’ll also enjoy our write-ups on getting an A+ on SSL Labs and post-quantum TLS on NGINX and Angie.

Second — and this is the part security folks will appreciate — the module treats everything the CA says as hostile network input. That sounds paranoid until you remember the CA’s responses arrive over the network, and anything that arrives over the network can be tampered with, malformed, or hostile if the connection is ever compromised. So the module ships its own depth-bounded JSON parser and a strict base64url decoder that rejects garbage rather than silently truncating it — which is exactly what NGINX’s permissive built-in decoder would happily do, and exactly the kind of silent-truncation behaviour that turns into a security bug three years later. Every single ACME request verifies the CA’s certificate chain and hostname; there is no “skip verification” escape hatch, not even a hidden one for testing. A full security proofread of roughly ten thousand lines of C turned up zero memory-safety, parser, or TLS-verification bugs. For a thing that parses attacker-reachable input and holds your private keys, that paranoia is precisely the right amount.

The directive reference

Everything you can configure, in one place. Most people will only ever touch the first one.

Directive Context What it does
autocert on|off [email]; http, server Master switch. Global form sets the ACME contact email; per-server form opts that vhost into issuance for its server_names.
autocert_challenge http-01|tls-alpn-01; http, server Domain-control method. Default http-01. Use tls-alpn-01 to avoid opening port 80.
autocert_key_type secp384r1|secp256r1; http, server Certificate key curve. Default secp384r1 (P-384). These are the OpenSSL curve names — not p384/p256.
autocert_renew_before time; http, server How long before expiry to renew. Default 7 days.
autocert_path dir; http Root of the certificate store. Per-domain dirs live underneath, root-only (0700).
autocert_resolver addr; http DNS resolver the helper uses to reach the CA. Falls back to the http{} resolver if unset.
autocert_resolver_timeout time; http Timeout for those DNS lookups.
autocert_ca_certificate file; http Trust anchor for verifying the CA endpoint. For pointing at a staging or private ACME server.

The canonical, always-current reference lives in the project’s README — see the link at the end — but the table above covers everything in normal use.

How it compares: certbot, Angie, and this module

Not a fight — a “pick the right tool” table. All three speak ACME; they differ in where the client lives and what that buys you.

certbot + cron Angie native acme nginx-autocert-module
Where the client runs External process Inside the server Inside the server (helper process)
Reload on renewal Yes (deploy hook) No No
Domain list source Maintained by hand Server config Server config (server_name)
Works on mainline NGINX Yes n/a (it’s a fork) Yes
Keys isolated from workers n/a (no workers) Partial Yes (dedicated helper)
RSA option Yes Yes No — ECDSA only
Wildcards / DNS-01 Yes Yes Not yet

The honest summary: if you’re on Angie, use Angie’s built-in acme — it’s excellent and we test that this module still compiles there but deliberately don’t run it. If you need wildcards or DNS-01 today, certbot is still your friend. If you’re on mainline NGINX and you want the in-server, no-reload, key-isolated experience without switching forks, this module is the one that didn’t exist until we wrote it.

What it doesn’t do (yet)

Honesty section, because cornerstone pages that only list strengths age badly. Today the module does full issuance and renewal on mainline NGINX, both challenge types, ECDSA keys, and robust rate-limit handling — it even honours Let’s Encrypt’s Retry-After header and backs off per-domain so it never hammers the CA. What it doesn’t do yet:

  • Wildcards and DNS-01. Not supported. The “use the names already in your config” model doesn’t cover *.example.com, which fundamentally needs DNS-based validation — a different challenge type that proves control of the whole zone, not one hostname.
  • Multiple CAs / EAB. One CA, one account at a time. No External Account Binding for commercial ACME CAs yet.
  • Angie. The module compiles against Angie — we test that on every change — but since Angie ships its own native acme, we don’t run it there. Use Angie’s built-in one instead.

It builds as a clean dynamic module against current mainline NGINX, and it’s part of the same family as our other modules over on the NGINX modules repository.

Frequently asked questions

Do I still need certbot or a cron job?

No. nginx-autocert-module is the ACME client, and it runs its own renewal timer inside NGINX. There’s nothing external to install or schedule — no certbot package, no cron line, no systemd timer.

Does NGINX reload when a certificate renews?

No. Renewed certificates are written to disk atomically and picked up at the next TLS handshake via a modification-time check. No nginx -s reload, no dropped connections, no capacity wobble.

Which certificates does it request — do I list domains somewhere?

There’s no separate list. It provisions the concrete server_names already declared in any vhost that has autocert on;. Regex and wildcard server names are skipped, because they don’t name a concrete host to certify.

Can I get certificates without opening port 80?

Yes — set autocert_challenge tls-alpn-01;. Validation then happens entirely inside the TLS handshake (RFC 8737), so port 80 can stay firewalled shut. Otherwise HTTP-01 serves the challenge on port 80.

Can I get RSA certificates?

No. Keys are ECDSA P-384 by default, or P-256 if you set autocert_key_type secp256r1;. Note those are the OpenSSL curve names, not p384/p256. RSA is intentionally not an option.

Where are the private keys, and who can read them?

A dedicated helper process holds the account key and every certificate key; NGINX workers never see them. On disk they live under <autocert_path>/ at 0600 inside a 0700 root-only store.

Will NGINX start if I haven’t gotten a certificate yet?

Yes. A listen ssl; autocert on; server with no ssl_certificate starts immediately behind a self-signed bootstrap certificate, then swaps in the real one per-SNI the moment issuance completes.

What happens if Let’s Encrypt rate-limits me?

The scheduler backs off per-domain — exponential, from 60 seconds up to an hour — and honours a CA Retry-After header on HTTP 429, waiting the later of the two deadlines before retrying. It won’t get your account throttled.

Does it work with HTTP/3 and the rest of a modern NGINX stack?

Yes. It’s a standard dynamic module and doesn’t care what else you’ve loaded. Pair it with our HTTP/3 and QUIC setup guide and the certificates it issues are served over whichever protocols your listeners speak.

Where to get it

The module is open source and lives on GitHub: github.com/eilandert/nginx-autocert-module. The README there has the full directive reference, build instructions, and the CI matrix we run on every change. If you run our packaged NGINX builds, keep an eye on the modules repository page — that’s where it’ll show up as a drop-in package you can apt install instead of compiling.

Related reading