Self-host music streaming on your own VPS
Run Navidrome on a VPS and stream your own music library to any phone. Storage sizing, Subsonic clients, offline sync, TLS, and safe backups.
What self-hosted music streaming on a VPS gives you
Self-hosted music streaming on a VPS means you run the player and you supply the music. The server holds files you already own, and any phone can reach it over the internet with a normal login. Be honest about the trade before you start: this replaces the player of a streaming service, not its catalogue. Nothing new appears in your library until you buy it or rip it, and copy the file to the server.
Audio is far kinder to a server than video is. The files are small, phones decode every common format without help, and one listener uses less bandwidth than a video call. CPU is the easy part here. Disk space is the real constraint. The other two things that decide whether this works are the quality of your tags and whether your phone app can download music for offline use.
Which music server: Navidrome, Jellyfin, or Funkwhale?
Navidrome is the default choice when you only care about audio. It is one Go binary in one container, and it keeps its state in a single SQLite database. It answers the Subsonic API, which is the reason a large family of third party phone apps exists at all. Version 0.63.2 was current in August 2026. The project describes it as running well on hardware as small as a Raspberry Pi Zero, so the server software is not the part you will pay for.
Jellyfin is worth using if you already run Jellyfin as a media server on a VPS for video. Its music library works, and Finamp is a Jellyfin music app for Android and iOS that downloads tracks for offline listening. The limit is the API. Jellyfin has no built in Subsonic endpoint, and the community plugin that added one stopped being updated in 2022, so the Subsonic app ecosystem is closed to it. You use the apps that speak Jellyfin's own API instead, and there are fewer of those.
Funkwhale is the federated option, and version 2.0 was released in March 2026. A Funkwhale server is called a pod, pods federate over ActivityPub (the protocol behind Mastodon), and a user on one pod can follow a public library on another. It answers part of the Subsonic API too, with one difference worth knowing: each user sets a separate Subsonic password in their own settings, because the Subsonic protocol expects a password the server can read back. Funkwhale is a heavier install, since it needs PostgreSQL and a task queue beside the web app.
Pick Navidrome unless you want federation, or unless Jellyfin is already running. The rest of this guide sets up Navidrome with Docker.
Why the Subsonic API decides which phone app you get
Subsonic was a music server whose HTTP API became the common language for self-hosted audio, and OpenSubsonic is the community project that keeps extending it. That history is why your phone has choices. Navidrome ships no mobile app of its own and does not need one, because any Subsonic client logs in with a server address and your account details.
This matters most for offline sync, which is the feature that decides whether the setup survives daily use. A phone in a tunnel cannot reach your server, so the client must have copied the files to local storage in advance. Every client streams. Only some download. The client directory at navidrome.org/apps says which ones do, and the choice is wide on both platforms: Substreamer and Ultrasonic on Android, Amperfy and play:Sub on iOS. Several of the strongest clients are paid apps, and Symfonium on Android is the one people name most often. Install two before you decide, because this is the part of the system you touch every day.
How much storage does a music library need?
The data behind this chart
[
{
"label": "Opus 128k",
"kbps": 128,
"gb_per_1000_albums": 43
},
{
"label": "MP3 320k",
"kbps": 320,
"gb_per_1000_albums": 108
},
{
"label": "FLAC 16/44.1",
"kbps": 900,
"gb_per_1000_albums": 304
},
{
"label": "FLAC 24/96",
"kbps": "3,000",
"gb_per_1000_albums": "1,013"
}
]Those are order of magnitude figures derived from the bitrate, not measurements of a real collection, and the arithmetic is simple enough to check against your own files. Treat an album as 45 minutes, which is 2,700 seconds. Multiply the bitrate in kilobits per second by 2,700, then divide by 8,000 to get megabytes. At 320 kbps that gives 108 MB for one album, so a thousand albums is about 108 GB.
Lossless changes the answer. CD quality FLAC averages near 900 kbps on ordinary material, so the same thousand albums needs about 304 GB. A 24 bit, 96 kHz library runs to roughly 1,013 GB, a full terabyte for a collection you could list on paper. A phone friendly Opus copy at 128 kbps fits that same thousand albums into 43 GB. Run du -sh /path/to/music on the library you already have, because your own average bitrate is the only number that matters when you pick a plan.
Bandwidth is the smaller half of the bill. A 320 kbps stream is 40 kilobytes per second, so an hour of listening moves about 144 MB. A hundred hours in a month is around 14 GB, which no VPS transfer allowance notices. The exception is the first offline sync of a phone, which can push tens of gigabytes in one evening.
Storage tier or compute tier?
A music server is a large pile of cold data with almost no computation on top of it. Reading a file at 40 kilobytes per second leaves any disk idle, and the CPU only works during a library scan or during transcoding, which you mostly will not do. So the fast NVMe on a compute plan buys you nothing here, while its price per gigabyte is the thing that stops you uploading the FLAC copies. This is the case where a storage VPS beats a regular VPS, because those plans are priced per terabyte rather than per core.
Memory is modest. Navidrome serves a personal library in a few hundred megabytes, and the scan is the peak rather than playback. Give the box 1 GB or 2 GB of RAM and spend the rest of the budget on disk.
Install Navidrome with Docker Compose
Create the directories first, owned by the user id the container will run as. If Compose is new to you, Docker Compose on a VPS covers what this file assumes.
sudo install -d -m 755 -o 1000 -g 1000 /srv/navidrome /srv/musicWrite docker-compose.yml next to nothing else, in its own directory:
services:
navidrome:
image: deluan/navidrome:0.63.2
user: "1000:1000"
ports:
- "127.0.0.1:4533:4533"
restart: unless-stopped
environment:
ND_LOGLEVEL: "info"
ND_SESSIONTIMEOUT: "24h"
ND_SCANNER_SCHEDULE: "@every 24h"
ND_BACKUP_PATH: "/data/backup"
ND_BACKUP_SCHEDULE: "0 4 * * *"
ND_BACKUP_COUNT: "7"
volumes:
- /srv/navidrome:/data
- /srv/music:/music:rodocker compose up -d
docker compose psdocker compose ps should show the service as running rather than restarting. A container that restarts in a loop is almost always a permissions problem on /srv/navidrome, and docker compose logs navidrome names the file it could not write.
Four details in that file are worth explaining. The port is published on 127.0.0.1 only, so the server is reachable through the reverse proxy and not on port 4533 from the open internet: Docker writes its own firewall rules, so a plain 4533:4533 stays exposed even on a box where ufw reports everything as denied. The music volume is read only, which means a scanner bug cannot delete your only copy. ND_SCANNER_SCHEDULE is disabled by default, and guides written before Navidrome 0.55 call it ND_SCANSCHEDULE, a name that no longer exists. The three backup settings turn on the built in database backup that the backup section below relies on.
Get your music onto the server
Copy the library up with rsync, which resumes instead of starting again when the connection drops.
rsync -av --info=progress2 ~/Music/ user@music.example.com:/srv/music/The trailing slash on the source matters. Without it you get /srv/music/Music. When the copy finishes, fix the ownership:
sudo chown -R 1000:1000 /srv/music
id -uThe container runs as user id 1000, and the mount is read only, so every file has to be readable by that id. If your SSH account is not uid 1000 on the VPS, the copy lands owned by someone else and the scan finds zero tracks while the web interface stays cheerfully empty. id -u prints your real id, and how PUID and PGID work in Docker containers explains the mapping in full.
A reverse proxy and TLS so the phone works from anywhere
Point a DNS A record at the VPS, then give Caddy three lines:
music.example.com {
reverse_proxy 127.0.0.1:4533
}sudo systemctl reload caddyCaddy requests the certificate on the first request. Ports 80 and 443 both need to be open, because the ACME (automatic certificate management environment) challenge is answered on port 80. On nginx, add proxy_buffering off; to the location block: Navidrome pushes progress events to the web interface over one long lived connection, and with buffering on, the interface sits and waits for a response nginx is still holding. If you serve it under a path such as /music rather than its own subdomain, set ND_BASEURL to the same path or the interface loads as a blank page. The three common proxies are compared in Nginx, Caddy and Traefik on a VPS.
Open the site and create the first account. There is no default password, and the first visitor is asked to make the admin user, so do this before you tell anyone the address. Then test the exact path a phone client uses:
SALT=$(openssl rand -hex 6)
TOKEN=$(printf '%s%s' 'YOUR_PASSWORD' "$SALT" | md5sum | cut -d' ' -f1)
curl -s "https://music.example.com/rest/ping.view?u=YOUR_USER&t=$TOKEN&s=$SALT&v=1.16.1&c=curl&f=json"A healthy answer starts with {"subsonic-response":{"status":"ok" and names navidrome as the server type. A body containing "status":"failed" and error code 40 means the proxy is fine and the credentials are wrong. A certificate error at this step is the one to fix first, because most phone clients reject a bad certificate with a message that tells the user nothing.
Why your library looks wrong after the first scan
Navidrome browses by tags rather than by folders, so the tags decide what you see. A track with no album artist tag is filed under its track artist, which is why one compilation with a different artist on each track becomes twenty albums of one track each. Fix that in the files, not in Navidrome: MusicBrainz Picard and beets both look an album up in the MusicBrainz database and write standard tags back.
Navidrome also splits a multi artist tag into separate artists, so a band whose name contains a separator is split by mistake, AC/DC being the example everybody meets. ND_SCANNER_ARTISTSPLITEXCEPTIONS holds the names that must never be split.
New files are picked up by a file watcher a few seconds after they land. The watcher depends on kernel change notifications, and those do not arrive for files written on a network share mounted from another machine, so on that kind of setup the periodic ND_SCANNER_SCHEDULE is what keeps the library current. A full rescan reads the tags of every file, which is slow on a big library, and that is one reason the database below is worth protecting.
Users, playlists, and sharing
An admin creates the other accounts in the web interface, and there is no self signup. Each user gets separate play counts, playlists, favourites and ratings, so a household does not end up sharing one taste profile.
Playlists arrive from two directions. A playlist made in a client lives in the database. An .m3u file dropped into the library folder is imported during the scan, which is the easy way to carry playlists over from a desktop player. Smart playlists are .nsp files, small JSON rule files imported the same way, and they update themselves as the library changes.
Sharing is enabled by default from version 0.63.0, released in July 2026. It lets a user create a public link to an album that anyone can open without logging in. On a server holding your whole library that may not be what you want, and ND_ENABLESHARING set to false turns it off.
Back up the database separately from the music files
Navidrome's own backup covers the database only. The documentation is blunt about it: the backup process backs up the database, meaning users and play counts and the rest, and it does not back up the music or the config. That split is the right way to think about it, because the two halves fail differently. Music files can be copied up again from the disk you ripped them from. Play counts, ratings, favourites and playlists exist nowhere else, and a rescan does not bring them back.
The compose file already writes a nightly copy into /data/backup and keeps seven of them. Take one by hand before an upgrade:
sudo docker compose run --rm navidrome backup createRestoring wipes the current database and copies the backup into its place, and it has to run while Navidrome is stopped. Nothing about a restore is safe on a live server.
Those files still sit on the same VPS, so they do not survive the VPS. Push /srv/navidrome to another machine or to object storage on a schedule, which is the job restic and BorgBackup are built for. The whole directory is small, usually well under a gigabyte, so a daily encrypted copy off the box costs almost nothing and buys back everything a rescan cannot.
FAQ
Do I need to transcode music on a VPS?
Almost never. Phones and browsers decode MP3, AAC, Opus and FLAC on their own, so the server sends the file as it is and uses close to no CPU. There is one case worth enabling it for: a FLAC library streamed over mobile data, where converting from about 900 kbps down to Opus at 128 kbps cuts the data used by roughly seven times. Navidrome can do that per user and per player, and it stays off until you turn it on.
Why can my phone app not download music for offline listening?
Because offline storage is a client feature, not a server feature. The Subsonic API lets any client fetch a whole file, but the app decides whether to keep a copy on the phone. Check the client directory at navidrome.org/apps and pick one whose description mentions offline downloads or caching. Some clients only cache what you have already played, which is not the same as syncing an album before a journey.
Why did one album split into several albums after the scan?
The album artist tag is missing or inconsistent across the tracks. Navidrome groups by tags rather than by folder, so twelve tracks with twelve different artist values and no shared album artist value look like twelve albums. Set the album artist tag on every track of the album, usually to Various Artists for a compilation, then let it rescan. MusicBrainz Picard or beets will do this for a whole folder at once.
Does self-hosted music streaming replace Spotify?
It replaces the player and the library, not the catalogue. You get your own collection on every device, with the playlists and play counts that no licensing change can take away from you. You do not get new releases, and you do not get recommendations built from other people's listening. Most people who run this buy their music and keep a cheap streaming account for discovery.