NATS certificates: generation, TLS, layout, and container deployment
Table of Contents
- 1. Summary
- 2. Detail
- 2.1. Certificate inventory (
build/keys/nats/) - 2.2. Generation (
compass nats certs) - 2.3. IAM JWT signing key (not a certificate)
- 2.4. TLS versions
- 2.5. How the NATS server uses the certificates
- 2.6. How services locate their certificates
- 2.7. How certificates reach containers
- 2.8. Rotation and re-deploy
- 2.9. One-command bring-up (
compass nats ensure) - 2.10. Recovering a drifted local database
- 2.1. Certificate inventory (
- 3. See also
1. Summary
All NATS traffic in ORE Studio runs over mutual TLS. A private internal
CA, an EC P-256 (prime256v1) key pair, self-signs itself for 365 days
and issues two kinds of 90-day leaves: one certificate for the NATS
server itself (SAN localhost + the checkout hostname/IP) and one
mTLS client certificate per service, all under
build/keys/nats/. compass nats certs (a.k.a. nats_certs.py) is the
single generator, idempotent by default; compass env configure calls
it automatically, and compass nats ensure restarts the local server
after rotation so the new certs are actually loaded (the Go broker
reads certs only at startup). The NATS server presents nats-server.crt and
requires a client cert signed by ca.crt (verify: true in the
rendered server config); services locate their certs via the
--nats-tls-ca/cert/key CLI trio (deliberately not part of the
shared-domain env fallback) or, for the shell, the ORES_NATS_TLS_*
environment variables. No TLS version is pinned
anywhere: the Go nats-server (v2.14.3) and cnats/OpenSSL defaults
negotiate TLS 1.2–1.3. In containers, client certs are staged into a
podman-managed volume and mounted read-only — a workaround for the
cnats/OpenSSL bind-mount quirk — while the NATS server's config and
keys use plain bind mounts, which the Go binary handles fine.
2. Detail
2.1. Certificate inventory (build/keys/nats/)
All NATS material lives under build/keys/nats/ (gitignored; carries
private keys):
build/keys/nats/
├── ca.key ca.crt # internal CA: EC P-256, self-signed, 365 days
├── nats-server.key nats-server.crt # broker leaf: EC P-256, 90 days, SANs
└── <service>.key <service>.crt # one mTLS client leaf per service
# ores.iam.service, ores.refdata.service, ..., ores.shell,
# ores.compute.wrapper, ores.analytics.service (19 services)
The service list is the _SERVICES constant in
projects/ores.compass/src/nats_certs.py. It is still hand-maintained
(it mirrors the service registry but is not generated from it);
adding a service means adding its name there and re-running
compass nats certs. The Anatomy of a Service doc flags driving
certs from the registry as a known automation gap.
2.2. Generation (compass nats certs)
The generator is nats_certs.py, exposed standalone as
compass nats certs and called automatically by
compass env configure (compass env configure → env_init.py
invokes nats_certs.generate() before writing .env). Requires
openssl in PATH.
Settings in effect (all from nats_certs.py):
| Material | Key | Subject | Validity | SAN |
|---|---|---|---|---|
| CA | EC prime256v1 (P-256), self-signed x509 |
/CN=ores-nats-ca/O=ORE Studio |
365 d | — |
| NATS server leaf | EC prime256v1 |
/CN=nats-server/O=ORE Studio |
90 d | DNS:localhost, DNS:<hostname> or IP:<hostname>, IP:127.0.0.1 |
| Service client leaf | EC prime256v1 |
/CN=<service>/O=ORE Studio |
90 d | — |
- Curve choice:
openssl ecparam -name prime256v1 -genkey -noout— EC P-256 everywhere; no RSA certs (the only RSA material in the build tree is the IAM JWT signing key, see below). - Server SANs:
--hostname(defaultlocalhost) is added to the server certificate. The extra entry is typedDNS:normally, butIP:when the argument parses as an IP address — TLS clients verifying a connection to an IP target check the SAN's IP entries (RFC 6125/2818), and a DNS-typed entry containing dotted-decimal text fails that check with a generic SSL error. - Serial file:
ca.srlis created byopenssl x509 -CAcreateserialduring leaf signing and deleted when generation finishes. - Intermediates: there are none — leaves are signed directly by the CA.
Idempotency and rotation: generation skips any existing *.key
(presence of the key is the gate), so re-running is a no-op.
--force regenerates everything (use in CI with ephemeral keys, or to
rotate); --hostname <name> adds a SAN to the server cert.
./compass.sh nats certs # idempotent — no-op when keys exist ./compass.sh nats certs --force # rotate: regenerate CA + all leaves ./compass.sh nats certs --hostname devbox.local # add a SAN to the server cert
Because leaves are 90-day certificates, rotation is a periodic concern;
the 365-day CA is the real clock everything chains to. After
regeneration, the running NATS server and all services must be
restarted to pick up new certs (compass env deploy <host> re-runs
idempotently and recreates the containers; locally, compass nats
ensure restarts the systemd unit — see below).
2.3. IAM JWT signing key (not a certificate)
Separate from the NATS PKI, compass env configure also generates
build/keys/iam-rsa-private.pem (openssl genrsa 2048, chmod 600,
preset-independent). It signs the JWTs services mint for
service-to-service authentication, and is consumed in containers only
by ores.iam.service (passed via --env
ORES_IAM_SERVICE_JWT_PRIVATE_KEY=$(cat <pem>) in
docker/remote-run.sh; the value is a single-line PEM string). See
Identity and Access Management for the JWT side of the model.
2.4. TLS versions
Nothing in ORE Studio pins a TLS version — not in the NATS server config, not in the C++ client, not in the container scripts. The negotiated range comes entirely from the two stacks' defaults:
- Server: the NATS broker is the Go
nats-serverv2.14.3 binary (docker/nats.Dockerfile, statically linked, runs in ascratchimage). Go'scrypto/tlsserver default is a minimum of TLS 1.2 (since Go 1.18) and negotiates TLS 1.3 when the client supports it. - Client: services connect through cnats (the NATS C client) over
OpenSSL.
client.cpponly loads the CA and the certificate chain (natsOptions_LoadCATrustedCertificates/natsOptions_LoadCertificatesChain) — no version options are set. The effective floor is the system OpenSSL configuration (Debian bookworm'sopenssl.cnfsetsMinProtocol = TLSv1.2).
The practical outcome is that all connections negotiate TLS 1.2 or 1.3. To verify against a live broker:
# TLS 1.1 must be refused, TLS 1.2 accepted: openssl s_client -connect localhost:20405 -tls1_1 </dev/null # fails openssl s_client -connect localhost:20405 -tls1_2 </dev/null # succeeds
(The port is per-environment, from the port: line of
build/config/nats-<label>.conf.)
2.5. How the NATS server uses the certificates
compass nats init renders
build/config/nats.conf.template into
build/config/nats-<label>.conf with per-environment values:
./compass.sh nats init
The TLS block of the rendered config is:
tls {
cert_file: "<checkout>/build/keys/nats/nats-server.crt"
key_file: "<checkout>/build/keys/nats/nats-server.key"
ca_file: "<checkout>/build/keys/nats/ca.crt"
verify: true # require client certificates (mTLS)
timeout: 5
}
verify: true is what makes the setup mutual: the server presents
nats-server.crt and requires every connecting client to present a
certificate signed by ca.crt. The server's config and certs are
consumed on the host (or from the container's bind mounts) — the Go
binary reads them as plain files.
2.6. How services locate their certificates
- Domain services (the C++
ores.<x>.servicebinaries): the TLS trio is a command-line option group defined inores.nats/src/config/nats_configuration.cpp:--nats-tls-ca,--nats-tls-cert,--nats-tls-key(env namesORES_NATS_TLS_CA,ORES_NATS_TLS_CERT,ORES_NATS_TLS_KEY). At connect time,client.cppfirst verifies all three files exist (a clear diagnostic instead of a cryptic handshake error), then loads CA + chain via cnats and logsmTLS enabled; a missing client cert trips the mTLS gate and the connection is refused. - Deliberate fallback exclusion: the trio is not in the
shared-domain env fallback (NATS Wire Format documents this
decision and its regression tests
register_shared_domain_does_not_resolve_tls_ca): the cert/key paths are genuinely per-service, and letting only the CA resolve from a sharedORES_NATS_CAwould leave cert/key empty and trip the mTLS gate. Per-appORES_<APP>_NATS_TLS_*mirrors win where present; the container runners pass the trio explicitly on the command line. - Shared client identity: reads
ORES_NATS_TLS_CA/CERT/KEYfrom.env, whereenv_init.pypoints them atca.crtand theores.shell.crt/.keypair. ores.shell: keeps its ownORES_SHELL_NATS_TLS_*mirror block in.env(same trio,ores.shellcerts), read via its app-prefix mapper.
2.7. How certificates reach containers
Two transport paths exist, both driven by
compass env deploy <host> (deploy recipe): local deployment
(docker/run-pod.sh) and remote deployment
(docker/remote-run.sh). The mechanism is identical in both.
Runtime role:
env_deploy.pyscp'sbuild/keys/nats/to$REMOTE_ROOT/build/keys/on the remote (removing the previous remote copy first soscp -rdoesn't nest), plus the IAM PEM and a path-rewritten copy of the NATS config.remote-run.shstages the client certs into a podman-managed volume:podman volume create ores-nats-client-certs-<label>,cp -athe keys into the volume's mountpoint, thenchmod -R u+rwX,go-rwx(owner-only: the services container reads the volume as the same uid,--user $(id -u):$(id -g)).- Each service container mounts the volume read-only (= -v "$certs_volume:$keys_dir:ro"=) and receives the CLI trio pointing into that path. The NATS sidecar instead gets plain bind mounts of the config and the keys directory (see below).
Compute role: the wrapper's client certs come from the serving
environment's checkout (the ORES_COMPUTE_NATS_TLS_CA/CERT/KEY
profile keys, local paths in that checkout); env_deploy.py copies
them into the staging tree, scp's them to $REMOTE_ROOT/compute/keys/,
and remote-run.sh volume-stages them exactly like the runtime role.
The wrapper resolves them via the app-prefixed
ORES_COMPUTE_WRAPPER_NATS_TLS_* block in compute.env, with paths
rewritten to $REMOTE_ROOT/compute/keys/<basename>.
The bind-mount quirk (why the volume exists): documented in the
header of docker/run-pod.sh — cnats/OpenSSL silently fails to
present a client certificate over a bind mount, even though the
bytes, path, and permissions are correct. Staging through a podman
volume works, and so does baking the cert into the image; a bind
mount does not. The Go nats-server binary in the sidecar has no such
problem, which is why the server's config + keys are bind-mounted
directly while every C++ service gets the volume.
2.8. Rotation and re-deploy
./compass.sh nats certs --force # regenerate everything ./compass.sh nats certs --hostname devbox.local # new SAN, rest untouched ./compass.sh nats ensure # local: restart the server with the new certs ./compass.sh env deploy <host> # remote: idempotent re-deploy ./compass.sh env deploy <host> --stop # tear down (--purge drops the certs volume too)
compass env deploy <host> re-runs idempotently — the remote keys
directory is removed and re-scp'd, files are overwritten, the certs
volume is recreated, and the containers are rm -f before recreate.
No manual cleanup required.
2.9. One-command bring-up (compass nats ensure)
compass nats ensure composes the pieces above so a session never
starts against stale infrastructure: certificates (idempotent,
--force to rotate), the rendered config, and — the step nothing else
performs — a restart of the per-environment systemd user unit
nats-server-<label>.service. That restart is the whole point: the Go
server loads certs at startup and does not pick up a regenerated
CA/leaf by itself, so every regeneration must be followed by a restart
or every mTLS client fails with SSL Error / bad record MAC (see
Idempotency and rotation above). The command finishes by probing the
client port for the NATS INFO handshake, so success means the server
is actually up and speaking TLS.
./compass.sh nats ensure # certs + config + restart + probe ./compass.sh nats ensure --force # force-rotate certs, then restart
2.10. Recovering a drifted local database
After a rebase the local schema can drift from the code it now sits
next to (the DB was seeded from an older commit), surfacing as
repository errors such as column "workspace_id" does not exist or
Invalid concurrency_policy: skip. Check with compass db status and,
only when it reports drift, recreate:
./compass.sh db status # reports schema drift, if any ./compass.sh db recreate -y -k # drop + recreate + reseed (-y: skip prompt, -k: kill connections)
3. See also
- Message Queue — the structure note for this cluster; this page sits alongside its sequence rather than in it.
- How do I generate NATS certificates with compass? — the command recipe (
--force,--hostname, adding services). - How do I initialise the NATS server config with compass? —
compass nats init, the config + JetStream store side. - How do I manage the checkout environment with compass? —
compass env configure, which calls cert generation automatically. - How do I initialise the checkout environment? — the end-to-end init flow.
- How do I deploy the service runtime to a remote WSL host? — the deploy recipe covering both roles.
- NATS — background on the messaging system and its mTLS setup.
- NATS Wire Format — the shared-domain fallback and why the TLS trio is excluded from it.
- Anatomy of a Service — the per-service layer checklist (certs, IAM, registry gaps).
- Identity and Access Management — JWT/signing-key side of the authentication model.