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 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_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.

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.)

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-file (MP3-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).
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 NoGET / 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, /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/-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.
  • 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