Skip to content

TLS: a renewed certificate is only served after a full restart — reload cert/key without dropping connections (SIGHUP or file change) #189

Open

Description

@assantech

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: true and the new cert, then stop() 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.listen throws KEY_VALUES_MISMATCH on a cert/key mismatch and ENOENT on 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

  1. 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.
  2. On a pair that cannot be loaded, keep serving the current certificate and log the error instead of exiting.
  3. 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

  1. egeominotti commented on Nov 13, 2026

    @egeominotti
    Owner

    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, SIGHUP re-reads the cert and key from the paths the server was started with and serves them without a restart:

    # replace both files (write a sibling, then rename it over the live path), then
    kill -HUP <pid>
    • Ask 1, reload without a restart: new handshakes on both ports (TCP and HTTP: 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.
    • Ask 2, keep the current certificate on a bad pair: a pair that cannot be loaded (key does not match, missing or unreadable file, not PEM) is refused before any listener changes, the process keeps running, and it logs TLS reload failed; still serving the previous certificate with the reason. The reload is all-or-nothing across the two listeners.
    • Ask 3, one log line per reload: TLS certificate reloaded with the new certificate's subject, notAfter and fingerprint256, 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 with SO_REUSEPORT, and the old one stops accepting without closing its sockets. Two things on top of that:

    • A TLS listener first makes a plain exclusive probe bind at startup, so SO_REUSEPORT does not let a second broker silently share the port.
    • The HTTP unix-socket variant, which cannot be shared, is rebound in place (keeping the socket file's mode and owner) and goes back to the previous pair if the new bind fails.

    Guide: Renewing certificates.

    Things to know for your setup

    • Docker and the restart policy. The official images declare no stop signal, so Docker records docker kill -s HUP as 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 1 on alpine, docker exec <container> sh -c 'kill -HUP 1' on debian and slim), or run the container with --stop-signal SIGTERM / Compose stop_signal: SIGTERM (the only option on distroless). Details: Reloading in Docker.
    • Linux, during the swap. Both listeners are bound for an instant; a TCP handshake that completed in the old listener's accept queue but was not accepted before it closes is reset, and the client reconnects. Established connections are never affected.
    • No file watching. The reload is signal-only: nothing changes until the server gets SIGHUP, so a half-written pair is never picked up by itself. A SIGHUP that lands between the two file writes is refused as a mismatch and the next one takes the complete pair.
    • Without TLS, SIGHUP is 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.
    • Changing the cert or key path still needs a restart.

    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.

  2. self-assigned this
    on Nov 13, 2026
  3. assantech commented on Nov 14, 2026

    @assantech
    Author

    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 be docker kill -s HUP.

    The repro from the issue, unchanged. Step 3 used to be process exited (code 129, signal SIGHUP):

    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 70 jobs / 0 error events
    3 SIGHUP                       process still running; serves: wire B (new), http B (new)
    

    Bare process, a wider check (35/35):

    • The reload is logged 20 ms after the signal, same pid; the line carries the new certificate's fingerprint256 and notAfter.
    • A producer (one add every 100 ms) and a worker connected for the whole run: 0 failed adds, 0 worker error events, every job consumed. A raw TLS socket and a wss://…/ws subscription opened before the reload stay open, and the WebSocket keeps receiving events.
    • Refused, logged, previous certificate on both ports, process alive: the key of another certificate (a SIGHUP between the two writes), a missing key file, a cert file that is not PEM. The next good pair loads.
    • 20 reloads in a row under the same load: one listener per port at the end (ss -ltn), the process's fd count unchanged (16 → 16), still 0 failed adds.
    • A new client pinned to the replaced certificate is refused, one pinned to the served one connects.
    • A second broker on the same ports fails to start (Failed to listen at 0.0.0.0), so SO_REUSEPORT does not let it in. Without TLS SIGHUP still ends the process (129), as documented. SIGTERM after the reloads exits 0 and frees both ports.

    Our container setup (26/26). The broker runs with Compose init: true, so PID 1 is docker-init and bunqueue is its child (we need it to reap the ssl_client orphans of the https healthcheck), plus cap_drop: ALL, no-new-privileges, restart: unless-stopped, the certificate directory mounted read-only:

    • docker exec <container> kill -HUP 1 works in that shape too: docker-init forwards the signal, both listeners serve the new certificate, StartedAt and RestartCount do 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 --init PID 1 is not bunqueue and the command is still the right one.
    • Restart policy, as you describe it. After the reload from inside, a crashed broker (kill -9 of the bunqueue process) is restarted by unless-stopped. After docker kill -s HUP <container> the reload works as well, but the same crash leaves the container exited: Docker took the signal for a manual stop.
    • No zombie and a flat pids count after the reloads and the healthcheck probes; docker stop ends 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_client on loopback, fingerprint compared with the file) and fail with your TLS 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 1 ends 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions