Restructure the docs ahead of sharing the project for others to self-host. The README is now a focused get-it-running path (overview, architecture, prereqs, quick start, operational notes, security, backup/restore). Moved the fuller feature tour, the maintainer pre-built-image release flow, and the Gluetun VPN guide into a new docs/DEPLOYMENT.md, cross-linked both ways. The pre-built-image section is now labelled a maintainer process with a LYRA_REGISTRY override so a self-hoster can point at their own registry (or just build from source). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.2 KiB
Lyra — deployment & advanced guide
Topics beyond the README quick-start: the full feature tour, publishing pre-built images to a registry, and running the whole stack behind a VPN. For a basic self-hosted setup you only need the README — start there.
Features in depth
Lyra was built in three slices, all complete:
- Acquisition engine. Resolve a request against MusicBrainz, match/rank across
Qobuz/Soulseek/YouTube, download the best source into an isolated staging dir, verify
completeness, tag (mutagen), and atomically promote the clean album into
Artist/Album (Year)/## Title.ext. - Library manager. Watched artists (live MB search + follow), per-release monitor toggles + auto-monitor-future, a wanted list with retry + quality-upgrade window, and a worker "monitor" that discovers new releases and auto-grabs through the acquisition pipeline. Plus a library scan that ingests existing on-disk albums as have + monitored + followed, and a per-artist studio-only / show-all release-type filter.
- Discovery. Recommendation-driven finding of new music via ListenBrainz
similar-artists and Last.fm
artist.getSimilar(both MBID-native), behind a pluggableSimilaritySourceinterface — Last.fm registers as a second source automatically once its API key is set, and cross-source scores are summed per candidate. A background sweep (and a "Discover now" button) aggregates artists similar to the ones you follow into a manual review feed of suggested artists and albums at/discover; Follow and Want reuse the slice-2 follow / wanted flows, Dismiss is permanent. Plus a web-side seed-search box. Every suggestion and seed-search result opens a read-only artist preview (/discover/artist/[mbid]) — discography, per-album tracklists, and "in library" / "monitored" badges — where you can Follow or Want before committing. No Spotify (its recommendation APIs are dead for new apps).
Alongside the three slices:
- Last.fm browse (
/lastfm) — your Last.fm top artists and albums (by period), with a "hide items already in my library" toggle and inline Follow / Want buttons on each row; each artist also opens a modal of your most-played songs and albums. - Library (
/library) — a cover-art grid of everything on disk, filterable, each album opening a tracklist modal. - The Floor (home) — a live queue of in-flight acquisitions with three-phase progress (Searching → Downloading → Finishing), a live download percentage, a per-job "pressing ticket" (source · format · tracks · elapsed), and a Retry button on anything that needs attention.
The whole UI runs a hand-rolled "Pressing Plant" design system (warm-paper theme, self-hosted Fraunces serif for prose vs. system mono for machine data, light/dark aware).
Deferred hardening and the full roadmap live in the implementation-plan "Notes" sections under
docs/superpowers/plans/; the full design specs are under
docs/superpowers/specs/.
Publishing pre-built images
Maintainer release process — this is how the project maintainer publishes images to a private registry. If you're self-hosting your own copy, ignore this and just build from source with
docker compose up -d --build(see the README). You'd only need this if you run your own registry and want servers to pull rather than build.
The web and worker images can be published to a container registry and consumed by a
compose file that pulls instead of builds. The maintainer publishes to a Gitea container
registry consumed by a compose file in the
vm-download ops repo (docker/lyra/).
Publish a new release from this repo (amd64):
docker login <your-registry> # once — registry user + a token with package:write
./scripts/build-push.sh # builds + pushes lyra-web / lyra-worker (:latest and :<sha>)
scripts/build-push.sh defaults the registry namespace to git.jger.nl/jonathan; override it
with LYRA_REGISTRY=<your-registry>/<namespace>.
Then on the server:
cd /opt/git/vm-download && git pull
cd docker/lyra && docker compose pull && docker compose up -d
db (Postgres) and db-backup use the stock postgres:17 image, so only the two custom
images are built here.
Running behind a VPN (Gluetun)
Lyra runs fine with its traffic routed through a Gluetun
VPN container — useful for hiding the download traffic (Soulseek / Qobuz / YouTube). Nothing
in Lyra is VPN-incompatible; the only thing that changes is container networking, because a
service using network_mode: "service:gluetun" gives up its own network stack and shares
Gluetun's. Two consequences:
- Containers that share Gluetun's namespace reach each other over
localhost, not by Docker service name — soDATABASE_URLchanges from@db:5432to@localhost:5432. - Published ports move to the
gluetuncontainer (the individual services can no longer publish their own).
The simplest, most consistent setup is to put the whole stack behind Gluetun. Sketch (merge into your existing Gluetun compose; adjust the VPN provider block to yours):
services:
gluetun:
image: qmcgaw/gluetun
cap_add: [NET_ADMIN]
environment:
# ... your VPN provider / credentials ...
FIREWALL_INPUT_PORTS: "3000" # let your browser reach the web UI
FIREWALL_OUTBOUND_SUBNETS: "192.168.0.0/16" # your LAN — so the worker can reach slskd
ports:
- "8770:3000" # Lyra web (published on gluetun, not on `web`)
# - "5432:5432" # only if you want DB access from the LAN
web:
build: ./web
network_mode: "service:gluetun" # no `ports:` / `networks:` here
environment:
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra # `db` -> `localhost`
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
HOSTNAME: 0.0.0.0
LYRA_LIBRARY_ROOT: /music
volumes:
- ${MUSIC_DIR:-./music}:/music
depends_on:
gluetun: { condition: service_healthy }
db: { condition: service_healthy }
worker:
build: ./worker
network_mode: "service:gluetun"
environment:
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
STAGING_ROOT: /staging
SLSKD_DOWNLOADS_ROOT: /slskd-downloads
volumes:
- ${MUSIC_DIR:-./music}:/music
- ${STAGING_DIR:-${MUSIC_DIR:-./music}/.staging}:/staging
- ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads
depends_on:
gluetun: { condition: service_healthy }
db: { condition: service_healthy }
db:
image: postgres:17
network_mode: "service:gluetun" # shares the namespace so `localhost:5432` resolves
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
(Give db-backup the same network_mode and PGHOST: localhost.) The HOSTNAME=0.0.0.0
and the built-in healthchecks keep working, since everything is in one namespace.
Caveats worth knowing:
- Qobuz is geo-sensitive — its catalog/availability varies by country, so pick a VPN exit in a region where your Qobuz account works, or some downloads will fail to match.
- The worker's
net.ipv6.conf.all.disable_ipv6sysctl can't apply behind Gluetun (a shared netns can't set sysctls) — harmless, because Gluetun blocks IPv6 anyway, which is exactly what that sysctl worked around (Qobuz's CDN resolving to an unroutable IPv6). - MusicBrainz rate-limits per IP; a shared VPN exit is marginally more likely to hit the 1 req/s ceiling, but the built-in response cache makes that a non-issue in normal use.
- Bind mounts (
/music,/staging,/slskd-downloads), the Postgres volume, and backups are filesystem, not network — completely unaffected by the VPN.