# Deployment This is the canonical reference for running Bindery in production. For a five-minute taste, the [README's Quick Start](../README.md#quick-start) is faster. For operational topics that change out-of-band with code (reverse proxy recipes, community-contributed setups, troubleshooting stories), see the [project wiki](https://github.com/vavallee/bindery/wiki). ## Docker ```bash docker run -d \ --name bindery \ -p 8787:8787 \ -v /path/to/config:/config \ -v /path/to/books:/books \ -v /path/to/downloads:/downloads \ ghcr.io/vavallee/bindery:latest ``` **Image tracks:** | Tag | Meaning | |-----|---------| | `:latest` | Most recent tagged release | | `:vX.Y.Z` | Specific release — pin this for reproducible deploys | | `:development` | Bleeding edge from the `development` branch | | `:sha-` | Per-commit main-branch image — pin for rollback | | `:dev-` | Per-commit development-branch image | ## Docker Compose ```yaml services: bindery: image: ghcr.io/vavallee/bindery:latest container_name: bindery ports: - 8787:8787 volumes: - ./config:/config - /media/books:/books - /media/downloads:/downloads environment: - BINDERY_LOG_LEVEL=info restart: unless-stopped ``` ## Kubernetes (Helm) ```bash helm install bindery charts/bindery \ --set image.tag=latest \ --set persistence.config.storageClass=longhorn \ --set ingress.host=bindery.example.com ``` See [`charts/bindery/values.yaml`](../charts/bindery/values.yaml) for all configuration options. ### PodSecurityStandards namespace label The chart's `securityContext` already satisfies Kubernetes' `restricted` policy (non-root user, read-only root filesystem, all capabilities dropped, `seccomp: RuntimeDefault`). To enforce this at the namespace level so that no chart override or future operator can weaken it, label your namespace before installing: ```bash kubectl label namespace bindery \ pod-security.kubernetes.io/enforce=restricted \ pod-security.kubernetes.io/enforce-version=latest ``` `restricted` is the recommended setting. If you run a sidecar or init container that requires elevated privileges, use `baseline` instead — it blocks only the most critical misconfigurations (host namespaces, privileged mode) while permitting containers that need capabilities Bindery itself doesn't use. The label belongs on the namespace, not in the chart, because Helm charts should not create or mutate namespaces they're installed into. ## Unraid (Community Applications) Bindery ships a Community Applications template at [`.github/unraid/bindery.xml`](../.github/unraid/bindery.xml). Once the template is registered with the CA feed, Unraid users can install Bindery from the **Apps** tab. Until then, paste the raw URL into Apps → "Click here to add a missing application": ``` https://raw.githubusercontent.com/vavallee/bindery/main/.github/unraid/bindery.xml ``` Defaults the template provides: bridge networking, port `8787`, `--user 99:100` (Unraid's `nobody:users`), and four mounts — `/mnt/user/appdata/bindery → /config`, `/mnt/user/Books → /books`, `/mnt/user/Audiobooks → /audiobooks` (optional), and `/mnt/user/downloads/bindery → /downloads`. If you change `BINDERY_PUID` or `BINDERY_PGID`, also change the matching half of `--user` in **Extra Parameters** — the container fail-fast-validates the pair on startup. ## Binary Pre-built archives are attached to every [Release](https://github.com/vavallee/bindery/releases) for: | OS | Architectures | Runs on | |----|---------------|---------| | Linux | amd64, arm64, armv7, armv6 | x86_64 servers, Raspberry Pi 4 / 5 (64-bit), Pi 2 / 3 (32-bit), Pi Zero / 1 | | macOS | amd64, arm64 | Intel Macs, Apple Silicon | | Windows | amd64, arm64 | x86_64 desktops, Windows on ARM | Pick the archive matching your platform, verify against `bindery__checksums.txt`, extract, and run. ### Linux ```bash tar -xzf bindery__linux_amd64.tar.gz ./bindery ``` Database and backups land in `/config/` by default so the same binary slots into existing Docker / Helm deployments. Override with `BINDERY_DB_PATH` / `BINDERY_DATA_DIR` if you don't want `/config` (bare-metal users running as non-root will need to). ### macOS ```bash tar -xzf bindery__darwin_arm64.tar.gz # or _amd64 for Intel Macs ./bindery ``` On first run the database resolves to `~/Library/Application Support/Bindery/bindery.db`. The app respects `BINDERY_DB_PATH` / `BINDERY_DATA_DIR` if you want them elsewhere. Gatekeeper may flag the unsigned binary; allow it in **System Settings → Privacy & Security** (the "bindery" entry shows up under "Security" after the first blocked launch). ### Windows Unzip `bindery__windows_amd64.zip` (or `_arm64.zip` for Windows on ARM) and double-click `bindery.exe`. On first run the database resolves to `%APPDATA%\Bindery\bindery.db`. If the console window closes instantly, open `cmd` and run the binary from there so the error message stays readable: ```cmd cd %USERPROFILE%\Downloads\bindery__windows_amd64 bindery.exe ``` SmartScreen will warn about the unsigned binary on first launch — choose **More info → Run anyway**. Signed Windows builds are on the roadmap. ### Resolved paths logged at startup Every launch emits a `"starting bindery"` JSON log line containing the resolved `dbPath` and `dataDir`. If the binary can't write to them, `db.Open`'s preflight will name the directory and the required UID so you can fix the permission without guesswork. The frontend is embedded in the binary via `go:embed` — no separate static-file hosting needed. ## Running as a specific UID/GID Bindery ships on a [distroless/static-debian12:nonroot](https://github.com/GoogleContainerTools/distroless) base. The image has no shell, no `gosu`, and no entrypoint hook — it cannot switch user at runtime the way LinuxServer.io images do. If you need the container to own files as your media-library user (e.g. `1000:1000`), launch it with that UID/GID directly. ### Docker ```bash docker run -d \ --user 1000:1000 \ -e BINDERY_PUID=1000 \ -e BINDERY_PGID=1000 \ ... ghcr.io/vavallee/bindery:latest ``` ### Docker Compose ```yaml services: bindery: image: ghcr.io/vavallee/bindery:latest user: "1000:1000" environment: - BINDERY_PUID=1000 - BINDERY_PGID=1000 ``` ### Kubernetes ```yaml spec: template: spec: securityContext: runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 # makes mounted volumes owned by GID 1000 containers: - name: bindery env: - { name: BINDERY_PUID, value: "1000" } - { name: BINDERY_PGID, value: "1000" } ``` ### Sanity-check semantics `BINDERY_PUID` / `BINDERY_PGID` are **sanity checks, not user switchers.** If you set them but forget the `--user` / `runAsUser` side, Bindery fails fast at startup with a log line that shows exactly what flag was missing — replacing the usual silent `permission denied` on `/config` or the library mount. Leaving both unset skips the check entirely (Bindery runs as the default non-root UID `65532` from the distroless base). ## Storage layout and hardlinks (single mount) Bindery imports with **`hardlink`** mode by default when the completed download and the library destination are on the **same filesystem**. A hardlink is instant, uses no extra disk, and lets a torrent keep seeding from the original path. This is the recommended layout. The catch: "same filesystem" is decided by the OS device ID *inside the container*. Two separate bind mounts get different device IDs even when they point at the same host filesystem, so a hardlink across them fails and Bindery falls back to **`copy`** (which doubles disk usage and logs a warning). To get hardlinks, the download directory and the library directory must live under **one mount**, as subpaths of it. This is the [TRaSH-Guides](https://trash-guides.info/Hardlinks/Hardlinks-and-Instant-Moves/) layout the rest of the \*arr stack uses: mount a single volume (e.g. `/data`) and point everything at subpaths of it. The download client mounts the **same** volume at the **same** path, so no path remap is needed either. ### Docker Compose ```yaml services: bindery: image: ghcr.io/vavallee/bindery:latest volumes: - /mnt/storage:/data # ONE mount for downloads + library environment: - BINDERY_DOWNLOAD_DIR=/data/downloads/complete - BINDERY_LIBRARY_DIR=/data/books - BINDERY_AUDIOBOOK_DIR=/data/audiobooks sabnzbd: image: lscr.io/linuxserver/sabnzbd:latest volumes: - /mnt/storage:/data # SAME volume at the SAME path # SABnzbd's complete dir -> /data/downloads/complete ``` ### Kubernetes (Helm `values.yaml`) ```yaml nfs: enabled: true server: 192.168.1.4 path: /volume1/MEDIA mountPath: /data env: BINDERY_DOWNLOAD_DIR: /data/downloads/complete BINDERY_LIBRARY_DIR: /data/books BINDERY_AUDIOBOOK_DIR: /data/audiobooks ``` > **Don't point a library/download dir at an unmounted path.** `BINDERY_LIBRARY_DIR` / `BINDERY_DOWNLOAD_DIR` / `BINDERY_AUDIOBOOK_DIR` must resolve *inside* a mounted volume. A dir that isn't backed by a volume writes to ephemeral container storage and is lost on restart. The chart's stock defaults (`/downloads`, `/books`) are placeholders — override them to subpaths of your real mount. If you genuinely can't use a single mount (the download client mounts the share at a different path than Bindery), keep them separate and use [path remapping](#path-remapping-multi-container--multi-pod-setups) below — but note imports will then **`copy`**, not hardlink. ### Shared ebook + audiobook folder (Storyteller layout) Storyteller (and similar read-along tools) wants a book's ebook and its audio files in **one folder**. Bindery already produces exactly that when `BINDERY_LIBRARY_DIR` and `BINDERY_AUDIOBOOK_DIR` point at the same path — which is the default: leave `BINDERY_AUDIOBOOK_DIR` unset and audiobooks use the library dir. Both default naming templates share the `{Author}/{Title} ({Year})` prefix, so the audiobook import creates `Author/Title (Year)/` with the audio files inside, and the ebook import places its file in that same folder: ``` /data/books/Ursula K. Le Guin/A Wizard of Earthsea (1968)/ ├── A Wizard of Earthsea.epub ├── Part 001.m4b └── cover.jpg ``` No extra configuration needed — just don't split the two dirs if you want the shared layout. (For handing files to Storyteller's *watch folder* instead, see [Handing off to another library tool](#handing-off-to-another-library-tool-cwa-calibre-storyteller).) ### `BINDERY_DOWNLOAD_DIR` is not a watch folder A common misreading: `BINDERY_DOWNLOAD_DIR` does **not** mean "all completed downloads must land here" — Bindery never scans it to discover finished downloads. Import paths come from the **download client's API, per job**: when SABnzbd or qBittorrent reports a job complete, it also reports where the files are, and Bindery imports from that reported path. The env var is used for: - startup validation and the Settings → General storage-health panel, - the hardlink probe (whether imports can link instead of copy), - the save path Bindery submits with qBittorrent grabs (and the category save-path warning in Settings), - manual/bulk import's default scan location. So the TRaSH split-tree layout — separate `/data/torrents` and `/data/usenet` trees, one per client — works as-is. Point `BINDERY_DOWNLOAD_DIR` at whichever completed folder is your primary (or the common parent); each client's own reported paths drive the actual imports. ## Path remapping (multi-container / multi-pod setups) When Bindery and your download client run in **separate containers**, they typically mount the same storage volume at different paths. Bindery needs to read the files the download client just completed, but the path the client reports (e.g. `/downloads/complete/My.Book`) doesn't exist inside Bindery's container. Set a download-client path remap in **Settings → Download clients** or set the global `BINDERY_DOWNLOAD_PATH_REMAP` fallback to a comma-separated list of `from:to` pairs. Bindery applies a longest-prefix match to every path the download client reports, replacing the matched prefix before it tries to access the file. A per-client remap takes precedence when it matches the reported path; the global env var still applies as a fallback. Per-client remaps are stored on each download client, so separate qBittorrent / SABnzbd / NZBGet instances can map different mount points. Existing download clients keep an empty remap after upgrade, which preserves the previous global-only behavior until you add a client-specific value. For a per-client remap, open **Settings → Download clients**, edit the client, and set **Download client path remap**. The left side is the path the client reports; the right side is the path Bindery can read. For qBittorrent this normally means mapping the qBittorrent category save path or torrent content path to Bindery's download mount. Example: if qBittorrent reports `/downloads/books/My.Book` and Bindery sees that same folder as `/media/books/My.Book`, set `/downloads:/media/books`. **Common scenario — SABnzbd or qBittorrent and Bindery on the same NAS storage, different mount points:** | Container | NAS path | Mount point | |-----------|----------|-------------| | Download client | `/volume1/MEDIA` | `/downloads` | | Bindery | `/volume1/MEDIA` | `/media` | The download client reports `/downloads/complete/My.Book`; Bindery remaps to `/media/complete/My.Book`. ### Docker Compose ```yaml services: sabnzbd: image: lscr.io/linuxserver/sabnzbd:latest volumes: - /mnt/media:/downloads # NAS/share mounted at /downloads bindery: image: ghcr.io/vavallee/bindery:latest volumes: - /mnt/media:/media # same share, different mount point environment: - BINDERY_DOWNLOAD_PATH_REMAP=/downloads:/media ``` ### Kubernetes (Helm `values.yaml`) ```yaml env: BINDERY_DOWNLOAD_PATH_REMAP: /downloads:/media nfs: enabled: true server: 192.168.1.4 path: /volume1/MEDIA mountPath: /media ``` Multiple remaps are separated by commas: `BINDERY_DOWNLOAD_PATH_REMAP=/sab/complete:/media/complete,/qbit:/media/qbit`. Longest prefix wins, so more-specific rules take precedence over shorter ones. For qBittorrent, Bindery also sends a save path when submitting torrent grabs. If a per-client remap is configured, Bindery inversely maps `BINDERY_DOWNLOAD_DIR` or `BINDERY_AUDIOBOOK_DOWNLOAD_DIR` back to qBittorrent's mount point before submitting the torrent. The Settings page checks the configured qBittorrent category and warns when its save path does not map to Bindery's expected download folder. After fixing a path or category mismatch, use **Queue → Retry import** on an `importFailed` item. This is also the right action after manually moving the completed torrent in qBittorrent to a path Bindery can read. Bindery resets the import retry counter and imports from the existing completed download, so the release does not need to be grabbed or downloaded again. ## Handing off to another library tool (CWA, Calibre, Storyteller) If a separate tool manages your library, there are two distinct topologies. Pick by who owns the library directory. **1. Bindery owns the library, mirror a copy to CWA.** Bindery places the imported file in its library (`hardlink`/`copy`/`move` mode), *and* additionally copies it into a Calibre-Web-Automated ingest folder. Set **Settings → Integrations → Calibre → CWA ingest path** (`cwa.ingest_path`). Use this when Bindery's library and CWA's library are the same directory and you just want CWA to also see new ebooks. **2. An external tool owns the library (drop folder).** Bindery does *not* write into the library; instead it renames the finished download into a drop folder and lets the other tool ingest it and produce the managed copy. This is the right setup for "Bindery → `/cwa-book-ingest` → CWA writes `/books`", for Calibre auto-ingest, and for Storyteller's watched folder. Configure under **Settings → General → File Naming** (visible when Import Mode is **External**): - Set **Import Mode** to `External`. - **Drop folder** — the watch folder the other tool ingests from (e.g. `/cwa-book-ingest`). Empty disables the drop and Bindery just hands off in place (the file stays in the download dir). - **Layout** — `flat` (a sanely-named file in the folder root, what most watch-folder tools expect) or `templated` (recreate the `{Author}/{Title (Year)}/…` tree inside the drop folder). - **Placement** — `copy` (default; safest, since the ingesting tool usually deletes what it consumes) or `hardlink` (disk-free, same filesystem only). The download source is never moved, so torrents keep seeding. Bindery parks the download as *handed off* and reconciles the managed copy the external tool lands in `BINDERY_LIBRARY_DIR` on the next **library scan** (so the library dir must still point at where the external tool ultimately writes). Single-format Storyteller works today by pointing the drop folder at Storyteller's watch folder; guaranteed ebook+audiobook pair-gating is tracked as a follow-up (#942). ## Environment variables | Variable | Default | Description | |----------|---------|-------------| | `BINDERY_PORT` | `8787` | HTTP server port | | `BINDERY_URL_BASE` | _(empty)_ | URL path prefix when hosting Bindery under a reverse-proxy subpath (e.g. `/bindery`). Accepts a bare path or full URL — only the path component is used. No trailing slash needed. See the [Reverse-proxy & SSO wiki](https://github.com/vavallee/bindery/wiki/Reverse-proxy-and-SSO) for Nginx / Caddy / Traefik examples. | | `BINDERY_DB_PATH` | `/config/bindery.db` on Linux; `%APPDATA%\Bindery\bindery.db` on Windows; `~/Library/Application Support/Bindery/bindery.db` on macOS | SQLite database path | | `BINDERY_DATA_DIR` | `/config` on Linux; `%APPDATA%\Bindery` on Windows; `~/Library/Application Support/Bindery` on macOS | Config directory (backups live here) | | `BINDERY_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | | `BINDERY_API_KEY` | _(empty)_ | **Seed only.** Bootstraps the initial API key on first launch if set; after that the key lives in the database and can be regenerated from the UI. | | `BINDERY_DOWNLOAD_DIR` | `/downloads` | Where the download client places completed downloads. **Not a watch folder** — per-job import paths come from the client's API; this feeds validation, storage health, the hardlink probe, and qBittorrent save paths (see [above](#bindery_download_dir-is-not-a-watch-folder)). Manual/bulk import may also read from here (and from `BINDERY_AUDIOBOOK_DOWNLOAD_DIR`), so a migration backlog sitting in the download folder can be scanned and attached in bulk. | | `BINDERY_AUDIOBOOK_DOWNLOAD_DIR` | falls back to `BINDERY_DOWNLOAD_DIR` | Separate watch folder for audiobook downloads; set this when your download client routes audiobook grabs to a dedicated category/path | | `BINDERY_LIBRARY_DIR` | `/books` | Destination for imported ebook files | | `BINDERY_AUDIOBOOK_DIR` | falls back to `BINDERY_LIBRARY_DIR` | Destination for imported audiobook folders | | `BINDERY_ENHANCED_HARDCOVER_API` | `true` | Set to `false` to disable token-backed Hardcover series search, linking, catalog diffs, and missing-book fill even when an admin enables the feature in Settings. | | `BINDERY_DOWNLOAD_PATH_REMAP` | _(empty)_ | Global comma-separated `from:to` pairs rewriting paths reported by download clients into paths Bindery can access. Per-client path remaps in Settings take precedence when they match. Longest-prefix match wins. See [Path remapping](#path-remapping-multi-container--multi-pod-setups). | | `BINDERY_PUID` | _(unset)_ | Sanity check — see [Running as a specific UID/GID](#running-as-a-specific-uidgid) | | `BINDERY_PGID` | _(unset)_ | Sanity check — same as `BINDERY_PUID` for the primary GID | | `BINDERY_COOKIE_SECURE` | `auto` | Session cookie `Secure` flag policy. `auto` (default) flips the flag on when TLS is detected directly or via `X-Forwarded-Proto: https`; `always` forces it on (use when your reverse proxy doesn't forward the header); `never` forces it off (legacy plain-HTTP installs). | | `BINDERY_NOTIFICATIONS_ALLOW_PRIVATE` | _(unset)_ | Set to `1` to flip outbound webhook SSRF policy from Strict to LAN, allowing RFC1918 targets. Use when ntfy / Home Assistant / Gotify live on your private network. Loopback, link-local, and cloud-metadata endpoints stay blocked. | | `BINDERY_DOWNLOAD_ALLOW_LOOPBACK` | _(unset)_ | Set to `1` to allow Bindery to fetch indexer-provided `.torrent` / `.nzb` download links that resolve to **loopback** (`127.0.0.1`, `::1`). Off by default because the download URL is chosen by the indexer's response (not an admin-typed value), so a malicious indexer could otherwise point it at a service on Bindery's own loopback. Turn this on when you legitimately run Prowlarr / an indexer co-located on loopback (e.g. `network_mode: host` with the companion bound only to `127.0.0.1`, [#1062](https://github.com/vavallee/bindery/discussions/1062)). RFC1918 LAN targets are allowed regardless; link-local and cloud-metadata stay blocked. | | `BINDERY_ALLOW_LAN_OIDC` | _(unset)_ | Set to `1`/`true` to disable the SSRF guard on the OIDC discovery probe, allowing LAN / loopback / private-range issuer URLs. Only enable when your OIDC provider runs on the Bindery host or a trusted private network. See [auth-oidc.md](auth-oidc.md). | | `BINDERY_OUTBOUND_PROXY` | _(empty)_ | Route Bindery's **remote-facing** outbound HTTP through a proxy: indexer searches, metadata/cover providers, webhook notifications, and the telemetry ping. A single URL whose scheme selects the protocol — `http`, `https`, or `socks5` (e.g. `http://proxy.lan:3128`, `http://user:pass@proxy:3128`, `socks5://gluetun:1080`). Credentials go in the URL userinfo and are never logged. Download clients and OIDC discovery are intentionally left direct. Handy for routing egress through a VPN container's proxy (e.g. the Privoxy on `:8118` in binhex's `*vpn` images). | | `BINDERY_OUTBOUND_PROXY_BYPASS_LOCAL` | `true` | When the proxy is set, dial LAN / loopback destinations **direct** instead of through it: any RFC1918 / loopback / link-local IP, `localhost`, `*.local` / `*.lan` / `*.internal`, and single-label Docker service names (e.g. `prowlarr`, `jackett`). Keeps a local indexer manager reachable while remote egress still flows through the proxy. Set `false` to force every in-scope request through the proxy. | | `BINDERY_OUTBOUND_PROXY_NO_PROXY` | _(empty)_ | Comma-separated extra exceptions that always bypass the proxy, on top of `BINDERY_OUTBOUND_PROXY_BYPASS_LOCAL`. Each entry is a host, a domain suffix (`example.com` also matches `sub.example.com`), or a CIDR (`10.0.0.0/8`). Use for a Prowlarr / Jackett reached by a dotted, public-looking hostname (e.g. a `*.ts.net` Tailscale name) that the local-bypass heuristics won't catch. | | `BINDERY_CONTACT` | _(falls back to project URL)_ | Contact pointer Bindery advertises in its `User-Agent` header. Accepts a `mailto:` URI, a bare email address (auto-prefixed with `mailto:`), or an `http(s)://` URL. **Set this when OpenLibrary author/title searches return HTTP 403** — OpenLibrary rate-limits per-UA and the default shared URL causes the whole Bindery fleet to count as one client. A per-instance contact differentiates each install and satisfies OpenLibrary's API policy (see [#848](https://github.com/vavallee/bindery/issues/848)). Example: `BINDERY_CONTACT=you@example.org`. Bindery never connects to the address; it goes only into the header. | | `BINDERY_RATE_LIMIT_MAX_FAILURES` | `5` | Maximum failed login attempts per IP before the account is locked for the rate-limit window. | | `BINDERY_RATE_LIMIT_WINDOW_MINUTES` | `15` | Duration in minutes of the per-IP login rate-limit window. After the window expires the failure counter resets. | | `BINDERY_SHUTDOWN_GRACE` | `30` | Seconds to drain in-flight HTTP requests after receiving SIGTERM or SIGINT before the process exits. Increase if your load balancer / Kubernetes sends long-lived SSE or WebSocket connections. | | `BINDERY_ENFORCE_TENANCY` | _(off)_ | Set to `true`/`1` to enforce per-user data isolation: each user sees only their own authors, books, profiles, and root folders, and the join-scoped queue / history / pending / OPDS feeds are scoped to the requesting user. **Defaults off**, in which case every authenticated user shares one library view (single-user behaviour). Admin-only configuration gating applies regardless of this flag. See [multi-user.md](multi-user.md). | | `BINDERY_LOG_RETENTION_DAYS` | `14` | Days to retain persisted log entries in the SQLite log store before they are pruned. | | `BINDERY_TRUSTED_PROXY` | _(empty)_ | Comma-separated IP/CIDR list of reverse proxies trusted to set `X-Forwarded-*`. Bindery resolves the real client IP (for local-only auth and the per-IP login rate-limiter) by walking the `X-Forwarded-For` chain and only trusting hops in this list; it never trusts a client-supplied leftmost entry. An entry like `0.0.0.0/0` trusts every peer and effectively disables per-IP decisions. **Required** when proxy auth mode is active — Bindery refuses to start without it. | | `BINDERY_TELEMETRY_DISABLED` | _(unset)_ | Set to `true` to opt out of the daily anonymous telemetry ping before any DB setting exists (e.g. on first boot). Equivalent to `telemetry.enabled: false` in **Settings → General**, but takes effect before the first ping fires. | | `BINDERY_FRAME_ANCESTORS` | _(empty)_ | Allow the UI to be embedded in an `