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/subscribe 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. |
--transcode |
PODSPINE_TRANSCODE |
off |
Re-encode sources podcatchers can't play (FLAC/Ogg/Opus/ALAC) to aac or mp3. MP3/AAC sources are never re-encoded. See below. |
--metrics-bind |
PODSPINE_METRICS_BIND |
off | Serve Prometheus metrics on this separate address (127.0.0.1:9090). See below. |
--log-level |
PODSPINE_LOG_LEVEL |
info |
Log verbosity: a level (error, warn, info, debug, trace) or a per-module directive (podspine_scanner=debug). RUST_LOG overrides it when set. An unrecognized value falls back to info with a warning. |
--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.
Which version is running?¶
The running version shows in the footer of every web page, for example Podspine v1.7.2.
Read it to see which build a deployment serves. This matters most with the :nightly
image, whose tag does not change between releases.
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.)
Transcoding oddball formats (PODSPINE_TRANSCODE)¶
Podspine is copy-first: every episode is a stream copy of the source, so no
quality is lost and a split costs almost no CPU. That works because MP3 and AAC
(.mp3, .m4a/.m4b) play in every podcast app. FLAC, Ogg Vorbis, Opus and ALAC
do not — Apple Podcasts and Overcast, among others, simply refuse them.
Set PODSPINE_TRANSCODE=aac (or mp3) to re-encode those sources at ingest:
off(default) — nothing is ever re-encoded; a FLAC book is served as FLAC.aac— non-podcast-safe sources become AAC 128 kbps in.m4a. The right choice for almost everyone: it's what M4B audiobooks already are.mp3— MP3 128 kbps instead, for a client that still chokes on AAC. Needs an ffmpeg built withlibmp3lame(the bundled Docker image has one).
Things worth knowing before you turn it on:
- MP3 and AAC sources are never touched, whatever this is set to — they already play everywhere, and re-encoding them would only lose quality.
- Ingest gets much slower for the affected books: a re-encode is CPU-bound where a stream copy is not. Expect minutes per book rather than seconds, more on a Raspberry Pi. It happens once, at ingest, under the same concurrency limit as every other ffmpeg job.
- A transcoded book is always stored
full, even underPODSPINE_STORAGE_MODE=saver, and it is never evicted from the cache. A re-encode is not reproducible byte-for-byte across ffmpeg versions, so a regenerated chapter could no longer match theenclosure lengthalready published in the feed. Budget disk for those books as ifsaverwere off. - A chapterless FLAC/Ogg book stops being served in place for the same
reason — the bytes clients receive are the re-encoded ones, which live under
--data-dir. - Flipping the setting re-ingests the affected books on the next scan (the
container and every byte length change), with no change to episode GUIDs, so
subscribers don't re-download the rest of the feed. The episodes in the previous
container are deleted as part of that re-ingest, so switching back and forth
doesn't pile up copies under
--data-dir. An.m4a/.m4bbook is the one exception: Podspine can't tell AAC from ALAC without re-probing, so an ALAC book keeps its existing episodes until its file changes — setforce_reingest = truein its.podspine.tomlto pick the new setting up immediately.
Prefer converting the files yourself if you care about the encoder settings; this flag exists so a library you'd rather not touch still plays.
Library layout¶
Point --library at the top of your collection. Podspine walks it and treats the
first folder that contains audio as a book, so all of these work side by side:
library/
├── Standalone Book.m4b # a loose file
├── Another Book/book.m4b # a book in its own folder
├── Jules Verne/
│ ├── The Mysterious Island/verne - island.m4b # author → title
│ └── Journey to the Centre of the Earth/jttcote.m4b
└── Homer/
└── The Epic Cycle/ # author → series → title
└── 1 - The Odyssey/odyssey.m4b
- A folder holding several
.m4b/.m4afiles is several books — one file is one whole audiobook. A book already in the index keeps its slug: a newly discovered file never takes an id that another book, whose file is still there, already holds (it gets a-2suffix instead). That id is what a feed URL is built from. A folder of several.mp3files is the opposite: one book, its tracks in order. - A book folder is not descended into. The first level with audio wins, so a
book's own
extras/folder is never ingested as another book. The trade: in a mixedAuthor/{loose.m4b, Title/…}, onlyloose.m4bis found — give each book its own folder. - Nested books are named by their folder.
Jules Verne/The Mysterious Island/Jules Verne - - The Mysterious Island.m4bis titled The Mysterious Island, not the filename, and gets the slugjules-verne-the-mysterious-islandso two authors can both have a Dracula. Books at the top level (or one folder below it) keep the names they already had — their slug is their id, and changing it would rotate a book's capability feed URL out from under anyone subscribed to it. - Skipped while walking: dot-directories,
@eaDir,lost+found, and the--data-dirif you put it inside the library. Nesting deeper than 8 levels is logged and not descended into. - Symlinks are followed only within the library. A link pointing at audio elsewhere on the host is logged and skipped, because Podspine refuses to serve an episode whose file resolves outside the library root — indexing it would publish a feed whose audio 404s. A link that stays inside the library is fine, and the same book reached two ways is indexed once.
- Several
.ogg/.opus/.flacfiles in one folder are one book's tracks, the same rule.mp3folders have always had. WithPODSPINE_TRANSCODEon, each such track is re-encoded once at ingest, because most podcast apps do not play those formats; with it off they are served as they are. A folder can hold both kinds: an MP3 track streams in place beside a re-encoded FLAC one. - A folder of several
.m4b/.m4afiles stays several books. That is what an author folder usually is, and each file keeps its own feed URL. If the folder is really one book split by disc, putfolder_is_one_book = truein a.podspine.tomlinside it. Flipping that key retires the books of the old shape, with their feed URLs, so one folder is one reading of its audio and never two. A book is retired only once the new shape is indexed and serving every one of its episodes, so a scan that fails leaves the old books playing.
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-track 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). |
folder_is_one_book |
true makes every audio file in this folder one book's tracks, whatever their extension. |
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"
Editing a .podspine.toml starts a reconcile on its own, like any other library
change, so an edit takes effect within a couple of seconds.
Server-wide keys (bind, base_url, library, data_dir, cache_size,
cache_ttl, transcode, …) are ignored with a log warning if they appear in a
per-book file — they only make sense server-wide. (transcode is server-wide for
now: it is decided from the source codec, so a per-book key would buy little.) An unparsable 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}, /cover/{id}/thumb, /subscribe/{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. /subscribe must be public in a split deployment: the QR on the (LAN-only) book page points your phone at base_url/subscribe/{feed_id}, and it exposes only that one book's links |
| Browse UI | GET /, /book/{slug}, POST /book/{slug}/regenerate, POST /theme/{mode} |
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, /subscribe, /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,
/subscribe, since podcast apps (and a phone that just scanned a QR) 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/* /subscribe/* /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, audio (with Range), and subscribe.
location ~ ^/(feed|audio|cover|subscribe)/ {
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; }
}
Permissions-Policy warnings in the browser console¶
Seeing Error with Permissions-Policy header: Unrecognized feature: … on the browse
page? Podspine sends no Permissions-Policy header at all. The header comes from
whatever fronts it (a reverse proxy, a tunnel, a CDN), and the warning means that
header names a browser feature your browser does not know. It affects nothing
Podspine serves.
Confirm where it comes from, and fix it there:
curl -sI http://127.0.0.1:8080/ | grep -i permissions-policy # direct: no such header
curl -sI https://podspine.example.com/ | grep -i permissions-policy # through the proxy
Health checks¶
GET /healthz returns 200 ok. Use it for container/orchestrator liveness (see the
compose example above) or an uptime monitor.
Metrics (Prometheus)¶
Off by default. Give it an address and Podspine serves GET /metrics in the
Prometheus text format:
| Metric | Type | Meaning |
|---|---|---|
podspine_books_indexed |
gauge | Books currently in the index (re-read after each reconcile, so prunes show up too). |
podspine_feeds_served_total |
counter | Feeds rendered and passed the self-check before being sent. |
podspine_split_duration_seconds |
histogram | Wall-clock per chapter split, successful splits only. |
podspine_errors_total{kind} |
counter | Request failures; kind is not_found, forbidden, or internal. |
This is deliberately a second listener, not a route on --bind. Feed URLs are
unguessable capability URLs and safe to expose; metrics are operator data — how big
your library is, how often things fail — and don't belong on an internet-facing
surface. Bind it to loopback (and let Prometheus scrape over the host network) or to
a LAN-only interface. Podspine refuses to start if --metrics-bind matches --bind.
In Docker, publish the port explicitly (-p 127.0.0.1:9090:9090) and bind inside the
container to 0.0.0.0:9090. A minimal scrape config:
Nothing about listeners, titles, or paths is labelled — the only label in the whole
set is kind on the error counter, so series count stays flat no matter how large
the library or how much traffic it takes.
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. A source that went missing under a folder
that is now unreadable, or that holds nothing but housekeeping files (
.DS_Store,@eaDir,lost+found), is kept instead of pruned, so an unmounted share does not delete those books or their feed URLs — the cost is that a book you delete from a folder that is now empty stays listed until you remove that folder. - 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.