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:
binddefaults to0.0.0.0:8080. bind to loopback if a proxy sits in front, and to0.0.0.0only if the relay terminates tls itself.modedefaults tosingle_user: one account, all your devices.multi_useris for running a relay for other people and adds signup and quota handling.max_blob_bytesdefaults to 110 mb, which is the 100 mb attachment ceiling plus room for the encryption envelope. raise both together or neither.pending_ttl_secondsdefaults 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
- open shizumu, then settings, then sync.
- choose my own relay and enter
https://sync.example.com. - paste the enrollment token from step 4. this device is now enrolled.
- 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-configas theshizumuuser. most failures are a missing storage path or a directory that user cannot write.journalctl -u shizumu-relay -n 50has the rest. - the app says it cannot reach the relay
- check
/healthzfrom 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-useragain 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.