Self-host Relayium: run your own file-and-text transfer server
Last updated: 2026-08-06
Relayium is AGPL-3.0-licensed and open source, and the server is one self-contained image — no external database, no third-party storage bucket, nothing to sign up for. If you'd rather run the whole thing yourself than rely on relayium.com, this guide gets a server up with Docker and points the CLI at it.
Self-hosting gives you full control over where your data lives, your own domain and TLS certificate, and no dependency on anyone else's infrastructure. Everything below is grounded in the files that ship in the repo — docker-compose.yml, server/.env.example, and docs/self-hosting.md — so nothing here is a flag or setting that doesn't actually exist.
Why self-host
Relayium's realtime transfers are end-to-end encrypted. A self-hosted TURN relay may carry ciphertext bytes, and the server handles signaling metadata, but neither can read or decrypt file plaintext; neither the server nor relay stores a server-side copy or history of realtime content. The server does hold your account and — for stored/link-based transfers — ciphertext blobs and a small SQLite database. Self-hosting means that data lives on infrastructure you control, under your own domain, with nobody else's operational decisions in the loop.
Because the project is AGPL-3.0-licensed and open source (github.com/relayium/relayium), you can read exactly what the server does before you trust it with anything, and fork or modify it freely.
Quick start with Docker
What you need before step 1
- A host with Docker Engine and the Compose plugin. docker compose version prints a version string; "docker: 'compose' is not a docker command" means the plugin is missing.
- A clone of the repository. The compose file builds the image from this source tree, so it needs the Dockerfile and web/ beside it — there is no prebuilt image to pull.
- Disk for the named volume relayium-data: the SQLite database plus any stored-transfer ciphertext you keep.
- A domain and a TLS-terminating reverse proxy if anyone but you will use it. The container speaks plain HTTP and publishes on the loopback interface only.
- Nothing else. No external database, no object-storage bucket, no third-party account.
The repo root ships a Dockerfile and a docker-compose.yml that build one self-contained image — a static Go binary that serves the prebuilt web app, so there is no separate Node, Go toolchain, or nginx required just to run it.
Clone the repository and change into it.
git clone https://github.com/relayium/relayium.gitcd relayiumBuild and start it. The placeholder secret is required even though the relay is off: Compose validates the gated coturn service's required variable while it parses the file, so a bare docker compose up refuses to start.
RELAYIUM_TURN_SECRET=placeholder docker compose up -d --buildConfirm the container stayed up rather than crash-looping.
docker compose psAsk the instance whether it can actually serve. Use /readyz, not /healthz — the difference between them is the whole point of the check, and the expected-result box below spells it out.
curl -s http://127.0.0.1:8080/readyzCopy the config template and set your public URL. RELAYIUM_BASE_URL builds the links in outgoing email and decides whether session cookies carry the Secure flag, so it has to be your real https:// address.
cp server/.env.example server/.envchmod 600 server/.envPut nginx or Caddy in front, terminating TLS for your domain and proxying everything — /, /api, /ws, /admin — to port 8080. Then restart to pick up server/.env.
docker compose up -d
What a working instance looks like
The container reports Up, and both endpoints answer. ready is the one that matters: /healthz returns ok unconditionally, before anything is opened, so it also passes on an instance whose database or blob directory is unusable. /readyz pings the SQLite database and the blob directory and answers 503 when either is broken.
$ docker compose ps
NAME IMAGE STATUS PORTS
relayium-server-1 relayium/relayium:local Up 12 seconds 127.0.0.1:8080->8080/tcp
$ curl -s http://127.0.0.1:8080/healthz
ok
$ curl -s http://127.0.0.1:8080/readyz
ready- That's the whole server, listening on :8080. Put nginx or Caddy in front for TLS in production — docs/self-hosting.md covers the Docker path and what to proxy; Relayium's own production nginx configuration isn't published.
- App config comes from an optional server/.env file plus the environment: block in docker-compose.yml. Every setting has a RELAYIUM_* key — copy server/.env.example as a starting point.
- The four keys that matter for a basic deploy: RELAYIUM_ADDR (listen address), RELAYIUM_STATIC (path to the built web app), RELAYIUM_DB (SQLite file path), and RELAYIUM_BLOB_DIR (where stored-link ciphertext is written). docker-compose.yml already sets sane defaults for all four and persists them in a named volume.
Add a TURN relay for cross-network transfers
Same-network (LAN) transfers and SSH-based push/pull work with nothing extra. Cross-network realtime transfers (two devices behind different NATs) sometimes need a TURN relay to establish a path — the relay only ever sees ciphertext, never your file contents.
docker-compose.yml has an optional relay profile that starts coturn (the TURN server) and a small Redis instance for relay-byte metering, alongside the main server.
The secret has to reach two different places, and getting that wrong fails silently. coturn receives it through Compose variable interpolation, which resolves from the shell or a project-root .env. The server reads it from its own environment, which means server/.env — and an empty secret disables TURN outright. Set only one of them and you get a running coturn the server never issues credentials for: every container reports healthy, nothing is logged, and strict-NAT transfers keep failing exactly as they did before.
Generate one long random secret. Use the same value everywhere below.
openssl rand -hex 32Put that secret, and the relay addresses your domain resolves to, in server/.env so the server enables TURN at all.
RELAYIUM_TURN_SECRET=<the value from step 1> RELAYIUM_TURN_URLS=turn:example.com:3478,turns:example.com:5349Export that same file into the shell so Compose's interpolation can hand coturn the identical secret. Sourcing it keeps one source of truth and keeps the secret off the command line, where ps would expose it.
set -a; . ./server/.env; set +aStart the stack with the relay profile.
docker compose --profile relay up -d --buildOpen the relay ports on the host firewall. coturn runs with host networking, so these are host rules, not Docker ones: UDP 3478 and 49152-65535, TCP 3478 and 5349.
Confirm the server — not just coturn — came up holding the secret. This is the check that catches the silent case.
docker compose exec server env | grep RELAYIUM_TURN
What a working relay looks like
Both keys come back non-empty from inside the server container. coturn being up tells you nothing on its own — the browser only ever gets relay credentials the server minted.
$ docker compose exec server env | grep RELAYIUM_TURN
RELAYIUM_TURN_SECRET=3f7a…
RELAYIUM_TURN_URLS=turn:example.com:3478,turns:example.com:5349- coturn needs the host's real public IP and an open UDP port range to work — docs/self-hosting.md covers running it through the Docker relay profile above; Relayium's own production coturn configuration (and install script) isn't published.
- Without --profile relay and RELAYIUM_TURN_SECRET, the server still runs fine — cross-network transfers just fall back to STUN-only, which works for easier NAT types but not the strictest ones.
Install the CLI on your machine
This last step runs the relayium CLI on your own computer (not the server), so install it there if you haven't. On macOS or Linux:
curl -fsSL https://relayium.com/install.sh | sh
- relayium.com/cli lists every install option — a Windows binary, the releases page, or go build if you have Go.
- relayium --version confirms it. Without the CLI, the command below prints 'command not found'.
Point the CLI at your server
The Relayium CLI defaults to relayium.com's rendezvous server for cross-network send/receive and text. Pass --server to use your own instead.
Sign in against your server rather than relayium.com, on a machine that already has the CLI from the section above. It prints a URL and a code; approve it in a browser signed in to your instance.
relayium login --server https://your-domainConfirm which server the stored credentials are bound to. whoami takes no flags — it reports what the login actually wrote, which is what makes it worth running.
relayium whoamiSend, passing the same --server. Without it the CLI mints the pairing code against relayium.com and the other end will never find it on your instance.
relayium send ./report.pdf --server https://your-domainReceive on the other machine with the printed code and the same --server. Text sessions work the same way.
relayium receive 483920 --server https://your-domainrelayium text --server https://your-domainrelayium text 483920 --server https://your-domain
How you know it is talking to your instance
whoami prints the account followed by the server it is bound to, in parentheses. Your own domain there — not relayium.com — is the confirmation.
$ relayium login --server https://your-domain
Open https://your-domain/device and enter code: WDJB-MJHT
logged in as you@example.com
$ relayium whoami
you@example.com (https://your-domain)- The CLI is free either way — --server changes which rendezvous server it talks to for the pairing-code handshake. send or text without a code mints one against that server, and cloud up stores under an account on it, so sign in there first with relayium login --server https://your-domain. receive, down, and text with the printed code need no login.
- Both text peers must stay online. Messages use their own end-to-end encrypted, direct peer-to-peer session; CLI text is direct-only and does not use the browser's TURN relay. Neither Relayium nor your self-hosted server stores message bodies or server-side history, but either terminal or recipient can copy or retain text after receiving it.
- push/pull (over your own SSH) and serve + daemon-direct push relayium://host don't touch relayium.com at all, self-hosted or not — they connect straight to the remote you specify.
When it doesn't work
Five failures cover nearly every unsuccessful self-host. Each has a line you can read or a command you can run that decides it, and three of the five look like success until you run the check.
Symptom, check, fix
- docker compose up refuses to start at all, before anything is built.
docker compose up -d --build # required variable RELAYIUM_TURN_SECRET is missing a valueCompose interpolates the whole file before it filters by profile, so the gated coturn service's required variable is validated even though the relay is off. Prefix any placeholder — RELAYIUM_TURN_SECRET=placeholder docker compose up -d --build — and replace it with a real secret only when you actually enable the relay profile.
- The container is Up, but a browser on another machine cannot reach it.
docker compose ps # PORTS 127.0.0.1:8080->8080/tcpThat loopback bind is the default, so a public host does not expose plaintext HTTP to the internet. For production, leave it and terminate TLS in a reverse proxy on the same host. For a LAN-only box with no proxy, publish it wider with RELAYIUM_BIND=0.0.0.0 docker compose up -d — that variable is read by compose, not by the server.
- /healthz says ok, but sign-up fails and stored links never appear.
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/readyz # 503/healthz answers ok unconditionally and proves only that the process is listening. /readyz pings the SQLite database and the blob directory, so a 503 means one of them is unusable — check that the relayium-data volume is mounted and that RELAYIUM_DB and RELAYIUM_BLOB_DIR point inside it.
- relayium login prints a verification URL on localhost that you cannot open.
relayium login --server https://your-domain # Open http://localhost:8080/device and enter code: WDJB-MJHTThe server builds that URL from RELAYIUM_BASE_URL, which defaults to http://localhost:8080. Set it in server/.env to your real https:// address and restart. It also decides whether session cookies carry the Secure flag, so leaving it wrong is not only cosmetic.
- coturn is running, but cross-network transfers through strict NAT still fail — with nothing logged.
docker compose exec server env | grep RELAYIUM_TURN # (no output)The secret reached coturn through Compose interpolation but never reached the server, whose empty secret disables TURN outright. Put RELAYIUM_TURN_SECRET and RELAYIUM_TURN_URLS in server/.env, source it with set -a; . ./server/.env; set +a so interpolation sees the same value, and restart the relay profile. Both keys must come back non-empty from that check.
Frequently asked questions
Do I need to set up TURN?
Only if you want cross-network realtime transfers to work through strict NATs. Same-network transfers, SSH-based push/pull, and daemon-direct all work without it — TURN is purely for NAT traversal on the cross-network pairing-code path.
Is the CLI still free if I self-host?
Yes. The CLI is completely free whether it talks to relayium.com or a server you run yourself — --server just points it at your instance. An account on that server is needed by send or text when run without a code to mint one, and by up to store a file. receive, down, and text with a printed code need no login.
Can I use my own domain and TLS certificate?
Yes. The Docker image listens on plain HTTP on :8080; put nginx or Caddy in front with your own domain and certificate (e.g. via certbot/Let's Encrypt). docs/self-hosting.md covers what to proxy; Relayium's own production nginx configuration isn't published, so you'll write your own.
What data does my self-hosted server store?
A SQLite database (accounts, sessions) at RELAYIUM_DB and, for stored/link-based transfers, encrypted blobs at RELAYIUM_BLOB_DIR that the server itself cannot decrypt. The server keeps no realtime file or message body and only relays the signaling handshake; receiving endpoints can still save files or retain text.
Install the free Relayium CLI and point it at your own server with --server.
Get the CLI