Already running an earlier build? Pull and restart — no data loss:
cd ~/xns-relayer
docker compose pull
docker compose up -d
If you're stuck or the dashboard doesn't come up, ask for help before reaching
for a clean reinstall: docker compose down -v permanently deletes your relayer's
data — its database, configuration, claim, and any stored objects — and you'll re-claim from
scratch. Treat it as a last resort, not a fix.
What this is
⌄
The XNS Relayer is an S3‑compatible storage gateway. You point any S3 client at it and it spreads your data across a decentralized network of independent storage providers using erasure coding — 80 data + 40 parity shards across 120 hosts, so your files survive even when a lot of those hosts don't.
Run it on your own machine, get a local S3 endpoint, and use it like you'd use any S3 bucket.
System requirements
⌄
Port 8888 is the web dashboard, 9000 is the S3 API (plain HTTP), and
9443 is the encrypted S3 endpoint that turns on if you install a TLS certificate.
You'll install Docker in the next step — that's the only software you need.
What throughput to expect: on the 2-vCPU reference hardware this build is tested against, a single upload stream (S3 PUT) moves roughly 5–7 MiB/s. The bottleneck is CPU, not your network — machines with more CPU headroom will see higher rates.
Know what you're about to expose
⌄
Before you install, pick the deployment profile that matches your situation. Each profile shows what every port is for, who needs to reach it, and the consequence if the wrong party reaches it. This takes one minute and saves you from guessing later.
A personal machine — your laptop, desktop, or a single-user dev box. You are the only operator and the only user.
| Port | What it is | Who needs to reach it | If the wrong party reaches it |
|---|---|---|---|
8888 |
Web dashboard (management UI) | You, from this machine or your LAN | They see and can manage your storage — treat this like an admin panel |
9000 |
S3 API (plain HTTP) | Your S3 clients (local scripts, dev tools) | They can read/write your stored objects if they have credentials |
9443 |
S3 API (HTTPS, once a certificate is installed) | Same clients, over an encrypted channel | Same as port 9000 — credentials still control access, but the connection is encrypted |
On a workstation, the default binding (0.0.0.0, meaning all interfaces) is usually fine — only your LAN can reach these ports. If your machine is directly on the public internet without a NAT, consider narrowing the dashboard bind (see below).
A dedicated server in a production or managed environment — a rack-mount, a cloud VM in a VPC, or an MSP-managed node behind a proxy. Multiple operators or tenants may share the infrastructure.
| Port | What it is | Who needs to reach it | If the wrong party reaches it |
|---|---|---|---|
8888 |
Web dashboard (management UI) | Designated operators only, via a controlled network path (VPN, bastion host, or reverse proxy with NO_ORIGIN_DECLARED_PROXY_CIDRS) |
Full administrative access to the node — storage management, configuration changes, certificate operations, and operator credential surfaces. In a multi-tenant deployment this is a cross-tenant boundary. |
9000 |
S3 API (plain HTTP) | Authorized service accounts and applications with scoped credentials | Credential-protected, but unencrypted traffic on a shared network is observable. In a multi-tenant or compliance-bound environment, use port 9443 instead. |
9443 |
S3 API (HTTPS, once a certificate is installed) | Same service accounts, with TLS encryption required by policy | Same credential protection as 9000 with encrypted transport. This is the expected production endpoint for enterprise and compliance-bound deployments. |
On a dedicated server, bind the dashboard to 127.0.0.1 and front it with a reverse proxy or access it via SSH tunnel. Configure NO_ORIGIN_DECLARED_PROXY_CIDRS in your .env to declare the proxy's source address. Enable encrypted-only S3 (port 9443) and install a proper certificate.
Three decisions before you continue
These are optional. The defaults work for most workstation installs. Choosing "not yet" on any of them is a complete, documented outcome — nothing will prompt you again.
Dashboard bind address (UI_PORT)
Default: 0.0.0.0:8888 — the dashboard listens on all
interfaces and is reachable from any machine on your LAN.
Cost of narrowing: setting UI_PORT=127.0.0.1:8888 in your
.env restricts the dashboard to localhost only. Other machines on your LAN
will no longer be able to reach it without a reverse proxy or SSH tunnel.
Not yet? Leave the .env as-is. The dashboard stays reachable
from your LAN, exactly as today. You can narrow it any time later by adding one line to
.env and running docker compose up -d.
Checking both switch states from the host
The Security page in the dashboard is the primary place both switches are reported. To read the same two states from the host — before you have a browser on the box, or to confirm what the running services actually hold rather than what was saved — run:
docker exec xns-relayer sh -c 'grep -E "require_encrypted_management|refuse_unencrypted_storage_offbox" /relayer/conf/relayer_configuration.json'
docker exec xns-relayer sh -c 'grep S3GW_REFUSE_UNENCRYPTED_OFFBOX /relayer/conf/xns.env 2>/dev/null || pm2 env 0 | grep S3GW_REFUSE_UNENCRYPTED_OFFBOX'
The first command shows the saved state of both switches. The second shows what the storage service is actually enforcing — it reads its setting once at start, so this is the value in force right now.
If those two disagree, the storage service has not picked up a recent
change yet. Saving the switch restarts that service for you; if you edited the
configuration file by hand instead, run docker compose up -d --force-recreate.
Management-plane encrypted-only switch
Default: Off — the dashboard serves over HTTP. This is safe when only your LAN can reach it (workstation/home network).
What it does: when enabled, the dashboard refuses unencrypted HTTP connections and serves only over HTTPS. Requires a TLS certificate to be installed first.
Cost of enabling: you must have a working certificate before flipping this switch, or the dashboard becomes unreachable. On a shared or enterprise host this is recommended; on a workstation it is optional.
Not yet? Leave it off. The dashboard keeps serving over HTTP. You can enable it later from Settings once a certificate is installed.
Storage-plane encrypted-only switch
Default: Off — the S3 API serves on both :9000 (HTTP)
and :9443 (HTTPS, once a certificate is installed). Both remain available.
What it does: when enabled, the S3 API refuses connections on the plain
HTTP port (:9000) and serves only on :9443 (HTTPS).
Cost of enabling: every S3 client must be reconfigured to use
https://<host>:9443 and trust the certificate. Any client still
pointing at :9000 will stop working.
Not yet? Leave it off. Both endpoints stay available. You can enable it later from Settings once your clients are reconfigured for HTTPS.
If you use a self-signed certificate
When you install a self-signed certificate (via Settings → Certificates), here is what happens:
- Your browser will show a certificate warning when you visit the dashboard over HTTPS. This is expected — the certificate was not issued by a public certificate authority, so the browser cannot verify it automatically.
- Why: a self-signed certificate still encrypts the connection, but it does not prove the server's identity to a third party. The browser warns because it cannot distinguish your self-signed certificate from an intercepted connection.
- How to verify: print the certificate's SHA-256 fingerprint from the
container and compare it with the one your browser shows in the certificate details dialog:
The Settings → Certificates page also shows the fingerprint. If both match the browser's certificate details, the connection is to your Relayer.
docker exec xns-relayer openssl x509 -in /relayer/conf/certs/public.crt -noout -fingerprint -sha256 - To replace the certificate later: upload a new certificate (from a public CA such as Let's Encrypt, or from your organization's internal CA) on the Settings → Certificates page. The browser warning disappears once the certificate is issued by a trusted CA. No reinstall is needed.
Verify actual port publication (after install)
After starting the Relayer (Step 5), confirm that Docker actually published the ports you expect:
docker port xns-relayer
On a default install you should see:
8888/tcp -> 0.0.0.0:8888
9000/tcp -> 0.0.0.0:9000
9443/tcp -> 0.0.0.0:9443
Compare the output against whatever you set in .env. If you set
UI_PORT=127.0.0.1:8888, the first line should show
8888/tcp -> 127.0.0.1:8888. If the output still shows the old value,
remember that editing .env alone does not change port publication —
you must recreate the container: docker compose up -d.
.env settings (or the defaults if you haven't changed anything)?
Your host is publishing exactly what you intended.Let your AI assistant install it
⌄
Prefer not to copy‑paste commands? If you use Claude, it can do the whole setup for you — check your system, register your account, start the Relayer, and verify storage — just by talking to it. This is the fastest way to get running. The manual steps below still work if you'd rather drive it yourself.
Claude Desktop — add this to your claude_desktop_config.json
(Settings → Developer → Edit Config), then fully quit and reopen Claude Desktop:
{
"mcpServers": {
"xns-relayer": {
"command": "npx",
"args": ["-y", "@xns-cloud/relayer-mcp@latest"]
}
}
}
Claude Code — one command instead of editing JSON:
claude mcp add xns-relayer -- npx -y @xns-cloud/relayer-mcp@latest
Then just tell Claude:
Claude installs the current public release build by default — no compose file to track down and no registry login. It handles the rest; you click one email‑verification link and complete one browser sign‑in.
The assistant installs the same release build this guide covers — agentic and manual installs land on the identical image.
Optional: the xns command-line tool
⌄
xns command-line tool
Prefer the terminal? The xns CLI is a single self-contained binary that
talks to your Relayer from the command line — handy for scripting and quick checks. It's optional; the
dashboard and your S3 client cover everything the CLI does.
Download the build for your platform from the release file server, then make it executable:
Linux (x86-64)
curl -fL https://releases.scpri.me/xns-cli/beta/linux-amd64/xns -o xns
chmod +x xns
macOS (Apple Silicon)
curl -fL https://releases.scpri.me/xns-cli/beta/darwin-arm64/xns -o xns
chmod +x xns
Step 1 of 2 — Verify the download. Each binary has a SHA-256 checksum beside it. Download it and compare:
curl -fL https://releases.scpri.me/xns-cli/beta/linux-amd64/xns.sha256 -o xns.sha256
sha256sum -c xns.sha256
xns: OK?
The binary is intact. If it fails, re-download — do not run an unverified binary.Then confirm it runs:
./xns version
PATH (e.g. sudo mv xns /usr/local/bin/) to run
xns from anywhere.Step 1 Install Docker Desktop
⌄
The Relayer runs inside Docker. If you've never used Docker, no problem — you install it once and never think about it again. Pick your operating system:
- Download Docker Desktop for Windows from docker.com.
- Run the installer, accept the defaults, and restart if it asks you to.
- Launch Docker Desktop and wait until the whale icon says it's running.
- Download Docker Desktop for Mac from docker.com — pick the Apple Silicon or Intel build to match your Mac.
- Open the
.dmgand drag Docker into Applications. - Launch it from Applications and wait until it says running.
- Install Docker Engine for your distro — follow the official guide at docs.docker.com/engine/install (Ubuntu users: the apt repository steps).
- Make sure the
docker composeplugin is included (it is, in current installs). - Optional: add yourself to the
dockergroup so you don't needsudo.
Verify it worked
Open a terminal (PowerShell on Windows, Terminal on Mac/Linux) and run:
docker --version
Step 2 No login needed
⌄
This is a public build — there is nothing to log into. Skip straight to the next step.
Step 3 Create a folder for the Relayer
⌄
This folder holds your one config file. Create it and move into it:
mkdir -p ~/xns-relayer
cd ~/xns-relayer
On Windows PowerShell the same two commands work as‑is.
Step 4 Save the compose file and its .env
⌄
.envThis step saves two files side by side: the compose file and a small
.env next to it. Create a file named docker-compose.yml inside
~/xns-relayer and paste in exactly this:
services:
relayer:
container_name: xns-relayer # keep this name — Prometheus scrapes it by name
image: releases.scpri.me/xns-relayer:release-latest
pull_policy: always # `up -d` fetches the current moving-tag image, never a stale cache
restart: unless-stopped
privileged: true # needed for the Relayer's disk + storage management
# Audit-mode plumbing — turns the audit file sink on only in loki mode (the
# default). Driven by RELAYER_AUDIT_MODE from your .env; do not edit.
command: >
sh -c '
if [ "${RELAYER_AUDIT_MODE:-loki}" = "loki" ]; then
export S3GW_AUDIT_NDJSON_PATH=/var/log/relayer-audit/audit.ndjson;
fi;
exec /xns/xns.sh
'
ports:
- "${UI_PORT:-8888}:8888" # web dashboard
- "${S3_PORT:-9000}:9000" # S3 API (plain HTTP, always on)
- "${S3_TLS_PORT:-9443}:9443" # S3 API (HTTPS, active once you install a cert)
environment:
- NODE_ENV=production
- XNS_RELEASE_CHANNEL=release
# Webhook mode only — set both in your .env; empty = disabled.
- S3GW_AUDIT_WEBHOOK_URL=${S3GW_AUDIT_WEBHOOK_URL:-}
- S3GW_AUDIT_WEBHOOK_SECRET=${S3GW_AUDIT_WEBHOOK_SECRET:-}
volumes:
- relayer_data:/relayer # all data + database + config live here
- ${LEGACY_DATA_PATH:-./data}:/relayer-legacy-check:ro # B-4: lets the relayer refuse to start over a stranded bind-mount DB (see "Upgrading"); harmless on fresh installs
- /mnt:/mnt:rslave # host-disk passthrough for Cloud Sync (no-op if /mnt is empty)
- audit_logs:/var/log/relayer-audit # audit-log landing (loki mode); harmless when audit is off
- relayer_logs:/relayer/log # E3: app logs on their own volume so alloy can tail them without seeing /relayer/conf secrets
networks:
default:
ipv4_address: 172.28.0.10 # pinned — the Grafana auth proxy trusts this IP; do not change
# Audit-egress sidecars — ON by default. Your .env (Step 4, next block) switches
# them via RELAYER_AUDIT_MODE; never edit these entries and never set
# COMPOSE_PROFILES directly.
loki:
image: releases.scpri.me/relayer-loki:release-latest
pull_policy: always
restart: unless-stopped
profiles: ["loki"]
command:
- -config.file=/etc/loki/loki.yml
volumes:
- loki_data:/loki # no host port — reached only inside the compose network
deploy:
resources:
limits:
memory: 256M
alloy:
image: releases.scpri.me/relayer-alloy:release-latest
pull_policy: always
restart: unless-stopped
profiles: ["loki"]
command:
- run
- --server.http.listen-addr=0.0.0.0:12345
- /etc/alloy/config.alloy
environment:
- S3GW_AUDIT_LOKI_PUSH_URL=${S3GW_AUDIT_LOKI_PUSH_URL:-} # optional BYO-Loki push target; empty = on-box Loki
depends_on:
- loki
volumes:
- audit_logs:/var/log/relayer-audit:ro # tails the audit log read-only
- relayer_logs:/relayer/log:ro # E3: tails the app logs; dedicated volume = Alloy never sees /relayer/conf secrets
deploy:
resources:
limits:
memory: 128M
audit-logrotate:
image: releases.scpri.me/relayer-audit-logrotate:release-latest
pull_policy: always
restart: unless-stopped
profiles: ["loki"]
volumes:
- audit_logs:/var/log/relayer-audit # rotates audit.ndjson in place (~600 MB cap)
deploy:
resources:
limits:
memory: 32M
# Monitoring stack — powers the dashboards under Monitoring in the web UI.
prometheus:
container_name: prometheus # keep — the docs' `docker exec prometheus` commands rely on it
image: releases.scpri.me/relayer-prometheus:release-latest
pull_policy: always
restart: unless-stopped
# The first two flags must stay; the retention pair reads your .env.
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=${PROMETHEUS_RETENTION_TIME:-15d}
- --storage.tsdb.retention.size=${PROMETHEUS_RETENTION_SIZE:-2GB}
# Alert-threshold dials — all optional, defaults built in. See "Alerts" below.
environment:
- ALERT_DISK_FREE_BYTES=${ALERT_DISK_FREE_BYTES:-}
- ALERT_DISK_FREE_FOR=${ALERT_DISK_FREE_FOR:-}
- ALERT_S3_UNREACHABLE_FOR=${ALERT_S3_UNREACHABLE_FOR:-}
- ALERT_S3_ERROR_RATE=${ALERT_S3_ERROR_RATE:-}
- ALERT_S3_ERROR_RATE_FOR=${ALERT_S3_ERROR_RATE_FOR:-}
- ALERT_S3_P95_LATENCY_SECONDS=${ALERT_S3_P95_LATENCY_SECONDS:-}
- ALERT_S3_P95_LATENCY_FOR=${ALERT_S3_P95_LATENCY_FOR:-}
- ALERT_HOSTIO_DOWN_FOR=${ALERT_HOSTIO_DOWN_FOR:-}
- ALERT_GATEWAY_DOWN_FOR=${ALERT_GATEWAY_DOWN_FOR:-}
- ALERT_CRASH_LOOP_FOR=${ALERT_CRASH_LOOP_FOR:-}
- ALERT_CACHE_PRESSURE_RATIO=${ALERT_CACHE_PRESSURE_RATIO:-}
- ALERT_CACHE_PRESSURE_FOR=${ALERT_CACHE_PRESSURE_FOR:-}
- ALERT_BRIDGE_STALE_SECONDS=${ALERT_BRIDGE_STALE_SECONDS:-}
- ALERT_BRIDGE_STALE_FOR=${ALERT_BRIDGE_STALE_FOR:-}
- ALERT_TARGET_DOWN_FOR=${ALERT_TARGET_DOWN_FOR:-}
- ALERT_AUDIT_SIEM_DEAD_FOR=${ALERT_AUDIT_SIEM_DEAD_FOR:-}
volumes:
- prometheus_data:/prometheus
deploy:
resources:
limits:
memory: ${PROMETHEUS_MEMORY_LIMIT:-512M}
alertmanager:
# Delivers firing alerts to your webhook/email and sends the Watchdog
# heartbeat — see "Alerts" below. Runs fine unconfigured (no delivery).
container_name: alertmanager # keep — the docs' `docker exec alertmanager` commands rely on it
image: releases.scpri.me/relayer-alertmanager:release-latest
pull_policy: always
restart: unless-stopped
environment:
- ALERTMANAGER_WEBHOOK_URL=${ALERTMANAGER_WEBHOOK_URL:-}
- ALERTMANAGER_GROUP_WAIT=${ALERTMANAGER_GROUP_WAIT:-}
- ALERTMANAGER_REPEAT_INTERVAL=${ALERTMANAGER_REPEAT_INTERVAL:-}
- ALERTMANAGER_SMTP_SMARTHOST=${ALERTMANAGER_SMTP_SMARTHOST:-}
- ALERTMANAGER_SMTP_FROM=${ALERTMANAGER_SMTP_FROM:-}
- ALERTMANAGER_SMTP_TO=${ALERTMANAGER_SMTP_TO:-}
- ALERTMANAGER_SMTP_AUTH_USERNAME=${ALERTMANAGER_SMTP_AUTH_USERNAME:-}
- ALERTMANAGER_SMTP_AUTH_PASSWORD=${ALERTMANAGER_SMTP_AUTH_PASSWORD:-}
- WATCHDOG_HEARTBEAT_URL=${WATCHDOG_HEARTBEAT_URL:-}
- WATCHDOG_REPEAT_INTERVAL=${WATCHDOG_REPEAT_INTERVAL:-}
volumes:
- alertmanager_data:/alertmanager
deploy:
resources:
limits:
memory: ${ALERTMANAGER_MEMORY_LIMIT:-128M}
grafana:
image: releases.scpri.me/relayer-grafana:release-latest
pull_policy: always
restart: unless-stopped
environment:
# Auth-proxy mode: relayer-ui /grafana proxy injects X-WEBAUTH-USER; anon
# off (fail-closed). WHITELIST pins trust to the relayer static IP
# (172.28.0.10, the networks:/ipam pin below) so no sibling container can
# forge the header.
- GF_AUTH_ANONYMOUS_ENABLED=false
- GF_AUTH_PROXY_ENABLED=true
- GF_AUTH_PROXY_HEADER_NAME=X-WEBAUTH-USER
- GF_AUTH_PROXY_HEADER_PROPERTY=username
- GF_AUTH_PROXY_AUTO_SIGN_UP=true
- GF_AUTH_PROXY_WHITELIST=172.28.0.10
# Admin for the auto-provisioned proxy user (identity-bound). Grafana's
# default is Viewer (read-only) — omitting this makes dashboards RO.
- GF_USERS_AUTO_ASSIGN_ORG_ROLE=Admin
- GF_SERVER_SERVE_FROM_SUB_PATH=true
- GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s/grafana
volumes:
- grafana_data:/var/lib/grafana
deploy:
resources:
limits:
memory: ${GRAFANA_MEMORY_LIMIT:-256M}
node-exporter:
image: prom/node-exporter:v1.11.1
restart: unless-stopped
command:
- --path.rootfs=/host
volumes:
- /:/host:ro,rslave # read-only host metrics (rslave: required on real Linux hosts)
volumes:
relayer_data:
relayer_logs: # E3: dedicated app-log volume (relayer writes, alloy tails ro) — keeps /relayer/conf out of the log shipper
prometheus_data:
grafana_data:
alertmanager_data:
audit_logs:
loki_data:
networks:
# Fixed private subnet so the relayer's pinned IP above is stable — the
# Grafana auth proxy whitelists it. Chosen not to collide with home LANs.
default:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
gateway: 172.28.0.1
Now create the second file, named .env, in the same folder — it carries the
audit-logging switch the compose file reads:
# Audit logging mode — loki (default) | webhook | off. See "Audit logging" below.
#RELAYER_AUDIT_MODE=off
#S3GW_AUDIT_WEBHOOK_URL=
#S3GW_AUDIT_WEBHOOK_SECRET=
# Internal plumbing — never edit this line and never set COMPOSE_PROFILES yourself.
COMPOSE_PROFILES=${RELAYER_AUDIT_MODE:-loki}
# Need different ports? Uncomment and change if 8888/9000/9443 are taken.
#UI_PORT=8888
#S3_PORT=9000
#S3_TLS_PORT=9443
# ---- Alerts (all optional — see the "Alerts" section below) ----
# Webhook that receives every firing alert (JSON POST):
#ALERTMANAGER_WEBHOOK_URL=
# Email as a second receiver — set all three; auth optional:
#ALERTMANAGER_SMTP_SMARTHOST=smtp.example.com:587
#[email protected]
#[email protected]
#ALERTMANAGER_SMTP_AUTH_USERNAME=
#ALERTMANAGER_SMTP_AUTH_PASSWORD=
# Off-box heartbeat (Healthchecks.io / Cronitor ping URL) — tells you when the
# monitoring itself dies. Never the same URL as the webhook above:
#WATCHDOG_HEARTBEAT_URL=
#WATCHDOG_REPEAT_INTERVAL=5m
# How often a still-firing alert re-notifies / how long the first one waits:
#ALERTMANAGER_GROUP_WAIT=30s
#ALERTMANAGER_REPEAT_INTERVAL=5m
# ---- Alert thresholds (defaults shown; *_FOR = how long before it fires) ----
#ALERT_DISK_FREE_BYTES=5368709120
#ALERT_DISK_FREE_FOR=5m
#ALERT_S3_UNREACHABLE_FOR=60s
#ALERT_S3_ERROR_RATE=0.01
#ALERT_S3_ERROR_RATE_FOR=2m
#ALERT_S3_P95_LATENCY_SECONDS=2.0
#ALERT_S3_P95_LATENCY_FOR=2m
#ALERT_HOSTIO_DOWN_FOR=60s
#ALERT_GATEWAY_DOWN_FOR=60s
#ALERT_CRASH_LOOP_FOR=2m
#ALERT_CACHE_PRESSURE_RATIO=0.85
#ALERT_CACHE_PRESSURE_FOR=5m
#ALERT_BRIDGE_STALE_SECONDS=120
#ALERT_BRIDGE_STALE_FOR=60s
#ALERT_TARGET_DOWN_FOR=2m
# How long a CONFIGURED SIEM webhook must fail continuously before the
# AuditSIEMFeedDead alert fires. No effect when no webhook is set.
#ALERT_AUDIT_SIEM_DEAD_FOR=15m
# ---- Monitoring footprint ----
# WARNING: the first time you LOWER a retention value on an existing install,
# metrics history above the new ceiling is pruned once, permanently.
#PROMETHEUS_RETENTION_TIME=15d
#PROMETHEUS_RETENTION_SIZE=2GB
# Loki (audit-log) retention is currently fixed at 14 days inside the image —
# this key is reserved and has no effect yet.
#LOKI_RETENTION=14d
#PROMETHEUS_MEMORY_LIMIT=512M
#GRAFANA_MEMORY_LIMIT=256M
#ALERTMANAGER_MEMORY_LIMIT=128M
Audit logging. Out of the box the Relayer keeps an audit trail of every S3
request and feeds it into a small on-box log stack (three extra containers: Loki, Alloy and a
log rotator) — open Monitoring in the dashboard to see it. That default costs about
710 MB of disk and 416 MB of RAM, and it manages its own growth
(the log file is capped around 600 MB and Loki keeps 14 days). One value in your
.env switches the behavior — uncomment RELAYER_AUDIT_MODE and set it to:
loki— the default described above; leaving the line commented out means exactly this.webhook— no extra containers. The Relayer pushes signed audit events straight to your own SIEM or log collector: also uncommentS3GW_AUDIT_WEBHOOK_URL(your HTTPS endpoint) andS3GW_AUDIT_WEBHOOK_SECRET(deliveries are HMAC-signed with it). The secret lives only in your.env— runchmod 600 .envso only your user can read it, and know that anyone who can reach the Docker socket on this machine can read it too (docker inspectshows container environment values).off— no audit trail at all; smallest footprint.
Change the mode any time: edit .env, then run
docker compose --profile "*" down followed by docker compose up -d (your data
is safe — it lives in the named volumes). The --profile "*" down matters: a plain
up -d or down leaves the previous mode's audit containers running. Always use
RELAYER_AUDIT_MODE — never set COMPOSE_PROFILES directly
(an exported COMPOSE_PROFILES in your shell silently overrides the switch).
Everything the Relayer stores — your local database, config, and logs — lives in a
Docker‑managed volume called relayer_data, so it survives restarts and updates.
Want the data on a specific disk? Replace the named volume with a folder of your choice by changing only the left side of the relayer's volume line:
volumes:
- /mnt/bigdisk/relayer-data:/relayer # your folder : container path
The right side must stay exactly /relayer — that's where the Relayer looks for its
data inside the container. If you change the right side instead, the Relayer starts with a fresh, empty
data directory and your buckets and settings seem to vanish (they're safe, just not mounted). Moving an existing
install? Stop the container first (docker compose down), move the whole data folder intact, update
the left side, then docker compose up -d.
Step 5 Start the Relayer
⌄
From inside ~/xns-relayer, run:
docker compose up -d
- The first run downloads the image (a few hundred MB) — give it a minute or two.
- When it's done, open http://localhost:8888 in your browser.
- You should land on the XNS Relayer dashboard.
http://localhost:8888? You're running. Point any S3 client at http://localhost:9000 to start putting objects. If you later install a TLS certificate (Settings → Certificates), http://localhost:9000 keeps working unchanged and an encrypted endpoint also becomes available at https://localhost:9443 — the Certificates page shows both.Alerts — get told when something breaks (optional)
⌄
The stack you just started ships ten alert rules already watching the box (disk space,
S3 errors and latency, dead services, crash loops and more) plus a bundled
Alertmanager that can deliver them to you. Out of the box nothing is
delivered — the rules evaluate, but until you set a receiver in your
.env they have nowhere to go.
1. Webhook — the primary receiver
Uncomment ALERTMANAGER_WEBHOOK_URL in your .env and point it at
anything that accepts a JSON POST (a chat-ops bridge, PagerDuty/Opsgenie webhook ingestion,
your own endpoint), then recreate the alertmanager so it picks the value up —
restarting is not enough, the config is rendered when the container starts:
docker compose up -d --force-recreate alertmanager
ALERTMANAGER_REPEAT_INTERVAL (default 5 minutes — raise it in
.env if that's chatty for your channel).2. Email — an optional second receiver
Uncomment and fill the three ALERTMANAGER_SMTP_* lines (smarthost, from, to
— all three together; the AUTH pair only if your relay needs a login), recreate the
alertmanager the same way, and the same notifications also go out by mail.
3. Heartbeat — know when the watcher itself dies
If Prometheus or the Alertmanager crashes, no alert can tell you — the messenger is dead.
The fix is a dead-man's-switch: the stack sends a check-in every few
minutes to an off-box service that pages you when the check-ins stop.
Create a free check at Healthchecks.io
(or a Cronitor heartbeat monitor), give it an expected period of 5 minutes plus a few
minutes' grace, paste its ping URL into WATCHDOG_HEARTBEAT_URL, and recreate
the alertmanager. Don't reuse your webhook URL here — a receiver that only reacts to
messages it receives can never tell you about silence.
Tuning thresholds and footprint
Every dial lives in the same .env: the ALERT_* lines change when
each rule fires (recreate prometheus to apply those), and the retention/memory
lines at the bottom bound how much history and RAM the monitoring stack uses. A bad value
fails the container start with the validator's message as the last line of
docker logs alertmanager (or docker logs prometheus) instead of
silently breaking the monitoring — if a container is missing after a change, check those
logs, fix the value, recreate again. Mind the retention warning in the .env:
lowering retention on an existing install prunes the over-ceiling history once,
permanently.
Searching your logs
You don't need to SSH in and grep to chase a fault. In the Relayer dashboard,
Monitoring → Log Search runs one search across both
your application logs (hostio, gateway, s3gateway, and the Relayer UI process) and the audit
trail together — type a term once and matching lines from every service and the audit events
come back in one list, newest first. It's additive to the richer Audit Log page, not a
replacement. A provisioned Relayer Log Search Grafana dashboard shows recent
activity at a glance without writing a query.
Updating to a new build
⌄
The dashboard shows a banner when a new build is available. To update, from
~/xns-relayer:
docker compose pull
docker compose up -d
That pulls the latest release image and restarts. Your data in relayer_data
is untouched.
Automatic error reporting
⌄
The Relayer reports crashes and errors back to the dev team automatically — nothing for you to set up or configure. If the UI crashes, the server throws, or the S3 gateway returns a 5xx, we see the stack trace, tagged with the exact build you're running.
Still report it in Discord, though. The automatic report tells us what broke; your message tells us what you were doing when it broke — and that's the half that actually helps us fix it.
Upgrading & moving your data
⌄
By default your buckets, objects, database and config all live in a Docker
named volume called relayer_data (the relayer_data:/relayer
line in the compose file). A normal docker compose pull && docker compose up -d
upgrade keeps that volume, so your data carries across new versions automatically.
Switching from an older bind-mount install? Read this first.
If you previously ran the Relayer with a bind-mount — a line like
- ./data:/relayer or - /your/path:/relayer — your data lives in
that host folder, not in the relayer_data named volume. If you
just adopt the named-volume compose, the Relayer would start against an empty volume and
your buckets would look gone. They are not lost — the old data is safe in your folder, just
no longer connected.
To prevent silent data loss, the Relayer now refuses to start when it detects an existing database in the old folder while the named volume is empty, and prints migration steps in the logs. Pick one of these:
- Keep your data where it is (simplest). In the compose file, point the
volume back at your bind-mount instead of the named volume:
thenvolumes: - /your/path:/relayer # your existing data folderdocker compose up -d. - Move your data into the named volume. With the stack stopped, copy the
old folder's contents into the
relayer_datavolume, then start normally. Ask your AI assistant (or us in Discord) for the exact copy command for your setup.
Used a custom bind-mount path (not ./data)? Point the safety
check at it by adding one line to a .env file next to your compose:
LEGACY_DATA_PATH=/your/old/path. On a clean first-time install none of this
applies — the check is a no-op, and an empty data folder appearing next to the
compose is harmless.
Troubleshooting
⌄
"port is already allocated" / "address already in use"
.env file you saved in Step 4 and pick free numbers, then run docker compose up -d again.
A common one: Portainer uses 9443 as its default HTTPS port — if you run
Portainer, set S3_TLS_PORT to something free (e.g. 9444) in your .env.Upgrading from an earlier beta? Internal port defaults changed (GATEWAY / HOSTIO)
34554 → 5500 and HOSTIO moved 37809 → 3480 (both now sit below the Linux
ephemeral port range, eliminating a boot-ordering race that could crash-loop storage services on
restart). Fresh installs: no action — the new defaults apply automatically.
Existing installs that persisted the old port values in device-local configuration
must update GATEWAY_PORT to 5500 and HOSTIO_PORT to
3480 (or any values below 32768) in their device config and restart. Symptom of the
old race: bind: address already in use in container logs with storage stuck down."unauthorized" or "denied" when it pulls the image
docker logout releases.scpri.me and pull again."permission denied" / "cannot connect to the Docker daemon"
sudo or to add yourself to the docker group.The container won't start, or keeps restarting
docker compose logs --tail=100Using the encrypted (HTTPS) S3 endpoint after installing a certificate
Installing a certificate no longer changes your existing endpoint — http://<host>:9000
keeps working unchanged. It additionally enables an encrypted endpoint at
https://<host>:9443. To switch a client to TLS, point it at https://<host>:9443.
For self-signed certificates: download the Relayer's public certificate and tell your client to trust it explicitly — do not disable certificate verification.
First, copy the certificate out of the container (host-local, no network hop):
docker cp xns-relayer:/relayer/conf/certs/public.crt relayer-ca.crt
AWS CLI / SDKs — set the AWS_CA_BUNDLE environment variable
or pass --ca-bundle per command:
# Option A: environment variable (applies to all AWS CLI / SDK calls)
export AWS_CA_BUNDLE=/path/to/relayer-ca.crt
# Option B: per-command flag
aws s3 ls --endpoint-url https://<host>:9443 --ca-bundle /path/to/relayer-ca.crt
rclone — set ca_cert in the remote config instead of
disabling verification:
[myrelayer]
type = s3
provider = Other
endpoint = https://<host>:9443
ca_cert = /path/to/relayer-ca.crt
Python (boto3) — pass verify with the certificate path:
import boto3
s3 = boto3.client('s3',
endpoint_url='https://<host>:9443',
verify='/path/to/relayer-ca.crt'
)
The Settings → Certificates page shows the exact endpoint and config snippets after a cert is installed.
Can't reach the dashboard
http://localhost:8888 — that's http,
not https — and that docker compose ps shows the container as running.The Relayer is using too much CPU / my machine gets hot
cpus limit to the
relayer service only in your docker-compose.yml — for example
cpus: 1.0 limits it to one core:
relayer:
container_name: xns-relayer
image: releases.scpri.me/xns-relayer:release-latest
cpus: 1.0 # cap the Relayer at one CPU core
# ...rest of the relayer service unchanged...docker compose up -d to apply it. Use a fraction (cpus: 0.5) to give it
half a core, or a higher number to allow more. Only put cpus on the relayer
service — not on the monitoring services, and not on any other container — otherwise you'll throttle
the wrong thing. A too-low limit will make the Relayer feel sluggish; remove the line to lift the cap.Version history
⌄
Every shipped release build — expand a version to see what changed.
v2.19.02026-08-17⌄
- The packer's flush defaults are now 97% / 10m (were 75% / 1m), so rows ship nearly full by default
- The packer flush defaults shown and seeded by the UI are now 97% / 10m (were 75% / 1m), matching the new relayer defaults (2.19.0)
- Handing a box to a new owner no longer costs it its credential
- A box whose storage account and owner account are the same name no longer gets stuck restarting forever after an upgrade
- Retained versions now have a name and a number of their own on every storage screen
- The tenant usage page's "Objects" count no longer contradicts the "Stored" figure beside it
- The usage CSV export now counts on the same basis as the screen
- The tenant dashboard now says plainly that its figures are not an invoice
- Bucket and usage totals now count every retained version of an object, not just the current one
- A physical storage estimate now appears where none existed before
- "Public" is no longer shown for a bucket that is not actually publicly readable
- The badge updates as soon as access changes
- Port readout in `docker logs` and the installer banner is now correct-or-absent
- `docker logs` now streams only the dashboard app's output
- Existing certificate material is no longer silently replaced
- Turning the storage switch on now reaches the storage service
- The trust-proxy invariant is now asserted at boot
- A re-minted box credential now reaches the corp billing record
- An upload where every file failed used to report success
- A failed download used to tell the operator nothing at all
- A large file left behind on the box when an upload was refused
- Creating a storage user from Identity & Access ignored which customer you had selected, and quietly attached the user to your own account instead
- Naming a customer's administrator meant typing their account identifier by hand from memory
- The storage screen now explains the network total as an equation the customer can check: your data + protection + packing = network total
- Trickle-workload customers get a dial for billed slack: the packer flush wait is now an Advanced Setting
- Public access is now visible on the bucket list itself, without opening anything
- A Public Access panel in bucket settings
- Confirmation before a bucket is opened to the internet
- The reason is shown when the box refuses to open a bucket
- Security page
- Posture indicator
- First-run transport card
- Certificate lifecycle on the Security page
- Two transport-policy switches, both shipping off
- A switch engages only against an encrypted listener that is observably serving
- Combination validation at the moment a setting changes
- An operator could see every bucket belonging to one customer, but never every bucket on the box at once
- Permanent operator help page covering how the access-grant mechanism works, how to open one, and the disclosure text in full
- Permanent tenant-portal help page explaining what the access-history log shows, including operator grants, and the custodial relationship in plain language
- A newly created storage user now says outright that it cannot touch any storage yet
- Customers can now see, on their own screen, every completed action their storage provider took on their data
- Operator help for naming a customer's administrator
- Console2 4.80.0 or later
- A reverse proxy declared in the operator's configuration is no longer silently exempted from authentication on disk-management routes
- An unlisted Origin now returns a clean 403 JSON refusal instead of an unhandled 500 with a stack trace
- Naming a tenant administrator for a cost centre that isn't yours is now refused
- Every management route that accepts a cost centre now validates it in one place, before the request reaches the code that acts on it
- Entering a customer's data in the File Manager now requires stating why, first
- A lapsed grant now tells the operator plainly that their access ended — never the vague "try again" message a dropped session shows
- What a grant lets the operator technically do is now disclosed at every request, and stays permanently reachable from operator help
- A customer-data download refused because the operator has no active grant now says so, instead of a generic "unable to download" message that gave no next step
- A tenant administrator now sees any operator access grant covering their account in the same access-history page they already check — not a separate trail to go find
- A tenant administrator cannot finish activating their account without reading what the storage operator can technically do with their data
- Picking a customer in the File manager now actually changes who the box acts as
- A customer whose account is suspended can no longer have their files downloaded
- When the box cannot obtain a customer credential, the operation fails instead of quietly running as the box owner
- A shareable link can no longer outlive the permission behind it
- An operator working inside one customer's account could still pull the whole box's storage data through a second, unguarded set of addresses
- The box now refuses to build if anyone adds a second way in
v2.9.02026-07-18⌄
- The S3 Gateway's multi-tenant conformance gaps close, and the published compatibility number becomes reproducible instead of hollow
- Every storage snapshot tick now lays down the box's aggregate row byte-identically PLUS one row per tenant — the source data finally tells the per-tenant truth
- s3gateway: `s3:*` no longer authorizes non-s3 actions — closes a namespace-crossing privilege escalation exposed by the IAM self-service reversal
- mt-reshape E-A1 (iam-deconflation) — relayer regression verification
- mt-reshape E-A2 (one quota dial): the tenant byte quota lives on the cost-center, not on `s3_tenants`
- mt-reshape E-A2 (S6 nested-ID): heal-package regex regression against `{ccid}_cusN_tenM`
v2.7.02026-07-13⌄
- s3gateway: runtime Sentry kill-switch — silence error reporting without a rebuild
- monitoring: alert delivery spine — firing rules now reach the operator instead of evaluating into a void
- monitoring: Watchdog dead-man's-switch — the operator is told when the monitoring stack itself dies
- monitoring: alert thresholds are `.env`-tunable — threshold changes are a config edit + recreate, never a source change or rebuild
- docs: observability & alerting operator runbook
- s3gateway: audit-pipeline health metrics registered unconditionally + NDJSON write-health counters
- s3gateway: WORM-defeat attempt detection — counters + ≤60s trip-wire alert
- monitoring: application logs now reach Loki — the operator can search every service's logs, not just audit
- hostio + s3gateway: cache-hit-rate and per-bucket activity are now instrumented — the two series the AI-engineer overview screen needs
- monitoring: WORM-defeat alerts missed their ≤60s detection SLA on the default Alertmanager route
- s3gateway: valid credentials were rejected with `InvalidAccessKeyId` under high request concurrency
v2.6.52026-07-07⌄
- s3gateway: a configured rate limit never throttled the box owner's account or any of its service-account/IAM keys
- s3gateway: service-account create/delete and credential rotation left no audit trail
- s3gateway: clients uploading an object could see the connection reset instead of the real error response
- s3gateway: running out of physical disk returned a generic internal error instead of a storage-full response
- s3gateway: newly created users and access keys could be assigned a cost center that doesn't exist, leaving them unable to perform any storage operation; such requests are now rejected up front with a clear error
- s3gateway: deleting a bucket and immediately recreating it under the same name could fail until a brief background cleanup finished
v2.1.02026-06-19⌄
- audit landing: `S3GW_AUDIT_LOKI_PUSH_URL` lets a bring-your-own-Loki admin redirect audit logs to an external Loki with one env var — no UI, no rebuild of behavior
v2.0.02026-06-16⌄
- s3gateway: conditional writes (`If-Match`/`If-None-Match`) on a missing object no longer 500 (NS1)
- gateway + s3gateway: streaming write errors no longer destroyed by TCP resets; internal-hop outages now retryable 503s (beta-soak vae report, 2026-06-10)
- hostio: delete-during-upload no longer crash-loops HOSTIO (beta-soak 500 storms)
- s3gateway: DeleteBucket no longer `500`s on large buckets, and previously wedged buckets heal themselves (BUG #685, Sentry `RELAYER-BACKEND-8A`)
- s3gateway: large/slow uploads no longer fail with a 60s `500 InternalError` (UploadPart / PutObject body-read wall)
- s3gateway: 5xx Sentry events are now self-diagnosing and no longer fan out per object key
- s3gateway: delimited `ListObjects` (V1+V2) no longer returns phantom empty folders after a versioned delete
- s3gateway: `AssumeRoleWithWebIdentity` now resolves multi-audience tokens correctly (E1 SF-2)
- s3gateway: `PutBucketPolicy` now rejects `NotPrincipal` on `Allow` statements with 400 `MalformedPolicy` (NS2-A)
- s3gateway: temp (STS) credentials now enforce `X-Amz-Security-Token` on every signing path (epic E0, closes a credential-replay hole)
- s3gateway: bucket-policy `Principal` is now enforced (closes a privilege escalation, epic E3)
- Monitoring stack now survives install/upgrade/reboot (observability rework)
- s3gateway: bootstrap owner credential is now seeded with `is_owner=true` (RC-1)
- s3gateway: UploadPartCopy returns `InvalidRange` (416) for out-of-bounds copy range
- gateway: versioned GET of small multipart-completed objects no longer returns HostIO fileID as content
- hostio/cacherepo: removed diagnostic logging
- hostio: read-only `POST /v1/hostio/evaluate` endpoint (epic EP-E1)
- all services: per-service structured logging bootstrap + support-grade operational stream (epic E9)
- s3gateway: `AccessLogWorker` — bucket-logging access-log delivery (epic E4)
- s3gateway admin: per-bucket live-object stats endpoint `GET /admin/v1/bucket-stats` (epic E10)
- hostio: bounded upload host-switch loop (epic E3, D2)
- hostio + s3gateway: staging load-shed → S3 503 SlowDown with Retry-After (epic E3, D3)
- gateway + s3gateway: client-disconnect classified as a clean disconnect, not a 500 (epic E3, D4)
- hostio: Go-side backstop clamp on `SimultaneousUploads` (epic E3, D1, §7 D9/D10)
- docs: version → commit index + 2.0.0 promotion note
- s3gateway: webhook audit sink — signed NDJSON audit export to a customer SIEM (epic E2)
- s3gateway: AWS IAM query-protocol dialect — 16 user-plane management verbs for Terraform/Vault (epic E1)
- s3gateway: per-request audit-record enrichment — one record answers both "who/allowed?" and "how big/how long/what client/what error?" (epic E3)
- s3gateway: pluggable audit sinks + opt-in Loki/Alloy audit landing (epic E1)
- s3gateway: cross-account `AssumeRole` — persisted roles, trust policies & `ExternalId` confused-deputy guard (epic E2)
- s3gateway: `AssumeRoleWithWebIdentity` — keyless OIDC federation for ephemeral workloads (epic E1)
- s3gateway: dual-listen — `:9000` is now permanently plain HTTP, HTTPS is additive on `:9443` (SP1)
- s3gateway: admin/metrics `:9101` decoupled from the S3 TLS cert — now always plain HTTP (SP1)
- s3gateway: in-account canonical-ID unification — IAM/SA/STS delegation now works end-to-end (epic E1, authz-model-fix-b)
- s3gateway: Sentry error reporting (epic A2)
- Observability dashboards + baseline alerts (E3)
- Monitoring sidecar stack (E2)
- s3gateway: reword four stale `:9000` source comments
- SEC-1: drop `127.0.0.1:9101:9101` host publish from `docker-compose.relayer.yml`. SEC-2: set `KEYCLOAK_AUDIENCE=relayer`
Stopping & removing
⌄
Stop, but keep your data
docker compose down
Stop and wipe everything for a clean slate
docker compose down -v
The -v flag deletes the relayer_data volume — your local
database, config, and any stored objects. Use it when you want to start completely fresh.