Skip to content

Repository files navigation

podfeed — self-hosted podcast RSS feed

podfeed_screenshots

A small self-hosted webapp for publishing a personal podcast feed. Drop audio files into the browser, and subscribe to the result from Apple Podcasts (or any other podcast app) — over your LAN, or over a Cloudflare tunnel when the feed has to be reachable from outside it.

  • Drag and drop audio anywhere on the page to open an Add new Episode modal. Submit it untouched and the episode is named after the file.
  • Preview, download, edit and delete episodes from a single mobile-friendly list.
  • An optional username and password protect the web UI and the feed.
  • The feed URL carries a token so podcast apps that ignore credentials in a URL can still subscribe.
  • Uploads are chunked, so episodes larger than Cloudflare's ~100MB request body cap still make it through the tunnel.
  • cloudflared is baked into the image; pass a tunnel token and it connects itself. Leave the token unset and it never starts.

Quick start

Build the image once:

docker build -t podfeed .

Then pick how the feed is served. Either way the app listens on port 8080 and is reachable on your LAN — the tunnel is an extra route in, not a replacement for the port.

On your LAN, without a tunnel

TUNNEL_TOKEN is optional. Leave it out and cloudflared never starts; the container runs the server and nothing else.

docker run -d --name podfeed \
  -p 8080:8080 \
  -v podfeed-data:/data \
  -e PODFEED_USERNAME=me \
  -e PODFEED_PASSWORD='a-long-password' \
  -e PUBLIC_URL='http://192.168.0.10:8080' \
  --restart unless-stopped \
  podfeed

Or with compose:

PODFEED_USERNAME=me PODFEED_PASSWORD='a-long-password' \
PUBLIC_URL='http://192.168.0.10:8080' \
  docker compose up -d --build

Set PUBLIC_URL to the address the podcast app will use, with the host's own LAN address or hostname. Without it the origin in the feed and in every enclosure URL comes from whichever Host the client sent, so a feed you fetch on localhost publishes localhost URLs that your phone cannot resolve.

This is plain HTTP, and Basic auth sends the credentials on every request, so keep it to a network you trust — see Notes on security.

Through a Cloudflare tunnel

Pass a tunnel token and cloudflared starts alongside the server and connects itself, which is what puts the feed on HTTPS and makes it reachable from off the LAN.

docker run -d --name podfeed \
  -p 8080:8080 \
  -v podfeed-data:/data \
  -e PODFEED_USERNAME=me \
  -e PODFEED_PASSWORD='a-long-password' \
  -e TUNNEL_TOKEN='eyJhIjoi...' \
  -e PUBLIC_URL='https://pod.example.com' \
  --restart unless-stopped \
  podfeed

Or with compose:

PODFEED_USERNAME=me PODFEED_PASSWORD='a-long-password' TUNNEL_TOKEN='eyJhIjoi...' \
PUBLIC_URL='https://pod.example.com' \
  docker compose up -d --build

PUBLIC_URL is the tunnel's public hostname here. The tunnel should point at http://localhost:8080 on this host.

Open http://<host>:8080, log in, and drop an audio file onto the page.

Configuration

PODFEED_USERNAME, PODFEED_PASSWORD and TUNNEL_TOKEN are all optional.

Variable Default Purpose
PODFEED_USERNAME (unset) Username for the UI and the feed
PODFEED_PASSWORD (unset) Password for the UI and the feed
TUNNEL_TOKEN (unset) Cloudflare tunnel token; when set, cloudflared starts alongside the server
PUBLIC_URL derived from the request Force the origin used in feed and enclosure URLs
ALLOWED_HOSTS (unset) Comma-separated hostnames allowed to build feed URLs when PUBLIC_URL is not set
TRUST_PROXY loopback What Express believes about X-Forwarded-*. false, true, a hop count, or a subnet list
PORT 8080 Port the server listens on
DATA_DIR /data Where audio, artwork and metadata live
MAX_UPLOAD_MB 2048 Largest single episode accepted
CHUNK_SIZE_MB 20 Size of each upload chunk

Auth is only enabled when PODFEED_USERNAME and PODFEED_PASSWORD are both set. With either missing the server starts wide open and logs a warning — fine on a private LAN, not fine behind a public tunnel.

cloudflared runs inside the container and connects over loopback, so only X-Forwarded-* headers arriving from 127.0.0.1 are believed. A request straight to port 8080 on your LAN cannot name its own client IP and walk past the login throttle. Put the app behind a reverse proxy on another host and you will need TRUST_PROXY set to match that hop count or subnet.

The Host header still comes from the client, and whatever it says is published in the feed alongside ?token=. A Host that does not look like a hostname is rejected outright; set PUBLIC_URL to settle the origin for good, which is what you want on any deployment that publishes a stable feed URL. ALLOWED_HOSTS is the middle option when the origin has to stay dynamic.

Subscribing from Apple Podcasts

The feed always carries artwork. With nothing uploaded it publishes the PodFeed logo that ships with the app, so a show never subscribes as a blank tile — but that is a placeholder, and your own art is one upload away under Settings → Upload art.

Artwork resolves in three steps, for the channel and for every episode: the episode's own embedded art, then the channel cover, then the built-in default.

