Podcasting
    Podcasting 2.0
    AGPLv3

    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

    ProjectCastopod (CodeIgniter 4)
    LicenseAGPLv3
    Recommended PlanRamNode KVM 2 GB minimum, 4 GB comfortable; 100 GB+ storage
    OSUbuntu 24.04 LTS
    StackNginx + 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.

    1

    Initial Server Setup

    Update + baseline tools
    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-upgrades

    On <4 GB plans, add a 2 GB swapfile — FFmpeg encoding occasionally spikes memory:

    Swap
    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.conf
    2

    Install Nginx + PHP 8.3

    Nginx
    sudo apt install -y nginx
    sudo systemctl enable --now nginx
    PHP 8.3 + required extensions
    sudo 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-opcache
    /etc/php/8.3/fpm/php.ini
    upload_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 = 60
    Reload
    sudo systemctl restart php8.3-fpm
    3

    MariaDB

    Install + harden
    sudo apt install -y mariadb-server mariadb-client
    sudo systemctl enable --now mariadb
    sudo mysql_secure_installation
    Create db + user (full privileges required)
    CREATE 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.

    4

    Redis + FFmpeg

    Redis (localhost-only)
    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:6379
    FFmpeg + Composer
    sudo 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/composer
    5

    Deploy Castopod

    Always use the release tarball (vendor pre-populated), not the raw source repo, unless you're doing dev work.

    Download + extract
    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-host
    Permission split (limits compromised PHP blast radius)
    sudo 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/media
    6

    Nginx vhost

    /etc/nginx/sites-available/castopod
    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; }
    }
    Enable + reload
    sudo ln -s /etc/nginx/sites-available/castopod /etc/nginx/sites-enabled/
    sudo nginx -t
    sudo systemctl reload nginx
    7

    Let's Encrypt SSL

    HTTPS is required for ActivityPub federation to work properly.

    Issue + auto-renew
    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-run
    8

    Run the Install Wizard

    Visit https://podcast.example.com/cp-install:

    1. Database: host localhost, db castopod, user castopod, prefix cp_.
    2. Cache: Redis, host 127.0.0.1, port 6379, no password.
    3. Admin account with strong password.
    4. Site URL reads https://podcast.example.com/.
    Lock down .env after wizard
    sudo chown root:www-data /var/www/castopod-host/.env
    sudo chmod 640 /var/www/castopod-host/.env

    If 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.

    9

    Scheduled Tasks (cron)

    Without cron, ActivityPub delivery, scheduled publishing, analytics rollups, and feed regeneration won't run.

    sudo crontab -u www-data -e
    * * * * * /usr/bin/php /var/www/castopod-host/spark tasks:run >> /dev/null 2>&1

    tasks:run is the modern entry point; older guides reference schedule:run which is deprecated.

    10

    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:

    pool.d/www.conf
    pm = dynamic
    pm.max_children = 10
    pm.start_servers = 2
    pm.min_spare_servers = 2
    pm.max_spare_servers = 4
    pm.max_requests = 500

    MariaDB tuning for 4 GB+ (/etc/mysql/mariadb.conf.d/50-server.cnf):

    50-server.cnf [mysqld]
    innodb_buffer_pool_size = 512M
    innodb_log_file_size = 128M
    innodb_flush_log_at_trx_commit = 2
    innodb_flush_method = O_DIRECT
    max_connections = 50

    fail2ban jail for the login form — credential stuffing target:

    /etc/fail2ban/jail.d/castopod.conf
    [castopod-auth]
    enabled = true
    port = http,https
    filter = castopod-auth
    logpath = /var/log/nginx/castopod.access.log
    maxretry = 5
    findtime = 600
    bantime = 3600
    /etc/fail2ban/filter.d/castopod-auth.conf
    [Definition]
    failregex = ^<HOST> .* "POST /cp-auth/login HTTP/.*" 401 .*$
                ^<HOST> .* "POST /cp-auth/login HTTP/.*" 403 .*$
    ignoreregex =
    Activate
    sudo systemctl restart fail2ban
    sudo fail2ban-client status castopod-auth
    11

    Backups

    /usr/local/sbin/backup-castopod.sh
    #!/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 -delete
    Schedule
    sudo chmod 700 /usr/local/sbin/backup-castopod.sh
    sudo crontab -e
    # 0 3 * * * /usr/local/sbin/backup-castopod.sh

    Push offsite with Restic to S3 or another RamNode VPS — same pattern as a Nextcloud or Plesk backup setup.

    12

    Verify the Deployment

    1. HTTPS: SSL Labs gives an A/A+ on default Certbot + Nginx 1.24 config.
    2. RSS feed: publish a test episode, validate at /@yourpodcast/feed.xml with Podbase or cast feeds validate.
    3. ActivityPub: from a Mastodon account, search @yourpodcast@podcast.example.com — federation should resolve.
    4. Video clips: exercise FFmpeg + GD/FreeType by generating a clip from the test episode.
    5. Background tasks: tail writable/logs/log-$(date +%Y-%m-%d).log.

    Where to go from here

    • CDN in front of /media/: Castopod's .env supports an alternate media URL — Cloudflare or BunnyCDN cuts origin egress dramatically.
    • External object storage: point MEDIA_ROOT at an S3 mount via s3fs or rclone mount.
    • SMTP: configure in .env via Postmark or SES — more reliable than running Postfix locally.