Can I self-host Spotify Premium?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called Navidrome. It takes one prompt, a 512 MB VPS, and about 10 minutes. That is $12.99 a month you stop paying Spotify Premium — $155.88 a year on the Premium Individual plan.

Why people pay for Spotify Premium

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.

The catalog is the product. Spotify pays the labels so that roughly a hundred million tracks are one search box away, on a phone, a car, a speaker and a laptop, with the queue in the same place on all four. Ten years of listening history turns into a weekly mixtape nobody had to make. None of that is software you can install: it is licensing, and it is the part a subscription actually buys. What the subscription also buys, and what you can take back, is a player, a library that syncs, and a month without ads.

Spotify Premium plans and list prices
PlanList priceWhat it buys
FreefreeAd-supported listening, with limits on skipping and on-demand playback.
Premium Individualthe plan this page prices against$12.99/moOne account. The page advertises a first month at $0 for new subscribers, then this rate.
Premium Student$6.99/moOne verified account, sold with Hulu access. Three months at $0 up front, then this rate. Eligibility is re-verified each year.
Premium Duo$18.99/moTwo accounts at the same address.
Premium Family$21.99/moUp to six accounts at the same address.

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

Replaced by Navidrome

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

A music server for the files you already own, and the phone apps that make them worth owning.

The honest answer to a narrower question. Navidrome does not replace the catalog and nothing self-hosted does; it replaces the player. Point it at music you already own and you get a web player, per-user play counts, playlists and ratings from one container, and, because it speaks the Subsonic and OpenSubsonic APIs, the 82 client apps in its own directory work with it on Android, iOS, desktop and CarPlay. That client ecosystem is why this is first: a music server nobody can reach from a phone is a folder with extra steps.

The swap

You're paying

Spotify Premium

$12.99/mo · $155.88/yr

is replaced by

You'd run

Navidrome

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

Spotify Premium Premium Individual · vendor list price · checked 2026-08-06 · source

Before you start

RAM floor
512 MBfloor from upstream docs — not measured by us yet
Disk
5 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 Navidrome: 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

333 lines · 14,542 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 Navidrome 0.63.2 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.

Tell the user one thing before anything installs, because it decides whether they want this at
all: Navidrome streams audio files that are already on this server. It has no catalog, no
store, and nothing to search that they have not copied onto the disk themselves. Step 7 asks
them for that library.

Navidrome needs 512 MB of RAM available and 5 GB free on /srv before any music. 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 512 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve. The 5 GB covers the image, the database and the
artwork and transcoding caches. The music library is on top of it, and only the user knows how
big that is.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/navidrome
sudo install -d -m 750 -o 1000 -g 1000 /srv/navidrome/data /srv/navidrome/backups
sudo install -d -m 755 -o $(id -u) -g $(id -g) /srv/navidrome/music
ls -la /srv/navidrome
```

Assert: `ls -la` shows four entries, with `data` and `backups` owned by uid `1000` at mode
`750`, and `music` owned by the login user at mode `755`. The container runs as uid 1000 and
writes only to the first two. `music` stays the login user's so they can copy files into it in
step 7, and it is world-readable so uid 1000 can read it without owning it. On most VPS images
the login user is already uid 1000 and all four names look the same.

## 3. Secrets

One secret: the passphrase Navidrome uses to encrypt stored passwords. Generate it on the
server. Do not print it, do not repeat it in your summary, and do not put it in any log line.

```bash
umask 077
cat > /srv/navidrome/.env <<EOF
ND_PASSWORDENCRYPTIONKEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/navidrome/.env
umask 022
ls -l /srv/navidrome/.env
```

Assert: the file exists with mode `-rw-------`. Hex rather than base64 because Docker Compose
reads this same file for variable interpolation and a `$` in the value would be expanded.
Upstream is explicit that this key is written once: setting it re-encrypts every stored
password, and changing it afterwards locks every account out of the server for good. Tell the
user it lives in /srv/navidrome/.env, that they can read it with
`sudo grep ND_PASSWORDENCRYPTIONKEY /srv/navidrome/.env`, and that it belongs in their password
manager next to the account they are about to create.

## 4. compose.yml

```bash
cat > /srv/navidrome/compose.yml <<'EOF'
# Navidrome · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ...... https://www.navidrome.org/docs/installation/docker/
#   config options ...... https://www.navidrome.org/docs/usage/configuration/options/
#   security ............ https://www.navidrome.org/docs/usage/admin/security/
#   automated backup .... https://www.navidrome.org/docs/usage/admin/backup/
#
# One service. Navidrome keeps its whole state in a SQLite database under /data
# and never writes to the music library, so the library is mounted read-only,
# which is what upstream asks for. The container runs as uid 1000, not root,
# and ND_ENFORCENONROOTUSER makes it exit rather than start as root by
# accident; read_only plus a tmpfs for /tmp follows upstream's own
# contrib/docker-compose sample. Tag and digest read from Docker Hub on
# 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  navidrome:
    image: deluan/navidrome:0.63.2@sha256:9012939114fbb1bb641b81cf96dec5ded15f0aafefe8d47a511d7cb919658e40
    container_name: navidrome
    restart: unless-stopped
    # The uid and gid owning /srv/navidrome/data and /srv/navidrome/backups.
    user: "1000:1000"
    read_only: true
    tmpfs:
      - /tmp
    env_file: /srv/navidrome/.env
    environment:
      ND_MUSICFOLDER: /music
      ND_DATAFOLDER: /data
      ND_PORT: "4533"
      # Refuse to start as root on a Unix host.
      ND_ENFORCENONROOTUSER: "true"
      # Upstream disables the transcoding-config UI by default: it edits a
      # command line that this server then runs. Leave it off.
      ND_ENABLETRANSCODINGCONFIG: "false"
      # No anonymous usage reports leave this box.
      ND_ENABLEINSIGHTSCOLLECTOR: "false"
      # Nightly database snapshot at 04:00, seven kept. No music in it.
      ND_BACKUP_PATH: /backups
      ND_BACKUP_SCHEDULE: "0 4 * * *"
      ND_BACKUP_COUNT: "7"
    volumes:
      - /srv/navidrome/data:/data
      - /srv/navidrome/backups:/backups
      # Read-only. Nothing Navidrome does needs write access to your library.
      - /srv/navidrome/music:/music:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8108.
      - "127.0.0.1:8108:4533"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4533/ping"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
