Skip to content

Self-host a relay

Use an operator relay for your own SDK application. The public Playground uses the personal relay flow. An operator relay does not host workspaces or execute workloads. Your application authenticates users. Rust enforces tickets, network policy and quotas.

Install the SDK in your application:

Terminal window
npm install @oxidevm/sdk

Self-hosted Linux x64 relay

This distribution is for application operators. The personal desktop relay and the Playground’s personal connection flow remain separate. The relay transports public TCP 80/443 and controlled DNS. Workspaces and execution stay in the browser. The native executable does not need Node.js. The reference authorization gateway does.

Supported platforms

Use a Linux x64 server with one of these distributions:

Distribution Versions
Ubuntu Server 22.04 LTS, 24.04 LTS, 26.04 LTS
Debian 12, 13
Rocky Linux 9, 10
AlmaLinux 9, 10

ARM64, RHEL, Fedora, Alpine, FIPS and multiple Fly Machines are not supported by this release.

Prerequisites

Use an x86_64 Linux server with systemd, OpenSSL 3, curl, tar, xz and ss from iproute2 (Debian/Ubuntu) or iproute (Rocky/AlmaLinux). For a new HTTPS endpoint, point its DNS A/AAAA records at the server. Remove unusable AAAA records. Permit inbound TCP 80/443 to your proxy. Permit outbound DNS and TCP 80/443 to public destinations. Do not expose 7338/7339. Keep SELinux enforcing on Rocky/AlmaLinux. The reference gateway’s ten tickets/minute/token is an admission limit, not a capacity claim. Native defaults admit 32 sessions and 256 total streams, at most 64 per session. Measure your workload before changing them. There is no automatic updater.

On Ubuntu or Debian, install the prerequisites:

Terminal window
sudo apt-get update
sudo apt-get install -y ca-certificates curl tar xz-utils openssl iproute2

On Rocky Linux or AlmaLinux, install the prerequisites:

Terminal window
sudo dnf install -y ca-certificates curl tar xz openssl iproute

If firewalld is active, permit only the HTTPS proxy’s public services:

Terminal window
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

If UFW is active instead, use sudo ufw allow 80/tcp and sudo ufw allow 443/tcp. Preserve SSH and existing application rules. Do not activate, disable or replace a firewall as part of these instructions.

Download a published release

Discover the approved signed version at https://oxidevm.dev/relay/server/releases.json. The example selects version 0.1.1. Use a new private directory as an ordinary user. Do not download or extract as root.

Terminal window
set -eu
umask 077
relay_server_version=0.1.1
relay_release_url="https://oxidevm.dev/relay/server/releases/$relay_server_version"
relay_download_dir=$(mktemp -d)
cd "$relay_download_dir"
for relay_file in \
"oxidevm-relay-$relay_server_version-server-linux-x64.tar.gz" \
"oxidevm-relay-$relay_server_version-source.tar.gz" \
candidate.json qualification.json qualification-logs.tar.gz releases.json SHA256SUMS
do
curl --fail --show-error --location --proto '=https' --tlsv1.2 "$relay_release_url/$relay_file" -o "$relay_file"
curl --fail --show-error --location --proto '=https' --tlsv1.2 "$relay_release_url/$relay_file.sig" -o "$relay_file.sig"
done
curl --fail --show-error --location --proto '=https' --tlsv1.2 "$relay_release_url/release-public-key.pem" -o release-public-key.pem

Verify and extract before installation

Obtain the release key’s SHA-256 SPKI fingerprint through an independently trusted channel. A key and fingerprint from the same download are not a trust anchor. The current release-key SPKI SHA-256 is ad5d05598c6ab94396b0ca21b99faf60e9ec71da82a0c9e2e0de0c542b70321d.

Print the downloaded key fingerprint with OpenSSL 3:

Terminal window
openssl pkey -pubin -in release-public-key.pem -outform DER | sha256sum

Stop if it differs from the independently trusted fingerprint. After it matches, verify all detached Ed25519 signatures and the complete checksum list:

Terminal window
set -eu
for relay_file in \
"oxidevm-relay-$relay_server_version-server-linux-x64.tar.gz" \
"oxidevm-relay-$relay_server_version-source.tar.gz" \
candidate.json qualification.json qualification-logs.tar.gz releases.json SHA256SUMS
do
openssl base64 -d -A -in "$relay_file.sig" -out "$relay_file.sig.bin"
openssl pkeyutl -verify -pubin -inkey release-public-key.pem -rawin -in "$relay_file" -sigfile "$relay_file.sig.bin"
done
sha256sum --check SHA256SUMS

Each signature must print Signature Verified Successfully. Every checksum must print OK. After verification, extract only the server archive into a new private directory:

Terminal window
umask 022
mkdir release
tar -xzf "oxidevm-relay-$relay_server_version-server-linux-x64.tar.gz" -C release
cd release

The download parent remains mode 0700. Restoring umask 022 lets service accounts read packaged programs and traverse installation directories. Credential tools set their private file modes explicitly. Run the remaining commands from this extracted directory. Keep the verified archives and signatures for rollback. Do not execute a remote installer directly as root.

Install the reference gateway runtime

Review server/native/install-node.sh. It downloads the pinned official Node 24 x64 archive, verifies its committed SHA-256 and installs it under /opt/oxidevm-relay. It does not run downloaded scripts. Distribution default nodejs packages can be too old. On SELinux hosts, the installer restores the policy labels for the installed Node files.

Terminal window
sudo sh server/native/install-node.sh

The command prints the installed Node.js version. Node is used by the gateway, not the Rust relay.

Each service checks the original root-owned credential files before startup. Private systemd copies do not excuse a readable source file. Service accounts must have different nonzero UIDs/GIDs and no supplementary groups. Activation also checks that the expected service PIDs own both loopback listeners.

Native installation

From the extracted archive, run the following commands. Replace the example origin, relay URL, user and workspace. Use an HTTPS URL with a trailing slash.

Terminal window
sudo /opt/oxidevm-relay/node/bin/node server/native/admin.js install "$PWD"
sudo /opt/oxidevm-relay/node/bin/node server/native/admin.js init https://app.example.com https://relay.example.com/ user-1 project-1
sudo /opt/oxidevm-relay/node/bin/node server/native/admin.js activate 0.1.1
curl --fail http://127.0.0.1:7339/v2/health

Expected health includes "version":2,"kind":"server". Installation creates separate oxidevm-relay and oxidevm-gateway users. Services use systemd LoadCredential. Original secrets and configuration stay root-owned under /etc/oxidevm-relay. Native and gateway listeners use loopback 7338 and 7339. current points atomically at a versioned /opt/oxidevm-relay/releases directory. Both services start after reboot. Repeated install preserves existing configuration and identical release bytes. init refuses an existing configuration directory.

Deliver /etc/oxidevm-relay/initial-application-token through your application’s private credential flow. Do not print it into shared logs or embed it in a public build. The issuer file is a different credential and never leaves the server. Remove the initial token file after secure delivery. Its hash remains in access.json.

Fresh server: Caddy

Review and run sudo sh server/native/install-proxy-packages.sh caddy. It uses the official repositories from https://caddyserver.com/docs/install for Debian/Ubuntu or CentOS/RHEL. Use the CentOS/RHEL package instructions on Rocky Linux and AlmaLinux. Set the hostname in server/native/Caddyfile. Use its reverse_proxy route in your Caddy configuration. Do not overwrite unrelated sites.

Terminal window
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl enable --now caddy
sudo systemctl reload caddy
curl --fail https://relay.example.com/v2/health

