Deploy Castopod on a VPS
A production-ready self-hosted podcast host with first-class Podcasting 2.0 support — chapters, transcripts, value-for-value, and ActivityPub federation. Fixed monthly cost instead of per-GB pricing.
At a Glance
| Project | Castopod (CodeIgniter 4) |
| License | AGPLv3 |
| Recommended Plan | RamNode KVM 2 GB minimum, 4 GB comfortable; 100 GB+ storage |
| OS | Ubuntu 24.04 LTS |
| Stack | Nginx + PHP-FPM 8.3 + MariaDB + Redis + FFmpeg |
Bandwidth math
Every download counts. A show with 5,000 listeners and a 50 MB episode is 250 GB of egress per release. RamNode's bandwidth allotments are generous compared to the major clouds — but check the plan against projected listener growth before you commit.
Initial Server Setup
sudo apt update && sudo apt upgrade -y
sudo apt install -y ufw fail2ban unattended-upgrades curl wget unzip git \
software-properties-common ca-certificates lsb-release apt-transport-https
sudo hostnamectl set-hostname podcast.example.com
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo dpkg-reconfigure -plow unattended-upgradesOn <4 GB plans, add a 2 GB swapfile — FFmpeg encoding occasionally spikes memory:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
sudo sysctl vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.confInstall Nginx + PHP 8.3
sudo apt install -y nginx
sudo systemctl enable --now nginxsudo apt install -y php8.3 php8.3-fpm php8.3-cli php8.3-common \
php8.3-mysql php8.3-curl php8.3-gd php8.3-mbstring php8.3-xml \
php8.3-bcmath php8.3-intl php8.3-zip php8.3-redis php8.3-opcacheupload_max_filesize = 512M
post_max_size = 512M
memory_limit = 512M
max_execution_time = 300
max_input_time = 300
date.timezone = America/New_York
; OPcache
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 60sudo systemctl restart php8.3-fpmMariaDB
sudo apt install -y mariadb-server mariadb-client
sudo systemctl enable --now mariadb
sudo mysql_secure_installationCREATE DATABASE castopod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'castopod'@'localhost' IDENTIFIED BY 'replace_with_a_long_random_password';
GRANT CREATE, ALTER, DELETE, EXECUTE, INDEX, INSERT, SELECT, UPDATE,
REFERENCES, CREATE VIEW
ON castopod.* TO 'castopod'@'localhost';
FLUSH PRIVILEGES;
EXIT;Critical: Castopod needs the full privilege list including REFERENCES and CREATE VIEW — otherwise migrations fail in confusing ways. Generate the password with openssl rand -base64 32.
Redis + FFmpeg
sudo apt install -y redis-server
sudo sed -i 's/^# *bind 127.0.0.1.*/bind 127.0.0.1/' /etc/redis/redis.conf
sudo sed -i 's/^supervised .*/supervised systemd/' /etc/redis/redis.conf
sudo systemctl enable --now redis-server
ss -tlnp | grep 6379 # should show 127.0.0.1:6379, NOT 0.0.0.0:6379sudo apt install -y ffmpeg
ffmpeg -version | head -n 1 # 4.1.8+ required
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
sudo chmod +x /usr/local/bin/composerDeploy Castopod
Always use the release tarball (vendor pre-populated), not the raw source repo, unless you're doing dev work.
cd /tmp
wget https://code.castopod.org/adaures/castopod/-/releases/permalink/latest/downloads/castopod.tar.gz
sudo mkdir -p /var/www
sudo tar -xzf castopod.tar.gz -C /var/www/
sudo mv /var/www/castopod /var/www/castopod-hostsudo chown -R root:root /var/www/castopod-host
sudo chown -R www-data:www-data /var/www/castopod-host/writable
sudo chown -R www-data:www-data /var/www/castopod-host/public/media
sudo find /var/www/castopod-host -type d -exec chmod 755 {} \;
sudo find /var/www/castopod-host -type f -exec chmod 644 {} \;
sudo chmod -R 775 /var/www/castopod-host/writable
sudo chmod -R 775 /var/www/castopod-host/public/mediaNginx vhost
server {
listen 80;
listen [::]:80;
server_name podcast.example.com;
root /var/www/castopod-host/public;
index index.php index.html;
client_max_body_size 512M;
access_log /var/log/nginx/castopod.access.log;
error_log /var/log/nginx/castopod.error.log;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Long cache for media files
location /media/ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# Static assets
location ~* \.(?:css|js|woff2?|ttf|otf|eot|svg|ico|jpg|jpeg|png|gif|webp)$ {
expires 7d;
add_header Cache-Control "public";
access_log off;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 300;
fastcgi_buffer_size 16k;
fastcgi_buffers 16 16k;
}
location ~ /\. { deny all; access_log off; log_not_found off; }
location ~ /writable/ { deny all; }
}sudo ln -s /etc/nginx/sites-available/castopod /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxLet's Encrypt SSL
HTTPS is required for ActivityPub federation to work properly.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d podcast.example.com \
--non-interactive --agree-tos -m admin@example.com --redirect
sudo systemctl list-timers | grep certbot
sudo certbot renew --dry-runRun the Install Wizard
Visit https://podcast.example.com/cp-install:
- Database: host
localhost, dbcastopod, usercastopod, prefixcp_. - Cache: Redis, host
127.0.0.1, port6379, no password. - Admin account with strong password.
- Site URL reads
https://podcast.example.com/.
sudo chown root:www-data /var/www/castopod-host/.env
sudo chmod 640 /var/www/castopod-host/.envIf the wizard fails: missing PHP extensions (verify php -m), the DB user lacking REFERENCES/CREATE VIEW, or PHP-FPM hitting max_execution_time during migrations. Logs at writable/logs/ are far more informative than browser errors.
Scheduled Tasks (cron)
Without cron, ActivityPub delivery, scheduled publishing, analytics rollups, and feed regeneration won't run.
* * * * * /usr/bin/php /var/www/castopod-host/spark tasks:run >> /dev/null 2>&1tasks:run is the modern entry point; older guides reference schedule:run which is deprecated.
Tuning + fail2ban
PHP-FPM pool for 2 GB box (/etc/php/8.3/fpm/pool.d/www.conf) — caps total PHP memory at ~600 MB:
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 2
pm.max_spare_servers = 4
pm.max_requests = 500MariaDB tuning for 4 GB+ (/etc/mysql/mariadb.conf.d/50-server.cnf):
innodb_buffer_pool_size = 512M
innodb_log_file_size = 128M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
max_connections = 50fail2ban jail for the login form — credential stuffing target:
[castopod-auth]
enabled = true
port = http,https
filter = castopod-auth
logpath = /var/log/nginx/castopod.access.log
maxretry = 5
findtime = 600
bantime = 3600[Definition]
failregex = ^<HOST> .* "POST /cp-auth/login HTTP/.*" 401 .*$
^<HOST> .* "POST /cp-auth/login HTTP/.*" 403 .*$
ignoreregex =sudo systemctl restart fail2ban
sudo fail2ban-client status castopod-authBackups
#!/bin/bash
set -euo pipefail
BACKUP_DIR=/var/backups/castopod
DATE=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
# Database
mysqldump --single-transaction --quick --lock-tables=false castopod \
| gzip > "$BACKUP_DIR/castopod-db-$DATE.sql.gz"
# Media (incremental rsync)
rsync -a --delete /var/www/castopod-host/public/media/ \
"$BACKUP_DIR/media-current/"
# Retention
find "$BACKUP_DIR" -name 'castopod-db-*.sql.gz' -mtime +14 -deletesudo chmod 700 /usr/local/sbin/backup-castopod.sh
sudo crontab -e
# 0 3 * * * /usr/local/sbin/backup-castopod.shPush offsite with Restic to S3 or another RamNode VPS — same pattern as a Nextcloud or Plesk backup setup.
Verify the Deployment
- HTTPS: SSL Labs gives an A/A+ on default Certbot + Nginx 1.24 config.
- RSS feed: publish a test episode, validate at
/@yourpodcast/feed.xmlwith Podbase orcast feeds validate. - ActivityPub: from a Mastodon account, search
@yourpodcast@podcast.example.com— federation should resolve. - Video clips: exercise FFmpeg + GD/FreeType by generating a clip from the test episode.
- Background tasks: tail
writable/logs/log-$(date +%Y-%m-%d).log.
Where to go from here
- CDN in front of
/media/: Castopod's.envsupports an alternate media URL — Cloudflare or BunnyCDN cuts origin egress dramatically. - External object storage: point
MEDIA_ROOTat an S3 mount vias3fsorrclone mount. - SMTP: configure in
.envvia Postmark or SES — more reliable than running Postfix locally.
