Self-host Castopod to publish a podcast
Follow this on your own server. Start free, no card needed
Most podcast hosts rent you an RSS feed: stop paying and your episodes, your analytics and your own feed URL can all disappear with the account. Castopod is an open-source podcast host, built on CodeIgniter, that gives you the same publishing workflow, real listener analytics, and Podcasting 2.0 features like transcripts and chapters, with the feed living at your own domain instead of a vendor’s. By the end of this guide you’ll have it running with MariaDB and Redis behind HTTPS, your first show published, and a backup that actually covers the audio files, not just the database.
What you’ll need
- A server with Docker and the Compose plugin. A Hetzner CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, €5.99/month excl. VAT) runs Castopod comfortably; plan real disk beyond the default 40 GB once you’re hosting more than a handful of episodes, audio files add up faster than most self-hosted apps.
- A domain name you can point an A record at. Castopod requires HTTPS to federate properly with the fediverse (podcast follows and comments), so this isn’t optional the way it might be for an internal tool.
- SSH access to the server.
Step 1: Point DNS and open the firewall
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable
Create an A record for your domain pointing at the server’s public IP, and give it a few minutes before Step 5.
Step 2: Generate the secrets
openssl rand -hex 24 # MYSQL_ROOT_PASSWORD
openssl rand -hex 24 # MYSQL_PASSWORD
openssl rand -hex 24 # REDIS_PASSWORD
openssl rand -hex 16 # CP_ANALYTICS_SALT
mkdir -p /opt/castopod
cat > /opt/castopod/.env <<'EOF'
MYSQL_ROOT_PASSWORD=paste-the-first-value-here
MYSQL_PASSWORD=paste-the-second-value-here
REDIS_PASSWORD=paste-the-third-value-here
CP_ANALYTICS_SALT=paste-the-fourth-value-here
EOF
chmod 600 /opt/castopod/.env
CP_ANALYTICS_SALT is what Castopod hashes listener IP addresses with before counting a download, giving you real aggregate analytics without storing raw IPs; it has to stay the same value for the life of the instance, changing it resets how repeat listeners are deduplicated.
Step 3: Write the Compose file
services:
castopod:
image: castopod/castopod:1.15.5
restart: unless-stopped
env_file: .env
security_opt:
- no-new-privileges:false
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
environment:
CP_BASEURL: https://podcast.yourdomain.com
CP_DATABASE_HOSTNAME: db
CP_DATABASE_NAME: castopod
CP_DATABASE_USERNAME: castopod
CP_DATABASE_PASSWORD: ${MYSQL_PASSWORD}
CP_REDIS_HOST: redis
CP_REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
- castopod_media:/app/public/media
expose:
- "8080"
db:
image: mariadb:12.1
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: castopod
MYSQL_USER: castopod
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- castopod_db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7.2.5-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- castopod_redis_data:/data
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- castopod
volumes:
castopod_media:
castopod_db_data:
castopod_redis_data:
caddy_data:
caddy_config:
This guide pins 1.15.5, the current stable release as of writing, and mariadb:12.1, the database engine Castopod’s own docs document rather than Postgres. security_opt: no-new-privileges:false is required, not optional: Castopod’s image runs as a non-root www-data user and needs to chown /run on its own boot, which the kernel’s default no-new-privileges protection blocks, causing the container to fatal out with s6-overlay-suexec: child failed with exit code 100 if left on. Castopod’s own entrypoint script, not a config file you write, generates its real .env internally from the CP_* variables this guide sets, on every boot.
Check the file parses:
docker compose config
Step 4: Add Caddy and start the stack
podcast.yourdomain.com {
reverse_proxy castopod:8080
}
docker compose up -d
docker compose ps
curl -I https://podcast.yourdomain.com
You should see every service Up (db as healthy) and the curl return HTTP/2 200.
Step 5: Create your account and first podcast
Visit https://podcast.yourdomain.com/cp-install and follow the setup wizard to create the first administrator account, nothing is pre-seeded. From the admin dashboard, create a podcast, fill in its metadata, and publish your first episode by uploading an audio file; Castopod transcodes and generates the RSS entry automatically. Your feed is published at https://podcast.yourdomain.com/@<your-podcast-handle>/feed.xml, the URL to submit to Apple Podcasts, Spotify and any other directory.
Step 6: Back up the database and the media volume
Audio files and a growing MariaDB database both matter here, and the media volume is usually the bigger one:
cat > /opt/castopod/backup.sh <<'EOF'
#!/bin/sh
cd /opt/castopod
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db mariadb-dump -u root -p"${MYSQL_ROOT_PASSWORD}" castopod | gzip > backups/db-$TIMESTAMP.sql.gz
find backups -mtime +14 -delete
EOF
mkdir -p /opt/castopod/backups
chmod +x /opt/castopod/backup.sh
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/castopod/backup.sh") | crontab -
Run it once by hand and check ls -lh backups shows a dated file. The database dump alone doesn’t cover uploaded audio on castopod_media; back that up with whatever full-disk snapshot tooling your server provider offers, or a periodic rsync to another machine, since a tar of actively-served media while episodes are live is the kind of thing worth scheduling for low-traffic hours.
Step 7: Upgrade
docker compose pull castopod
docker compose up -d castopod
Castopod runs its own database migrations automatically on boot when the image version changes. Check the release notes for the version you’re moving to, and confirm docker compose logs castopod shows a clean startup with no migration errors before considering the upgrade done.
The shortcut: do it with Railyard
Everything above, MariaDB and Redis wired up automatically, the no-new-privileges fix Castopod’s own image needs to boot cleanly, a dedicated volume for uploaded audio, is also what Railyard’s Castopod stack template sets up for you. Connect a server, then pick Castopod in the templates catalog: app.railyard.run/deploy?stack=castopod. HTTPS is handled the same way Step 4 does by hand, which also satisfies Castopod’s own requirement for fediverse federation.
See deploy with a button and databases and backups for how templates and persistent data work on the platform more generally.
Troubleshooting
The container exits with s6-overlay-suexec: child failed with exit code 100. no-new-privileges is still enabled, check security_opt in Step 3 is actually set to no-new-privileges:false on the castopod service specifically, this is the one hardening default that has to be relaxed for this image.
MariaDB logs “Host X is not allowed to connect” only on the very first boot. This shows up if the server’s CPU is heavily loaded right when MariaDB is creating its initial users and grants, interrupting that process. Stop the stack, remove the castopod_db_data volume, and docker compose up -d again on a quieter moment; there’s no real data to lose yet if this happens on a genuinely fresh install.
Uploaded audio fails with a permissions error. Check ownership inside the container with docker compose exec castopod ls -la /app/public/media; this shows up most often after restoring a backup as root when the container runs as www-data.
HTTPS not issuing. docker compose logs caddy showing repeated ACME errors almost always means DNS from Step 1 hasn’t propagated or points at the wrong IP.
Disk filling up faster than expected. Audio files accumulate quickly across episodes; du -sh castopod_media (or the equivalent docker volume inspect path) shows what’s actually consuming space, and resizing the server’s disk ahead of running out is simpler than migrating a full volume under pressure.
FAQ
Does Castopod require an SMTP provider like some other self-hosted apps do? Not to publish a podcast or for core moderation to work; SMTP is only needed if you enable optional email notifications, which is a much lighter requirement than apps where account confirmation itself depends on it.
Can I migrate an existing podcast’s RSS feed to Castopod? Yes, Castopod supports importing episodes from an existing feed URL during podcast creation, which preserves episode history rather than starting from zero.
Is the fediverse integration mandatory? No, podcasts work as a normal RSS feed and web player without it; federation (follows and comments from Mastodon-style apps) is an additional feature on top, not a requirement to publish.
How does real analytics work without tracking listeners individually? Castopod implements the Podcasting 2.0 analytics spec, hashing identifying data like IP addresses with the salt from Step 2 before counting a download, so you get real download and geographic numbers without storing anything that identifies an individual listener.
What’s the realistic disk growth for an active show? A weekly hour-long episode at a reasonable bitrate is tens of megabytes per episode; it adds up slowly for most shows, but video or very high-bitrate audio changes that math quickly, check actual file sizes against the “What you’ll need” server before assuming the default 40 GB is enough for a year.
Once MariaDB, Redis and HTTPS are in place, publishing an episode is the same few clicks it would be on a hosted platform, the real ongoing work is the backup habit from Step 6 and keeping an eye on disk space as the show grows.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.