Skip to content

fnayou/podcastify

Repository files navigation

Podcastify — tiny self-hosted podcast RSS generator

Docker Pulls Image Size GitHub Stars License: MIT

Create private podcast feeds from your own MP3s with minimal setup. Drop MP3s into a folder, add a simple YAML file, and Podcastify builds an iTunes-compatible RSS feed. Serve everything (XML + media) from one container (Caddy + Python) via Docker.

Documentation


Features

  • Simple layout: ./podcasts/*.yaml for configs, ./public/<name>/ for media and images.
  • Auto-discovery of episodes if you omit the episodes: list.
  • MP3 duration extraction via ffprobe (falls back gracefully).
  • iTunes fields: author, owner, subtitle, summary, explicit, categories, episode types, seasons.
  • Clean XML: stable GUIDs (SHA-1), configurable language, empty tags rather than empty CDATA.
  • Single container image: Caddy (static hosting) + Python generator.
  • Developer-friendly Taskfile (contributors only) with namespaced commands like task docker:up, task podcastify:generate.
  • Configurable ports: choose any host port (e.g., 1234) via .env / Compose. Caddy listens on ${PORT} inside the container.

Run from Docker Hub

Image: fnayou/podcastify (public)

Quick run

# choose host port and base URL
export HOST_PORT=1234
export PUBLIC_BASE_URL="http://localhost:${HOST_PORT}"

docker run --rm   -e PORT=8080   -e PUBLIC_BASE_URL="${PUBLIC_BASE_URL}"   -p ${HOST_PORT}:8080   -v "$(pwd)/public:/app/public"   -v "$(pwd)/podcasts:/app/podcasts"   fnayou/podcastify:latest
# or pin a version:
# fnayou/podcastify:v1.0.0

docker-compose.yml

services:
  podcastify:
    image: fnayou/podcastify:latest   # or fnayou/podcastify:v1.0.0
    ports:
      - "${HOST_PORT:-8080}:${PORT:-8080}"
    environment:
      - PORT=${PORT:-8080}
      - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:${HOST_PORT:-8080}}
      - PODCASTS_ROOT=/app/podcasts
      - PUBLIC_ROOT=/app/public
      - RUN_ON_START=${RUN_ON_START:-true}
      - PUBLISH_XML=${PUBLISH_XML:-true}
    volumes:
      - ./public:/app/public
      - ./podcasts:/app/podcasts
    restart: unless-stopped

Create a .env file (next section) to set HOST_PORT and PUBLIC_BASE_URL.

For production behind a reverse proxy at https://podcasts.domain.tld, set PUBLIC_BASE_URL=https://podcasts.domain.tld and forward to the container’s ${PORT} (default 8080).


Directory structure

.
├─ app.py                # generator (root of the project; already in the image)
├─ docker-compose.yml
├─ docker/               # Dockerfile & Caddy config (only needed for local builds)
│  └─ podcastify/
│     ├─ Dockerfile
│     ├─ Caddyfile       # uses :{$PORT} and hides .gitkeep (see below)
│     └─ entrypoint.sh
├─ podcasts/             # <name>-podcast.yaml lives here
│  └─ example-podcast.yaml
├─ public/               # served statically by Caddy
│  └─ example/
│     ├─ cover.jpg
│     ├─ ep01.mp3
│     └─ episode.png
├─ Taskfile.yml
├─ .env                  # optional env vars
├─ .gitignore
└─ README.md
  • Configs go in ./podcasts as name-podcast.yaml.
  • Media and images live in ./public/<name>/.
  • The feed is generated to ./public/<name>.xml.

Requirements

  • Docker and Docker Compose
  • Task (go-task) for convenient commands — optional if you just use docker compose
  • Linux watchers (optional): inotifywait from inotify-tools
  • YAML linter (optional): yamllint

Environment variables (.env)

# Host port → visit http://localhost:${HOST_PORT}
HOST_PORT=8080

# Container listen port (Caddy listens on this; keep 8080 unless you know why)
PORT=8080

# Base URL used in generated <enclosure> and image links
# Must reflect the public URL and host port you expose (or your domain)
PUBLIC_BASE_URL=http://localhost:${HOST_PORT}

# (Advanced) override in-container paths if needed
PODCASTS_ROOT=/app/podcasts
PUBLIC_ROOT=/app/public

# Run generator on container start (default true)
RUN_ON_START=true

# Write XML to disk (default true)
PUBLISH_XML=true

By default, Caddy serves ./public. Change the published host port via HOST_PORT in .env or directly in docker-compose.yml.


Caddyfile example

The image ships with a Caddyfile that uses an environment variable for the port and hides .gitkeep from listings:

:{$PORT} {
  root * /app/public
  encode gzip
  header Access-Control-Allow-Origin "*"

  file_server {
    browse
    hide .gitkeep
    # hide .git* .DS_Store  # optionally hide more dotfiles
  }

  log {
    output stdout
    format console
  }
}

Security hardening

The image runs fine as-is. For additional defense-in-depth, you can apply these Compose settings with v1.0.0 and later without changing the image:

services:
  podcastify:
    # ... your existing config ...
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    cap_drop: ["ALL"]

These settings make the root filesystem read-only (your mounted public/ and podcasts/ remain writable), prevent privilege escalation, and drop Linux capabilities (the app listens on high port ${PORT} so none are needed).

If you later publish an image that runs as a non-root user by default (for example v1.0.1+), you can also add:

services:
  podcastify:
    user: "10001:10001"

and keep the same hardening flags above.


Quickstart (local development with Taskfile)

Task is for contributors and local development only. End users can run the published Docker image without installing Task.

  1. Clone and configure

    git clone https://github.com/fnayou/podcastify
    cd podcastify
    # create .env from the section above (set HOST_PORT if you want a custom port)
  2. Start the stack

    task docker:up
  3. Create a podcast

    # scaffold a new config: podcasts/myshow-podcast.yaml
    task podcastify:new NAME=myshow
    
    # add media under public/myshow/
    mkdir -p public/myshow
    cp /path/to/ep01.mp3 public/myshow/
    cp /path/to/cover.jpg public/myshow/
  4. Generate feeds

    task podcastify:generate
  5. Subscribe

    • Feed URL: http://localhost:${HOST_PORT}/myshow.xml
    • Media files: http://localhost:${HOST_PORT}/myshow/ep01.mp3

YAML config example

Minimal example: podcasts/myshow-podcast.yaml

name: "myshow"
title: "My Private Show"
author-name: "Your Name"
author-email: "you@example.com"
description: "Personal feed for testing"
language: "en"
explicit: false
image: "cover.jpg"   # place in ./public/myshow/cover.jpg
categories:
  - "Technology"
  - ["Society & Culture", "Personal Journals"]

# Optional: list episodes explicitly.
# If omitted, Podcastify will auto-discover *.mp3 in ./public/myshow/
episodes:
  - file: "ep01.mp3"     # basename only is used internally
    title: "Hello World"
    description: "First episode"
    pub_date: "2025-01-01T08:00:00Z"
    image: "episode.png" # optional; resolve from ./public/myshow/
    explicit: false
    season: 1
    episode: 1
    episode_type: "full" # full | trailer | bonus
    guid: "custom-guid-if-you-really-want"  # otherwise auto-generated

Notes:

  • file: only the basename is used; put the MP3 in public/<name>/.
  • image at channel or episode level can be a filename (served from public/<name>/) or a full URL.
  • Categories input accepted as:
    • "Technology"
    • ["Technology", "Education"]
    • [["Society & Culture", "Personal Journals"]]
    • [{ name: "Technology", sub: "Software How-To" }]
  • If pub_date is missing or invalid, the file modification time is used.
  • Durations come from ffprobe; if that fails, the <itunes:duration> tag is omitted.

Task commands (development)

task                          # show task list

# Docker (local stack)
task docker:up                # build and start container
task docker:down              # stop and remove
task docker:restart           # restart
task docker:status            # ps + recent logs
task docker:logs              # follow logs
task docker:logs:recent       # recent logs only
task docker:logs:errors       # grep errors/warnings
task docker:shell             # shell into container
task docker:shell:root        # root shell
task docker:build:quick       # quick image rebuild
task docker:compose:ps        # docker compose ps
task docker:compose:config    # resolved compose config
task docker:clean             # down + prune volumes
task docker:clean:all         # feeds + containers + images

# Podcastify app
task podcastify:generate      # run the generator now
task podcastify:new NAME=myshow
task podcastify:watch         # watch ./podcasts (Linux, inotifywait)
task podcastify:watch-public  # watch ./public (Linux, inotifywait)
task podcastify:clean:feeds   # delete generated XML

# Development
task dev:test                 # run tests
task dev:test:coverage        # tests with 90% coverage gate
task dev:validate             # coverage + docker build
task doctor                   # env and directory checks

How it works

  • On task docker:up, the container starts Caddy and optionally runs the generator at boot (RUN_ON_START=true).
  • You can run the generator any time with task podcastify:generate.
  • The XML <generator> tag is podcastify.
  • Episode GUIDs default to a SHA-1 of <podcast>/<filename> and are not permalinks.

License

MIT — see LICENSE.

About

tiny self-hosted podcast RSS generator. Create private podcast feeds from your own MP3s with zero fuss.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors