Skip to Content

Production Guide: Deploy Castopod with Docker Compose + Caddy on Ubuntu

Self-host your podcast platform with automatic HTTPS, RSS feed generation, and ActivityPub federation

Podcasters who want full ownership over their show — feed, analytics, transcripts, and listener data — often find third-party hosts limiting: storage tiers, per-download pricing, proprietary RSS extensions that lock you in, and no way to integrate episodes with the rest of your self-hosted infrastructure. Castopod solves that. It is a free, open-source podcast hosting platform with a built-in web player, standards-compliant RSS feed generation, episode scheduling, ActivityPub federation so listeners can follow and comment via Mastodon and other Fediverse clients, and a clean admin interface for managing shows, episodes, and chapters. No per-episode fees, no bandwidth surprises, and all your metadata stays on hardware you control.

This guide covers a production-ready Castopod deployment on Ubuntu using Docker Compose and Caddy for automatic TLS. You end up with a private podcast host that accepts MP3 and M4A uploads, generates a valid RSS feed that any podcast app can subscribe to, and serves your content over HTTPS with a verified Let's Encrypt certificate — all fully self-hosted.

Architecture and flow overview

The stack has three components. Castopod runs as a PHP and Apache application container listening on port 8000 internally. A MariaDB container provides durable relational storage for podcast metadata, episode records, chapter data, and analytics. Caddy sits in front as the TLS-terminating reverse proxy, automatically issuing and renewing a Let's Encrypt certificate for your chosen subdomain. Episode media files — MP3 audio, cover art, transcripts — are stored in a Docker named volume mounted into the Castopod container at /var/www/html/public/media, so they survive container recreation and image upgrades.

Traffic flow: podcast app or browser (HTTPS) → Caddy (port 443) → Castopod container (port 8000, Docker bridge network) → MariaDB (port 3306, internal network only). Caddy never exposes port 8000 to the internet; the host firewall allows only ports 80 and 443. Media files are served directly by the Apache layer inside the Castopod container, so no separate CDN is needed for small to medium podcast libraries.

Both the database and media volumes are named Docker volumes, making backup, migration, and point-in-time recovery straightforward without touching the running containers.

Prerequisites

  • Ubuntu 22.04 or 24.04 server with a public IP address
  • A DNS A record pointing your subdomain (e.g., podcast.example.com) to the server IP — fully propagated before starting
  • Docker Engine 24+ and Docker Compose v2 installed (docker compose plugin, not the legacy binary)
  • Ports 80 and 443 open in your firewall (ufw allow 80/tcp && ufw allow 443/tcp)
  • At least 1 GB of free RAM; Castopod and MariaDB together use under 512 MB at idle
  • Adequate disk space for media — each podcast episode is typically 25–100 MB per hour of audio; plan storage proportional to your back-catalog size
  • A non-root sudo user or root shell for initial setup

Step-by-step deployment

1. Create the project directory

mkdir -p /opt/castopod && cd /opt/castopod

2. Create the environment file

Store all secrets in a .env file and restrict its permissions immediately. The Compose file reads these variables via substitution so no credentials appear in docker inspect output.

cat > /opt/castopod/.env << 'EOF'
MYSQL_ROOT_PASSWORD=change_root_pw_here
MYSQL_DATABASE=castopod
MYSQL_USER=castopod
MYSQL_PASSWORD=change_db_pw_here
CASTOPOD_BASE_URL=https://podcast.example.com/
EOF
chmod 600 /opt/castopod/.env

3. Write the Docker Compose file

services:
  castopod-mariadb:
    image: mariadb:10.11
    container_name: castopod-mariadb
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    volumes:
      - castopod_db:/var/lib/mysql
    networks:
      - internal
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5

  castopod:
    image: castopod/castopod:latest
    container_name: castopod
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_HOST: castopod-mariadb
      app.baseURL: ${CASTOPOD_BASE_URL}
      app.adminGateway: cp-admin
      app.authGateway: cp-auth
    volumes:
      - castopod_media:/var/www/html/public/media
    networks:
      - internal
      - web
    depends_on:
      castopod-mariadb:
        condition: service_healthy

  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - web
    depends_on:
      - castopod

networks:
  internal:
    driver: bridge
  web:
    driver: bridge

volumes:
  castopod_db:
  castopod_media:
  caddy_data:
  caddy_config:

4. Write the Caddyfile

Replace podcast.example.com with your actual subdomain.

podcast.example.com {
    reverse_proxy castopod:8000
    encode gzip
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
    }
}

5. Start the stack

cd /opt/castopod
docker compose up -d

Watch the logs for startup signals — Caddy prints certificate obtained successfully once TLS is provisioned, and Castopod logs database migration output on first run:

docker compose logs -f

The full stack is typically ready within 60 seconds. MariaDB initializes its data directory on first launch, which takes 15–20 seconds before the Castopod container starts. The depends_on: condition: service_healthy directive ensures Castopod only starts once MariaDB passes its health check.

6. Complete the setup wizard

Open https://podcast.example.com/cp-admin in a browser. The Castopod install wizard runs automatically on first visit and prompts you to create an admin account, set the instance name and description (shown in the Fediverse profile), and choose your analytics storage preference. Complete the wizard and log in. You now have a functioning podcast management interface.

7. Create your first podcast and episode

In the admin panel, navigate to Podcasts → New Podcast. Fill in the show title, description, language, category, and upload cover art (minimum 1400×1400 pixels per Apple Podcasts requirements). After saving, Castopod generates an RSS feed URL at https://podcast.example.com/@your-show/feed.xml — submit this to Apple Podcasts, Spotify, and other directories. Upload episode audio files via Episodes → New Episode, add chapters and transcripts if needed, and click Publish to push the episode live on the feed.

Configuration and secrets handling

All sensitive values live in /opt/castopod/.env with permissions set to 600. The Compose file uses variable substitution so no passwords appear in docker inspect output. To rotate the database password, update the MariaDB user first, then update the file and restart the application container:

docker exec castopod-mariadb mariadb -u root -p"${MYSQL_ROOT_PASSWORD}" \
  -e "ALTER USER 'castopod'@'%' IDENTIFIED BY 'new_strong_password';"
# Update MYSQL_PASSWORD in .env, then restart the app:
docker compose up -d castopod

The CASTOPOD_BASE_URL must match your public HTTPS URL exactly, including the trailing slash. Changing it after installation requires updating both the .env file and the base URL setting in the Castopod admin panel under Settings → General, then recreating the container. Keep the MYSQL_ROOT_PASSWORD in a secrets manager — it is not used by the application at runtime but is required for database maintenance and password rotation.

Castopod does not require a separate Redis instance by default; it uses file-based caching internally. For high-traffic deployments with many concurrent listeners, you can configure an external Redis cache by adding the appropriate Castopod environment variables for the cache driver and Redis connection details.

Verification

After the stack is running, verify each layer systematically:

# All three containers should show "Up"
docker compose ps

# Caddy should serve HTTPS with a valid certificate
curl -I https://podcast.example.com

# Castopod homepage should return HTTP 200
curl -o /dev/null -w "%{http_code}\n" https://podcast.example.com

# MariaDB should accept connections from the application user
docker exec castopod-mariadb mariadb -u castopod -p"${MYSQL_PASSWORD}" \
  -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='castopod';" castopod

Log in to the admin panel at https://podcast.example.com/cp-admin and confirm the dashboard loads without error banners. After creating a podcast and publishing an episode, validate the RSS feed by opening https://podcast.example.com/@your-show/feed.xml in a browser — it should be well-formed XML including the episode enclosure URL. Run the feed through a public validator such as castfeedvalidator.com to confirm Apple Podcasts compliance before directory submission.

Common issues and fixes

Castopod container exits immediately on startup: The most common cause is a database connection failure before MariaDB has completed initialization on first run. The health-check condition on the castopod-mariadb service prevents this in most cases, but older Docker versions that do not honor health-check conditions will race. Ensure the Castopod service has restart: unless-stopped so it retries until the database is reachable.

ACME certificate challenge fails — Caddy logs "challenge failed": Port 80 must be publicly reachable for the HTTP-01 challenge. Confirm ufw status shows port 80 allowed and that no upstream NAT, security group, or load balancer is filtering it. Verify DNS resolution with dig +short podcast.example.com — it must return your server's public IP, not a private address.

