# Can I self-host Transistor?

**YES** — it's called Castopod. ONE EVENING setup · ~1.8 hours to running · 2 GB RAM minimum · $19/mo you stop paying ($228/yr on the Starter plan).

Castopod authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/transistor-fm/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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:
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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
```

## Also evaluated

Ranked below Castopod for this swap. The prompts above install Castopod only.

- **PeerTube** — 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 page this mirrors: https://caniselfhostit.com/self-host/transistor-fm/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
