Self-Hosting the Relay

The relay is only needed for device pairing. After pairing, all auth is P2P with no relay involved.

Pairing (~30s):  Device A  <-WS->  Relay  <-WS->  Device B
Runtime:         Device A  --HTTP-->  Device B   (no relay)

The relay is stateless. Sessions exist in memory for ~30 seconds during pairing, then are forgotten. No database, no persistence.

Docker

The simplest way to self-host.

$ git clone https://github.com/ameshdev/amesh.git
$ cd amesh
$ docker compose up -d

# Health check
$ curl http://localhost:3001/health
{"status":"ok","sessions":0}
# Or build the image separately
$ docker build -f Dockerfile.relay -t amesh-relay .
$ docker run -p 3001:3001 amesh-relay

Google Cloud Run

Scales to zero — no cost when nobody is pairing. Native WebSocket support.

# Build and push
gcloud builds submit \
  --tag gcr.io/YOUR_PROJECT/amesh-relay \
  -f Dockerfile.relay .

# Deploy
gcloud run deploy amesh-relay \
  --image gcr.io/YOUR_PROJECT/amesh-relay \
  --port 3001 \
  --allow-unauthenticated \
  --session-affinity \
  --min-instances 0 \
  --max-instances 3 \
  --set-env-vars AMESH_TRUST_PROXY=1

--session-affinity keeps WebSocket connections on the same instance during pairing.

Required: set AMESH_TRUST_PROXY=1 on Cloud Run (or any reverse proxy / load balancer). The TCP peer Cloud Run exposes is the front-end LB, identical for every client — without this env var the relay's per-IP rate limiter collapses into a single global bucket. Do NOT set it on directly-exposed deployments where clients could spoof X-Forwarded-For.

Fly.io

Simple CLI deployment with auto-stop when idle.

$ fly launch --dockerfile Dockerfile.relay
$ fly deploy

Plain Bun

No Docker required.

$ npm install @authmesh/relay
$ PORT=3001 bunx @authmesh/relay
// Or programmatically
import { createRelayServer } from '@authmesh/relay';

const relay = await createRelayServer({ port: 3001 });
await relay.start();

Kubernetes

apiVersion: apps/v1
kind: Deployment
metadata:
  name: amesh-relay
spec:
  replicas: 1
  selector:
    matchLabels:
      app: amesh-relay
  template:
    spec:
      containers:
        - name: relay
          image: node:22-slim  # build your own image — see Dockerfile below
          ports:
            - containerPort: 3001
          readinessProbe:
            httpGet:
              path: /health
              port: 3001

Security

The relay is designed to be untrusted:

Encrypted key exchange
The relay forwards opaque ChaCha20-Poly1305 blobs. It cannot read the key exchange.
MITM protection (pairing and shell)
Pairing uses a 6-digit SAS the user confirms across both devices. The shell handshake binds its identity signature to the ECDH ephemeral transcript — a relay forwarding captured envelopes between two legs produces signatures that don't verify on the receiving leg.
Rate limiting (per client IP)
5 failed OTC attempts per IP/minute and 10 bootstrap-watch registrations per IP/minute. Behind a reverse proxy, set AMESH_TRUST_PROXY=1 so the left-most X-Forwarded-For entry is used instead of the LB peer.
Single-use bootstrap tokens
The relay tracks consumed bootstrap-token jtis for 25h and rejects replays. A leaked provisioning token can't pair a second attacker-controlled device.
Session caps
50,000 concurrent pairing sessions and 10,000 concurrent WebSocket connections per instance. Bounds memory under adversarial load.
No persistence
Nothing is stored. Sessions exist in memory for ~60 seconds during pairing, then are forgotten. Single-use jtis persist in memory only (best-effort across restarts).

Configuration

PORT Listen port. Default: 3001
HOST Listen address. Default: 0.0.0.0

That's it. No database, no secrets, no external services.