fuck-eu-chat-control docs

Deployment guide

Deployment Guide

Operator guide for deploying fuck-eu-chat-control. Covers the deployment model, ports, TLS and WSS, the runtime configuration surface, logging and metadata, and NAT traversal. For the security properties themselves, read the threat model and the protocol spec; this document is about running the thing.

The compose file ships a coturn service for STUN and TURN. Credentials are minted per request by the Nitro server from a shared secret that never reaches the client. Loopback and LAN work with nothing configured; connections across the internet need the operator to publish the coturn ports and set the endpoint environment variables below.

1. The single-process model

The TanStack Start (Nitro) server is the only process. It serves:

  • The SSR React application (HTML, JS, CSS, assets) over HTTPS.
  • The signaling broker as a WebSocket route at /ws, on the same port as the app.
  • GET /ice-config, which returns the public STUN/TURN endpoints and a freshly minted, time-limited TURN credential pair. The shared secret used to mint those credentials comes from TURN_SHARED_SECRET and is never sent to the client.

There is no separate broker host. The broker is an in-process WebSocket handler at apps/web/src/server/broker.ts that keeps no persistent state and no application logs. For 1:1 conversations it also holds no presence table: once a WebRTC data channel opens between two peers, both disconnect from the broker. Group conversations are the exception — members stay seated in the broker room for the life of the group so late joiners can be admitted, so the broker observes the room id plus up to GROUP_MAX_MEMBERS online sockets while a group is active. It still sees no content. (See "What the system protects" and "Persistent broker seats" in the threat model.)

A separate coturn container is defined in docker-compose.yml alongside web. web is HTTP behind traefik and uses expose:, so it stays on the Docker network. coturn speaks UDP and must be published on the host with ports: — an HTTP reverse proxy cannot relay TURN traffic. The port list is in §2 and §6.

The broker is served from the same origin as the app, so the client works out its broker URL as wss://<host>/ws from window.location at runtime (apps/web/src/features/chat/runtime/chat-provider.tsx, resolveBrowserDeps). No broker URL is configured server-side.

ICE configuration

The ICE server list is runtime configuration, not a build-time constant. The browser fetches GET /ice-config at boot (same origin, relative URL) and passes the result into the controller:

const instance = createChatController({
  // ...
  iceServers, // fetched from /ice-config, falls back to [] on failure
});

The route (apps/web/src/server/ice-config.ts) builds the list from server-side environment variables (STUN_URL, TURN_URL, TURN_TLS_URL) and, for TURN entries, mints a fresh HMAC-SHA1 long-term credential per request using the TURN REST API (time-limited credentials):

  • username = "<unix-expiry-seconds>:fck-web"
  • credential = base64( HMAC-SHA1(TURN_SHARED_SECRET, username) )
  • credential lifetime is 6 hours; the route sets Cache-Control: public, max-age=3600 so the browser re-fetches before expiry.

The shared secret (TURN_SHARED_SECRET) is read from the environment and used only to compute the credential. It is never serialized into a response, never baked into the client bundle, and never written to logs. Only the resulting username and credential reach the browser.

When nothing is configured (loopback, LAN, CI), /ice-config returns { iceServers: [] } and the client falls back to host candidates — which is how localhost-to-localhost connections work with no STUN/TURN. A fetch failure is also swallowed and falls back to [], so the UI never blocks waiting for ICE config.

2. Ports

Port Protocol Purpose Required?
443 HTTPS App + broker /ws + /ice-config (WSS after the proxy upgrades). Yes.
3478 UDP STUN + TURN relay (coturn). Yes for cross-network P2P; optional for LAN.
3478 TCP TURN relay, TCP fallback for UDP-blocking networks. Recommended; needed on UDP-blocking networks.
5349 TCP TURN over TLS (turns:). Optional; needs cert/pkey mounted in coturn.
49152-49172 UDP TURN relay allocation range (matches coturn min-port/max-port). Yes when TURN is enabled; widen for more relays.

The web service listens on TCP 9000 inside the container (ENV PORT=9000, ENV HOST=0.0.0.0 in apps/web/Dockerfile). The compose file uses expose: ["9000"] rather than ports: for web, so the service is reachable only on the Docker network — Dokploy/traefik (or your reverse proxy) reaches it as http://web:9000, and it is never published on a host interface. In production, terminate TLS at a reverse proxy in front of the node server and expose 443 to the internet. Do not publish the web container port on a host interface.

