Self-Hosted Podcast Workflow

How I host my side podcast, using AWS, and Astro, directly from this site.
4 min read

This post describes my workflow for how I publish episodes of my podcast. This is totally self-hosted on AWS, not relying on podcast hosting providers. This way, I can provide the upmost privacy and have ultimate control over my content and traffic.

AWS File Hosting

I upload all my podcast files to AWS S3, using AWS CloudFront as a CDN. This doesn’t cost too much for mine, but it’s still a best practice to set cost alarms in case something goes awry. Podcast files are (relatively) big for longer episodes, and can use a bit of bandwidth.

Astro Content Types

As I’ve written about before, this site is built using Astro at the moment.

This is the Astro.js content collection. Note - this is a “content” collection, so these are created just like blog posts. The front matter contains the links and show info, and the body is an optional set of show notes which I render on show pages.

const podcast_episodes = defineCollection({
  type: 'content',
  schema: z.object({
    guid: z.string().uuid(),
    title: z.string(),
    has_show_notes: z.boolean().optional(),
    episode_type: z.enum(['episode', 'bonus', 'trailer']),
    season: z.number().int().optional(),
    episode: z.number().int().optional(),
    duration: z.number().nonnegative().int(),
    date: z.coerce.date(),
    description: z.string().optional(),
    filename: z.string()  // full URL to the .mp3 file on S3 / CloudFront
  }),
});

Full RSS Implementation

This assumes you have and use MDX - although it should be trivial to change it to use any manner of markup languages. This should be mostly explanatory, and includes all rendering for mdx -> html for inside of the feed.

Also, you can (and likely should) - customize getPublishedEpisodes and potentially the content collection to implement features such as drafts, scheduled publishing, and development mode.

import rss from '@astrojs/rss';
import { experimental_AstroContainer as AstroContainer } from "astro/container";
import { getContainerRenderer as getMDXRenderer } from "@astrojs/mdx";
import { loadRenderers } from "astro:container";
import type { CollectionEntry } from 'astro:content';
type PodcastEpisode = CollectionEntry<'podcast_episodes'>;

// READER EXERCISE - implement sorting, publishing logic
function getPublishedEpisodes() {
  return await getCollection('podcast_episodes')
}

export async function GET(context: any) {
  const year = new Date().getFullYear();
  const episodes = (await getPublishedEpisodes());
  const renderers = await loadRenderers([getMDXRenderer()]);
  const container = await AstroContainer.create({ renderers });

  const rendered = {} as Record<string, any>
  // render the content to a string we can put into the site.
  for (const episode of episodes) {
    const { Content } =  await episode.render();
    rendered[episode.slug] = await container.renderToString(Content);

    // ensure all site links are absolute - edit with your site
    rendered[episode.slug] = rendered[episode.slug].replace(/href=(["'])\//g, 'href=$1https://richinfante.com/')
  }

  // process and ensure guids/slugs are not reused
  const used_guids: Record<string, boolean> = {}
  const used_slugs: Record<string, boolean> = {}
  for (const episode of episodes) {
    const episode_data = episode.data;
    if (used_guids[episode_data.guid]) {
      throw new Error(`Duplicate episode GUID found: ${episode_data.guid}`);
    } else {
      used_guids[episode_data.guid] = true;
    }
    if (used_slugs[episode.slug]) {
      throw new Error(`Duplicate episode slug found: ${episode.slug}`);
    } else {
      used_slugs[episode.slug] = true;
    }
  }

  // create the episode listing
  const items = episodes.map((episode_rec: PodcastCollectionEntry) => {
    const episode = episode_rec.data;
    return {
      customData: [
        `<guid isPermaLink="false">${episode.guid}</guid>`,
        `<itunes:episodeType>${episode.episode_type}</itunes:episodeType>`,
        episode.season ? `<itunes:season>${episode.season}</itunes:season>` : null,
        episode.episode ? `<itunes:episode>${episode.episode}</itunes:episode>` : null,
        episode.duration ? `<itunes:duration>${episode.duration}</itunes:duration>` : null,
      ].filter(el => !!el).join('\n'),
      title: episode.title,
      description: episode.description,
      content: rendered[episode_rec.slug],
      link: `https://richinfante.com/podcast/episodes/${episode_rec.slug}`,
      pubDate: episode.date,

      enclosure: {
        url: episode.filename,  // this points to your file on s3
        type: 'audio/mpeg',
        length: episode.duration,
      }
    }
  })

  return rss({
    // stylesheet: '/xml_stylesheet.xsl',
    // `<title>` field in output xml
    title: 'One Million Bytes',

    // `<description>` field in output xml
    description: `A podcast about the web, technology, and more. Hosted by Rich Infante. Explore different areas of technology, including the web, mobile apps, cybersecurity, and other stuff I'm hacking on at the moment.`,

    // Pull in your project "site" from the endpoint context
    // https://docs.astro.build/en/reference/api-reference/#site
    site: context.site + '/podcast',

    // Array of `<item>`s in output xml
    // See "Generating items" section for examples using content collections and glob imports
    items: items,
    xmlns: {
      'sy': "http://purl.org/rss/1.0/modules/syndication/",
      'itunes': "http://www.itunes.com/dtds/podcast-1.0.dtd",
      'podcast': "https://podcastindex.org/namespace/1.0",
      'content': "http://purl.org/rss/1.0/modules/content/"
    },

    // custom XML - edit contents to match your show!
    customData: [
      `<language>en-us</language>`,
      `<sy:updatePeriod>hourly</sy:updatePeriod>`,
      `<sy:updateFrequency>1</sy:updateFrequency>`,
      `<podcast:medium>podcast</podcast:medium>`,
      `<itunes:summary>A podcast about the web, technology, and more. Hosted by Rich Infante. Explore different areas of technology, including the web, mobile apps, cybersecurity, and other stuff I'm hacking on at the moment.</itunes:summary>`,
      `<itunes:author>Rich Infante</itunes:author>`,
      `<itunes:explicit>no</itunes:explicit>`,
      `<itunes:image href="https://www.richinfante.com/img/podcast/OMB_v7.jpg" />`,
      `<itunes:type>episodic</itunes:type>`,
      `<itunes:owner><itunes:name>Rich Infante</itunes:name><itunes:email>contact@onemillionbytes.com</itunes:email></itunes:owner>`,
      `<itunes:category text="Technology" />`,
      `<copyright>&copy; 2024-${year} Rich Infante. All Rights Reserved.</copyright>`
    ].join('\n'),
  });
}

Feedback

Found a typo or technical problem? report an issue!

Subscribe to my Newsletter

Like this post? Subscribe to get notified for future posts like this.