Skip to content

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.

Terminal window
fly apps create YOUR_UNIQUE_APP --org YOUR_ORGANIZATION
cp server/fly/fly.toml fly.toml

Create 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:

Terminal window
umask 077
openssl rand -hex 32 > issuer
node server/gateway/token.js application-credential.json user-1 project-1

Prepare 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:

Terminal window
{
printf 'RELAY_ISSUER_FILE='
base64 -w0 issuer
printf '\nRELAY_ACCESS_FILE='
base64 -w0 access.json
printf '\n'
} | fly secrets import --stage --app YOUR_UNIQUE_APP

The 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

Terminal window
fly deploy --app YOUR_UNIQUE_APP --ha=false
fly scale count 1 --app YOUR_UNIQUE_APP
fly machine list --app YOUR_UNIQUE_APP
fly volumes list --app YOUR_UNIQUE_APP
curl --fail https://YOUR_UNIQUE_APP.fly.dev/v2/health

Confirm 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

Terminal window
fly logs --app YOUR_UNIQUE_APP
fly status --app YOUR_UNIQUE_APP
fly machine restart MACHINE_ID --app YOUR_UNIQUE_APP
fly releases --app YOUR_UNIQUE_APP

For 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

Terminal window
fly apps destroy YOUR_UNIQUE_APP --yes
fly apps list

Confirm 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/.

fly.toml
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"
Dockerfile
FROM node:24-bookworm-slim@sha256:91acba91b842bec0bf7ea6f5e09c644c7ccad72c4996ed9616d861d1c7cd60d4
RUN 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-gateway
WORKDIR /app
# Build context is the extracted, verified server archive, never the private checkout.
COPY oxidevm-relay /app/oxidevm-relay
COPY server /app/server
COPY compatibility.json /app/compatibility.json
COPY package.json LICENSE THIRD_PARTY_NOTICES.txt sbom.cdx.json /app/
RUN chmod 755 /app/oxidevm-relay && mkdir -p /run/secrets
EXPOSE 8080
STOPSIGNAL SIGTERM
CMD ["node", "/app/server/fly/supervisor.js"]