TLS and certificates
What this page covers
Section titled “What this page covers”A production deployment serves https from the start: after docker compose up -d the web interface is on 443, and plain http arriving on 80 is redirected to it.
The certificate is generated by the system itself by default, and you can replace it with your own.
This page covers choosing where the certificate comes from, distributing the CA to the machines that connect, handing TLS to an ingress you already run, and confirming that TLS and the wss connection channels really work.
What you need
Section titled “What you need”- A public host name, set as
TLS_DOMAINin.env. An internal name your own DNS or a client hosts file resolves is enough; it does not have to be registered publicly. PUBLIC_BASE_URLin.env, pointing at the https address of that same host name. The single sign-on callback address is built from this value.- A certificate chain and private key (
fullchain.pemandprivkey.pem) when you bring your own. - Two published ports, 443 for https and 80 for the http redirect, so the address carries no port number. When the host already runs something on those ports, set
TLS_HTTPS_PORTandTLS_HTTP_PORTin.envto another pair (8443 and 8088 are the usual choice), and the address then carries the port.
The built-in proxy provides TLS 1.2 or later, HSTS, and the WebSocket upgrade forwarding that terminal and graphical connections need.
The host name is substituted into the proxy configuration at startup from TLS_DOMAIN, so the file itself needs no editing.
The application side is prepared as well: the frontend switches between ws and wss to match the page protocol, and the access credential travels in the Authorization header rather than in the address.
How to set it up
Section titled “How to set it up”The default: a local CA the system signs
Section titled “The default: a local CA the system signs”TLS_MODE in .env ships as selfsigned. The first start generates a local certificate authority and a server certificate in tls/, and every later start keeps them.
The certificate covers the host name in TLS_DOMAIN plus the addresses listed in TLS_IP_SAN.
The CA private key stays on the host under tls/ca-private/ and never enters the proxy container.
TLS_DOMAIN=jumper.example.internalTLS_IP_SAN=10.0.0.5 # comma separated, for reaching the system by address tooPUBLIC_BASE_URL=https://jumper.example.internalWhen those three are empty, bash scripts/quickstart.sh fills them in from the machine’s host name and addresses, and prints the address to open along with the download link for the CA certificate. Values you have already filled in are left as they are.
docker compose up -dThen distribute the CA certificate to the machines that connect:
curl -sk https://jumper.example.internal/custodexa-ca.crt -o custodexa-ca.crtIn a Windows domain use group policy (Computer Configuration → Windows Settings → Security Settings → Public Key Policies → Trusted Root Certification Authorities); on macOS and mobile devices use an MDM configuration profile; on a standalone machine import it into the system trust store by hand. Once it is distributed, browsers show the site as trusted. The CA certificate is public data and can be handed to users directly.
Bringing your own certificate
Section titled “Bringing your own certificate”When you already hold a certificate from a public or organizational CA:
- Put the chain at
tls/fullchain.pemand the private key attls/privkey.pem. - In
.env, setTLS_MODE=provided,TLS_DOMAIN(matching the host name on the certificate), andPUBLIC_BASE_URL=https://<host name>, carrying the port when the external port is not 443. docker compose up -d.
If either file is absent, the start stops at the certificate preparation step and names the missing one, so the proxy never comes up half-configured:
docker compose logs tls-initCertificates already in tls/ are kept on every later start. To replace them, delete the files and start again.
Signing with your organization’s CA
Section titled “Signing with your organization’s CA”Generate the private key and a signing request on the host, with the host name in the SAN, and submit it:
openssl req -newkey rsa:2048 -nodes -keyout tls/privkey.pem \ -out custodexa.csr -subj "/CN=jumper.bnc.prod" \ -addext "subjectAltName=DNS:jumper.bnc.prod"When it comes back, concatenate the server certificate and the intermediates into tls/fullchain.pem in order, leaf first, set TLS_MODE=provided, and restart.
Client trust for that CA is usually distributed by AD group policy or MDM.
When an internal identity provider runs over https with the same CA, add the CA certificate to the trust store of the backend container.
When you already run an ingress
Section titled “When you already run an ingress”With a cloud load balancer or an existing nginx or Traefik in front, an overlay keeps the built-in proxy out of the way and lets the frontend publish a plain http port for your ingress to reach:
docker compose -f docker-compose.yml -f docker-compose.external-ingress.yml up -dSetting COMPOSE_FILE=docker-compose.yml:docker-compose.external-ingress.yml in .env makes up -d, ps and down use it without -f every time.
In this shape the frontend publishes 80 (HTTP_PORT in .env changes it) and your ingress carries the external TLS: TLS 1.2 or later, a trusted certificate, HTTP redirected to HTTPS, HSTS, and WebSocket upgrade forwarding. Encrypt the leg from the ingress to this host as well when it crosses an untrusted segment.
Set PUBLIC_BASE_URL to the https address users actually see.
Forward the Host header with the port people actually connect to, adding it whenever that port is not 443: with CORS_ALLOWED_ORIGINS unset the backend accepts same-origin requests only, which it recognises by comparing the request’s Origin against its Host, and a Host without the port makes a credentialed request look cross-origin.
Set TRUSTED_PROXIES to your ingress address, or the network it sits on: it decides which source address a request is attributed to, and with that what the audit record holds and what the login rate limit counts against. (In the default shape with the built-in proxy, bash scripts/quickstart.sh fills it in with the Docker subnet.)
Customizing the proxy configuration
Section titled “Customizing the proxy configuration”The shipped template covers the usual needs. To change it, copy it, point .env at the copy, and leave the original alone:
cp docker/reverse-proxy/nginx-tls.conf.template tls/custodexa.conf.templateTLS_NGINX_TEMPLATE=./tls/custodexa.conf.templateWhat you can change there: server_name (filled from TLS_DOMAIN by default), the certificate paths, extra headers (the HSTS max-age, includeSubDomains and preload according to your domain strategy), and the upstream (on the same Docker network, the service name frontend:80).
Apply the change with docker compose up -d tls-proxy.
To use an nginx already installed on the host instead of running the proxy container: take the ingress overlay above so the frontend publishes an http port (bind it to loopback by changing the mapping to 127.0.0.1:8088:80), reuse the same template, point the upstream at 127.0.0.1:8088, and replace server_name and the certificate paths with yours.
On nginx before 1.25.1, write listen 443 ssl http2; in place of http2 on;.
Other proxies (Caddy, Traefik, a cloud load balancer) work as long as they meet the contract in the previous section; docker/reverse-proxy/Caddyfile.example is the equivalent Caddy configuration.
Verifying
Section titled “Verifying”docker compose ps # tls-proxy is Up; tls-init exits once the certificates are readycurl -sI http://localhost/ | head -1 # 301, redirected to httpscurl -skI https://localhost/ | head -1 # 200curl --cacert tls/ca-public/custodexa-ca.crt -sI https://<TLS_DOMAIN>/ | head -1 # self-signed mode: 200The fourth command validates the whole trust chain against the CA certificate; a 200 means the certificate matches the host name.
Then sign in and open a connection. The Network tab in the browser developer tools should show a wss:// stream.
What keeps the signed-in state
Section titled “What keeps the signed-in state”The credential that renews the signed-in state travels in an HttpOnly cookie. The “Connections and accounts” section of the policy page has a switch, “keep the signed-in state only over https”, which ships on. While it is on, the cookie is sent only over encrypted connections, and the next cookie issued after you save carries the new value, with no restart needed.
Before the policy holds a value, the initial value comes from AUTH_REFRESH_COOKIE_SECURE in .env, then the protocol in PUBLIC_BASE_URL, then the shipped default. The startup log prints the effective value and where it came from:
docker compose logs backend | grep "refresh cookie"Time synchronization
Section titled “Time synchronization”Timestamps in audit records come from the host clock. In production, run a time synchronization service on the host and use UTC as the time source.
What auditors can see
Section titled “What auditors can see”- The channel inventory on the transmission security page lists the current encryption state of each channel. The web layer is marked as managed by the deployer and carries a deviation notice.
- The inventory exports as a snapshot that carries its generation time and the person who generated it, and the export itself is audited.
- A change to the policy switch leaves a record with the person who made it and the values before and after.