JPEG or PNG only. The format is read out of the file itself, not out of what your browser labels it, and anything else — WebP included — is refused with a reason. Square, and between 1400×1400 and 3000×3000: that range is the common baseline across Apple Podcasts, Spotify and the Podcast Standards Project's PSP-1, so art that satisfies it is safe everywhere — and under 8MB, which a photographic PNG at the top of that range can exceed, so reach for JPEG there. Art outside it is still accepted, but the settings dialog says what is wrong with it and the server repeats that at startup — those rules are documented and enforced when a show is submitted to a directory, but this feed is private and added by URL, so whether a given app applies them when fetching artwork directly is anyone's guess.

Podcast apps also cache artwork per feed URL, hard. If you replace a cover and nothing changes, Regenerate token gives the feed a new URL, which is the reliable way to force a re-fetch. Whatever hosts the feed has to answer HEAD as well as GET on the artwork URL; the built-in /covers/ route does.

Artwork is served with a week of max-age, which is safe because a cover's filename always identifies its bytes: uploads embed a unique id, and the bundled default is served under a name carrying a hash of its content, so replacing the shipped artwork in a later release changes the URL and misses every cache.

Open Settings → Podcast settings in the UI and copy the feed URL, or use the Copy feed URL button in the header. It looks like:

https://pod.example.com/feed.xml?token=<64 hex characters>

In Apple Podcasts: Library → (⋯) → Add a Show by URL, and paste that URL.

The token exists because Apple Podcasts on iOS does not reliably honour https://user:pass@host/feed.xml. The feed also accepts ordinary HTTP Basic auth, so clients that do handle credentials properly can use either. Every enclosure URL inside the feed carries the same token, so audio downloads never hit a second auth challenge.

Regenerate token in the settings dialog rotates it and immediately invalidates the old URL — use it if a feed URL leaks. You will need to resubscribe afterwards.

Uploading large episodes

The browser slices each file into 20MB chunks, uploads them one at a time with a retry, and the server reassembles them. This is what keeps a full-length episode under Cloudflare's ~100MB per-request body limit.

That said, uploading a 500MB file through the tunnel is still slow. If the machine is on your LAN, upload over http://<host>:8080 directly and let the tunnel serve only the feed and downloads.

Data layout

Everything lives under DATA_DIR, so back up (or mount) that one directory:

/data
  data.json          podcast settings, feed token, episode records
  media/<id>.<ext>   the original audio, unmodified
  covers/<id>.<ext>  channel and episode artwork
  tmp/<uploadId>/    in-flight upload chunks (swept after 24h)

Audio files are stored exactly as uploaded — nothing is re-encoded. Duration and any embedded cover art are read out of the file when it lands, so an episode's artwork comes from its ID3 tags when one is present. Embedded art that is not JPEG or PNG is skipped rather than stored, which leaves the episode falling back to the channel cover — or to the built-in default — instead of showing art no app can draw.

Supported: .mp3, .m4a, .m4b, .mp4, .aac, .ogg, .oga, .opus, .wav, .flac. Apple Podcasts is happiest with MP3 and M4A.

Development

npm install
PODFEED_USERNAME=me PODFEED_PASSWORD=pw DATA_DIR=./data npm start
npm test

No build step and no bundler — public/ is served as-is.

src/
  server.js        express wiring and boot
  config.js        environment
  auth.js          basic auth + feed token, timing-safe, throttled
  store.js         atomic JSON persistence
  feed.js          RSS / iTunes XML
  images.js        image header sniffing and the cover-art rules
  default-cover.js the bundled fallback artwork and its content-hashed name
  uploads.js       chunked upload sessions and metadata extraction
  routes/api.js    episodes, settings, uploads
  routes/media.js  audio and artwork, with Range support
public/            index.html, app.js, styles.css, default-cover.png

Notes on security

This is a private feed, so it is built to stay private:

  • Credentials are compared with a timing-safe digest comparison, and repeated failures from one IP are throttled.
  • The feed advertises <itunes:block>Yes</itunes:block> so directories that encounter it will not list it.
  • Media filenames are generated server-side, matched against a known episode or cover, and read through sendFile's root containment, so a request cannot escape the media directory even if a future route forgets to look the name up.
  • Content types are derived from the server-side filename, never from what the uploading client declared, and everything is served with nosniff. Artwork goes further and takes its stored extension from the image header itself, so a file cannot be handed out under a type its bytes do not match.
  • Chunked uploads are bounded by the size the session declared, which is itself checked against MAX_UPLOAD_MB, so an open session cannot fill the disk.
  • The container drops from root to the unprivileged node user once the data volume's ownership is fixed.
  • A browser sends your cached Basic credentials to this origin no matter which page asked, so a request whose Origin is not the host you reached the app on is refused. Without that, a page you happened to open could rotate the feed token and unsubscribe every listener. Browsers send Origin on every write whatever the scheme, so this holds on a plain-HTTP LAN address too; the Sec-Fetch-Site label is checked as a second layer where it is available. Clients that are not browsers — podcast apps, curl, the container healthcheck — send neither header and are unaffected.
  • The UI refuses to be framed, so the check above cannot be sidestepped by embedding the app and letting you click through it.
  • A failed login only counts against the rate limit when credentials were actually offered. Otherwise a page elsewhere could spend your whole budget on requests it forced your browser to make, and lock you out of your own server.

Basic auth sends credentials on every request, so serve this over HTTPS — which is exactly what the Cloudflare tunnel gives you. Reaching it over plain HTTP on your LAN is fine; exposing port 8080 to the internet directly is not.

License

MIT — see LICENSE.

About

Self-hosted podcast RSS feed generator — drag-and-drop audio, a private feed for Apple Podcasts, Docker with Cloudflare Tunnel built in.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages