Background
I was setting up a new web server for awheelmaker.com. I was just expecting a quick migration. The old setup has been running for 4 years. It was was simple: Caddy handled HTTPS termination and certificate renewal, while Node.js served application logic.
When I moved the same configuration to a new server, Caddy refused to complete the ACME((Automated Certificate Management Environment) challenge. As someone who loves building unnecessary tools for necessary problems, I decided to write my own ACME client in Node.js from scratch.
Here comes the result, a simple ACME client, no external dependency. In fact the web site you are reading now is using HTTPS certificate refresh by this ACME client.
Of course I did some digging with help from Ruben Laguna, I realized my Kubernetes (k3s) test cluster had hijacked part of the networking stack, breaking Caddy’s HTTP-01 validation. Sorry Caddy, bye bye.
Philosophy
The project follows a simple rule: use only functionalities provided by Nodejs. That means no express, no request, no forge, no third-party crypto wrappers. Just standard modules like https, http, crypto, and fs.
The entire process from signing ACME payloads to generating CSRs is implemented by hand. It’s not the fastest approach, but it’s completely transparent. Every request, every key, every byte of PEM data is visible and auditable.
Usage
Collect the domain name you want get the HTTPS certificate. And your email address. Then just call to obtainCertificate the request information will be print out during the whole request process.
How ACME.js Works
At its core, ACME.js exports a single async function, obtainCertificate(domain, email). This function either loads an existing certificate from disk or performs the full ACME flow to request a new one.
The logic is built around nested closures rather than classes. Each helper function (signing, nonce fetching, order creation, polling, etc.) captures the shared state through lexical scope. This design eliminates global state while keeping the code modular.
Step 1: Directory and Nonce
The client starts by fetching the ACME directory endpoint, which provides URLs for registration, new orders, nonces, and certificate retrieval. Every ACME request requires a fresh replay-nonce, so ACME.js calls the newNonce endpoint before each signed transaction.
Nonce handling is manual — each time a request completes, the next nonce is extracted from the response header for subsequent requests.
Step 2: Account Creation
A fresh RSA key pair is generated using Node’s built-in crypto.generateKeyPairSync. These keys are used to create a new ACME account tied to the user’s email. The public key is converted to JWK format manually for the JSON Web Signature process.
The private key stays local, ACME.js never uploads it. Instead, it signs payloads to prove key ownership.
Step 3: Order and Authorization
Once the account exists, ACME.js requests a new certificate order for the domain. Let’s Encrypt returns a list of authorizations, one per domain name, each with possible challenge types.
The client searches for an http-01 challenge and sets up a lightweight HTTP server that listens on port 80 to serve the verification token. This is the most critical part: the challenge server must respond with the exact key authorization when Let’s Encrypt requests it.
Step 4: Signing Requests
Every ACME request must be signed with the account private key using JWS (JSON Web Signature). The payload is encoded with base64url rules, concatenated with the header, and signed using RSA-SHA256. Node’s crypto module makes this possible without any external library.
The function sign(payload) returns the base64url signature string. Combined with the protected header and payload, it forms a valid ACME POST body.
Step 5: CSR and Finalization
After the challenge is validated, the client must submit a Certificate Signing Request (CSR). Instead of using a library, ACME.js spawns OpenSSL directly to generate the CSR using the previously created private key. The PEM output is then base64-encoded and sent to Let’s Encrypt’s finalize endpoint.
The resulting certificate (fullchain + private key) is written to ./certs/<domain>/ for reuse and auto-renewal.
openssl req -new -key key.pem -subj "/CN=awheelmaker.com" -out csr.pem
Step 6: Renewal Logic
The script checks existing certificates before contacting Let’s Encrypt. It reads the validity dates directly from the PEM using Node’s crypto.X509Certificate and determines if the cert will expire within seven days. If so, it triggers a full re-issuance. Otherwise, it simply loads the existing PEM files.
Outcome
With ACME.js running, awheelmaker.com now handles HTTPS entirely through Node.js. The process is simple: when the server starts, it checks certificate validity, renews if needed, and uses the resulting PEMs for the HTTPS server. The system is self-contained, explainable, and surprisingly durable. It works under Kubernetes without extra networking tweaks and has no opaque dependencies. Everything is under control, every byte of the ACME exchange is my own.
Reflection
Reimplementing the ACME protocol taught me more about TLS, PKI, and the trust model of the web than any certificate manager ever could. It demystified how HTTPS actually comes alive: from RSA key generation to nonce management to challenge-response validation.
Caddy failed because of a misconfigured network. Not Caddy's fault. But sorry, caddy, bye-bye.
Reinventing a wheel, have fun.
