Back to Blog

Nginx Does ACME Itself Now. It Still Can't Do Wildcards.

Nginx can issue and renew its own TLS certificates with no certbot, no cron and no reload. Here's how the native ACME module works, why it still can't do wildcards, and the quiet new way it fails.

CertGuard TeamSSL Security Experts10 min read
A man in a dark hooded top sitting at a desk in front of two monitors filled with lines of code in a dim room, shot in black and white

The certbot cron job you can finally delete

For a decade the shape of TLS on nginx never changed. Nginx served the certificate, and something else went and got it. Certbot, acme.sh, lego, a cert-manager sidecar, a bash script somebody wrote in 2019 and nobody has touched since. That external thing wrote a file to disk, then poked nginx to reload so it would pick the file up. Two moving parts held together with a cron entry and a --deploy-hook.

Nginx does the whole thing itself now. As of the native ACME preview announced on August 12, 2025, ngx_http_acme_module talks to Let's Encrypt directly, solves the challenge, gets the certificate, and renews it in-process. No certbot. No cron. No reload. The catch is that "the whole thing" has an asterisk on it, and if you skip the asterisk you will find out about it the hard way, in production, on a wildcard.

What "native" actually buys you

The old model has a seam in it. Certbot runs on its own schedule, in its own process, with its own idea of where the files go, and nginx finds out a renewal happened only because certbot told it to reload. Most of the failures I've spent evenings on live exactly in that seam: the renewal succeeded but the reload hook didn't fire, or the file landed in a path nginx wasn't reading, or the reload happened but a worker held the old certificate in memory. We wrote a whole post on the ways ACME clients break, and a fair share of them are really seam problems, not protocol problems.

The native module removes the seam because there is no second process. The module is written in Rust against the nginx-rust SDK and loads as a dynamic module. NGINX Plus ships it prebuilt as nginx-plus-module-acme from version 1.29.0 onward; on open-source nginx you build it yourself from the nginx/nginx-acme repo against nginx 1.22.0 or newer. Either way, once it's loaded, certificates stop being files you manage and become state nginx owns.

The whole config is four directives

Here is a working setup against Let's Encrypt. This is the entire thing, not an excerpt.

# in the http block
resolver 127.0.0.1:53;

acme_issuer letsencrypt {
    uri                     https://acme-v02.api.letsencrypt.org/directory;
    contact                 admin@example.com;
    state_path              /var/cache/nginx/acme-letsencrypt;
    accept_terms_of_service;
}

acme_shared_zone zone=ngx_acme_shared:1M;

server {
    listen 443 ssl;
    server_name www.example.com;

    acme_certificate letsencrypt;

    ssl_certificate       $acme_certificate;
    ssl_certificate_key   $acme_certificate_key;
    ssl_certificate_cache max=2;
}

The resolver line is not optional and it is the first thing people forget. The module makes its own outbound calls to the ACME directory and needs to resolve names itself, so without a resolver in the http block nginx will refuse to start. acme_issuer defines who you're getting certificates from. acme_certificate in a server block says "get a cert for this server_name from that issuer." And the two ssl_certificate lines point at variables, $acme_certificate and $acme_certificate_key, instead of file paths. That variable is the whole trick, and it's worth understanding why.

Renewal happens without a reload

When ssl_certificate points at a file, nginx reads that file at startup and at reload, and only then. That is precisely why certbot has to poke nginx after every renewal: the process is holding a parsed copy of the old certificate and has no reason to look at the disk again.

When ssl_certificate points at $acme_certificate, nginx resolves the certificate per handshake from an in-memory store backed by the shared zone, with ssl_certificate_cache keeping the parsed version hot. The module renews in the background, drops the new certificate into that store, and the next handshake picks it up. No reload, no worker restart, no dropped connections. For anyone who has ever watched a nginx -s reload land badly under load, this is the part that actually matters. Renewal stops being an event that touches the whole server and becomes a value that quietly changes.

That's also why the state_path line earns its keep. The certificate and the account key get written there so a restart doesn't send the module back to the CA for everything it already has. Point it at a directory that survives restarts, back that directory up, and treat the account key inside it the way you'd treat any other private key. If you set state_path off, the module keeps everything in memory only, and every restart is a fresh set of ACME requests against your rate limits, which is a fast way to get yourself throttled for a week.

tls-alpn-01 landed. dns-01 did not.

The August 2025 preview shipped with HTTP-01 as the only challenge. Since then TLS-ALPN-01 has been added, so the module now validates over either port 80 or the TLS handshake on 443. HTTP-01 needs the CA to reach http://your-domain/.well-known/acme-challenge/..., which the module serves itself, so the reverse-proxy gymnastics that break certbot's HTTP-01 mostly go away. There's no separate webroot to misconfigure.

