Skip to content

Castopod

Open-source podcast hosting platform. Castopod manages your episodes, generates RSS feeds for distribution to Apple Podcasts, Spotify, and other directories, tracks anonymous listener analytics, and federates with the Fediverse via ActivityPub. Metadata lives in MariaDB and uploaded audio and images live on a persistent volume. The chart applies migrations and creates the superadmin on first start, so the instance never opens with a public install wizard.

Key Features

  • RSS feed generation — automatic, standards-compliant podcast feeds for all directories
  • Built-in analytics — privacy-respecting listener tracking with hashed IDs
  • Fediverse integration — ActivityPub support for social interactions and episode sharing
  • Ready on first start — setup init container runs migrations and creates the superadmin from a Secret
  • Hardened pod — non-root www-data, no privilege escalation, all capabilities dropped except NET_BIND_SERVICE
  • Scheduler sidecar — scheduled publishing, WebSub, and federation tasks run every minute
  • MariaDB backend — bundled subchart or external database on any port
  • Optional Redis cache — authenticated Redis subchart as the cache handler
  • Persistent media — one PVC holds the writable directory and uploaded audio and images
  • S3 backup — scheduled archive of the data volume to S3-compatible storage
  • Configurable listener — castopod.port changes the real FrankenPHP port
  • Ingress or Gateway API — Ingress or HTTPRoutes
  • CDN support — separate media base URL for audio file delivery

Installation

HTTPS repository:

helm repo add helmforge https://repo.helmforge.dev
helm repo update
helm install castopod helmforge/castopod \
  --set castopod.baseURL=https://podcast.example.com

OCI registry:

helm install castopod oci://ghcr.io/helmforgedev/helm/castopod \
  --set castopod.baseURL=https://podcast.example.com

castopod.baseURL only sets the URL that Castopod embeds in links and feeds. It does not expose the Service: enable ingress or gatewayAPI for that hostname (see the Deployment Examples below) before using the public URL.

Getting Started

Without a base URL, Ingress, or HTTPRoute, the chart uses http://localhost:8080 and disables HTTPS redirection for a local check:

helm install castopod helmforge/castopod
kubectl rollout status deploy/castopod-castopod --timeout=300s
kubectl get secret castopod-castopod-admin -o jsonpath='{.data.admin-password}' | base64 -d; echo
kubectl port-forward svc/castopod-castopod 8080:80

Open http://localhost:8080/cp-auth/login and log in with [email protected] and the password above.

Deployment Examples

# values.yaml — Castopod with bundled MariaDB (default)
castopod:
  baseURL: 'https://podcast.example.com'
  admin:
    username: owner
    email: [email protected]
    existingSecret: castopod-admin # key admin-password

mariadb:
  enabled: true
  auth:
    password: 'mariadb-password'

persistence:
  enabled: true
  size: 20Gi

ingress:
  enabled: true
  ingressClassName: traefik
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: podcast.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: castopod-tls
      hosts:
        - podcast.example.com
# values.yaml — Castopod with MariaDB and Redis for caching
castopod:
  baseURL: 'https://podcast.example.com'

mariadb:
  enabled: true
  auth:
    password: 'mariadb-password'

# Castopod requires a Redis password; the subchart generates one.
redis:
  enabled: true
  architecture: standalone

persistence:
  enabled: true
  size: 20Gi

ingress:
  enabled: true
  ingressClassName: traefik
  hosts:
    - host: podcast.example.com
      paths:
        - path: /
          pathType: Prefix
# values.yaml — Castopod with media served from a CDN
# Audio files are stored in the PVC but served via CDN URL
castopod:
  baseURL: 'https://podcast.example.com'
  mediaBaseURL: 'https://cdn.example.com/castopod'

mariadb:
  enabled: true
  auth:
    password: 'mariadb-password'

persistence:
  enabled: true
  size: 20Gi

ingress:
  enabled: true
  ingressClassName: traefik
  hosts:
    - host: podcast.example.com
      paths:
        - path: /
          pathType: Prefix
