Deployment¶
Running Podspine in a homelab. For a first run, the quick start is enough; this page covers the production details: persistence, exposing it safely, and running it as a service.
Podspine has no built-in login. It splits into two surfaces (see Exposing Podspine safely below): the feed/audio/cover routes are protected by an unguessable per-book capability URL and are safe to expose to the internet, while the browse UI enumerates your whole library and must stay on your LAN or behind proxy-auth. See SECURITY.md.
Configuration¶
Every option is a CLI flag, an environment variable, or a TOML key (--config), in
that precedence. The library path is the only required input.
| Flag | Env var | Default | Purpose |
|---|---|---|---|
--library |
PODSPINE_LIBRARY |
— (required) | Folder of audiobooks to scan. |
--data-dir |
PODSPINE_DATA_DIR |
./data |
SQLite index + extracted chapter files + covers (whole-file episodes stream from the library). |
--bind |
PODSPINE_BIND |
0.0.0.0:8080 |
Address to listen on. |
--base-url |
PODSPINE_BASE_URL |
http://localhost:<port> |
External URL used to build feed/audio links. |
--default-cover-url |
PODSPINE_DEFAULT_COVER_URL |
none | Feed-level fallback cover for books with no embedded art. |
--force-embedded-chapters |
PODSPINE_FORCE_EMBEDDED_CHAPTERS |
off | Ignore .cue/.ffmeta sidecars. |
--storage-mode |
PODSPINE_STORAGE_MODE |
full |
full keeps every chapter split on disk; saver still splits once at ingest but caches on demand. See below. |
--cache-size |
PODSPINE_CACHE_SIZE |
2GB |
saver only: cache cap (2GB, 500MB; 0/off = unbounded). |
--cache-ttl |
PODSPINE_CACHE_TTL |
off | saver only: evict chapters unplayed this long (30d, 12h; off = size-only). |
--remux-non-faststart |
PODSPINE_REMUX_NON_FASTSTART |
off | Remux a non-faststart whole-file mp4 to faststart on demand (cache-managed). See below. |
--config |
PODSPINE_CONFIG |
none | Path to a TOML config file. |
PODSPINE_BASE_URLis the one that bites people. Feed and enclosure (audio) URLs are built from it. If it's left atlocalhost, a podcast app on another device can't fetch anything. Set it to the LAN IP / hostname (and scheme + port, or the public URL if behind a proxy) that clients actually reach.
Storage mode: full vs saver¶
Disk use — only chaptered books materialize under
--data-dir. A chaptered container (e.g. one.m4b) is split into per-chapter episodes stored under the data dir (full) or a bounded on-demand cache (saver), so the data dir grows on top of the originals you keep. Whole-file episodes are streamed in place: folder-of-MP3 tracks and chapterless single files play straight from the read-only library — nothing is copied for them, andstorage-modedoesn't apply. So budget extra disk for chaptered libraries; whole-file books cost only their index row and any extracted cover.
By default (full) Podspine pre-splits every chapter to disk at ingest, so a
chaptered book uses roughly twice its source size — the original in your
library plus a full set of per-chapter files under --data-dir — and serves
instantly. Across a chaptered library, budget for --data-dir growing to about
the size of the library itself.
Set PODSPINE_STORAGE_MODE=saver to trade a little latency for disk. In saver
mode Podspine still splits each chapter once at ingest to record its exact size
(the podcast enclosure length must be a real byte count), but then deletes
the file — peak extra disk is one chapter, not a second copy of the book. The
first time a chapter is played it's regenerated on demand (a fast stream-copy,
typically well under a second on an SSD, a few seconds on a slow NAS CPU) and
cached; replays and seeks are then served straight from the cache. The
regenerated bytes are identical to the recorded ones, so seeking and resuming
work exactly as in full mode. Steady-state extra disk is therefore roughly the
cache cap (PODSPINE_CACHE_SIZE) rather than a second copy of every book.
PODSPINE_CACHE_SIZEbounds the on-demand cache (least-recently-played chapters are evicted first).0/offkeeps everything ever played — which, once every chapter has been played, is the same footprint asfull.PODSPINE_CACHE_TTL(optional) also drops chapters not played within the window.- Ingest takes the same time in either mode —
saversaves disk, not ingest time. - Whole-file episodes are served in place — never copied, never evicted. A
folder-of-MP3 book's tracks, and a chapterless single file, are already whole
files (not sub-ranges of a container), so Podspine streams them directly from
the library and records only their source path.
storage-modetherefore has no effect on them; it governs only chaptered containers, which must be extracted per chapter.
saver suits large chaptered libraries on small disks; keep full if disk
is cheap and you want every play to start instantly. Either way you only need to
size --data-dir for your chaptered books — whole-file books add essentially
nothing beyond their index rows and covers.
Faststart for whole-file mp4¶
A whole-file .m4a/.m4b served in place plays fine but can seek slowly if
its moov atom (the index) sits after the audio ("non-faststart"). Podspine
detects this at ingest (a quick header read, no ffmpeg) and logs a one-line
callout naming the book. MP3/OGG/FLAC and chaptered books are never affected (a
chaptered episode is already written moov-first).
Set PODSPINE_REMUX_NON_FASTSTART=true to fix it automatically: such a book is
remuxed to faststart on demand — a stream-copy (no re-encode) served from the
saver cache, evicted and regenerated under PODSPINE_CACHE_SIZE like any cached
chapter, not a pinned second copy. The default is off because in-place
serving costs no disk; enable it if slow seeking on those books bothers you and
you can spare the cache. (A faststart mp4, or any non-mp4, is left untouched.)
Per-book overrides (.podspine.toml)¶
Any of the per-book-meaningful settings above can be overridden for one book
with a small .podspine.toml sidecar — handy for troubleshooting a single
misbehaving book without changing the whole server. Precedence: sidecar → CLI /
env → global config file → default.
Where to put it (works for every library layout):
- A single file — name it after the file:
Author - Title.podspine.tomlbesideAuthor - Title.m4b(same as a.cuesidecar). Works whether the file is at the top level or in its own folder. - A multi-file (MP3-folder) book, or a lone file kept in its own folder — drop
a
.podspine.tomlinside that folder.
Keys you can set:
| Key | Effect |
|---|---|
storage_mode |
"full"/"saver" for just this book (serve + eviction honor it). |
force_embedded_chapters |
Ignore this book's .cue/.ffmeta sidecar. |
remux_non_faststart |
Remux this book to faststart if it's a non-faststart mp4. |
default_cover_url |
Feed cover fallback for this book. |
title / author |
Override the feed title/author (fix metadata without renaming files). |
disabled |
true removes this book from the index and every feed/page. |
force_reingest |
true re-processes the book on every scan (drop it once you're done). |
# Author - Title.podspine.toml — force this one big book onto saver, fix its title
storage_mode = "saver"
title = "The Correct Title"
Server-wide keys (bind, base_url, library, data_dir, cache_size,
cache_ttl, …) are ignored with a log warning if they appear in a per-book
file — they only make sense server-wide. An unparseable sidecar is logged and
skipped for that book; it never aborts the scan.
Exposing Podspine safely¶
Podspine has two kinds of routes, and they want different exposure:
| Surface | Routes | Keyed by | Expose to the internet? |
|---|---|---|---|
| Capability | GET /feed/{id}.xml, /audio/{id}/{n}, /cover/{id}, /healthz |
a random, unguessable per-book feed_id |
Yes — a guessed id just 404s, and feeds carry itunes:block + X-Robots-Tag: noindex so they aren't listed or crawled |
| Browse UI | GET /, /book/{slug}, POST /book/{slug}/regenerate |
the human slug |
No — GET / lists every book, handing out the "unguessable" URLs. Keep it on the LAN or behind proxy-auth |
This is what lets you subscribe to a book and stream it on the road without a VPN: you copy the capability feed URL (or scan its QR) from the book page on your LAN, add it to your podcast app, and only the capability routes ever need to be reachable externally. If a link leaks, hit Regenerate on the book page — the old URL dies.
The reverse-proxy configs below implement exactly this split.
Docker¶
The image bundles ffmpeg, runs as a non-root user (uid 10001), and defaults to
PODSPINE_LIBRARY=/library and PODSPINE_DATA_DIR=/data.
docker run -d --name podspine \
-v /path/to/audiobooks:/library:ro \
-v podspine-data:/data \
-p 8080:8080 \
-e PODSPINE_BASE_URL=http://<your-lan-ip>:8080 \
ghcr.io/schubydoo/podspine:latest
- Mount the library read-only (
:ro) — Podspine only reads it. This is also a security control: whole-file episodes are streamed straight from the library, so a read-only mount prevents a less-trusted process from swapping a file for a symlink to escape the served trust boundary. - Keep
/dataon a named volume (or a host path): it holds the SQLite index and any extracted chapter files, and should persist across restarts and upgrades.
docker-compose¶
services:
podspine:
image: ghcr.io/schubydoo/podspine:latest
restart: unless-stopped
ports:
- "8080:8080"
environment:
PODSPINE_BASE_URL: http://your-lan-ip:8080
volumes:
- /path/to/audiobooks:/library:ro
- podspine-data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
volumes:
podspine-data:
Prebuilt binary + systemd¶
Download the static musl binary for your architecture from the
releases and make sure
ffmpeg/ffprobe are installed. Releases are signed — verify before running:
cosign verify-blob --bundle checksums.txt.sigstore.json checksums.txt then
sha256sum -c checksums.txt (see SECURITY.md).
Example unit:
# /etc/systemd/system/podspine.service
[Unit]
Description=Podspine
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/podspine
Environment=PODSPINE_LIBRARY=/srv/audiobooks
Environment=PODSPINE_DATA_DIR=/var/lib/podspine
Environment=PODSPINE_BIND=127.0.0.1:8080
Environment=PODSPINE_BASE_URL=https://podspine.example.com
DynamicUser=yes
StateDirectory=podspine
ReadOnlyPaths=/srv/audiobooks
Restart=on-failure
[Install]
WantedBy=multi-user.target
macOS & Windows binaries¶
The releases page also carries macOS (Intel + Apple Silicon) and Windows (x64) binaries. Podspine is a self-hosted server — these are for convenience (dev boxes, quick trials); production belongs on the Docker image or the Linux binary above, behind a reverse proxy.
They are not code-signed or notarized (that needs paid Apple/Microsoft
certificates), so the OS warns on first launch. Verify the download the same way
as Linux — cosign-signed checksums.txt + SLSA provenance (see
SECURITY.md) — rather than relying on OS
signing, then clear the warning:
- macOS (Gatekeeper): a browser download is quarantined. Remove it with
xattr -d com.apple.quarantine ./podspine-<version>-darwin-arm64(thenchmod +x), or first-launch via right-click -> Open. Downloading withgh release downloadavoids the quarantine flag entirely. - Windows (SmartScreen): click More info -> Run anyway, or unblock first
with
Unblock-File .\podspine-<version>-windows-amd64.exe.
Build provenance for any asset: gh attestation verify ./<file> --owner schubydoo.
Reverse proxy¶
Front Podspine with a proxy for TLS, and use it to enforce the surface split: expose only the capability routes publicly, keep the browse UI private. Two hard requirements:
- Pass through
RangeandAccept-Rangeson/audio/*— podcast apps use HTTP Range to seek/scrub; stripping it breaks playback. - Set
PODSPINE_BASE_URLto the public URL so generated feed/audio links match.
The configs below publish /feed, /audio, /cover, /healthz and refuse the
browse UI (/, /book/*) — you use the UI over the LAN. (Prefer one authenticated
hostname instead? Drop the @ui/location / blocks and put basicauth/auth_basic
on the whole server — just never require auth on /feed,/audio,/cover, since
podcast apps can't log in.)
Caddy (Range passes through automatically):
podspine.example.com {
# Public: the capability surface only (unguessable per-book URLs).
@capability path /feed/* /audio/* /cover/* /healthz
handle @capability {
reverse_proxy 127.0.0.1:8080
}
# Browse UI + regenerate enumerate the library — not reachable from the public host.
handle {
respond 404
}
}
nginx:
server {
server_name podspine.example.com;
# Public capability surface: feeds, cover, and audio (with Range).
location ~ ^/(feed|audio|cover)/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header Range $http_range; # forward seek requests
proxy_set_header If-Range $http_if_range;
proxy_buffering off; # stream large audio
}
location = /healthz { proxy_pass http://127.0.0.1:8080; }
# Browse UI + management enumerate the library — not exposed here (use the LAN).
location / { return 404; }
}
Health checks¶
GET /healthz returns 200 ok. Use it for container/orchestrator liveness (see the
compose example above) or an uptime monitor.
Data, backups, and updating¶
- Back up
<data_dir>—podspine.db(plus its-wal/-shmsidecars) andbooks/. The DB is the source of truth for slugs, capabilityfeed_ids, episode order, and stable guids. Losing it is disruptive: a rescan mints new capability URLs (every existing feed link breaks and must be re-shared) and new guids (subscribers may re-download). Thebooks/tree can be regenerated by re-scanning the library, but backing it up avoids the re-split. Podspine runs the DB in WAL mode; back it up with the server stopped (or use SQLite's backup API) so the-walfile's contents aren't missed. - Auto-refresh: the library is watched while the server runs — adding, replacing, or removing a book is picked up automatically within a couple of seconds, no restart needed (a removed source is pruned from the index along with its split output). It's also reconciled once at startup.
- Your source library is never modified — Podspine only reads it.
- Updating: pull the new image (or drop in the new binary) and restart. Unchanged books are idempotent (no re-split), so restarts are cheap.
See also¶
- ARCHITECTURE.md — how it works internally.
- importing.md — adding a feed to podcast apps + troubleshooting.
- SECURITY.md — threat model and vulnerability reporting.