Can I self-host Vimeo?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called PeerTube. It takes one prompt, a 2048 MB VPS, and about 135 minutes. That is $20 a month you stop paying Vimeo — $240 a year on the Starter plan.

Why people pay for Vimeo

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.

Vimeo sells the parts of online video that are invisible until they break: encoding that turns whatever you exported into a file every browser can play, a network that keeps the stream smooth for someone watching on hotel wifi, a player you can drop on a client's site with no ads and no recommendations afterwards, and privacy controls precise enough to show a rough cut to four people and nobody else. The plans differ mostly in how many videos and how much storage, which is another way of saying the product is somebody else's disk and somebody else's uplink.

Vimeo plans and list prices
PlanList priceWhat it buys
Freefree2 videos a month, 25 in total, and 2 TB of player bandwidth a month.
Starterthe plan this page prices against$20/mo$144 a year on the annual plan, which the page shows as $12/month billed annually. 60 videos per seat per year, 2 TB of storage, 1 seat.
Standard$41/mo$300 a year on the annual plan, shown as $25/month billed annually. 120 videos per seat per year, 4 TB of storage, 5 seats.
Advanced$125/mo$900 a year on the annual plan, shown as $75/month billed annually. 240 videos per seat per year, 7 TB of storage, 10 seats.
Enterprisequote onlyQuote only. The page sends you to a contact form.

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

Replaced by PeerTube

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

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

The only one of these that does the whole job Vimeo does: you upload a file, it transcodes to adaptive HLS, and you get a page, a player and an embed code on your own domain. It also hands you the two costs Vimeo was absorbing. Transcoding runs on your CPU, so a ten-minute upload occupies a small VPS for about ten minutes, and every viewer streams from your uplink with no CDN behind it. Federation is the part with no paid equivalent: other instances can follow yours, and yours can follow theirs, which is a different relationship with an audience than a private link.

The swap

You're paying

Vimeo

$20/mo · $240/yr

is replaced by

You'd run

PeerTube

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

Vimeo Starter · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
40 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
~135 min1–3 hours, through the first backup

The prompt

Two paths to the same PeerTube: 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

340 lines · 14,757 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 PeerTube 8.2.4 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask once and stop. `<DOMAIN>` becomes
`PEERTUBE_WEBSERVER_HOSTNAME`, inside every embed code and federated URL this instance publishes,
so changing it later breaks all of them; its A record must point here now. Tell the user this
streams only videos they upload, and that ffmpeg re-encodes each one here.

PeerTube with PostgreSQL and Redis wants 2048 MB of RAM available and 40 GB free on /srv before
any video. Upstream's own floor is 1.5 GB for the application alone.

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

Both architectures ship. Under 2048 MB or 40 GB, print both and stop; do not install and hope.
If `dig +short` prints nothing, print that and stop: Caddy cannot certify a name that does not
resolve. 40 GB is a floor, not a budget, because HLS keeps every video as segments.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/peertube /srv/peertube/backups
sudo install -d -m 750 -o 999 -g 999 /srv/peertube/data /srv/peertube/config
sudo install -d -m 700 /srv/peertube/postgres /srv/peertube/redis
ls -la /srv/peertube
```

Assert: five entries, `data` and `config` owned by uid `999`, `postgres` and `redis` at mode `700`
owned by root. The image runs as uid 999 and walks /data at every start to chown what it does not
own, so handing those two over keeps that walk short. The database images chown their own
directory.

## 3. Secrets

Three: the PostgreSQL password, the key PeerTube signs tokens with, and the password its built-in
`root` account is created with. Generate all three here, print none, and keep them out of your
summary and every log line.

```bash
umask 077
cat > /srv/peertube/.env <<EOF
PEERTUBE_WEBSERVER_HOSTNAME=<DOMAIN>
PEERTUBE_ADMIN_EMAIL=<ADMIN_EMAIL>
POSTGRES_PASSWORD=$(openssl rand -hex 32)
PEERTUBE_SECRET=$(openssl rand -hex 32)
PT_INITIAL_ROOT_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/peertube/.env
umask 022
ls -l /srv/peertube/.env
```

Assert: mode `-rw-------`. Hex not base64: Docker Compose reads this same file for interpolation
and a `$` in a value would be expanded.

The third line is why this block matters. Left unset, PeerTube invents the root password and the
documented way to learn it is to grep the container log, which would put a live credential in this
transcript. Tell the user it is in /srv/peertube/.env, read with
`grep PT_INITIAL_ROOT_PASSWORD /srv/peertube/.env`, and belongs in their password manager now.
PeerTube logs it once at first boot, so say that whoever reads that log can already read the
file.

## 4. compose.yml

```bash
cat > /srv/peertube/compose.yml <<'EOF'
# PeerTube · the deterministic fallback. Authored by caniselfhostit from the
# upstream docs, not copied from a repository:
#   https://docs.joinpeertube.org/install/docker
#   https://github.com/Chocobozzz/PeerTube/tree/v8.2.4/support/docker/production
#
# Three services. Upstream's compose ships seven: nginx, certbot and a reload
# loop in front, a postfix relay behind. Caddy replaces the first three. No
# postfix means no mail: one admin, closed signup, a password reset from a
# shell. PeerTube connects as the superuser the PostgreSQL image creates,
# because it runs CREATE EXTENSION for pg_trgm and unaccent at first boot.
# Digests read 2026-08-06; all three are multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    restart: unless-stopped
    environment:
      POSTGRES_DB: peertube
      POSTGRES_USER: peertube
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/peertube/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U peertube -d peertube"]
      interval: 10s
      retries: 18
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    # Session store and job queue. Appendonly keeps queued jobs.
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/peertube/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 18

  peertube:
    image: chocobozzz/peertube:v8.2.4@sha256:fee7ff44b9705401d8c228227e770257f088c3f3cb056746493888344a5a0324
    restart: unless-stopped
    env_file: /srv/peertube/.env
    environment:
      PEERTUBE_DB_HOSTNAME: postgres
      PEERTUBE_DB_USERNAME: peertube
      PEERTUBE_DB_PASSWORD: ${POSTGRES_PASSWORD}
      PEERTUBE_DB_SSL: "false"
      PEERTUBE_REDIS_HOSTNAME: redis
      # Caddy terminates TLS; without these two, every URL PeerTube
      # writes would say http.
      PEERTUBE_WEBSERVER_HTTPS: "true"
      PEERTUBE_WEBSERVER_PORT: "443"
      # Trust the docker bridge, so rate limits see real client IPs.
      PEERTUBE_TRUST_PROXY: '["loopback","linklocal","uniquelocal"]'
      # Closed registration, stated rather than assumed. Upstream agrees.
      PEERTUBE_SIGNUP_ENABLED: "false"
      PEERTUBE_CONTACT_FORM_ENABLED: "false"
      # Live wants a second published port and transcoding pipeline.
      PEERTUBE_LIVE_ENABLED: "false"
    volumes:
      # Videos, HLS segments, thumbnails, logs. The one that grows.
      - /srv/peertube/data:/data
      # PEERTUBE_LOCAL_CONFIG: what the admin UI writes.
      - /srv/peertube/config:/config
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8124.
      - "127.0.0.1:8124:9000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