Caddy obtains and renews certificates automatically when DNS and challenge ports are correct. See https://caddyserver.com/docs/quick-starts/reverse-proxy. Keep your firewall active. On SELinux systems, inspect audit denials before adding the smallest required proxy network policy. Do not use permissive mode as a fix.

Existing HTTPS: nginx

Include server/native/nginx.conf inside your existing HTTPS server. Keep your certificate and Host/Origin headers. Set public_url in /etc/oxidevm-relay/relay.toml and publicUrl in /etc/oxidevm-relay/gateway.json to the exact prefixed URL, for example https://app.example.com/oxidevm-relay/. Set allowed origins to your application origin. The session URL also uses the prefix. Root routing can use / instead, with public URLs ending at /.

If nginx is not installed, run sudo sh server/native/install-proxy-packages.sh nginx first.

On an enforcing Rocky/AlmaLinux host, nginx may need permission to reach 7339. The supplied module grants only httpd_t connections to a dedicated gateway port type. Review it first. Do not enable broad httpd network access or permissive mode.

Terminal window
sudo dnf install -y checkpolicy policycoreutils-python-utils selinux-policy-devel
checkmodule -M -m -o oxidevm_relay_proxy.mod server/native/oxidevm-relay-proxy.te
semodule_package -o oxidevm_relay_proxy.pp -m oxidevm_relay_proxy.mod
sudo semodule -i oxidevm_relay_proxy.pp
sudo semanage port -a -t oxidevm_relay_gateway_port_t -p tcp 7339

If 7339 already has another explicit port assignment, stop and choose a different unused gateway port in all configurations. Do not replace another application’s policy. Inspect AVCs after starting the proxy. Resolve relevant access denials before opening the endpoint to users.

After the proxy configuration and any required SELinux policy are ready, run:

Terminal window
sudo nginx -t
sudo /opt/oxidevm-relay/node/bin/node server/native/admin.js activate 0.1.1
sudo systemctl enable --now nginx
sudo systemctl reload nginx
curl --fail https://app.example.com/oxidevm-relay/v2/health

Use the installed release version in activate. Activation restarts the gateway and native relay so both load the edited configuration. Existing sessions end. The 60-second proxy idle limit exceeds native 15-second WebSocket heartbeats.

