Host a relay on Fly.io
Run a self-hosted relay on one Fly.io Machine with HTTPS, application tokens and file secrets.
First read Self-host a relay for signature verification, application tokens, exact workspace permissions and the SDK HTTP contract.
One-Machine Fly.io relay
Use the extracted, signature-verified server archive as the Docker build context. This image copies the release executable and gateway. It does not compile Rust. Keep credentials out of the image. Keep the archive’s .dockerignore file. It excludes credentials and unrelated files from the build context, including files that Docker would not copy into the image.
Install the Fly CLI from its official instructions and run fly auth login locally.
Use a paid account with a spending limit appropriate to your service.
The local preparation commands require Node.js 24.2 or later within the 24 release
line, OpenSSL 3, curl and GNU base64. Install Node.js from https://nodejs.org/en/download
if node is unavailable. Check node --version before generating credentials.
This local runtime prepares credentials. The image supplies its own pinned Node.js.
Prepare
Create a unique app in your selected organization. Copy server/fly/fly.toml to
fly.toml in the extracted archive. Set app, primary_region, APP_ORIGIN and
RELAY_PUBLIC_URL. Use your real HTTPS application origin. The public relay URL
ends in /. The application token authorizes an exact SDK workspace ID.
fly apps create YOUR_UNIQUE_APP --org YOUR_ORGANIZATIONcp server/fly/fly.toml fly.tomlCreate credentials in a private directory with umask 077. Use the token generator from HTTP.md for application tokens. Create access.json with version 1 and a users array containing only generated entries. The issuer is separate random data:
umask 077openssl rand -hex 32 > issuernode server/gateway/token.js application-credential.json user-1 project-1Prepare access.json by copying entry from application-credential.json into users.
Do not send token or the entire credential output to the relay access file.
Deliver the application token through your application’s private credential flow.
Fly file secrets decode base64 into files on each Machine. Import through stdin so values do not appear in shell command arguments or command history:
{ printf 'RELAY_ISSUER_FILE=' base64 -w0 issuer printf '\nRELAY_ACCESS_FILE=' base64 -w0 access.json printf '\n'} | fly secrets import --stage --app YOUR_UNIQUE_APPThe supervisor reads these files, validates them and creates private copies for two different unprivileged users. Native listens only on 127.0.0.1:7338. Gateway listens on 8080 behind Fly HTTPS. The root supervisor prepares secrets but serves no traffic. Startup first makes the original secret directory root-only and its files root-owned mode 400. It replaces temporary role copies before starting either child.
Deploy one Machine
fly deploy --app YOUR_UNIQUE_APP --ha=falsefly scale count 1 --app YOUR_UNIQUE_APPfly machine list --app YOUR_UNIQUE_APPfly volumes list --app YOUR_UNIQUE_APPcurl --fail https://YOUR_UNIQUE_APP.fly.dev/v2/healthConfirm exactly one Machine and zero persistent volumes. The template uses one
shared CPU and 512 MiB RAM. Autostop is off. Fly can create multiple Machines by
default, so --ha=false and the read-back check are required. Do not enable HA:
ticket issuance and WebSocket authentication use one in-memory Rust authority.
The health endpoint succeeds only if native health succeeds. Native child failure stops the gateway and fails the Machine. SIGTERM stops authorization before native shutdown. Existing sessions end. Your app must request an explicit new connection.
Use the SDK connection example in HTTP.md with this app’s HTTPS URL and session endpoint. Permit its exact HTTPS and WSS origins in CSP. The configured APP_ORIGIN must equal the application’s actual browser origin. Test guest curl and npm access.
Operations
fly logs --app YOUR_UNIQUE_APPfly status --app YOUR_UNIQUE_APPfly machine restart MACHINE_ID --app YOUR_UNIQUE_APPfly releases --app YOUR_UNIQUE_APPFor an update, verify the new archive, copy your non-secret fly.toml into it, and
run the same deploy command with --ha=false. Secrets remain in Fly. Check health,
Machine count and a new SDK connection. Keep the prior verified archive and its
image digest. For rollback, redeploy that retained archive or its recorded image
using fly deploy --image RETAINED_IMAGE --ha=false. Do not build a different
binary under the same release version.
For credential changes, stop the Machine, update staged file secrets through stdin, then redeploy and reconnect. This revokes all existing sessions. It does not provide selective live revocation. Do not log secrets or include them in Docker build args.
For a custom domain, use fly certs add relay.YOUR_DOMAIN --app YOUR_UNIQUE_APP,
apply the DNS instructions Fly returns, and check fly certs check. Update
RELAY_PUBLIC_URL and the SDK URLs together. Check normal TLS validation and the
correct application origin. See https://docs.fly.io/networking/custom-domain/.
Remove
fly apps destroy YOUR_UNIQUE_APP --yesfly apps listConfirm the app and its Machines, volumes and unused address reservations are removed. Remove deployment DNS records you no longer need. Delete local temporary credential files. Removal ends existing sessions.
Reference: https://docs.fly.io/reference/configuration/ and https://docs.fly.io/apps/app-availability/.
Packaged recipe
Section titled “Packaged recipe”app = "REPLACE_WITH_YOUR_UNIQUE_APP"primary_region = "ams"kill_signal = "SIGTERM"kill_timeout = "20s"
[build] dockerfile = "server/fly/Dockerfile"
[env] APP_ORIGIN = "https://app.example.com" RELAY_PUBLIC_URL = "https://REPLACE_WITH_YOUR_UNIQUE_APP.fly.dev/"
[http_service] internal_port = 8080 force_https = true auto_stop_machines = "off" auto_start_machines = true min_machines_running = 1
[[http_service.checks]] interval = "15s" timeout = "3s" grace_period = "10s" method = "GET" path = "/v2/health"
[[vm]] cpu_kind = "shared" cpus = 1 memory = "512mb"
[[files]] guest_path = "/run/secrets/issuer" secret_name = "RELAY_ISSUER_FILE"
[[files]] guest_path = "/run/secrets/access.json" secret_name = "RELAY_ACCESS_FILE"FROM node:24-bookworm-slim@sha256:91acba91b842bec0bf7ea6f5e09c644c7ccad72c4996ed9616d861d1c7cd60d4RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u2 \ && rm -rf /var/lib/apt/lists/* \ && groupadd --gid 10001 oxidevm-relay && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin oxidevm-relay \ && groupadd --gid 10002 oxidevm-gateway && useradd --uid 10002 --gid 10002 --no-create-home --shell /usr/sbin/nologin oxidevm-gatewayWORKDIR /app# Build context is the extracted, verified server archive, never the private checkout.COPY oxidevm-relay /app/oxidevm-relayCOPY server /app/serverCOPY compatibility.json /app/compatibility.jsonCOPY package.json LICENSE THIRD_PARTY_NOTICES.txt sbom.cdx.json /app/RUN chmod 755 /app/oxidevm-relay && mkdir -p /run/secretsEXPOSE 8080STOPSIGNAL SIGTERMCMD ["node", "/app/server/fly/supervisor.js"]