Skip to content

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_URL is the one that bites people. Feed and enclosure (audio) URLs are built from it. If it's left at localhost, 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, and storage-mode doesn'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_SIZE bounds the on-demand cache (least-recently-played chapters are evicted first). 0/off keeps everything ever played — which, once every chapter has been played, is the same footprint as full.
  • PODSPINE_CACHE_TTL (optional) also drops chapters not played within the window.
  • Ingest takes the same time in either mode — saver saves 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-mode therefore 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:

PODSPINE_TRANSCODE=aac
  • 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 with libmp3lame (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 under PODSPINE_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 the enclosure length already published in the feed. Budget disk for those books as if saver were 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/.m4b book 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 — set force_reingest = true in its .podspine.toml to 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/.m4a files 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 -2 suffix instead). That id is what a feed URL is built from. A folder of several .mp3 files 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 mixed Author/{loose.m4b, Title/…}, only loose.m4b is found — give each book its own folder.
  • Nested books are named by their folder. Jules Verne/The Mysterious Island/Jules Verne - - The Mysterious Island.m4b is titled The Mysterious Island, not the filename, and gets the slug jules-verne-the-mysterious-island so 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-dir if 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/.flac files in one folder are one book's tracks, the same rule .mp3 folders have always had. With PODSPINE_TRANSCODE on, 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/.m4a files 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, put folder_is_one_book = true in a .podspine.toml inside 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.toml beside Author - Title.m4b (same as a .cue sidecar). 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.toml inside 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 /data on 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
sudo systemctl daemon-reload && sudo systemctl enable --now podspine

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 (then chmod +x), or first-launch via right-click -> Open. Downloading with gh release download avoids 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:

  1. Pass through Range and Accept-Ranges on /audio/* — podcast apps use HTTP Range to seek/scrub; stripping it breaks playback.
  2. Set PODSPINE_BASE_URL to 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:

podspine --library /library --metrics-bind 127.0.0.1:9090
# → http://127.0.0.1:9090/metrics
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:

scrape_configs:
  - job_name: podspine
    static_configs:
      - targets: ["127.0.0.1:9090"]

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/-shm sidecars) and books/. The DB is the source of truth for slugs, capability feed_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). The books/ 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 -wal file'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