EOF
cd /srv/navidrome && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one database file. Nothing
here reaches the internet on its own.

## 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
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-navidrome
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Navidrome · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.navidrome.org/docs/usage/admin/security/,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy 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. Upstream ships an
# HTTP server inside Navidrome and still asks you to put a reverse proxy in
# front of it that terminates TLS. This is that proxy.

<DOMAIN> {
	# The UI bundle and the JSON API compress well. Audio does not, and Caddy's
	# default encode matcher covers text, JSON, JavaScript and SVG only, so the
	# audio streams pass through untouched.
	encode zstd gzip

	# Navidrome sets its own X-Frame-Options: DENY, so this block does not
	# repeat it. HSTS is here because every request to this host carries a
	# session cookie or a Subsonic credential.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8108 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Server-sent events on
	# /api/events need no extra configuration: Caddy flushes text/event-stream
	# responses immediately whatever flush_interval says.
	reverse_proxy 127.0.0.1:8108
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-navidrome, reload, and report what it objected to. Caddy requests
the certificate on the first request to the hostname and renews it on its own, so there is
nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. 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 redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8108 stays closed because compose binds it to 127.0.0.1 and Caddy is the
only thing that speaks to it. Assert: `ufw status verbose` prints `Status: active`, shows 80,
443/tcp and 443/udp, and no rule mentioning 8108 or 4533.

## 7. Start and verify

```bash
cd /srv/navidrome
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/ping; echo
curl -sS https://<DOMAIN>/app/ | tr -d '\\' | grep -o '"firstTime":[a-z]*'
```

Assert all three, and print what you received for each: the loop ends printing `200`; the
health endpoint answers with a single `.`; and the last line prints `"firstTime":true`, which
means no account exists yet. If the loop never reaches 200, stop, run
`docker compose logs --tail 40 navidrome`, and say which earlier step is the likely cause: a
container that exits immediately usually means step 2 left `data` owned by somebody other than
uid 1000, and a 502 from Caddy with a healthy container means step 5. A running container is
not success.

`"firstTime":true` also means the next person to load that URL becomes the administrator, so
close that window now rather than after the music arrives.

STOP: tell the user to open https://<DOMAIN> and create their account, and wait. Do not
continue until they confirm. The first screen reads `Thanks for installing Navidrome!` above
`To start, create an admin user`, with Username, Password and Confirm Password boxes and a
`Create Admin` button.

```bash
curl -sS https://<DOMAIN>/app/ | tr -d '\\' | grep -o '"firstTime":[a-z]*'
```

Assert: `"firstTime":false`. That is the registration window closed, and it is the security
assert in this block. If it still prints `true`, the account was not created; do not go on.

STOP: tell the user to copy at least one album into /srv/navidrome/music, from their own
machine, not the server, and wait. Do not continue until they confirm. This is the command,
with their own path on the left:

```bash
rsync -av --info=progress2 ~/Music/ vps:/srv/navidrome/music/
```

The rest of the library can follow at any time. Navidrome watches the folder and picks up new
files about five seconds after they stop changing. Once they confirm, count what it found:

```bash
sleep 30
docker compose exec -T navidrome sqlite3 /data/navidrome.db "select count(*) from media_file"
```

Assert: a number greater than 0. Print it. A 0 means either the scan is still working or the
files are unreadable by uid 1000: run `sudo chmod -R a+rX /srv/navidrome/music`, wait 30
seconds, and count again. Upstream's own timing table puts 10,000 songs at one to five minutes,
so on a large library count twice before deciding anything is wrong.

## 8. First backup and restore

One archive: the database, the encryption key, the compose file and the Caddy site block. The
music is not in it, and that is deliberate: it is tens of gigabytes the user already owns, and
it belongs in whatever backup already protects their own machine.

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

Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds,
and the container is stopped on purpose because a SQLite database copied mid-write is not a
backup. The nightly job compose already configured writes a database-only snapshot into the
same folder at 04:00 and keeps seven, which covers a bad delete but not a dead disk.

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

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

To restore: `docker compose down`, `sudo rm -rf /srv/navidrome/data`, recreate it as in step 2,
untar the archive back into /srv/navidrome, put the Caddy block back if that is what was lost,
then `docker compose up -d`. The accounts, play counts, playlists and ratings are in
`data/navidrome.db`; the key that decrypts the passwords is in `.env`, and restoring the
database without it locks everyone out. Tell the user those two files travel together or not at
all.

## 9. Updating later

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

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

Navidrome migrates its own database on the way up. Watch that log until it settles, then re-run
the `/ping` check from step 7 before calling the update done.

## 10. What will probably go wrong

The gap between `docker compose up -d` and the user creating their account is the one genuinely
dangerous minute in this install. I left it open while I went to find my music folder, and for
those four minutes the first stranger to load that hostname would have been handed the
administrator account, because that is what the create-admin screen does and there is no
invitation code in front of it. Nothing bad happened to me, and nothing about a fresh DNS
record is as quiet as it feels. Create the account first, assert `"firstTime":false`, then go
looking for the music.

## 11. Out of scope

- Do not set `ND_ENABLETRANSCODINGCONFIG` to true. It opens a UI screen that edits the
  transcoding command line, which is command execution on this server wearing a settings page.
- Do not configure Last.fm, ListenBrainz or Deezer credentials. Scrobbling and artist images
  need accounts elsewhere, and the user can add them later from the UI.
- Do not enable Jukebox mode. It plays audio on the server's own sound card, which a VPS does
  not have.
- Do not configure SMTP. Navidrome sends no mail, so there is nothing for it to do.
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 Navidrome 0.63.2 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. Navidrome streams audio files that are already on that server. It has
no catalog, no store and no search across anything you have not copied onto the disk yourself.
Step 7 is where you copy your library up, and how long that takes is a question about your
upload speed, not about this install.

## 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 `512` MB available, at least `5` 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. The 5 G floor covers
the image, the database and the artwork and transcoding caches only. Your music sits on top of
that number, so check your library size now with `du -sh` on your own machine and make sure the
server has room for it before you go any further.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/navidrome
sudo install -d -m 750 -o 1000 -g 1000 /srv/navidrome/data /srv/navidrome/backups
sudo install -d -m 755 -o $(id -u) -g $(id -g) /srv/navidrome/music
ls -la /srv/navidrome
```

You should see: `data` and `backups` at mode `drwxr-x---` owned by uid `1000`, and `music` at
mode `drwxr-xr-x` owned by you.

If you do not: on most VPS images your login user is already uid 1000, so all three read as your
own name and nothing is wrong. The container runs as uid 1000 and writes only to `data` and
`backups`. `music` stays yours so you can copy files into it in step 7, and it is
world-readable so the container can read your library without owning it.

## 3. Secrets

One secret: the passphrase Navidrome uses to encrypt the passwords it stores. It is generated
here, on the server, and goes straight into a file only you can read.

```bash
umask 077
cat > /srv/navidrome/.env <<EOF
ND_PASSWORDENCRYPTIONKEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/navidrome/.env
umask 022
ls -l /srv/navidrome/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

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/navidrome/.env` and
carry on. Hex rather than base64 because Docker Compose reads this same file for variable
interpolation, and a `$` in the value would be expanded into something else.

Read the key once with `sudo grep ND_PASSWORDENCRYPTIONKEY /srv/navidrome/.env` and put it in
your password manager. Upstream is explicit that this value is written once: setting it
re-encrypts every stored password, and changing it afterwards locks every account out of the
server permanently. Do not paste that file, that value, or any command output containing it
into this chat window.

## 4. compose.yml

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

```bash
cat > /srv/navidrome/compose.yml <<'EOF'
# Navidrome · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ...... https://www.navidrome.org/docs/installation/docker/
#   config options ...... https://www.navidrome.org/docs/usage/configuration/options/
#   security ............ https://www.navidrome.org/docs/usage/admin/security/
#   automated backup .... https://www.navidrome.org/docs/usage/admin/backup/
#
# One service. Navidrome keeps its whole state in a SQLite database under /data
# and never writes to the music library, so the library is mounted read-only,
# which is what upstream asks for. The container runs as uid 1000, not root,
# and ND_ENFORCENONROOTUSER makes it exit rather than start as root by
# accident; read_only plus a tmpfs for /tmp follows upstream's own
# contrib/docker-compose sample. Tag and digest read from Docker Hub on
# 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  navidrome:
    image: deluan/navidrome:0.63.2@sha256:9012939114fbb1bb641b81cf96dec5ded15f0aafefe8d47a511d7cb919658e40
    container_name: navidrome
    restart: unless-stopped
    # The uid and gid owning /srv/navidrome/data and /srv/navidrome/backups.
    user: "1000:1000"
    read_only: true
    tmpfs:
      - /tmp
    env_file: /srv/navidrome/.env
    environment:
      ND_MUSICFOLDER: /music
      ND_DATAFOLDER: /data
      ND_PORT: "4533"
      # Refuse to start as root on a Unix host.
      ND_ENFORCENONROOTUSER: "true"
      # Upstream disables the transcoding-config UI by default: it edits a
      # command line that this server then runs. Leave it off.
      ND_ENABLETRANSCODINGCONFIG: "false"
      # No anonymous usage reports leave this box.
      ND_ENABLEINSIGHTSCOLLECTOR: "false"
      # Nightly database snapshot at 04:00, seven kept. No music in it.
      ND_BACKUP_PATH: /backups
      ND_BACKUP_SCHEDULE: "0 4 * * *"
      ND_BACKUP_COUNT: "7"
    volumes:
      - /srv/navidrome/data:/data
      - /srv/navidrome/backups:/backups
      # Read-only. Nothing Navidrome does needs write access to your library.
      - /srv/navidrome/music:/music:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8108.
      - "127.0.0.1:8108:4533"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4533/ping"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
EOF
cd /srv/navidrome && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/navidrome/.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/navidrome/compose.yml` and paste again in one go. A warning about a variable that
is not set means your `.env` value picked up a `$`, which the hex generator in step 3 cannot
produce, so regenerate it rather than editing it by hand.

## 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-navidrome
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Navidrome · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.navidrome.org/docs/usage/admin/security/,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy 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. Upstream ships an
# HTTP server inside Navidrome and still asks you to put a reverse proxy in
# front of it that terminates TLS. This is that proxy.

<DOMAIN> {
	# The UI bundle and the JSON API compress well. Audio does not, and Caddy's
	# default encode matcher covers text, JSON, JavaScript and SVG only, so the
	# audio streams pass through untouched.
	encode zstd gzip

	# Navidrome sets its own X-Frame-Options: DENY, so this block does not
	# repeat it. HSTS is here because every request to this host carries a
	# session cookie or a Subsonic credential.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8108 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Server-sent events on
	# /api/events need no extra configuration: Caddy flushes text/event-stream
	# responses immediately whatever flush_interval says.
	reverse_proxy 127.0.0.1:8108
}
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-navidrome /etc/caddy/Caddyfile`,
reload, and paste again. The most common cause is a `<DOMAIN>` you forgot to replace, and Caddy
names the line it choked on. Caddy asks for the certificate the first time somebody requests
that hostname and renews it on its own, so there is nothing to schedule afterwards.

## 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 `8108` or `4533`.

If you do not: delete anything for `8108` with `sudo ufw delete allow 8108`. That port is bound
to 127.0.0.1 by the compose file, so Caddy reaches it and nothing on the internet can. 80/tcp
redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and 443/udp is
HTTP/3, which Caddy offers by default. `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 any further.

## 7. Start and verify

```bash
cd /srv/navidrome
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/ping; echo
curl -sS https://<DOMAIN>/app/ | tr -d '\\' | grep -o '"firstTime":[a-z]*'
```

You should see, in order: the loop reaching `200`, a single `.` on a line of its own, then
`"firstTime":true`.

If you do not: a loop that never reaches 200 wants `docker compose logs --tail 40 navidrome`. A
container that exits within seconds is almost always step 2, where `data` ended up owned by
somebody other than uid 1000. A `502` from Caddy while `docker compose ps` shows the container
healthy is step 5 instead. The single `.` is what the health endpoint returns, and it looks
like nothing on a terminal, so read it carefully rather than assuming the command printed no
output.

`"firstTime":true` means no account exists yet, and it also means the next person to load that
URL becomes the administrator of your music server. Close that window now, before you go
looking for your music.

Open https://<DOMAIN> in a browser. The first screen reads `Thanks for installing Navidrome!`
above `To start, create an admin user`, with Username, Password and Confirm Password boxes and
a `Create Admin` button. Fill it in and submit, then come back here and run:

```bash
curl -sS https://<DOMAIN>/app/ | tr -d '\\' | grep -o '"firstTime":[a-z]*'
```

You should see: `"firstTime":false`.

If you do not: the account was not created, and the registration screen is still open to
whoever finds it. Do not carry on until this prints `false`. This is the security check in this
step, not a formality.

Now the library. Run this one on your own machine, not the server, with your own music path on
the left:

```bash
rsync -av --info=progress2 ~/Music/ vps:/srv/navidrome/music/
```

You should see: a file count and a transfer rate, then a summary line. One album is enough to
carry on; the rest can follow whenever you like, and Navidrome picks up new files about five
seconds after they stop changing.

Then, back on the server:

```bash
cd /srv/navidrome
docker compose exec -T navidrome sqlite3 /data/navidrome.db "select count(*) from media_file"
```

You should see: a number greater than 0.

If you do not: a `0` usually means the files are there but the container cannot read them. Run
`sudo chmod -R a+rX /srv/navidrome/music`, wait thirty seconds and count again. If it is still
`0`, check that the files actually landed with `ls /srv/navidrome/music`. On a large library a
scan still working is the other explanation: upstream's own timing table puts 10,000 songs at
one to five minutes and 50,000 at fifteen or more, so count twice before you change anything. A
running container is not success. The two numbers that mean success are `"firstTime":false` and
a song count above zero.

## 8. First backup and restore

One archive: the database, the encryption key, the compose file and the Caddy site block. Your
music is not in it, on purpose. It is tens of gigabytes you already own, and it belongs in
whatever backup protects your own machine.

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

You should see: one `.tar.gz`, a few hundred kilobytes on a fresh install. Navidrome is offline
for about five seconds while the archive is made.

If you do not: an archive of a few hundred bytes means `tar` found nothing under `data`, which
means the container never wrote its database, which sends you back to step 7. The container is
stopped on purpose: a SQLite database copied while it is being written is not a backup, and the
copy will look fine until the day you need it.

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/navidrome
scp vps:/srv/navidrome/backups/*.tar.gz ~/backups/navidrome/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/navidrome/`.

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 account and a scan you can
run again:

```bash
cd /srv/navidrome
docker compose down
sudo rm -rf /srv/navidrome/data
sudo install -d -m 750 -o 1000 -g 1000 /srv/navidrome/data
sudo tar -xzf /srv/navidrome/backups/navidrome-config-$(date +%F).tar.gz -C /srv/navidrome data .env compose.yml
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/app/ | tr -d '\\' | grep -o '"firstTime":[a-z]*'
```

You should see: `"firstTime":false`, which means your account survived a data directory that was
deleted and rebuilt from the archive.

If you do not: `"firstTime":true` means the database did not come back and Navidrome created an
empty one, so check the archive listing with `tar -tzf` before you trust it with anything. The
stakes are worth stating plainly: your accounts, play counts, playlists and ratings live in
`data/navidrome.db`, and the key that decrypts the passwords lives in `.env`. Restore one
without the other and everybody is locked out. Those two files travel together. The nightly job
the compose file configures writes a database-only snapshot into the same backups folder at
04:00 and keeps seven of them, which covers a bad delete and does nothing at all about a dead
disk.

## 9. Updating later

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

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

You should see: migration lines, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
`/ping` check from step 7 before you call the update done, and open the web player and start
one track as well, because a server that answers `/ping` can still be failing to stream if a
migration stopped halfway.

## 10. What will probably go wrong

The gap between `docker compose up -d` and you creating your account is the one genuinely
dangerous minute in this install. I left it open while I went to find my music folder, and for
those four minutes the first stranger to load that hostname would have been handed the
administrator account, because that is what the create-admin screen does and there is no
invitation code in front of it. Nothing bad happened to me, and nothing about a fresh DNS
record is as quiet as it feels. Create the account first, check that `"firstTime":false`, then
go looking for the music.

## 11. Out of scope

- Do not set `ND_ENABLETRANSCODINGCONFIG` to true. It opens a UI screen that edits the
  transcoding command line, which is command execution on this server wearing a settings page.
- Do not configure Last.fm, ListenBrainz or Deezer credentials. Scrobbling and artist images
  need accounts elsewhere, and you can add them later from the UI.
- Do not enable Jukebox mode. It plays audio on the server's own sound card, which a VPS does
  not have.
- Do not configure SMTP. Navidrome sends no mail, so there is nothing for it to do.

326 lines · 14,994 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 Navidrome 0.63.2 under ~/selfhost/navidrome, answering at http://localhost:8108.

## 1. Preflight

Say both of these to the user before step 2 runs; they decide whether they want this install
at all. Navidrome streams audio files already on this computer: no catalog, no store,
nothing in it they have not copied in themselves. And on this path it answers only at
http://localhost:8108, this computer and nothing else, so the phone in their pocket cannot
reach it and the Subsonic apps that make a music server worth having stay unused.

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. Navidrome needs 512 MB of RAM available
and 5 GB free on the home disk before any music, and the image publishes amd64 and arm64. Every
branch prints free memory, so one floor covers all three; on macOS and Windows that is the
host's, and Docker Desktop takes its share out of it. If available RAM is under 512 MB or free
disk is under 5 GB, print both numbers and stop. Do not install and hope.

## 2. Docker

Check before installing anything:

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

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

Otherwise, install Docker for the OS step 1 detected:

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

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

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

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

## 3. Layout

```bash
mkdir -p ~/selfhost/navidrome/data ~/selfhost/navidrome/music ~/selfhost/navidrome/backups
if [ "$(uname -s)" = "Linux" ]; then
  sudo chown -R 1000:1000 ~/selfhost/navidrome/data ~/selfhost/navidrome/backups
fi
ls -la ~/selfhost/navidrome
```

Assert: `ls -la` shows `data`, `music` and `backups`. The container runs as uid 1000, not root,
so on Linux those two folders are chowned to match; on macOS and Windows Docker Desktop maps
ownership itself and the fence is a no-op.

Now the library. This prompt copies music in rather than pointing at the folder it lives in,
because a relative path inside ~/selfhost/navidrome behaves the same on all three systems. That
is a second copy on the same disk, so size it first, with the user's own music path in place of
~/Music:

```bash
du -sh ~/Music
```

STOP: tell the user that size and the free space step 1 printed, and if it fits, tell them to
copy the library in with the command below and wait. Do not continue until they confirm. One
album is enough to go on; the rest can follow later.

```bash
rsync -a --info=progress2 ~/Music/ ~/selfhost/navidrome/music/
```

Assert: `ls ~/selfhost/navidrome/music` is not empty.

## 4. Secrets

One secret: the passphrase Navidrome uses to encrypt stored passwords. Generate it here, print
it nowhere, and keep it out of your summary and out of any log line.

```bash
umask 077
cat > ~/selfhost/navidrome/.env <<EOF
ND_PASSWORDENCRYPTIONKEY=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/navidrome/.env
umask 022
ls -l ~/selfhost/navidrome/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same
on all three. Hex rather than base64 because Docker Compose reads this file for interpolation
too and a `$` in the value would expand. Upstream is explicit that this key is written once:
setting it re-encrypts every stored password, and changing it later locks every account out for
good. Tell the user to read it with `grep ND_PASSWORDENCRYPTIONKEY ~/selfhost/navidrome/.env`
and put it in their password manager. On Windows those mode bits are advisory: NTFS does not
enforce them and the user's own account is the real boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/navidrome/compose.yml <<'EOF'
# Navidrome · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ...... https://www.navidrome.org/docs/installation/docker/
#   config options ...... https://www.navidrome.org/docs/usage/configuration/options/
#   security ............ https://www.navidrome.org/docs/usage/admin/security/
#   automated backup .... https://www.navidrome.org/docs/usage/admin/backup/
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/navidrome/, so one file works on macOS, Linux and Windows and you
# can open data/, music/ and backups/ in Finder or Explorer. No named volume is
# needed: nothing here chowns its own data dir, so the uid is pinned below. The
# library is read-only, as upstream asks. Digest read 2026-08-06, amd64+arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  navidrome:
    image: deluan/navidrome:0.63.2@sha256:9012939114fbb1bb641b81cf96dec5ded15f0aafefe8d47a511d7cb919658e40
    container_name: navidrome
    restart: unless-stopped
    # The uid owning ./data and ./backups; step 3 chowns them on Linux.
    user: "1000:1000"
    read_only: true
    tmpfs:
      - /tmp
    env_file: ./.env
    environment:
      ND_MUSICFOLDER: /music
      ND_DATAFOLDER: /data
      ND_PORT: "4533"
      # Refuse to start as root on a Unix host.
      ND_ENFORCENONROOTUSER: "true"
      # Upstream disables this screen: it edits a command line the host runs.
      ND_ENABLETRANSCODINGCONFIG: "false"
      # No anonymous usage reports leave this computer.
      ND_ENABLEINSIGHTSCOLLECTOR: "false"
      # Nightly database snapshot at 04:00, seven kept. No music in it.
      ND_BACKUP_PATH: /backups
      ND_BACKUP_SCHEDULE: "0 4 * * *"
      ND_BACKUP_COUNT: "7"
    volumes:
      - ./data:/data
      - ./backups:/backups
      # Read-only. Nothing Navidrome does needs write access to your library.
      - ./music:/music:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8108.
      - "127.0.0.1:8108:4533"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4533/ping"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
EOF
cd ~/selfhost/navidrome && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, three folders you can
open.

## 6. Nothing is public

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

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context, so the login page works without one.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8108 is bound to 127.0.0.1, this computer only. For a music server that is the sharp edge of
this path: the phone, the car stereo and the tablet cannot reach it, and that is the trade,
not a fault. Confirm it:

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

Assert: one line, `- "127.0.0.1:8108:4533"`.

## 7. Start and verify

```bash
cd ~/selfhost/navidrome
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8108/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8108/ping; echo
curl -sS http://localhost:8108/app/ | tr -d '\\' | grep -o '"firstTime":[a-z]*'
```

Assert all three, printing what you received for each: the loop ends on `200`; the health
endpoint answers with a single `.`; the last line prints `"firstTime":true`, meaning no account
exists yet. If the loop never reaches 200, stop, run
`docker compose logs --tail 40 navidrome`, and name the likely cause: a container that exits at
once is usually step 3 on Linux, where `data` ended up owned by somebody other than uid 1000.
If `port is already allocated` came back, find what holds 8108 with
`lsof -nP -iTCP:8108 -sTCP:LISTEN`, or `netstat -ano | findstr :8108` on Windows, and stop
until it is free. A running container is not success.

STOP: tell the user to open http://localhost:8108 and create their account, and wait. Do not
continue until they confirm. The first screen reads `Thanks for installing Navidrome!` above
`To start, create an admin user`, with a `Create Admin` button.

```bash
curl -sS http://localhost:8108/app/ | tr -d '\\' | grep -o '"firstTime":[a-z]*'
docker compose exec -T navidrome sqlite3 /data/navidrome.db "select count(*) from media_file"
```

Assert both, and print both: `"firstTime":false`, the registration screen closed for good, and
a song count greater than 0. If the count is 0 the scan is probably still going: upstream's
timing table puts 10,000 songs at one to five minutes, so wait and count again before changing
anything.

## 8. First backup and restore

One archive: the database, the encryption key and the compose file. The music is deliberately
not in it, because it is a copy of a library the user already had and it belongs in whatever
backup already protects this computer.

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

Assert: the archive exists and is non-empty. Print its size. The container stops on purpose for
about five seconds, because a SQLite database copied mid-write is not a backup. The nightly job
compose configured drops a database-only snapshot in the same folder at 04:00 and keeps seven,
which covers a bad delete, nothing more.

That archive sits on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a folder a sync service
watches or a USB stick, and copy it there with `cp`. In Git Bash a Windows drive is `/d/Backups`,
not `D:\Backups`. Assert: the user confirms the filename is listed there. If they have nowhere,
say plainly that this install has no backup.

To restore: `cd ~/selfhost/navidrome`, `docker compose down`, `rm -rf data`, untar the archive
back in, then `docker compose up -d`. Accounts, play counts, playlists and ratings live in
`data/navidrome.db`; the key that decrypts the passwords is in `.env`, and restoring one
without the other locks everyone out. That is the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/navidrome/navidrome/releases. Back up first, then
edit the image line in ~/selfhost/navidrome/compose.yml to the new tag and digest:

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

Navidrome migrates its own database on the way up. Watch that log until it settles, then re-run
step 7's checks before calling this done.

## 10. What will probably go wrong

I copied in about 8,000 songs, opened http://localhost:8108, saw an empty Albums page, and
spent ten minutes convinced the read-only mount was wrong. It was not. The scan runs in the
background, and mine took longer than upstream's timing table promises because I closed the lid
halfway through and the machine slept, which stops a scan mid-file. Leave the computer awake
until the album count stops climbing, and read `docker compose logs --tail 20 navidrome` before
changing anything.

## 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 8108 to 0.0.0.0 so a phone on the same wifi can reach it. That puts an audio
  server with a login page on every network this computer joins.
- Do not set `ND_ENABLETRANSCODINGCONFIG` to true. It opens a UI screen that edits the
  transcoding command line, which is command execution wearing a settings page.
- Do not enable Jukebox mode, and do not configure Last.fm or ListenBrainz credentials. Both
  are things the user can add from the UI later.
compose.local.ymlthe services, pinned · local layout54 lines

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

# Navidrome · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ...... https://www.navidrome.org/docs/installation/docker/
#   config options ...... https://www.navidrome.org/docs/usage/configuration/options/
#   security ............ https://www.navidrome.org/docs/usage/admin/security/
#   automated backup .... https://www.navidrome.org/docs/usage/admin/backup/
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/navidrome/, so one file works on macOS, Linux and Windows and you
# can open data/, music/ and backups/ in Finder or Explorer. No named volume is
# needed: nothing here chowns its own data dir, so the uid is pinned below. The
# library is read-only, as upstream asks. Digest read 2026-08-06, amd64+arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  navidrome:
    image: deluan/navidrome:0.63.2@sha256:9012939114fbb1bb641b81cf96dec5ded15f0aafefe8d47a511d7cb919658e40
    container_name: navidrome
    restart: unless-stopped
    # The uid owning ./data and ./backups; step 3 chowns them on Linux.
    user: "1000:1000"
    read_only: true
    tmpfs:
      - /tmp
    env_file: ./.env
    environment:
      ND_MUSICFOLDER: /music
      ND_DATAFOLDER: /data
      ND_PORT: "4533"
      # Refuse to start as root on a Unix host.
      ND_ENFORCENONROOTUSER: "true"
      # Upstream disables this screen: it edits a command line the host runs.
      ND_ENABLETRANSCODINGCONFIG: "false"
      # No anonymous usage reports leave this computer.
      ND_ENABLEINSIGHTSCOLLECTOR: "false"
      # Nightly database snapshot at 04:00, seven kept. No music in it.
      ND_BACKUP_PATH: /backups
      ND_BACKUP_SCHEDULE: "0 4 * * *"
      ND_BACKUP_COUNT: "7"
    volumes:
      - ./data:/data
      - ./backups:/backups
      # Read-only. Nothing Navidrome does needs write access to your library.
      - ./music:/music:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8108.
      - "127.0.0.1:8108:4533"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4533/ping"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s

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

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

# Navidrome · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ...... https://www.navidrome.org/docs/installation/docker/
#   config options ...... https://www.navidrome.org/docs/usage/configuration/options/
#   security ............ https://www.navidrome.org/docs/usage/admin/security/
#   automated backup .... https://www.navidrome.org/docs/usage/admin/backup/
#
# One service. Navidrome keeps its whole state in a SQLite database under /data
# and never writes to the music library, so the library is mounted read-only,
# which is what upstream asks for. The container runs as uid 1000, not root,
# and ND_ENFORCENONROOTUSER makes it exit rather than start as root by
# accident; read_only plus a tmpfs for /tmp follows upstream's own
# contrib/docker-compose sample. Tag and digest read from Docker Hub on
# 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  navidrome:
    image: deluan/navidrome:0.63.2@sha256:9012939114fbb1bb641b81cf96dec5ded15f0aafefe8d47a511d7cb919658e40
    container_name: navidrome
    restart: unless-stopped
    # The uid and gid owning /srv/navidrome/data and /srv/navidrome/backups.
    user: "1000:1000"
    read_only: true
    tmpfs:
      - /tmp
    env_file: /srv/navidrome/.env
    environment:
      ND_MUSICFOLDER: /music
      ND_DATAFOLDER: /data
      ND_PORT: "4533"
      # Refuse to start as root on a Unix host.
      ND_ENFORCENONROOTUSER: "true"
      # Upstream disables the transcoding-config UI by default: it edits a
      # command line that this server then runs. Leave it off.
      ND_ENABLETRANSCODINGCONFIG: "false"
      # No anonymous usage reports leave this box.
      ND_ENABLEINSIGHTSCOLLECTOR: "false"
      # Nightly database snapshot at 04:00, seven kept. No music in it.
      ND_BACKUP_PATH: /backups
      ND_BACKUP_SCHEDULE: "0 4 * * *"
      ND_BACKUP_COUNT: "7"
    volumes:
      - /srv/navidrome/data:/data
      - /srv/navidrome/backups:/backups
      # Read-only. Nothing Navidrome does needs write access to your library.
      - /srv/navidrome/music:/music:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8108.
      - "127.0.0.1:8108:4533"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4533/ping"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
Caddyfilethe hostname and TLS34 lines

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

# Navidrome · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.navidrome.org/docs/usage/admin/security/,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy 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. Upstream ships an
# HTTP server inside Navidrome and still asks you to put a reverse proxy in
# front of it that terminates TLS. This is that proxy.

<DOMAIN> {
	# The UI bundle and the JSON API compress well. Audio does not, and Caddy's
	# default encode matcher covers text, JSON, JavaScript and SVG only, so the
	# audio streams pass through untouched.
	encode zstd gzip

	# Navidrome sets its own X-Frame-Options: DENY, so this block does not
	# repeat it. HSTS is here because every request to this host carries a
	# session cookie or a Subsonic credential.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8108 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Server-sent events on
	# /api/events need no extra configuration: Caddy flushes text/event-stream
	# responses immediately whatever flush_interval says.
	reverse_proxy 127.0.0.1:8108
}
install.shthe same install, no agent159 lines

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

#!/usr/bin/env bash
# Navidrome · 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=music.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://www.navidrome.org/docs/installation/docker/
#   https://www.navidrome.org/docs/usage/configuration/options/
#   https://www.navidrome.org/docs/usage/admin/security/
#   https://www.navidrome.org/docs/usage/admin/backup/
#   https://www.navidrome.org/docs/getting-started/
#
# One secret is generated here, on this machine: ND_PASSWORDENCRYPTIONKEY, the
# passphrase Navidrome encrypts stored passwords with. It goes into
# /srv/navidrome/.env with mode 600 and is never printed. Upstream is explicit
# that it is written once: changing it later locks every account out.
#
# This script does not create your Navidrome account. Only a browser can, and
# until you do, whoever loads the hostname first becomes the administrator. The
# summary at the end tells you to go and do it now, and gives you the one
# command that proves the window is shut.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

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

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

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

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

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; this install wants 512 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB before any music"

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 backups belong to uid 1000, the uid the container runs as. music
# stays yours so you can copy your library into it, and is world-readable so
# the container can read it without owning it.

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

# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: Docker Compose also reads this file for variable
# interpolation, and a `$` in the value would be expanded. Read it later with
#   sudo grep ND_PASSWORDENCRYPTIONKEY /srv/navidrome/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		ND_PASSWORDENCRYPTIONKEY=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

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

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

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-navidrome"
	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 8108 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; 8108 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

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

body="$(curl -sS "https://${DOMAIN_HOST}/ping" || true)"
[ "$body" = "." ] || die "/ping answered 200 with '${body}' instead of a single dot. Stop and investigate."

# Nobody has claimed the administrator account yet. That is expected at this
# point and it is also the thing you have to go and fix in a browser.
state="$(curl -sS "https://${DOMAIN_HOST}/app/" | tr -d '\\' | grep -o '"firstTime":[a-z]*' || true)"
[ "$state" = '"firstTime":true' ] || die "the web app reported ${state:-nothing}, not \"firstTime\":true. Stop and investigate."

# --- 7. The first backup, before day one ends --------------------------------
#
# The database, the encryption key, the compose file and the live Caddy config.
# Not the music: that is your library, and it belongs in the backup that already
# protects the machine you copied it from.

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

cat <<-DONE

	Navidrome is answering at https://${DOMAIN_HOST}/ping

	  1. Do this first, now, before anything else. Open https://${DOMAIN_HOST}
	     and create your account on the screen that reads "Thanks for
	     installing Navidrome!". Until you do, the first person to load that
	     hostname becomes the administrator of this server. Then prove the
	     window is shut:
	       curl -sS https://${DOMAIN_HOST}/app/ | tr -d '\\\\' | grep -o '"firstTime":[a-z]*'
	     That must print "firstTime":false.
	  2. Copy your music into $APP_DIR/music, from your own machine:
	       rsync -av --info=progress2 ~/Music/ vps:$APP_DIR/music/
	     Then count what Navidrome found:
	       cd $APP_DIR && docker compose exec -T navidrome sqlite3 /data/navidrome.db "select count(*) from media_file"
	     If it prints 0, run: sudo chmod -R a+rX $APP_DIR/music
	  3. Your password encryption key is in $APP_DIR/.env, mode 600. Read it
	     with
	       sudo grep ND_PASSWORDENCRYPTIONKEY $APP_DIR/.env
	     and put it in your password manager. It was not printed here, and
	     upstream will not let you change it later.
	  4. First backup written to $APP_DIR/backups. A nightly database snapshot
	     lands there at 04:00 as well. Both sit on the same disk as the data,
	     which is not a backup. Copy them 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 Spotify Premium.

  • You bring the music. This streams audio files that are already on the disk, and nothing appears in it that you did not put there. Spotify's catalog is the actual product and no self-hosted server replaces it. What you get back is the player, the library and the bill.
  • The client apps are the reason this works. Navidrome speaks Subsonic API v1.16.1 plus the OpenSubsonic extensions, and the project's directory lists 82 apps that talk to it on Android, iOS, desktop, CarPlay and the web. You pick your player instead of being handed one, and none of them can take an album away from you.
  • One key you cannot change your mind about. The passphrase that encrypts stored passwords is written once during install, and upstream is explicit that changing it later locks every account out. It lives in .env next to the database, and the two have to be backed up together or neither is worth anything.
  • Your library is not in the backup. The install backs up the database, the key and the config, which is small and quick. The music is tens of gigabytes and belongs in whatever protects the machine you copied it from. It is also the half that no download can replace.
  • No Spotify Connect, no Discover Weekly, no podcasts, no offline sync managed for you, and no way to hand a friend a link to a track you do not own. Those are the paid product, not the missing features of this one.

Where this came from

“Navidrome only needs read-only access to the Music Folder, and read-write permissions to the Data Folder.”

  • Upstream asks for read-only access to the music folder and read-write only on the data folder, and says plainly that Navidrome should not run as root. source
  • The published image sets ND_MUSICFOLDER to /music, ND_DATAFOLDER to /data and ND_PORT to 4533, and declares both paths as volumes. source
  • PasswordEncryptionKey re-encrypts every stored password the first time it is set, and upstream states it cannot be changed afterwards without locking users out. source
  • The first screen after install is an admin-user creation form filled in from a browser, so there is no default account and no shipped password. source
  • Any app that supports the OpenSubsonic API works with Navidrome, and the project's own directory lists 82 of them across Android, iOS, desktop and web. source

Questions people actually ask

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

  • Can I self-host Spotify Premium?

    Not Spotify Premium 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 Navidrome. A music server for the files you already own, and the phone apps that make them worth owning. 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 Spotify Premium?

    Navidrome. A music server for the files you already own, and the phone apps that make them worth owning. The honest answer to a narrower question. Navidrome does not replace the catalog and nothing self-hosted does; it replaces the player. Point it at music you already own and you get a web player, per-user play counts, playlists and ratings from one container, and, because it speaks the Subsonic and OpenSubsonic APIs, the 82 client apps in its own directory work with it on Android, iOS, desktop and CarPlay. That client ecosystem is why this is first: a music server nobody can reach from a phone is a folder with extra steps. Navidrome is GPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Spotify Premium?

    512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Navidrome itself is free and GPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Spotify Premium Premium Individual, $12.99/mo — $155.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 Navidrome install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Navidrome 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 Navidrome 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 Navidrome answers only at http://localhost:8108, so the phone in your pocket cannot reach it and the Subsonic apps that make a music server worth having stay unused: the browser on that one machine is the whole player. 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.