How to build a podcast host
How to build a podcast host publishes one show as a feed directories can read, and keeps each episode GUID the same after the first publish.
How to build a podcast host is one show, episode files, and an RSS feed that stays valid. Transistor is that product. The catalog line is “One valid RSS feed. The directories do the rest.”
The card lists $19–$99 a month. That is $228–$1,188 a year. The verdict is “The feed is the product”. The first estimate is 8 hours. The project name on the card is Podshed.
A reader on the other side of that feed is the RSS reader guide. Feedly is $7–$12 a month, about 8 hours. A site that publishes posts and a feed is the Ghost guide and the Ghost post. Ghost Pro is $9–$35 a month. A video file on a player page is the Vimeo guide. This one is audio, and the feed is the thing a directory fetches.
The sample show is Bench notes. The owner email on the fixture is [email protected]. The category is Technology. The first episode is The first file. You will upload a real audio file for that title. The checks use whatever duration and byte size that file actually has.
How to build a podcast host from one file
The Transistor page splits the work into three rows.
The first row is 8 hours. Episode uploads, a valid RSS feed, and episode pages. You create the show, upload a file, and the feed lists that episode.
The second row is 3 days, 24 hours. Scheduled publishing, private feeds, transcripts, and richer analytics. A future publish time can wait until a live episode already validates.
The third row is a month and up, 160 hours. IAB-certified analytics, dynamic ad insertion, multiple shows, and a site builder.
If you tell an agent to “clone Transistor”, it aims at ads and a second show. Paste the prompts below and keep Bench notes on the first row.
What the 8-hour build does
You set the show title, the description, the owner email, the language en, the category Technology, and an explicit flag. You upload square artwork. Apple’s cover notes accept JPEG or PNG from 1400×1400 through 3000×3000, in RGB, with no alpha channel. The same RSS guide also asks for 72 dpi. This upload check enforces size, format, RGB, and alpha. A 1399-pixel square is rejected on this screen, with that size in the message, and the same for a file with an alpha channel. The feed gets a new image URL when the art changes, because Apple’s artwork troubleshooting says a reused URL can keep the old image.
You upload The first file as MP3 or M4A. The row is a draft. You add chapters as seconds and titles. Publish runs the feed through checks and, if they pass, writes a GUID and an enclosure URL that the editor can never change again. The public episode page plays the file. A podcast app can request ranges. Forty range requests from one client on one UTC day count as one download. A title typo fixed the next day changes the feed item and leaves the GUID byte for byte as it was.
You mark Apple and Spotify as submitted when you have done that yourself. You can paste each directory’s show URL. The app does not call their APIs.
What it leaves out
No publish-at time, no private feed, no transcript. Those are the 24-hour row. No IAB report, no stitched ads, no second show, no site builder. The same v1 also replaces Buzzsprout and Captivate if what you wanted was one valid feed. The card names them and does not give them prices.
Where the feed comes from
One podcaster, one show. A control that would create a second show is absent, and a posted extra show is refused. Node.js and Astro render settings, the episode list, and the public page. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. The show, episodes, chapters, checklist rows, and daily counters live in D1. Audio, artwork, and the chapter JSON live in R2.
Each fetch renders the feed from those rows, so a title fix shows up on the next request. Drafts are left out. Published episodes are included in publish order.
If D1 is down, the feed responds with 503. An empty feed would tell every app that Bench notes has no episodes. A storage error on one audio GET leaves the item in the feed. Apps keep the enclosure URL. The player shows a failure, and you fix storage.
Publish is the gate. The server builds the feed it would serve, then checks it. Well-formed XML. The show has title, description, language, category, explicit, owner email, and artwork. Apple’s RSS guide caps the show description at 4000 bytes, so this build refuses a longer one. Each published item has a title, a description, a GUID, an enclosure URL, a byte length, a MIME type, a duration, and a pubDate in RFC 822. The enclosure length equals the object size. Titles and notes are escaped, and notes that contain markup go in a CDATA section, so an ampersand in the title stays inside the document. A failure names the field. The episode stays a draft, or, if it was already published, the previous title and notes stay in the feed.
Artwork checks use Apple’s published range. Square JPEG or PNG, 1400 to 3000 pixels on a side, RGB, no alpha. The page for that is Show cover, and the tag rules are in A Podcaster’s Guide to RSS. The category picker is Apple’s category list, stored as that string. Technology is the fixture. Language is an ISO 639 code. en is the one Bench notes uses. Explicit is true or false.
The GUID is its own value, assigned at publish, separate from the slug, the title, and the file URL. The enclosure URL is assigned then too, and it does not contain the title, so a rename cannot move it. There is no edit control for either field. Replacing the audio file is allowed while the episode is a draft. After publish, the file stays. If you ever have to move storage, the old enclosure URL redirects. Unpublish removes the item from the feed and keeps the GUID, the enclosure URL, and the first pubDate. Republish puts that same item back.
Chapters are seconds plus a title, and an optional URL. The editor accepts hh:mm:ss or mm:ss and stores seconds. They are sorted on save. Two chapters at the same second are refused. A chapter past the duration is refused. If the first chapter does not start at 0, you get a warning and publish is still allowed. At publish, and whenever chapters change later, you write a JSON file and point podcast:chapters at it. That edit does not touch the GUID or the enclosure URL. The public page lists the chapters, and choosing one seeks the player to that second.
Audio is audio/mpeg or audio/mp4. Other types are refused at upload. The response supports range requests: 206, a Content-Range header, and a HEAD that returns the length. HEAD does not count as a download. A range that only asks for the first kilobytes does not count. A full GET, or a range that asks for more than that opening slice, counts once per file per client per UTC day. The client key is a hash of the user agent plus a truncated address, computed in memory. For IPv4, drop the last octet before hashing. For IPv6, keep the first 48 bits and drop the rest before hashing. Store the day’s integer. The raw address and the raw user agent are not written. Known crawler user agents are ignored. Forty chunks from one app on one day register one download. That is the check the product prompt already names.
The directory checklist is two rows, Apple and Spotify. Each is not submitted or submitted. The owner pastes the public show URL after a directory accepts the feed. Those URLs appear on the episode page as subscribe links, next to the feed URL.
What an episode actually stores
The card lists Show, Episode, Chapter, and DownloadEvent. All four exist in these 8 hours. DownloadEvent is the daily counter, one row per episode per UTC date, with an integer.
A show is Bench notes. One row. Title, description, owner email, language, category, explicit, artwork key, and the artwork’s public URL path. Updating artwork writes a new object key so the feed URL changes. The feed URL itself is stable.
An episode is The first file. Draft, then published. Title, notes, duration, byte size, MIME type, audio key, GUID, enclosure URL, pubDate, and a published flag. GUID, enclosure URL, and pubDate are null in draft and filled in one write at publish. Unpublish clears the flag and leaves those three fields. A later publish does not mint new ones.
A chapter belongs to one episode. Start seconds, title, optional URL, position. Editing chapters rewrites the JSON object and the podcast:chapters URL can stay if the path is stable, or change. Either way the enclosure URL is untouched. The public player seeks to the stored second.
A checklist row is Apple or Spotify, a status, and an optional show URL the owner pasted.
A download counter is episode id, UTC date, and an integer. The hash used to decide “same client” is not stored.
The screens you ship
Show settings, the episode list, the public episode page, and a small dashboard. The transcript and the private feed wait.
Show settings
Title, description with a byte counter toward 4000, artwork upload, category, language, explicit, owner email, and the feed URL with a copy button. A second-show button is not on this screen. Artwork errors name the requirement: square, 1400 to 3000, JPEG or PNG, RGB, no alpha. Saving a new image changes the URL in the feed preview.
Episodes
A list of drafts and published episodes with duration, file size, and the day’s download total. New episode opens an upload for MP3 or M4A, then title, notes, and chapters. Publish is disabled until duration and byte size are both known. The error from a refused publish names the field, and the row stays a draft. After publish, GUID and enclosure URL show up as plain text. Unpublish is a separate button.
Episode page
The player, the notes, the chapter list, the publish date, the feed URL, and any directory URLs you pasted. Choosing “The feed” seeks to 90 seconds. A draft has no public page. Unknown slugs and unpublished episodes use one not-available page.
Dashboard
Per episode, a list of UTC dates and integers, including today. The last validation result is a pass or a field name. The checklist shows Apple and Spotify, each not submitted or submitted, with a field for the directory URL. This is not a chart library and not an IAB report.
Screens that wait
A scheduled episode stores a UTC time and stays out of the feed until that time, then uses the same publish checks. A private feed is a second URL with a token, listing the same GUIDs or a subset, and revoke returns 404 for that URL only. The public feed stays. A transcript is text on the episode page and a link in the feed, edited without touching the GUID. Richer analytics can chart the integers you already store. None of these add a listener table.
From a draft to one download
You fill in Bench notes and upload a 1400-pixel JPEG with no alpha. A 1399-pixel file is rejected and the previous art, if any, stays. You upload The first file. You add a chapter at 0 titled Cold open and a chapter at 90 seconds titled The feed, and only if the duration is greater than 90. You add a chapter one second past the duration. That one is refused. You put an ampersand in the title. Publish succeeds. The feed shows the title, the GUID, the enclosure length equal to the object size, and the chapter reference.
You copy the GUID text. You change the title. You publish the edit, which is just a save now. The GUID text matches the copy. The enclosure URL matches too.
You fetch the audio with a HEAD. The counter stays 0. You request the file in many ranges from one client. The counter for today’s UTC date is 1. A second client, or the next UTC day, can add another. You unpublish. The feed no longer lists the item, and the stored GUID is still the one you copied. You publish again. The feed item is back, with that GUID and the original pubDate.
You point the database at a closed port in a dev build. The feed URL returns 503. An empty channel would tell apps the show has no episodes, so that response stays an error.
Prompt 1. One show and artwork that matches the directory
Copy the Astro prompt from Build your own Transistor. Paste this above it.
Ship the 8-hour row only. Project name: Podshed.
Node.js and Astro. The show, episodes, chapters,
checklist rows, and daily counters live in D1.
Audio, artwork, and chapter JSON live in R2.
TanStack Start is the same screens and the same rules,
with server functions, a SQL database, and object storage.
One show. Refuse a second show.
Fields: title, description, owner email, language,
category, explicit, artwork.
Description max 4000 bytes.
Language is an ISO 639 code. The fixture uses en.
Category is one entry from Apple's category list.
The fixture uses Technology.
Explicit is true or false.
Artwork must be square JPEG or PNG, 1400 to 3000
pixels on a side, RGB, no alpha channel.
Reject anything else at upload and name the rule.
Replacing artwork stores a new object key so the
feed URL of the image changes.
The feed URL of the show stays stable.
The sample show title is Bench notes.
Owner email on the fixture: [email protected].
No IAB report, no ads, no site builder, no transcript,
no future publish time, no private feed.
When the plan lists routes and tables, stop.
What to check after the show
Save Bench notes with a short description. The feed URL copies. Upload a square JPEG at 1400 pixels and confirm it stores. Upload a 1399-pixel image and an image with an alpha channel. Both are rejected, and the 1400-pixel art is still the one in the row. Try to create a second show. It is refused. A logged-out window does not see settings. The description field rejects a string over 4000 bytes.
Prompt 2. A draft, chapters, and a frozen identity
Add episodes and chapters on Podshed.
Accept audio/mpeg and audio/mp4 only.
Store the object byte size and the duration the browser
reports. Publish stays disabled until both are known.
A draft has no public page and is absent from the feed.
Replacing the audio is allowed only while the episode
is a draft.
Chapters: start seconds and title, optional URL.
Accept hh:mm:ss or mm:ss in the editor and store seconds.
Sort by start. Refuse two chapters at the same second.
Refuse a start past the duration.
If the first chapter does not start at 0, warn and allow it.
Write chapter JSON when the episode is published
and whenever chapters change after that.
The fixture episode title is The first file.
When duration is over 90 seconds, chapters at 0
(Cold open) and 90 (The feed) are allowed.
A chapter at duration plus one second is refused.
On first successful publish, set GUID, enclosure URL,
and pubDate. The GUID is not the title, the slug,
or the file URL. The enclosure URL does not contain
the title. No control can edit those three fields.
Unpublish removes the item from the feed and keeps them.
Publishing again reuses them, including the original pubDate.
Done when a title edit leaves the GUID and enclosure URL
byte-identical, and the admin UI has no input for either.
What to check after the episode
Upload an MP3 longer than 90 seconds if you have one. If your file is shorter, use a chapter inside the real duration and a chapter past it. The late chapter is refused. Publish. Copy the GUID and the enclosure URL. Change the title to include an ampersand. Save. The copied GUID still matches, and the enclosure URL still matches. The file replace control is gone. Unpublish, confirm the feed omits the item, publish again, and confirm the same GUID and the same pubDate.
Prompt 3. A feed that fails closed
Add the RSS feed on Podshed.
Render RSS 2.0 on each request from the rows.
Include the iTunes podcast namespace and the
podcast namespace for chapters.
Do not store the XML as the source of truth.
Drafts are omitted.
If the database read fails, respond 503.
Do not respond with an empty channel.
Before publish, and before saving an edit to a
published episode, build the feed and validate it.
Required show fields present. Description within
4000 bytes. Artwork still valid.
Each item has title, description, GUID, enclosure
URL, length, MIME type, duration, and an RFC 822 pubDate.
Enclosure length equals the object byte size.
Escape text. Wrap notes that contain markup in CDATA.
A failure names the field and does not change what
the live feed serves.
A published episode that fails a later edit keeps
its previous title and notes.
podcast:chapters points at the JSON file.
Editing chapters does not change GUID or enclosure URL.
Done when the fixture feed contains The first file,
the enclosure length matches the object,
and a deliberately broken edit does not change the live item.
What to check after the feed
Open the feed URL. Bench notes is the channel. The first file is an item. The enclosure length matches a HEAD of the audio. The GUID matches what the editor shows. Force a bad save, such as clearing the show title, and confirm the live feed still has the old title. Stop the database and request the feed. The status is 503 and the body is not a channel with zero items.
Prompt 4. Ranges, one daily count, and the checklist
Add playback counts and the directory checklist on Podshed.
Serve audio with byte ranges. 206 and Content-Range
for a range request. HEAD returns the length.
The public episode page player can seek, and a chapter
click seeks to that second.
Count a download once per file per client per UTC day.
Derive the client key in memory from the user agent
plus a truncated IP. Drop the last IPv4 octet.
For IPv6, keep the first 48 bits and drop the rest.
Store only the counter. Never store the raw IP
or the raw user agent.
Ignore HEAD. Ignore a range that only fetches
the first kilobytes. Ignore crawler user agents
you keep on a short list.
Forty range requests from one client on one UTC day
increment the counter by one.
The owner dashboard lists dates and integers per episode.
It shows the last validation result.
The checklist has Apple and Spotify, each
not submitted or submitted, plus an optional URL
the owner pastes. Those URLs show on the episode page
next to the feed URL.
Do not call directory APIs.
Done when forty ranges count as one, a HEAD counts
as zero, and the counter table has no listener column.
What to check after the count
Play the episode and seek using the chapter. The player lands on that second. Send a HEAD. Today’s count stays put. Send a run of range requests that cover the file from one client. Today’s UTC count is 1. Repeat them. Still 1. Query the counter table and confirm the columns are episode id, date, and count. Mark Apple submitted and paste a show URL. The episode page shows that URL. Spotify stays not submitted until you mark it.
Failures the agent ships anyway
An editable GUID
The episode form has a text box for the GUID, or a title save rewrites it from the slug. Copy the value, edit the title, and diff the feed. Any change means subscribed apps can treat the episode as new. Remove the input and stop deriving the GUID from the title.
An empty feed during an outage
The database errors and the handler returns a valid channel with no items. Apps drop the back catalog. The outage check is a 503. Run it in dev with the database stopped.
Enclosure length from the browser
The feed says a length the browser guessed, and the object is a different size. Apps fail the download or retry forever. Read the length from the stored object at publish time and on each feed render.
Every range is a download
One listen shows a number near the chunk count. The forty-request check is the one that catches this. HEAD and a first-kilobyte probe stay at zero. If the number climbs with each range, the counter is counting requests.
Artwork that waits until Apple rejects it
A 500-pixel image is stored, the feed publishes, and you find out at submission. Reject at upload. The message should say 1400 to 3000, square, JPEG or PNG, no alpha.
A second show “just in case”
The settings page grows a show switcher. The 160-hour row is multiple shows. This row has one show row, and the create path refuses another.
The 24-hour row, after the GUID survives a rename
Publish The first file, change the title, and read the same GUID in the feed. Then add the clock, the private URL, and the transcript.
A scheduled episode stays out of the feed until its UTC time, then runs the same validator. A private feed is another URL with a token. Revoking it does not remove the public item. The transcript is a document you edit, linked from the item. The enclosure stays the audio file. Richer analytics chart the daily integers. The counter still stores a date and a number.
The scope post is the three-row habit. The prompt post is the line that keeps an ad inserter out of the first prompt.
Deploy, then fetch the feed
Podshed wants Astro, D1, and R2. Put it on a hostname you already own, pod.flaviocopes.com. The hosting post is the adapter and the domain. Take a backup of D1 before you delete a paid account. The objects in R2 are the audio. The enclosure URLs have to keep resolving, or old apps lose the file, so a move is a redirect from the old URL.
Log a refused publish with the episode id and the field name. Leave the notes out of the log. Log the GUID assignment once, at first publish. Log a download increment with the episode id and the UTC date. Leave the client hash out of the log too. You already decided not to keep it. When the feed returns 503, log that the database read failed.
Then use it. Publish one real episode, fetch the feed, and change a title before you submit the URL to a directory.
What to skip on purpose
Leave IAB certification, dynamic ads, a second show, and a site builder. Leave a listener table. The daily integer is the count this row can defend.
If the rent is “read other people’s feeds”, build the RSS reader. If the rent is “posts on a site, plus a feed”, build the Ghost guide. If the rent is “my audio, one feed, a GUID that never moves”, build this.
Start now
Go to Build your own Transistor. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate. Episode uploads, a valid RSS feed, episode pages. Eight hours. The feed is rendered from the rows.
Publish The first file. Change the title. Read the same GUID.