This guide deploys Relay as a hosted product: the web UI on Vercel and the backend control plane on Railway with Railway Postgres. It covers the two origin models, the settings each one needs, and what deliberately stays off both platforms.
To put Cloudflare in front of Railway instead of Vercel — one origin, with Cloudflare as CDN, TLS edge, and WAF — see deployment-cloudflare.md.
Relay splits into a control plane and an execution plane, and that split decides the hosting:
| Component | Host | Why |
|---|---|---|
web/ (Next.js UI) |
Vercel | Static/edge-friendly client app; no server state. |
backend/ (FastAPI) |
Railway | Long-lived process: SSE streams, daemon command queue, background task scheduler. |
| Postgres | Railway | Sessions, tasks, events, auth, daemon registry. |
relay-daemon |
Neither | Runs where the sandbox lives — see below. |
relay-supervisor |
Depends on its provider | Control plane with --provider command, execution plane with --provider local. See The supervisor. |
Daemons do not belong on Vercel or Railway. The backend never executes
agents; it queues commands that daemons poll for. A daemon in its default mode
boots a BoxLite microVM, which needs hardware virtualization that neither
platform's containers provide. Daemons run on employee machines (SANDBOX_MODE=none)
or on a VM host you control, and they connect out to the Railway backend URL —
so the backend needs a public URL, but daemons never need inbound ports.
You can run a daemon in a plain container with --sandbox none, where agents
are host processes rather than VM guests. That trades the sandbox boundary for
container isolation only, and you must supply each agent CLI's credentials in
the image. Relay requires --allow-host-agent-execution (or
RELAY_ALLOW_HOST_AGENT_EXECUTION=1) so this trust decision cannot happen by
accident. Treat it as a deliberate choice, not the default.
relay-supervisor reconciles requested managed computers into running daemons.
Where it can be hosted depends entirely on which provider it uses, because the
two providers put it on opposite sides of the control/execution split:
--provider local(the default) spawnsrelay-daemonas a child process on its own host (LocalProcessProvider), and defaults to--sandbox boxlite. That makes it an execution-plane component: it inherits every daemon constraint, including hardware virtualization, and does not belong on Railway.--provider commandrenders a command template —gcloud workstations ssh …,ssh, a cloud API call — that starts the daemon on infrastructure you control. The supervisor itself then runs no agents and no sandbox, so it is deployable next to the backend as a second Railway service.
Either way it is never a Vercel workload: it is a reconcile loop
(--interval-ms, default 10s), and in command mode it holds a live child
process per launched daemon rather than dispatching and forgetting.
If you deploy it on Railway with --provider command:
- It authenticates to the backend with
RELAY_ADMIN_TOKENas a bearer token, so that variable must stay set on the backend and be given to the supervisor service. That is a standing admin credential — see Secrets. - Node tokens are interpolated into a shell command line (
shell: true), so they are visible in the process table of whatever host runs the template. Keep that host dedicated. - Restarts kill the child processes it is holding. Reconciliation restarts them on the next pass, but a redeploy is a disruption, not a no-op.
- Run one replica, for the same reason the backend does: two supervisors reconcile the same desired state against the same nodes.
--once runs a single reconcile pass and exits, which suits a scheduled job
better than a long-lived service if your provider template is fully
fire-and-forget.
browser
│
▼
┌───────────────┐ /api/* ┌──────────────────┐ ┌────────────┐
│ Vercel (web) │──────────▶│ Railway (backend)│─────▶│ Postgres │
└───────────────┘ └──────────────────┘ └────────────┘
▲
poll commands │ post run events
│
┌──────────────────────────────┐
│ daemons: laptops / VM hosts │
│ (BoxLite sandbox + agent CLIs)│
└──────────────────────────────┘
Relay authenticates with an HTTP-only session cookie. Where the browser sends that cookie is the only real decision in this deployment, and everything else follows from it.
Vercel rewrites /api/* and /profile-images/* to the Railway backend. The
browser only ever talks to the Vercel origin, so the cookie stays same-origin:
default SameSite=Lax, no CORS, nothing to configure on the cookie at all.
Use this to get running on *.vercel.app and *.up.railway.app without owning
a domain. The cost is that every API call takes an extra network hop, and
long-lived SSE streams pass through Vercel's proxy — cap them (see
SSE through a proxy).
Point app.example.com at Vercel and api.example.com at Railway. The browser
calls the backend directly: no proxy hop, and SSE streams go straight to Railway.
Sibling subdomains of one registrable domain are same-site, so the cookie stays
SameSite=Lax — it just needs Domain=.example.com. The backend needs CORS
because the origins differ.
This is the better steady state once you have a domain. Requires DNS.
Not recommended: direct across unrelated domains.
relay.vercel.app→relay.up.railway.appis cross-site (both are public suffixes), so the cookie needsSameSite=None; Secure, which browser tracking-prevention modes increasingly block outright. Relay supports it (RELAY_SESSION_COOKIE_SAMESITE=none), but expect Safari and hardened Chrome profiles to drop the session. Use Option A instead.
-
Create the project and database. New project → Provision Postgres. Railway exposes
DATABASE_URLon the database service. -
Add the backend service from this repo.
railway.jsonat the repo root already selects the build and deploy settings:- builds
backend/Dockerfile(backend only — no agent CLIs, no BoxLite); - runs
alembic -c backend/alembic.ini upgrade headas the pre-deploy command, so migrations land before the new container takes traffic; - health-checks
/healthz; - pins
numReplicas: 1— see Run one replica.
- builds
-
Reference the database. In the backend service's variables, add
DATABASE_URLas a reference to the Postgres service (${{Postgres.DATABASE_URL}}). Railway issues a barepostgresql://URL; Relay pins the psycopg 3 driver on it automatically (relay.core.storage_config.normalize_database_url), so paste it unedited. -
Set the backend variables. The image already sets
RELAY_STORAGE,RELAY_AUTH_STORE,RELAY_DAEMON_STORE,RELAY_CHAT_STORE,HOST, andRELAY_DATA_DIR;PORTcomes from Railway. Add:Variable Value Notes RELAY_ADMIN_TOKENa long random string Seeds the admin bearer token on first boot. Its SHA-256 is then stored in Postgres and the env var no longer changes it; rotate from the admin console ( POST /api/v1/admin/admin-token/reissue), which shows the new token once.RELAY_CORS_ALLOW_ORIGINShttps://app.example.comOption B only. Exact origins, comma-separated. *is rejected.RELAY_CORS_ALLOW_ORIGIN_REGEXhttps://relay-web-[a-z0-9-]+\.vercel\.appOption B only, if you want Vercel preview deployments to reach the API. RELAY_SESSION_COOKIE_DOMAIN.example.comOption B only. Shares the cookie across app.andapi..RELAY_SESSION_COOKIE_SAMESITElaxDefault. Only set to nonefor the unrelated-domains case.RELAY_TRUST_PROXY_HEADERS1Trust forwarded scheme/client headers from the deployment edge. RELAY_FORWARDED_ALLOW_IPScomma-separated proxy IPs/CIDRs Required with proxy trust. Use the socket-peer addresses or networks assigned to your edge; *is rejected.RELAY_STREAM_MAX_SECONDS240Option A only. See SSE through a proxy. RELAY_LOG_LEVELINFORELAY_PUBLIC_BACKEND_URLhttps://api.example.comPublic backend origin used in computer connection commands. Use your Railway/custom API domain, without /api/v1. Required for reliable copy-out URLs behind proxies.TLS terminates at Railway's edge, so the app sees plain HTTP. Enable proxy headers and restrict them to the edge's socket-peer IPs/CIDRs; Relay then marks session cookies
SecurefromX-Forwarded-Proto. If you cannot obtain a stable trusted-proxy range, leave proxy headers disabled and setRELAY_FORCE_SECURE_COOKIES=1instead. -
Decide on a volume. Everything but skill bundles lives in Postgres — profile images, managed-node records, and the admin token's hash. Skill bundle bytes are written to
/data/skills/objectsunless a blob store is configured:- With R2 (
RELAY_BLOB_STORE=r2, see deployment-cloudflare.md), no volume is needed. - Without R2, attach a volume at
/data(Railway → service → Volumes); without one, published skills lose their bundles on every deploy.
Upgrading a deployment that already has a volume: keep it attached for the first boot on this version, which imports the legacy
auth/admin-tokenfile into Postgres as a hash. To retire the volume, enable R2 and runrelay migrate-blobs-to-r2 --data-dir /datauntil it reports nothing left to move. - With R2 (
-
Generate a domain. Service → Settings → Networking → Generate Domain, or attach
api.example.comfor Option B. This URL is what both the web app and every daemon will use. SetRELAY_PUBLIC_BACKEND_URLon the backend to this HTTPS origin and redeploy. For a single-domain deployment, use the domain serving Relay's API. This setting is independent of proxy header trust and prevents internal HTTP URLs from appearing in commands.
Create a Vercel project from the same repo with Root Directory = web.
Vercel detects Next.js and installs the npm workspace from the repo root, so
relay-core builds via the prebuild script. web/vercel.json supplies the
build command and response headers.
Then set environment variables for Production, Preview, and Development:
Option A (proxied):
| Variable | Value |
|---|---|
RELAY_WEB_HOST |
proxy |
RELAY_BACKEND_URL |
https://relay-backend.up.railway.app |
NEXT_PUBLIC_RELAY_BACKEND_ORIGIN |
https://relay-backend.up.railway.app |
RELAY_WEB_HOST=proxy switches the build from a static export to a Node build
whose rewrites forward /api/* and /profile-images/* to RELAY_BACKEND_URL.
NEXT_PUBLIC_RELAY_BACKEND_ORIGIN does not affect request routing — it is the
origin printed in values a human copies out of the UI (daemon start commands,
chat webhook base URLs), which must address the backend directly rather than
going through the proxy.
Option B (direct):
| Variable | Value |
|---|---|
NEXT_PUBLIC_RELAY_API_ORIGIN |
https://api.example.com |
Leave RELAY_WEB_HOST unset: the build stays a static export and the client
calls the backend origin directly with credentials: "include".
Both are build-time values. Changing either requires a redeploy, not just a restart.
-
Bootstrap the admin. With
RELAY_ADMIN_TOKENset on the backend:curl -X POST https://<backend>/api/v1/auth/bootstrap \ -H 'Content-Type: application/json' \ -d '{"token":"<RELAY_ADMIN_TOKEN>","username":"admin","password":"<password>"}'
Then keep
RELAY_ADMIN_TOKENin the service variables only as the first-boot seed. Once the backend has booted, the token's SHA-256 is stored in Postgres and that stored value is authoritative — deleting or changing the env var afterwards has no effect. WithoutRELAY_ADMIN_TOKENno token is generated and bootstrap answers503; set it before the first boot. A supervisor authenticates with the persisted value (see The supervisor).This token is not only a bootstrap credential.
require_admin_sessionaccepts it as a bearer token on every admin route, so it is a standing admin bypass that no user account, password change, or session revocation affects. Treat it as a break-glass key: rotate it from the admin console (orPOST /api/v1/admin/admin-token/reissue) once the first admin exists and whenever it leaks — env var edits alone do not revoke it. -
Sign in at the Vercel URL and confirm the admin dashboard loads.
-
Connect a computer. Open Computers → Connect this computer, register the workspace, and copy the Install and connect command to that computer. The browser-approved setup is a bare
curl -fsSL <origin>/install.sh | sudo bash; a command for an already enrolled computer appends its computer ID, owner ID and workspace path (… | sudo bash -s -- …) and asks for the node token at a hidden terminal prompt. Credentials are never embedded in the copied command. ConfigureRELAY_PUBLIC_BACKEND_URLto your public HTTPS API origin; keep the web build'sNEXT_PUBLIC_*settings aligned. Loopback HTTP is supported for local development only.The installer supports macOS and Linux on x64/arm64. It downloads a SHA-256 verified archive of compiled JavaScript, so users do not need repository access, Git, npm, or a compiler. If Node.js 22.19+ is missing it downloads a private Node.js 24 runtime from nodejs.org and checks its published SHA-256. It requires curl, tar, and shasum or sha256sum. Agent CLIs and their login credentials must already be installed separately.
sudois only the entry point: nothing is installed as root or system-wide. The script hands the whole install to the invoking account ($SUDO_USER) through that account's login shell, so every file and the service below belong to that user. A root shell with no invoking user is refused, and the hand-off needs bash — hence| sudo bash. Piping to a shell withoutsudoinstalls for the current user exactly the same way. Because sudo resets the environment, the service sees what the login shell exports (plus the PATH sudo kept); agent settings exported only from an interactive rc file need… | sudo bash -s -- --runtime-env-file <private JSON file>.After checking backend registration and the workspace, the installer creates a per-computer LaunchAgent (macOS) or systemd user service (Linux). The service starts at user login and restarts after failures. Linux requires an active systemd user session; append
--foregroundto the copied command to run in the terminal instead. It does not enable system-wide startup or Linux linger. Control the service afterwards withrelay stop,relay start,relay restart,relay status, andrelay logs [--follow].stopkeeps the service off across logins untilrelay start(or re-running the installer); with several connected computers,--computer <node-id>narrows any command. Confirm that the computer becomes online in Relay; an accepted registration alone is not an online heartbeat.Client versions live in
~/.local/share/relay/releases/<sha256>/, private runtimes in~/.local/share/relay/runtimes/, and launchers in~/.local/bin/relay(service control) and~/.local/bin/relay-daemon. Services use absolute paths, so no PATH edit is needed to connect. Node credentials remain in the existing private~/.relay/daemon-nodes/<node-id>/credentials/storage, not service definitions. Re-running the command installs the current release and restarts that node's service; stop active agent work before updating. Existing unrelated launchers are preserved. Add~/.local/binto PATH to typerelaywithout its full path; the installer always prints commands using the quoted full path.backend/Dockerfilebuilds and ships the archive automatically. For source deployments runnpm run build:computerbefore starting the backend (also included inmake build-packagesandnpm run build). The default archive isbackend/relay/computer/daemon.tar.gz;RELAY_COMPUTER_BUNDLEcan override it. A missing archive returns HTTP 503 with an actionable build instruction. Keep/install.shand/computer/daemon-<sha256>.tar.gzreachable without login at the origin named byRELAY_PUBLIC_BACKEND_URL(the script also bakes that origin in as its backend). A separately hosted web build (RELAY_WEB_HOST=proxy) proxies both paths to the backend. Archives are immutable by digest; the script is not cached. This change does not publish a CDN release or alter token expiry.
The backend runs a background TaskScheduler that promotes due automations and
dispatches assigned tasks. It is not leader-elected: a second replica would
promote and dispatch the same work twice. Keep numReplicas: 1 (as
railway.json sets), and scale up only after giving the scheduler a lock — or
by running extra replicas with RELAY_TASK_SCHEDULER_ENABLED=0 and keeping
exactly one scheduler instance.
Thread streaming is Server-Sent Events. The backend caps a single connection at
RELAY_STREAM_MAX_SECONDS (default 1800) and the client reconnects with
Last-Event-ID, so a capped connection costs a reconnect and nothing else.
Under Option A the stream crosses Vercel's proxy, which enforces its own
duration limit. Set RELAY_STREAM_MAX_SECONDS below that limit (240 is a safe
starting point) so the server closes the stream cleanly with a resumable done
frame instead of the proxy cutting it mid-frame. Option B streams directly from
Railway and can keep the default.
If the Railway service is allowed to sleep, the first request after idle pays the container start. That also stops the task scheduler while asleep, so due automations fire late. Keep the backend always-on for any deployment where automations matter.
RELAY_ADMIN_TOKEN, DATABASE_URL, and chat integration credentials belong in
the platform's variable store, never in backend/.env — that file is
git-ignored and is a local-development convenience only. Daemon tokens are
issued per computer by the backend and are never set as service variables.
RELAY_ADMIN_TOKEN deserves separate care: require_admin_session accepts it
as a bearer token on every admin route, ahead of any session check. The env
var only seeds the token on first boot; the hash stored in Postgres is
authoritative afterwards, and the token grants full admin access unaffected by
user accounts, password changes, or session revocation. Only the hash is
stored, so a database dump does not reveal it — and the console cannot show it
again either: rotate it from the admin console (POST /api/v1/admin/admin-token/reissue), which returns the new value once, if it
is ever exposed or lost. Removing the env var does not revoke the stored token.
None of these settings apply locally. With no deployment variables set, the
backend binds 127.0.0.1:8790, issues host-only SameSite=Lax cookies, adds no
CORS headers, and npm run build -w web still produces the static export that
the backend serves at /. See local-development.md.
Backend (Railway)
| Variable | Default | Purpose |
|---|---|---|
PORT / BACKEND_PORT |
8790 |
Listen port; Railway injects PORT. BACKEND_PORT wins if both are set. |
HOST / BACKEND_HOST |
127.0.0.1 |
Listen interface; the image sets 0.0.0.0. |
DATABASE_URL / RELAY_DATABASE_URL |
— | Postgres URL. Bare postgres:// and postgresql:// are normalized to psycopg 3. |
RELAY_PUBLIC_BACKEND_URL |
request base URL | Public HTTPS API origin for enrollment/reconnect commands and daemon environment. No path, credentials, query, or fragment. HTTP allowed only for loopback development. |
RELAY_CORS_ALLOW_ORIGINS |
unset | Exact browser origins allowed to send credentials. |
RELAY_CORS_ALLOW_ORIGIN_REGEX |
unset | Anchored origin pattern for preview deployments. |
RELAY_SESSION_COOKIE_DOMAIN |
unset (host-only) | Cookie Domain, e.g. .example.com. |
RELAY_SESSION_COOKIE_SAMESITE |
lax |
lax, strict, or none. none forces Secure. |
RELAY_FORCE_SECURE_COOKIES |
0 |
Mark cookies Secure regardless of request scheme. |
RELAY_TRUST_PROXY_HEADERS |
0 |
Honor trusted X-Forwarded-Proto/X-Forwarded-For values. |
RELAY_FORWARDED_ALLOW_IPS |
unset | Required comma-separated IP/CIDR allowlist when proxy headers are enabled; * is rejected. |
RELAY_EDGE_SECRET |
unset | Comma-separated secrets (32+ chars) a CDN edge sends as X-Relay-Edge. Set, requests without one get 403 (except /healthz). See deployment-cloudflare.md. |
RELAY_CLIENT_IP_HEADER |
unset | Header naming the real client on edge-verified requests (e.g. CF-Connecting-IP), used by rate limits. Requires RELAY_EDGE_SECRET. |
RELAY_BLOB_STORE |
database |
r2 keeps workspace-artifact snapshots and skill bundle bytes in Cloudflare R2 instead of Postgres and the data dir; needs RELAY_R2_ACCOUNT_ID, RELAY_R2_ACCESS_KEY_ID, RELAY_R2_SECRET_ACCESS_KEY, RELAY_R2_BUCKET (optional RELAY_R2_ENDPOINT). See deployment-cloudflare.md. |
RELAY_BACKUP_R2_BUCKET |
unset | Bucket for relay backup-database (the nightly pg_dump cron service, railway.backup.json). Uses RELAY_BACKUP_R2_ACCESS_KEY_ID/RELAY_BACKUP_R2_SECRET_ACCESS_KEY, falling back to the RELAY_R2_* keys. See deployment-cloudflare.md. |
PG_CLIENT_MAJOR |
17 |
Image build arg: the pg_dump major version installed for backups; must be at least the server's. |
RELAY_MAX_JSON_BODY_BYTES |
4194304 |
Maximum JSON request body size before a 413 response. |
RELAY_STREAM_MAX_SECONDS |
1800 |
Cap on one SSE connection. |
RELAY_ADMIN_TOKEN |
unset | One-time admin bootstrap token. |
RELAY_DATA_DIR |
.relay |
File-store state for local installs, and where a legacy auth/admin-token is imported from; /data in the image. |
Web (Vercel)
| Variable | Default | Purpose |
|---|---|---|
RELAY_WEB_HOST |
unset (static export) | proxy builds a Node app that rewrites /api to the backend. |
RELAY_BACKEND_URL |
http://127.0.0.1:8790 |
Proxy target for RELAY_WEB_HOST=proxy and for next dev. |
NEXT_PUBLIC_RELAY_API_ORIGIN |
unset (same-origin) | Backend origin the browser calls directly. |
NEXT_PUBLIC_RELAY_BACKEND_ORIGIN |
unset | Backend origin shown in copy-out values under proxied mode. |
Stop old backend processes, apply Alembic revision 20261010_0089 to clear
legacy daemon_nodes.node_token_secret values, and start the updated backend. The
nullable column remains for schema compatibility, but updated code always writes
NULL and never reads it. Existing daemon authentication hashes remain valid;
reconnection through the Token drawer now requires explicit reissue. File-backed
node records are scrubbed when the store starts. Downgrading does not restore
erased secrets; older backend code can write new plaintext again.
The daemon host state directory must survive process restarts. Managed bootstrap identity files are mode 0600 under a mode 0700 directory, scoped to the backend and grant. Keep this state outside guest workspaces. A new provisioning attempt uses a new grant and its own identity. Upgrade the backend before the daemon so heartbeats honor the configured command lease and transient event failures return retryable 503 responses. Updated daemons exit successfully on revoked credentials, so reconnect them through setup after token reissue.