# values.yaml — Castopod behind a shared Gateway
castopod:
  baseURL: 'https://podcast.example.com'

gatewayAPI:
  enabled: true
  httpRoutes:
    - name: public
      parentRefs:
        - name: shared-gateway
          namespace: gateway-system
          sectionName: https
      hostnames:
        - podcast.example.com
# values.yaml — Daily backup of the Castopod data volume to S3
# NOTE: Backup covers writable files and uploaded media only.
#       Back up the MariaDB database separately.
castopod:
  baseURL: 'https://podcast.example.com'

mariadb:
  enabled: true
  auth:
    password: 'mariadb-password'

persistence:
  enabled: true
  size: 20Gi

backup:
  enabled: true
  schedule: '0 3 * * *'
  s3:
    endpoint: https://s3.amazonaws.com
    bucket: my-castopod-backups
    accessKey: '<set-me>'
    secretKey: '<set-me>'

Configuration Reference

Core

Parameter Type Default Description
nameOverride string "" Override the chart name.
fullnameOverride string "" Override the full release name.
commonLabels object {} Extra labels added to all resources.

Image

Parameter Type Default Description
image.repository string docker.io/castopod/castopod Castopod container image.
image.tag string "1.15.5" Image tag.
image.pullPolicy string IfNotPresent Image pull policy.
imagePullSecrets array [] Pull secrets for private registries.

Castopod Configuration

Parameter Type Default Description
castopod.baseURL string "" Public URL; falls back to the first Ingress host, then HTTPRoute hostname, then http://localhost:8080.
castopod.mediaBaseURL string "" Media URL for a CDN; defaults to the base URL.
castopod.port integer 8080 FrankenPHP listener (CADDY_HTTP_PORT), 1024-65535.
castopod.adminGateway string cp-admin Admin area path.
castopod.authGateway string cp-auth Authentication path.
castopod.extraEnv array [] Extra environment variables; CP_* settings (SMTP, S3 media, 2FA) are rendered into .env.
castopod.admin.create boolean true Create the superadmin during setup when none exists.
castopod.admin.username string admin Superadmin username.
castopod.admin.email string [email protected] Superadmin email, used to log in.
castopod.admin.password string "" Superadmin password (8+ characters); generated once when empty.
castopod.admin.existingSecret string "" Existing Secret with the superadmin password.
castopod.admin.existingSecretPasswordKey string admin-password Key in the existing Secret.
setup.databaseWaitTimeoutSeconds integer 300 Seconds the setup init container waits for MariaDB.
setup.resources object {} Resources for the setup init container.
scheduler.enabled boolean true Run the task scheduler sidecar (spark tasks:run every minute).
scheduler.resources object {} Resources for the scheduler container.
baseURL is required and affects all RSS feeds

All episode audio URLs, podcast RSS feed links, and API responses are built using castopod.baseURL. If this value is incorrect, podcast directories (Apple Podcasts, Spotify, etc.) will be unable to download episodes from your instance. Always set the exact public URL including https:// and without a trailing slash.

Analytics

Parameter Type Default Description
analytics.salt string "" Salt for hashing listener IDs. Generated once (64 chars) and kept on upgrade.
analytics.existingSecret string "" Existing Kubernetes Secret containing the analytics salt.
analytics.existingSecretKey string analytics-salt Key inside the existing secret holding the salt value.
Persist the analytics salt for consistent reporting

The analytics salt is used to hash listener IP addresses into anonymous IDs. If the salt changes, historical listener data becomes inconsistent — the same listener appears as multiple distinct users across time periods. The chart keeps a generated salt across upgrades; set analytics.existingSecret to manage it outside the release.

Database — Embedded Subchart

Parameter Type Default Description
mariadb.enabled boolean true Deploy a bundled MariaDB subchart for Castopod.
mariadb.architecture string standalone MariaDB deployment architecture.
mariadb.auth.database string castopod Database name created by the subchart.
mariadb.auth.username string castopod Database username created by the subchart.
mariadb.auth.password string "" Database password (auto-generated if empty).

