sqi can terminate TLS in-process on every listener it opens: sqi-server's REST
API, WebSocket gateway and embedded web UI on one listener, its embedded NATS
broker on another, and the worker's own metrics and health endpoint on a third.
Workers can optionally be required to present a client certificate.
TLS is off by default. A sqi-server started with no TLS configuration
behaves exactly as it did before TLS existed: one plaintext HTTP listener, one
plaintext broker. Everything below is opt-in.
Running behind a TLS-terminating reverse proxy is equally supported and is what many deployments will do anyway — see Reverse proxy.
| Surface | Covered by | Notes |
|---|---|---|
| REST API, WebSocket, web UI | http.tls |
One listener; it upgrades in place |
| Worker ↔ broker (assignments, status, logs) | nats.tls |
Closes the cleartext gap that broker authentication alone does not |
Worker enrollment (POST /api/v1/workers/enroll) |
http.tls server-side, nats.server_tls_ca_file worker-side |
Runs over REST before the worker holds any credential |
| Outbound LDAP / OIDC | Their own settings (auth.ldap.*, auth.oidc.*) |
Unchanged by anything here |
Worker metrics / health (metrics.addr) |
metrics.tls (worker) |
Loopback by default, where plaintext is fine; turn TLS on with any address reachable from elsewhere, since this carries metrics, health and — with enable_pprof — full runtime profiles |
Two things TLS does not do:
- It is not authentication. Broker TLS encrypts the worker transport; it does
not decide which worker is connecting. That remains the per-worker nkey
credential — see
docs/auth.md. The two are independent and a production farm wants both. - It does not protect data at rest. The SQLite database, JetStream's file-backed streams and worker session directories are unaffected.
http:
tls:
enabled: false
cert_file: "" # PEM certificate: leaf first, then any intermediates
key_file: "" # PEM private key matching cert_file
nats:
tls:
enabled: false
cert_file: ""
key_file: ""
client_ca_file: "" # set = require and verify worker client certificatesEnvironment: SQI_HTTP_TLS_ENABLED, SQI_HTTP_TLS_CERT_FILE,
SQI_HTTP_TLS_KEY_FILE, SQI_NATS_TLS_ENABLED, SQI_NATS_TLS_CERT_FILE,
SQI_NATS_TLS_KEY_FILE, SQI_NATS_TLS_CLIENT_CA_FILE.
The two blocks are deliberately independent. An operator may legitimately serve the API with a publicly-issued certificate and the broker with a private farm CA, or turn on one without the other.
There is no minimum-version or cipher-suite setting. Go's server default is already TLS 1.2 minimum, and a knob there is a footgun with no farm use case.
nats:
url: "nats://sqi-server.example:4222"
tls_enabled: "auto" # "auto" | "true" | "false"
tls_ca_file: "" # CA that verifies the BROKER certificate
tls_cert_file: "" # client certificate, for mTLS
tls_key_file: ""
server_url: "https://sqi-server.example:8080"
server_tls_ca_file: "" # CA that verifies the API certificate
server_tls_insecure_skip_verify: false
metrics:
addr: "127.0.0.1:9091"
tls: # the worker's own metrics listener
enabled: false
cert_file: ""
key_file: ""Environment: SQI_WORKER_NATS_TLS_ENABLED, SQI_WORKER_NATS_TLS_CA_FILE,
SQI_WORKER_NATS_TLS_CERT_FILE, SQI_WORKER_NATS_TLS_KEY_FILE,
SQI_WORKER_NATS_SERVER_TLS_CA_FILE,
SQI_WORKER_NATS_SERVER_TLS_INSECURE_SKIP_VERIFY,
SQI_WORKER_METRICS_TLS_ENABLED, SQI_WORKER_METRICS_TLS_CERT_FILE,
SQI_WORKER_METRICS_TLS_KEY_FILE.
metrics.tls is independent of everything else here: it protects the worker's
own listener, not its connection to the server. A worker needs a certificate of
its own for it — issue one with sqi-server tls issue --host <worker-host>, or
use whatever your monitoring stack already trusts.
tls_ca_file and server_tls_ca_file are separate keys on purpose: they verify
two different certificates, on two different ports, which can legitimately come
from different issuers. On a farm generated by sqi-server tls init they are
simply the same ca.crt.
tls_enabled is a three-valued string, not a bool — the same shape as the
server's auth.session.cookie_secure:
| Value | Meaning |
|---|---|
"auto" (default) |
Use TLS when there is a reason to: any tls_* field is set, or the discovered server advertises a TLS-required broker |
"true" |
Always use TLS, even with no CA configured (system roots) — for a broker presenting a publicly-trusted certificate, which nothing else would signal |
"false" |
Never use TLS, even with a CA configured — an explicit override for an operator who knows the broker is plaintext |
A bool cannot express this: the interesting states are infer and force off,
and a bool collapses both onto its zero value. The usual boolean spellings
(1/yes/on, 0/no/off) are accepted as synonyms in both the file and
the environment; anything else is rejected at startup rather than silently
falling back to auto.
A worker that finds its server over mDNS reads the nats_tls TXT record the
server advertises. Under "auto" that record is a reason to use TLS, so a
discovered TLS farm connects over TLS with nothing configured on the worker at
all. Two consequences worth knowing:
- With no
tls_ca_file, the broker certificate is verified against the system roots, so a farm CA fails with a certificate error naming the issuer. The worker warns about exactly this at startup. Copyca.crtover and settls_ca_file. - Under
"false"the advertisement is not overridden — false means false — but the worker warns that the connection is about to be refused, because the broker's own error will not mention the setting responsible.
An explicit nats.url discovers nothing, so none of this applies: an operator
who configures the URL by hand configures the transport by hand.
Uniform in every component: an empty CA setting means the system roots; a CA setting means that CA only. Pointing a worker at a farm CA pins the acceptable issuer rather than adding to the public set. A deployment using publicly-issued certificates leaves the CA settings empty.
Certificate problems are refused at load, with a message naming the config key. Nothing about a certificate is allowed to fail for the first time at connect time.
| Condition | Result |
|---|---|
enabled: true with an empty cert_file or key_file |
error at startup |
| File missing, unreadable, or key/certificate mismatch | error at startup |
| Certificate already expired | error at startup |
| Certificate expires within 30 days | WARN at startup |
Certificate files set while enabled: false |
WARN — "serving plaintext" |
nats.tls.client_ca_file set while nats.tls.enabled: false |
error at startup |
An expired certificate is an error rather than a warning because a server that boots with one is a server whose entire farm fails to connect — strictly worse than refusing to boot with an actionable message.
sqi-server tls init --out ./certs --host sqi-server.exampleWrites a farm CA (ca.crt, ca.key — ECDSA P-256, 10 years) and a server
certificate (server.crt, server.key — 2 years) covering the hosts given by
--host, plus localhost, 127.0.0.1 and ::1 in every case. Private keys are
written 0600; certificates 0644. On success it prints the exact configuration
keys to set on each side.
--host is repeatable. Loopback is always included even when --host names
specific hosts: a certificate naming only the LAN host cannot be verified from the
machine itself, which breaks local health probes and any client reaching the server
by the name it actually runs under.
For the optional mTLS path, issue per-worker client certificates:
sqi-server tls init --out ./certs --host sqi-server.example --client render-01 --client render-02The command refuses to overwrite an existing ca.key. Replacing a farm CA
invalidates every certificate ever issued from it, so that has to be a deliberate
act: move the old directory aside first.
That refusal means tls init cannot be re-run to add a worker later — use
tls issue.
# add a worker to a farm that already exists
sqi-server tls issue --out ./certs --client render-07
# rotate the server certificate (replaces server.crt, so --force)
sqi-server tls issue --out ./certs --host sqi-server.example --forcetls issue signs from the CA already in --out and never touches it, so every
certificate already deployed keeps working. Distribute the new
client-<id>.crt/.key to that worker and set nats.tls_cert_file /
nats.tls_key_file; no other worker needs to change.
It refuses to replace an existing file without --force. A client key that
is already deployed belongs to a running worker, and replacing it takes that
worker offline at its next restart with nothing to indicate why.
A rotated server certificate is only picked up on restart — certificates are read once, at startup.
The command exists because SANs and extended key usage are exactly what
hand-rolled openssl invocations get wrong, and getting them wrong fails at
worker-connect time — far from the mistake — rather than at load. Any CA works;
this one just makes the common case correct by default.
Setting nats.tls.client_ca_file makes the broker require a client certificate
signed by that CA from every connecting worker.
This layers on the nkey credential rather than replacing it. The certificate gates who may open a connection; the nkey decides which worker they are, and remains what drives subject permissions, per-worker inbox prefixes and revocation. A valid farm certificate on its own authenticates nobody.
sqi does not bind a client certificate to a worker ID: any enrolled nkey may be presented over any valid farm client certificate. If you need certificate-to-worker binding, that is not available today.
The server's own connection to its own broker is exempt from the client-certificate requirement. It runs in-process over a pipe rather than the network, and requiring a certificate of the process that already holds the broker's private key would be meaningless. Network peers are never exempt.
This is a coordinated restart, not a rolling one. When TLS is enabled the listener upgrades in place — there is no plaintext port left — and a worker configured for TLS cannot talk to a plaintext broker. There is no window in which both old and new workers can connect.
The supported order:
sqi-server tls init --out ./certs --host <server-name>- Copy
ca.crtto every worker. - Stage each worker's
tls_ca_fileandserver_tls_ca_filein its config, but do not restart the workers yet. - Set
http.tlsandnats.tlson the server, then restart the server and all workers together.
Rotation requires a server restart. Certificates are read once, at startup, and validated before the listener opens; there is no hot-reload. Replacing the files under a running server has no effect until it restarts.
Renew before expiry: the server refuses to start with an expired certificate, and warns for 30 days beforehand.
Terminating TLS at nginx, Caddy, Traefik or a cloud load balancer is fully supported and needs no TLS configuration in sqi. Two things to get right:
- Set
X-Forwarded-Proto: https. Session cookies default toauth.session.cookie_secure: "auto", which resolvesSecurefrom the request's TLS state or that header. Without it, cookies issued behind a proxy are not markedSecure. The CSRF origin check reads the same signal. - The link from the proxy to sqi is still plaintext unless you also enable
http.tlsand point the proxy at HTTPS. On a single host over loopback that is usually fine; across a network it is not.
A reverse proxy in front of the API does nothing for the broker. Workers
connect to the NATS port directly, so nats.tls is the only thing that encrypts
assignments, status and log chunks.
curl --cacert ./certs/ca.crt https://sqi-server.example:8080/readyzOn Windows, add --ssl-revoke-best-effort:
# curl.exe, not curl: Windows PowerShell aliases `curl` to Invoke-WebRequest.
curl.exe --cacert .\certs\ca.crt --ssl-revoke-best-effort https://sqi-server.example:8080/readyzWindows curl builds link Schannel, which insists on checking certificate
revocation. The CA tls init generates publishes no CRL or OCSP endpoint, so
the request otherwise fails with schannel: the revocation status is unknown
(exit 60) against a chain that is perfectly valid. The flag relaxes only the
revocation check — an untrusted root is still rejected, so the command keeps
proving the server presents a certificate signed by your CA. This affects curl
only; sqi's own components verify with Go's TLS stack and need no such flag.
The startup log states the transport plainly:
level=INFO msg="http: listening" addr=0.0.0.0:8080 tls=true url=https://localhost:8080
A worker that fails to connect over TLS reports the reason rather than retrying
silently: a certificate signed by an unknown CA fails verification with that named
as the cause, and a worker with no TLS configuration attempting a TLS-required
broker fails with secure connection not available.
The server advertises tls=1 / nats_tls=1 in its mDNS TXT records when the
respective listener is TLS-terminated, and a discovering worker acts on
nats_tls — see Discovery fills in "auto".
scripts/smoke.sh runs its whole flow in three modes — plain, broker-auth and TLS
— so the TLS path is exercised end to end on every CI run.
docs/auth.md— the authentication model, including broker authentication and worker enrollmentdocs/configuration.md— every configuration keydocs/architecture.md— NATS subjects and data flow