Skip to content

The hosted demo

The demo stack runs on a LAN by default. To test and show Fjarr over the internet, the same pieces run on one small public server: fjarr-server, coturn, the demo backend and the demo dashboard. Robots and browsers anywhere connect out to it; nothing connects in to a robot.

This is the demo, not Fjarr Cloud: one server, one tenant, the dev registry for robots (one device token, until enrollment in M5) and the demo backend’s grants. Fjarr Cloud is M5 (docs/17).

browser ──TLS──▶ Cloudflare edge ──TLS──▶ VPS :443 Caddy ─┬─ demo.fjarr.io → dashboard files, /api → demo-backend :9090
robot ──TLS──▶ (Access on demo) └─ signal.fjarr.io → fjarr-server :8080 (WebSocket)
robot, browser ─────── UDP/TCP 3478, UDP 49160–49200 ─────▶ VPS coturn (turn.fjarr.io, not proxied)
nameDNSserves
demo.fjarr.ioproxied by Cloudflare, behind Cloudflare Accessthe dashboard (static build) and the demo backend under /api
signal.fjarr.ioproxied by Cloudflarefjarr-server: signaling (/ws) and the backend contract
turn.fjarr.ioDNS only: the server’s own addresscoturn. Cloudflare’s proxy carries no UDP

Cloudflare ends each visitor’s TLS with its own certificate, then opens a new TLS connection to the server with the original name in Host. Caddy on the server is the reverse proxy that dispatches by that name. The zone’s SSL mode is full; the origin presents a Cloudflare Origin CA certificate (trusted by Cloudflare only, valid 15 years, its key generated on the server and never copied off it), so full (strict) works too and is the goal for the whole zone.

signal is not behind Access: robots are not browsers. It is protected by its own authentication: operators present a grant the demo backend minted, robots the device token. demo is behind Access, because the demo backend mints a grant for whoever asks: without Access, anyone with the URL could drive a robot.

The exception is the operator API that fjarr-connect calls: /api/fjarr/robots, /api/fjarr/grants and /api/fjarr/cli-codes. A terminal has no Access session, so a second Access application bypasses Access for those three paths, and the backend authenticates them itself: robots and grants take only a credential the backend minted for a browser that passed Access (/api/me/fjarr-cli-token, which stays behind it); cli-codes hands one out only to the holder of the poll token, after a browser behind Access approved the code. Creating a code needs nothing, so pending codes are capped. The backend refuses a request whose path is not already in normal form, so a path that Access reads as one of the three (/api/fjarr/grants/../grant) cannot reach another route. Everything else under /api, the dashboard’s /api/fjarr/grant included, stays behind Access.

Robot and browser connect directly when their NATs allow it, and through coturn when they do not. Neither side configures a STUN server: the TURN allocation returns each side’s public address too, which gives the server-reflexive candidates a direct path needs. Whether a session went direct or relayed shows in the browser’s chrome://webrtc-internals (the selected candidate pair) and in the session pipeline’s ICE state (docs/24).

One small VPS, in Stockholm to be near the robots and operators (Kamatera, type B general purpose: a dedicated CPU thread, so relayed video does not stall on a neighbour; 1 vCPU, 2 GB RAM, 20 GB disk, Ubuntu 26.04, one public IPv4). Nothing is compiled on it: images are built elsewhere and copied over.

  • SSH: keys only (/etc/ssh/sshd_config.d/10-fjarr-keys-only.conf); root logs in with a key. The provider’s web console is the way in without one.
  • Firewall (ufw): 22/tcp; 443/tcp from Cloudflare’s address ranges only, so nobody reaches Caddy, and past Access, by the server’s address; 3478/udp, 3478/tcp and 49160–49200/udp for coturn, from anywhere. Caddy and coturn run on the host’s network: a port Docker publishes bypasses ufw, so a published 443 would be open to anyone, past Access. fjarr-server and the demo backend publish on loopback only, as Caddy’s upstreams. coturn relays to the internet only, never to the server itself or a private range (denied-peer-ip).
  • Layout: /opt/fjarr holds compose.yml, Caddyfile, the dashboard’s files, the origin certificate and .env. One-time setup: deploy/demo/server-setup.sh (Docker, the firewall) and deploy/demo/cloudflare.py (the origin certificate, DNS, Access).

/opt/fjarr/.env (0600, root) is generated on the server the first time it is deployed and never leaves it: the grant secret (shared by fjarr-server and the demo backend), the device token (shared by the robots), the TURN secret (shared by fjarr-server and coturn) and the webhook secret. None of the dev-only-* defaults are used. A robot gets the device token from the server, by hand, into its own config (0600).

make demo-deploy DEMO_HOST=root@<address> (repo deploy/demo/):

  1. builds the fjarr-server image (docker/signaling) and the dashboard with VITE_FJARR_SERVER=wss://signal.fjarr.io/ws and an empty VITE_DEMO_BACKEND (its /api calls go to its own origin);
  2. copies the image (docker save | ssh docker load), the demo backend’s source, the dashboard’s files, compose.yml and Caddyfile to /opt/fjarr;
  3. creates .env there if there is none, then docker compose up -d.

Cloudflare (DNS records, the origin certificate, Access) is set up once, with an API token scoped to the zone: DNS edit, zone and zone-settings read, SSL and certificates edit, and Access apps and policies, organizations and identity providers edit, for the one account.

  • Logs: docker compose -f /opt/fjarr/compose.yml logs -f fjarr-server (and coturn, caddy, demo-backend); container logs are capped in size.
  • Update: make demo-deploy again; .env and the certificate stay.
  • A robot onto it: server_url = "wss://signal.fjarr.io/ws" and the device token in the robot’s /etc/fjarr/fjarr.toml, then restart the agent. The server accepts any robot id with the token, but the demo backend is the company’s registry and knows only the robots in its list (demos/demo-backend/src/server.ts): add the new id there with the capabilities the robot has enabled, then deploy. A grant names only those, and a robot refuses a grant that names one it lacks.
  • fjarr-connect against it: fjarr-connect login https://demo.fjarr.io, approved as the developer role (the only one with fjarr.net), and FJARR_SERVER_URL=wss://signal.fjarr.io/ws. login does not learn the signaling URL yet, and the default is ws://localhost:8080/ws, a dev stack’s server if one is running, which then calls the demo’s grant invalid.