run your own instance
Self-hosting
shiori-server is a single Rust binary with an embedded SQLite database. Docker
and NixOS are the two supported ways to run it, and both start from the same env-first config,
so neither changes how the server behaves, only how it's packaged and started. This page is
the how; the plan's deployment section covers the why.
Overview
There's exactly one binary and exactly one file worth backing up. Everything else, the repo, the built image, the config, is reproducible from source. Before starting, you'll want:
- A machine to run it on. Docker (or Podman) for the container route, or a NixOS host for the module route. The NixOS
module binds to loopback by default; the Docker image binds to all interfaces, so the Docker command below publishes the container port to
127.0.0.1explicitly. Either way, nothing is exposed until you put a reverse proxy in front of it. - A domain, if it'll be reachable off a trusted network. TLS termination is a reverse proxy's job, not the server's; see TLS & reverse proxy below.
- A backup plan for one file. The SQLite database is the entire durable state of the server; see Backups.
Both routes run the same binary against the same SHIORI_* config, documented in full in Configuration below.
Docker
Build the image, prepare the data directory, then run it:
docker build -t shiori-server .
mkdir -p ./shiori-data && chown 65532:65532 ./shiori-data
docker run -d --name shiori -p 127.0.0.1:8080:8080 \
-v "$(pwd)/shiori-data:/data" \
shiori-server The image runs as distroless's fixed nonroot uid/gid (65532) and has no shell
to fix ownership at startup, so ./shiori-data must already be owned by 65532:65532 before the first run; rootless Podman needs podman unshare chown 65532:65532 ./shiori-data instead, since the plain chown above targets a uid that doesn't exist in your own user namespace.
Or, with Docker Compose, the same setup as a compose.yaml:
services:
shiori:
image: git.flur.dev/shiori/shiori-server:latest
container_name: shiori
restart: unless-stopped
ports:
- '127.0.0.1:8080:8080'
volumes:
- ./shiori-data:/data The mkdir/chown step above still has to run once before the first docker compose up -d, since compose doesn't fix ownership either.
/data is the bind-mounted host directory; SHIORI_DATABASE_URL already points there by default, and it's the one thing worth backing up. A Docker-managed named
volume works too on plain dockerd (it copies the image directory's ownership into a fresh volume
on first use), but that copy-up isn't reliable on every runtime, so the bind mount above is the
recipe to trust. Every other SHIORI_* variable can be overridden with -e.
NixOS
Import the module directly:
{
imports = [ /path/to/shiori/nix/module.nix ];
services.shiori = {
enable = true;
# bindAddr defaults to 127.0.0.1:8080; settings takes any extra
# SHIORI_* variable, e.g. settings.SHIORI_INACTIVE_TTL_SECS = "...";
};
} Or as a flake input, so the host's own nixosSystem call pulls in both the module
and its default package:
{
inputs.shiori.url = "git+https://git.flur.dev/Shiori/Shiori.git";
outputs = { self, nixpkgs, shiori, ... }: {
nixosConfigurations.example = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
shiori.nixosModules.default
{ services.shiori.enable = true; }
];
};
};
} The service runs as a DynamicUser systemd unit with its state confined to /var/lib/shiori (StateDirectory) and fairly aggressive sandboxing
(ProtectSystem = "strict" and friends). bindAddr defaults to 127.0.0.1:8080, databaseUrl overrides SHIORI_DATABASE_URL outright (default: a SQLite path under stateDirectory; set it to a postgres:// URL to run against
Postgres instead), and settings takes any other SHIORI_* variable
as a plain attribute set. shiori.packages.<system>.default is the same shiori-server binary on its own, if all you want is the package rather than the systemd
unit.
One naming collision to know about: nixpkgs ships its own services.shiori module for an unrelated project, go-shiori/shiori, a bookmarks manager that
happens to share this project's name. Both modules declare options and a systemd unit at
that same services.shiori path, so importing this flake's module on a host that
still has nixpkgs' own shiori.nix loaded fails at evaluation. Free up the name by
disabling nixpkgs' module before importing this one:
{
# nixpkgs ships its own services.shiori (an unrelated bookmarks
# manager, go-shiori/shiori) — disable it to free up the name for
# this flake's module instead.
disabledModules = [ "services/web-apps/shiori.nix" ];
imports = [ shiori.nixosModules.default ];
services.shiori.enable = true;
} Wanting both the bookmarks manager and this sync server on the same host is rare, and
there's no one-line alias for it: nixpkgs' module hardcodes shiori as both the
option path and the systemd unit name, not just the option prefix, so a real alias would
mean forking a renamed copy of that module rather than wrapping it. If you do need both, the
simpler route is running the bookmarks manager in its own NixOS container, so the
two never share a module namespace.
Configuration
All settings have sane defaults and are loaded from the environment on startup ( .env.example is the canonical list). Nothing here is required to get a working server; the defaults are what
a fresh install runs with.
| Variable | Default | Controls |
|---|---|---|
SHIORI_BIND_ADDR | 0.0.0.0:8080 | Listen address. The Dockerfile keeps this default (the container boundary is
already the isolation); the NixOS module defaults to 127.0.0.1:8080 instead. |
SHIORI_DATABASE_URL | sqlite://shiori.db?mode=rwc | SQLite (default) or a postgres:// connection string. |
SHIORI_PROVISIONAL_TTL_SECS | 300 | How long a provisional account survives unconfirmed before it's swept. |
SHIORI_INACTIVE_TTL_SECS | 7776000 (90 days) | How long a confirmed account survives without activity. |
SHIORI_NONCE_RETENTION_SECS | 600 | How long a seen nonce is remembered for replay rejection; twice the timestamp window, by design. |
SHIORI_TIMESTAMP_WINDOW_SECS | 300 | How far a request's timestamp header may drift from server time before it's rejected. |
SHIORI_BLOB_QUOTA_BYTES | 10485760 (10 MiB) | Max decoded blob size accepted by PUT /v1/sync. |
SHIORI_SWEEP_INTERVAL_SECS | 60 | How often the background TTL sweep runs. |
SHIORI_CREATE_RATE_LIMIT_MAX SHIORI_CREATE_RATE_LIMIT_WINDOW_SECS | 103600 | Per-IP limit on POST /v1/account, the one endpoint with no auth yet. |
SHIORI_SYNC_RATE_LIMIT_MAX SHIORI_SYNC_RATE_LIMIT_WINDOW_SECS | 6060 | Per-account limit on GET/PUT /v1/sync. |
SHIORI_AUTH_FAILURE_RATE_LIMIT_MAX SHIORI_AUTH_FAILURE_RATE_LIMIT_WINDOW_SECS | 6060 | Per-IP budget of failed authentication attempts. Only requests that end in a 401 spend it, and it's checked before a body is read, so an over-budget
caller costs nothing. 0 disables it. |
SHIORI_TRUSTED_PROXY_CIDRS | (empty) | Addresses or CIDR blocks whose X-Forwarded-For is believed, so per-IP limits
key on the client instead of the proxy. Empty means trust nobody, and the header is ignored. |
TLS & reverse proxy
shiori-server speaks plain HTTP and always will; TLS termination is explicitly
a reverse proxy's job. Bind it to 127.0.0.1, the NixOS module's default (for
Docker, publish the container port to 127.0.0.1 explicitly, as in the Docker command above), and put a proxy in front that owns the certificate.
This matters more than usual here: account_id is a stable pseudonym derived from
the root key, so leaking it in cleartext lets a network observer link a device to an account over
time, even though the payload itself is end-to-end encrypted. TLS is what's actually protecting
that link, not the blob encryption.
Caddy is the simplest option; it gets ACME certs on its own:
sync.example.com {
reverse_proxy 127.0.0.1:8080
} nginx with acme.sh or certbot works the same way: the standard proxy_pass http://127.0.0.1:8080; plus the usual Host/X-Forwarded-* headers. Anything else that terminates TLS and forwards
plain HTTP works too; there's nothing shiori-specific about this part.
One thing here is shiori-specific: per-IP limits key on the address the TCP connection came
from, and X-Forwarded-For is believed only when that address is inside SHIORI_TRUSTED_PROXY_CIDRS. Left unset behind the proxy above, every client
shares one bucket keyed on the proxy, so the limits still fire, just collectively instead of
per client. Set it to the proxy's own address (127.0.0.1 for the setups here)
and each budget keys on the rightmost X-Forwarded-For hop that isn't itself trusted. Only list a proxy you trust to write
that header itself: one that forwards a caller-supplied value untouched lets a caller name any
address and mint a fresh budget per request.
Skip the reverse proxy only for 127.0.0.1-only or otherwise fully
trusted-network deployments, such as behind a VPN you already trust.
Backups
On the SQLite backend, which is what both the Dockerfile and the NixOS module default to,
exactly one file matters: shiori.db, in WAL mode. Everything else, the repo,
the built binary, the config, is reproducible from source; the database is not. Cheapest
option first: stop the server, copy the file, restart it. Downtime is proportional to
database size, which for this workload (derived identifiers, ciphertext, timestamps) stays
small unless you have an enormous number of accounts.
To back up without stopping the server, use SQLite's own consistent-snapshot command:
sqlite3 shiori.db ".backup /path/to/backup.db" Point SHIORI_DATABASE_URL at a postgres:// URL instead and none of
this applies: there's no shiori.db to copy, and backup becomes whatever your Postgres
already does.
Either way, a backup is exactly as sensitive as the live database. It's ciphertext plus
derived identifiers, never plaintext, but account_ids are still stable
pseudonyms, so treat backup storage and transport with the same care as the live TLS link.
Is this for you?
The short version, expanded in the threat model: the server, and anyone who breaches it, never sees plaintext, and can't impersonate an account to write or delete data. There's no email or IP tied to an account by design, no PII in logs, and the rate limiter's state is in-memory only.
What it doesn't protect against, and isn't trying to: a live malicious operator can still observe request timing and sizes, serve stale data, delete data, or refuse service. End-to-end encryption hides content, not metadata or availability. Self-hosting this doesn't change that residual; it just moves who's trusted with it to you.
This is not an audited production system. Read the full threat model and the README's known simplifications before trusting real data to a deployment of it.