EOF
cd /srv/peertube && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Three services, one published port, no mail.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-peertube
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# PeerTube · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/Chocobozzz/PeerTube/blob/v8.2.4/support/nginx/peertube,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Upstream ships a 262-line nginx config and a container to run it in. This
# block replaces both; each comment names the nginx setting it stands in for.
# Its ciphers, certbot and ACME webroot become Caddy's automatic HTTPS; its
# sendfile, aio and limit_rate are dropped as I/O tuning.
#
# Append to /etc/caddy/Caddyfile with <DOMAIN> replaced by your hostname.

<DOMAIN> {
	# nginx: client_max_body_size 12G on the upload routes. Caddy applies
	# no limit unless told to, so one ceiling is tighter than the default.
	# Upstream's per-route regexes are not copied: such a list rots the day
	# PeerTube adds an endpoint.
	request_body {
		max_size 12GB
	}

	# nginx: X-File-Maximum-Size, which PeerTube's uploader reads off a
	# 413. 8GB against a 12GB cap because multipart encoding inflates a
	# body by about 1.4x. Upstream pairs the same two numbers.
	header /api/v1/videos/upload* X-File-Maximum-Size "8GB"

	header {
		# PeerTube sets its own X-Frame-Options, so this does not.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# nginx: gzip on CSS, JS, fonts, SVG and XML. Caddy's default matcher
	# is that list and holds no video type, so HLS segments and Range
	# requests pass through untouched and seeking works.
	encode zstd gzip

	# 8124 is the loopback port compose publishes here, not a container
	# port and not open in the firewall. Three nginx settings need nothing
	# written: proxy_request_buffering off (Caddy never spools a request
	# body to disk), proxy_read_timeout 15m (no transport read timeout) and
	# the Upgrade headers on the socket routes (reverse_proxy upgrades
	# WebSockets itself).
	reverse_proxy 127.0.0.1:8124
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. On failure restore /etc/caddy/Caddyfile.before-peertube, reload, and report
what it objected to. Caddy gets the certificate on the first request and renews it itself, the
whole job of upstream's certbot container.

## 6. Firewall

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

```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, 443/tcp is the way in, 443/udp is HTTP/3. 8124
stays closed because compose binds it to loopback, 5432 and 6379 because compose publishes
neither, 1935 because live is off. Assert: `Status: active`, rules for 80, 443/tcp and 443/udp,
none for those four.

## 7. Start and verify

PeerTube runs its migrations, creates `root` from `PT_INITIAL_ROOT_PASSWORD` and builds its
storage tree on the way up. On a cold pull that takes several minutes.

```bash
cd /srv/peertube
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>/api/v1/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/v1/ping; echo
curl -sS https://<DOMAIN>/api/v1/config | grep -o '"signup":{"allowed":false'
curl -sS https://<DOMAIN>/api/v1/accounts/root | grep -o '"name":"root"'
curl -sS https://<DOMAIN>/login | grep -o 'og:platform" content="PeerTube"'
```

Assert all five, printing what you got: the loop ends on `200`; ping answers `pong`; the third
prints `"signup":{"allowed":false`, the security assert here and the reason nobody else can open
an account; the fourth prints `"name":"root"`; the fifth prints `og:platform" content="PeerTube"`,
the served page rather than a Caddy error. On any miss, stop, run
`docker compose logs --tail 40 peertube` and `docker compose logs --tail 20 postgres` and name the
step: a database never healthy is step 2, a `502` is step 5, `pg_trgm` in the log means PeerTube
is not connecting as the superuser PostgreSQL created. A running container is not success.

The first screen at https://<DOMAIN>/login shows the heading `Login on PeerTube` over a
`Username or email address` box, a `Password` box and a `Login` button.

STOP: tell the user to read their password with
`grep PT_INITIAL_ROOT_PASSWORD /srv/peertube/.env`, save it, sign in at https://<DOMAIN>/login as
`root`, upload one short video at https://<DOMAIN>/videos/upload, and wait. Do not continue until
they confirm it plays back: that is the product end to end, a file in, ffmpeg to HLS here, back
out through Caddy. Step 10 says how long to expect.

## 8. First backup and restore

Two artifacts: the database holds accounts, video records, comments and views; the config archive
holds what rebuilds the service around them. The video files are in neither, on purpose:
/srv/peertube/data runs to tens of gigabytes and wants its own copy.

```bash
cd /srv/peertube
docker compose exec -T postgres pg_dump -U peertube -d peertube | gzip > /srv/peertube/backups/peertube-db-$(date +%F).sql.gz
sudo tar -czf /srv/peertube/backups/peertube-config-$(date +%F).tar.gz -C /srv/peertube compose.yml .env config -C /etc/caddy Caddyfile
ls -lh /srv/peertube/backups/
```

Assert: both exist, both non-empty, print both sizes. Nothing stops: `pg_dump` snapshots a running
database consistently. A backup on the same disk is not a backup, so run this from the user's
machine:

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

To restore: `docker compose down`, `sudo rm -rf /srv/peertube/postgres`, recreate it as in step 2,
untar the config archive into /srv/peertube so .env is back first, `docker compose up -d postgres`,
wait 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U peertube -d peertube`, then `docker compose up -d`. Order
matters: PostgreSQL takes its password from .env the moment it initialises an empty directory. And
say the other half: a restored database whose rows point at videos nobody copied is a catalogue of
dead links.

## 9. Updating later

Releases are at https://github.com/Chocobozzz/PeerTube/releases. Take both artifacts first, then
edit the image line in /srv/peertube/compose.yml to the new tag and digest:

```bash
cd /srv/peertube
docker compose pull
docker compose up -d
docker compose logs --tail 40 peertube
```

PeerTube migrates its database on the way up, and a major version spends minutes on it. Watch that
log until it settles, then re-run step 7's five checks.

## 10. What will probably go wrong

The first upload will look like a broken install. Mine did: the page said the video was published,
the video page showed a spinner where the player belongs, and the logs read like nothing was
happening for eleven minutes. Nothing was wrong. PeerTube had handed the file to ffmpeg with one
thread, upstream's default, and a two-core VPS re-encodes ten minutes of 1080p in about real time
or worse. That container near 100% CPU in `docker stats` is this working, not failing. If the user
wants it faster, the honest answers are more cores or a remote runner.

## 11. Out of scope

- Do not enable live streaming. It wants port 1935 open to the internet and a second transcoding
  pipeline running the whole time somebody watches.
- Do not configure SMTP or add upstream's postfix container. Registration is closed and there is
  one account, whose password is reset with
  `docker compose exec -u peertube peertube npm run reset-password -- -u root`.
- Do not enable object storage. Moving video to S3 is right for a growing instance, and it is a
  bucket, a credential pair and a base URL this prompt has not set up.
- Do not follow other instances or turn on auto-follow. That pulls remote videos and comments onto
  this disk, and it is the user's call.
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 PeerTube 8.2.4 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, and `<ADMIN_EMAIL>` with the address you want on the administrator account.

Read this before step 1. `<DOMAIN>` becomes `PEERTUBE_WEBSERVER_HOSTNAME`, which is written into
every embed code and every federated video URL this instance publishes. Change it later and every
one of those breaks. Read step 10 too, before you upload anything: transcoding is why this feels
slow, and it is not a fault.

## 1. Preflight

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

You should see: at least `2048` MB available, at least `40` G free, `amd64` or `arm64`, 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,
and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does
not resolve and failed attempts count against a rate limit you cannot see. If the disk figure is
short, stop and resize now rather than later: 40 GB is the floor before a single video, and HLS
keeps every upload as segments on top of whatever else lives on that disk. Upstream's own floor
is 1.5 GB of RAM for PeerTube alone; the rest of the 2048 is PostgreSQL and Redis.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/peertube /srv/peertube/backups
sudo install -d -m 750 -o 999 -g 999 /srv/peertube/data /srv/peertube/config
sudo install -d -m 700 /srv/peertube/postgres /srv/peertube/redis
ls -la /srv/peertube
```

You should see: five entries. `backups` owned by you, `data` and `config` owned by uid `999`,
and `postgres` and `redis` at mode `drwx------` owned by root.

If you do not: leave `postgres` and `redis` owned by root on purpose. Both images chown their own
data directory the first time they start, and one you have already chowned to yourself makes
PostgreSQL refuse to initialise. The 999 on the other two is the PeerTube image's own service
account: its entrypoint walks /data at every start and chowns anything it does not own, so doing
it once here keeps that walk short forever.

## 3. Secrets

Three secrets, all generated here on the server: the PostgreSQL password, the key PeerTube signs
tokens and TOTP with, and the password its built-in `root` account gets created with. Hex rather
than base64, because Docker Compose reads this same file for variable interpolation and a `$` in
a value would be expanded.

```bash
umask 077
cat > /srv/peertube/.env <<EOF
PEERTUBE_WEBSERVER_HOSTNAME=<DOMAIN>
PEERTUBE_ADMIN_EMAIL=<ADMIN_EMAIL>
POSTGRES_PASSWORD=$(openssl rand -hex 32)
PEERTUBE_SECRET=$(openssl rand -hex 32)
PT_INITIAL_ROOT_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/peertube/.env
umask 022
ls -l /srv/peertube/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` and
`<ADMIN_EMAIL>` on the first two lines with your real values before you paste.

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/peertube/.env` and carry on.
If the file already existed from an earlier attempt, this block has now overwritten all three
secrets, which is fine before the database exists and a problem afterwards: PostgreSQL keeps the
password it was created with, so a changed one on an existing directory shows up as an
authentication failure in the PeerTube log rather than as anything about passwords.

Do not paste that file, any of those three values, or any command output containing them into
this chat window. Read your own password once with
`grep PT_INITIAL_ROOT_PASSWORD /srv/peertube/.env` and put it straight into your password
manager. That third line is the whole reason this install does not follow upstream's own
instruction to read the root password out of the container log: setting it yourself means you
already have it. PeerTube does still write it to its own log once at first boot, and anyone who
can read that log can read this file too, so on a one-admin box it is the same boundary.

## 4. compose.yml

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

```bash
cat > /srv/peertube/compose.yml <<'EOF'
# PeerTube · the deterministic fallback. Authored by caniselfhostit from the
# upstream docs, not copied from a repository:
#   https://docs.joinpeertube.org/install/docker
#   https://github.com/Chocobozzz/PeerTube/tree/v8.2.4/support/docker/production
#
# Three services. Upstream's compose ships seven: nginx, certbot and a reload
# loop in front, a postfix relay behind. Caddy replaces the first three. No
# postfix means no mail: one admin, closed signup, a password reset from a
# shell. PeerTube connects as the superuser the PostgreSQL image creates,
# because it runs CREATE EXTENSION for pg_trgm and unaccent at first boot.
# Digests read 2026-08-06; all three are multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    restart: unless-stopped
    environment:
      POSTGRES_DB: peertube
      POSTGRES_USER: peertube
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/peertube/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U peertube -d peertube"]
      interval: 10s
      retries: 18
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    # Session store and job queue. Appendonly keeps queued jobs.
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/peertube/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 18

  peertube:
    image: chocobozzz/peertube:v8.2.4@sha256:fee7ff44b9705401d8c228227e770257f088c3f3cb056746493888344a5a0324
    restart: unless-stopped
    env_file: /srv/peertube/.env
    environment:
      PEERTUBE_DB_HOSTNAME: postgres
      PEERTUBE_DB_USERNAME: peertube
      PEERTUBE_DB_PASSWORD: ${POSTGRES_PASSWORD}
      PEERTUBE_DB_SSL: "false"
      PEERTUBE_REDIS_HOSTNAME: redis
      # Caddy terminates TLS; without these two, every URL PeerTube
      # writes would say http.
      PEERTUBE_WEBSERVER_HTTPS: "true"
      PEERTUBE_WEBSERVER_PORT: "443"
      # Trust the docker bridge, so rate limits see real client IPs.
      PEERTUBE_TRUST_PROXY: '["loopback","linklocal","uniquelocal"]'
      # Closed registration, stated rather than assumed. Upstream agrees.
      PEERTUBE_SIGNUP_ENABLED: "false"
      PEERTUBE_CONTACT_FORM_ENABLED: "false"
      # Live wants a second published port and transcoding pipeline.
      PEERTUBE_LIVE_ENABLED: "false"
    volumes:
      # Videos, HLS segments, thumbnails, logs. The one that grows.
      - /srv/peertube/data:/data
      # PEERTUBE_LOCAL_CONFIG: what the admin UI writes.
      - /srv/peertube/config:/config
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8124.
      - "127.0.0.1:8124:9000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
EOF
cd /srv/peertube && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/peertube/.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/peertube/compose.yml` and paste again in one go. Nothing in this file is optional.
PeerTube will not start without Redis, and it creates two PostgreSQL extensions on first boot,
which is why it connects as the superuser the image makes rather than a role of its own.

## 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-peertube
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# PeerTube · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/Chocobozzz/PeerTube/blob/v8.2.4/support/nginx/peertube,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Upstream ships a 262-line nginx config and a container to run it in. This
# block replaces both; each comment names the nginx setting it stands in for.
# Its ciphers, certbot and ACME webroot become Caddy's automatic HTTPS; its
# sendfile, aio and limit_rate are dropped as I/O tuning.
#
# Append to /etc/caddy/Caddyfile with <DOMAIN> replaced by your hostname.

<DOMAIN> {
	# nginx: client_max_body_size 12G on the upload routes. Caddy applies
	# no limit unless told to, so one ceiling is tighter than the default.
	# Upstream's per-route regexes are not copied: such a list rots the day
	# PeerTube adds an endpoint.
	request_body {
		max_size 12GB
	}

	# nginx: X-File-Maximum-Size, which PeerTube's uploader reads off a
	# 413. 8GB against a 12GB cap because multipart encoding inflates a
	# body by about 1.4x. Upstream pairs the same two numbers.
	header /api/v1/videos/upload* X-File-Maximum-Size "8GB"

	header {
		# PeerTube sets its own X-Frame-Options, so this does not.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# nginx: gzip on CSS, JS, fonts, SVG and XML. Caddy's default matcher
	# is that list and holds no video type, so HLS segments and Range
	# requests pass through untouched and seeking works.
	encode zstd gzip

	# 8124 is the loopback port compose publishes here, not a container
	# port and not open in the firewall. Three nginx settings need nothing
	# written: proxy_request_buffering off (Caddy never spools a request
	# body to disk), proxy_read_timeout 15m (no transport read timeout) and
	# the Upgrade headers on the socket routes (reverse_proxy upgrades
	# WebSockets itself).
	reverse_proxy 127.0.0.1:8124
}
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-peertube /etc/caddy/Caddyfile`, reload,
and paste again. The one line worth understanding is `request_body`: Caddy has no request body
limit unless you set one, so without that block a 12 GB ceiling would be no ceiling at all, and
with it your uploads stop at 12 GB rather than at nginx's default of 1 MB. Caddy terminates TLS
and speaks plain http to the container, which is why `PEERTUBE_WEBSERVER_HTTPS` is true in the
compose file.

## 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 `8124`, `5432`, `6379` or `1935`.

If you do not: delete anything for those four with `sudo ufw delete allow 8124`. 8124 is bound to
127.0.0.1 by the compose file, 5432 and 6379 are never published at all, and 1935 is the RTMP
port live streaming would want, which this install leaves off. 80/tcp is there to redirect to
HTTPS and answer the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3.
`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 before you go further.

## 7. Start and verify

PeerTube runs its own database migrations, creates the `root` account from
`PT_INITIAL_ROOT_PASSWORD` and builds its storage tree on the way up. On a cold pull the first
boot takes several minutes; the loop below waits ten.

```bash
cd /srv/peertube
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>/api/v1/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/v1/ping; echo
curl -sS https://<DOMAIN>/api/v1/config | grep -o '"signup":{"allowed":false'
curl -sS https://<DOMAIN>/api/v1/accounts/root | grep -o '"name":"root"'
curl -sS https://<DOMAIN>/login | grep -o 'og:platform" content="PeerTube"'
```

You should see, in order: the loop reaching `200`, then `pong`, then
`"signup":{"allowed":false`, then `"name":"root"`, then `og:platform" content="PeerTube"`.

If you do not: the `"signup":{"allowed":false` line is the one with security meaning. It says
nobody but you can make an account on this server, and if it prints nothing, stop and check that
`PEERTUBE_SIGNUP_ENABLED` really is `"false"` in the compose file before you leave this running
on a public hostname. If the loop never reaches `200`, run `docker compose logs --tail 20 postgres`
first, because a database that never reports healthy is step 2 done wrong, then
`docker compose logs --tail 40 peertube`. A log line about `pg_trgm` means PeerTube is not
connecting as the superuser PostgreSQL created. A `502` from Caddy with all three containers up
is step 5. A running container is not success.

The first screen at https://<DOMAIN>/login shows the heading `Login on PeerTube` over a
`Username or email address` box, a `Password` box and a `Login` button.

Now read your password, sign in, and upload something:

```bash
grep PT_INITIAL_ROOT_PASSWORD /srv/peertube/.env
```

You should see: one line. Put the value in your password manager, do not paste it here, sign in
at https://<DOMAIN>/login as `root`, then upload one short video at
https://<DOMAIN>/videos/upload and wait for it to play back. That is the product working end to
end: a file went in, ffmpeg re-encoded it to HLS on this box, and it came back out through Caddy.

If you do not get a playable video: read step 10 before you conclude anything is broken. There is
no mail on this install, so a forgotten password is recovered with
`docker compose exec -u peertube peertube npm run reset-password -- -u root`, not by email.

## 8. First backup and restore

Two artifacts. The database holds the accounts, the video records, the comments and the view
counts. The config archive holds what rebuilds the service around them. The video files are in
neither, on purpose: /srv/peertube/data runs to tens of gigabytes and wants its own copy.

```bash
cd /srv/peertube
docker compose exec -T postgres pg_dump -U peertube -d peertube | gzip > /srv/peertube/backups/peertube-db-$(date +%F).sql.gz
sudo tar -czf /srv/peertube/backups/peertube-config-$(date +%F).tar.gz -C /srv/peertube compose.yml .env config -C /etc/caddy Caddyfile
ls -lh /srv/peertube/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

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/peertube
scp vps:/srv/peertube/backups/* ~/backups/peertube/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/peertube/`.

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 alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is one test video:

```bash
cd /srv/peertube
docker compose down
sudo rm -rf /srv/peertube/postgres
sudo install -d -m 700 /srv/peertube/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/peertube/backups/peertube-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U peertube -d peertube
docker compose up -d
sleep 60
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/ping
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `200` from the last command, and
your test video still on the site.

If you do not: `role "peertube" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand the stakes before you
skip this: the dump and the `data` folder travel together or neither is worth much, because a
restored database whose rows point at video files nobody copied is a catalogue of dead links.

## 9. Updating later

New versions are listed at https://github.com/Chocobozzz/PeerTube/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/peertube/compose.yml to the new tag and its
digest.

```bash
cd /srv/peertube
docker compose pull
docker compose up -d
docker compose logs --tail 40 peertube
```

You should see: migration output, 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. A major version
can spend minutes migrating the database, so watch that log until it settles rather than
interrupting it, then re-run the five checks from step 7 before you call the update done.

## 10. What will probably go wrong

The first upload will look like a broken install. Mine did: the page said the video was published,
the video page showed a spinner where the player belongs, and the logs read like nothing was
happening for eleven minutes. Nothing was wrong. PeerTube had handed the file to ffmpeg with one
thread, upstream's default, and a two-core VPS re-encodes ten minutes of 1080p in about real time
or worse. That container near 100% CPU in `docker stats` is this working, not failing. If you want
it faster, the honest answers are more cores or a remote runner.

## 11. Out of scope

- Do not enable live streaming. It wants port 1935 open to the internet and a second transcoding
  pipeline running the whole time somebody watches.
- Do not configure SMTP or add upstream's postfix container. Registration is closed and there is
  one account, whose password is reset with
  `docker compose exec -u peertube peertube npm run reset-password -- -u root`.
- Do not enable object storage. Moving video to S3 is right for a growing instance, and it is a
  bucket, a credential pair and a base URL this prompt has not set up.
- Do not follow other instances or turn on auto-follow. That pulls remote videos and comments onto
  this disk, and it is your call.

324 lines · 14,973 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 PeerTube 8.2.4, with the PostgreSQL and Redis it needs, under ~/selfhost/peertube,
answering at http://localhost:8124.

## 1. Preflight

Say this before step 2 runs; it decides whether the user wants this at all. PeerTube is a
federated video platform and here it federates with nothing: it answers only at
http://localhost:8124, which means "this computer" wherever it is read. Nobody they send a link
to can open it and their own phone cannot play it. They get a video library on one machine.

Detect the OS and measure:

```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. This wants 2048 MB of RAM available and
40 GB free on the home disk before a single video; upstream's own floor is 1.5 GB for the
application alone. All three images are multi-arch. Under either floor, print both and stop. 40 GB
is a floor, not a budget: every upload is re-encoded and kept as HLS segments beside it.

## 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/peertube/data ~/selfhost/peertube/config ~/selfhost/peertube/redis ~/selfhost/peertube/backups
ls -la ~/selfhost/peertube
```

Assert: four folders owned by the user. No ownership fix runs on any of the three systems: the
container starts as root, chowns /data and /config to its own account and drops to it. The
database lives in a Docker volume, not in this tree.

## 4. Secrets

Three: the PostgreSQL password, the key PeerTube signs tokens with, and the password its built-in
`root` account is created with. Generate all three here, print none, keep them out of your summary
and logs.

```bash
umask 077
cat > ~/selfhost/peertube/.env <<EOF
PEERTUBE_WEBSERVER_HOSTNAME=localhost
PEERTUBE_ADMIN_EMAIL=admin@example.com
POSTGRES_PASSWORD=$(openssl rand -hex 32)
PEERTUBE_SECRET=$(openssl rand -hex 32)
PT_INITIAL_ROOT_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/peertube/.env
umask 022
ls -l ~/selfhost/peertube/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these run the same on all three. Hex not
base64: Compose reads this file for interpolation and a `$` in a value would expand. The
administrator address is a reserved documentation domain; this install sends no mail.

The third line matters most: left unset, PeerTube invents the root password and the documented
way to learn it is to grep the container log, putting a live credential in this transcript. Tell
the user it is in ~/selfhost/peertube/.env, read with
`grep PT_INITIAL_ROOT_PASSWORD ~/selfhost/peertube/.env`, and belongs in their password manager
now. On Windows those mode bits are advisory: their account is the boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/peertube/compose.yml <<'EOF'
# PeerTube · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream docs, not copied from a repository:
#   https://docs.joinpeertube.org/install/docker
#   https://github.com/Chocobozzz/PeerTube/tree/v8.2.4/support/docker/production
#
# Three services, every path relative to ~/selfhost/peertube/ so one file
# works on macOS, Linux and Windows. Upstream's nginx, certbot, reload-loop
# and postfix containers have no job here: nothing is published past loopback
# and there is nothing to certify. The database is a named volume because
# PostgreSQL chowns its data directory to a uid a home-directory bind mount
# cannot grant on Windows. PeerTube connects as the superuser that image
# creates, for CREATE EXTENSION pg_trgm and unaccent. Digests 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    restart: unless-stopped
    environment:
      POSTGRES_DB: peertube
      POSTGRES_USER: peertube
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - peertube-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U peertube -d peertube"]
      interval: 10s
      retries: 18
    # No `ports:`: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - ./redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 18

  peertube:
    image: chocobozzz/peertube:v8.2.4@sha256:fee7ff44b9705401d8c228227e770257f088c3f3cb056746493888344a5a0324
    restart: unless-stopped
    env_file: ./.env
    environment:
      PEERTUBE_DB_HOSTNAME: postgres
      PEERTUBE_DB_USERNAME: peertube
      PEERTUBE_DB_PASSWORD: ${POSTGRES_PASSWORD}
      PEERTUBE_DB_SSL: "false"
      PEERTUBE_REDIS_HOSTNAME: redis
      # Nothing terminates TLS, so PeerTube's links say http.
      PEERTUBE_WEBSERVER_HTTPS: "false"
      PEERTUBE_WEBSERVER_PORT: "8124"
      # Closed registration, stated rather than assumed. No proxy sits in
      # front of this, so no trust_proxy override either.
      PEERTUBE_SIGNUP_ENABLED: "false"
      # Federation off: nobody outside can resolve this name.
      PEERTUBE_FEDERATION_ENABLED: "false"
      PEERTUBE_LIVE_ENABLED: "false"
    volumes:
      # The folder that grows; a bind mount, so Finder can see it.
      - ./data:/data
      - ./config:/config
    ports:
      # Loopback only: no other device on the wifi can reach 8124.
      - "127.0.0.1:8124:9000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  peertube-pgdata:
EOF
cd ~/selfhost/peertube && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Three services, one published port, one volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision: there is no hostname
to resolve, a certificate attests a public name nothing here has, and browsers treat
http://localhost as a secure context anyway, so the player and the uploader work. 8124 is bound
to 127.0.0.1: not the phone, not a laptop on the wifi, not anyone on the internet. That is the
trade here, not a defect. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/peertube/compose.yml
```

Assert: one line, `- "127.0.0.1:8124:9000"`. The other two publish no host port.

## 7. Start and verify

PeerTube runs its migrations, creates `root` from `PT_INITIAL_ROOT_PASSWORD` and builds its
storage tree on the way up; on a cold pull, several minutes.

```bash
cd ~/selfhost/peertube
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:8124/api/v1/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8124/api/v1/ping; echo
curl -sS http://localhost:8124/api/v1/config | grep -o '"signup":{"allowed":false'
curl -sS http://localhost:8124/api/v1/accounts/root | grep -o '"name":"root"'
curl -sS http://localhost:8124/login | grep -o 'og:platform" content="PeerTube"'
```

Assert all five, printing what you got: the loop ends on `200`; ping answers `pong`; the third
prints `"signup":{"allowed":false`, the security assert here; the fourth prints `"name":"root"`;
the fifth prints `og:platform" content="PeerTube"`. On any miss, stop, run
`docker compose logs --tail 40 peertube` and `docker compose logs --tail 20 postgres` and name
the cause: a database never healthy points at step 4, where an empty `POSTGRES_PASSWORD` leaves
PostgreSQL refusing to start. On `port is already allocated`, find what holds 8124 and wait until
it is free. A running container is not success.

The first screen at http://localhost:8124/login shows the heading `Login on PeerTube` over a
`Username or email address` box, a `Password` box and a `Login` button.

STOP: tell the user to read their password with
`grep PT_INITIAL_ROOT_PASSWORD ~/selfhost/peertube/.env`, save it, sign in at
http://localhost:8124/login as `root`, upload one short video at
http://localhost:8124/videos/upload, and wait. Do not continue until they confirm it plays back:
a file in, ffmpeg to HLS, back out. Step 10 says how long.

## 8. First backup and restore

Two artifacts: a database dump with the accounts, video records, comments and views, and a config
archive with what rebuilds the service around them. The videos are in neither: that folder runs to
tens of gigabytes and needs its own copy.

```bash
cd ~/selfhost/peertube
docker compose exec -T postgres pg_dump -U peertube -d peertube | gzip > ~/selfhost/peertube/backups/peertube-db-$(date +%F).sql.gz
tar -C ~/selfhost/peertube -czf ~/selfhost/peertube/backups/peertube-config-$(date +%F).tar.gz compose.yml .env config
ls -lh ~/selfhost/peertube/backups/
```

Assert: both exist, both non-empty, print both sizes. Nothing stops: `pg_dump` is consistent on a
running database.

Both archives sit on the same disk as the data, which is not a backup, 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 both there with `cp`; in Git Bash a Windows drive is `/d/Backups`.
Assert: the user confirms both filenames are there. If not, say plainly that this install has no
backup.

To restore, in this order. `cd ~/selfhost/peertube` and untar the config archive there first, so
.env is back before any container starts: PostgreSQL reads `POSTGRES_PASSWORD` from it the moment
it initialises an empty volume, and without it the database will not start. Then
`docker compose down -v`, the one place `-v` belongs because it drops the old volume on purpose,
`docker compose up -d postgres`, wait 30 seconds, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U peertube -d peertube`, then `docker compose up -d`. Rows
pointing at videos nobody copied are dead links, so `data` travels with the dump.

## 9. Updating later

Releases are at https://github.com/Chocobozzz/PeerTube/releases. Back up first, then edit the
image line in ~/selfhost/peertube/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/peertube
docker compose pull
docker compose up -d
docker compose logs --tail 40 peertube
```

PeerTube migrates its database on the way up, a major version for minutes. Watch it settle, then
re-run step 7's five checks.

## 10. What will probably go wrong

The first upload will look like a broken install, and on a laptop it is worse than on a server.
Mine said the video was published, then showed a spinner where the player belongs for eleven
minutes while the fans spun up. Nothing was wrong: ffmpeg had the file, on one thread, upstream's
default. Then I closed the lid halfway through, and the job was still queued when I opened it,
because a sleeping computer transcodes nothing. Leave it awake until the log settles.

## 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 turn `PEERTUBE_FEDERATION_ENABLED` back on or follow another instance. Federation needs
  a name other servers can resolve, and this one is `localhost` to everybody.
- Do not enable live streaming. It wants a second published port and a second transcoding
  pipeline running while somebody watches.
- Do not configure SMTP or add upstream's postfix container. The one password resets with
  `docker compose exec -u peertube peertube npm run reset-password -- -u root`.
compose.local.ymlthe services, pinned · local layout76 lines

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

# PeerTube · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream docs, not copied from a repository:
#   https://docs.joinpeertube.org/install/docker
#   https://github.com/Chocobozzz/PeerTube/tree/v8.2.4/support/docker/production
#
# Three services, every path relative to ~/selfhost/peertube/ so one file
# works on macOS, Linux and Windows. Upstream's nginx, certbot, reload-loop
# and postfix containers have no job here: nothing is published past loopback
# and there is nothing to certify. The database is a named volume because
# PostgreSQL chowns its data directory to a uid a home-directory bind mount
# cannot grant on Windows. PeerTube connects as the superuser that image
# creates, for CREATE EXTENSION pg_trgm and unaccent. Digests 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    restart: unless-stopped
    environment:
      POSTGRES_DB: peertube
      POSTGRES_USER: peertube
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - peertube-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U peertube -d peertube"]
      interval: 10s
      retries: 18
    # No `ports:`: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - ./redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 18

  peertube:
    image: chocobozzz/peertube:v8.2.4@sha256:fee7ff44b9705401d8c228227e770257f088c3f3cb056746493888344a5a0324
    restart: unless-stopped
    env_file: ./.env
    environment:
      PEERTUBE_DB_HOSTNAME: postgres
      PEERTUBE_DB_USERNAME: peertube
      PEERTUBE_DB_PASSWORD: ${POSTGRES_PASSWORD}
      PEERTUBE_DB_SSL: "false"
      PEERTUBE_REDIS_HOSTNAME: redis
      # Nothing terminates TLS, so PeerTube's links say http.
      PEERTUBE_WEBSERVER_HTTPS: "false"
      PEERTUBE_WEBSERVER_PORT: "8124"
      # Closed registration, stated rather than assumed. No proxy sits in
      # front of this, so no trust_proxy override either.
      PEERTUBE_SIGNUP_ENABLED: "false"
      # Federation off: nobody outside can resolve this name.
      PEERTUBE_FEDERATION_ENABLED: "false"
      PEERTUBE_LIVE_ENABLED: "false"
    volumes:
      # The folder that grows; a bind mount, so Finder can see it.
      - ./data:/data
      - ./config:/config
    ports:
      # Loopback only: no other device on the wifi can reach 8124.
      - "127.0.0.1:8124:9000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  peertube-pgdata:

agent-readable mirror: /self-host/vimeo.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, pinned76 lines

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

# PeerTube · the deterministic fallback. Authored by caniselfhostit from the
# upstream docs, not copied from a repository:
#   https://docs.joinpeertube.org/install/docker
#   https://github.com/Chocobozzz/PeerTube/tree/v8.2.4/support/docker/production
#
# Three services. Upstream's compose ships seven: nginx, certbot and a reload
# loop in front, a postfix relay behind. Caddy replaces the first three. No
# postfix means no mail: one admin, closed signup, a password reset from a
# shell. PeerTube connects as the superuser the PostgreSQL image creates,
# because it runs CREATE EXTENSION for pg_trgm and unaccent at first boot.
# Digests read 2026-08-06; all three are multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    restart: unless-stopped
    environment:
      POSTGRES_DB: peertube
      POSTGRES_USER: peertube
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/peertube/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U peertube -d peertube"]
      interval: 10s
      retries: 18
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    # Session store and job queue. Appendonly keeps queued jobs.
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/peertube/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 18

  peertube:
    image: chocobozzz/peertube:v8.2.4@sha256:fee7ff44b9705401d8c228227e770257f088c3f3cb056746493888344a5a0324
    restart: unless-stopped
    env_file: /srv/peertube/.env
    environment:
      PEERTUBE_DB_HOSTNAME: postgres
      PEERTUBE_DB_USERNAME: peertube
      PEERTUBE_DB_PASSWORD: ${POSTGRES_PASSWORD}
      PEERTUBE_DB_SSL: "false"
      PEERTUBE_REDIS_HOSTNAME: redis
      # Caddy terminates TLS; without these two, every URL PeerTube
      # writes would say http.
      PEERTUBE_WEBSERVER_HTTPS: "true"
      PEERTUBE_WEBSERVER_PORT: "443"
      # Trust the docker bridge, so rate limits see real client IPs.
      PEERTUBE_TRUST_PROXY: '["loopback","linklocal","uniquelocal"]'
      # Closed registration, stated rather than assumed. Upstream agrees.
      PEERTUBE_SIGNUP_ENABLED: "false"
      PEERTUBE_CONTACT_FORM_ENABLED: "false"
      # Live wants a second published port and transcoding pipeline.
      PEERTUBE_LIVE_ENABLED: "false"
    volumes:
      # Videos, HLS segments, thumbnails, logs. The one that grows.
      - /srv/peertube/data:/data
      # PEERTUBE_LOCAL_CONFIG: what the admin UI writes.
      - /srv/peertube/config:/config
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8124.
      - "127.0.0.1:8124:9000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
Caddyfilethe hostname and TLS49 lines

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

# PeerTube · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/Chocobozzz/PeerTube/blob/v8.2.4/support/nginx/peertube,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Upstream ships a 262-line nginx config and a container to run it in. This
# block replaces both; each comment names the nginx setting it stands in for.
# Its ciphers, certbot and ACME webroot become Caddy's automatic HTTPS; its
# sendfile, aio and limit_rate are dropped as I/O tuning.
#
# Append to /etc/caddy/Caddyfile with <DOMAIN> replaced by your hostname.

<DOMAIN> {
	# nginx: client_max_body_size 12G on the upload routes. Caddy applies
	# no limit unless told to, so one ceiling is tighter than the default.
	# Upstream's per-route regexes are not copied: such a list rots the day
	# PeerTube adds an endpoint.
	request_body {
		max_size 12GB
	}

	# nginx: X-File-Maximum-Size, which PeerTube's uploader reads off a
	# 413. 8GB against a 12GB cap because multipart encoding inflates a
	# body by about 1.4x. Upstream pairs the same two numbers.
	header /api/v1/videos/upload* X-File-Maximum-Size "8GB"

	header {
		# PeerTube sets its own X-Frame-Options, so this does not.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# nginx: gzip on CSS, JS, fonts, SVG and XML. Caddy's default matcher
	# is that list and holds no video type, so HLS segments and Range
	# requests pass through untouched and seeking works.
	encode zstd gzip

	# 8124 is the loopback port compose publishes here, not a container
	# port and not open in the firewall. Three nginx settings need nothing
	# written: proxy_request_buffering off (Caddy never spools a request
	# body to disk), proxy_read_timeout 15m (no transport read timeout) and
	# the Upgrade headers on the socket routes (reverse_proxy upgrades
	# WebSockets itself).
	reverse_proxy 127.0.0.1:8124
}
install.shthe same install, no agent165 lines

authored from upstream docs, never pasted · 7,984 bytes

#!/usr/bin/env bash
# PeerTube · 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=video.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.joinpeertube.org/install/docker
#   https://github.com/Chocobozzz/PeerTube/tree/v8.2.4/support/docker/production
#   https://github.com/Chocobozzz/PeerTube/blob/v8.2.4/config/default.yaml
#   https://github.com/Chocobozzz/PeerTube/blob/v8.2.4/support/nginx/peertube
#
# Three secrets are generated here, on this machine: the PostgreSQL password,
# PEERTUBE_SECRET, and the password the built-in root account is created with.
# All three go into /srv/peertube/.env with mode 600 and none is ever printed.
# Upstream's own path leaves the root password to be read out of the container
# log; setting it here means you have it without printing it anywhere.
#
# DOMAIN_HOST is PEERTUBE_WEBSERVER_HOSTNAME, which is written into every embed
# code and federated video URL this instance publishes. Choose it once.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/peertube}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

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. video.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address for the administrator account"
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; PeerTube plus PostgreSQL and Redis wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 40 ] || die "only ${avail_gb} GB free on /srv; this install wants 40 GB before a single video"

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

# --- 2. Lay the files out ----------------------------------------------------
#
# data and config go to uid 999, the service account the PeerTube image builds.
# Its entrypoint walks /data at every start to chown what it does not own, so
# doing it here keeps that walk short. postgres and redis stay root: both
# images chown their own data directory on first start.

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

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex rather than base64 for all three: Docker Compose reads this same file for
# variable interpolation and a $ in a value would be expanded. Read them later
# with
#   grep -E 'POSTGRES_PASSWORD|PEERTUBE_SECRET|PT_INITIAL_ROOT_PASSWORD' /srv/peertube/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		PEERTUBE_WEBSERVER_HOSTNAME=${DOMAIN_HOST}
		PEERTUBE_ADMIN_EMAIL=${ADMIN_EMAIL}
		POSTGRES_PASSWORD=$(openssl rand -hex 32)
		PEERTUBE_SECRET=$(openssl rand -hex 32)
		PT_INITIAL_ROOT_PASSWORD=$(openssl rand -hex 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

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

# --- 4. Caddy site block, on the host ----------------------------------------
#
# This is where upstream's nginx container, certbot and reload loop went. The
# Caddyfile header maps every setting from support/nginx/peertube to its place.

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-peertube"
	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 8124, 5432, 6379 and 1935 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; 8124, 5432, 6379 and 1935 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 -------------------------------------------------------------
#
# PeerTube runs its own migrations, creates the root account from
# PT_INITIAL_ROOT_PASSWORD and builds its storage tree during first boot.

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/api/v1/ping" | grep -q 'pong' \
	|| die "/api/v1/ping answered 200 without pong. Check: docker compose logs --tail 40 peertube"

# Registration must be closed. This is the assert with security meaning.
curl -sS "https://${DOMAIN_HOST}/api/v1/config" | grep -q '"signup":{"allowed":false' \
	|| die "signup is not closed on this instance. Stop and check PEERTUBE_SIGNUP_ENABLED before leaving this running."

# The administrator exists, and the page Caddy serves is PeerTube's own.
curl -sS "https://${DOMAIN_HOST}/api/v1/accounts/root" | grep -q '"name":"root"' \
	|| die "the root account was not created. Check: docker compose logs --tail 60 peertube"
curl -sS "https://${DOMAIN_HOST}/login" | grep -q 'og:platform" content="PeerTube"' \
	|| die "https://${DOMAIN_HOST}/login did not serve PeerTube. Check the Caddy site block from step 4."

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

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U peertube -d peertube | gzip > "$APP_DIR/backups/peertube-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/peertube-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env config -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/peertube-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	PeerTube is answering at https://${DOMAIN_HOST}/login

	  1. Sign in as root. Your password is in $APP_DIR/.env, mode 600:
	       grep PT_INITIAL_ROOT_PASSWORD $APP_DIR/.env
	     Put it in your password manager. It was not printed here. There is
	     no mail on this install, so the reset path is a command:
	       cd $APP_DIR && docker compose exec -u peertube peertube npm run reset-password -- -u root
	  2. Registration is closed and this run proved it. You are the only
	     account, and new ones are made from the admin screens.
	  3. Upload one short video and wait for it to play. PeerTube re-encodes
	     every upload with ffmpeg on this box, one thread by default, so ten
	     minutes of 1080p takes about ten minutes of a small VPS at 100% CPU.
	     That is this working, not failing.
	  4. First backup written to $APP_DIR/backups: a database dump and a
	     config archive. The video files are in neither, and $APP_DIR/data is
	     the part that grows. Copy all of it somewhere else tonight.

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

  • You bring the videos and the CPU. Every upload is re-encoded by ffmpeg on your own server, one thread by default, so ten minutes of 1080p is roughly ten minutes of a small VPS at full tilt. Nothing about that is broken, and no setting makes it free.
  • Disk is the running cost. HLS keeps a transcoded copy of every video beside everything else you store, upstream says serving both delivery formats doubles that again, and the 40 GB floor here is what you need before the first upload rather than a budget for the library.
  • Federation is on, and it is a decision rather than a feature. Public videos are announced to instances that follow yours, and following someone else pulls their videos and their comments onto your disk. This install follows nobody; the day you follow anyone, your disk usage stops being about you.
  • No mail, on purpose. One administrator, registration closed, and a password reset that is a command on the server rather than a link in an inbox. Add SMTP the day you invite a second person, not before.
  • No CDN and no bandwidth allowance. Most of what Vimeo charges for is delivery, and here every viewer streams from your server's uplink. The peer-to-peer part helps when several people watch the same video at once and does nothing at all for the first one.

Where this came from

“1.5 GB of RAM should be plenty for a basic PeerTube instance, which usually takes at most 500 MB in RAM.”

  • Upstream's own compose file runs seven services: an nginx webserver, a certbot, a reload loop, PeerTube, PostgreSQL, Redis and a postfix relay. This install replaces the first three with the Caddy already on the box and drops postfix, leaving three. source
  • The nginx config upstream ships caps the video upload route at 12G with request buffering off, returns X-File-Maximum-Size on a rejected upload, and gzips only CSS, JavaScript, fonts, SVG and XML, which is what the Caddyfile here had to reproduce. source
  • Registration is closed in PeerTube's shipped defaults: signup.enabled is false, and transcoding to HLS is on while the second web-videos format is off, which is what keeps one copy per video rather than two. source
  • PeerTube creates its root account on first boot and, unless PT_INITIAL_ROOT_PASSWORD is set, invents a password and writes it to the log. This install sets it, so the credential never has to be read back out of a container log. source
  • Upstream puts the minimum at 1 vCore and 1.5 GB of RAM, recommends 8 vCore and 8 GB when transcoding runs on the same machine, and says plainly that serving both delivery formats doubles storage. source

Questions people actually ask

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

  • Can I self-host Vimeo?

    Not Vimeo 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 PeerTube. A video site of your own, with the player, the embed codes and the transcoding, on a server whose bandwidth bill is yours. 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 135 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 Vimeo?

    PeerTube. A video site of your own, with the player, the embed codes and the transcoding, on a server whose bandwidth bill is yours. The only one of these that does the whole job Vimeo does: you upload a file, it transcodes to adaptive HLS, and you get a page, a player and an embed code on your own domain. It also hands you the two costs Vimeo was absorbing. Transcoding runs on your CPU, so a ten-minute upload occupies a small VPS for about ten minutes, and every viewer streams from your uplink with no CDN behind it. Federation is the part with no paid equivalent: other instances can follow yours, and yours can follow theirs, which is a different relationship with an audience than a private link. PeerTube 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 Vimeo?

    2048 MB of RAM and 40 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. PeerTube itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Vimeo Starter, $20/mo — $240 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 PeerTube install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run PeerTube 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 PeerTube 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: On your own computer PeerTube answers only at http://localhost:8124, so nobody you would send a video to can open it and no other instance can follow yours: what you get is a private library with a real player, on one machine. 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-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.