feat(calibre-server): Calibre Content Server as a boot-persistent Quadlet

lscr.io/linuxserver/calibre:9.15.0 (desktop build) running calibre-server
as the s6 main service on internal 8090 -> host 8084, auth-gated with a
plaintext userdb (mode 600) bootstrapped once by the deploy handler.
The desktop GUI is kept on 8080/8182, basic-authed with the same
credential pair via the machine-local env file.
This commit is contained in:
Frank
2026-09-21 12:46:02 -07:00
parent 660db661b6
commit 94cfb90ead
5 changed files with 173 additions and 24 deletions
@@ -0,0 +1,59 @@
# Quadlet unit: Calibre Content Server (rootless, boot-persistent).
#
# Install path on dankserver:
# ~/.config/containers/systemd/calibre-server.container
# Unit name: calibre-server.service (podman 5.x drops the container- prefix)
#
# The image is the CURRENT linuxserver/calibre (9.15.0), which is a desktop
# image (Selkies web-streamed GUI on 8080/8181) with the headless
# calibre-server available inside. The compose-era snippet in
# serverAppConfigs-docs/Dankserver/Calibre.md predates the desktop revamp;
# the dedicated headless images (ta264/docker-calibre, agentgreen) are
# dormant (calibre 6.10 / 2017), so this unit runs calibre-server as the
# image's s6-overlay main service via Exec= and keeps the desktop as a
# bonus (browser GUI on 8080, basic-authed via the env file).
#
# Port map (Network=pasta, so container ports bind on the host directly):
# 8090 -> host 8084 calibre-server (the content server; 8080 is taken by
# the image's own nginx, 8083 by calibre-web)
# 8080 -> host 8080 desktop GUI (nginx; CUSTOM_HTTPS_PORT moved to 8182
# because Tautulli holds host 8181)
#
# Auth: calibre-server 9.x keeps its users in a SQLite userdb (the pw column
# is PLAINTEXT, so the file is mode 600). The single admin account is
# bootstrapped once by the deploy handler (deploy_calibre_server) via
# `calibre-server --manage-users -- add`, password piped on stdin with
# printf (a trailing newline from echo lands in the stored password and
# breaks every login). The desktop's basic auth (CUSTOM_USER/CUSTOM_PASSWORD)
# reuses the same credential pair. Both live in the machine-local env file;
# this unit holds no secrets.
#
# --auth-mode=basic is required: on a non-SSL port the server defaults to
# Digest auth, which rejects plain Basic clients with 400 "Unsupported
# authentication method".
[Unit]
Description=Calibre content server container
After=network.target
[Container]
ContainerName=calibre-server
Image=lscr.io/linuxserver/calibre:9.15.0
Network=pasta
EnvironmentFile=/home/dank/Containers/calibre-server/env
Environment=PUID=1000
Environment=PGID=1000
Environment=TZ=America/Vancouver
Environment=CUSTOM_HTTPS_PORT=8182
Volume=/home/dank/Containers/calibre-web/books:/books
Volume=/home/dank/Containers/calibre-server/userdb.sqlite:/userdb.sqlite
Exec=calibre-server --port 8090 --enable-auth --auth-mode=basic --userdb /userdb.sqlite /books
PublishPort=8084:8090
[Service]
TimeoutStartSec=300
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+3
View File
@@ -52,6 +52,7 @@ SERVICE_ORDER = (
"copyparty",
"jellyfin",
"calibre-web",
"calibre-server",
"plex",
"tautulli",
"prowlarr",
@@ -103,6 +104,7 @@ DANKSERVER_SERVICES = {
"copyparty",
"jellyfin",
"calibre-web",
"calibre-server",
"plex",
"tautulli",
"prowlarr",
@@ -353,6 +355,7 @@ def services_for_path(path: str, target: str) -> set[str]:
"romm": {"romm", "romm-mariadb", "romm-redis"},
"jellyfin": {"jellyfin"},
"calibre-web": {"calibre-web"},
"calibre-server": {"calibre-server"},
"copyparty": {"copyparty"},
"plex": {"plex"},
"tautulli": {"tautulli"},
+46 -1
View File
@@ -92,7 +92,7 @@ service_is_allowed() {
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
dankclaw:frank-large|dankclaw:frank-small|\
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:plex|dankserver:tautulli|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
dankserver:frank-medium|dankserver:node-exporter|\
@@ -850,6 +850,50 @@ deploy_calibre_web() {
systemctl --user restart calibre-web.service
}
# The content server's single admin account and the desktop's basic-auth
# credential (same pair) are bootstrapped exactly once: the env file is the
# plaintext store (mode 600), the userdb's pw column is plaintext too, so
# its file is mode 600 as well. The password is piped on stdin with printf -
# a trailing newline from echo lands in the stored password and breaks every
# login (verified 2026-09-21).
deploy_calibre_server() {
local data_root=/home/dank/Containers/calibre-server
mkdir -p "$data_root"
if [[ ! -f $data_root/env ]]; then
local pw
pw=$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)
printf 'CUSTOM_USER=calibre\nCUSTOM_PASSWORD=%s\n' "$pw" > "$data_root/env"
chmod 600 "$data_root/env"
echo "calibre-server: generated credential (shown once): calibre / $pw"
fi
[[ -f $data_root/env ]] || {
echo "FAIL: $data_root/env does not exist; seed it before the first calibre-server deploy" >&2
return 1
}
if [[ ! -f $data_root/userdb.sqlite ]]; then
local pw
pw=$(grep '^CUSTOM_PASSWORD=' "$data_root/env" | cut -d= -f2)
touch "$data_root/userdb.sqlite"
printf '%s' "$pw" | podman run --rm -i --entrypoint /usr/bin/calibre-server \
-v "$data_root/userdb.sqlite:/userdb.sqlite" \
lscr.io/linuxserver/calibre:9.15.0 \
--userdb /userdb.sqlite --manage-users -- add calibre
chown 1000:1000 "$data_root/userdb.sqlite"
chmod 600 "$data_root/userdb.sqlite"
fi
[[ -f $data_root/userdb.sqlite && -s $data_root/userdb.sqlite ]] || {
echo "FAIL: $data_root/userdb.sqlite missing or empty; the --manage-users bootstrap failed" >&2
return 1
}
cp "dankserver/calibre-server/quadlet/calibre-server.container" "$quadlet_unit_dir/"
systemctl --user daemon-reload
if ! systemctl --user is-active -q calibre-server.service; then
podman stop calibre-server 2>/dev/null || true
podman rm calibre-server 2>/dev/null || true
fi
systemctl --user restart calibre-server.service
}
deploy_copyparty() {
# The admin account is created from ${COPAY_ACCOUNT}:${COPAY_PASSWORD} expanded
# at ExecStart time; a missing env file must fail the unit, not boot copyparty
@@ -1476,6 +1520,7 @@ deploy_service() {
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
jellyfin) deploy_jellyfin ;;
calibre-web) deploy_calibre_web ;;
calibre-server) deploy_calibre_server ;;
copyparty) deploy_copyparty ;;
plex) deploy_plex ;;
tautulli) deploy_tautulli ;;
+39 -23
View File
@@ -39,33 +39,49 @@ gone; the quadlet unit mounts the Containers data root.)
- Read/write access to the library directory. Calibre-Web edits metadata through the database file.
- Ownership matching `PUID=1000` / `PGID=1000` (the `dank` user on Dankserver).
## Optional: Calibre Content Server (not running)
## Calibre Content Server (running)
Calibre includes a built-in HTTP content server (`calibre-server`) that can serve the library via OPDS feeds and a mobile-friendly web interface. This enables:
- OPDS feeds for mobile reading apps (FBReader, Moon+ Reader)
- Mobile-optimized UI via the `--mobile` flag
- Direct Kindle push
Calibre includes a built-in HTTP content server (`calibre-server`) that serves
the library via OPDS feeds and a web interface:
- OPDS feeds for mobile reading apps (FBReader, Moon+ Reader, KyBook)
- Web UI for browsing, metadata editing, and uploads
- Direct device push (from the GUI, not the content server)
To run as a container:
**Live shape (deployed 2026-09-21):** a rootless Quadlet unit
(`dankserver/calibre-server/quadlet/calibre-server.container`), boot-persistent
and self-healing, on `lscr.io/linuxserver/calibre:9.15.0`.
```yaml
services:
calibre-server:
image: lscr.io/linuxserver/calibre:latest
container_name: calibre-server
restart: unless-stopped
environment:
PUID: "1000"
PGID: "1000"
TZ: Etc/UTC
command: calibre-server --daemonize --port 8080 --enable-auth --url-prefix /calibre /books
volumes:
- /opt/calibre-web/books:/books
ports:
- "8084:8080"
```
The image is the current **desktop** build (Selkies web-streamed GUI), not the
headless image the old compose snippet assumed — the dedicated headless images
(ta264/docker-calibre, agentgreen/calibre-server) are dormant (calibre 6.10 /
2017, both below the 9.9.0 content-server fix). The unit therefore runs
`calibre-server` as the image's s6-overlay main service via `Exec=` and keeps
the desktop as a bonus:
Note: The content server runs headless in `--daemonize` mode. It requires the full Calibre package, not a minimal server.
| Surface | Host port | Auth |
|---------|-----------|------|
| Content server (OPDS + web UI) | **8084** (internal 8090) | HTTP Basic, single admin `calibre` in the SQLite userdb |
| Desktop GUI (bonus) | 8080 (HTTPS on 8182) | HTTP Basic, same credential |
Port notes: 8080 inside the container is the image's own nginx (the desktop),
so the content server runs on internal 8090; `CUSTOM_HTTPS_PORT=8182` avoids
Tautulli's host 8181.
**Auth model (calibre 9.x):** users live in a SQLite userdb whose `pw` column
is **plaintext** — the file is mode 600. The single admin account is
bootstrapped once by the deploy handler
(`deploy_calibre_server` in `scripts/deploy-remote.sh`) via
`calibre-server --manage-users -- add`, the password piped on stdin with
`printf` (a trailing newline from `echo` lands in the stored password and
breaks every login). The desktop's basic auth (`CUSTOM_USER`/`CUSTOM_PASSWORD`)
reuses the same pair. Both live in the machine-local env file
`/home/dank/Containers/calibre-server/env` (mode 600); the repo holds no
secrets. `--auth-mode=basic` is pinned: on a non-SSL port the server defaults
to Digest auth, which rejects plain Basic clients with
`400 Unsupported authentication method`.
LAN-only (no vhost, no public DNS). The library is mounted read-write — the
content server writes metadata, the same library Calibre-Web uses.
## See Also
+26
View File
@@ -80,3 +80,29 @@ load helpers
grep -q '^Environment=PGID=0$' "$unit" || return 1
! grep -qE '^(UserNS|UsernsMode)=' "$unit" || return 1
}
@test "calibre-server unit exists and is boot-persistent" {
unit="$REPO_ROOT/dankserver/calibre-server/quadlet/calibre-server.container"
[ -f "$unit" ] || return 1
grep -q '^WantedBy=default.target$' "$unit" || return 1
grep -q '^Restart=always$' "$unit" || return 1
}
@test "calibre-server serves the content server auth-gated on the published port" {
# The security contract: the content server runs with --enable-auth and
# --auth-mode=basic (Digest is the non-SSL default and rejects Basic
# clients with 400), on the internal port the unit publishes as 8084.
unit="$REPO_ROOT/dankserver/calibre-server/quadlet/calibre-server.container"
grep -q '^Exec=calibre-server --port 8090 --enable-auth --auth-mode=basic --userdb /userdb.sqlite /books$' "$unit" || return 1
grep -q '^PublishPort=8084:8090$' "$unit" || return 1
}
@test "calibre-server consumes host-local credential stores, never in-repo secrets" {
unit="$REPO_ROOT/dankserver/calibre-server/quadlet/calibre-server.container"
grep -q '^EnvironmentFile=/home/dank/Containers/calibre-server/env$' "$unit" || return 1
grep -q '^Volume=/home/dank/Containers/calibre-server/userdb.sqlite:/userdb.sqlite$' "$unit" || return 1
# The userdb's pw column is plaintext: no literal password may appear in
# the tracked unit, and the desktop basic-auth credential comes from the
# env file, not an Environment= line.
! grep -qE '^Environment=CUSTOM_(USER|PASSWORD)=' "$unit" || return 1
}