Can I self-host Transistor?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Castopod. It takes one prompt, a 2048 MB VPS, and about 110 minutes. That is $19 a month you stop paying Transistor — $228 a year on the Starter plan.

Why people pay for Transistor

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Transistor sells the unglamorous half of podcasting: a feed URL that keeps answering years after you published episode one, storage and bandwidth for audio files that are large and get requested all at once, and download numbers somebody else keeps. The meter on the plan ladder is the honest shape of it. You are renting bandwidth and a promise to keep serving it, and the price steps up when your show gets listened to.

Transistor plans and list prices
PlanList priceWhat it buys
Starterthe plan this page prices against$19/mo20K downloads a month, unlimited podcasts and team members, 50 private podcast subscribers. $190 a year on annual billing, which the page advertises as twelve months for the price of ten.
Professional$49/mo100K downloads a month, 500 private podcast subscribers. $490 a year on annual billing.
Business$99/mo250K downloads a month, 3,000 private podcast subscribers. $990 a year on annual billing.
Enterprise$199/moShown as $199+ per month, or $1,990+ a year, for more than 250,000 downloads a month or more than 3,000 private subscribers. A starting figure with a contact link, not a published rate.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high

Replaced by Castopod

One project, named before the prompt, so you know what you are about to install.

Podcast hosting on a domain you own: your feed, your audio files, your download numbers, and no monthly download meter.

The only one here built to be a podcast host and nothing else: it publishes the RSS feed, serves the audio, counts the downloads itself and speaks the Podcasting 2.0 namespace, so Apple Podcasts and Spotify take the feed without a translation layer. It also federates, which no paid host offers. What you take on is the part Transistor's meter is really charging for, because every download now leaves your server on your bandwidth, and the feed URL is a promise you keep renewing.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Castopodour pickONE EVENING— setup effort 2 of 4

    Podcast hosting on a domain you own: your feed, your audio files, your download numbers, and no monthly download meter.

    The only one here built to be a podcast host and nothing else: it publishes the RSS feed, serves the audio, counts the downloads itself and speaks the Podcasting 2.0 namespace, so Apple Podcasts and Spotify take the feed without a translation layer. It also federates, which no paid host offers. What you take on is the part Transistor's meter is really charging for, because every download now leaves your server on your bandwidth, and the feed URL is a promise you keep renewing.

  2. PeerTubeONE EVENING— setup effort 2 of 4

    A video site of your own, with the player, the embed codes and the transcoding, on a server whose bandwidth bill is yours.

    Video-first, and honest about the gap: it publishes Podcast 2.0 RSS feeds that AntennaPod and Apple Podcasts accept, so an audio-only channel does work as a podcast. But the upload flow, the storage sizing and the whole interface are built around video, and the podcast feed is a well-supported export rather than the product. Pick it if you were going to publish video anyway and want the feed as well.

The swap

You're paying

Transistor

$19/mo · $228/yr

is replaced by

You'd run

Castopod

ONE EVENING · ~110 min to running · 2048 MB RAM

Transistor Starter · vendor list price · checked 2026-08-06 · source

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~110 min1–3 hours, through the first backup

The prompt

Two paths to the same Castopod: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

336 lines · 14,997 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Castopod 1.15.5 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Say this when you ask, because it is the one decision here that cannot be undone: `<DOMAIN>`
becomes `CP_BASEURL`, the address inside their feed and inside every audio URL that feed hands
to Apple Podcasts, Spotify and every app between. Moving hostname later means keeping the old
one alive as a redirect for as long as anyone still has the show subscribed. Its A record must
already point at this server.

