Repository navigation
TLS Setup
Canonical source:
docs/08-runtime-devops/tls-setup.md— this page is rendered from it on every publish; edit the source.
ClawAI ships with end-to-end local TLS: the browser talks to nginx
over HTTPS, nginx talks to every backend service over HTTPS, and every
service-to-service fetch is verified against the same local root CA.
TLS is forced on by default — there is no opt-out prompt.
Two certificates, two jobs. On a public server the stack serves both. The mkcert leaf is the stack's internal identity — its SANs are container hostnames like
auth-service, so no public CA can ever issue a replacement, and every service presents it on its own listener. A Let's Encrypt leaf is layered on top for the public domain and is the only certificate a browser sees. Replacingcerts/claw.crtwith a public certificate breaks every service-to-service hop in the stack. See Public TLS on a real domain.
Browser ──HTTPS──▶ nginx (:443)
│
├─HTTPS──▶ auth-service (:4001)
├─HTTPS──▶ chat-service (:4002)
├─HTTPS──▶ … 15 more services
└─HTTP───▶ frontend (:3000) ← Next dev server stays HTTP internally
The cert chain:
-
certs/rootCA.pem— local root CA, installed into the OS trust store bymkcert -install. Trusted by the browser, bycurl, and by node viaNODE_EXTRA_CA_CERTS=/certs/rootCA.pem. -
certs/claw.crt— single wildcard leaf cert signed by the local CA, coveringlocalhost+ every internal docker hostname. -
certs/claw.key— leaf private key. Never committed; lives only on the developer's machine.
# Mac / Linux
bash scripts/install-tls.sh
# Windows (PowerShell)
powershell -ExecutionPolicy Bypass -File scripts/install-tls.ps1scripts/install.sh and scripts/install.ps1 call this automatically
as Step 6/9 — you don't normally invoke it directly.
What the script does:
- Installs
mkcertif missing (brew on Mac, apt + binary on Linux, winget / choco / direct download on Windows) - Runs
mkcert -install(idempotent — no-op if CA already trusted) - Issues a leaf cert covering 22 hostnames, writes to
certs/claw.crt - Copies the root CA into
certs/rootCA.pem
The Windows script self-elevates once for the LocalMachine cert store write. Subsequent runs need no admin.
After install:
# Browser: open https://claw.local — expect green-lock, no warning
# CLI:
curl -sS https://claw.local/api/v1/health
# {"status":"ok",...}
# Inter-service trust (from inside any service container):
docker exec claw-chat-service \
curl https://auth-service:4001/api/v1/health
# {"status":"ok",...}
# Nginx config parses with the new TLS server block:
docker exec claw-nginx nginx -tEach NestJS service main.ts calls resolveHttpsOptions() from
@claw/shared-utilities. It reads two env vars set in .env:
HTTPS_CERT_PATH=/certs/claw.crt
HTTPS_KEY_PATH=/certs/claw.key
NODE_EXTRA_CA_CERTS=/certs/rootCA.pem
If both HTTPS_CERT_PATH and HTTPS_KEY_PATH resolve to readable
files, the service boots on TLS. If either is missing, the service
falls back to plain HTTP and logs a one-line stderr warning. This
graceful fallback exists so a fresh checkout that hasn't run
install-tls still boots.
NODE_EXTRA_CA_CERTS makes node's built-in fetch, https.request,
and any axios/undici-based client trust the local root CA without
per-call agent configuration. Without it, every inter-service call
would throw UNABLE_TO_VERIFY_LEAF_SIGNATURE.
When you add a new microservice, you must:
-
Add its hostname to the SAN list in both
install-tls.shandinstall-tls.ps1(theHOSTSarray) (today the array ends withthreads-service,thread-generation-service,plugin-service) -
Rerun
install-tlsto reissue the leaf cert with the new SAN -
Mount
../certs:/certs:rointo the new service indocker/docker-compose.dev.services.ymland the prod equivalent -
Use the shared
resolveHttpsOptions()in the newmain.ts— never roll your own cert read
Forgetting step 1 will cause the new service's startup to fail with
Hostname/IP doesn't match certificate. The browser will not be
affected (the cert covers localhost), but inter-service calls into
the new service will fail TLS verification.
Once TLS is live, your OAuth callback URLs become:
- Google:
https://claw.local/api/v1/workspace/oauth/google/callback - Slack:
https://claw.local/api/v1/workspace/oauth/slack/callback - Atlassian:
https://claw.local/api/v1/workspace/oauth/jira/callback - GitHub:
https://claw.local/api/v1/workspace/oauth/github/callback
You still need to register these manually in each provider's developer console — there's no API for that. Most providers (Google in particular) require HTTPS for production-tier consent screens, so this unblocks flows that previously required ngrok.
Provider webhooks (GitHub push, Slack event_callback, Jira
webhook) still cannot reach https://claw.local because the
provider's server doesn't trust your local CA. For webhook testing
use a tunnel:
# ngrok
ngrok http https://claw.local:443
# or cloudflared
cloudflared tunnel --url https://claw.localThis isn't a regression — webhooks never worked locally before TLS
either. See docs/11-runbooks/troubleshoot-tls.md for details.
The mkcert leaf cert expires in 825 days (default). When near expiry,
rerun bash scripts/install-tls.sh — it'll issue a fresh leaf without
re-installing the root CA.
The root CA itself expires in 10 years. If you ever see the browser
warn about an expired CA, run mkcert -uninstall && mkcert -install
then rerun scripts/install-tls.sh.
mkcert is a development CA. Nobody else's browser trusts it, so on a public domain every visitor gets a full-page interstitial. The fix is a free Let's Encrypt certificate at the edge, issued by:
bash scripts/install-letsencrypt.sh --verify-renewalIt reads CLAW_HOSTNAME from .env, adds the www. form when that resolves,
and exits cleanly with an explanation for claw.local, a bare IP, or any other
name a public CA cannot validate. A --prod install runs it automatically as
step 9b, after the stack is up.
The command every tutorial gives is sudo certbot --nginx -d example.com. It
does not work on this stack, and understanding why prevents a long detour:
-
nginx is not installed on the host. It runs as the
claw-nginxcontainer fromnginx:alpine. The--nginxplugin looks for a host nginx binary and rewrites/etc/nginx/sites-*; there is nothing there to find. - The config is bind-mounted read-only from this repo. Anything the plugin managed to write in-place would be discarded on the next container recreate.
-
ufw allow 'Nginx Full'also fails. That application profile ships with the nginx APT package, which is not installed. Use80/tcp+443/tcp.
So the flow uses the webroot authenticator instead.
Let's Encrypt ──HTTP :80──▶ nginx container ──▶ /var/www/certbot (challenge)
│
Browser ──HTTPS :443──▶ nginx ─────┤ SNI claw-ai.co → Let's Encrypt leaf
└ SNI anything else → mkcert leaf
│
└─HTTPS──▶ services (mkcert, unchanged)
-
docker/docker-compose.prod.services.ymlpublishes host port 80 and mounts/var/www/certbotand/etc/letsencryptinto the container. -
infra/nginx/nginx.confserves/.well-known/acme-challenge/from that webroot. It is alocation, not a server-level bypass — areturn 301inservercontext runs in the rewrite phase, before nginx picks a location, and would redirect the CA away from the challenge it came for. - certbot writes the token, the CA fetches it over plain HTTP, and the
certificate lands in
/etc/letsencrypt/live/<domain>/. - The script generates
infra/nginx/public-tls/<domain>.conf— a server block bound to the real domain, using the Let's Encrypt leaf andinclude-ing the sameinfra/nginx/locations.confas the default block. - nginx reloads. SNI now selects the trusted certificate for the public domain and the mkcert one for everything else.
/etc/letsencrypt is mounted, not copied, so a renewed certificate is
picked up by a reload alone — no rebuild, and no "renewed weeks ago, still
serving the old cert" outage.
infra/nginx/locations.conf holds all ~52 location blocks and is included by
every TLS server block. It exists as a separate file for one reason: a public
server serves the same routes under two certificates, and duplicating the
routing table guarantees the copies drift — a drifted route is an outage that
only reproduces on one hostname. Add routes there, never inside a server block.
infra/nginx/public-tls/*.conf is generated and gitignored: it points at a
/etc/letsencrypt path that exists only on the host that holds the certificate,
so committing one would break nginx startup everywhere else. The wildcard
include tolerates an empty directory, which is what lets a laptop and a
production server share one nginx.conf.
Because that drop-in lives only in the working tree, it is the one piece of
public TLS a fresh clone, a git clean, or a restored backup can lose while
the certificate itself sits untouched in /etc/letsencrypt. Losing it is
silent: nginx falls through to the mkcert block, nothing is unhealthy, no
container restarts, and the only symptom is a visitor's browser warning.
scripts/claw.sh closes that gap on its own. Every up, services:up, and
services:rebuild calls ensure_public_tls, which checks whether
infra/nginx/public-tls/<CLAW_HOSTNAME>.conf exists and, if not, regenerates
it from the certificate already on disk:
bash scripts/install-letsencrypt.sh --restore-onlyThis mode reads the domain names out of the certificate's SANs — never
re-derived, so the block can't claim a name the leaf doesn't cover — and does
only that: no CA request, no rate-limit slot consumed, no DNS lookup, no
firewall change. It fails soft: if there is no certificate on disk yet, or the
restore can't get a non-interactive sudo, claw.sh prints a warning and
keeps starting the stack on mkcert rather than blocking the deploy.
Running it by hand is safe at any time and is the fix if a rebuild ever
outpaces a claw.sh version that predates this:
bash scripts/install-letsencrypt.sh --restore-onlycertbot's snap installs a systemd timer; renewal is automatic inside the 30-day
window. Because nginx keeps a certificate in memory until reloaded, the script
installs a deploy hook at
/etc/letsencrypt/renewal-hooks/deploy/00-claw-reload-nginx.sh that reloads the
container on an actual renewal.
Verify the whole path without consuming a rate limit:
sudo certbot renew --dry-run# Trusted by the system CA store? ssl_verify_result must be 0.
curl -sS -o /dev/null -w "code=%{http_code} tls=%{ssl_verify_result}\n" https://claw-ai.co/
# Which leaf does a given SNI select?
echo | openssl s_client -connect claw-ai.co:443 -servername claw-ai.co 2>/dev/null \
| openssl x509 -noout -issuer -dates
# The challenge path must 404 — NOT 301. A 301 means the redirect is
# shadowing the ACME location and validation will fail.
curl -sS -o /dev/null -w "%{http_code}\n" http://claw-ai.co/.well-known/acme-challenge/probe| Symptom | Cause |
|---|---|
Could not find a profile matching 'Nginx Full' |
nginx is containerized; use ufw allow 80/tcp && ufw allow 443/tcp. |
Script reports the domain resolves to 127.0.0.1
|
install-tls.sh adds 127.0.0.1 <hostname> to /etc/hosts. The script queries public DNS directly to avoid this; a stale copy checking getent will false-fail. |
| ACME challenge returns 301 | The redirect is winning over the location. Check the port-80 block still uses location / for the redirect. |
| ACME challenge returns 404 from outside | Host port 80 is not published. The container predates the compose change — recreate it: ./scripts/claw.sh --prod up. |
| Certificate renewed but the browser sees the old one | The deploy hook is missing, so nginx was never reloaded. |
www shows a certificate warning |
www was not included at issuance. Re-run with --domain example.com --domain www.example.com. |
| mkcert warning reappears on a domain that had Let's Encrypt working | The generated block was lost (fresh clone, git clean, restored backup). claw.sh up/services:up/services:rebuild restore it automatically; to fix it without a restart, run bash scripts/install-letsencrypt.sh --restore-only. |
scripts/install.ps1 does not issue public certificates: certbot's Windows
builds were discontinued and the challenge flow writes to a Linux path. A
--prod install on a public domain prints the exact command to run on the
Linux host instead, and records LETSENCRYPT_EMAIL in .env so that run needs
no extra input.
ClawAI Wiki · source-controlled under wiki/ · generated/mirrored from the current repository knowledge layer.
ClawAI Wiki
Product
- Product-Vision
- Flagship-Features
- Business-Overview
- Requirements-Register
- Drift-Log
- Runtime-Managed-Connector-Providers
- Threads
- ADR-Index
Architecture
- System-at-a-Glance
- Architecture-Map
- System-Architecture
- Message-Flow
- End-to-End-Data-Flow
- Routing-Engine
- Event-Bus
- Security-Architecture
Platform
- Backend-Services
- Frontend-Architecture
- Shared-Packages
- Database-Reference
- API-Reference
- Provider-Catalog
- Memory-and-Context-Architecture
- File-Support-Architecture
- Research-and-Web-Crawling
Runtime & Ops
- Docker-Guide
- Nginx-Reference
- CI-CD-Pipeline
- Logging-and-Observability
- Operational-Runbooks
- Troubleshooting
Quality
Engineering OS
- AI-Native-Engineering-OS
- Generated-Manifests
- Rules-Catalog
- Skills-Catalog
- Context-Layer
- Engineering-Memory
- Tooling-and-Automation
- Scripts-and-Operations
- ESLint-and-Architecture-Enforcement
- Extended-Work-Skills
Agents
Reference