DNS-01 is still not there. That is not a footnote. It's the ceiling on what this module can do today. No DNS-01 means no wildcards: the module explicitly rejects wildcard and regex server_name values as certificate identifiers. If you serve *.example.com from one certificate, the native module cannot get it for you, full stop. It also means you can't validate a name the CA can't reach over the public internet. Internal-only hosts, split-horizon setups, anything behind a network where port 80 and the TLS handshake aren't exposed to Let's Encrypt's validators. For those you're still reaching for a DNS-01 client, and if wildcards are your world you already know how many ways DNS-01 finds to break. Native ACME doesn't fix any of that. It just isn't for that.

So the honest scope is: public, per-hostname certificates on a box that terminates its own TLS. That covers an enormous number of real deployments, and it covers almost none of the interesting Kubernetes ones, where TLS terminates at an ingress and cert-manager already owns the lifecycle.

The new way it fails, and why it's quieter

Here's the part that should give you pause before you rip certbot out everywhere. Certbot is clumsy, but it is loud. It runs on a cron or a systemd timer, it writes to /var/log/letsencrypt/, it exits non-zero when it fails, and it can mail root or fire a webhook. Every one of those is a place you can hang a monitor. We've argued before that you should watch the renewal, not just the certificate, and that advice depends on there being a renewal job to watch.

Native ACME renews inside the nginx worker. There is no cron line, no exit code, no separate log file with "Failed" in it to grep for. Renewal failures go to nginx's error_log, mixed in with everything else nginx is shouting about, and if you're not specifically watching for ACME lines you will not see them. Meanwhile the last good certificate keeps serving right up until it expires, because the module has no reason to stop using a certificate that's still valid. So the failure mode is: renewal has been quietly failing for three weeks, every dashboard is green because the site is up, and then the certificate hits notAfter and the whole thing goes red at once.

The fix is the same fix it always was, just more necessary now: check the certificate from the outside, on the wire, as a client sees it. Not the config, not the log, the actual bytes nginx presents during a handshake, and how many days are left on them. That external check is the one thing native ACME can't quietly break, because it doesn't run inside nginx. This is exactly the gap CertGuard exists to close. It connects to your endpoint, reads the live certificate, and tells you the renewal stopped working long before notAfter does. Whatever you use for it, the rule holds: the thing that renews your certificate should never also be the only thing that watches it.

Should you switch?

If you run a handful of public hostnames on nginx boxes that terminate their own TLS, this is a genuine simplification and I'd move to it deliberately, one host at a time. Fewer processes, no reload dance, no webroot to misconfigure. Pin the module version, test a real renewal in staging rather than trusting that it'll work, and wire up external expiry monitoring before you delete certbot, not after.

If you need wildcards, internal certificates, or DNS-01 for any reason, native ACME is not ready for you yet and pretending otherwise will cost you an outage. Keep your existing client for those. And if your TLS lives in Kubernetes, cert-manager already solved this problem and solved it better for that environment; this module isn't aimed at you.

It's a real step forward. It is also a preview that still can't do the single most-requested thing, and it moves your renewal into a process that's very good at failing without telling anyone. Both of those are true at once. Deploy it where it fits, watch it from outside, and don't let "nginx handles certificates now" turn into "nobody handles certificates now."

Frequently asked questions

Does the native nginx ACME module replace certbot completely? Only for public, per-hostname certificates on a server that terminates its own TLS. It supports HTTP-01 and TLS-ALPN-01 but not DNS-01, so it cannot issue wildcard certificates or validate names the CA can't reach over the public internet. For those cases you still need a DNS-01 client like certbot, lego or acme.sh.

Do I need to reload nginx when the certificate renews? No. When ssl_certificate points at the $acme_certificate variable, the module renews in the background and loads the new certificate into nginx's in-memory store, so the next TLS handshake uses it without any reload or worker restart. That reload-free renewal is the main advantage over the certbot-plus-deploy-hook pattern.

How do I monitor renewals if there's no cron job or log to check? You monitor the certificate from outside nginx, over the wire, the way a browser sees it: connect to the endpoint, read the certificate it presents, and alert on the days remaining. Native ACME renewal failures only surface in nginx's error_log, and a stale certificate keeps serving until it expires, so an external expiry check is the only signal that can't be silently broken by the module itself.

Which nginx version do I need for native ACME? NGINX Plus ships the module prebuilt as nginx-plus-module-acme from version 1.29.0 onward. On open-source nginx you build the module yourself from the nginx/nginx-acme repository against nginx 1.22.0 or newer. Either way it loads as a dynamic module rather than being compiled into the core binary.

Why does nginx fail to start after I add the ACME config? The most common cause is a missing resolver directive in the http block. The module makes its own outbound requests to the ACME directory and has to resolve those hostnames itself, so without a resolver configured, nginx refuses to start. A missing or unwritable state_path directory is the other frequent culprit.

Related posts

Never miss an expiring certificate again

CertGuard watches your SSL certificates and domains, and alerts you long before anything expires. Free for your first five domains.

Start Free