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.

01

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.1 explicitly. 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.

02

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.

03

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.

04

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.

VariableDefaultControls
SHIORI_BIND_ADDR0.0.0.0:8080Listen 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_URLsqlite://shiori.db?mode=rwcSQLite (default) or a postgres:// connection string.
SHIORI_PROVISIONAL_TTL_SECS300How long a provisional account survives unconfirmed before it's swept.
SHIORI_INACTIVE_TTL_SECS7776000 (90 days)How long a confirmed account survives without activity.
SHIORI_NONCE_RETENTION_SECS600How long a seen nonce is remembered for replay rejection; twice the timestamp window, by design.
SHIORI_TIMESTAMP_WINDOW_SECS300How far a request's timestamp header may drift from server time before it's rejected.
SHIORI_BLOB_QUOTA_BYTES10485760 (10 MiB)Max decoded blob size accepted by PUT /v1/sync.
SHIORI_SWEEP_INTERVAL_SECS60How often the background TTL sweep runs.
SHIORI_CREATE_RATE_LIMIT_MAX SHIORI_CREATE_RATE_LIMIT_WINDOW_SECS103600Per-IP limit on POST /v1/account, the one endpoint with no auth yet.
SHIORI_SYNC_RATE_LIMIT_MAX SHIORI_SYNC_RATE_LIMIT_WINDOW_SECS6060Per-account limit on GET/PUT /v1/sync.
SHIORI_AUTH_FAILURE_RATE_LIMIT_MAX SHIORI_AUTH_FAILURE_RATE_LIMIT_WINDOW_SECS6060Per-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.
05

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.

06

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.

07

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.

Shiori is a personal project. Made with ❤️ for the open source community.