A self-hosted Zotero sync stack: keep one library in sync across your own machines — metadata and attachment files — without zotero.org or any third-party cloud. A small server reimplements the subset of the Zotero Web API v3 that the client needs, and stock Zotero clients are pointed at it.
Status: in active development (pre-1.0). The sync contract is implemented and tested, but interfaces, the schema, and the deployment surface may still change without notice. Expect breaking changes.
- Client redirect, no patching. Zotero reads its sync endpoint from a hidden
pref, so setting
extensions.zotero.api.url(andstreaming.url) redirects all sync and authentication to your server. No app patch or re-sign needed. - Server. A Rust/axum service backed by PostgreSQL. Library objects (items,
collections, searches) are stored as opaque
jsonbblobs keyed by(kind, key); a single library version counter is bumped per write so the client'ssincereads andIf-Unmodified-Since-Versionwrites stay coherent. Attachment bytes go to an S3-compatible bucket (Cloudflare R2): uploads pass through the server (which verifies the md5), downloads redirect to a pre-signed URL the client follows straight to the bucket. - Auth. Single user, single library. The client obtains a read/write API token through Zotero's browser-login session flow, which the server gates so the token is released only after the login is approved (via SSO when exposed publicly, or the private network otherwise). The token is a bearer string checked on every request. Multiple tokens can be configured, each read/write or read-only — handy for giving a CLI or agent read-only access while the app keeps write access.
The full request contract is documented in server/SPEC.md.
The same endpoints serve any token-holding client, so a CLI can read items,
files and full-text directly over HTTP without the Zotero app.
| Output | Purpose |
|---|---|
packages.zhost |
the sync server binary |
nixosModules.zhost |
systemd service + local postgres + credential-loaded keys |
homeModules.zotero |
programs.zotero: stock client configured at the server |
overlays.default |
pkgs.zotero patched to point at a self-hosted server |
The server binds to localhost behind a reverse proxy. The data API is protected
by the bearer token, so it is safe to expose over HTTPS; the one path that hands
out a token — the login session's /login step — must be gated, either by SSO
(public deployment) or by a private network (WireGuard/ZeroTier). The token is
provisioned as a secret, never generated by the server.
Generate the tokens (one line each, any high-entropy string):
openssl rand -hex 32Store them with sops-nix / clan vars (or any secret manager); the server reads them from files. The S3 bucket needs a (bucket-scoped) access key / secret key the same way.
Server:
imports = [ inputs.zhost.nixosModules.zhost ];
nixpkgs.overlays = [ inputs.zhost.overlays.default ];
services.zhost = {
enable = true;
bind = "127.0.0.1:8189";
publicUrl = "https://zotero.example.org"; # reverse-proxy address, no trailing slash
s3 = {
endpoint = "https://<account>.r2.cloudflarestorage.com";
bucket = "zotero";
accessKeyFile = config.sops.secrets."zhost/s3-access".path;
secretKeyFile = config.sops.secrets."zhost/s3-secret".path;
};
keys = {
# The Zotero app needs write access.
app.file = config.sops.secrets."zhost/app-key".path;
# A CLI / agent that should only read.
cli = {
file = config.sops.secrets."zhost/cli-key".path;
readOnly = true;
};
};
};
# Reverse proxy -> http://127.0.0.1:8189, with /login behind your SSO
# (e.g. oauth2-proxy) when the vhost is public. On a private network the network
# is the gate and /login needs no extra auth.For a single read/write key, apiKeyFile = config.sops.secrets."zhost/api-key".path;
is shorthand for one keys entry.
Client (home-manager):
imports = [ inputs.zhost.homeModules.zotero ];
nixpkgs.overlays = [ inputs.zhost.overlays.default ];
programs.zotero = {
enable = true;
apiUrl = "https://zotero.malt.wg/";
streamUrl = "wss://zotero.malt.wg/stream/";
};Then sign in once per device via Zotero's Sync preferences.
- Token-gated data API. Every request carries the bearer token over HTTPS; the read/write token is the crown jewel, so a CLI / agent gets a read-only one.
- Gated enrollment. The only path that hands out a token is the login
session, released only after
/loginapproves it — behind SSO when public, or the private network otherwise. File downloads use unguessable, expiring pre-signed URLs, so they need no token (mirroring pre-signed storage URLs). - Secrets at rest: the API tokens and the S3 keys are provisioned out of band and loaded as systemd credentials; never placed in the Nix store or environment.
- Encryption at rest for attachment bytes is the object store's concern (e.g. bucket-level encryption), not the application's.
Validated end-to-end against a real Zotero 9 client (cross-machine sync of
metadata and attachment files, both directions) and by a NixOS VM test
(checks/nixos-sync) exercising the API and the module on Linux.
Implemented: the full sync contract — objects, settings, deletions, write
conflict semantics (If-Unmodified-Since-Version → 412), the three-step file
upload and S3-backed download, full-text content storage, read-only API keys, and
authorization-gated login sessions — plus the CLI-facing read/query API:
GET /items with search (q, and
qmode=everything over stored full-text), itemType/tag filters, sorting and
paginated results (Total-Results + Link: …; rel="next"), and the
/items/top, /items/trash, /collections/<key>/items and /tags listings.
See server/SPEC.md for the full contract.
Out of scope: group libraries, the streaming server, binary-diff uploads, and styled bibliography/citation rendering (done downstream from the raw data).
nix develop # postgres + sqlx-cli + rust toolchain
nix build .#zhostRun against a local database and an S3-compatible store (e.g. RustFS or MinIO on
:9000):
ZHOST_DATABASE_URL='postgres:///zhost?host=/run/postgresql' \
ZHOST_S3_ENDPOINT='http://127.0.0.1:9000' ZHOST_S3_BUCKET='zotero' \
ZHOST_S3_ACCESS_KEY='...' ZHOST_S3_SECRET_KEY='...' \
result/bin/zhostConfiguration is via environment variables: ZHOST_BIND, ZHOST_PUBLIC_URL,
ZHOST_DATABASE_URL, the ZHOST_S3_* group (ENDPOINT, REGION, BUCKET,
ACCESS_KEY[_FILE], SECRET_KEY[_FILE], PATH_STYLE, PRESIGN_TTL), and ZHOST_KEYS (or
ZHOST_API_KEY_FILE / ZHOST_API_KEY for a single key in local development).
The NixOS integration test (nix build .#checks.x86_64-linux.nixos-sync) runs the
full flow against a RustFS instance.
MIT.