Only the gateway is public. /v2/sessions, /v2/revoke and /local/* return 404. Read HTTP.md for authorization, SDK integration, exact request fields, CORS and CSP.

Configuration

Native relay.toml requires listen, public_url, allowed_origins and issuer_key_file. Optional max_sessions defaults to 32 (1–1024); max_streams defaults to 256 (1–4096). The gateway JSON requires version 1, listen host/port, upstream, publicUrl, allowedOrigins, issuerFile and accessFile. Unknown fields fail startup. The native upstream must be loopback HTTP. Public URLs must be HTTPS and end in /. Systemd credential paths in the templates must match the installed unit names. Access files contain version 1 and users with id, tokenSHA256 and exact workspaces. Configuration and credential files must not be readable by unrelated users.

Upgrade and rollback

Verify and extract a new immutable version into a private directory. Run its install command. Existing configuration and keys are preserved. Run activate NEW_VERSION. Activation stops gateway admission before native shutdown, switches current, and checks native-dependent gateway health. A failed check restores the old pointer and restarts the old services. Check health and connect a new SDK session afterward. Changes interrupt all sessions. The SDK does not silently select another relay.

For explicit rollback, run admin.js activate PREVIOUS_VERSION from a retained verified archive. Release directories remain intact. If rollback health fails, inspect both journals and keep the original configuration and issuer file.

Rotation, maintenance and removal

Stop gateway first, then native before rotating issuer or application credentials. Edit only the necessary protected files. Start native, then gateway. Removing an application entry blocks new tickets after gateway restart. A coordinated restart also invalidates existing sessions. There is no selective live revocation promise.

Terminal window
sudo systemctl stop oxidevm-relay-gateway oxidevm-relay-native
sudo systemctl start oxidevm-relay-native oxidevm-relay-gateway
sudo journalctl -u oxidevm-relay-native -u oxidevm-relay-gateway --since today

Run sudo /opt/oxidevm-relay/node/bin/node server/native/admin.js remove from a retained extracted archive to remove services and binaries. It preserves /etc/oxidevm-relay and service users. Permanent deletion is separate: after backup and an explicit decision, remove that directory and the two service users. Do not delete shared proxy config.

Troubleshooting

  • DNS: check server resolution and guest /etc/resolv.conf. Guest DNS fails while disconnected.
  • TLS: check the hostname, DNS records, certificate chain and proxy logs. Do not disable certificate validation.
  • 401: check the application credential and access-file hash. Never use the issuer as an application token.
  • 403: compare HTTP/body origins, exact workspace permissions and configured public URL.
  • 429: wait one minute, close unused sessions and inspect native admission limits.
  • 502/503: check native service health, loopback ports and private issuance logs.
  • systemd credentials: check root-only source permissions and the unit-specific /run/credentials paths.
  • WebSockets: preserve Origin, Upgrade, Connection and oxidevm-relay-v2. Permit HTTPS and WSS in CSP.
  • SELinux: inspect ausearch -m AVC -ts recent. Resolve relevant denials with narrow reviewed policy, never permissive mode.
  • Restart: reconnect explicitly. Existing tickets and connections do not survive native restart.

The Fly recipe is in server/fly. It uses one Machine because ticket authority is in memory. Follow its guide rather than adding a second Machine or a public native port.

gateway.json
{
"version": 1,
"listen": { "host": "127.0.0.1", "port": 7339 },
"upstream": "http://127.0.0.1:7338/",
"publicUrl": "https://relay.example.com/",
"allowedOrigins": ["https://app.example.com"],
"issuerFile": "/run/credentials/oxidevm-relay-gateway.service/issuer",
"accessFile": "/run/credentials/oxidevm-relay-gateway.service/access"
}
Caddyfile
# Replace this hostname. Public DNS must reach this server on TCP 80 and 443.
relay.example.com {
reverse_proxy 127.0.0.1:7339
}
Existing nginx HTTPS server
# Include these locations in your existing HTTPS server. Keep its certificates.
# The trailing slash on proxy_pass strips only this public prefix.
location /oxidevm-relay/ {
proxy_pass http://127.0.0.1:7339/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Origin $http_origin;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
client_max_body_size 8k;
}
# Set public_url/publicUrl to https://YOUR-HOST/oxidevm-relay/ in both configs.
# Set sessionUrl to https://YOUR-HOST/oxidevm-relay/api/oxidevm/relay-session.

Application authorization and HTTP integration

Section titled “Application authorization and HTTP integration”

Application authorization contract

The reference gateway supplies application tokens, not accounts or a login database. Your application delivers each token privately to its authenticated user. Do not put tokens in URLs, public bundles or logs. Keep the relay issuer credential on the server.

The protected access file has this exact structure. Generate a different random token for every user with the installed native runtime:

Terminal window
/opt/oxidevm-relay/node/bin/node server/gateway/token.js PRIVATE_FILE USER WORKSPACE...

Run it from the extracted archive in a private directory. For the Fly.io path, use the local Node.js 24 runtime required by server/fly/README.md instead. The private output contains token and entry. Copy only entry into users.

{"version":1,"users":[{"id":"user-1","tokenSHA256":"64 lowercase hexadecimal characters","workspaces":["project-1"]}]}

User IDs and token hashes must be unique. Workspace permissions match exactly. An empty users array denies every application token. Restart the gateway after an access-file edit. To revoke existing connections, stop the gateway first, stop the native relay, replace credentials, then start native and gateway. This interrupts all sessions. Selective live revocation is not supplied by this example.

Public request

POST /api/oxidevm/relay-session with Content-Type: application/json, an allowed HTTP Origin, and Authorization: Bearer APPLICATION_TOKEN.

{"version":2,"origin":"https://app.example.com","workspaceId":"project-1","browserSessionId":"0123456789abcdef0123456789abcdef"}

These are the only four body fields. The HTTP and body origins must match the configured allowlist. An origin is a canonical HTTPS origin without a trailing slash or path. HTTP is permitted only for loopback development origins. Workspace IDs contain 1–256 UTF-8 bytes without control characters. Browser session IDs contain exactly 32 lowercase hexadecimal characters. The SDK supplies them. Bodies are limited to 8 KiB. Each valid application token can request ten tickets per minute. Failed private issuance also consumes admission. Restart resets this in-memory limit. This does not replace an edge proxy’s connection limits.

Missing/invalid credentials return 401. Origin or workspace denial returns 403. Malformed input returns 400, an oversized body 413, an unsupported content type 415, and admission denial 429 with Retry-After. Private relay failure returns 502. Native health failure returns 503. Successful tickets use Cache-Control: no-store. Responses never contain the issuer credential. Do not log ticket responses.

Backend implementation in another language

Authenticate the application user first. Authorize this exact workspace and HTTP Origin. Decode and bound the four-field body. POST it unchanged to the loopback native /v2/sessions with Authorization: Bearer PRIVATE_ISSUER_CREDENTIAL. Limit private issuance to five seconds. Validate the returned fields and socket URL. Return the native success unchanged with no-store. Do not forward private error text.

The native response has exactly version, webSocketUrl, ticket, expiresAt. The ticket is single use and expires in 30 seconds. expiresAt is Unix milliseconds. Its scope includes origin, workspace and browser session. Rust enforces this scope.

The browser connects to the returned WSS URL with subprotocol oxidevm-relay-v2. Its first message contains version, ticket, workspaceId and browserSessionId. Browsers cannot add an application Authorization header to this WebSocket. The gateway forwards the handshake and bytes. Rust authenticates and enforces policy.

Expose only public health, socket and session routes. Do not proxy /v2/sessions, /v2/revoke or /local/* to the public internet. Keep the native listener private. An arbitrary backend URL must never become the proxy upstream.

SDK example

// vm is an already-created SDK VM. Obtain token through your app's private flow.
await vm.network.connect({
kind: "server",
url: "https://relay.example.com/",
sessionUrl: "https://relay.example.com/api/oxidevm/relay-session",
headers: { authorization: `Bearer ${token}` },
});
await vm.network.disconnect();

For a prefixed nginx route, both URLs include /oxidevm-relay/. The proxy strips that prefix once. Both native public_url and gateway publicUrl retain it. Permit the exact relay HTTPS and WSS origins in your application’s CSP connect-src. CORS permits exact configured application origins and credentialed SDK requests. No wildcard origin, permissive default token or automatic relay fallback is provided.

Guest networking prerequisite

The relay does not configure a Linux guest. A System image must configure its virtual interface, default route and resolver during initialization. With the SDK’s default virtual network, the guest address is 192.0.2.2/24, the gateway is 192.0.2.1, and /etc/resolv.conf contains nameserver 192.0.2.1. Keep loopback enabled. Userspace images also need a resolver file. A successful relay connection does not prove that an image supplied this configuration. If guest curl reports DNS failure, inspect the guest’s resolver file and route before changing the relay.

Connect your application with this TypeScript example:

Application connection
import type { VM } from "@oxidevm/sdk";
/** The application obtains token through its own authenticated credential flow. */
export async function connectApplicationRelay(vm: VM, publicUrl: string, token: string): Promise<void> {
const url = new URL(publicUrl);
await vm.network.connect({
kind: "server",
url,
sessionUrl: new URL("api/oxidevm/relay-session", url),
headers: { authorization: `Bearer ${token}` },
});
}

For the one-Machine recipe, see Fly.io. For the VM connection lifecycle, see Networking.