- TypeScript 57.2%
- Go 27%
- Shell 7.8%
- Dockerfile 7.5%
- JavaScript 0.5%
| scripts_and_tools | ||
| upload-form | ||
| upload-handler | ||
| .gitignore | ||
| Dockerfile | ||
| Dockerfile.vanilla | ||
| entrypoint.sh | ||
| fly.toml | ||
| fly.vanilla.toml | ||
| navidrome.toml | ||
| nginx.conf | ||
| README.md | ||
| supervisord.conf | ||
| UPLOAD_PLAN.md | ||
navidrome stack on Fly.io
Personal music streaming server running Navidrome on Fly.io.
Two flavors: This repo defaults to the full stack (
Dockerfile+fly.toml). For vanilla Navidrome-only, useDockerfile.vanilla+fly.vanilla.tomlinstead (tested atv1.0.0tag — deluan/navidrome:latest, no nginx, no upload UI).
Architecture
flowchart LR
subgraph Fly["Fly Proxy (SSL termination)"]
direction TB
proxy["*:80 → :443"]
end
subgraph Container["Container (single Fly Machine)"]
direction TB
nginx["nginx :8080"]
subgraph Services[" "]
direction LR
nginx --> navidrome["navidrome :4533"]
nginx --> upload_handler["upload-handler :3000 (Go)"]
nginx -.-> static["static files<br>/var/www/upload<br>(Next.js build)"]
end
end
subgraph Volume["Fly Volume /data"]
direction TB
db["navidrome.db"]
music["music/<br>Library 1 (manual)"]
uploads["music/uploads/<br>Library 2 (uploaded)"]
end
proxy --> nginx
static -.-> nginx
navidrome --> db
navidrome --> music
navidrome --> uploads
upload_handler --> uploads
Services managed by supervisord:
- nginx — reverse proxy, routes traffic (port 8080)
- navidrome — music server (port 4533)
- upload-handler — Go binary receiving uploads (port 3000)
Setup
App and volume were created manually before first deploy:
fly apps create juliannymark-navidrome --org personal
fly volumes create navidrome_data --app juliannymark-navidrome --region ams --size 1
fly deploy
Volume
A single 1GB volume is mounted at /data. Both the Navidrome database and the music library live here:
graph TD
volume["/data (Fly Volume)"]
volume --> navidrome_db["navidrome.db"]
volume --> navidrome_toml["navidrome.toml"]
volume --> transcoding["transcoding/"]
volume --> music["music/<br>Library 1: manually curated"]
volume --> uploads["music/uploads/<br>Library 2: uploaded (7-day staging)"]
music --- m1["ALBUM_1/"]
music --- m2["ALBUM_2/"]
uploads --- u1["UUID_xxx/"]
style music stroke:#90EE90
style uploads stroke:#FFE4B5
| Path | Contents |
|---|---|
/data |
Navidrome database & cache |
/data/music |
Manually curated music library |
/data/music/uploads |
Uploaded music (7-day staging) |
Library Separation
Navidrome is configured with two libraries:
- Library 1 —
/data/music— manually curated, permanent - Library 2 —
/data/music/uploads— uploaded via web UI, auto-deleted after 7 days if not processed
The volume auto-extends by 1GB whenever usage hits 80%, up to a maximum of 100GB (configured via auto_extend_* in fly.toml). You can also extend manually:
fly volumes list --app juliannymark-navidrome
fly volumes extend <volume-id> -s <new-size-gb> --app juliannymark-navidrome
Fly takes daily snapshots retained for 5 days. Check them with:
fly volumes snapshots list <volume-id> --app juliannymark-navidrome
Uploading music
Web UI
Open https://juliannymark-navidrome.fly.dev/upload/ in your browser. You'll be prompted for the same credentials as Navidrome itself.
The upload UI:
- Accepts FLAC, MP3, M4A, OGG, WAV, AIFF, ZIP
- Shows current storage usage and limits
- Max file size: 1GB
- Files are grouped together if uploaded simultaneously (optional)
Important: Uploaded files land in /data/music/uploads (staging area) and are auto-deleted after 7 days if not processed. After uploading, poke the Navidrome instance maintainer to process/organize your files into the main library.
Quick single file or folder (no proxy needed)
fly sftp put --app juliannymark-navidrome -R ~/Music/Radiohead /data/music/Radiohead
SSH/rsync (for large transfers or power users)
All SSH approaches go through an fly proxy tunnel.
Automated Connection (Recommended)
./scripts_and_tools/fly-connect.sh
This script checks if your SSH keys are older than 20 hours and re-issues them automatically before starting the fly proxy.
Manual SSH/rsync setup (alternatives)
Step 1 — issue SSH credentials (once per 24h)
mkdir -p ~/.fly/ssh
fly ssh issue personal ~/.fly/ssh/navidrome --overwrite
This writes a private key and certificate to ~/.fly/ssh/navidrome (valid 24 hours). Re-run it when it expires.
Step 2 — open the tunnel
# keep this running in a dedicated terminal
fly proxy 2222:22 --app juliannymark-navidrome
SSH config alias
To make things easier (and the upload scripts kinda expect it) you can add this to ~/.ssh/config once — it keeps the key and port wiring in one place so all tools below just use fly-navidrome:
Host fly-navidrome
User root
Hostname 127.0.0.1
Port 2222
IdentityFile ~/.fly/ssh/navidrome
IdentitiesOnly yes
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
rsync helper scripts
./scripts_and_tools/rsync-to-cloud.sh # upload: ~/Music/navidrome/ → /data/music/
./scripts_and_tools/rsync-from-cloud.sh # download: /data/music/ → ~/Music/navidrome/
Only transfers new or changed files, so re-running after adding more music is fast.
Upload processing
Uploaded files sit in /data/music/uploads for 7 days before automatic deletion. To make them permanent:
- Navidrome picks up new files on its next scan (automatic, or trigger from Settings → Library in web UI)
- The instance maintainer needs to move/copy files from the Uploads library into the main library
- Once organized, files survive the 7-day cleanup
Upload flow
sequenceDiagram
participant Friend
participant nginx
participant upload_handler as upload-handler
participant navidrome
Friend->>nginx: GET /upload/
nginx-->>Friend: upload form HTML
Friend->>nginx: POST /upload-api (multipart)
nginx->>upload_handler: POST /upload-api
upload_handler->>uploads: save file to /data/music/uploads/groupID/
Note over upload_handler: 7-day cleanup runs on every boot
loop every scan
navidrome->>uploads: detect new files
navidrome->>navidrome: appear in Uploads library
end
Streaming
sequenceDiagram
participant User
participant nginx
participant navidrome
User->>nginx: GET /
nginx->>navidrome: GET /
navidrome-->>nginx: web UI
nginx-->>User: Navidrome UI
User->>nginx: GET /rest/ (Subsonic API)
nginx->>navidrome: GET /rest/
navidrome-->>nginx: JSON response
nginx-->>User: Subsonic response
Deploy
fly deploy
Logs
fly logs --app juliannymark-navidrome
Service-specific logs (inside the container):
# nginx
fly ssh issue personal ~/.fly/ssh/navidrome --app juliannymark-navidrome
# then: cat /var/log/nginx/stdout.log
# navidrome
fly ssh console --app juliannymark-navidrome
# then: cat /var/log/navidrome/stdout.log
# upload-handler
fly ssh console --app juliannymark-navidrome
# then: cat /var/log/upload-handler/stdout.log
Auto-stop
The machine stops when idle and starts automatically on the next request. To disable this (always-on), set auto_stop_machines = "off" in fly.toml and redeploy.
Development
To run the upload-form locally:
cd upload-form
pnpm install
NEXT_PUBLIC_API_BASE=http://localhost:3000 pnpm dev
Use docker-compose.dev.yml in scripts_and_tools/ for local testing of the full stack.