A podcast server you run yourself. It subscribes to your shows, downloads the audio, and serves it to your own players — a web app that installs to a phone's home screen, and an HTTP API.
Most podcast apps are a directory with a player attached: your device fetches each episode straight from the publisher, and Apple's catalogue decides what exists. Podarium inverts that. Three rules shape everything else:
- The server does every fetch. Feeds, audio, and artwork are all pulled by the server and served from it. Publishers see one address, and no client ever makes a request to a host you did not choose.
- Apple is never in the loop. Discovery runs through Podcast Index, never iTunes.
- It is a media server, not a sync service. It holds the files, serves them with range requests, and decides what to keep.
- Subscribe by search, by category, by what is trending, or by pasting a feed URL
- Downloads episodes ahead of time, with retention rules per show or globally
- Queue, inbox with filters, starred episodes, and a resume list
- Playback speed per show, chapter skip, and skipping of publisher-marked ad breaks
- Configurable intro and outro trimming per show, and a sleep timer
- Removes silence and levels loudness on the server, so no client repeats the work
- Fetches transcripts where publishers offer them, and searches your library by what was said rather than only by title
- Bookmarks a timestamp inside an episode, which is the thing hardest to find again
- Listening statistics measured from audio that actually played, not from what was marked as done
- Web push for new episodes, with a count on the home-screen icon
- Offline playback for episodes you save to the device
- OPML in and out, Prometheus metrics, and a nightly database dump
The server and the web app are complete and in daily use. A native iOS client is in progress against the same API. The reasoning behind the design is in podarium-spec.md.
Requires uv and Docker.
docker compose -f docker-compose.dev.yml up -d # Postgres 18 on :5455
uv venv --python 3.12 && uv pip install -e ".[dev]"
cp .env.example .env # then edit the password
uv run alembic upgrade head
uv run uvicorn --app-dir src podarium.main:app --port 8044 --reloadFor the UI, either build it once and let the API serve it at /:
cd web && npm install && npm run buildor run the Vite dev server for hot reload, which proxies /api to port 8044:
cd web && npm run devThe proxy matters for more than convenience: the session cookie is httpOnly, so
<audio src="/api/stream/..."> only carries it on a same-origin request.
The user account is created on first boot from PODARIUM_USERNAME / PODARIUM_PASSWORD,
and only while the users table is empty — restarting will not reset the password.
Interactive API docs: http://localhost:8044/docs
uv run pytest && (cd web && npm test)The Python tests run against the dev Postgres container, creating a podarium_test
database beside it. Coverage is deliberately narrow on both sides: the invariants that are
expensive to get wrong (refresh idempotency, first_seen_at stability, retention keeping
rows, byte-range correctness, Podcast Index signing, show-note sanitising) rather than
every endpoint and component.
scripts/verify.sh walks the whole phase-1 surface with curl — subscribe, refresh, queue,
download, range request, purge, sync — against a running server.
deploy/portainer-stack.yml is pasted into the Portainer web editor on the Docker host; no
compose file lives on the host. Read the comments at the top of it before deploying — the
port, the NFS mount options, and the PGDATA path all have specific gotchas carried over
from PinePods.
The image builds the web UI in its own stage and serves it from /app/web, so there is
one container and one port for both the API and the UI.
Off unless the server has a VAPID keypair. Generate one, paste the three lines it prints into the environment, and restart:
docker exec podarium-api python -m podarium.vapidThe private key prints base64-encoded rather than as a PEM. Both are read, along with a PEM
whose newlines are written as \n — but this value has to cross a Portainer environment
panel, and a multi-line PEM full of newlines and slashes is exactly the shape that arrives
truncated. A mangled key says so on sight rather than surfacing later as a push that never
lands.
Browsers pin the public key at subscribe time, so rotating the private key silently breaks every subscription already issued — every device then has to enable notifications again. Generate a separate pair for local development rather than reusing the server's.
On iOS, web push only works once Podarium has been added to the Home Screen, and only over HTTPS. On the LAN over plain HTTP it will not be offered at all.
Cadence decides whether this feature survives. A show publishing hourly produces a notification every hour around the clock, which buries the weekly show you actually wanted telling about — and the only remaining control is switching notifications off entirely.
Each show therefore has its own toggle, on by default, under Settings on the show's page. Turning it off silences the alert; episodes still arrive, download, and appear in the inbox exactly as before.
Podarium streams from the server by design, so with no network there is nothing to play. Episodes explicitly kept on a device are the exception: the service worker stores them whole and serves byte ranges out of that copy, so they play with the server unreachable.
This needs a secure context, which means the HTTPS hostname rather than the LAN address. Browser storage is also granted rather than guaranteed — iOS in particular reclaims it without warning — so it is a convenience for a journey, not a copy to rely on. Downloading to the device properly is what the native client is for.
vzdump already sweeps pgdata, but a filesystem snapshot of a running Postgres is
crash-consistent rather than consistent: what you would have if the power went out mid-write.
Postgres normally replays its WAL and comes up clean from that, and "normally" is doing real
work in that sentence.
The stack therefore runs a nightly pg_dump into /home/YOUR_USER/docker/podarium/backups,
inside the tree vzdump already picks up, so the backup holds both the raw directory and a
logically consistent dump. The service carries its own script inline — there is nothing to
place on the host, and docker restart podarium-backup forces a dump immediately.
Restore into a scratch database and compare before touching the live one; loading a dump into a populated database conflicts on every table it recreates.
gunzip -c podarium-YYYYMMDD-HHMMSS.sql.gz | docker exec -i podarium-db psql -U podarium -d restore_testWhat is in that database: subscriptions, every playback position, stars, the queue, and API tokens. The audio can be downloaded again; none of that can.
Auth: argon2 passwords, optional TOTP, throttled login, httpOnly session cookie (SameSite=Lax), bearer tokens stored as SHA-256. Publisher HTML is sanitised with an allowlist, and a strict Content-Security-Policy backs the sanitiser up: even markup it missed cannot run script or fetch from a publisher host in a browser that honours CSP.
Everything a publisher controls is bounded. Feed documents, artwork and chapter files are
read with hard size ceilings enforced while the bytes arrive; a single download may not
exceed DOWNLOAD_MAX_BYTES (default 2 GB), so a hostile feed cannot fill the disk. Outbound
fetches refuse literal private and loopback addresses -- once a feed is subscribed, its
enclosure and artwork URLs are the publisher's to choose, and without the guard a malicious
feed could point this server at the router, Portainer, or the NAS. The guard checks literal
IPs and localhost names only; it does not resolve hostnames, so DNS rebinding is out of its
scope. ALLOW_PRIVATE_FETCH=true disables it for a deployment that hosts feeds on its own
network (the dev .env sets it, so local verification can preview its own server).
/metrics is open by default for a same-LAN Prometheus. Through a public hostname, set
METRICS_TOKEN and give the scrape job authorization: Bearer <token> -- or block the
path at Cloudflare.
Two-factor codes are checked against a secret stored encrypted with a key derived from
SECRET_KEY. Change that variable and the secret can no longer be read — sign-in then says
so explicitly rather than pretending the code was wrong.
Either way, the way back in is one statement on the host. It clears the second factor and leaves the password alone:
docker exec podarium-db psql -U podarium -d podarium \
-c "update users set totp_secret = null, totp_last_step = null"The same applies if repeated failures have locked sign-in and you would rather not wait out the window:
docker exec podarium-db psql -U podarium -d podarium -c "delete from login_attempts"Both require access to the host, which is the point: they are recovery for the person who owns the machine, not a bypass reachable from the login form.
Changes to this codebase should preserve these. Each has a test.
- No client response carries a publisher URL. Artwork is
/api/images/..., audio is/api/stream/..., and that holds for search results too — a show you have not subscribed to still has its cover proxied, keyed by a server-minted hash so the endpoint cannot be pointed anywhere else. Show notes are sanitised in the browser as well: an<img>left in a publisher's description would fetch straight from their CDN and leak the viewer's IP. - A show is one subscription however you reach it. Feeds are matched by URL, by resolved URL after redirects, and by Podcast Index id — matching the raw string would let the same podcast be subscribed twice, and two feed rows means two copies of every episode.
- Retention deletes files, never episode rows.
first_seen_at, notpublished_at, decides whether an episode is new.- Feed refresh is idempotent.
- Every user-scoped table carries
user_id, even with exactly one user. - A one-time code is accepted once. Its 30-second window makes a code seen over a shoulder or in a log otherwise replayable.
- Nothing large or churny is written to the VM disk.