Skip to content

Internal mTLS

ANVILBASE_MTLS_ENABLED=true turns on mutual TLS for service-to-service traffic: internal listeners require a client certificate signed by the AnvilBase internal CA, and every internal client presents its own service certificate. Off by default; when the flag is unset or false, every service behaves byte-for-byte as before (plaintext on the internal network).

Enabling

Terminal window
docker/mtls/generate-certs.sh # internal CA + one cert per service
# .env:
ANVILBASE_MTLS_ENABLED=true
docker compose up -d

Certificates mount read-only at /etc/anvilbase/tls/. Each service reads the same three env vars (compose sets per-service defaults):

VariableMeaningExample default
ANVILBASE_MTLS_ENABLEDone switch for BOTH legs (listener + outbound)false
ANVILBASE_MTLS_CERTthe service’s own cert (server and client identity)/etc/anvilbase/tls/<service>.pem
ANVILBASE_MTLS_KEYthe service’s private key/etc/anvilbase/tls/<service>-key.pem
ANVILBASE_MTLS_CAthe internal CA bundle (sole trust anchor)/etc/anvilbase/tls/ca.pem

Every generated certificate carries extendedKeyUsage = serverAuth, clientAuth and SANs for the compose service name (control-plane, auth, realtime, deno, …), localhost, and 127.0.0.1 — so one cert per service covers both its listener and its outbound identity.

The internal edge map

Every service-to-service HTTP/WS edge in the platform, and what ANVILBASE_MTLS_ENABLED=true does to it:

#EdgeCarriesWith mTLS on
1control-plane → auth (GoTrue/native auth proxy, users admin, invalidation)per-project JWT secretsmTLShttps://auth:3002, control-plane client cert; auth’s Bun listener requires + verifies it
2auth → control-plane (POST /internal/email/send)internal secret, email payloadsmTLShttps://control-plane:3001, auth client cert via Bun fetch tls: init
3realtime → control-plane (GET /internal/projects/:id/jwt-secret)per-project JWT secretsmTLShttps, realtime client cert via :httpc ssl options (verify_peer against the internal CA)
4control-plane → realtime (WS bridge + longpoll proxy, health probe)user JWTs in ?token=mTLSwss/https://realtime:4000; Bandit serves verify_peer + fail_if_no_peer_cert, plaintext listener disabled; the WS bridge presents the control-plane cert via a rustls connector
5control-plane → deno (function invoke, deploy sync, health probe)project DB URLs, service-role/anon keys (headers)TLS, server-auth onlyhttps://deno:8082 with the internal CA pinned. Gap: Deno.serve cannot VERIFY client certificates (Deno 2.1.x), so this leg is encrypted + server-authenticated but not mutually authenticated
6deno worker → control-plane (lazy rehydration fetch)internal secret, function sourcemTLShttps, deno client cert via Deno.createHttpClient({ caCerts, cert, key }); workers get read access to exactly the three cert files, only when the flag is on
7Traefik → control-plane / autheverything at the edgemTLSdocker/traefik/dynamic/mtls.yml serversTransports.internal-mtls presents the traefik cert
8control-plane ↔ webhooks servicedelivery rowsno HTTP edge — Postgres LISTEN/NOTIFY + shared DB (see #11)
9webhooks → customer endpointssigned webhook payloadsexternal — never sends internal client certs (by design); HMAC signatures + SSRF guard apply
10deno supervisor → workerinvocation trafficloopback (127.0.0.1 inside one container) — plaintext by design
11* → Postgres / Valkey / MinIO / imgproxy / SMTPdata backplaneout of scope for this switch (plaintext on the internal network). Postgres TLS, MinIO TLS and Valkey TLS are separate hardening items; until then keep them unpublished on a private network

Additional consequence of #5/#6: the X-AnvilBase-Url context header handed to edge functions advertises https://control-plane:3001/... when mTLS is on — function code calling back into the REST engine must use Deno.createHttpClient with the mounted cert material (the worker has read access to it), since the control-plane listener rejects uncertified callers.

Plaintext refusal

With the flag on, internal listeners fail closed:

  • control-plane: rustls RequireAndVerifyClientCert — plain HTTP and cert-less TLS are both refused at the handshake.
  • auth: Bun.serve with requestCert: true + rejectUnauthorized: true.
  • realtime: Bandit/:ssl with verify: :verify_peer + fail_if_no_peer_cert: true; the plaintext :http listener is disabled.
  • deno: TLS listener (plain HTTP refused); client certs are accepted but not verified (the documented gap above).

A missing/corrupt cert file fails loudly at boot (control-plane, auth, realtime, deno listener) rather than silently downgrading to plaintext.

External access

With mTLS on, browsers/CLIs cannot talk to internal listeners directly — route external traffic through Traefik, which presents its own client certificate (docker/traefik/dynamic/mtls.yml). The compose healthchecks are mTLS-aware (the control-plane probes itself with its own cert).

Smoke coverage

scripts/mtls-smoke.sh (CI: .github/workflows/mtls-smoke.yml — label a PR ci:mtls, push to main touching the mTLS surface, or run it from the Actions tab) boots postgres + minio + valkey + control-plane + auth + deno + realtime with mTLS on and asserts:

  1. control-plane/auth/realtime answer mTLS health probes and reject plain-HTTP and cert-less HTTPS;
  2. deno answers TLS health probes and rejects plain HTTP;
  3. the full proxied chain works: project create → db push → REST query → GoTrue signup (control-plane→auth) → function deploy + invoke (control-plane→deno) → runtime-cache wipe + reinvoke (deno→control-plane mTLS rehydration).

Rotation

Re-run docker/mtls/generate-certs.sh (delete the expiring service’s <service>.pem/<service>-key.pem first — the script skips existing files), then roll the affected services. The CA is valid for 10 years; service certs for 1 year.

Next: Network Security.