Relay
The relay bridges public HTTPS endpoints to local xPort clients. Zero-storage by design: content is relayed, never persisted.
Install
Section titled “Install”Build from source (Go 1.27+):
go build -o relay ./cmd/relayRequirements
Section titled “Requirements”- Go 1.27 or later
git clone https://github.com/xport-dev/xport-relay.gitcd xport-relaygo build -o relay ./cmd/relayConfigure
Section titled “Configure”Copy the example configuration:
sudo mkdir -p /etc/xportsudo cp config.example.yaml /etc/xport/relay.yamlserver
Section titled “server”| Key | Default | Description |
|---|---|---|
server.host |
0.0.0.0 |
Address the relay listens on |
server.port |
8080 |
Port the relay listens on |
server.public_base |
https://{name}.xport.sh |
URL template advertised to clients; {name} is replaced by the session name |
server.free_base |
(empty) | Separate URL template for anonymous and free-tier (non-vanity) sessions, e.g. https://{name}.xport.free. Empty serves every tier from public_base. Requires wildcard DNS + a certificate for that domain |
| Key | Default | Description |
|---|---|---|
tls.cert_file |
(empty) | TLS certificate. Empty = plain HTTP (dev or behind a TLS proxy) |
tls.key_file |
(empty) | TLS private key |
| Key | Default | Description |
|---|---|---|
auth.mode |
token |
token (static bearer) or oauth (validate login tokens via introspection) |
auth.token |
(empty) | Bearer token clients must present in token mode. Empty = auth disabled (dev only) |
auth.introspect_url |
(empty) | RFC 7662-style endpoint for oauth mode, e.g. https://xport.dev/oauth/introspect |
auth.cache_ttl |
60s |
How long introspection answers are cached |
auth.allow_anonymous |
false |
In oauth mode: let requests without a token through as anonymous free-tier clients (hosted free tier) |
Enforced in oauth mode only — token mode keeps full freedom (any
valid name, nothing persisted).
| Key | Default | Description |
|---|---|---|
names.anon_random_length |
20 |
Random name length for anonymous clients (logged-in users keep short 5-char randoms) |
names.blocked |
[] |
Extra blocked words on top of the built-in infrastructure/brand list (exact, case-insensitive match) |
names.free_limit |
3 |
Reserved custom names for logged-in free users (the fixed name never counts) |
names.pro_limit |
10 |
Reserved custom names for vanity (pro/team) users |
storage
Section titled “storage”| Key | Default | Description |
|---|---|---|
storage.names_file |
(empty) | JSON file persisting reserved names across restarts (written 0600). Empty = in-memory only |
The hosted policy at a glance: anonymous clients always get random
names; requesting a custom name requires an account (xport login) and
counts against the per-plan limit; blocked words are rejected for
everyone; reserved names survive relay restarts.
On the hosted relay, the advertised domain mirrors the plan: anonymous
and free-tier sessions are served from the free zone (free_base,
e.g. *.xport.free — long random names, isolated from the paid
domain’s reputation), while paid (vanity) tiers get public_base
(e.g. *.xport.sh). Self-hosters leave free_base empty and serve
everything from one domain.
logging
Section titled “logging”| Key | Default | Description |
|---|---|---|
logging.level |
info |
debug, info, warn, error |
limits
Section titled “limits”| Key | Default | Description |
|---|---|---|
limits.session_ttl |
24h |
Default lifetime of a tunnel session (safety cap; tunnels die with the client anyway) |
limits.anon_max_session_ttl |
4h |
Maximum lifetime for anonymous (not logged-in) sessions, explicit requests and the default alike |
limits.free_max_session_ttl |
24h |
Maximum lifetime for registered free-plan sessions |
limits.max_session_ttl |
720h (30 days) |
Maximum lifetime for pro/team sessions |
limits.enterprise_max_session_ttl |
8760h (365 days) |
Maximum lifetime for enterprise sessions; values <= 0 remove the cap entirely |
limits.cleanup_interval |
1m |
How often expired sessions are reaped |
internal
Section titled “internal”| Key | Default | Description |
|---|---|---|
internal.token |
(empty) | Bearer token for the account website’s admin API (/internal/v1/names — list/release a user’s reserved names). Empty = endpoint not mounted |
domains
Section titled “domains”| Key | Default | Description |
|---|---|---|
domains.url |
(empty) | Account server’s /internal/domains endpoint the relay polls for the verified custom-domain (BYOD) list. Empty = --domain rejected |
domains.token |
internal.token |
Bearer token for the poll |
domains.cache_ttl |
60s |
How long the fetched list is used before refreshing |
Raw TCP tunnels (xport tcp): any protocol, not just web
services. Disabled by default; the relay rejects tcp registrations
otherwise.
| Key | Default | Description |
|---|---|---|
tcp.enabled |
false |
Turn the TCP listener pool on |
tcp.host |
0.0.0.0 |
Address the per-session listeners bind |
tcp.port_min / tcp.port_max |
12000 / 12999 |
Port pool allocated per session |
tcp.public_host |
localhost |
Host advertised to clients (tcp://<public_host>:<port>) |
Control endpoint
Section titled “Control endpoint”GET /v1/control is the WebSocket control plane (see the
protocol spec): clients register tunnel sessions and
keep them alive with heartbeats. It sits behind the bearer-token middleware
like every route except /healthz.
Clients may attach a per-session traffic policy
to the register message; the relay compiles it (failing closed on invalid
policies) and enforces it on every public request and response.
Every option also works as an environment variable: uppercase,
XPORT_RELAY_ prefix, dots become underscores — e.g.
XPORT_RELAY_SERVER_PORT, XPORT_RELAY_AUTH_TOKEN. The config directory
defaults to /etc/xport and can be moved with XPORT_RELAY_CONFIG_DIR.
./relayStop with Ctrl-C — in-flight requests finish before shutdown.
GET /healthz is public and returns {"status":"ok"}; all other routes
require the bearer token when one is configured.
Point the CLI at your relay with relay.url.