Database — External

Parameter Type Default Description
database.external.host string "" External MariaDB hostname or IP.
database.external.port string "3306" External MariaDB port.
database.external.name string castopod Database name on the external server.
database.external.username string castopod Username for the external database.
database.external.password string "" Password for the external database (plain text — prefer secret).
database.external.existingSecret string "" Existing secret containing the database password.
database.external.existingSecretPasswordKey string password Key inside the existing secret for the password.

Cache — Redis Subchart

Parameter Type Default Description
redis.enabled boolean false Deploy a bundled Redis subchart for caching.
redis.architecture string standalone Redis deployment architecture.
redis.auth.enabled boolean true Must stay true: Castopod requires a Redis password.

Persistence

One PVC holds two paths: the volume root is mounted at /var/www/html/writable (cache, sessions, logs, temporary files) and the public-media subPath at /var/www/html/public/media (uploaded audio, artwork, and site images).

Parameter Type Default Description
persistence.enabled boolean true Enable a PVC for writable files and uploaded media.
persistence.size string 10Gi PVC size. Increase based on expected audio file volume.
persistence.storageClass string "" StorageClass for the PVC.
persistence.accessMode string ReadWriteOnce PVC access mode.
persistence.existingClaim string "" Use an existing PVC instead of creating one.
persistence.annotations object {} Annotations for the PVC.

Backup

S3 backup covers the data volume only — not MariaDB

The S3 backup CronJob archives the Castopod data volume (writable files and uploaded audio and images). It does not back up the MariaDB database, which contains episode metadata, podcast definitions, user accounts, and analytics data. Configure a separate MariaDB backup strategy (e.g. the HelmForge MariaDB chart backup feature) to protect the full Castopod dataset.

Parameter Type Default Description
backup.enabled boolean false Enable scheduled S3 backup CronJob.
backup.schedule string "0 3 * * *" Cron schedule for backups.
backup.suspend boolean false Suspend the CronJob without deleting it.
backup.concurrencyPolicy string Forbid CronJob concurrency policy.
backup.successfulJobsHistoryLimit integer 3 Number of successful Job records to keep.
backup.failedJobsHistoryLimit integer 3 Number of failed Job records to keep.
backup.backoffLimit integer 1 Job retry limit.
backup.archivePrefix string castopod Prefix for backup archive filenames.
backup.images.archiver string docker.io/library/busybox:1.37 Image used for tar archive.
backup.images.uploader string docker.io/helmforge/mc:1.0.0 Image used for S3 upload.
backup.resources object {} Resources for backup containers.
backup.s3.endpoint string "" S3-compatible endpoint URL.
backup.s3.bucket string "" Target bucket name.
backup.s3.prefix string castopod Key prefix within the bucket.
backup.s3.createBucketIfNotExists boolean true Create the bucket automatically if it does not exist.
backup.s3.existingSecret string "" Existing secret containing S3 access and secret keys.
backup.s3.existingSecretAccessKeyKey string access-key Key in the existing secret for the S3 access key.
backup.s3.existingSecretSecretKeyKey string secret-key Key in the existing secret for the S3 secret key.
backup.s3.accessKey string "" Inline S3 access key (ignored when existingSecret is set).
backup.s3.secretKey string "" Inline S3 secret key (ignored when existingSecret is set).

Service

Parameter Type Default Description
service.type string ClusterIP Kubernetes service type.
service.port integer 80 Service port exposed to the cluster.
service.annotations object {} Annotations for the Service.

Ingress

Parameter Type Default Description
ingress.enabled boolean false Enable an Ingress resource.
ingress.ingressClassName string traefik Ingress class name.
ingress.annotations object {} Annotations for the Ingress (e.g. cert-manager).
ingress.hosts array [] Ingress host and path rules.
ingress.tls array [] TLS configuration (secret name and hosts).

