Reloading TLS Certificates Without Restarting¶
Certificates expire — every 90 days for many automated CAs, every few hours for some internal ones — and a service that must restart to pick up a new certificate turns routine renewal into a deployment. An asyncio TLS server can switch certificates in place, because the ssl.SSLContext it was started with is consulted on every handshake. The question is how to switch safely. Measured on Python 3.14 with an asyncio.start_server TLS listener: calling load_cert_chain with the new files on the live context took 0.09 ms; a connection opened afterwards received the new certificate, and a connection opened before it kept working with the old one. But when the new bundle was broken — a certificate paired with the wrong key — load_cert_chain raised KEY_VALUES_MISMATCH and left the live context unusable: the next client connection failed with ConnectionResetError. Building and validating a separate context first, and switching to it through an sni_callback, rejected the broken bundle, kept serving the old certificate, and switched cleanly when a good one arrived. This guide implements the safe version.
Prerequisites¶
- Python 3.7+ for
sni_callback(tested on 3.14);pip install trustmefor testing. - TLS servers in asyncio, from measuring TLS handshake cost in async services.
- Signal-driven reloads, from reloading configuration on SIGHUP in asyncio.
1. See that the live context is consulted per handshake¶
Start a TLS server, connect, reload, connect again:
server_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
server_ctx.load_cert_chain("old.pem")
server = await asyncio.start_server(handler, "127.0.0.1", 8443, ssl=server_ctx)
reader_a, writer_a = await connect() # sees the old certificate
server_ctx.load_cert_chain("new.pem") # 0.09 ms, on the running server
reader_b, writer_b = await connect() # sees the new certificate
writer_a.write(b"ping\n") # A keeps working, still on the old one
Measured: connection A presented the old certificate's serial before and after the reload; connection B, opened after it, presented the new serial; A continued to exchange data normally. Certificates only matter during the handshake, so existing connections are unaffected and keep their original certificate until they close. The reload is fast enough to do on the event loop thread, though file reads for large bundles belong in a thread.
Verify: after a reload, openssl s_client -connect host:port shows the new certificate's expiry date.
2. Do not reload into the live context¶
The failure case is the important one. load_cert_chain is not atomic with respect to the context's state: when it fails partway, the context can be left without a usable key pair:
try:
server_ctx.load_cert_chain("broken.pem") # new cert, old key
except ssl.SSLError as exc:
log.error("reload failed: %s", exc) # [X509: KEY_VALUES_MISMATCH] ...
# Measured: the next client connection failed with ConnectionResetError.
Renewal tooling fails in exactly this way often enough to plan for — a certificate written before its key, a half-written file read mid-rotation, a key that does not match after a CA reissue. Reloading into the live context turns any of those into an outage for every new connection, which is worse than not reloading at all.
Verify: a test that reloads a mismatched bundle confirms that new connections still succeed afterwards.
3. Validate a new context, then swap it in¶
Build the new context completely — loading the chain proves the key and certificate match — and only then make handshakes use it. An sni_callback on the listening context can replace the context for each new connection:
import ssl
class CertStore:
def __init__(self, cert_path: str, key_path: str) -> None:
self.current = self._build(cert_path, key_path)
@staticmethod
def _build(cert_path: str, key_path: str) -> ssl.SSLContext:
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(cert_path, key_path) # raises on a mismatched or unreadable pair
return ctx
def reload(self, cert_path: str, key_path: str) -> bool:
try:
self.current = self._build(cert_path, key_path) # swap only after success
except (ssl.SSLError, OSError) as exc:
log.error("certificate reload rejected, keeping the current one: %s", exc)
return False
log.info("certificate reloaded")
return True
def listening_context(self, cert_path: str, key_path: str) -> ssl.SSLContext:
ctx = self._build(cert_path, key_path)
ctx.sni_callback = lambda sslobj, server_name, _ctx: setattr(sslobj, "context", self.current)
return ctx
store = CertStore("cert.pem", "key.pem")
server = await asyncio.start_server(handler, "0.0.0.0", 8443,
ssl=store.listening_context("cert.pem", "key.pem"))
Measured: the broken bundle was rejected with the mismatch error and new connections still received the original certificate; a subsequent good bundle switched new connections to the new certificate. The callback ran for clients that sent SNI and for clients that did not — it was called with server_name=None when the client connected without a hostname — so the swap applies to every handshake. Assigning self.current is a single reference replacement, so handshakes see either the whole old context or the whole new one.
Verify: inject a mismatched bundle in staging; the reload logs an error and the service keeps serving the previous certificate.
4. Trigger reloads from signals or file changes¶
Renewal tools typically write new files and then signal the process, or simply write the files and expect the service to notice. Support both:
import signal
async def main() -> None:
store = CertStore(CERT, KEY)
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGHUP, lambda: store.reload(CERT, KEY))
async def watch() -> None:
last = os.stat(CERT).st_mtime_ns
while True:
await asyncio.sleep(30)
mtime = os.stat(CERT).st_mtime_ns
if mtime != last:
await asyncio.sleep(1) # let the writer finish both files
if store.reload(CERT, KEY):
last = mtime
async with asyncio.TaskGroup() as tg:
tg.create_task(watch())
server = await asyncio.start_server(handler, "0.0.0.0", 8443,
ssl=store.listening_context(CERT, KEY))
await server.serve_forever()
The signal handler gives operators and renewal hooks an explicit trigger; the poll catches renewals that only write files. The short pause before reloading avoids reading a certificate whose key has not been written yet, and a failed reload leaves last unchanged so the next poll retries. Polling every 30 seconds is plenty for certificates measured in days; an inotify-based watcher, as in watching files for changes in asyncio, reacts faster.
Verify: kill -HUP <pid> and an updated certificate file each produce a "certificate reloaded" log line and a new certificate on the next connection.
5. Monitor expiry, not just reloads¶
A reload mechanism that silently keeps serving an old certificate after repeated failures delays the outage until the old certificate expires. Export the expiry of the certificate actually being served:
import datetime
from cryptography import x509
def served_expiry(cert_path: str) -> float:
with open(cert_path, "rb") as f:
cert = x509.load_pem_x509_certificate(f.read())
return cert.not_valid_after_utc.timestamp()
# in CertStore.reload(): after a successful swap
CERT_EXPIRY_SECONDS.set(served_expiry(CERT))
# ...and in its except branch
CERT_RELOAD_FAILURES.inc()
Alert when the served certificate expires within a week, and on any reload failure. Together they catch both "renewal stopped happening" and "renewal happens but is rejected". Clients have the mirror-image problem when they pin or cache certificates; the general advice in reusing SSL contexts in async clients — trust CAs, not individual certificates — keeps server-side rotation invisible to them.
Verify: the expiry metric changes after a reload, and a test certificate that expires in five days fires the alert.
Verification¶
Certificate rotation is safe when:
- New certificates load into a fresh context, never into the live one.
- Handshakes pick up the current context through
sni_callback, with or without SNI. - Reloads are triggered by a signal and by file changes, and failures leave the old certificate serving.
- The served certificate's expiry and reload failures are monitored.
Diagnostic Hook: after each renewal window, compare the expiry date reported by openssl s_client against the one on disk. If the file is newer than what the server presents, reloads are failing or not being triggered, and the reload-failure counter or the absence of a "certificate reloaded" log line says which.
Pitfalls & edge cases¶
- Reloading into the live context. Measured: a mismatched bundle made the next connection fail.
- Reading files mid-write. Wait briefly after a change, and retry on failure.
- Silent fallback forever. Alert on reload failures and on approaching expiry.
- Expecting existing connections to switch. They keep the certificate from their handshake.
Frequently Asked Questions¶
How do I reload a TLS certificate in a Python asyncio server without restarting?
Build a new SSLContext with load_cert_chain to validate the new files, then swap it in for new handshakes with an sni_callback on the listening context that sets sslobj.context to the current one. Existing connections keep their certificate.
Can I call load_cert_chain on a running server's SSLContext?
It works for a valid bundle — new connections got the new certificate after a 0.09 ms reload — but a bad bundle left the context broken and the next connection failed with ConnectionResetError. Validate in a separate context first.
Does the sni_callback run when clients don't send SNI?
Yes: in testing it was called with server_name None for a client connecting without a hostname, so the context swap applies to every handshake.
Do existing TLS connections pick up a new certificate?
No. Certificates are only exchanged during the handshake; existing connections continue with the certificate they started with until they close.
Related¶
- TLS & DNS — up to the topic overview.
- Upgrading connections with STARTTLS in asyncio — TLS that starts mid-connection.
- Network I/O & Protocol Handling — the section overview.