Repository navigation
Description
Summary
The server reads TLS_CERT_FILE / TLS_KEY_FILE (--tls-cert / --tls-key) once, when it creates its two listeners (infrastructure/server/tls.js → Bun.listen in tcp.js, Bun.serve in http.js). When the certificate is renewed on disk, the running server keeps serving the old one on both ports until it is restarted. There is no reload path: SIGHUP is not handled and terminates the process (exit 129, without the drain that SIGTERM gets).
So every renewal means a full broker restart: every client is disconnected and reconnects, for a change that only concerns future handshakes. With Let's Encrypt that is every ~60 days today, and the CA/Browser Forum schedule takes public certificates down to 47 days by 2029.
This is a feature request, not a bug. Nothing is broken and the clients recover well (numbers below); the question is whether the restart can be avoided.
Verified on 2.9.21 (server + client from npm, Bun 1.4.4).
Repro
# two self-signed certs for localhost: A (current) and B (the "renewed" one)
for c in a b; do
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout $c-key.pem -out $c-cert.pem \
-days 2 -nodes -subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1'
done
cp a-cert.pem live-cert.pem; cp a-key.pem live-key.pem
served() { openssl s_client -connect 127.0.0.1:$1 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256; }
bunqueue start --tcp-port 6921 --http-port 6922 --tls-cert live-cert.pem --tls-key live-key.pem &
served 6921; served 6922 # both: fingerprint of A
cp b-cert.pem live-cert.pem.new && mv live-cert.pem.new live-cert.pem # what an ACME deploy hook does
cp b-key.pem live-key.pem.new && mv live-key.pem.new live-key.pem
served 6921; served 6922 # both: still A
kill -HUP %1 # the server exits (129)The same sequence as a script, with a producer adding one job every 100 ms and a worker connected for the whole run:
bunqueue 2.9.21 (server + client), Bun 1.4.4
1 started with cert A serves: wire A (old), http A (old)
2 cert+key replaced on disk serves: wire A (old), http A (old) (file on disk: B (new), 5 s later)
clients so far producer 70 adds ok / 0 failed, worker 69 jobs / 0 error events
3 SIGHUP process exited (code 129, signal SIGHUP)
4 restart serves: wire B (new), http B (new) (listening again after 0.2 s)
clients across the restart producer 150 adds ok / 0 failed, worker 149 jobs / 0 error events
after a 2 s drain producer 150 adds ok / 0 failed, worker 150 jobs / 0 error events
In the lab the restart is nearly free (bare process, empty database: back in 0.2 s, 0–1 failed adds across runs). In production, where the broker is a container with a healthcheck and a real database, our last renewal took it away for 7 s: producers spooled, workers reconnected, nothing was lost. The cost is small. What we would like to lose is a broker restart as a scheduled side effect of certificate renewal: we restart for no other reason than the certificate, and it lands whenever the renewal does.
What Bun allows (probe on Bun 1.4.4, no bunqueue involved)
I checked what the runtime offers before asking:
1 Bun.serve.reload({tls}) no error; serves A (old) → A (old)
2 Bun.listen.reload({tls}) no error; serves A (old) → A (old)
3 Bun.listen replace no error; 8 new handshakes serve {B (new)}; connection opened before the swap: alive before=true, after=true
4 Bun.serve replace no error; 8 new handshakes serve {B (new)}
reload({ tls })is accepted and silently ignored on both primitives, so an in-place swap is not available.- Replacing the listener works: open a second listener on the same port with
reusePort: trueand the new cert, thenstop()the first one without closing its sockets. New handshakes get the new certificate; a TLS connection opened before the swap kept working on the old one. - A bad pair is refused before anything changes:
Bun.listenthrowsKEY_VALUES_MISMATCHon a cert/key mismatch andENOENTon a missing file, so the old listener can simply stay up.
Roughly, for the wire listener (the socketHandlers object is the same, so the connection registry and every open socket are untouched):
const listen = () =>
Bun.listen({ hostname, port, reusePort: true, tls: loadTlsOptions(config.tls), socket: socketHandlers });
let server = listen();
function reloadTls() {
const next = listen(); // throws on a bad pair → keep serving the old cert
server.stop(); // stop accepting on the old listener, its sockets stay open
server = next;
}Not tested: WebSocket / SSE clients across the replacement of the Bun.serve listener, reusePort outside Linux, and the unix-socket variant of the HTTP server.
Asks
- Reload cert and key without a restart — on
SIGHUP, and/or when the files change — so that new handshakes use the renewed certificate and established connections are left alone. - On a pair that cannot be loaded, keep serving the current certificate and log the error instead of exiting.
- One log line per reload (the new certificate's expiry is enough), so a deploy hook can tell that it took effect.
If a reload is not something you want in the server, handling SIGHUP explicitly (ignore, or the same drain as SIGTERM) would still be an improvement over the default termination.
Use case
A broker with a publicly trusted certificate (Let's Encrypt), renewed by an ACME client that runs next to it and copies cert and key into the directory the container mounts. Today the deploy hook has to be docker compose restart; with a reload it becomes docker kill -s HUP, and renewing a certificate stops being an availability event.
Activity
feat(tls): reload the certificate on SIGHUP without a restart (#189)
egeominotti commentedon Nov 13, 2026
Thanks for the report, and for the Bun probe: it was the basis of the implementation. This shipped in 2.9.22 (release, release post), on npm, in the Docker images and in the binaries.
What you get
With TLS configured,
SIGHUPre-reads the cert and key from the paths the server was started with and serves them without a restart:https://,wss://, SSE) get the renewed certificate. Connections that are already open stay on the socket and the certificate they were opened with; the regression tests keep a TLS TCP connection and a WebSocket open across the swap.TLS reload failed; still serving the previous certificatewith the reason. The reload is all-or-nothing across the two listeners.TLS certificate reloadedwith the new certificate'ssubject,notAfterandfingerprint256, so a deploy hook can confirm it took effect.It works the way your probe showed: Bun ignores
reload({ tls }), so each listener is replaced by a new one bound on the same port withSO_REUSEPORT, and the old one stops accepting without closing its sockets. Two things on top of that:SO_REUSEPORTdoes not let a second broker silently share the port.Guide: Renewing certificates.
Things to know for your setup
docker kill -s HUPas a manual stop: the reload works, but the restart policy will not restart the container after its next exit. To keep the policy, reload from inside the container (docker exec <container> kill -HUP 1on alpine,docker exec <container> sh -c 'kill -HUP 1'on debian and slim), or run the container with--stop-signal SIGTERM/ Composestop_signal: SIGTERM(the only option on distroless). Details: Reloading in Docker.SIGHUP, so a half-written pair is never picked up by itself. ASIGHUPthat lands between the two file writes is refused as a mismatch and the next one takes the complete pair.SIGHUPis unchanged. I did not add the explicit handler you suggested as a fallback: installing any handler would override an inherited "ignore" (nohup), and a non-TLS broker started that way would begin to stop on an SSH logout. With TLS the signal now reloads; without it the server installs no handler, exactly as before.The full list of behavior changes is in the 2.9.22 upgrade notes.
I am leaving this open: if you can run your repro (and your real deploy hook) against 2.9.22, I would like to know whether it behaves as expected in your container setup, in particular the restart-policy point above.
assantech commentedon Nov 14, 2026
Confirmed on 2.9.22 (image
ghcr.io/egeominotti/bunqueue:2.9.22, client 2.9.22, Bun 1.4.4, Docker 29.5.3) — thank you, all three asks are there and the note on the restart policy saved us a mistake: our hook was going to bedocker kill -s HUP.The repro from the issue, unchanged. Step 3 used to be
process exited (code 129, signal SIGHUP):Bare process, a wider check (35/35):
fingerprint256andnotAfter.wss://…/wssubscription opened before the reload stay open, and the WebSocket keeps receiving events.SIGHUPbetween the two writes), a missing key file, a cert file that is not PEM. The next good pair loads.ss -ltn), the process's fd count unchanged (16 → 16), still 0 failed adds.Failed to listen at 0.0.0.0), soSO_REUSEPORTdoes not let it in. Without TLSSIGHUPstill ends the process (129), as documented.SIGTERMafter the reloads exits 0 and frees both ports.Our container setup (26/26). The broker runs with Compose
init: true, so PID 1 isdocker-initand bunqueue is its child (we need it to reap thessl_clientorphans of the https healthcheck), pluscap_drop: ALL,no-new-privileges,restart: unless-stopped, the certificate directory mounted read-only:docker exec <container> kill -HUP 1works in that shape too: docker-init forwards the signal, both listeners serve the new certificate,StartedAtandRestartCountdo not change, the healthcheck stays healthy, a producer and a worker connected through the published port lose nothing. Maybe worth one line in the guide: with--initPID 1 is not bunqueue and the command is still the right one.kill -9of the bunqueue process) is restarted byunless-stopped. Afterdocker kill -s HUP <container>the reload works as well, but the same crash leaves the containerexited: Docker took the signal for a manual stop.docker stopends the broker in 0.5 s, exit code 0.Our real deploy hook now does this at a renewal: copy cert and key (write aside, rename over),
docker exec <container> kill -HUP 1, then wait until both ports serve the certificate that is on disk (openssl s_clienton loopback, fingerprint compared with the file) and fail with yourTLS reload failed…line otherwise. It repeats the comparison at every run of its timer, so a signal that was lost (SIGHUP ignored: …) is sent again. On a mismatched pair it exits 1 and the broker keeps serving, which is what we wanted from ask 2.One thing for whoever updates the hook before the image: on 2.9.20 the same
kill -HUP 1ends the broker and the restart policy brings it back, so the image has to reach the host first.The rest of our regression set is green on 2.9.22 as well (state enumeration over TCP and HTTP, #97/#110, #98, #100, #101, #102, #113, #118, #155, TLS, reconnect after a 90 s outage and after a half-open link); first boot of 2.9.22 on a 90 MB database written by 2.9.20: no migration, healthy in 0.44 s. A 2.9.20 client against the 2.9.22 broker, our rollout window, behaves the same. Our pin moves from 2.9.20 to 2.9.22; fine to close from our side.