Can I self-host Plex?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called Jellyfin. It takes one prompt, a 2048 MB VPS, and about 10 minutes. That is $6.99 a month you stop paying Plex — $83.88 a year on the Pass plan.

Why people pay for Plex

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.

Plex sells a polished clients-everywhere media experience: TVs, phones, living-room apps, and optional hosted extras, with a Pass that unlocks mobile sync, hardware features and fewer nags. People pay for the clients and the convenience more than for the server binary.

Plex plans and list prices
PlanList priceWhat it buys
FreefreeServer free with limitations and nags.
Passthe plan this page prices against$6.99/moPlex Pass at $6.99/mo or $69.99/yr. Lifetime is $749.99 as of 2026-07-01 (was $249.99); a 5-year recurring option at $249.99 also appears on the plans page.

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

Replaced by Jellyfin

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

A free media system that streams movies, shows and music you already own, with no subscription tier and no account at a vendor.

The fully free media server with no Pass tier. You bring the library, the CPU for transcodes, and the clients; you drop the Plex account requirement and the upsells.

The swap

You're paying

Plex

$6.99/mo · $83.88/yr

is replaced by

You'd run

Jellyfin

ONE COMMAND · ~10 min to running · 2048 MB RAM

Plex Pass · vendor list price · checked 2026-08-07 · source

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
20 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
~10 minunder 10 minutes, through the first backup

The prompt

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

294 lines · 13,667 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 Jellyfin 10.10.7 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.
Its A record must already point at this server.

Say three things before anything installs, because they decide whether this is the right box.
One: Jellyfin streams files the user already owns. There is no catalogue, no store, and nothing
to search that they have not copied onto a disk. Two: a home box with the library on the same
LAN is the usual shape; a VPS is a remote front door, and every remote stream burns that
provider's egress quota the way a Netflix bill would. Three: between first start and the end of
the setup wizard, anyone who can reach the hostname may finish setup first and become the
administrator.

Jellyfin needs 2048 MB of RAM available and 20 GB free on /srv before any media. The image
publishes amd64 and arm64. Measure all four:

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

If available RAM is under 2048 MB or free disk is under 20 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a hostname that does not resolve. The 20 GB covers the image, config, cache and a small library
staging area; a real movie collection lives on storage the user already has and is not counted
here.

## 2. Layout

Config and cache are what Jellyfin writes. Media is a path the user already owns, mounted read
only, not an empty directory this install invents and never fills.

STOP: ask the user for the absolute path on this server to a media library they already have
(or will fill themselves), for example `/mnt/media` or `/home/them/videos`. Do not continue until they confirm. If they have no library yet, they may give `/srv/jellyfin/media` and you
will create that empty directory, with the understanding that playback stays empty until they
copy files in.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/jellyfin /srv/jellyfin/backups /srv/jellyfin/config /srv/jellyfin/cache
# If the user chose /srv/jellyfin/media as the library path, create it owned by them:
# sudo install -d -m 755 -o $(id -u) -g $(id -g) /srv/jellyfin/media
ls -la /srv/jellyfin
test -d "<MEDIA_PATH>" && ls -ld "<MEDIA_PATH>"
```

Replace `<MEDIA_PATH>` with the path they gave. Assert: config, cache and backups exist and are
owned by the login user. The media path exists as a directory (create it only if they chose
`/srv/jellyfin/media`). If the media path is owned by root and unreadable by other users, fix
ownership or mode so the container can read it: `sudo chmod -R a+rX <MEDIA_PATH>` is enough
when the image runs as root (the default when no `user:` line is set). Do not leave a
root-owned empty `media/` that nothing ever mounts from their real library.

Write the path into `.env` so compose can interpolate it. This is not a secret:

```bash
umask 077
printf 'JELLYFIN_MEDIA_PATH=%s\n' '<MEDIA_PATH>' > /srv/jellyfin/.env
chmod 600 /srv/jellyfin/.env
umask 022
ls -la /srv/jellyfin/.env
cat /srv/jellyfin/.env
```

Assert: `.env` is mode 600 and prints one line with the path they chose, not a placeholder.

## 3. Secrets

No secret is generated for this install and there is no application password in `.env`. The
first credential is the administrator account the user creates in the setup wizard in step 7.
Between the container starting and that account existing, anyone who can reach the hostname may
finish the wizard first. Step 7 is a hard stop for that reason: create the account immediately,
then assert the wizard is closed.

## 4. compose.yml

```bash
cat > /srv/jellyfin/compose.yml <<'EOF'
# Jellyfin · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   container install ... https://jellyfin.org/docs/general/installation/container
#   setup wizard ........ https://jellyfin.org/docs/general/post-install/setup-wizard/
#   backup .............. https://jellyfin.org/docs/general/administration/backup-and-restore
#
# One container. Config and cache are the server state (users, library metadata,
# watch progress). Media is a bind mount of a path the user already owns, read
# only, supplied via JELLYFIN_MEDIA_PATH in .env (not a secret; compose
# interpolates it). This file never downloads content. Tag and digest are the
# 10.10.7 release read from Docker Hub on 2026-08-07; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  jellyfin:
    image: jellyfin/jellyfin:10.10.7@sha256:7ae36aab93ef9b6aaff02b37f8bb23df84bb2d7a3f6054ec8fc466072a648ce2
    container_name: jellyfin
    restart: unless-stopped
    volumes:
      # Database, users, library metadata, watch state. The thing to back up.
      - /srv/jellyfin/config:/config
      # Transcode and image cache. Safe to lose; rebuilds itself.
      - /srv/jellyfin/cache:/cache
      # The user's own library. Path comes from .env (JELLYFIN_MEDIA_PATH).
      # Read only: Jellyfin does not need to write into the media tree.
      - ${JELLYFIN_MEDIA_PATH}:/media:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8204.
      - "127.0.0.1:8204:8096"
