I'll admit that I still manually renew many ssl certificates by hand but the reality is that certificates can now only be issued for less than a year and next year it'll only be valid for around three months. In 2029 - it'll only be valid for a little over a month so having automatic tls renewal is something that a lot of people are going to end up needing. While you could have your certbot cut the new cert in a ci/cd deploy cycle we wanted the capability for your site to auto-renew itself in a nanos unikernel as well.
While, this ACME implmentation relies on having a domain publically accessible for the challenge request/response cycle we can also run this locally with Pebble, which is a smaller test version of Boulder, which LetsEncrypt uses.
Run Pebble
Pebble will run as two tools here. The challtestsrv is a stand alone mock dns server.
pebble-challtestsrv -defaultIPv6 ""
sudo pebble -config pebble-config.json -dnsserver 127.0.0.1:8053
Create a certificate that will renew in ~2min:
Now you can create a fake certificate for 'exampl.com'. Here we use 'faketime' to mock out the system time for openssl so that we can get a certificate that is going to renew within the next few minutes. This is just for test purposes so you can see the flow of things. After generating it we copy the cert and the key over to our nginx package.
faketime '958 minutes ago' openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt -days 1 -subj "/CN=exampl.com"
mv server.crt ~/.ops/packages/amd64/eyberg/nginx_1.28.3/sysroot/etc/ssl/certs/.
mv server.key ~/.ops/packages/amd64/eyberg/nginx_1.28.3/sysroot/etc/ssl/private/server.key
Then we can run nginx as a test:
ops pkg load eyberg/nginx_1.28.3 -c config.json -n -p 8083 -p 4443
You may also wish to turn on the acme debug logging in nanos to see when things might renew:
[0.113829, 0, acme] cert at /etc/ssl/certs/server.crt valid until 1792547020, will renew at 1792518220
go example:
Let's try a simple go webserver that reloads certs when they are changed. This is a lot more code than a normal go webserver has, but it comes with the automagically loading cert feature.
package main
import (
"fmt"
"crypto/tls"
"net/http"
"os"
"sync"
"time"
)
type FileReloader struct {
sync.RWMutex
certFile string
keyFile string
cached *tls.Certificate
modTime time.Time
}
func NewFileReloader(certFile, keyFile string) *FileReloader {
return &FileReloader{
certFile: certFile,
keyFile: keyFile,
}
}
func (fr *FileReloader) GetCertificate(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
stat, err := os.Stat(fr.certFile)
if err != nil {
return nil, err
}
fr.RLock()
upToDate := fr.cached != nil && !stat.ModTime().After(fr.modTime)
fr.RUnlock()
if upToDate {
return fr.cached, nil
}
fr.Lock()
defer fr.Unlock()
if fr.cached != nil && !stat.ModTime().After(fr.modTime) {
return fr.cached, nil
}
cert, err := tls.LoadX509KeyPair(fr.certFile, fr.keyFile)
if err != nil {
return nil, err
}
fr.cached = &cert
fr.modTime = stat.ModTime()
return fr.cached, nil
}
func main() {
go func() {
dir := "./"
fs := http.FileServer(http.Dir(dir))
http.Handle("/", fs)
fmt.Println("Starting HTTP server on :8083...")
if err := http.ListenAndServe(":8083", nil); err != nil && err != http.ErrServerClosed {
fmt.Println("oh no")
os.Exit(1)
}
}()
reloader := NewFileReloader("server.crt", "server.key")
server := &http.Server{
Addr: ":4443",
TLSConfig: &tls.Config{
GetCertificate: reloader.GetCertificate,
},
}
server.ListenAndServeTLS("", "")
}
Here we run a http server alongside our https one so that the challenge request/response can happen. For this example we can use this config:
{
"BaseVolumeSz": "100m",
"Klibs": ["acme", "tls"],
"ManifestPassthrough": {
"acme": {
"server": "https://10.0.2.2/dir",
"web_dir": "/",
"email": "my@email.com",
"certificates": [
{
"cert_chain": "server.crt",
"private_key": "server.key",
"domains": ["exampl.com"]
}
]
},
"log_level": "info"
}
}
Finally, let's run it:
eyberg@venus:~/ng/goex$ ops run -c config.json -p 8083 -n -p 4443 goexOn the pebble side of things you should see some logs indicating the order happening:
Pebble 2026/10/21 01:17:21 Attempting to validate w/ HTTP: http://exampl.com:8083/.well-known/acme-challenge/FXHX4tE8Wdm1KILcairt6n0p-b2E9JMkQO5pky8EQG0
Pebble 2026/10/21 01:17:21 POST /my-order/ -> calling handler()
Pebble 2026/10/21 01:17:22 Attempting to validate w/ HTTP: http://exampl.com:8083/.well-known/acme-challenge/FXHX4tE8Wdm1KILcairt6n0p-b2E9JMkQO5pky8EQG0
Pebble 2026/10/21 01:17:23 Attempting to validate w/ HTTP: http://exampl.com:8083/.well-known/acme-challenge/FXHX4tE8Wdm1KILcairt6n0p-b2E9JMkQO5pky8EQG0
Pebble 2026/10/21 01:17:23 authz ODUHn5YPII-OtNvFKvIJ76TrCsB4LalCdOhn3LtGT-E set VALID by completed challenge N4ywhWH2NgXqBYcSPgzbG7-E95gYbSgCMkKSX7eRkfM
Pebble 2026/10/21 01:17:26 POST /my-order/ -> calling handler()
Pebble 2026/10/21 01:17:26 POST /finalize-order/ -> calling handler()
Pebble 2026/10/21 01:17:26 POST /finalize-order/ -> calling handler()
Pebble 2026/10/21 01:17:26 Order NJsYa2pvWKaSzZRv6YcTIgYM8sTQsYzWkptQtVtVFEA is fully authorized. Processing finalization
Pebble 2026/10/21 01:17:26 Issued certificate serial 3f0ce25a1c22107f for order NJsYa2pvWKaSzZRv6YcTIgYM8sTQsYzWkptQtVtVFEA
Pebble 2026/10/21 01:17:31 POST /my-order/ -> calling handler()
Pebble 2026/10/21 01:17:31 POST /certZ/ -> calling handler()
If you have enabled logging on the nanos acme side of things you'll see this log output:
[10.201603, 0, acme] polling order status at https://10.0.2.2/my-order/NJsYa2pvWKaSzZRv6YcTIgYM8sTQsYzWkptQtVtVFEA
[10.226533, 0, acme] nonce: 'CFEvpS1vbvNHKoPum1W9sA'
[10.227633, 0, acme] order status: ready
[10.228390, 0, acme] finalizing order at https://10.0.2.2/finalize-order/NJsYa2pvWKaSzZRv6YcTIgYM8sTQsYzWkptQtVtVFEA
[10.297851, 0, acme] nonce: 'e0m1qpTSvg69U055C4qJjw'
[10.455602, 0, acme] nonce: 'ZCj2-z9mISrELZZD6LnuhA'
[10.455967, 0, acme] order status: processing
[15.456383, 0, acme] polling order status at https://10.0.2.2/my-order/NJsYa2pvWKaSzZRv6YcTIgYM8sTQsYzWkptQtVtVFEA
[15.472211, 0, acme] nonce: 'E-xS_mjH3gKyLOUvBYnPEA'
[15.473165, 0, acme] order status: valid
[15.473827, 0, acme] retrieving new certificate from https://10.0.2.2/certZ/3f0ce25a1c22107f
[15.488503, 0, acme] nonce: 'CxvO7VRJTfSgmwTXHDLJbg'
[15.489062, 0, acme] downloaded certificate chain (2404 bytes)
[15.489867, 0, acme] certificate chain saved successfully to server.crt
[15.490654, 0, acme] private key saved successfully to server.key
[15.491309] acme: certificate at server.crt renewed
[15.491740, 0, acme] cert at server.crt valid until 1950311845, will renew at 1897723045.333333
For other types of servers that support it you can even send signals when reloading the certificate such as apache and nginx via the reload config:
"acme": {
"reload": "SIGUSR1"
}
Besides implementing the ACME support itself, a few other changes were made in nanos. For instance instead of having query be a buffer we turned it into a real boy as a sstring as this gets used in many varying places.
We also migrated strncmp out of lwip specific code so that we could use our sstring type in our fork of mbedtls for certificate creation.
We also added support for parsing arrays in our json parser.
We hope this new ACME klib helps you manage your certificate renewals in the future in an easier fashion. Try it out and let us know how it goes.
