For any system responsible for ensuring the stability and security of live services, managing SSL/TLS certificates is a critical but challenging routine task. It’s not just a technical practice — it directly relates to user trust and data protection. With the release of the ngx_http_acme_module by NGINX, we are witnessing a fundamental shift in TLS certificate management, pushing it toward a more reliable, secure, and infrastructure as code (IaC) ideal.
Background: The Evolution of SSL/TLS Certificate Management
Before diving into NGINX’s native solution, it’s helpful to look back at the two main paths historically used in the industry to acquire and manage SSL/TLS certificates:
-
Traditional commercial certificate route: This is the oldest and classic approach. Companies or individuals pay certificate authorities (CAs) like VeriSign or GeoTrust to issue SSL certificates. The process typically involves manually generating a certificate signing request (CSR), verifying domain ownership via email or DNS records, paying, and finally deploying the certificate files to servers. The major pain points are cost (especially for many domains), tedious steps with many manual operations prone to error, and difficult renewal — if you miss renewing before expiration, services go down.
-
Rise of Let’s Encrypt and Certbot: To promote encryption everywhere, Let’s Encrypt launched, providing free, automated certificate issuance via the ACME protocol. Certbot, its most famous client, quickly became ubiquitous. It dramatically lowered the barrier for HTTPS, enabling individual developers and small businesses to deploy SSL easily. This route shifted the challenge from “getting certificates” to “maintaining automation tools.”
Challenges with Traditional Automation (Certbot)
While Certbot + cron job combinations are widely used, their architecture introduces several operational challenges:
- Fragile external dependencies: Certbot depends on a particular runtime (e.g. Python); system updates or dependency conflicts may break it.
- Unreliable scheduled jobs: Cron scripts can “fail silently” without clear alerts or monitoring, often only discovered when the certificate expires.
- Configuration and execution separation: The service configuration (nginx.conf) and certificate management (scripts) are separate, violating the “single source of truth” principle, increasing the chance of human errors.
- Permission management issues: Scripts often require elevated permissions to automate, introducing potential security risks.
ACME Core Mechanisms: Domain Ownership Verification
The ACME protocol uses a series of “challenge-response” tests to prove domain ownership. Understanding these is key to grasping automation:
HTTP-01 Challenge (core)
This is the most common method and the one supported by NGINX’s ACME module currently. The CA provides a unique “token” and asks the client (now NGINX) to place the token content at a predetermined URL path (/.well-known/acme-challenge/). The CA then fetches that URL publicly; if it matches expected content, the client is proven to control that web server. This method is simple and aligns perfectly with web servers — but requires the server’s port 80 to be publicly open.
Other challenge methods (not yet supported, but worth noting)
- DNS-01 Challenge: Verification is done by adding a special TXT record in DNS. It supports wildcard certificates (e.g., *.example.com) and does not require exposing the server to the public internet.
- TLS-ALPN-01 Challenge: This method uses the TLS protocol itself for verification, and does not occupy port 80. However, it’s less commonly used in practice.
NGINX ACME Module: An Architectural Solution
NGINX’s native solution embraces the HTTP-01 method, embedding verification logic into request handling and integrating the entire certificate workflow directly into NGINX — achieving simplicity and efficiency never seen before.
How to Install the NGINX ACME Module
As with many NGINX modules, ngx_http_acme_module is a dynamic module and must be compiled and loaded manually. For NGINX Plus, it can be obtained directly from official repositories. For the open source NGINX, here’s the standard process:
The nginx-acme module (and its underlying ngx-rust SDK) is new and depends on recent NGINX core API additions.
- Minimum version: NGINX 1.25.1 — earlier versions lack necessary API and may fail compilation with errors like “not found in nginx_sys.”
- Production recommendation: NGINX 1.28.0 or higher. This version inherits TLS optimizations from 1.26+ and enters a stable maintenance cycle focused on bug fixes — making it ideal for production.
Step 1: Prepare build environment
In addition to standard C compiler and NGINX dependencies, you need to install the Rust toolchain (cargo, rustc).
sudo apt update
sudo apt install build-essential libpcre3-dev zlib1g-dev libssl-dev pkg-config libclang-dev git
curl https://sh.rustup.rs | sh
source $HOME/.cargo/env
Step 2: Download sources
Fetch matching NGINX source and the nginx-acme module.
$ mkdir -pv /app/nginx/{logs,conf,cache, acme} /app/nginx-build
$ cd /app/nginx-build
# clone ACME source
$ git clone https://github.com/nginx/nginx-acme.git /app/nginx-build/nginx-acme
# download NGINX source code(choose the version you need)
wget https://nginx.org/download/nginx-1.28.0.tar.gz
tar -zxf nginx-1.28.0.tar.gz
Step 3: Build dynamic module
Configure NGINX with --add-dynamic-module pointing to the nginx-acme source. Include all existing compile flags (you can get them with nginx -V).
$ cd nginx-1.28.0
$ ./configure \
--prefix=/app/nginx \
--error-log-path=/app/nginx/error.log \
--http-log-path=/app/nginx/access.log \
--pid-path=/app/nginx/nginx.pid \
--lock-path=/app/nginx/nginx.lock \
--http-client-body-temp-path=/app/nginx/cache/client_temp \
--http-proxy-temp-path=/app/nginx/cache/proxy_temp \
--http-fastcgi-temp-path=/app/nginx/cache/fastcgi_temp \
--http-uwsgi-temp-path=/app/nginx/cache/uwsgi_temp \
--http-scgi-temp-path=/app/nginx/cache/scgi_temp \
--user=nginx \
--group=nginx \
--with-compat \
--with-file-aio \
--with-threads \
--with-http_addition_module \
--with-http_auth_request_module \
--with-http_dav_module \
--with-http_flv_module \
--with-http_gunzip_module \
--with-http_gzip_static_module \
--with-http_mp4_module \
--with-http_random_index_module \
--with-http_realip_module \
--with-http_secure_link_module \
--with-http_slice_module \
--with-http_ssl_module \
--with-http_stub_status_module \
--with-http_sub_module \
--with-http_v2_module \
--with-http_v3_module \
--with-mail \
--with-mail_ssl_module \
--with-stream \
--with-stream_realip_module \
--with-stream_ssl_module \
--with-stream_ssl_preread_module \
--with-cc-opt='-g -O2 -ffile-prefix-map=/home/builder/debuild/nginx-1.28.0/debian/debuild-base/nginx-1.28.0=. -fstack-protector-strong -Wformat -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fPIC' \
--with-ld-opt='-Wl,-z,relro -Wl,-z,now -Wl,--as-needed -pie' \
--add-dynamic-module=/app/nginx-build/nginx-acme
$ make && \
make modules && \
make install
# Run the configuration script — the key option here is --add-dynamic-module
# Note: you must include all of your current NGINX compile flags here (you can check them via nginx -V)
# Compile the module — be sure to run make modules instead of make install
Step 4: Load and enable module
After successful compilation, a .so file will be generated under the objs directory. You need to copy it into NGINX’s module directory. If you ran make install as above, manual copying may not be necessary.
Below is a fully functional nginx.conf file:
# /app/nginx/conf/nginx.conf
user nginx;
error_log error.log debug;
pid nginx.pid;
load_module modules/ngx_http_acme_module.so;
events {
worker_connections 1024;
multi_accept on;
}
http {
include mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$host" "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log access.log main;
sendfile on;
tcp_nopush on;
charset utf-8;
keepalive_timeout 65;
gzip on;
resolver 8.8.8.8 1.1.1.1;
acme_issuer letsencrypt {
uri https://acme-v02.api.letsencrypt.org/directory;
contact mailto:[email protected];
state_path acme/letsencrypt;
accept_terms_of_service;
}
acme_shared_zone zone=acme_shared:1M;
server {
listen 443 ssl;
server_name ssl.aidig.co;
acme_certificate letsencrypt;
ssl_certificate $acme_certificate;
ssl_certificate_key $acme_certificate_key;
ssl_certificate_cache max=2; # required ngx 1.27.4+
location / {
default_type text/plain;
return 200 'OK';
}
}
server {
listen 80 default_server;
server_name _;
location / {
return 301 https://$host$request_uri;
}
}
}
After completing the steps above and restarting NGINX, the ACME module will be loaded and ready.
# Validate configuration syntax
$ cd /app/nginx/
$ ./sbin/nginx -c conf/nginx.conf -t
# Start
$ ./sbin/nginx -c conf/nginx.conf
# Reload after config changes
$ ./sbin/nginx -c conf/nginx.conf -s reload
Core Mechanism & Configuration Details of Ngx-ACME
Before showing the specific configuration for the ACME module, it’s important to emphasize: a full HTTPS setup in production should include various TLS optimization directives for performance and security (for example, ssl_session_cache / ssl_session_timeout for session reuse to reduce latency; ssl_protocols and ssl_ciphers to define supported secure protocols and cipher suites).
To keep this article focused on the core topic, the configuration shown below includes only the minimal settings necessary to implement ACME automation.
The whole flow is simplified into pure NGINX configuration directives — intuitive and powerful.
Define ACME Issuer (acme_issuer)
http {
resolver 127.0.0.1:53;
# Optional directive acme_shared_zone, used to store certificates, private keys, and challenge data for all configured issuers. The default zone size is 256K, which you can increase if needed.
acme_shared_zone zone=acme_shared:1M;
# Define an ACME issuer instance named letsencrypt_prod
acme_issuer letsencrypt {
# Specify the directory URL of the ACME provider; here it’s Let's Encrypt’s production environment
uri https://acme-v02.api.letsencrypt.org/directory;
# Provide a contact email address to receive important CA notifications (e.g., certificate expiration)
contact mailto:[email protected];
# Agree to terms of service — required when using Let's Encrypt or similar CAs
accept_terms_of_service;
# Specify the state path for storing the ACME account key (very important)
state_path acme/letsencrypt;
}
}
Declare and Apply ACME Certificates in a server Block
In a server block where HTTPS is needed, you use acme_certificate to declare that certificate issuance is needed, and immediately use dynamic variables from the module to apply them:
server {
listen 443 ssl;
server_name www.example.com;
# Step 1: Declare that this server block enables ACME and specify the previously defined issuer
acme_certificate letsencrypt;
# Step 2: Use dynamic variables to load the certificate and private key managed by the ACME module in memory
ssl_certificate $acme_certificate;
ssl_certificate_key $acme_certificate_key;
ssl_certificate_cache max=2; # ngx 1.27.4+
location / {
default_type text/plain;
return 200 "OK";
}
}
Configure the HTTP-01 Challenge Response Endpoint
server {
# Listen on port 80 as the default server, capturing all HTTP requests
listen 80 default_server;
# Use a wildcard server name to match any host not explicitly matched by other server blocks
server_name _;
# The ACME module will handle `/.well-known/acme-challenge/` requests automatically; this `location` is for all other requests
location / {
# Redirect all non-ACME HTTP traffic to HTTPS
return 301 https://$host$request_uri;
}
}
Conclusion: A Fundamental Shift in Workflow
The introduction of native ACME support in NGINX marks a major advancement in TLS automation management. It is no longer just an incremental improvement on existing workflows, but a complete paradigm shift — transforming certificate management from a fragile external dependency (like Certbot + cron) into a core capability of NGINX itself that addresses many traditional pain points.
This level of deep integration brings:
- Operational reliability: replacing unreliable scheduled jobs with NGINX’s mature event loop eliminates risks of silent failures.
- Unified configuration: embedding certificate lifecycle logic directly in
nginx.confmakes it the single source of truth, aligning perfectly with IaC philosophy. - Security: the entire workflow runs under NGINX’s standard process with low privileges, avoiding unnecessary privilege escalation and narrowing the attack surface.
For modern infrastructures that pursue high automation and stability, this is undoubtedly a meaningful step toward a “configuration-as-everything” future.
References
- https://nginx.org/en/linux_packages.html
- https://github.com/nginx/nginx-acme
- https://github.com/nginx/nginx-acme/blob/main/.github/workflows/ci.yaml#L50
- https://nginx.org/en/docs/http/ngx_http_acme_module.html
- https://blog.nginx.org/blog/native-support-for-acme-protocol
- https://www.rfc-editor.org/rfc/rfc8555.html
- https://christiangn.medium.com/testing-the-native-nginx-http-01-acme-module-5109b42bafe4
Note the post is translated from https://sconts.com/post/nginx-native-acme-support/
No comment for this article.