Release

XNS Relayer

Self‑host an S3‑compatible storage gateway backed by a decentralized provider network.

by ScPrime · SCP Corp
Current build: v2.19.0 (7dab8227) — 2026-08-18

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

Operating system
Win / Mac / LinuxWindows 10/11, macOS 12+, Ubuntu 22.04+
Memory
8 GB4 GB minimum
Free disk
10 GBapp + local database
Network
Ports 8888 + 9000 + 9443free on your machine

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 machine shared with other users or services — a team dev server, a small office server, or a VM with multiple tenants.

Port What it is Who needs to reach it If the wrong party reaches it
8888 Web dashboard (management UI) Authorized operators on your network Other users on the same host or LAN see the admin interface — they can manage storage, view configurations, and change settings
9000 S3 API (plain HTTP) Applications and users with S3 credentials Credential-protected, but traffic is unencrypted — other users on the network can observe the data in transit
9443 S3 API (HTTPS, once a certificate is installed) Same applications, with transport encryption Same credential protection as 9000, but the connection is encrypted — recommended for shared environments

On a shared host, consider narrowing the dashboard bind to 127.0.0.1 and accessing it through an SSH tunnel or reverse proxy. This prevents other users on the LAN from reaching the management interface directly.

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:
    docker exec xns-relayer openssl x509 -in /relayer/conf/certs/public.crt -noout -fingerprint -sha256
    The Settings → Certificates page also shows the fingerprint. If both match the browser's certificate details, the connection is to your Relayer.
  • 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.
You've picked your deployment profile and decided on the three settings above (or decided "not yet" — that's fine). You know which ports are open and why. Continue to the install.

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.

The output matches your .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:

claude_desktop_config.json
{
  "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:

“Install the XNS Relayer for me.”

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.

You still need Docker Desktop (Step 1) and Node.js 20+ (npx fetches the installer). Claude checks for Docker first and tells you exactly what to install if it's missing.

Optional: the 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
The checksum command prints xns: OK? The binary is intact. If it fails, re-download — do not run an unverified binary.

Then confirm it runs:

./xns version
It prints a version string? The CLI is installed. Move it onto your 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 .dmg and drag Docker into Applications.
  • Launch it from Applications and wait until it says running.
M1+ Apple Silicon Macs (M1/M2/M3/M4) — everything runs natively. The release images — the Relayer and the Prometheus + Grafana monitoring sidecars — all ship for both arm64 and amd64, so Docker pulls the right one automatically. No Rosetta or platform overrides needed.
  • 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 compose plugin is included (it is, in current installs).
  • Optional: add yourself to the docker group so you don't need sudo.

Verify it worked

Open a terminal (PowerShell on Windows, Terminal on Mac/Linux) and run:

docker --version
If it prints a version number, you're good. If it says "command not found," Docker Desktop isn't running yet — open it and wait for the running indicator.

Step 2 No login needed

This is a public build — there is nothing to log into. Skip straight to the next step.

No credentials are needed to pull the images.

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

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

docker-compose.yml
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:

.env
# 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 uncomment S3GW_AUDIT_WEBHOOK_URL (your HTTPS endpoint) and S3GW_AUDIT_WEBHOOK_SECRET (deliveries are HMAC-signed with it). The secret lives only in your .env — run chmod 600 .env so only your user can read it, and know that anyone who can reach the Docker socket on this machine can read it too (docker inspect shows 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.
The dashboard on first launch
Dashboard loads at 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
Every firing rule now POSTs a notification to your URL. Repeats are suppressed: however often a rule keeps firing, you get one message when it starts and then at most one per 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:
        volumes:
          - /your/path:/relayer        # your existing data folder
    then docker compose up -d.
  • Move your data into the named volume. With the stack stopped, copy the old folder's contents into the relayer_data volume, 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"
Something else on your machine is using port 8888, 9000, or 9443. Uncomment the port lines in the .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)
Port defaults changed in this release: GATEWAY moved 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
This build is public, so a pull never needs credentials. If you see this, you likely have a stale login for releases.scpri.me from an earlier private build — run docker logout releases.scpri.me and pull again.
"permission denied" / "cannot connect to the Docker daemon"
Docker Desktop isn't running. Open it and wait for the running indicator, then retry. On Linux, you may need sudo or to add yourself to the docker group.
The container won't start, or keeps restarting
Grab the logs and paste them in the Discord channel:
docker compose logs --tail=100
Using 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
Make sure you're using 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
You can cap how much CPU the Relayer is allowed to use. Add a cpus limit to the relayer service only in your docker-compose.yml — for example cpus: 1.0 limits it to one core:
docker-compose.yml (relayer service)
  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...
Then run 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-17ChangedFixedAddedRequiresSecurity
Changed
  • 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
Fixed
  • 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
Added
  • 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
Requires
  • Console2 4.80.0 or later
Security
  • 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-18AddedSecurityVerified (no code change)ChangedTests
Added
  • 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
Security
  • s3gateway: `s3:*` no longer authorizes non-s3 actions — closes a namespace-crossing privilege escalation exposed by the IAM self-service reversal
Verified (no code change)
  • mt-reshape E-A1 (iam-deconflation) — relayer regression verification
Changed
  • mt-reshape E-A2 (one quota dial): the tenant byte quota lives on the cost-center, not on `s3_tenants`
Tests
  • mt-reshape E-A2 (S6 nested-ID): heal-package regex regression against `{ccid}_cusN_tenM`
v2.7.02026-07-13AddedFixed
Added
  • 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
Fixed
  • 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-07Fixed
Fixed
  • 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-19Added
Added
  • 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-16FixedAddedChangedSecurity
Fixed
  • 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
Added
  • 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)
Changed
  • s3gateway: reword four stale `:9000` source comments
Security
  • 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.