EOF
cd /srv/jellyfin && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port. Config and cache are the
writable state; media is read only. Do not add a Caddy service to this file: Caddy is already
running under systemd on this box.

## 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 here takes down every other site on the box.

```bash
cat > /srv/jellyfin/Caddyfile <<'EOF'
# Jellyfin · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://jellyfin.org/docs/general/networking/caddy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8204 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8204
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-jellyfin
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${REAL_DOMAIN}|g" /srv/jellyfin/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Set `REAL_DOMAIN` to the hostname the user gave in step 1 before running sed (for example
`REAL_DOMAIN=media.example.com`). Do not wrap the value in extra quotes inside the sed
replacement. Assert: validate and reload exit 0. If validate fails, restore
`/etc/caddy/Caddyfile.before-jellyfin`, reload, and report what it objected to. Caddy requests
the certificate on the first request and renews it on its own.

## 6. Firewall

Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:

```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, and
443/udp is HTTP/3. 8204 stays closed because compose binds it to 127.0.0.1. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule
mentioning 8204 or 8096.

## 7. Start and verify

```bash
cd /srv/jellyfin
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/System/Info/Public); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/System/Info/Public
curl -sS https://<DOMAIN>/System/Info/Public | grep -o '"StartupWizardCompleted":[^,]*'
curl -sSL https://<DOMAIN>/web/ | grep -ci 'jellyfin'
```

Assert all four, and print what you received for each. The loop ends printing `200`. The public
system info is JSON that names the product. Before the wizard finishes,
`StartupWizardCompleted` is `false`. The web shell mentions jellyfin (case insensitive count
greater than 0). If any of the four misses, stop, run `docker compose logs --tail 40 jellyfin`,
and name the likely earlier step: a container that exits on a volume error is usually step 2
(the media path does not exist or is not readable), and a 502 from Caddy with a running
container is step 5. A running container is not success.

STOP: tell the user to open https://<DOMAIN> in a private window now, complete the setup
wizard (language, administrator username and password, and optionally add a library pointing at
`/media` inside the container, which is their host path), and confirm back to you that they can
sign in as that administrator. Do not continue until they confirm. This is the claim-race
window: whoever finishes the wizard first owns the box.

```bash
curl -sS https://<DOMAIN>/System/Info/Public | grep -o '"StartupWizardCompleted":[^,]*'
```

Assert: that prints `"StartupWizardCompleted":true`. That is the security assert in this block.
If it still prints `false`, the wizard was not completed; do not go on.

## 8. First backup and restore

One archive: config (users, library metadata, watch state), cache, compose, the media path
pointer in `.env`, and the live Caddy site block. Media files are not in it. That is deliberate:
they are large, already owned by the user, and belong in whatever backup protects the machine
they came from.