Castopod, MariaDB and Redis need 2048 MB of RAM available and 10 GB free on /srv, and audio is
what eats the disk. All three images publish amd64 and arm64. Measure all five:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
timedatectl show -p NTPSynchronized --value
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name nobody resolves. If `timedatectl` prints `no`, stop: upstream requires an
NTP-synced clock, because fediverse servers reject signed requests that have drifted.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/castopod /srv/castopod/backups
sudo install -d -m 700 /srv/castopod/mariadb
ls -la /srv/castopod
```

Assert: `backups` owned by the login user, `mariadb` at mode `700` owned by root. Leave that one
alone; the MariaDB image chowns its own data directory and refuses one somebody claimed first.
Audio gets no directory here: step 4 keeps it in a named volume the image can own.

## 3. Secrets

Four secrets, all generated here: the database password, the MariaDB root password, the Redis
password and the analytics salt. Print none, keep them out of your summary and out of every log
line you quote back.

```bash
umask 077
cat > /srv/castopod/.env <<EOF
CP_BASEURL=https://<DOMAIN>/
DB_PASSWORD=$(openssl rand -hex 32)
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
CP_ANALYTICS_SALT=$(openssl rand -hex 32)
EOF
chmod 600 /srv/castopod/.env
umask 022
ls -l /srv/castopod/.env
```

Assert: mode `-rw-------` and the login user's name twice. The salt is 64 characters, the length
upstream's generator produces, and it is not an encryption key: Castopod hashes it with the
date, the listener's IP and their user agent so one person downloading twice counts once, and
that hash expires at midnight. Changing it costs a day of accuracy, not history.

## 4. compose.yml

```bash
cat > /srv/castopod/compose.yml <<'EOF'
# Castopod · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   official image ... https://docs.castopod.org/getting-started/docker.html
#   env bootstrap .... https://github.com/ad-aures/castopod/blob/v1.15.5/docker/production/s6-rc.d/bootstrap/prepare-environment.sh
#   mariadb support .. https://mariadb.org/about/maintenance-policy/
#
# Castopod is one image carrying FrankenPHP, Caddy and its own per-minute cron.
# MariaDB holds the shows, episodes and download counts; Redis holds the daily
# hashes that stop one listener being counted twice.
#
# Upstream's example names mariadb:12.1, a rolling release whose image has not
# been rebuilt since February 2026; this uses the 11.8 LTS line, supported to
# June 2028. Media is a named volume: the image ships /app/public/media owned
# by its www-data user at mode 770, which a host directory cannot reproduce.
# Only 8080 is published, the host's Caddy terminates TLS. Digests read
# 2026-08-06; all three do arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: castopod-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: castopod
      MARIADB_USER: castopod
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - /srv/castopod/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 stays inside the compose network.

  cache:
    image: redis:8.4.5-alpine@sha256:bd4a0d37e7cd830117ffec9329052b4a1887afa060c265e1768f82b177ff6f43
    container_name: castopod-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
    volumes:
      # Snapshots land here; those hashes expire at midnight anyway.
      - castopod-cache:/data
    # No `ports:` at all: 6379 stays inside the compose network.

  castopod:
    image: castopod/castopod:1.15.5@sha256:4e4f0440520f45257bfeac7be4347defd20048b4efef8f53d73ec9ed3a4f7966
    container_name: castopod
    restart: unless-stopped
    environment:
      # The bootstrap inside the image refuses to start without these two.
      CP_BASEURL: ${CP_BASEURL}
      CP_ANALYTICS_SALT: ${CP_ANALYTICS_SALT}
      CP_DATABASE_HOSTNAME: db
      CP_DATABASE_NAME: castopod
      CP_DATABASE_USERNAME: castopod
      CP_DATABASE_PASSWORD: ${DB_PASSWORD}
      # A Redis host switches the cache handler and forces a password.
      CP_REDIS_HOST: cache
      CP_REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - castopod-media:/app/public/media
    healthcheck:
      # Without the header this is answered by a redirect rather than the
      # check itself, and a dead database still reports healthy.
      test: ["CMD", "curl", "-fsS", "-H", "X-Forwarded-Proto: https", "-o", "/dev/null", "http://127.0.0.1:8080/health"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8135.
      - "127.0.0.1:8135:8080"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

volumes:
  castopod-media:
  castopod-cache:
EOF
cd /srv/castopod && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No default credential appears in this file, and the bootstrap
exits with an error rather than starting without `CP_BASEURL` or `CP_ANALYTICS_SALT`.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-castopod
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Castopod · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.castopod.org/getting-started/docker.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also CP_BASEURL in .env: the address
# inside your feed and inside every audio URL it hands a podcast app.

<DOMAIN> {
	# No `encode`: the bulk of this site is compressed audio. There is
	# deliberately no X-Frame-Options either, because Castopod ships an
	# embeddable player and framing it elsewhere is the point of it.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8135 is the loopback port compose publishes here, not a container port
	# and not open in the firewall. Caddy sets X-Forwarded-Proto itself, the
	# only thing telling Castopod this arrived over TLS.
	reverse_proxy 127.0.0.1:8135
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-castopod, reload,
and report what it objected to. Caddy requests the certificate on the first request and renews
it on its own; nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3, which matters here because listeners pull large files. 8135 is bound to 127.0.0.1, and
compose publishes no host port for 3306 or 6379. Assert: `Status: active`, rules for 80, 443/tcp
and 443/udp, nothing for 8135, 3306 or 6379.

## 7. Start and verify

On first start the container writes its config, runs every migration, then starts the web server
and its cron. Read step 10 before you read that log: never quote it back unfiltered.

```bash
cd /srv/castopod
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/cp-install
curl -sS https://<DOMAIN>/cp-install | grep -c 'Create your Super Admin account'
```

Assert all four, printing what you received. The loop ends on `200`. The health body contains
`"code":200`, which upstream returns only when the database, the cache and the media directory
all answered. The third prints `200`, the fourth `1`. If any miss, stop, run the filtered log
command from step 10 and `docker compose logs --tail 20 db`, and name the likely step: a
database never reporting healthy is step 2, a lasting `502` is step 5, `CP_ANALYTICS_SALT is
empty` is step 3. A running container is not success.

The first screen at https://<DOMAIN>/cp-install shows `4/4` beside the heading
`Create your Super Admin account`, with Username, Email and Password fields.

STOP: tell the user to open https://<DOMAIN>/cp-install, create their account with a password of
at least 8 characters that is not a dictionary word, put it in their password manager, and wait.
Do not continue until they confirm they are signed in. Self-registration is off in this release,
so that account is the only way in, and there is no reset path until mail is configured.

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/cp-install
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from cp_users where is_owner = 1" "$MARIADB_DATABASE"'
```

Assert both. The first prints `404`: once an owner exists the installer refuses everyone else
who finds that URL, and that is the security assert here. The second prints `1`, one owner, made
in a browser rather than seeded from a file.

## 8. First backup and restore

Three artifacts: a dump with the shows, episodes and download history, the audio itself, and
the config archive that rebuilds the service around them.

```bash
cd /srv/castopod
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/castopod/backups/castopod-db-$(date +%F).sql.gz
docker compose exec -T castopod tar -C /app/public/media -czf - . > /srv/castopod/backups/castopod-media-$(date +%F).tar.gz
sudo tar -czf /srv/castopod/backups/castopod-config-$(date +%F).tar.gz -C /srv/castopod compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/castopod/backups/
```

Assert: all three exist, all three non-empty, all three sizes printed. Nothing is stopped;
`mariadb-dump` snapshots a running database consistently. Tell the user the media archive is the
one that grows: kilobytes today, their largest file after a year of episodes.

A backup on the same disk is not a backup. Run this from the user's machine, not the server:

```bash
mkdir -p ~/backups/castopod
scp vps:/srv/castopod/backups/* ~/backups/castopod/
```

To restore: `docker compose down`, `sudo rm -rf /srv/castopod/mariadb`, recreate it as in step 2,
untar the config archive into /srv/castopod so `.env` is back first, `docker compose up -d db`,
wait about 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
`docker compose up -d`, then the audio with
`docker compose exec -T castopod tar -C /app/public/media -xzf - < backups/castopod-media-<date>.tar.gz`.
`.env` comes back first because MariaDB reads its password from it the moment it initialises an
empty directory. The stakes: feed URLs in other people's apps outlive this server, and a restore
without the audio leaves those apps pointing at nothing.

## 9. Updating later

New versions are at https://code.castopod.org/adaures/castopod/-/releases, mirrored at
https://github.com/ad-aures/castopod/tags. Take all three backups first, then edit the castopod
image line in /srv/castopod/compose.yml to the new tag and digest:

```bash
cd /srv/castopod
docker compose pull
docker compose up -d
docker compose logs --tail 40 castopod | grep -viE 'password|salt'
```

The container migrates on every start, so a version bump needs no separate command. Watch that
log until it settles, then re-run step 7's health check and confirm /cp-install still gives
`404`.

## 10. What will probably go wrong

The first thing `docker compose logs castopod` prints is your entire configuration in plain text,
including the database password, the Redis password and the analytics salt. I pasted that log
into a chat window before I noticed and had to regenerate all three. The container rewrites its
config on every start and prints it under `INFO: Using config:`; no setting turns that off. Pipe
it through the filter every time:
`docker compose logs --tail 40 castopod | grep -viE 'password|salt'`.

## 11. Out of scope

- Do not configure SMTP. Publishing works with no mail; mail buys password reset and inviting a
  second contributor, and both are worth a separate evening.
- Do not change `CP_ADMIN_GATEWAY` or `CP_AUTH_GATEWAY`. Upstream suggests renaming those routes,
  and doing it here would make every admin URL in this prompt wrong.
- Do not set `CP_MEDIA_FILE_MANAGER` or any `CP_MEDIA_S3_` variable. Object storage for audio is
  a real option and a different install with a different bill.
- Do not add a cron job on the host. The image runs `spark tasks:run` itself every minute, which
  imports feeds and pushes episodes to the fediverse.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Castopod 1.15.5 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1, because it is the one decision here you cannot undo. `<DOMAIN>`
becomes `CP_BASEURL`, the address inside your RSS feed and inside every audio file URL that
feed hands to Apple Podcasts, Spotify and every app in between. Change it later and every app
that already has your show subscribed keeps asking the old name, so you are keeping that
hostname alive as a redirect for years. Pick the one you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
timedatectl show -p NTPSynchronized --value
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, `yes`,
and your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. A `no` from
`timedatectl` matters more here than on most installs: Castopod federates, and fediverse servers
reject signed requests whose clocks have drifted. Fix it with
`sudo timedatectl set-ntp true` before going on. The 10 GB floor is about audio; a weekly
hour-long show is roughly 60 MB an episode, so plan the disk for the year you intend to record,
not for today.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/castopod /srv/castopod/backups
sudo install -d -m 700 /srv/castopod/mariadb
ls -la /srv/castopod
```

You should see: `backups` owned by you, and `mariadb` at mode `drwx------` owned by root.

If you do not: leave `mariadb` owned by root on purpose. The MariaDB image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it refuse
to initialise. There is no directory here for your audio: step 4 keeps the media in a Docker
named volume, because the Castopod image ships /app/public/media owned by its own www-data user
at mode 770 and a fresh directory on the host would be root-owned and unwritable. Step 8 gets
the audio back out again.

## 3. Secrets

Four secrets: the database password, the MariaDB root password, the Redis password and the
analytics salt. All four are generated here, on the server, and all four go into a file only you
can read.

```bash
umask 077
cat > /srv/castopod/.env <<EOF
CP_BASEURL=https://<DOMAIN>/
DB_PASSWORD=$(openssl rand -hex 32)
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
CP_ANALYTICS_SALT=$(openssl rand -hex 32)
EOF
chmod 600 /srv/castopod/.env
umask 022
ls -l /srv/castopod/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste, and keep the trailing slash.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/castopod/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
all four values, which is fine before the database exists and a problem afterwards: MariaDB
keeps the password it was created with, so a changed `DB_PASSWORD` on an existing directory
produces an authentication failure in the Castopod log rather than anything about passwords.

Do not paste that file, any of those four values, or any command output containing them into
this chat window. That matters more on this install than on most, because step 7 explains that
Castopod prints its whole configuration into its own container log on every start, so
`docker compose logs castopod` is a command whose output you must never paste here unfiltered.

The salt is 64 characters, the length upstream's own generator produces. It is not an encryption
key: Castopod hashes it together with the date, the listener's IP and their user agent so that
one person downloading an episode twice is counted once, and the hash expires at midnight.
Changing it later costs a day of counting accuracy, not your history.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/castopod/compose.yml <<'EOF'
# Castopod · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   official image ... https://docs.castopod.org/getting-started/docker.html
#   env bootstrap .... https://github.com/ad-aures/castopod/blob/v1.15.5/docker/production/s6-rc.d/bootstrap/prepare-environment.sh
#   mariadb support .. https://mariadb.org/about/maintenance-policy/
#
# Castopod is one image carrying FrankenPHP, Caddy and its own per-minute cron.
# MariaDB holds the shows, episodes and download counts; Redis holds the daily
# hashes that stop one listener being counted twice.
#
# Upstream's example names mariadb:12.1, a rolling release whose image has not
# been rebuilt since February 2026; this uses the 11.8 LTS line, supported to
# June 2028. Media is a named volume: the image ships /app/public/media owned
# by its www-data user at mode 770, which a host directory cannot reproduce.
# Only 8080 is published, the host's Caddy terminates TLS. Digests read
# 2026-08-06; all three do arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: castopod-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: castopod
      MARIADB_USER: castopod
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - /srv/castopod/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 stays inside the compose network.

  cache:
    image: redis:8.4.5-alpine@sha256:bd4a0d37e7cd830117ffec9329052b4a1887afa060c265e1768f82b177ff6f43
    container_name: castopod-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
    volumes:
      # Snapshots land here; those hashes expire at midnight anyway.
      - castopod-cache:/data
    # No `ports:` at all: 6379 stays inside the compose network.

  castopod:
    image: castopod/castopod:1.15.5@sha256:4e4f0440520f45257bfeac7be4347defd20048b4efef8f53d73ec9ed3a4f7966
    container_name: castopod
    restart: unless-stopped
    environment:
      # The bootstrap inside the image refuses to start without these two.
      CP_BASEURL: ${CP_BASEURL}
      CP_ANALYTICS_SALT: ${CP_ANALYTICS_SALT}
      CP_DATABASE_HOSTNAME: db
      CP_DATABASE_NAME: castopod
      CP_DATABASE_USERNAME: castopod
      CP_DATABASE_PASSWORD: ${DB_PASSWORD}
      # A Redis host switches the cache handler and forces a password.
      CP_REDIS_HOST: cache
      CP_REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - castopod-media:/app/public/media
    healthcheck:
      # Without the header this is answered by a redirect rather than the
      # check itself, and a dead database still reports healthy.
      test: ["CMD", "curl", "-fsS", "-H", "X-Forwarded-Proto: https", "-o", "/dev/null", "http://127.0.0.1:8080/health"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8135.
      - "127.0.0.1:8135:8080"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

volumes:
  castopod-media:
  castopod-cache:
EOF
cd /srv/castopod && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/castopod/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/castopod/compose.yml` and paste again in one go. Two choices in that file are worth
knowing about. The MariaDB tag is the 11.8 long-term-support line rather than the 12.1 that
upstream's example names, because 12.1 is a rolling release whose image stopped being rebuilt in
February 2026 while 11.8 is supported until June 2028, and Castopod's own requirement is only
10.2 or newer. And the health check carries an `X-Forwarded-Proto` header, because without it
Castopod answers with a redirect to https instead of running the check, and a container with a
dead database would still report itself healthy.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-castopod
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Castopod · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.castopod.org/getting-started/docker.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also CP_BASEURL in .env: the address
# inside your feed and inside every audio URL it hands a podcast app.

<DOMAIN> {
	# No `encode`: the bulk of this site is compressed audio. There is
	# deliberately no X-Frame-Options either, because Castopod ships an
	# embeddable player and framing it elsewhere is the point of it.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8135 is the loopback port compose publishes here, not a container port
	# and not open in the firewall. Caddy sets X-Forwarded-Proto itself, the
	# only thing telling Castopod this arrived over TLS.
	reverse_proxy 127.0.0.1:8135
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-castopod /etc/caddy/Caddyfile`, reload,
and paste again. Caddy terminates TLS and speaks plain http to the container, and the
`X-Forwarded-Proto` header it adds by itself is the only thing telling Castopod the request
arrived over https. That is also why there is no `X-Frame-Options` in the block: Castopod ships
an embeddable player, and blocking framing would break it on every site you embed an episode in.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8135`, `3306` or `6379`.

If you do not: delete anything for those three with `sudo ufw delete allow 8135`. 8135 is bound
to 127.0.0.1 by the compose file, and MariaDB and Redis are never published at all, so neither
has a host port a firewall rule could apply to. 80/tcp answers the ACME challenge and redirects
to HTTPS, 443/tcp is the only way in, and 443/udp is HTTP/3, which is worth having when the
files leaving this box are audio. `Status: inactive` is a different problem: Prompt Zero left
this firewall enabled, so something has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

On the first start the container writes its configuration, runs every database migration, then
starts the web server and a cron that fires every minute. Give it a minute or two.

```bash
cd /srv/castopod
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/cp-install
curl -sS https://<DOMAIN>/cp-install | grep -c 'Create your Super Admin account'
```

You should see, in order: the loop reaching `200`, a small JSON object containing `"code":200`,
then `200`, then `1`.

If you do not: that health endpoint is the useful one, because upstream returns `200` from it
only when the database answered, the Redis cache answered and the media directory was writable,
so a `503` there tells you which of the three to look at. If the loop never reaches `200`, run
`docker compose logs --tail 20 db` first, because a database that never reports healthy is step
2 done wrong. Then, and only with the filter, look at the app:
`docker compose logs --tail 40 castopod | grep -viE 'password|salt'`. Never run that command
without the filter while this chat window is open, and read step 10 before you do.

The first screen at https://<DOMAIN>/cp-install shows `4/4` beside the heading
`Create your Super Admin account`, with Username, Email and Password fields and a
`Finish install` button. Open it in a browser now and create your account. The password must be
at least 8 characters and not a dictionary word, and it is the only credential this install has:
self-registration is off in this release, and there is no reset path until you configure mail.
Put it in your password manager before you close the tab.

Once you are signed in, prove that the installer closed behind you:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/cp-install
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from cp_users where is_owner = 1" "$MARIADB_DATABASE"'
```

You should see: `404`, then `1`.

If you do not: a `200` from that first command means no owner account exists yet, so the wizard
is still open to whoever finds the URL, and you should go back and finish it now rather than
later. Castopod decides this by looking for a user row flagged as the instance owner; once one
exists the installer answers `404` for good. A count of `0` from the second command says the
same thing from the other side. Do not treat three running containers as success.

## 8. First backup and restore

Three artifacts. The dump holds the shows, the episodes and the download history. The media
archive holds the audio. The config archive rebuilds the service around them.

```bash
cd /srv/castopod
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/castopod/backups/castopod-db-$(date +%F).sql.gz
docker compose exec -T castopod tar -C /app/public/media -czf - . > /srv/castopod/backups/castopod-media-$(date +%F).tar.gz
sudo tar -czf /srv/castopod/backups/castopod-config-$(date +%F).tar.gz -C /srv/castopod compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/castopod/backups/
```

You should see: three files, all small on a fresh install. Nothing goes offline; `mariadb-dump`
snapshots a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump` failed
and the shell created the file anyway. Run the dump line without `| gzip` to read the error. The
media archive is the one that changes character over time: it is kilobytes today and the largest
file you own after a year of weekly episodes, so whatever you set up to copy these off the box
has to be sized for audio, not for a database dump.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/castopod
scp vps:/srv/castopod/backups/* ~/backups/castopod/
```

You should see: three files copied, and all three listed by `ls -lh ~/backups/castopod/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty show:

```bash
cd /srv/castopod
docker compose down
sudo rm -rf /srv/castopod/mariadb
sudo install -d -m 700 /srv/castopod/mariadb
docker compose up -d db
sleep 30
gunzip -c /srv/castopod/backups/castopod-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose up -d
sleep 30
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/health
```

You should see: no output from the `gunzip` pipe, then `200` from the last command, and your
login still working at https://<DOMAIN>/cp-auth/login.

If you do not: `Access denied for user` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what you are rehearsing:
the feed URL you hand out lives in other people's podcast apps for years, and a restore that
brings back the database but not the audio leaves every one of those apps downloading nothing.
The audio comes back with
`docker compose exec -T castopod tar -C /app/public/media -xzf - < backups/castopod-media-<date>.tar.gz`.

## 9. Updating later

New versions are at https://code.castopod.org/adaures/castopod/-/releases, mirrored at
https://github.com/ad-aures/castopod/tags. Take all three backup artifacts first, then edit the
castopod `image:` line in /srv/castopod/compose.yml to the new tag and its digest.

```bash
cd /srv/castopod
docker compose pull
docker compose up -d
docker compose logs --tail 40 castopod | grep -viE 'password|salt'
```

You should see: the bootstrap output, migrations, then the server starting, and no repeating
restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, and confirm
https://<DOMAIN>/cp-install still answers `404`, because that is the check that tells you the
database came through the migration with your owner account intact.

## 10. What will probably go wrong

The first thing `docker compose logs castopod` prints is your entire configuration in plain text,
including the database password, the Redis password and the analytics salt. I pasted that log
into a chat window before I noticed and had to regenerate all three. The container rewrites its
config file on every start and prints it under `INFO: Using config:`, and no setting turns that
off. Pipe it through the filter every time:
`docker compose logs --tail 40 castopod | grep -viE 'password|salt'`.

## 11. Out of scope

- Do not configure SMTP. Publishing works with no mail; mail buys password reset and inviting a
  second contributor, and both are worth a separate evening.
- Do not change `CP_ADMIN_GATEWAY` or `CP_AUTH_GATEWAY`. Upstream suggests renaming those routes,
  and doing it here would make every admin URL in this guide wrong.
- Do not set `CP_MEDIA_FILE_MANAGER` or any `CP_MEDIA_S3_` variable. Object storage for audio is
  a real option and a different install with a different bill.
- Do not add a cron job on the host. The image runs `spark tasks:run` itself every minute, which
  imports feeds and pushes episodes to the fediverse.

331 lines · 14,997 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Castopod 1.15.5, with the MariaDB and Redis it needs, under ~/selfhost/castopod,
answering at http://localhost:8135.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all. The
feed URL Castopod publishes here begins with http://localhost:8135, which means "this computer"
wherever it is read, so Apple Podcasts cannot fetch it, a co-host cannot, and neither can the
user's phone. What they get is a real archive of the show that only this computer opens.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Castopod, MariaDB and Redis need 2048 MB of
RAM available and 10 GB free on the home disk, and the audio is what eats the disk. All three
images publish amd64 and arm64. On macOS and Windows that memory figure is the host's, and
Docker Desktop takes its allocation out of it. If available RAM is under 2048 MB or free disk is
under 10 GB, print both numbers and stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/castopod/backups
ls -la ~/selfhost/castopod
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` or `media` folder: step
5 keeps all three data sets in volumes Docker manages, because MariaDB chowns its directory to
its own uid and the image ships /app/public/media owned by its own user. Step 8 copies the audio
back out through the container.

## 4. Secrets

Four secrets: the database password, the MariaDB root password, the Redis password and the
analytics salt. Generate all four here, print none, keep them out of your summary and logs.

```bash
umask 077
cat > ~/selfhost/castopod/.env <<EOF
CP_BASEURL=http://localhost:8135/
DB_PASSWORD=$(openssl rand -hex 32)
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
CP_ANALYTICS_SALT=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/castopod/.env
umask 022
ls -l ~/selfhost/castopod/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. The salt is 64 characters, the length upstream's generator produces,
and it is not an encryption key: Castopod hashes it with the date, the listener's IP and their
user agent so one download counted twice counts once. On Windows the mode bits are advisory.

## 5. compose.yml

```bash
cat > ~/selfhost/castopod/compose.yml <<'EOF'
# Castopod · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://docs.castopod.org/getting-started/docker.html,
# not copied from a repository. Differences from the VPS file, and only these:
# all three data mounts are named volumes, because MariaDB and the Castopod
# image each chown their own directory to a uid a home-directory bind mount
# cannot grant on Windows; and CP_DISABLE_HTTPS is 1, because Castopod
# redirects to https by default, a loop in front of http://localhost.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: castopod-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: castopod
      MARIADB_USER: castopod
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - castopod-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:`: 3306 stays inside the compose network.

  cache:
    image: redis:8.4.5-alpine@sha256:bd4a0d37e7cd830117ffec9329052b4a1887afa060c265e1768f82b177ff6f43
    container_name: castopod-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
    volumes:
      - castopod-cache:/data

  castopod:
    image: castopod/castopod:1.15.5@sha256:4e4f0440520f45257bfeac7be4347defd20048b4efef8f53d73ec9ed3a4f7966
    container_name: castopod
    restart: unless-stopped
    environment:
      CP_BASEURL: ${CP_BASEURL}
      CP_ANALYTICS_SALT: ${CP_ANALYTICS_SALT}
      CP_DISABLE_HTTPS: "1"
      CP_DATABASE_HOSTNAME: db
      CP_DATABASE_NAME: castopod
      CP_DATABASE_USERNAME: castopod
      CP_DATABASE_PASSWORD: ${DB_PASSWORD}
      CP_REDIS_HOST: cache
      CP_REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - castopod-media:/app/public/media
    healthcheck:
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/health"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi can reach 8135.
      - "127.0.0.1:8135:8080"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

volumes:
  castopod-db:
  castopod-media:
  castopod-cache:
EOF
cd ~/selfhost/castopod && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one port, three named volumes.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one, and browsers treat
  http://localhost as a secure context anyway. That is why the compose file sets
  `CP_DISABLE_HTTPS`: Castopod redirects to https by default, and with nothing in front that is
  a loop rather than a protection.
- No firewall rule. Nothing is published beyond loopback.

8135 is bound to 127.0.0.1, this computer only. No phone, no laptop on the same wifi, no
fediverse server. Confirm it:

```bash
grep -n '"127.0.0.1:' ~/selfhost/castopod/compose.yml
```

Assert: one line, `- "127.0.0.1:8135:8080"`. MariaDB and Redis publish no host port.

## 7. Start and verify

On first start the container writes its config, runs every migration, then starts the web server
and its cron. That log prints the whole configuration, so filter it:
`docker compose logs --tail 40 castopod | grep -viE 'password|salt'`.

```bash
cd ~/selfhost/castopod
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8135/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8135/health
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8135/cp-install
curl -sS http://localhost:8135/cp-install | grep -c 'Create your Super Admin account'
```

Assert all four, printing what you received. The loop ends on `200`. The health body contains
`"code":200`, which upstream returns only when the database, the cache and the media directory
all answered. The third prints `200`, the fourth `1`. If any miss, stop, run the filtered log
command above and `docker compose logs --tail 20 db`: a database that never reports healthy is
step 4. If `port is already allocated` came back, find what holds 8135
(`lsof -nP -iTCP:8135 -sTCP:LISTEN`, or `netstat -ano | findstr :8135` on Windows) and stop
until it is freed. A running container is not success.

The first screen at http://localhost:8135/cp-install shows `4/4` beside the heading
`Create your Super Admin account`, with Username, Email and Password fields.

STOP: tell the user to open http://localhost:8135/cp-install, create their account with a
password of at least 8 characters that is not a dictionary word, put it in their password
manager, and wait. Do not continue until they confirm they are signed in. Self-registration is
off, so that account is the only way in.

```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8135/cp-install
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from cp_users where is_owner = 1" "$MARIADB_DATABASE"'
```

Assert both. The first prints `404`: once an owner exists the installer refuses everyone else,
the security assert here. The second prints `1`, one owner.

## 8. First backup and restore

Three artifacts: a dump with the shows, episodes and download history, the audio, and the config
archive that rebuilds the service around them.

```bash
cd ~/selfhost/castopod
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > backups/castopod-db-$(date +%F).sql.gz
docker compose exec -T castopod tar -C /app/public/media -czf - . > backups/castopod-media-$(date +%F).tar.gz
tar -C ~/selfhost/castopod -czf backups/castopod-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/castopod/backups/
```

Assert: all three exist, all three non-empty, all three sizes printed. Nothing is stopped;
`mariadb-dump` snapshots a running database consistently. The media archive grows: kilobytes
today, the largest file on this disk after a year of episodes.

All three sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a sync folder or a USB
stick, and copy all three there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms all three filenames are there, or say plainly that this
install has no backup.

To restore: `cd ~/selfhost/castopod`, untar the config archive there first, because MariaDB
takes `DB_PASSWORD` from .env the moment it initialises an empty volume. Then
`docker compose down -v`, the one place `-v` belongs because it drops the old volumes on
purpose, `docker compose up -d db`, wait 30 seconds for healthy, pipe `gunzip -c` on the
`.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
`docker compose up -d`, then
`docker compose exec -T castopod tar -C /app/public/media -xzf - < backups/castopod-media-<date>.tar.gz`.
Open one episode and play it. That is the disaster plan.

## 9. Updating later

New versions are at https://code.castopod.org/adaures/castopod/-/releases, mirrored at
https://github.com/ad-aures/castopod/tags. Back up first, then edit the castopod image line in
compose.yml to the new tag and digest:

```bash
cd ~/selfhost/castopod
docker compose pull
docker compose up -d
docker compose logs --tail 40 castopod | grep -viE 'password|salt'
```

The container migrates on every start. Watch that log until it settles, then re-run step 7's
health check.

## 10. What will probably go wrong

I rebooted this machine, opened http://localhost:8135, got a connection error, and spent ten
minutes convinced the database had been eaten. It had not: Docker Desktop had not started with
the session, so nothing was listening on 8135. `restart: unless-stopped` acts only once the
Docker daemon is up. Turn on start-at-login, then after a reboot run
`cd ~/selfhost/castopod && docker compose up -d` before concluding anything broke.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `CP_BASEURL` to this machine's LAN address and do not rebind 8135 to 0.0.0.0 so
  a phone can reach it. That publishes an admin login on every network the user joins.
- Do not configure SMTP, and do not set `CP_MEDIA_FILE_MANAGER` or any `CP_MEDIA_S3_` variable.
  Each is a different install with a different bill.
compose.local.ymlthe services, pinned · local layout72 lines

authored from upstream docs, never pasted · 2,569 bytes

# Castopod · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://docs.castopod.org/getting-started/docker.html,
# not copied from a repository. Differences from the VPS file, and only these:
# all three data mounts are named volumes, because MariaDB and the Castopod
# image each chown their own directory to a uid a home-directory bind mount
# cannot grant on Windows; and CP_DISABLE_HTTPS is 1, because Castopod
# redirects to https by default, a loop in front of http://localhost.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: castopod-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: castopod
      MARIADB_USER: castopod
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - castopod-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:`: 3306 stays inside the compose network.

  cache:
    image: redis:8.4.5-alpine@sha256:bd4a0d37e7cd830117ffec9329052b4a1887afa060c265e1768f82b177ff6f43
    container_name: castopod-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
    volumes:
      - castopod-cache:/data

  castopod:
    image: castopod/castopod:1.15.5@sha256:4e4f0440520f45257bfeac7be4347defd20048b4efef8f53d73ec9ed3a4f7966
    container_name: castopod
    restart: unless-stopped
    environment:
      CP_BASEURL: ${CP_BASEURL}
      CP_ANALYTICS_SALT: ${CP_ANALYTICS_SALT}
      CP_DISABLE_HTTPS: "1"
      CP_DATABASE_HOSTNAME: db
      CP_DATABASE_NAME: castopod
      CP_DATABASE_USERNAME: castopod
      CP_DATABASE_PASSWORD: ${DB_PASSWORD}
      CP_REDIS_HOST: cache
      CP_REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - castopod-media:/app/public/media
    healthcheck:
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/health"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi can reach 8135.
      - "127.0.0.1:8135:8080"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

volumes:
  castopod-db:
  castopod-media:
  castopod-cache:

agent-readable mirror: /self-host/transistor-fm.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned86 lines

authored from upstream docs, never pasted · 3,531 bytes

# Castopod · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   official image ... https://docs.castopod.org/getting-started/docker.html
#   env bootstrap .... https://github.com/ad-aures/castopod/blob/v1.15.5/docker/production/s6-rc.d/bootstrap/prepare-environment.sh
#   mariadb support .. https://mariadb.org/about/maintenance-policy/
#
# Castopod is one image carrying FrankenPHP, Caddy and its own per-minute cron.
# MariaDB holds the shows, episodes and download counts; Redis holds the daily
# hashes that stop one listener being counted twice.
#
# Upstream's example names mariadb:12.1, a rolling release whose image has not
# been rebuilt since February 2026; this uses the 11.8 LTS line, supported to
# June 2028. Media is a named volume: the image ships /app/public/media owned
# by its www-data user at mode 770, which a host directory cannot reproduce.
# Only 8080 is published, the host's Caddy terminates TLS. Digests read
# 2026-08-06; all three do arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: castopod-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: castopod
      MARIADB_USER: castopod
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - /srv/castopod/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 stays inside the compose network.

  cache:
    image: redis:8.4.5-alpine@sha256:bd4a0d37e7cd830117ffec9329052b4a1887afa060c265e1768f82b177ff6f43
    container_name: castopod-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
    volumes:
      # Snapshots land here; those hashes expire at midnight anyway.
      - castopod-cache:/data
    # No `ports:` at all: 6379 stays inside the compose network.

  castopod:
    image: castopod/castopod:1.15.5@sha256:4e4f0440520f45257bfeac7be4347defd20048b4efef8f53d73ec9ed3a4f7966
    container_name: castopod
    restart: unless-stopped
    environment:
      # The bootstrap inside the image refuses to start without these two.
      CP_BASEURL: ${CP_BASEURL}
      CP_ANALYTICS_SALT: ${CP_ANALYTICS_SALT}
      CP_DATABASE_HOSTNAME: db
      CP_DATABASE_NAME: castopod
      CP_DATABASE_USERNAME: castopod
      CP_DATABASE_PASSWORD: ${DB_PASSWORD}
      # A Redis host switches the cache handler and forces a password.
      CP_REDIS_HOST: cache
      CP_REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - castopod-media:/app/public/media
    healthcheck:
      # Without the header this is answered by a redirect rather than the
      # check itself, and a dead database still reports healthy.
      test: ["CMD", "curl", "-fsS", "-H", "X-Forwarded-Proto: https", "-o", "/dev/null", "http://127.0.0.1:8080/health"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8135.
      - "127.0.0.1:8135:8080"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

volumes:
  castopod-media:
  castopod-cache:
Caddyfilethe hostname and TLS26 lines

authored from upstream docs, never pasted · 1,048 bytes

# Castopod · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.castopod.org/getting-started/docker.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also CP_BASEURL in .env: the address
# inside your feed and inside every audio URL it hands a podcast app.

<DOMAIN> {
	# No `encode`: the bulk of this site is compressed audio. There is
	# deliberately no X-Frame-Options either, because Castopod ships an
	# embeddable player and framing it elsewhere is the point of it.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8135 is the loopback port compose publishes here, not a container port
	# and not open in the firewall. Caddy sets X-Forwarded-Proto itself, the
	# only thing telling Castopod this arrived over TLS.
	reverse_proxy 127.0.0.1:8135
}
install.shthe same install, no agent170 lines

authored from upstream docs, never pasted · 8,270 bytes

#!/usr/bin/env bash
# Castopod · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=podcast.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.castopod.org/getting-started/docker.html
#   https://docs.castopod.org/getting-started/install.html
#   https://github.com/ad-aures/castopod/blob/v1.15.5/docker/production/s6-rc.d/bootstrap/prepare-environment.sh
#   https://mariadb.org/about/maintenance-policy/
#
# Four secrets are generated here, on this machine: the database password, the
# MariaDB root password, the Redis password and the analytics salt. All four go
# into /srv/castopod/.env with mode 600 and none is ever printed.
#
# DOMAIN_HOST becomes CP_BASEURL, the address inside your RSS feed and inside
# every audio file URL that feed hands a podcast app. Choose it once. Changing
# it later means keeping the old hostname alive as a redirect for years.
#
# This script stops one step short of finished, on purpose: only a human with a
# browser can fill in the Create your Super Admin account form, and until that
# account exists the installer is open to anyone who finds the URL. The closing
# summary says what to do and how to check it.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/castopod}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. podcast.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP plus MariaDB plus Redis wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; audio makes this install want 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# Federation rejects signed requests from a server whose clock has drifted.
if command -v timedatectl >/dev/null 2>&1; then
	[ "$(timedatectl show -p NTPSynchronized --value)" = "yes" ] \
		|| die "the clock is not NTP-synced. Run: sudo timedatectl set-ntp true"
fi

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/mariadb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the four secrets, on the server -----------------------------
#
# Hex for all four: they travel through a compose file, a connection string and
# a redis-server argument, and none of those wants escaping. The salt is 64
# characters, the length upstream's own generator produces. Read them later with
#   sudo grep -E 'PASSWORD|SALT' /srv/castopod/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		CP_BASEURL=https://${DOMAIN_HOST}/
		DB_PASSWORD=$(openssl rand -hex 32)
		DB_ROOT_PASSWORD=$(openssl rand -hex 32)
		REDIS_PASSWORD=$(openssl rand -hex 32)
		CP_ANALYTICS_SALT=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-castopod"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8135, 3306 and 6379 are not among them ----------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8135, 3306 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The container writes its config, runs every migration, then starts the web
# server and a cron that fires every minute. Its log contains all four secrets,
# so never quote it back unfiltered:
#   docker compose logs --tail 40 castopod | grep -viE 'password|salt'

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 castopod | grep -viE 'password|salt'"

# Upstream returns 200 from /health only when the database, the cache handler
# and the media directory all answered.
curl -sS "https://${DOMAIN_HOST}/health" | grep -q '"code":200' \
	|| die "/health answered 200 without code 200. Check: docker compose logs --tail 20 db"

# The installer must be up and on its final step, waiting for a human.
install_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/cp-install" || true)"
[ "$install_code" = "200" ] || die "/cp-install returned ${install_code}, not 200. Stop and investigate."
curl -sS "https://${DOMAIN_HOST}/cp-install" | grep -q 'Create your Super Admin account' \
	|| die "/cp-install did not render the Create your Super Admin account form"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > "$APP_DIR/backups/castopod-db-${STAMP}.sql.gz"
docker compose exec -T castopod tar -C /app/public/media -czf - . > "$APP_DIR/backups/castopod-media-${STAMP}.tar.gz"
sudo tar -czf "$APP_DIR/backups/castopod-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/castopod-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/castopod-media-${STAMP}.tar.gz" ] || die "the media archive is empty"

cat <<-DONE

	Castopod is answering at https://${DOMAIN_HOST}/health

	  1. FINISH THE INSTALL NOW, in a browser:
	       https://${DOMAIN_HOST}/cp-install
	     The form says Create your Super Admin account. Until you fill it in,
	     that page is open to anyone who finds it. Use a password of at least 8
	     characters that is not a dictionary word, and put it in your password
	     manager: self-registration is off, so it is the only way in, and there
	     is no reset path until you configure mail.
	  2. Then check the installer closed behind you:
	       curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/cp-install
	     It must print 404. Anything else means no owner account exists yet.
	  3. Your four secrets are in $APP_DIR/.env, mode 600. None was printed
	     here, and none is printed by any command in this script. The container
	     log is the exception: it prints all four on every start, so read it as
	       docker compose logs --tail 40 castopod | grep -viE 'password|salt'
	  4. First backup written to $APP_DIR/backups: a database dump, the media
	     archive and a config archive. They are on the same disk as the data,
	     which is not a backup. Copy them somewhere else tonight, and size that
	     somewhere for audio rather than for a database dump.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Transistor.

  • The feed URL is the promise you are now making. Every podcast app that subscribes stores the hostname you picked before the install and keeps asking that hostname for years, so moving later means keeping the old name alive as a redirect for as long as anyone still has the show. That permanence is most of what a podcast host actually sells.
  • Bandwidth is yours, and so is being on call. Every download is a large file leaving your server, an episode that gets shared widely arrives as traffic on a box you sized for a quiet week, and nobody else notices at 3am when the feed stops answering mid-launch. There is no download meter here, which also means nothing stands between you and the bill.
  • You own a MariaDB and a volume full of audio. The database holds the shows, the episodes and the download history; the media volume holds the payload, and it is the backup that stops being small after the first year. Backups are a dump plus a tar taken through the container, restores are the reverse, and one you have never restored is not a backup.
  • The analytics ship with the app, and nobody audits them. Castopod counts downloads and unique listeners itself, de-duplicating with a salted hash of the date, the listener's IP and their user agent, and the image carries a geo database so country charts work with no MaxMind account. That database is baked in at build time, so the country data ages with the image pin, and the numbers are yours, which also means an advertiser has only your word for them.
  • The container prints its whole configuration, all four generated secrets included, into its own log on every start. No setting turns that off, so `docker compose logs` is a command you always filter, and a log pasted into a chat window is a secret rotation you now have to do.

Where this came from

“TLS is mandatory for ActivityPub to work. This job can easily be handled by a reverse proxy, for example with Caddy.”

  • Castopod publishes one official image, castopod/castopod, an all-in-one FrankenPHP and Caddy build that requires a MySQL-compatible database and optionally uses Redis as its cache layer. source
  • The image's bootstrap exits with an error when CP_BASEURL or CP_ANALYTICS_SALT is missing, rewrites the application's .env on every start, and then prints that file, database and Redis passwords included, into the container log. source
  • Once a user flagged as the instance owner exists, the install wizard at /cp-install answers 404 to everyone, which is what makes the closed installer a checkable assertion rather than a hope. source
  • The /health endpoint returns 200 only when Castopod could connect to the database, reach its cache handler and write through its media file manager, and 503 naming the failing check otherwise. source
  • Castopod is developed on its own GitLab at code.castopod.org; github.com/ad-aures/castopod is the synchronised read-only mirror this entry records, and github.com/castopod/castopod does not exist. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Transistor?

    Not Transistor itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Castopod. Podcast hosting on a domain you own: your feed, your audio files, your download numbers, and no monthly download meter. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 110 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Transistor?

    Castopod. Podcast hosting on a domain you own: your feed, your audio files, your download numbers, and no monthly download meter. The only one here built to be a podcast host and nothing else: it publishes the RSS feed, serves the audio, counts the downloads itself and speaks the Podcasting 2.0 namespace, so Apple Podcasts and Spotify take the feed without a translation layer. It also federates, which no paid host offers. What you take on is the part Transistor's meter is really charging for, because every download now leaves your server on your bandwidth, and the feed URL is a promise you keep renewing. Also evaluated and ranked below it: PeerTube. The prompt on this page installs Castopod only. Castopod is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Transistor?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Castopod itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Transistor Starter, $19/mo — $228 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Castopod install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Castopod on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Castopod on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: The feed URL becomes http://localhost:8135, which no podcast app on another device can fetch and no fediverse server can reach, so this path gives you a private archive of your show rather than a way to publish it. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.