Gateway API

Parameter Type Default Description
gatewayAPI.enabled boolean false Render Gateway API HTTPRoutes.
gatewayAPI.httpRoutes array [] HTTPRoutes; each requires parentRefs.

Probes

Parameter Type Default Description
probes.path string /healthcheck HTTP path used by every probe.
probes.startup.enabled boolean true Enable startup probe.
probes.startup.initialDelaySeconds integer 5 Startup probe initial delay.
probes.startup.periodSeconds integer 5 Startup probe period.
probes.startup.timeoutSeconds integer 3 Startup probe timeout.
probes.startup.failureThreshold integer 30 Startup probe failure threshold.
probes.liveness.enabled boolean true Enable liveness probe.
probes.liveness.initialDelaySeconds integer 0 Liveness probe initial delay.
probes.liveness.periodSeconds integer 15 Liveness probe period.
probes.liveness.timeoutSeconds integer 5 Liveness probe timeout.
probes.liveness.failureThreshold integer 3 Liveness probe failure threshold.
probes.readiness.enabled boolean true Enable readiness probe.
probes.readiness.initialDelaySeconds integer 0 Readiness probe initial delay.
probes.readiness.periodSeconds integer 10 Readiness probe period.
probes.readiness.timeoutSeconds integer 5 Readiness probe timeout.
probes.readiness.failureThreshold integer 3 Readiness probe failure threshold.

Resources and Security

Parameter Type Default Description
resources object {} CPU and memory requests and limits for the Castopod container.
podSecurityContext object non-root uid 33, RuntimeDefault seccomp Pod-level security context.
securityContext object no escalation, drop ALL, add NET_BIND_SERVICE Container security context for every Castopod container.
automountServiceAccountToken boolean false Mount the ServiceAccount token.
Why the chart does not run the upstream s6-overlay supervisor

The image’s /init supervisor depends on a setuid helper that fails without privilege escalation. The chart runs FrankenPHP and the scheduler as separate containers and runs the upstream bootstrap in the setup init container, so the pod can stay non-root with no privilege escalation. The FrankenPHP binary carries the NET_BIND_SERVICE file capability, which therefore stays in the bounding set.

Service Account

Parameter Type Default Description
serviceAccount.create boolean false Create a dedicated ServiceAccount.
serviceAccount.name string "" Override the ServiceAccount name.
serviceAccount.annotations object {} Annotations for the ServiceAccount.

Scheduling

Parameter Type Default Description
nodeSelector object {} Node selector for scheduling.
tolerations array [] Tolerations for scheduling.
affinity object {} Affinity rules.
topologySpreadConstraints array [] Topology spread constraints.
priorityClassName string "" PriorityClass for the pod.
terminationGracePeriodSeconds integer 30 Termination grace period.
podLabels object {} Extra labels for the pod.
podAnnotations object {} Extra annotations for the pod.

Extra

Parameter Type Default Description
extraVolumes array [] Extra volumes to attach to the pod.
extraVolumeMounts array [] Extra volume mounts for the container.
extraManifests array [] Extra Kubernetes manifests deployed alongside the chart.

Common Issues

Episodes not playing in podcast apps after URL change

If you change castopod.baseURL after publishing episodes, all RSS feed URLs will change and existing subscribers may lose access until their podcast app refreshes the feed. Plan your URL carefully before the first publish. If you must change it, use a 301 redirect from the old URL to the new one.

Log in with the superadmin email

The setup init container creates the superadmin from castopod.admin.* once, when no owner exists. Log in at /cp-auth/login with the email and the password from <release>-castopod-admin. To use the /cp-install wizard instead, set castopod.admin.create=false and complete it before exposing the instance: the first visitor becomes the owner.

Pod stuck in Init:0/1

The setup init container waits up to setup.databaseWaitTimeoutSeconds for MariaDB. Read kubectl logs deploy/<release>-castopod -c setup to see whether the database is unreachable or rejects the credentials.

More Information