self-deploy

run your own relay. your server, your rules. free, forever, agpl. unlimited devices, unlimited storage, and nothing of yours on anyone else's machine.

what the relay actually does

shizumu syncs through a relay: a small server that passes end-to-end encrypted blobs between your devices. it never holds your keys, so it never sees your writing. what it stores is ciphertext plus the minimum index needed to work out which device is missing which op.

it is one binary and one sqlite file. there is no separate database service, no queue, no cache. a raspberry pi is enough. this is the same binary behind the hosted tier, so nothing here is a cut-down version.

before you start

  • a linux box you can reach from the internet. the smallest vps tier is plenty.
  • a domain or subdomain pointing at its ip, for example sync.example.com.
  • ports 80 and 443 open if you want the relay to get its own certificate.
  • rust 1.95 or newer to build, or docker.

https is not optional. the app refuses a plain http relay unless it is on localhost. step 5 covers two ways to get it, one of which needs no extra software.

step 1 · get the source

build from a release tag rather than main, so you know which version you are running and can reproduce it later.

git clone https://github.com/shizumu-app/shizumu-relay
cd shizumu-relay

# see what has been released, newest last
git tag --sort=v:refname | tail -5

# check out the newest one
git checkout "$(git tag --sort=v:refname | tail -1)"

step 2 · build it

pick one. both produce the same relay.

from source

cargo build --release

# confirm it runs and says which version it is
./target/release/shizumu-relay --version

the binary lands at target/release/shizumu-relay. it is self-contained, so copy it wherever you like:

sudo install -m755 target/release/shizumu-relay /usr/local/bin/

with docker

the repo ships a Dockerfile and a docker-compose.example.yml. build the image locally:

docker build -t shizumu-relay:local .

the compose example names a ghcr.io image. there is no published image yet, so build locally as above and change the image: line to shizumu-relay:local. building from the source you just checked out is the honest path anyway: you can read exactly what went in.

step 3 · write the config

the relay reads a toml file, by default /etc/shizumu/relay.toml. environment variables override individual keys, which is what the docker examples use. a file is easier to keep track of on a real server.

sudo mkdir -p /etc/shizumu /var/lib/shizumu
sudo useradd --system --home /var/lib/shizumu shizumu
sudo chown shizumu:shizumu /var/lib/shizumu

then /etc/shizumu/relay.toml:

bind     = "127.0.0.1:8080"
mode     = "single_user"
db_path  = "/var/lib/shizumu/relay.db"

[storage]
kind = "fs"
root = "/var/lib/shizumu/blobs"

that is the whole minimum. everything else has a default:

  • bind defaults to 0.0.0.0:8080. bind to loopback if a proxy sits in front, and to 0.0.0.0 only if the relay terminates tls itself.
  • mode defaults to single_user: one account, all your devices. multi_user is for running a relay for other people and adds signup and quota handling.
  • max_blob_bytes defaults to 110 mb, which is the 100 mb attachment ceiling plus room for the encryption envelope. raise both together or neither.
  • pending_ttl_seconds defaults to 86400: how long a half-finished upload is kept before it is swept.

choosing a blob store

the index always lives in relay.db. attachments go wherever you point [storage]:

# a real disk. the default, and the right answer for most people.
[storage]
kind = "fs"
root = "/var/lib/shizumu/blobs"

# one extra sqlite file. nothing to mount, nothing to permission.
# ideal on a pi or anywhere you want the whole deploy to be two files.
[storage]
kind = "sqlite"
blobs_db_path = "/var/lib/shizumu/blobs.db"

# s3-compatible: minio, garage, r2, b2, wasabi, aws.
[storage]
kind = "s3"
endpoint = "https://s3.us-west-002.backblazeb2.com"   # omit for aws itself
bucket   = "my-shizumu"
region   = "us-west-002"                              # "auto" for r2
access_key = "..."
secret_key = "..."

keep secrets out of the toml if you would rather: S3_KEY and S3_SECRET override those two keys, so you can leave them in a root-owned env file instead.

check the config before you go further. this prints what the relay actually resolved, after defaults and env vars, and exits:

sudo -u shizumu shizumu-relay --config /etc/shizumu/relay.toml show-config

if a required value is missing it says so plainly, for example storage.kind=fs requires storage.root. fix it here rather than discovering it from a service that will not start.

step 4 · create your user

in single-user mode there is exactly one account and you make it once. it is keyed to your first device's public key, which the app shows in settings, sync, under "my own relay".

sudo -u shizumu shizumu-relay --config /etc/shizumu/relay.toml \
  init-user --pub <base64-device-pubkey>

this prints a user_id and an enrollment_token. the token is one-shot and expires in an hour. if you lose it or it lapses, run list-users to confirm the account exists, and re-run init-user to issue a fresh one.

step 5 · get https

two routes. the first needs nothing else installed.

let the relay do it

the relay can obtain and renew its own certificate over acme. set the domain and an email, bind to 443, and point dns at the box first so the challenge can be answered:

