nginx-autocert-module
August 15, 2026 · View on GitHub
Automatic TLS certificates for NGINX — built into the server.
Features: wildcard, IP-address certs, TLS-ALPN01 (skip port 80), dual-cert.
nginx-autocert-module is an ACME
client that lives inside NGINX. Add autocert on; to a vhost and NGINX itself
obtains, serves, and renews a certificate from Let's Encrypt (or any ACME CA) for
that vhost's server_names — no certbot, no cron job, no deploy hook, no reload.
Certificates are ECDSA by default; RSA and dual EC+RSA (one of each, served by
client preference) are also supported. The whole flow runs inside the worker, and
the new certificate is served on the very next TLS handshake. Your existing
server_name list is the domain list. Works on both NGINX and Angie (coexists
with Angie's native acme).
📖 New here? Start with the walkthrough: Automatic TLS Certs, No Certbot on deb.myguard.nl.
Quick start — set and forget
One directive. Add autocert on; to a vhost, make sure NGINX can reach the CA
(a resolver), and you are done — issuance and renewal then happen on their own,
forever.
load_module modules/ngx_http_autocert_module.so;
http {
resolver 1.1.1.1; # so NGINX can reach the ACME CA
server {
listen 80; # CA validates here over HTTP-01
listen 443 ssl;
server_name example.com www.example.com;
autocert on; # ← the whole feature. Nothing else needed.
# no ssl_certificate / ssl_certificate_key — autocert supplies them
}
}
That is a complete, production-ready config. The first handshake triggers issuance
from Let's Encrypt; a self-signed placeholder is served for the few seconds until
the real certificate lands, after which the module renews it on its own (by default
7 days before expiry). Add a vhost, set autocert on;, reload once — that's the
whole workflow.
No port 80? Use TLS-ALPN-01
Can't (or won't) open port 80? Switch the challenge to tls-alpn-01 — it validates
inside a TLS handshake on :443, so no HTTP listener is needed:
server {
listen 443 ssl;
server_name example.com www.example.com;
autocert on;
autocert_challenge tls-alpn-01; # validate in-handshake on :443, no port 80
}
Read these caveats before switching — tls-alpn-01 is not a drop-in for every setup:
- Not every CA offers it.
tls-alpn-01is optional in ACME (RFC 8737). Let's Encrypt supports it; many commercial CAs do not. The module asks the CA only for the configured challenge and fails the order if the CA doesn't list it.http-01is universal — that's why it's the default. - Validation must terminate on NGINX. The proof is a raw TLS handshake with
ALPN
acme-tls/1that the module answers in-process. Any TLS-terminating layer in front — reverse proxy, load balancer, CDN — intercepts that handshake and validation fails. Behind such a layer, usehttp-01ordns-01instead. - It's instance-wide, not per-vhost.
autocert_challengelives inhttp{}— one challenge type for the whole NGINX instance. You can't pair atls-alpn-01vhost with anhttp-01one; flipping it switches every autocert vhost. :443must be reachable from the internet on the standard port — the CA always connects there for this challenge (no custom port).
Everything below is for when you want more — LE staging, wildcards, DNS-01, a different CA, EAB. None of it is needed for the common case above.
Why
- No moving parts. Issuance, renewal, and serving all happen inside the running server. Nothing to install alongside, nothing to schedule.
- Three challenge types:
http-01(default),tls-alpn-01(RFC 8737), anddns-01. - ECDSA, RSA, or both.
autocert_key_typeselects the leaf key — P-384 (default), P-256, RSA-2048/3072/4096, or a dual EC+RSA pair (one of each). With a dual pair both certs are issued and stored, and OpenSSL serves whichever the client's handshake prefers. - Wildcards via
dns-01and an operator-supplied DNS hook (the only challenge type the ACME spec allows for*.example.com). - External Account Binding (EAB) for commercial CAs (ZeroSSL, Sectigo, Google)
that gate
newAccountbehind a key-id + HMAC key. - Certbot-compatible store option — drop-in
live/<domain>/layout — alongside the module's own hardened default. - Multiple CAs per instance, selected per-vhost; each CA gets its own account and account key.
- Per-SNI serving — the right certificate is chosen in the TLS handshake from the requested SNI, with a self-signed bootstrap cert served until issuance lands.
Full syntax — one annotated config
Every production directive below, with realistic values. Each line carries a
# context | default: … comment. http{}-only directives live in http{};
the rest may also appear per-server{}.
http {
resolver 127.0.0.1 [::1] valid=300s; # core nginx; autocert falls back to this if autocert_resolver is unset
# ---- instance-wide defaults (folded into every server) ----
autocert on; # http,server | default: off
autocert_contact admin@example.com; # http,server | ACME account email (optional)
# BARE address -- the module prepends "mailto:".
# "mailto:admin@example.com" passes config parse
# (one '@', text both sides) and is then sent as
# "mailto:mailto:..." -> the CA rejects the account
# with a 400 invalidContact.
autocert_ca https://acme-v02.api.letsencrypt.org/directory;
# http,server | default: LE production (mutually exclusive with autocert_staging)
autocert_staging off; # http,server | default: off (on = LE staging directory)
autocert_store_path /var/lib/autocert; # http only | default: autocert (relative → resolved against nginx prefix)
autocert_store_layout default; # http only | default: default (default | certbot)
autocert_key_type p384; # http only | default: p384 (p384 | p256 | rsa2048 | rsa3072(=rsa) | rsa4096; list up to one EC + one RSA for a dual cert)
autocert_challenge http-01; # http only | default: http-01 (http-01 | tls-alpn-01 | dns-01)
autocert_renew_before 7d; # http only | default: 7d (renew this long before notAfter)
# ---- resolver used to REACH the CA (not for dns-01 validation) ----
autocert_resolver 127.0.0.1 [::1]; # http only | default: falls back to core `resolver` above
autocert_resolver_timeout 30s; # http only | default: 30s
# =====================================================================
# 1) Plain HTTP-01 vhost. Needs `listen 80;`. CA fetches the token
# over plain HTTP from /.well-known/acme-challenge/<token>.
# =====================================================================
server {
listen 80;
listen 443 ssl;
server_name www.example.com example.com;
autocert on; # http,server | default: off (server-level `autocert on` is what seeds the empty cert arrays)
# no ssl_certificate / ssl_certificate_key — autocert supplies them
}
# =====================================================================
# 2) DNS-01 wildcard vhost. Requires dns-01 + both hooks (below).
# Wildcards are ONLY issuable under dns-01. Two ways to ask for one:
#
# (a) put the wildcard directly in server_name (this vhost then also
# ROUTES every subdomain — fine for a single catch-all vhost):
server {
listen 443 ssl;
server_name example.org *.example.org;
autocert on; # http,server | default: off
}
# (b) keep concrete per-subdomain vhosts and SHARE one wildcard cert via
# autocert_wildcard — no wildcard in server_name, no catch-all, and
# the covered concrete names are NOT issued separately:
server {
listen 443 ssl;
server_name a.example.net;
autocert on;
autocert_wildcard *.example.net; # http,server | served from the wildcard
}
server {
listen 443 ssl;
server_name b.example.net;
autocert on;
autocert_wildcard *.example.net; # same cert; declared once in http{} also works
}
# =====================================================================
# 3) A second CA with EAB (e.g. ZeroSSL). This vhost OWNS its CA
# selector, so it inherits NO trust bundle / EAB from http{} —
# everything CA-bound must be set here explicitly.
# =====================================================================
server {
listen 80;
listen 443 ssl;
server_name shop.example.com;
autocert on; # http,server | default: off
autocert_contact billing@example.com; # this CA's account email
autocert_ca https://acme.zerossl.com/v2/DV90;
# http,server | default: LE production
autocert_eab_kid AbCdEf0123456789; # http,server | default: (none) (both EAB lines or neither)
autocert_eab_hmac_key bX9...base64url...; # http,server | default: (none)
# autocert_ca_trusted_certificate /etc/ssl/internal-ca.pem; # http,server | default: (none) — only for a private CA
# autocert_ca_issuance_certificate /etc/ssl/issuing-ca.pem; # http,server | default: (none) — verify the ISSUED chain (not the TLS endpoint)
}
# =====================================================================
# 4) DNS-01 hooks — http{}-global, mandatory when challenge is dns-01.
# Absolute paths only. argv = {hook, _acme-challenge.<domain>, <txt>}.
# =====================================================================
autocert_challenge dns-01; # http only | default: http-01
autocert_dns_hook_add /etc/nginx/acme/dns-add.sh; # http only | default: (none) (absolute path)
autocert_dns_hook_remove /etc/nginx/acme/dns-del.sh; # http only | default: (none) (absolute path)
autocert_dns_propagation_delay 10s; # http only | default: 10s (wait after add-hook before asking CA to validate)
autocert_dns_hook_timeout 30s; # http only | default: 30s (per-hook exec timeout; must be > 0)
}
The four
autocert_dns_*lines and the wildcard server only make sense together — the snippet shows them in one block for reference. A real config picks one challenge type via the singleautocert_challengedirective.
Directive reference
http = http{} (main) context; server = server{}. http,server directives
set an instance-wide default in http{} that each server{} may override.
| Directive | Context | Default | Description |
|---|---|---|---|
autocert on|off | http, server | off | Master switch. A server{}-level autocert on is what seeds the empty cert arrays so a cert-less vhost still builds an SSL_CTX. |
autocert_contact <email> | http, server | (none) | ACME account contact email — a bare address, no mailto: prefix (the module adds one). One @, non-empty both sides. Picked per CA group — the first non-empty contact from a vhost in that group. (Was the optional 2nd arg of autocert on.) |
autocert_wildcard *.rest [*.rest …] | http, server | (none) | Declare wildcard SAN(s) for this scope without putting *. in server_name (which would also make the vhost a subdomain catch-all). In http{} it applies to every enabled vhost; a server{} occurrence adds to that vhost. dns-01 only. A concrete server_name the wildcard covers (one leading label, e.g. a.example.com under *.example.com) is served from the wildcard cert, not issued separately. Repeatable; sole-leading-label form only. |
autocert_ca <url> | http, server | LE production https://acme-v02.api.letsencrypt.org/directory | ACME directory URL to issue against. Distinct effective URLs become distinct CA groups. Mutually exclusive with autocert_staging. |
autocert_staging on|off | http, server | off | Shorthand for the LE staging directory (https://acme-staging-v02.api.letsencrypt.org/directory). For CI; no production rate limits. Mutually exclusive with autocert_ca. |
autocert_ca_trusted_certificate <file> | http, server | (none) | PEM trust bundle verifying a private CA's TLS endpoint. CA-bound: a server that overrides the CA does not inherit it. Made absolute against the nginx prefix. |
autocert_ca_issuance_certificate <file> | http, server | (none) | PEM trust anchor the issued certificate chain must verify against, checked before a new cert can replace the live one. Defence in depth against a buggy or compromised CA. Unset = no chain verification (the default; the check is opt-in). Not the same as autocert_ca_trusted_certificate — that anchors the CA's TLS endpoint, and a CA may serve its API under one root while signing certificates under another, in which case pointing this at the transport bundle rejects every issuance. Point it at the CA's issuing certificate or its root; an intermediate is accepted as the terminating anchor. CA-bound; made absolute against the nginx prefix. |
autocert_eab_kid <key-id> | http, server | (none) | EAB key identifier (RFC 8555 §7.3.4) for CAs requiring External Account Binding. Both-or-neither with autocert_eab_hmac_key. CA-bound. |
autocert_eab_hmac_key <b64url> | http, server | (none) | EAB HMAC key (base64url). Paired with autocert_eab_kid. CA-bound. |
autocert_store_path <path> | http | autocert (→ <prefix>/autocert) | Root of the cert / account-key store. Relative paths resolve against the nginx prefix, not the CWD. |
autocert_store_layout default|certbot | http | default | On-disk layout: the module's own hardened layout, or a certbot-compatible live/ layout. (secure accepted as an alias for default.) |
autocert_key_type <type> [type …] | http | p384 | Key type(s) for issued leaf certs. Accepts p384, p256, rsa2048, rsa3072 (alias rsa), rsa4096 — case-insensitive, with the OpenSSL/long aliases (secp384r1, prime256v1/secp256r1, ecdsa-p384/ecdsa-p256, rsa-2048, …) also accepted. List up to one EC + one RSA type (max 2 effective; ≥3 args or two of the same family is rejected) to issue and serve a dual EC+RSA pair per vhost — OpenSSL then picks the cert each client's handshake supports. The ACME account key stays ECDSA P-384 regardless. |
autocert_challenge http-01|tls-alpn-01|dns-01 | http | http-01 | ACME challenge type. dns-01 is required for wildcards and requires both DNS hooks. |
autocert_renew_before <time> | http | 7d | Renew this long before a cert's notAfter. Must be > 0 and ≤ 89d — a larger value would push the renew point before issuance and reissue every sweep (self-DoS / CA rate-limit), so it is rejected at config load. |
autocert_runtime_ttl <time> | http | 7d | Idle TTL for runtime-requested names (the bounded shared registry a consumer module fills — see Runtime cert requests). A node idle longer than this is evicted, freeing its cap slot and closing its SNI serve gate; its on-disk cert is kept (a re-learned host reuses it). "Idle" means no consumer ensure() and no driver activity (issuance outcome / renewal) — a live host is refreshed by both, so only de-labelled hosts age out. 0 disables eviction (learned hosts persist until restart; the 64-name cap can then wedge). Config names are never swept. |
autocert_profile <name> | http | (none) | ACME issuance profile requested in the order (ACME Profiles draft). Let's Encrypt requires shortlived to issue an IP-address certificate. Omitted when unset (CA default profile). Restricted to A-Z a-z 0-9 . _ -. |
autocert_resolver <addr> [addr…] | http | falls back to core resolver | DNS resolver(s) used to reach the CA host. Same address[:port] valid= ipv6= syntax as core resolver. |
autocert_resolver_timeout <time> | http | 30s | Timeout for that DNS resolution. |
autocert_dns_hook_add <path> | http | (none) | Absolute path to the executable that publishes the dns-01 TXT record. Required when challenge is dns-01. |
autocert_dns_hook_remove <path> | http | (none) | Absolute path to the executable that removes the TXT record after validation. Required when challenge is dns-01. |
autocert_dns_propagation_delay <time> | http | 10s | Wait after the add-hook returns before asking the CA to validate. 0 = no wait. |
autocert_dns_hook_timeout <time> | http | 30s | Per-hook exec timeout before SIGKILL. Must be > 0. |
Config-time rejections to know about:
autocert_ca+autocert_stagingon the same CA → emerg (mutually exclusive).dns-01selected but a hook missing → emerg (requires both …).autocert_eab_kid/autocert_eab_hmac_key— one set without the other → emerg.autocert_wildcardwith a non*.-form argument → emerg (sole-leading-label only).autocert_wildcardunder a non-dns-01 challenge → emerg (a wildcard is unissuable over http-01/tls-alpn-01).autocert_key_typewith more than 4 args, a duplicate type, two ECDSA types, or two RSA types → emerg (at most one EC + one RSA).- Two vhosts naming the same CA URL with different trust bundle / EAB / account email → emerg (one CA URL = one trust bundle, one EAB, one account).
- One
server_nameclaimed by two vhosts pinned to different CAs → emerg (one name = one cert from one CA).
Challenges
Pick one with autocert_challenge (it is http{}-global — one type per instance).
The module asks the CA only for the configured type and fails the order if the CA
offers no matching challenge.
| Type | What it needs | Wildcards |
|---|---|---|
http-01 (default) | A listen 80; vhost. The CA fetches the token over plain HTTP at /.well-known/acme-challenge/<token>. | No |
tls-alpn-01 | A listen 443 ssl; vhost. Validation is a TLS handshake with ALPN acme-tls/1 + SNI. The module serves a challenge cert in-handshake. | No |
dns-01 | Both DNS hooks (below). No :80/:443 needed for validation itself. | Yes — the only type that can issue *.example.com. |
The HTTP-01 handler is registered once in the content phase for the whole
http{} block, matches the /.well-known/acme-challenge/ URI prefix, and serves
the token only when the resolved server has autocert enabled. The :80 vhost that
answers the CA must therefore be (or inherit) an autocert-enabled server — a bare
listen 80; vhost with autocert disabled will decline the challenge. The :80 /
:443 requirements above are the ACME protocol's, not enforced by the module.
A content handler on the
:80vhost shadows the challenge. Because the token is served from a phase handler, anylocationthat sets a content handler covering/.well-known/acme-challenge/wins instead — a catch-alllocation / { return 301 https://$host$request_uri; }, arootplus the static handler, or aproxy_pass. The CA then reads a redirect, the wrong body, or a 404, and the order fails with "authorization did not become valid" — with nothing logged by this module, since its handler never ran. Either keep the:80vhost free of a catch-all, or carve the prefix out ahead of it:server { listen 80; server_name example.com; autocert on; # Empty block: it sets no content handler, so the phase handler above # stays reachable. Longest-prefix wins, so this beats `location /` # wherever it appears in the file. location ^~ /.well-known/acme-challenge/ { } location / { return 301 https://$host$request_uri; } }
^~additionally stops nginx from evaluating regex locations, which would otherwise be tried after the prefix match and could take the request back.
DNS-01 hook contract
When autocert_challenge dns-01 is active you must supply both
autocert_dns_hook_add and autocert_dns_hook_remove, each an absolute path.
On Windows, use a fully-qualified drive path (C:/... or C:\\...) or a UNC
path (//server/share/... or \\\\server\\share\\...); drive-relative (C:...)
and current-drive-rooted (/... or \\...) paths are rejected.
The module runs them with fork + execve — no shell, no system(). Both
hooks receive the same positional argv:
argv[0] = <hook path> # the configured absolute path
argv[1] = "_acme-challenge.<domain>" # record name; a leading "*." is stripped
argv[2] = "<txt-value>" # 43-char base64url(SHA-256(keyauth))
argv[3] = NULL
No environment variables are added by the module — the hook inherits the
worker's environment verbatim. This is the certbot-manual convention: pass your
DNS-provider credentials in the worker's environment; the domain and value arrive
as argv[1] / argv[2], not as CERTBOT_DOMAIN / CERTBOT_VALIDATION.
Flow and timing:
- Add-hook runs, publishing the TXT record. Must exit
0; a non-zero exit or a signal fails the order. - The module waits
autocert_dns_propagation_delay(default10s) for DNS to propagate, then asks the CA to validate. - After validation the remove-hook runs (same argv). A remove failure is non-fatal — the record expires by TTL.
Each hook exec is bounded by autocert_dns_hook_timeout (default 30s, must be
> 0); on timeout the whole hook process group is SIGKILLed. The hook runs on
worker 0 and blocks it for up to the timeout (only one order runs at a time).
For a wildcard *.example.org the ACME identifier is *.example.org, but the
hook is called with the base — argv[1] = "_acme-challenge.example.org".
A tiny add-hook (RFC 2136 nsupdate flavour):
#!/bin/sh
# argv: \$1 = _acme-challenge.<domain> \$2 = <txt-value>
nsupdate -k /etc/nginx/acme/tsig.key <<EOF
server 192.0.2.53
update add \$1 60 IN TXT "\$2"
send
EOF
The matching remove-hook is identical with update delete \$1 IN TXT "\$2".
IP-address certificates
If a vhost's server_name is an IP-address literal (IPv4 or IPv6), autocert
issues a certificate for that address rather than a hostname (RFC 8738) —
the SAN is an iPAddress, not a dNSName. Everything else is automatic; the
address in server_name is the identifier.
http {
autocert_challenge tls-alpn-01; # or http-01 — see below
autocert_profile shortlived; # required by Let's Encrypt for IP certs
autocert_renew_before 2d; # IP certs are short-lived (~6 days)
server {
listen 443 ssl;
server_name 192.0.2.10; # IPv4 literal
autocert on;
}
server {
listen [2001:db8::10]:443 ssl;
server_name 2001:db8::10; # IPv6 literal (unbracketed)
autocert on;
}
}
Constraints, all enforced at config load or issuance:
- Challenge:
http-01ortls-alpn-01only.dns-01is meaningless for an address (no zone, no CAA) and is rejected at config load for an IPserver_name. This is also why an IP cannot be a wildcard. - Profile: Let's Encrypt only issues IP certs under its
shortlivedprofile, so setautocert_profile shortlived;. The profile is sent in the ACMEnewOrder(ACME Profiles draft); leave it unset for CAs that don't require one. - Short validity: LE IP certs live ~6 days, so set
autocert_renew_beforewell under that (e.g.2d). The default7dexceeds a 6-day lifetime and would reissue every sweep — the renewal sweep runs atmin(12h, renew_before/2), so a2dwindow renews with plenty of margin.
On disk an IPv6 address is stored under a normalized, filesystem-safe segment
(2001:db8::10 → _ip6_2001-0db8-0000-0000-0000-0000-0000-0010); IPv4 is
stored verbatim.
Store layout
The store root is autocert_store_path (default autocert, resolved against the nginx
prefix). On disk a domain maps to a segment: literal names use themselves; a
wildcard *.rest is stored under _wildcard_.rest; an IPv6 address under
_ip6_<normalized-hex> (IPv4 verbatim). Certificates are committed
atomically via renameat2 on Linux ≥ 3.15; on a filesystem lacking
RENAME_EXCHANGE / RENAME_NOREPLACE the commit is deferred (the existing cert is
kept and renewal retries) rather than risking a mismatched pair, so a half-written
pair is never served. For a wildcard the cache entry, store dir, and the
≤1 stat/sec throttle are keyed by the shared _wildcard_.<rest> segment — one
entry for all subdomains, not per concrete SNI.
default — files live directly under <path> (the module's own hardened layout):
| Path | Mode |
|---|---|
<path>/<domain>/ | 0700 |
<path>/<domain>/privkey.pem | 0600 |
<path>/<domain>/fullchain.pem | 0644 |
certbot — certbot-compatible live/ tree:
| Path | Mode |
|---|---|
<path>/live/ | 0755 |
<path>/live/<domain>/ | 0700 |
<path>/live/<domain>/privkey.pem | 0600 |
<path>/live/<domain>/fullchain.pem | 0644 |
<path>/live/<domain>/cert.pem | 0644 (leaf only) |
<path>/live/<domain>/chain.pem | 0644 (intermediates; may be empty) |
cert.pem / chain.pem exist for certbot-tool compatibility; the serve path only
reads fullchain.pem + privkey.pem.
RSA and dual certs. The filenames above hold the ECDSA leaf. An RSA leaf is
stored alongside it with a .rsa. infix — privkey.rsa.pem / fullchain.rsa.pem
(and cert.rsa.pem / chain.rsa.pem under certbot) — in the same <domain>/
directory. A single autocert_key_type rsa…; writes only the .rsa. files; a dual
EC+RSA config writes both sets side by side. The suffix is keyed on the key type,
not on single-vs-dual.
Account keys (per CA, both layouts):
<path>/accounts/<ca_hash>/account.key # 0600, unencrypted PKCS#8 PEM
<path>/accounts/ # 0700
<path>/accounts/<ca_hash>/ # 0700
<ca_hash> is the leading 64 bits of SHA-256(<canonical CA URL>) as 16 lowercase
hex chars. Staging and production are different URLs → different hashes → fully
isolated accounts. The account key is rejected on load unless it is a regular file,
owned by the worker's euid, with no group/other permission bits.
Permissions — the store must be writable by the worker user. Worker 0 creates
and writes everything under autocert_store_path, so that tree must be owned by the
nginx worker user:
chown -R www-data /var/lib/autocert
chmod 0700 /var/lib/autocert
If a previous run created the store as root, fix it once with
chown -R <worker-user> <autocert_store_path>. Worker 0 also holds a singleton lock at
<path>/.driver.lock (0600).
How it works
-
One driver, worker 0. The ACME engine — account bootstrap, order flow, renewal scheduler — runs in exactly one process: worker 0 (or the single process under
master_process off). It is a deterministic, zero-IPC election: aflockon<path>/.driver.lockguarantees a single driver even across a reload generation. Every other worker only serves challenges and certificates. -
Per-SNI serving. On each TLS handshake a cert callback resolves the SNI, checks it against the set of issuable names, and serves the matching cert from a per-worker cache (reloaded by mtime, throttled to ≤1 stat/sec/name). With a dual EC+RSA config both leaves are installed and OpenSSL picks the one the client's ciphersuites/sigalgs support. A name with no issued cert yet gets a self-signed bootstrap cert (CN
localhost) — the honest pre-issuance state; the client sees a name mismatch until the real cert lands. (Underacme-tls/1the callback fails closed instead of leaking the bootstrap cert.) -
Renewal. A periodic sweep on worker 0 checks each name's stored leaf (
fullchain.pem, andfullchain.rsa.pemfor the RSA half of a dual config, each tracked independently). A name is due — and gets reissued — when no cert is stored, the cert is unreadable, ornow >= notAfter − autocert_renew_before(default7d). The sweep runs at most every 12 h and at least every 60 s; failures back off exponentially (60 s base, capped at 1 h), and a CA429 Retry-Afteris honoured. -
Staging vs production. The default CA is Let's Encrypt production. Set
autocert_staging on;to exercise the full issuance flow against LE staging without burning production rate limits — useful in CI. Staging certs are not publicly trusted, and (per the account-hash rule above) staging keeps its own account. -
Origin-pinned ACME client. The outbound client is pinned to the origin (scheme
https, host, port) of the configuredautocert_cadirectory URL. Every resource URL the CA returns —newNonce,newAccount,newOrder,finalize, the authorizations, the challenge, the final certificate — must stay on that origin or the request is refused before any account-signed JWS is sent. TLS verification already stops untrusted endpoints; this additionally stops a compromised or malicious directory document from redirecting the client at another HTTPS origin that merely happens to be in the trust store (an SSRF-shaped risk that widens with a broad private trust bundle). Real CAs (Let's Encrypt, ZeroSSL, Pebble) serve every resource from the directory's own origin, so this is transparent.
This module exposes no nginx variables — it is purely a cert/challenge-serving module.
Runtime cert API (for other modules)
A separate nginx module can ask autocert to obtain and serve a certificate for a
host it discovers at runtime (e.g. from a Docker label), without either module
linking the other. The integration surface is a named shared-memory zone with
a versioned on-shm layout, not an exported function API: the consumer vendors
ngx_autocert_requests.{h,c} and both modules operate on the same slab. The
accessors are compiled with hidden visibility, so each .so binds its own copy —
nginx loads modules with RTLD_GLOBAL, and without hiding, two copies of the same
helper would interpose and one version's code would parse another version's shm.
The full contract (zone name/size, state enum, locking rule,
helper signatures) is documented in
src/ngx_autocert_requests.h; summary below.
A task-oriented consumer walkthrough (attach the zone, enqueue, poll, worked
code) lives in docs/API.md.
Zone: autocert_requests, size 128 KiB, tag NULL (both modules must add
the zone with a NULL tag and the exact same size, or nginx rejects the mismatch
on the side that attaches second). Whichever module's postconfig runs first
creates it; autocert's init callback stamps a zone-header api_version
(currently 2) — a consumer checks that stamp against its compiled value and
disables the integration on mismatch. A consumer-only cycle records an
inactive-current stamp: the feature is off, but the stored layout remains known
for a later same-version reload. An owner rejects an inherited foreign layout;
stop nginx completely before loading an ABI-incompatible module version so it
receives a fresh arena. Ownership never depends on nginx's init callback data
argument.
Request lifecycle (ngx_autocert_request_state_e): REQUESTED (consumer
enqueued) → PENDING (autocert's worker-0 driver has an ACME order in flight)
→ ISSUED (cert on disk, servable) or FAILED (retry gated by exponential
backoff) or DENIED (rejected — over the 64-name cap, bad charset, wildcard;
terminal).
Consumer-side flow:
- Enqueue —
ngx_autocert_requests_ensure(shm_zone, &host)inserts aREQUESTEDnode (idempotent; validates + lowercases the host; returns the current state; over-cap or bad input never allocates). - Poll —
ngx_autocert_requests_state(shm_zone, &host)reads the current state without inserting.ISSUEDmeans the cert is on disk and served: autocert's cert callback falls back to this zone on any SNI that misses its config-driven name index, so no separate "please serve this" step is needed. - autocert's worker-0 driver owns everything past enqueue: it drains
REQUESTEDnodes (deduping against config-managed names), orders under the configured CA, applies the same global/per-host ACME rate caps as config issuance, flips state on completion, and renewsISSUEDruntime certs on the normalautocert_renew_beforeschedule. A consumer never touches certs, paths, or PEM data — those stay entirely inside autocert. - Keep-alive — the registry is a bounded table (64 names), so idle nodes
are evicted after
autocert_runtime_ttl(default 7d). Eachensure()call refreshes the node's idle clock: a consumer that periodically re-asserts its hosts (e.g. on every label-discovery sweep) keeps them alive indefinitely, while a de-labelled host silently ages out — freeing its cap slot and closing its serve gate. Driver activity (issuance, renewal) refreshes it too, so a live host never ages out between consumer sweeps.
Locking: every access to the zone's rbtree — including a consumer walking
it directly instead of using the helpers — must hold the zone's slab-pool
mutex (((ngx_slab_pool_t *) zone->shm.addr)->mutex) for the whole traversal.
The ensure/state/set_state/drain/list_issued helpers take the mutex
internally; only a raw walk needs to do this itself.
Policy: a runtime name is issued only via HTTP-01 or TLS-ALPN-01, never
DNS-01 — ACME domain-control validation is the runtime allowlist (a CNAME
pointed elsewhere would let anyone request a cert for an arbitrary name under
a config-wide autocert_ca dns-01, so runtime orders are hard-pinned off
DNS-01 regardless of the configured challenge mode). Runtime cert state
survives a hard process restart: a marker file beside each runtime cert lets
the driver re-seed the zone from disk on init_process, so a crashed/restarted
worker doesn't strand a previously-issued runtime name unserved.
Consumer implementation reference:
nginx-label-autoconf-module.
Build & test
OpenSSL ≥ 3.0.0 required. nginx must be built with HTTP SSL support: this is
an ACME client, so it opens its own TLS connections to the CA and serves per-SNI
certificates. Either --with-http_ssl_module or --with-http_v3_module provides
it (the latter turns SSL support on implicitly). configure rejects a build with
neither.
Build as a standard dynamic module (Linux):
cd nginx-<version>
./configure --with-compat --with-http_ssl_module \
--add-dynamic-module=/path/to/nginx-autocert-module
make modules
# -> objs/ngx_http_autocert_module.so
On Linux, the shipped artifact is ngx_http_autocert_module.so. Load it with
load_module modules/ngx_http_autocert_module.so; and enable it per the config
above.
To compile the in-tree test directives and run the test suite, configure the build
with AUTOCERT_TEST=1 set in the environment:
AUTOCERT_TEST=1 ./configure --with-compat --with-http_ssl_module \
--add-dynamic-module=/path/to/nginx-autocert-module
make
AUTOCERT_TEST=1 is consumed at configure time only (it bakes
-DNGX_AUTOCERT_TEST=1 into the generated Makefile), so it is set on ./configure
and not repeated on make. Use a full make here — not make modules — because
the e2e suite under ci/tests/e2e/*.sh runs the actual server binary (objs/nginx or
objs/angie), which make modules does not build.
(The e2e harness uses Pebble as a local ACME server.)
Windows
Windows support for nginx has no dynamic-module mechanism; the module must be
statically linked into nginx.exe via --add-module= at build time, not
--add-dynamic-module=.
Two toolchains are built in CI:
-
MinGW-w64 x64: builds OpenSSL 3.5.4, PCRE2 10.44 and zlib 1.3.1 from pinned source tarballs under
gccwith--crossbuild=win32. Producesnginx.exewith the module statically linked. Build succeeds on each PR. -
MSVC x64 static link: uses vcpkg's prebuilt
openssl:x64-windows-static, staged into anopenssl/{include,lib}tree soconfigure's hardcoded paths resolve correctly. Configures and links with MSVC'sclandnmakeunder-W4 -WX, then runs the producednginx.exe. Build succeeds on each PR.
Both lanes run configure --crossbuild=win32 to sidestep MSYS2's uname output,
then assert ngx_http_autocert_module appears in the generated objs/ngx_modules.c
and that nginx -t accepts an autocert_store_path directive while rejecting a
bogus one (the negative control that keeps the positive check meaningful).
Runtime coverage: The MSVC lane starts the built nginx.exe and exercises
the HTTP-01 challenge-serve path end-to-end: it seeds a token through the
test-only autocert_test_challenge directive (compiled in only under
AUTOCERT_TEST=1, never in a production build) and asserts the exact key
authorization is served, that an unknown token and a token with a trailing
path segment both 404, that Content-Length matches, and that a GET carrying a
body on a keepalive connection does not desync it for a pipelined follow-up.
It also runs full ACME issuance against a CA: a native pebble-windows-amd64
binary (no Docker) issues an RFC 8738 IPv4-literal certificate over http-01
against a real nginx.exe, then asserts the identifier was ordered as "ip"
(not "dns"), the store holds the verbatim IPv4 literal, the leaf SAN is an
iPAddress with no DNS SAN, and the issued certificate's public key matches
the stored private key -- the same assertions ci/tests/e2e/ipv4-issue.sh
makes on Linux, ported off Docker.
CI
A failure surfaces as a red run plus the uploaded artifact. ci-deep.yml also
pings Discord on failure; nothing else notifies.
ci.yml is the orchestrator and calls the PR-time members below, so a PR asks
for one run rather than several independent ones.
| Workflow | Trigger | Gates |
|---|---|---|
build-test.yml | PR (via ci.yml) | shellcheck/cppcheck/actionlint + stamp check, build on nginx and angie, .so dlopens, bad config rejected, -Werror strict compile, ABI check, unit tests, Guard suite, Pebble e2e suite; Validation alone also runs daily at 03:17 UTC |
security-scanners.yml | PR (via ci.yml) | flawfinder ≥4 blocks, clang-tidy, semgrep ≥WARNING |
lint.yml | PR (via ci.yml) | the same ci/linter/ checkers the pre-commit hook runs — shell, Python, YAML, nginx conventions, spelling, docs drift, sync-stamp and the workflow-policy checks (ports, runners, cadence, secrets). Blocking set is a deliberate subset (LINT_ONLY); the c checker is excluded permanently, since security-scanners.yml already covers src/. Full table: ci/linter/README.md |
fuzzing.yml | PR (via ci.yml), manual | replay every past crash, then 30s fresh fuzz per target (JSON + HTTP + base64url). Runs beside the e2e critical path, so it adds nothing to the merge budget; the 14400s/target deep fuzz stays in ci-deep.yml |
valgrind.yml | manual | 30s memcheck soak |
codeql.yml | PR (via ci.yml), monthly | CodeQL over the module TU |
asan.yml | weekly (Sun 03:45 UTC), manual | 30s ASan+UBSan request-storm soak. Green since #160. Two nginx-inherent checks are off: ODR (nginx generates ngx_module_names into both the binary and the .so) and config-load leaks (the cycle pool is never freed) — see ci/tools/lsan.supp. Request-path leaks, UAF, overflow and all UBSan checks stay armed |
ci-deep.yml | monthly, manual | long fuzz, memcheck + helgrind soaks, security scanners, angie Pebble e2e |
windows-build.yml | PR touching src/, config, the pins or the workflow itself; push to master; manual | native win32 build gate: MSVC x64 static link against the pinned nginx, then assert the module reached objs/ngx_modules.c and that nginx -t accepts autocert_store_path while rejecting a bogus directive. Then starts nginx.exe and verifies the HTTP-01 challenge-serve path (exact key authorization, 404s for unknown/nested tokens, Content-Length, and keepalive framing after a GET-with-body), the multi-worker singleton guarantee, and full RFC 8738 IPv4-literal ACME issuance against a native pebble-windows-amd64 binary (no Docker) |
bump.yml | weekly (Mon 04:00 UTC), manual | regenerate the nginx/angie version + sha256 pins in .github/versions.env and open a PR. Not a gate — it produces a reviewable change instead of letting builds drift onto a new upstream on their own |
nginx and angie are pinned. .github/versions.env is the single source of
truth for both versions and their tarball sha256s; resolve in
build-test.yml, the angie leg of ci-deep.yml and ci/tools/ci-build.sh all
read it, and every fetch is checked against the recorded digest. Builds
therefore no longer track whatever upstream happens to be serving: an upstream
release used to turn a green PR red with no diff, and two jobs in one run could
disagree about the version. bump.yml owns moving the pins.
Port bands: the e2e jobs derive AC_PORT_OFFSET from the run ID plus a
per-flavour offset, and ci/tests/e2e/max-port.sh verifies the ceiling before
run-all.sh binds anything, so two concurrent jobs on the same builder cannot
collide on a port.
See also
- Automatic TLS Certs, No Certbot — the walkthrough on deb.myguard.nl.
- NGINX modules repository for Debian & Ubuntu — prebuilt packages.
- Source: https://github.com/myguard-labs/nginx-autocert-module
License
2-clause BSD (the same license as nginx). See LICENSE.