```bash
cd /srv/jellyfin
docker compose stop
sudo tar -czf /srv/jellyfin/backups/jellyfin-$(date +%F).tar.gz -C /srv/jellyfin config cache compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/jellyfin/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is about ten seconds, and
the container is stopped on purpose so SQLite under config is not copied mid-write.

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

```bash
mkdir -p ~/backups/jellyfin
scp vps:/srv/jellyfin/backups/*.tar.gz ~/backups/jellyfin/
```

To restore: `docker compose down`, `sudo rm -rf /srv/jellyfin/config /srv/jellyfin/cache`,
recreate those directories as in step 2, untar the archive back into /srv/jellyfin (which
restores config, cache, compose.yml and `.env`), put the Caddy block back if that is what was
lost, then `docker compose up -d`. Tell the user which half matters: `config/` is every account
and every watched progress, `.env` is which host path is mounted as `/media`, and the media
files themselves are outside this archive. Losing config loses the server identity. Losing media
loses the library. Neither half replaces the other.

## 9. Updating later

New versions are listed at https://github.com/jellyfin/jellyfin/releases. The release tag and
the image tag are the same string, so release `10.10.8` is image tag `10.10.8`. This install
pins 10.10.7, the settled end of the 10.10 line, rather than the newer 10.11 series: 10.11 is
the release that migrates the library database, and a first install is the wrong moment to
take a one-way migration. Move to 10.11 deliberately, after a backup, once its release notes
read as settled to you. Take a backup first, then edit the image line in
/srv/jellyfin/compose.yml to the new tag and its digest:

```bash
cd /srv/jellyfin
docker compose pull
docker compose up -d
docker compose logs --tail 30 jellyfin
```

Watch that log until it settles, then re-run step 7's public info check and a signed-in browse
before calling the update done.

## 10. What will probably go wrong

You will stream a 4K file to a phone on a mobile network through this VPS and watch the
provider's egress counter climb while the CPU pegs on a software transcode. I did that on a
two-core cloud instance and called the box broken. It was not broken; it was the wrong place
for that workload. Direct-play on the LAN, or a home box with the library on the same switch,
is the shape this product assumes. The VPS path is for when the library already lives near a
good uplink and you still want HTTPS and remote friends. Match clients to formats the TV can
play without transcoding, or buy CPU, and treat egress as a real line item.

## 11. Out of scope

- Do not add a Caddy container to the compose file. Caddy is already running under systemd on
  this box, and a second one would fight it for 80 and 443.
- Do not publish 8096 or 8204 on the public interface or open them in the firewall. Caddy is
  the only way in.
- Do not download copyrighted media or point the library at someone else's content. This
  install only mounts a path the user supplies.
- Do not enable hardware transcoding devices in this prompt. That needs host-specific devices
  and drivers; software transcoding is the portable default.
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 Jellyfin 10.10.7 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 these three before step 1, because together they decide whether you want this at all.
Jellyfin streams files you already own: there is no catalogue and nothing appears that you did
not put on a disk. A home box with the library on the same LAN is the usual shape; a VPS is a
remote front door, and every remote stream burns that provider's egress the way a Netflix bill
would. Between first start and the end of the setup wizard, anyone who can reach the hostname
may finish setup first and become the administrator, so you will create that account in the
same session you start the container.

## 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 `20` 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,
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. Under the RAM or disk
floor means stop: a software transcode on a starving box fails mid-stream, not at start-up.


Jellyfin needs 2048 MB free RAM and 20 GB free on /srv before any media. The 20 GB covers
the image, config, cache and a small staging area. A real movie collection lives on storage
you already own and is not counted in that floor. Software transcoding is CPU-heavy: if you
plan to convert 4K to phone formats on this box, treat the RAM floor as a minimum and prefer
more cores.


## 2. Layout

Config and cache are what Jellyfin writes. Media is a path you already own, mounted read only.

Pick an absolute path on this server to a library you already have (or will fill yourself), for
example `/mnt/media`. If you have nothing yet, use `/srv/jellyfin/media` and create it empty.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/jellyfin /srv/jellyfin/backups /srv/jellyfin/config /srv/jellyfin/cache
# Only if you chose the empty staging path:
# sudo install -d -m 755 -o $(id -u) -g $(id -g) /srv/jellyfin/media
ls -la /srv/jellyfin
test -d /mnt/media && ls -ld /mnt/media
```

Replace `/mnt/media` with your real path in the `test` line and below. If the directory is
root-owned and unreadable, fix mode so the container can read it:
`sudo chmod -R a+rX /path/to/media`. Do not invent a fresh empty `media/` under /srv and leave
your real library unmounted.


Ownership matters. The official image runs as root when no `user:` line is set, so it can
read a world-readable library. Prefer not to leave media root-owned with mode 700: the
container will start, the library scan will find zero files, and the dashboard will look empty
for a reason that is not a Jellyfin bug. `ls -ld` on the path should show that ordinary
processes can traverse and read it (`a+rX` is enough). Never create an empty `/srv/jellyfin/media`
as a substitute for mounting the folder where your files already live, unless you intend to
copy files into that empty folder yourself.


Write the path into `.env` (not a secret; compose interpolates it):

```bash
umask 077
printf 'JELLYFIN_MEDIA_PATH=%s\n' '/mnt/media' > /srv/jellyfin/.env
chmod 600 /srv/jellyfin/.env
umask 022
cat /srv/jellyfin/.env
```

You should see one line with your path. Mode of `.env` is `-rw-------`.

## 3. Secrets

No secret is generated here. The first credential is the administrator account you create in
the setup wizard in step 7. Between the container starting and that account existing, anyone
who can reach the hostname may finish the wizard first. Create the account immediately after
start.

## 4. compose.yml

Paste the whole block:

```bash
cat > /srv/jellyfin/compose.yml <<'EOF'
# Jellyfin · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   container install ... https://jellyfin.org/docs/general/installation/container
#   setup wizard ........ https://jellyfin.org/docs/general/post-install/setup-wizard/
#   backup .............. https://jellyfin.org/docs/general/administration/backup-and-restore
#
# One container. Config and cache are the server state (users, library metadata,
# watch progress). Media is a bind mount of a path the user already owns, read
# only, supplied via JELLYFIN_MEDIA_PATH in .env (not a secret; compose
# interpolates it). This file never downloads content. Tag and digest are the
# 10.10.7 release read from Docker Hub on 2026-08-07; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  jellyfin:
    image: jellyfin/jellyfin:10.10.7@sha256:7ae36aab93ef9b6aaff02b37f8bb23df84bb2d7a3f6054ec8fc466072a648ce2
    container_name: jellyfin
    restart: unless-stopped
    volumes:
      # Database, users, library metadata, watch state. The thing to back up.
      - /srv/jellyfin/config:/config
      # Transcode and image cache. Safe to lose; rebuilds itself.
      - /srv/jellyfin/cache:/cache
      # The user's own library. Path comes from .env (JELLYFIN_MEDIA_PATH).
      # Read only: Jellyfin does not need to write into the media tree.
      - ${JELLYFIN_MEDIA_PATH}:/media:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8204.
      - "127.0.0.1:8204:8096"
EOF
cd /srv/jellyfin && docker compose config >/dev/null && echo "compose OK"
```

You should see `compose OK`. One service, one published port. Config and cache are writable
state; media is read only. Do not add a Caddy service: Caddy already runs under systemd.

## 5. Caddy and TLS

Write the site block, then append it with your hostname in place of `<DOMAIN>`. Copy the live
Caddyfile first.

```bash
cat > /srv/jellyfin/Caddyfile <<'EOF'
# Jellyfin · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://jellyfin.org/docs/general/networking/caddy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8204 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8204
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-jellyfin
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
REAL_DOMAIN='media.example.com'
sed "s|<DOMAIN>|${REAL_DOMAIN}|g" /srv/jellyfin/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Put your real hostname in `REAL_DOMAIN` before you paste. You should see validate exit 0 and
reload exit 0. If validate fails, restore `/etc/caddy/Caddyfile.before-jellyfin`, reload, and
read the error. Caddy gets the certificate on the first request and renews it on its own.

## 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 and 443, and nothing for 8204 or 8096. 80 is the
ACME challenge and HTTPS redirect, 443 is the only way in, 443/udp is HTTP/3.

## 7. Start and verify

```bash
cd /srv/jellyfin
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/System/Info/Public); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/System/Info/Public
curl -sS https://<DOMAIN>/System/Info/Public | grep -o '"StartupWizardCompleted":[^,]*'
curl -sSL https://<DOMAIN>/web/ | grep -ci 'jellyfin'
```

You should see: the loop ends on `200`; JSON public system info; `StartupWizardCompleted` is
`false` until you finish the wizard; a jellyfin count greater than 0 on the web shell. If the
loop never reaches 200, run `docker compose logs --tail 40 jellyfin`. A missing or unreadable
media path is step 2; a 502 with a running container is step 5.

STOP: open https://<DOMAIN> in a private window now. Complete the setup wizard: language, admin
username and password, and optionally a library at `/media` (that is your host path inside the
container). Sign in as that administrator. Do not continue until you can sign in. This is the
claim-race window.

```bash
curl -sS https://<DOMAIN>/System/Info/Public | grep -o '"StartupWizardCompleted":[^,]*'
```

You should see `"StartupWizardCompleted":true`. If it is still `false`, the wizard was not
finished; stop and finish it.


In the wizard, when you add a library, the path inside the container is `/media`. That is the
bind of `JELLYFIN_MEDIA_PATH` from `.env`, mounted read-only. If the scan finds nothing, re-check
permissions on the host path and that `.env` points at the folder that actually holds files,
not its parent with a different layout. Hardware acceleration is out of scope on this path:
software decode works without GPU devices.

After the wizard, put the admin password in a password manager. There is no second factor in
this install and no SMTP for resets. A forgotten admin password means database work or a
re-run of the wizard on a wiped config, both worse than writing it down once.


## 8. First backup and restore

One archive: config, cache, compose, `.env`, and the live Caddyfile. Media files are not in it.

```bash
cd /srv/jellyfin
docker compose stop
sudo tar -czf /srv/jellyfin/backups/jellyfin-$(date +%F).tar.gz -C /srv/jellyfin config cache compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/jellyfin/backups/
```

The archive should exist and be non-empty. Print its size. Downtime is about ten seconds; the
container is stopped so config is not copied mid-write.

A backup on the same disk is not a backup. From your own machine (not the server):

```bash
mkdir -p ~/backups/jellyfin
scp vps:/srv/jellyfin/backups/*.tar.gz ~/backups/jellyfin/
```

To restore: `docker compose down`, remove `config` and `cache`, recreate them, untar the
archive into /srv/jellyfin, put the Caddy block back if needed, then `docker compose up -d`.
`config/` is accounts and watch state. `.env` is which host path mounts as `/media`. Media
files live outside this archive. Neither half replaces the other.


What the archive is not: your movies and shows. Those stay on `JELLYFIN_MEDIA_PATH`. Back them
up with whatever already protects that disk. What the archive is: every user, every library
definition, every watch progress marker, the compose pin, the path pointer, and the live
Caddy configuration that terminates TLS. Restore without media still boots an empty server
with your accounts. Restore media without config still leaves files on disk with no server
identity.




Remote friends need enough uplink and a client they can install. Jellyfin has clients for
phones, TVs and browsers; none of them remove the need for you to own the files. Plugins are
community code you choose to trust, and this install does not enable any. Live TV guide
subscriptions and paid plugin stores are out of scope the same way vendor support is out of
scope: there is no line to call.

When you later change `JELLYFIN_MEDIA_PATH`, stop the stack, edit `.env`, run
`docker compose up -d`, and re-scan libraries in the dashboard. A path that moves without an
update to `.env` is a silent empty library after reboot.

For a cold restore on a new VPS: install Docker and Caddy (Prompt Zero), restore the archive
into /srv/jellyfin, restore the Caddyfile fragment, ensure `JELLYFIN_MEDIA_PATH` still points
at media that exists on the new box (or re-attach storage first), then start compose and sign
in with the same admin account. Order matters: `.env` before first start, media path present
before you expect scans to find files.

## 9. Updating later

New versions are at https://github.com/jellyfin/jellyfin/releases. This install pins 10.10.7,
the settled end of the 10.10 line, on purpose: the newer 10.11 series migrates the library
database, and a first install is the wrong moment for a one-way migration. Move to 10.11
deliberately, after a backup, once its release notes read as settled to you. Take a backup
first, then edit the image line in /srv/jellyfin/compose.yml to the new tag and digest:

```bash
cd /srv/jellyfin
docker compose pull
docker compose up -d
docker compose logs --tail 30 jellyfin
```

Watch the log until it settles, then re-run step 7's public info check and a signed-in browse
before calling the update done.


If a release notes page mentions a one-way database migration, read it before pulling. Jellyfin
upgrades are usually forward-only: take the backup first, then pull, then watch logs for
migration messages. If the container loops, restore config from the archive and pin the previous
digest until you understand the failure.


## 10. What will probably go wrong

You will stream a 4K file to a phone on a mobile network through this VPS and watch the
provider's egress counter climb while the CPU pegs on a software transcode. That is not a
broken box; it is the wrong place for that workload. Direct-play on the LAN, or a home box
with the library on the same switch, is the shape this product assumes. Match clients to
formats the TV can play without transcoding, or buy CPU, and treat egress as a real line item.

The other common miss is leaving the wizard open. DNS for a new hostname is quieter than it
feels. Finish the admin account before you walk away to copy files. Assert
`StartupWizardCompleted` is true before you call the install done. Config without that assert
is an unlocked front door with a media server behind it.

## 11. Out of scope

- Do not add a Caddy container to the compose file. Caddy is already running under systemd on
  this box, and a second one would fight it for 80 and 443.
- Do not publish 8096 or 8204 on the public interface or open them in the firewall. Caddy is
  the only way in.
- Do not download copyrighted media or point the library at someone else's content. This
  install only mounts a path you supply.
- Do not enable hardware transcoding devices in this prompt. That needs host-specific devices
  and drivers; software transcoding is the portable default.

- Do not skip the first backup after the wizard. Config without a copy is a single disk failure
  away from losing every account and every watched position.

258 lines · 11,846 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  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 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 Jellyfin 10.10.7 under ~/selfhost/jellyfin, answering at http://localhost:8204.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. Jellyfin streams files they already own. There is no catalogue and nothing appears that
they did not put on disk. This path answers only at http://localhost:8204, so a phone on the
same wifi and a friend on another network get nothing. What they get is a player and a library
on this desk, which is the home-box shape without the remote front door.

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. Jellyfin needs 2048 MB of RAM available
and 20 GB free on the home disk before a large library, and the image publishes amd64 and
arm64. Every branch prints free memory, so one floor covers all three; on macOS and Windows it
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 20 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

Config and cache live under ~/selfhost/jellyfin. Media is a folder the user already has,
mounted read only, not an empty tree this install invents.

```bash
mkdir -p ~/selfhost/jellyfin/config ~/selfhost/jellyfin/cache ~/selfhost/jellyfin/backups
ls -la ~/selfhost/jellyfin
```

STOP: ask the user for the absolute path to a media library on this computer (for example
`$HOME/Videos` or `$HOME/Movies`). Do not continue until they confirm. If they have no library
yet, they may give `$HOME/selfhost/jellyfin/media` and you create that empty directory.

```bash
# Create only if they chose the empty staging path:
# mkdir -p ~/selfhost/jellyfin/media
test -d "<MEDIA_PATH>" && ls -ld "<MEDIA_PATH>"
umask 077
printf 'JELLYFIN_MEDIA_PATH=%s\n' '<MEDIA_PATH>' > ~/selfhost/jellyfin/.env
chmod 600 ~/selfhost/jellyfin/.env
umask 022
cat ~/selfhost/jellyfin/.env
```

Replace `<MEDIA_PATH>` with the path they gave (expand `$HOME` yourself so `.env` holds a real
absolute path Docker can bind). Assert: the directory exists and `.env` prints one
`JELLYFIN_MEDIA_PATH=` line. On Windows under Git Bash, write a path Docker Desktop can see,
for example `/c/Users/them/Videos`, not `C:\Users\...`.

## 4. Secrets

No secret is generated for this install. The first credential is the administrator account the
user creates in the setup wizard in step 7. On localhost the claim-race is limited to other
software on this machine that can open http://localhost:8204, which is still enough reason to
finish the wizard in the same session.

## 5. compose.yml

```bash
cat > ~/selfhost/jellyfin/compose.yml <<'EOF'
# Jellyfin · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   container install ... https://jellyfin.org/docs/general/installation/container
#   setup wizard ........ https://jellyfin.org/docs/general/post-install/setup-wizard/
#   backup .............. https://jellyfin.org/docs/general/administration/backup-and-restore
#
# One container on the computer you are sitting at. Paths are relative to
# ~/selfhost/jellyfin/. Media is a bind of a folder the user already has, via
# JELLYFIN_MEDIA_PATH in .env. This file never downloads content. Tag and digest
# are the 10.10.7 release read from Docker Hub on 2026-08-07; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  jellyfin:
    image: jellyfin/jellyfin:10.10.7@sha256:7ae36aab93ef9b6aaff02b37f8bb23df84bb2d7a3f6054ec8fc466072a648ce2
    container_name: jellyfin
    restart: unless-stopped
    volumes:
      - ./config:/config
      - ./cache:/cache
      # User library path from .env. Read only.
      - ${JELLYFIN_MEDIA_PATH}:/media:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8204.
      - "127.0.0.1:8204:8096"
EOF
cd ~/selfhost/jellyfin && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one loopback port.

## 6. Firewall

Nothing to open. This install binds only to loopback. Confirm the binding:

```bash
grep -c '"127.0.0.1:' ~/selfhost/jellyfin/compose.yml
```

Assert: that prints `1`. Do not rebind to `0.0.0.0`. A media server on every network this
laptop joins is not what the local path is for.

## 7. Start and verify

```bash
cd ~/selfhost/jellyfin
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8204/System/Info/Public); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8204/System/Info/Public
curl -sS http://localhost:8204/System/Info/Public | grep -o '"StartupWizardCompleted":[^,]*'
curl -sSL http://localhost:8204/web/ | grep -ci 'jellyfin'
```

Assert all four, and print what you received for each. The loop ends printing `200`. The public
info is JSON. Before the wizard finishes, `StartupWizardCompleted` is `false`. The web shell
mentions jellyfin. If any miss, stop, run `docker compose logs --tail 40 jellyfin`, and name
the cause: a missing media path is step 3; a port conflict means something else holds 8204.

STOP: tell the user to open http://localhost:8204, complete the setup wizard, create the
administrator account, optionally add a library at `/media`, and confirm they can sign in.
Do not continue until they confirm.

```bash
curl -sS http://localhost:8204/System/Info/Public | grep -o '"StartupWizardCompleted":[^,]*'
```

Assert: `"StartupWizardCompleted":true`. If it is still `false`, the wizard was not finished.

## 8. First backup and restore

One archive: config, cache, compose and the media path pointer. Media files stay outside.

```bash
cd ~/selfhost/jellyfin
docker compose stop
tar -C ~/selfhost/jellyfin -czf ~/selfhost/jellyfin/backups/jellyfin-$(date +%F).tar.gz config cache compose.yml .env
docker compose start
ls -lh ~/selfhost/jellyfin/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is about ten seconds;
the container is stopped so config is not copied mid-write.

That archive sits on the same disk as the data. Ask the user for a destination that leaves this
computer, a folder their sync service watches or a USB stick, and copy it there with `cp`. In
Git Bash a Windows drive is written `/d/Backups`, not `D:\Backups`. Assert: the user confirms
the filename is listed there.

To restore: `cd ~/selfhost/jellyfin`, `docker compose down`, `rm -rf config cache`, untar the
archive there, then `docker compose up -d`. Tell the user `config/` is accounts and watch
state, `.env` is which host path mounts as `/media`, and the media files themselves are not in
the archive.

## 9. Updating later

New versions are listed at https://github.com/jellyfin/jellyfin/releases. This install pins
10.10.7, the settled end of the 10.10 line, on purpose: the newer 10.11 series migrates the
library database, and a first install is the wrong moment for a one-way migration. Move to
10.11 deliberately, after a backup, once its release notes read as settled to you. Take a
backup first, then edit the image line in ~/selfhost/jellyfin/compose.yml to the new tag and
digest:

```bash
cd ~/selfhost/jellyfin
docker compose pull
docker compose up -d
docker compose logs --tail 30 jellyfin
```

Watch the log until it settles, then re-run step 7's public info check before calling the
update done.

## 10. What will probably go wrong

I closed the laptop mid-transcode and came back to a stalled client and a warm machine. Docker
Desktop and the lid are a pair: when the host sleeps, playback stops, and nothing here pages
you about it. Direct-play of files this computer can decode without a software transcode is the
reliable path. If the library is large, keep it on a disk that stays mounted; a missing bind at
start-up is a container that restarts into the same volume error until you fix `.env`.

## 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 rebind 8204 to 0.0.0.0 so a phone on the wifi can load the library. Use the VPS path
  on this page when remote access is the goal.
- Do not download copyrighted media. This install only mounts a path the user supplies.
compose.local.ymlthe services, pinned · local layout26 lines

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

# Jellyfin · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   container install ... https://jellyfin.org/docs/general/installation/container
#   setup wizard ........ https://jellyfin.org/docs/general/post-install/setup-wizard/
#   backup .............. https://jellyfin.org/docs/general/administration/backup-and-restore
#
# One container on the computer you are sitting at. Paths are relative to
# ~/selfhost/jellyfin/. Media is a bind of a folder the user already has, via
# JELLYFIN_MEDIA_PATH in .env. This file never downloads content. Tag and digest
# are the 10.10.7 release read from Docker Hub on 2026-08-07; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  jellyfin:
    image: jellyfin/jellyfin:10.10.7@sha256:7ae36aab93ef9b6aaff02b37f8bb23df84bb2d7a3f6054ec8fc466072a648ce2
    container_name: jellyfin
    restart: unless-stopped
    volumes:
      - ./config:/config
      - ./cache:/cache
      # User library path from .env. Read only.
      - ${JELLYFIN_MEDIA_PATH}:/media:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8204.
      - "127.0.0.1:8204:8096"

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

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

# Jellyfin · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   container install ... https://jellyfin.org/docs/general/installation/container
#   setup wizard ........ https://jellyfin.org/docs/general/post-install/setup-wizard/
#   backup .............. https://jellyfin.org/docs/general/administration/backup-and-restore
#
# One container. Config and cache are the server state (users, library metadata,
# watch progress). Media is a bind mount of a path the user already owns, read
# only, supplied via JELLYFIN_MEDIA_PATH in .env (not a secret; compose
# interpolates it). This file never downloads content. Tag and digest are the
# 10.10.7 release read from Docker Hub on 2026-08-07; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  jellyfin:
    image: jellyfin/jellyfin:10.10.7@sha256:7ae36aab93ef9b6aaff02b37f8bb23df84bb2d7a3f6054ec8fc466072a648ce2
    container_name: jellyfin
    restart: unless-stopped
    volumes:
      # Database, users, library metadata, watch state. The thing to back up.
      - /srv/jellyfin/config:/config
      # Transcode and image cache. Safe to lose; rebuilds itself.
      - /srv/jellyfin/cache:/cache
      # The user's own library. Path comes from .env (JELLYFIN_MEDIA_PATH).
      # Read only: Jellyfin does not need to write into the media tree.
      - ${JELLYFIN_MEDIA_PATH}:/media:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8204.
      - "127.0.0.1:8204:8096"
Caddyfilethe hostname and TLS24 lines

authored from upstream docs, never pasted · 756 bytes

# Jellyfin · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://jellyfin.org/docs/general/networking/caddy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8204 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8204
}
install.shthe same install, no agent134 lines

authored from upstream docs, never pasted · 5,919 bytes

#!/usr/bin/env bash
# Jellyfin · 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=media.example.com JELLYFIN_MEDIA_PATH=/mnt/media ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://jellyfin.org/docs/general/installation/container
#   https://jellyfin.org/docs/general/post-install/setup-wizard/
#   https://jellyfin.org/docs/general/administration/backup-and-restore
#   https://jellyfin.org/docs/general/networking/caddy
#
# No application secret is generated here. The first credential is the admin
# account created in the setup wizard. Between first start and that account,
# anyone who can reach the hostname may finish setup first.
#
# Media is a bind mount of JELLYFIN_MEDIA_PATH (required). Config and cache are
# the backed-up state; media files are not.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/jellyfin}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
JELLYFIN_MEDIA_PATH="${JELLYFIN_MEDIA_PATH:-}"

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. media.example.com"
[ -n "$JELLYFIN_MEDIA_PATH" ] || die "set JELLYFIN_MEDIA_PATH to an absolute path of a media library you own, e.g. /mnt/media"
[ -d "$JELLYFIN_MEDIA_PATH" ] || die "JELLYFIN_MEDIA_PATH=$JELLYFIN_MEDIA_PATH is not a directory"
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."

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; this install wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 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."

# --- 2. Lay the files out ----------------------------------------------------
#
# Config and cache are writable state. Media is the user's path, never a fresh
# root-owned empty tree invented by this script.

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

umask 077
printf 'JELLYFIN_MEDIA_PATH=%s\n' "$JELLYFIN_MEDIA_PATH" > "$APP_DIR/.env"
chmod 600 "$APP_DIR/.env"
umask 022

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

# --- 3. 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-jellyfin"
	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

# --- 4. Ports: two open, and 8204 is not one of them -------------------------

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

# --- 5. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/System/Info/Public" | grep -qi 'jellyfin' \
	|| die "public system info does not name Jellyfin"

wizard="$(curl -sS "https://${DOMAIN_HOST}/System/Info/Public" | grep -o '"StartupWizardCompleted":[^,]*' || true)"
echo "==> $wizard (false until you finish the setup wizard in a browser)"

# --- 6. The first backup (config + cache only; media stays outside) ----------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/jellyfin-${STAMP}.tar.gz" \
	-C "$APP_DIR" config cache compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/jellyfin-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Jellyfin is answering at https://${DOMAIN_HOST}

	  1. Open that URL now and finish the setup wizard. Create the admin account
	     immediately: until StartupWizardCompleted is true, anyone who reaches
	     the hostname can claim the server.
	  2. In the wizard, add a library that points at /media inside the container
	     (that is ${JELLYFIN_MEDIA_PATH} on the host, mounted read-only).
	  3. After the wizard, check:
	       curl -sS https://${DOMAIN_HOST}/System/Info/Public | grep StartupWizardCompleted
	     It should print true.
	  4. First backup written to $APP_DIR/backups (config, cache, compose, .env,
	     live Caddyfile). Media files are not in it. Copy the archive off this
	     disk tonight.
	  5. Remote streams cost egress on this VPS. A home box on the same LAN as
	     the library is the usual shape for heavy playback.
	  6. NOT YET VERIFIED on a clean harness machine.

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

  • You bring the media. There is no catalogue to rent and nothing appears that you did not put on disk. Illegal streaming is not a feature and not something this site helps with.
  • A home box is the usual shape. A VPS with a thin root disk is a remote access front door, not a library: the files live on storage you already own, and remote friends cost egress on every stream. Budget bandwidth the way you would budget a Netflix bill.
  • Transcoding eats CPU. A cheap VPS will choke on 4K-to-phone streams; match clients to direct-play formats or buy CPU headroom for the household you actually have.
  • You own the library metadata and the user accounts. Back up config (and cache if you care about rebuild time), not only the video files. Losing config loses users and watch state while the files remain.
  • No Live TV guide subscriptions, no paid plugin store, no vendor support line. Plugins are community code you choose to trust.

Where this came from

“Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media.”

  • Upstream documents a Docker install with config, cache and media volume mounts and HTTP on port 8096, and shows media as a bind of a path the operator already has. source
  • The first-run setup wizard creates the administrator account and can add media libraries; after completion the public system info reports StartupWizardCompleted true. source
  • Hardware transcoding is optional and depends on host devices; software transcoding works without a GPU and is what a small VPS will do. source
  • Jellyfin is licensed under GPL-2.0. source
  • Caddy obtains and renews TLS certificates automatically for any public hostname named in the Caddyfile. source

Questions people actually ask

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

  • Can I self-host Plex?

    Not Plex 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 Jellyfin. A free media system that streams movies, shows and music you already own, with no subscription tier and no account at a vendor. The install is one command: one container 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 10 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 Plex?

    Jellyfin. A free media system that streams movies, shows and music you already own, with no subscription tier and no account at a vendor. The fully free media server with no Pass tier. You bring the library, the CPU for transcodes, and the clients; you drop the Plex account requirement and the upsells. Jellyfin is GPL-2.0-only-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Plex?

    2048 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Jellyfin itself is free and GPL-2.0-only-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Plex Pass, $6.99/mo — $83.88 a year.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the Jellyfin install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Jellyfin 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 Jellyfin 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. Worth knowing: Playback on this computer is the common home-lab shape; friends on other networks only work if you later put it on a server with enough egress, which the local path deliberately does not do. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

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