bind = "0.0.0.0:443"

[tls]
domain     = "sync.example.com"
acme_email = "you@example.com"

the process needs permission to bind a low port. under systemd that is one line, shown in step 6.

or put a proxy in front

keep bind = "127.0.0.1:8080" and let caddy handle certificates:

sync.example.com {
  reverse_proxy 127.0.0.1:8080
}

with nginx, raise the body limit above the attachment ceiling or large files fail at the proxy before the relay ever sees them:

client_max_body_size 120m;

if a proxy is in front, tell the relay which peers it may trust for forwarding headers, otherwise every request looks like it came from the proxy and rate limits apply to it rather than to real clients. TRUSTED_PROXIES takes bare ips or cidrs, for example 127.0.0.1 or 10.0.0.0/8. the default trusts loopback only.

the live channel needs its own block

one path, /v1/users/<uid>/live, is held open for the life of a session and pushes an event the moment another device syncs. nginx buffers proxied responses by default, which holds that push until the connection closes or the buffer fills — sync still works, it just falls back silently to the poll interval, arriving in tens of seconds instead of a couple. give the live path its own location so buffering is off only there:

location ~ ^/v1/users/[^/]+/live$ {
  proxy_pass http://127.0.0.1:8080;
  proxy_set_header X-Real-IP $remote_addr;

  proxy_http_version 1.1;
  proxy_set_header Connection '';
  proxy_buffering off;
  proxy_cache off;
  chunked_transfer_encoding off;
  proxy_read_timeout 3600s;
}

caddy does not buffer reverse_proxy responses by default, so the block in step 5 already covers it.

step 6 · run it as a service

/etc/systemd/system/shizumu-relay.service:

[Unit]
Description=shizumu relay
After=network-online.target
Wants=network-online.target

[Service]
User=shizumu
ExecStart=/usr/local/bin/shizumu-relay --config /etc/shizumu/relay.toml serve
Restart=on-failure
RestartSec=5

# only needed if the relay terminates tls itself on port 443
AmbientCapabilities=CAP_NET_BIND_SERVICE

# it needs its own state directory and nothing else
ProtectSystem=strict
ReadWritePaths=/var/lib/shizumu
PrivateTmp=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now shizumu-relay
systemctl status shizumu-relay

confirm it is alive. the health endpoint needs no auth and is the same one to point a monitor at:

curl https://sync.example.com/healthz

you should get json back with "status":"ok", the version, and the commit it was built from. if you get html instead, something in front is intercepting; if you get a certificate error, dns or acme has not settled yet.

step 7 · point the app at it

  1. open shizumu, then settings, then sync.
  2. choose my own relay and enter https://sync.example.com.
  3. paste the enrollment token from step 4. this device is now enrolled.
  4. on every other device, choose the same relay and use the short pairing code the first device shows.

write a line on one device and watch it appear on another. that round trip is the real test, and it exercises the whole path: encryption, upload, index, download, decryption.

keeping it running

backups

back up relay.db and the blob store. both hold only ciphertext, so a backup that leaks is not a disclosure of your writing. litestream works well for continuous replication of the sqlite file. if you chose the sqlite blob store, back up both .db files together or you will restore an index that points at blobs you no longer have.

upgrading

cd shizumu-relay
git fetch --tags
git checkout "$(git tag --sort=v:refname | tail -1)"
cargo build --release
sudo install -m755 target/release/shizumu-relay /usr/local/bin/
sudo systemctl restart shizumu-relay

migrations run automatically at start. take a copy of relay.db first anyway: it costs a second and turns a bad upgrade into an inconvenience rather than an incident.

housekeeping

two sweeps, safe to run from cron. neither touches anything a device still needs:

# drop half-finished uploads older than pending_ttl_seconds
shizumu-relay --config /etc/shizumu/relay.toml gc-pending

# drop blobs no committed op references any more
shizumu-relay --config /etc/shizumu/relay.toml gc-orphan-blobs

weekly is plenty for both. the full operator handbook, covering log redaction, metrics, and multi-user quotas, is in the repo at docs/operator.md.

when something is wrong

the service will not start
run show-config as the shizumu user. most failures are a missing storage path or a directory that user cannot write. journalctl -u shizumu-relay -n 50 has the rest.
the app says it cannot reach the relay
check /healthz from another machine, not from the server. reaching it locally but not remotely means a firewall or a proxy, not the relay.
the app refuses the url
it requires https everywhere except localhost. a self-signed certificate will also be refused. use acme, as in step 5.
attachments fail while text syncs fine
a proxy body limit below the attachment ceiling. text ops are small and slip under it; a 40 mb image does not. raise the limit above max_blob_bytes.
the enrollment token expired
they last an hour and are single use. run init-user again for a new one; the account is not recreated.

the deal

self-deploy is free, forever, under the agpl. no feature is held back and no device limit applies. the hosted tier exists for people who would rather not run a server, and it funds the work. same binary, same encryption, your choice.