Admin panel returns 404 at /cp-admin: The app.adminGateway environment variable was not passed correctly to the container. Check with docker compose exec castopod env | grep adminGateway. If it is missing, recreate the container: docker compose up -d --force-recreate castopod. Confirm the variable is set before accessing the admin URL again.

Media files return 403 after upload: The castopod_media volume must be mounted at exactly /var/www/html/public/media. Check with docker exec castopod ls -la /var/www/html/public/media. If the directory is missing or owned by root, the Apache process inside the container cannot write to it. Remove and recreate the volume, then restart the stack — Castopod regenerates the directory with correct ownership on startup.

RSS feed shows no episodes after publishing: In Castopod, episodes must be explicitly set to Published status — Draft episodes do not appear in the RSS feed. Navigate to Episodes in the admin panel and confirm each episode's status badge shows Published in green. Newly published episodes may also take up to 60 seconds to appear if Castopod's internal cache has not yet cleared.

FAQ

Which podcast clients can subscribe to a Castopod RSS feed?

All standard podcast clients work without any special configuration: Apple Podcasts, Spotify (via RSS submission), Overcast, Pocket Casts, Podcast Addict, Antennapod, and any client that accepts a custom RSS URL. Submit your feed URL from the Castopod admin panel under Podcast → Distribution to Apple and Spotify directories for indexed discovery. Listeners can also subscribe manually by pasting the feed URL directly into their app of choice.

Does Castopod support video podcasts?

Yes. Castopod handles any media type including MP4 video files. The RSS feed uses the correct MIME type for the enclosure tag, and the built-in web player automatically switches to a video player for MP4 episodes. Cover art and interactive chapter marks work for both audio and video episodes. Transcript upload and display are also supported for accessibility compliance.

How do I back up my Castopod data?

Back up both the database and the media volume. For the database, take a MariaDB dump from the running container:

docker exec castopod-mariadb mariadb-dump -u castopod -p"${MYSQL_PASSWORD}" castopod \
  | gzip > /backup/castopod_db_$(date +%F).sql.gz

For media files, archive the named volume:

docker run --rm -v castopod_media:/data -v /backup:/out alpine \
  tar czf /out/castopod_media_$(date +%F).tar.gz /data

Schedule both commands in a daily cron job and ship the output to off-site storage such as S3, Backblaze B2, or a remote server via rsync. Restoration requires importing the SQL dump into a fresh MariaDB container and restoring the tar archive into a new media volume before starting Castopod.

Can I migrate from a third-party podcast host to Castopod?

Yes. Castopod includes an RSS import feature under Podcasts → Import from RSS. Paste your existing feed URL, and Castopod pulls in episode metadata and downloads media files from the old host into the local media volume. This works with shows hosted on Anchor, Buzzsprout, Transistor, Libsyn, and any platform generating standard RSS feeds. After import, verify all episode enclosure URLs point to your new Castopod instance and update your directory submissions with the new feed URL before decommissioning the old host.

What is ActivityPub federation and how does it work in Castopod?

ActivityPub is the protocol that powers the Fediverse — Mastodon, Pixelfed, PeerTube, and compatible platforms. When Castopod publishes a new episode, it broadcasts an activity to all Fediverse accounts following the podcast's profile. Mastodon users can follow your podcast at @[email protected] and receive episode notifications directly in their feed. They can boost and comment on episodes through Mastodon without leaving their preferred client. Enable federation in Settings → Fediverse and ensure your base URL is correct so Webfinger discovery requests resolve properly.

How do I configure SMTP for email notifications?

Add SMTP connection variables to your .env file:

email.SMTPHost=smtp.example.com
email.SMTPPort=587
[email protected]
email.SMTPPass=your_smtp_password
[email protected]
email.fromName=My Podcast

After updating .env, recreate the Castopod container: docker compose up -d --force-recreate castopod. Castopod uses these settings for admin password reset emails and subscriber notification workflows. Test by triggering a password reset from the login page and confirming delivery to the admin address.

Internal links

Talk to us

If you want this deployed with hardened access controls, monitoring standards, and production runbooks tailored to your environment, our team can help end-to-end.

Contact Us

Production Guide: Deploy Wakapi with Docker Compose + Caddy + PostgreSQL on Ubuntu
Self-host a private, WakaTime-compatible coding activity tracker with unlimited history.