The coturn service is the exception: it uses ports: and is published on the host, because STUN and TURN speak UDP and cannot be proxied by an HTTP reverse proxy. Operators must publish 3478/udp, 3478/tcp, 5349/tcp (if using turns:), and the full relay range 49152-49172/udp on the host firewall. coturn reads its config from deploy/coturn/turnserver.conf (mounted read-only). The config parser does NOT expand $VARIABLE tokens, so the shared secret cannot be written into that mounted file — and it is never passed on turnserver's command line either, because /proc/<pid>/cmdline is world-readable to unprivileged host users and execve auditing captures argv. Instead deploy/coturn/entrypoint.sh resolves TURN_SHARED_SECRET from the runtime environment and renders a merged runtime config at /var/lib/coturn/turnserver.runtime.conf inside the container's writable layer: the mounted settings with any static-auth-secret directives stripped (coturn accumulates them rather than overriding, so a leftover would stay valid) plus a single appended static-auth-secret=<resolved> line, written with mode 0600 and owned by the nobody uid the image runs as — only the turnserver process itself can read it via turnserver -c. The secret thus reaches the parser as the resolved value — never argv, never the literal $TURN_… string (which would open the relay). The non-secret realm and external-ip values are still passed as --realm / --external-ip CLI flags, which override config-file values in coturn.

The /ws route must be reached over WSS (secure WebSocket) in production. A plain-WS broker reachable over HTTP is not acceptable: the signaling channel carries invitation-bearing rendezvous messages, and the threat model's guarantees assume TLS/WSS-encrypted signaling. The client derives wss:// from window.location when the app is served over HTTPS, so this happens on its own as long as the proxy terminates TLS correctly.

Reverse-proxy WebSocket upgrade

The proxy in front of the node server must forward the WebSocket upgrade headers for /ws. Without that, the broker handshake fails and peers cannot rendezvous.

nginx:

location /ws {
    proxy_pass http://127.0.0.1:9000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 3600s;
}

location / {
    proxy_pass http://127.0.0.1:9000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Caddy (automatic HTTPS and WebSocket pass-through by default):

chat.example.com {
    reverse_proxy 127.0.0.1:9000
}

Traefik (YAML):

http:
  routers:
    chat:
      rule: "Host(`chat.example.com`)"
      service: chat
      tls: {}
  services:
    chat:
      loadBalancer:
        servers:
          - url: "http://127.0.0.1:9000"

On Dokploy (and any Docker-network setup), point the proxy at the service name on the compose network — http://web:9000 — rather than 127.0.0.1:9000. The 127.0.0.1 form applies only when the proxy runs on the host alongside the container.

The app does not validate X-Forwarded-Proto directly; it reads window.location.protocol, so the proxy's TLS termination is what makes the client pick wss://.

3. TLS / WSS

Terminate TLS at the reverse proxy (Caddy, nginx, Traefik, or the hosting platform's HTTPS layer) in front of the node server. The node process speaks HTTP on port 9000 inside the container and does not present certificates.

  • The broker URL is derived as ${protocol === "https:" ? "wss" : "ws"}://${host}/ws from window.location, so when the proxy serves the app over HTTPS the broker URL is wss:// automatically.
  • The conversation ID is carried in the URL fragment, which browsers never send to the server in an HTTP request. TLS still protects the rest of the URL and the signaling payloads in transit.

4. Docker

Build and run from the repository root:

docker compose up -d --build

This builds apps/web/Dockerfile (node:24, builds the SSR bundle with pnpm run build, runs .output/server/index.mjs) and starts it with the healthcheck below. The compose file uses expose: ["9000"] (Docker-network only, not published on a host interface) and sets restart: unless-stopped.

Health check

The compose healthcheck hits http://localhost:9000/healthz every 10 seconds and expects a 2xx. /healthz is a dedicated route (apps/web/src/server/healthz.ts), not the SSR'd landing page: it returns 503 when the TURN configuration is half-set (URL without shared secret, or secret without URL) and mints one throwaway TURN credential when a secret is configured, so the HMAC path /ice-config depends on is exercised. A deployment running without TURN at all stays healthy; the broker WebSocket is not probed separately (same process, same event loop). coturn's healthcheck is a TCP connect on 3478.

Production environment variables

The server env surface is small. From packages/env/src/server.ts:

Variable Required Purpose
NODE_ENV Yes production (set in the Dockerfile).
CORS_ORIGIN No The exact origin allowed on the broker WebSocket. Canonicalized at parse time — https://host/, https://host:443, and HTTPS://HOST all fold to https://host.
PUBLIC_BASE_URL No The public origin used as the invitation-link prefix (surfaced to the client via /ice-config).
STUN_URL No Public STUN endpoint with an explicit stun:/stuns: scheme, e.g. stun:turn.example.com:3478. Served to the client via /ice-config.
TURN_URL No Public TURN endpoint (UDP/TCP relay) with an explicit turn:/turns: scheme, e.g. turn:turn.example.com:3478.
TURN_TLS_URL No Public TURN-over-TLS endpoint; must use the turns: scheme, e.g. turns:turn.example.com:5349.
TURN_SHARED_SECRET No* Server-held secret for minting TURN credentials (minimum 32 characters). Never sent to the client.
TURN_REALM No coturn realm directive. Set to your own domain; no default (empty falls back to coturn's).
TURN_EXTERNAL_IP No Host's public IPv4 when ports are NAT-forwarded (docker behind NAT); maps to coturn external-ip.
GROUP_MAX_MEMBERS No Seat cap for group-mode broker rooms — the maximum members a group can hold. Optional integer 2–16 (inclusive); defaults to 8 when unset. Served to the client via /ice-config.
SKIP_ENV_VALIDATION No Skips env validation entirely. Used during the Docker build; unset at runtime.

Client side (packages/env/src/web.ts): none. The client bakes no ICE endpoints. STUN/TURN coordinates are fetched at runtime from /ice-config, which mints per-request TURN credentials from the server-held TURN_SHARED_SECRET.

Validation is skipped when SKIP_ENV_VALIDATION is set (the Dockerfile sets it during the build phase, then unsets it). At runtime NODE_ENV=production is expected and CORS_ORIGIN should be set to your deployed origin. The TURN variables (STUN_URL, TURN_URL, TURN_TLS_URL, TURN_SHARED_SECRET, TURN_REALM) must be exported in the deployment environment (Dokploy/runtime injector), a project-root .env, or --env-file: both services pass them through an environment: block, which overrides env_file: entries (an empty passthrough value wins over a set apps/web/.env value), and Compose interpolation never reads service env_file: files. Values set only in apps/web/.env are therefore silently discarded for these variables — apps/web/.env still works for variables the passthrough does not cover (e.g. CORS_ORIGIN).

* TURN_SHARED_SECRET must match the value coturn is configured with (both services receive it through their compose environment: passthrough, which resolves from the deployment environment, a project-root .env, or --env-file — not from apps/web/.env, whose entry the passthrough overrides with empty). It is read server-side only — the /ice-config route uses it to compute per-request HMAC-SHA1 credentials and never serializes it into a response or log. The guard on the coturn side lives in deploy/coturn/entrypoint.sh, which exits if the secret is empty or shorter than 32 characters — the required minimum, enforced there so coturn-only deployments fail closed without depending on the web service's env validation. The web service's env schema (packages/env/src/server.ts) enforces the same 32-character floor at startup — /ice-config is an unauthenticated HMAC-SHA1 oracle, so a weak secret makes the credential TTL brute-forceable. The compose passthrough itself uses ${TURN_SHARED_SECRET:-} (empty default), not a required ${VAR:?} form, which is what makes the service deployable on Dokploy without a hard failure at interpolation time.

5. Logging and metadata posture

The broker keeps nothing. From the threat model:

  • For 1:1 conversations the broker holds no persistent state, no presence table, and no message log — both peers vacate the room once their data channel opens.
  • Group rooms are the one stateful surface: members stay seated for the life of the group, so the broker observes the room id and the presence of up to GROUP_MAX_MEMBERS sockets while a group is active — still no content, and still nothing written to durable state.
  • There are no application logs.
  • "No persistence" means the application retains none; it does not mean observation is impossible.

While the broker process runs, signaling metadata — conversation IDs, client IP addresses, connection timing — is observable by the hosting platform's own layers (reverse-proxy access logs, load-balancer logs, container log drivers, the network itself). The application cannot suppress those; the operator must.

What the operator must do

  • Disable reverse-proxy access logs (or reduce them to the minimum the platform requires). nginx: access_log off;. Caddy: do not enable the access-log directive for the chat vhost. Traefik: set accessLog to a disabled or no-op config.
  • Limit container log-driver retention. Docker: configure the json-file driver with max-size and max-file, or use a logless driver.
  • Disable hosting-platform request logging where the platform exposes it (cloud-provider load balancers, CDN edge logs, and so on).

This is the operational side of the threat model's metadata section: a stateless broker that retains nothing can still be observed while it runs. The application's contribution to that surface is zero by construction; the operator's contribution is a deployment choice.

6. NAT traversal

The bundled coturn service provides STUN and TURN. Practical consequences:

  • Peer pairs behind cone NATs, restricted-cone NATs, and symmetric-vs-port-preserving NATs connect via server-reflexive candidates from STUN (STUN_URL).
  • Peer pairs where at least one side is behind a symmetric NAT (roughly 10–20% of home networks) connect via the TURN relay (TURN_URL over UDP/TCP, optionally TURN_TLS_URL over TLS). The relay is allocated out of the coturn min-port/max-port range, and its credentials are minted per session by /ice-config from the server-held TURN_SHARED_SECRET.
  • When no path works (host, STUN, TURN), the user sees a connection error in the UI. The broker still relays nothing on the peers' behalf — once the data channel opens, TURN is only in the path if a relay is actually in use, and coturn keeps no persistent state about the session beyond the allocation.

Operators who do not set STUN_URL / TURN_URL / TURN_SHARED_SECRET get the loopback/LAN behavior: /ice-config returns { iceServers: [] }, the browser gathers host candidates only, and peers on the same LAN or on localhost connect directly. This is the CI path — no STUN/TURN is required for the test suite.

7. No telemetry, analytics, or error reporting

The application contains no analytics, no error-reporting SDK, no telemetry, and no data-exfiltration path. There is no Sentry, PostHog, Google Analytics (gtag), Mixpanel, Datadog, or any other third-party observation SDK imported by apps/web/src or any packages/* workspace.

The operator must not add such SDKs to a deployed build. Adding telemetry to a build that runs in a user's browser reintroduces the disclosure surface the application was built to avoid. If you need operational visibility, instrument the infrastructure (proxy, load balancer, container runtime), not the client.

8. Trust verification

These closure decisions are inherited by the deployment and verifiable from source at any time.

  • No telemetry. grep -riE "analytics|telemetry|sentry|gtag|posthog|mixpanel|datadog" apps/web/src packages/ returns no matches. The deployment must not reintroduce any.
  • No server-side message-key or identity material in env config. packages/env/src/server.ts defines CORS_ORIGIN, NODE_ENV, PUBLIC_BASE_URL, the public ICE endpoint URLs (STUN_URL / TURN_URL / TURN_TLS_URL), TURN_REALM, TURN_SHARED_SECRET, and GROUP_MAX_MEMBERS. There is no message-key config and no identity-key config. All cryptographic secrets protecting user content live in the user's browser (origin-scoped storage); none are sent to the server. The single server-held secret is TURN_SHARED_SECRET, used only to mint time-limited TURN credentials — it is never serialized into a response, never baked into the client bundle, and never written to logs.
  • Runtime config contains only public endpoint details plus one TURN minting secret. The client derives its broker URL from window.location; the server inputs that reach the browser are the CORS origin, the node environment, the public base URL, and the ICE endpoint coordinates. TURN_SHARED_SECRET stays server-side; only the resulting ephemeral username / credential (valid for the TTL configured on /ice-config) reach the client.

Cryptography in a browser cannot protect users served a malicious build, nor an endpoint controlled by malware or a privileged extension. Source inspection alone does not prove the deployed bytes match source. Self-hosting or independently verified / reproducible release artifacts are required for users whose threat model includes a malicious operator; that remains outside the implementation scope (see "Build and endpoint trust" in the threat model).

9. Operator checklist

Before going live:

  1. Stand up a reverse proxy (Caddy / nginx / Traefik, or Dokploy's built-in traefik) that terminates TLS on 443 and forwards /ws with the WebSocket upgrade headers to the service on port 9000 (http://web:9000 on the compose network, or 127.0.0.1:9000 if the proxy is host-side). Do not publish the web container port on a host interface — the compose file uses expose:, not ports:.
  2. If peers will be on the open internet, configure coturn in the same docker compose up deployment:
    • Generate a strong TURN_SHARED_SECRET and export it where Compose interpolation reads it: the deployment environment (Dokploy/runtime injector), a project-root .env, or --env-file. Both the web and coturn services receive it through their environment: passthrough — a value set only in apps/web/.env is overridden to empty and silently discarded.
    • Set STUN_URL, TURN_URL (and optionally TURN_TLS_URL) the same way to the public host:port clients will reach coturn on (e.g. stun:turn.example.com:3478). The URLs must carry their scheme — a bare host:port now fails startup env validation instead of silently breaking every client's RTCPeerConnection.
    • Publish the coturn ports on the host firewall: 3478/udp, 3478/tcp, 5349/tcp (if using turns:), and the full relay range 49152-49172/udp. These are already in docker-compose.yml's ports: for the coturn service; confirm your host firewall and any cloud security group allow them.
  3. Disable reverse-proxy access logs and cap container log retention.
  4. Set CORS_ORIGIN to your deployed origin and NODE_ENV=production.
  5. docker compose up -d --build and confirm both healthchecks return healthy (the web service probes GET /healthz, the coturn service probes a TCP connect on 3478).
  6. Verify the broker is reachable over WSS by opening the deployed app and creating an invitation; the broker must accept the wss://<host>/ws upgrade.
  7. Verify /ice-config returns the expected servers (when configured): curl https://<host>/ice-config should return {"iceServers":[{"urls":"stun:..."},...]} with TURN entries carrying username and credential. When nothing is configured it returns {"iceServers":[]}.
  8. Confirm no telemetry has been added to your build (the grep in §8 is empty).