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.
cloudflaredis baked into the image; pass a tunnel token and it connects itself. Leave the token unset and it never starts.
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.
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 \
podfeedOr with compose:
PODFEED_USERNAME=me PODFEED_PASSWORD='a-long-password' \
PUBLIC_URL='http://192.168.0.10:8080' \
docker compose up -d --buildSet 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.
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 \
podfeedOr with compose:
PODFEED_USERNAME=me PODFEED_PASSWORD='a-long-password' TUNNEL_TOKEN='eyJhIjoi...' \
PUBLIC_URL='https://pod.example.com' \
docker compose up -d --buildPUBLIC_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.
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.
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.
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.
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.
npm install
PODFEED_USERNAME=me PODFEED_PASSWORD=pw DATA_DIR=./data npm start
npm testNo 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
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'srootcontainment, 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
nodeuser 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
Originis 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 sendOriginon every write whatever the scheme, so this holds on a plain-HTTP LAN address too; theSec-Fetch-Sitelabel 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.
MIT — see LICENSE.