Deploy with Docker
Docker Compose runs Lumastra on one host: a home server, NAS, or single VM. You’ll need Docker (or Podman) with Compose, and a domain if you plan to front it with a reverse proxy. For multi-replica / cluster deployments, see Deploy on Kubernetes.
The repository ships a docker-compose.example.yml. Download it as your own
docker-compose.yml and customize it. You don’t need to clone the repository for this;
the Quick Start has the two curl
commands. The example below is a trimmed version of the file.
What you deploy
Section titled “What you deploy”| Service | Image | Role | Required |
|---|---|---|---|
postgres | pgvector/pgvector:pg18 | PostgreSQL 18 + pgvector: all application data and search vectors | Yes |
valkey | valkey/valkey:9-alpine | Persistent noeviction BullMQ/task/shared coordination | Yes |
valkey-cache | valkey/valkey:9-alpine | Disposable evictable application cache | Yes |
lumastra | ghcr.io/lumastra/lumastra:latest | Web app (UI and API), plus durable task submission | Yes |
worker | same image | Lean TS tasks/plugins and Rust watcher child | Yes |
proxy | same image | Live TV stream proxy, only with LIVETV_SOURCE=standalone | Optional |
Image tags: ghcr.io/lumastra/lumastra:stable and :latest (the latest release, amd64 +
arm64), version tags such as :0.1.0, and :edge (every commit to main, amd64
only, for testing, not production).
Separate web and task roles
Section titled “Separate web and task roles”The same image runs separate web and worker services. start.sh serves HTTP/API
requests with WORKER_MODE=disabled; start-worker.sh runs the dedicated lean
TypeScript task host. Run exactly one worker: a durable singleton lease stops a second one from working. It logs Another worker holds the singleton lease and waits unready. After a hard kill, its replacement waits up to 30 s. It preserves existing tasks/plugins
and supervises one credential-free Rust file-watcher child in its container.
The web and worker containers must share the config volume: the worker writes
artwork the web tier serves. BullMQ and shared coordination use persistent
noeviction Valkey; disposable cache uses a separate evictable Valkey. The worker
always uses the Rust watcher. See Performance tuning
for task parallelism and resource limits.
Minimal docker-compose.yml
Section titled “Minimal docker-compose.yml”A single-host setup with separate web and worker services and two Valkeys. Put passwords and keys in a
.env file (copy from .env.example).
services: # Web app: UI, API and playback; queues background work. lumastra: image: ghcr.io/lumastra/lumastra:latest restart: unless-stopped cap_drop: [ALL] security_opt: [no-new-privileges:true] environment: NODE_ENV: production WORKER_MODE: disabled # web requests and durable task submission BASE_PATH: /config DATABASE_URL: "postgresql://lumastra:${DATABASE_PASSWORD}@postgres:5432/lumastra" CACHE_URL: "redis://default:${CACHE_PASSWORD}@valkey-cache:6379/0" TASK_CACHE_URL: "redis://default:${TASK_CACHE_PASSWORD:-${CACHE_PASSWORD}}@valkey:6379/0" ORIGIN: "https://lumastra.example.com" # so the login cookie gets its Secure flag env_file: [.env] volumes: - config:/config # admin settings + artwork (shared with the worker) - cache:/app/.cache # image cache - plugins:/app/plugins # installed plugins, shared with the worker # - /path/to/media:/data/media:ro # media for playback; same line on the worker ports: - "127.0.0.1:3000:3000" # bind to loopback; front with a reverse proxy depends_on: postgres: { condition: service_healthy } valkey: { condition: service_healthy } valkey-cache: { condition: service_healthy } healthcheck: test: [ "CMD-SHELL", "curl -sf http://localhost:3000/api/health || [ $? -eq 22 ]", ] interval: 30s timeout: 10s retries: 3 start_period: 60s # time for first-boot migrations
# Background jobs and the library watcher. Run exactly one. worker: image: ghcr.io/lumastra/lumastra:latest restart: unless-stopped cap_drop: [ALL] security_opt: [no-new-privileges:true] command: ["./start-worker.sh"] stop_grace_period: 90s # lets running jobs finish on stop/update (Docker's default is 10 s) cpu_shares: 512 # yield to web + Postgres under contention (idle = still uses everything) # cpus: 8 # hard ceiling — set to ~half your host's cores (8 on a 16-core box) environment: NODE_ENV: production BASE_PATH: /config DATABASE_URL: "postgresql://lumastra:${DATABASE_PASSWORD}@postgres:5432/lumastra" CACHE_URL: "redis://default:${CACHE_PASSWORD}@valkey-cache:6379/0" TASK_CACHE_URL: "redis://default:${TASK_CACHE_PASSWORD:-${CACHE_PASSWORD}}@valkey:6379/0" env_file: [.env] volumes: - config:/config # MUST be the same config volume as the web container - plugins:/app/plugins # the worker loads the installed task plugins # - /path/to/media:/data/media:ro # the SAME line as on the web service; the worker scans it healthcheck: # Readiness, not just liveness: unhealthy while another worker holds the singleton # lease, during a prepared upgrade, or without PostgreSQL / task Valkey. # `curl -s http://127.0.0.1:3000/api/health/ready` lists the reasons. test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/health/ready > /dev/null"] interval: 30s timeout: 10s retries: 3 start_period: 60s depends_on: postgres: { condition: service_healthy } valkey: { condition: service_healthy } valkey-cache: { condition: service_healthy }
# Database: catalog, users and search vectors. postgres: image: pgvector/pgvector:pg18 restart: unless-stopped shm_size: 1gb # 64MB default /dev/shm OOMs Postgres parallel queries on large libraries command: > postgres -c shared_buffers=${POSTGRES_SHARED_BUFFERS:-512MB} -c jit_above_cost=10000000 -c jit_optimize_above_cost=50000000 -c jit_inline_above_cost=50000000 environment: POSTGRES_DB: lumastra POSTGRES_USER: lumastra POSTGRES_PASSWORD: ${DATABASE_PASSWORD} volumes: - postgres_data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U lumastra -d lumastra"] interval: 30s timeout: 10s retries: 10 start_period: 60s
# Task Valkey (TASK_CACHE_URL): durable job queue and locks; never evicts, saved to disk. valkey: image: valkey/valkey:9-alpine restart: unless-stopped environment: # valkey-cli reads REDISCLI_AUTH, so the password is not on any command line REDISCLI_AUTH: ${TASK_CACHE_PASSWORD:-${CACHE_PASSWORD}} command: > valkey-server --requirepass ${TASK_CACHE_PASSWORD:-${CACHE_PASSWORD}} --maxmemory-policy noeviction --appendonly yes --appendfsync everysec volumes: - valkey_data:/data healthcheck: test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"] interval: 30s timeout: 5s retries: 3
# Cache Valkey (CACHE_URL): disposable cache, safe to lose. valkey-cache: image: valkey/valkey:9-alpine restart: unless-stopped environment: REDISCLI_AUTH: ${CACHE_PASSWORD} command: > valkey-server --requirepass ${CACHE_PASSWORD} --save "" --appendonly no --maxmemory 256mb --maxmemory-policy allkeys-lru healthcheck: test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"] interval: 30s timeout: 5s retries: 3
volumes: postgres_data: valkey_data: config: cache: plugins:Compose prefixes these names with the project name (the folder name by default), so
an install in a folder called lumastra gets lumastra_config, lumastra_postgres_data
and so on, and a second install in another folder gets its own set. Don’t pin them with
name:: two stacks that share a Postgres volume corrupt the database as soon as both
run.
Worker resources
Section titled “Worker resources”The required worker service above uses cpu_shares as a relative scheduling weight
under contention. A cpus limit is a hard ceiling; size it for your host. See
Capping total CPU.
Required environment
Section titled “Required environment”In .env:
| Variable | Purpose | Generate |
|---|---|---|
DATABASE_PASSWORD | Postgres password (interpolated into DATABASE_URL) | openssl rand -hex 24 |
CACHE_PASSWORD | Valkey password (interpolated into CACHE_URL) | openssl rand -hex 24 |
LUMASTRA_MASTER_KEY | Encrypts stored integration/plugin secrets | openssl rand -hex 32 |
Passwords are placed inside redis:// and postgresql:// URLs, and Compose cannot
percent-encode them. Use only letters, digits and -._~ (the openssl rand -hex
values qualify), or percent-encode any other character yourself. To give the task Valkey
its own password, also set TASK_CACHE_PASSWORD (same rules); when unset it uses
CACHE_PASSWORD.
That’s the minimum. Two more you’ll usually want:
| Variable | Purpose |
|---|---|
JWT_SECRET | Signs login / device-flow tokens. Optional, derived from the master key if unset, but set it explicitly (openssl rand -hex 32) to rotate it independently. |
TRUSTED_PROXY_IPS | The fixed address of a reverse proxy that runs in this compose file. Required for forward auth, together with FORWARD_AUTH_ENABLED=true. It also makes client IPs and rate limits work per client. The proxy must remove the sign-in headers. See Reverse proxy. |
.env.example lists only deployment settings (secrets, address, reverse proxy, GPU).
Metadata keys such as TMDB, AI providers, Jellyfin and the other integrations are
entered in the setup wizard or under Admin → Settings; each of them can also be set
in .env by its variable name, which then locks the field in the UI. See the full
Environment Variables reference.
Production Valkey layout
Section titled “Production Valkey layout”The Compose file uses two Valkeys with distinct policies:
| Instance | Environment variable | Policy | Holds |
|---|---|---|---|
Disposable cache (valkey-cache) | CACHE_URL | bounded allkeys-lru, no persistence | Rebuildable cache data |
Task/shared coordination (valkey) | TASK_CACHE_URL | AOF synced every second, noeviction | BullMQ jobs, run state, identity keys, locks and shared session/playback coordination |
Task Valkey uses AOF with appendfsync everysec; a crash can lose roughly the last second of writes. Keep its data volume and include it in backups.
Set the same two URLs on web and worker. Keep task/shared-coordination persistence
when recreating containers. Never publish either Valkey port to the Internet.
noeviction ensures memory exhaustion rejects writes visibly instead of dropping
a live queue lock or task record. Monitor memory and leave capacity headroom.
Keep the persistent task volume and both roles configured with the same task URL. Do not flush task/shared-coordination data to recover cache capacity; see task troubleshooting.
Volumes
Section titled “Volumes”| Volume | Mount | Holds | Containers |
|---|---|---|---|
postgres_data | /var/lib/postgresql | The database | postgres |
valkey_data | /data | Valkey persistence (queue state) | valkey |
config | /config | Admin settings, extracted artwork, trickplay tiles | web and worker (shared) |
cache | /app/.cache | TMDB / Jellyfin image cache, avatars | web |
To index local files, mount your media (read-only) into both the web and the worker containers, at the same container path. The worker scans the files and the web container plays them. Then add it as a library in Library → Sources & scanning. See Organizing your media:
volumes: - /path/to/media:/data/media:roIf your library is on NFS, the mount options make a big difference to scan speed
(use nconnect, vers=4.2, noatime, …). See Performance
tuning.
Hardening and containment
Section titled “Hardening and containment”The example Compose setup uses these safeguards:
- The web app (
lumastra), worker (worker) and Live TV proxy (proxy) usecap_drop: [ALL]andsecurity_opt: [no-new-privileges:true]. - Media mounts use
:ro. The proxy mounts only the read-only FFmpeg binary cache,ffmpeg-cache:/config/bin:ro. Never mount all of/configon the proxy: it holdsmaster.key. See Live TV stream proxy. postgres,valkeyandvalkey-cachehave noenv_file. They receive only their explicitly configured database/cache credentials.LUMASTRA_MASTER_KEYand other app secrets in.envare not passed to them.- The app port is published on
127.0.0.1only.
Read-only mounts do not isolate the network. :ro stops writes through that
mount only. A compromised container can still access anything its network allows,
including a writable NFS/SMB export or an internal admin service. On the NAS, export
the media share read-only, or restrict the export to the Lumastra host only.
If containers can reach sensitive internal services, add host firewall rules scoped
to what Lumastra needs. With Docker’s iptables backend, use the DOCKER-USER chain.
See Docker packet filtering and firewalls.
A blanket block of private networks breaks supported integrations: Jellyfin,
Sonarr/Radarr, Live TV tuners and M3U sources, NAS media, and Authentik or another
OIDC provider.
Resource limits need separate settings. A NODE_OPTIONS heap size limits the
Node.js heap, not container memory. FFmpeg and native allocations sit outside that
heap. cpu_shares is only a relative CPU weight under contention.
For hard limits, add mem_limit and pids_limit per service. The commented YAML
below shows illustrative values, not tested Lumastra recommendations. Size each
service for your own hardware. Transcoding needs headroom: watch peak usage under a
transcode plus a library scan first, then set the limits with margin.
# Example only: merge into your existing services after measuring peak usage.# services:# lumastra:# mem_limit: 4g# pids_limit: 512# worker:# mem_limit: 4g# pids_limit: 512# proxy:# mem_limit: 2g# pids_limit: 512Valkey memory overcommit warning
Section titled “Valkey memory overcommit warning”On start, both Valkey containers can log WARNING Memory overcommit must be enabled!.
Valkey saves its data in a forked child process. With Linux’s default setting, that fork
can fail on a host that is short on memory, and a background save or replication sync then
fails. The warning comes from the host kernel, not from Lumastra, and you don’t have to
act on it: a small install runs fine with it. To silence it, set this on the Docker
host (not inside the container):
sudo sysctl vm.overcommit_memory=1echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/99-valkey.confThe second line keeps the setting after a reboot. The setting applies to the whole host, so skip it on a machine shared with workloads that rely on strict memory accounting.
First run
Section titled “First run”-
On first boot the app runs all database migrations automatically (watch
docker compose logs -f). -
Open the app. With an empty database it redirects you to the
/setupwizard. Its first screen, Check your install, shows whether the config volume keeps your settings, which sidecars answer, and how requests reach the app; fix any warning there first. The wizard then creates the first admin. Single sign-on (OIDC, or forward auth) is recommended and pre-selected; built-in login is one click away. See Authentication. On the library step it lists the media folders it found and adds the ticked ones when you continue. -
Verify:
Terminal window curl http://localhost:3000/api/healthcurl http://localhost:3000/api/health/config # config doctor, admins onlyThe config doctor answers
401until you are signed in as an admin; before that, rundocker compose exec lumastra npm run doctorinstead.
Reverse proxy & TLS
Section titled “Reverse proxy & TLS”Lumastra listens on port 3000. Terminate TLS at a reverse proxy and forward to it, and
set ORIGIN (above) to the public address. The
Reverse proxy guide has a checklist of what the proxy must do
and copy-paste setups for Caddy, nginx, Traefik and Nginx Proxy Manager. The sections
below cover compression, access logs and request body size in more detail.
If you use forward auth, set FORWARD_AUTH_ENABLED=true (off by default), the proxy
must also inject X-Forwarded-User / X-Forwarded-Groups, and you must set
TRUSTED_PROXY_IPS to the proxy’s address: those headers are rejected from untrusted
peers (Authentication → proxy trust).
With forward auth on, Lumastra believes these headers from a trusted proxy, so every
proxy must remove the copies a client sends. The setups in the
Reverse proxy guide do.
Compression
Section titled “Compression”Enable compression at the proxy for dynamic pages and JSON (including /api/v1).
Only static assets under /_app/immutable/ ship pre-compressed.
Compress these content types:
text/html,text/css,application/javascriptapplication/json,application/manifest+json,image/svg+xml
Never compress:
text/event-stream: live updates (notifications, watch-state sync, Watch Together) use server-sent events, and a compressing proxy buffers the stream, so updates arrive late or not at all.- Video, audio and HLS (
application/vnd.apple.mpegurl,video/mp2t,video/iso.segment,audio/*) and images. They are already compressed, and compressing them breaks the byte-range requests that seeking relies on.
Select compression by content type: /api/v1/ serves both JSON and media. Keep
streaming responses uncompressed and set proxy_buffering off on streaming locations.
gzip on;gzip_vary on;gzip_proxied any;gzip_comp_level 5;# text/html is always included when gzip is ongzip_types text/css application/javascript application/json application/manifest+json image/svg+xml;
# Optional, if the Brotli module is installed# (e.g. Debian's libnginx-mod-http-brotli-filter):brotli on;brotli_comp_level 5;brotli_types text/css application/javascript application/json application/manifest+json image/svg+xml;encode compresses every text/* response by default, and that includes
text/event-stream. List the types explicitly:
lumastra.example.com { encode { zstd gzip 5 match { header Content-Type text/html* header Content-Type text/css* header Content-Type application/javascript* header Content-Type application/json* header Content-Type application/manifest+json* header Content-Type image/svg+xml* } } reverse_proxy 127.0.0.1:3000 { header_up -X-Forwarded-User header_up -X-Forwarded-Groups header_up -X-Authentik-Name }}The compress middleware excludes no content types by default, so it would also
compress media. Use an include list:
http: middlewares: lumastra-compress: compress: includedContentTypes: - text/html - text/css - application/javascript - application/json - application/manifest+json - image/svg+xmlTo verify, request the public web manifest (large enough to pass every proxy’s
minimum-size threshold) and check for a content-encoding header:
curl -s -o /dev/null -D - -H 'Accept-Encoding: br, gzip' \ https://lumastra.example.com/manifest.json | grep -i content-encodingThen open the app with your browser’s network tools. Page data and /api/ JSON should
show content-encoding: br or gzip. Event-stream requests, video segments, and images
should show none.
Access logs: keep credentials out
Section titled “Access logs: keep credentials out”Some clients can’t send auth headers, so they put credentials in the query string:
- Jellyfin-compatible apps (
/j/):api_key,ApiKey,token - Subsonic apps (
/subsonic/):t+s,apiKey, andp, which is the user’s password - Media URLs for native players,
<audio>/<video>streams, and the iPhone, iPad and Apple TV apps (/api/v1/):grant, a credential bound to one item and one playback session that works until it expires (up to 24 hours)
Most proxies’ default access-log formats record the full request URI (nginx’s
$request, for example), so every one of those requests writes a live credential into
your logs in plain text. Log the path without the query string.
Define the format in the http block and use it in the Lumastra server block:
# http { ... }log_format lumastra_no_query '$remote_addr - $remote_user [$time_local] ' '"$request_method $uri $server_protocol" $status ' '$body_bytes_sent "$http_referer" "$http_user_agent" ' 'rt=$request_time';
# server { ... }access_log /var/log/nginx/access.log lumastra_no_query;Filter the query keys out of the logged URI:
lumastra.example.com { log { format filter { request>uri query { delete api_key delete ApiKey delete token delete apiKey delete grant delete stream_token delete p delete t delete s } } } reverse_proxy 127.0.0.1:3000 { header_up -X-Forwarded-User header_up -X-Forwarded-Groups header_up -X-Authentik-Name }}Drop query parameters from the access log. With the command-line flags used in the
Reverse proxy guide, add these to the command: list:
- --accesslog=true - --accesslog.fields.queryparameters.defaultmode=dropTraefik reads its static configuration from one source only. If you use a traefik.yml
file instead of flags, put the same setting there:
accessLog: fields: queryParameters: defaultMode: dropOn an older release without queryParameters, drop the RequestPath field instead.
Changing the format only protects new log lines. Rotated and archived logs written before the change still hold those credentials. Delete them or redact them in place, and treat any personal access token or Subsonic password that appears in them as exposed. Revoke the token, or change the password.
Request body size
Section titled “Request body size”The proxy’s request-body limit has to be at least as large as the biggest upload you
want Lumastra to accept. Otherwise the proxy rejects it with 413 before it reaches
the app. The app’s own limits:
| Upload | Limit | Set by |
|---|---|---|
| Artwork (posters, backdrops, logos) | 10 MB | MAX_IMAGE_UPLOAD_MB (up to 50) |
| Library uploads, per chunk | 5 MB | UPLOAD_CHUNK_SIZE_MB |
Diagnostics bundles from the iOS and tvOS apps (/api/v1/) | 3 MiB | fixed |
| Avatars | 2 MB | fixed |
| Everything, ceiling | 50 MB in the example compose file | BODY_SIZE_LIMIT |
Caddy and Traefik don’t limit request bodies unless you add a limit (Caddy’s
request_body, Traefik’s buffering middleware). nginx defaults to 1 MB, which is
too small for artwork and library uploads. Set the limit for the whole Lumastra
server block so every location inherits it:
server { server_name lumastra.example.com; # Match the app's BODY_SIZE_LIMIT. client_max_body_size 50m; # ...}If you use nginx auth_request for forward auth (Authentik’s outpost, for example),
the auth subrequest carries the original request’s Content-Length and is
size-checked against the auth location’s limit. If you raise the limit only in
some locations and not in the auth location, large uploads fail with 500 (“auth
request unexpected status: 413”) instead of a clean 413. A server-level limit
covers both: raising it in the auth location costs nothing, since the body isn’t
forwarded there (proxy_pass_request_body off).
Test uploads through the same proxy configuration you run in production. A staging
proxy with no body limit (client_max_body_size 0) passes every upload and hides a
413 that production will return.
Updating
Section titled “Updating”docker compose pulldocker compose up -dMigrations run on boot, and an advisory lock ensures only one container migrates if web and worker start together. Avoid running mismatched image versions against the same database for long. Roll updates promptly. For rolling updates with no dropped requests or interrupted streams, see Zero-downtime updates.
Security notices are published as
GitHub Security Advisories and in the
release notes. Lumastra checks the releases once a
day and shows admins a newer version in the bell and on Admin → System → Instance. A security
release shows in red until you have installed it. :edge builds get the quiet notice only,
because a fix usually reaches main before it is released.
Upgrading to the dedicated worker and two Valkeys
Section titled “Upgrading to the dedicated worker and two Valkeys”Older installs run one lumastra container that serves the web and runs the background
jobs, with one Valkey. Current versions need three changes, made once in your compose
file: a separate worker container, a second Valkey, and a TASK_CACHE_URL. An
install that was not changed refuses to start and the log says so (for example
TASK_CACHE_URL is required or The web server requires WORKER_MODE=disabled).
Your existing Valkey keeps its volume and becomes the task Valkey. Jobs that are
queued when you upgrade are still there afterwards. The new Valkey only holds a
disposable cache. The example below uses lumastra-valkey for the existing one and
lumastra-valkey-cache for the new one; use your own service names.
-
Back up first. Take a PostgreSQL dump and copy your
LUMASTRA_MASTER_KEY; see Backup & recovery. -
Switch the existing Valkey to AOF while it is still running, and wait. A Valkey that has only saved snapshots (
dump.rdb) and is restarted with--appendonly yesstarts empty: it ignoresdump.rdb, and its next shutdown overwrites the snapshot. Never add--appendonly yesto the command and restart first. Do it live, in this order. Set the password forvalkey-clithroughREDISCLI_AUTHinstead of-a, so it does not show in the process list:Terminal window # bash; the password comes from .env, the service is your existing Valkeyset -a; . ./.env; set +aVK="docker compose exec -e REDISCLI_AUTH=$CACHE_PASSWORD lumastra-valkey valkey-cli"$VK CONFIG SET appendonly yes$VK CONFIG SET maxmemory-policy noeviction$VK INFO persistence | grep -E 'aof_enabled|aof_rewrite_in_progress|aof_last_bgrewrite_status'Repeat the last command until it shows
aof_rewrite_in_progress:0andaof_last_bgrewrite_status:ok. Only then continue. (These settings are lost on a restart; step 3 makes them permanent.) -
Edit the compose file.
-
Existing Valkey: keep the service, its volume and its password. Only now add
--maxmemory-policy noeviction --appendonly yes --appendfsync everysecto its command (remove any--maxmemorylimit). It loads the AOF you just wrote. -
Add the cache Valkey as a new service, with its own command and no volume:
lumastra-valkey-cache:image: valkey/valkey:9-alpinerestart: unless-stoppedcommand: >valkey-server --requirepass ${CACHE_PASSWORD}--maxmemory 1024mb --maxmemory-policy allkeys-lru--save "" --appendonly no -
On the web container, remove
WORKER_MODEandDEDICATED_WORKER(if you set them, the web container refuses to start) and set the two URLs.TASK_CACHE_URLis your existing Valkey,CACHE_URLis the new one. Keep the database number your oldCACHE_URLused (usually/0) inTASK_CACHE_URL, so the queue is found. ATASK_CACHE_URLwithout a number selects database 0; the worker refuses to start when the queue sits in another database and names the URL to use. Always write the number explicitly:CACHE_URL: "redis://default:${CACHE_PASSWORD}@lumastra-valkey-cache:6379/0"TASK_CACHE_URL: "redis://default:${CACHE_PASSWORD}@lumastra-valkey:6379/0" -
Add one worker service from the same image with
command: ["./start-worker.sh"],stop_grace_period: 90s, the same two URLs,DATABASE_URL, and the sameconfigandpluginsvolumes and media mounts as the web container. The minimal compose file above shows the full service. -
Make web and worker
depends_onboth Valkeys.
-
-
Apply in one go.
docker compose pull && docker compose up -d. Compose recreates the existing Valkey with the new flags (it loads its AOF), starts the cache Valkey, then the web and worker containers. Migrations run on boot as before. -
Check.
docker compose logs lumastra-workershows the workers starting. Run$VK CONFIG GET maxmemory-policy(expectnoeviction) and$VK CONFIG GET appendonly(expectyes). In Admin → Tasks the queued jobs from before the upgrade are listed and start running.
Alternative for a one-time move (Docker): instead of steps 2 and 3 for the Valkey, copy
the old dump.rdb into a new, empty volume, start a Valkey on that volume without
--appendonly once, run the three commands from step 2 against it (CONFIG SET appendonly yes, CONFIG SET maxmemory-policy noeviction, wait for the rewrite to finish), then stop it
and start it with the flags from step 3. Do not start a fresh volume with --appendonly yes
and a copied dump.rdb: it would be ignored.
What Lumastra checks at startup, so a wrong setup fails loudly instead of losing work:
-
It refuses to start when the task Valkey is empty but Postgres still shows task state that was in flight. This is what an AOF switch gone wrong looks like. Restore the Valkey data (or the volume) instead of starting over. If the data is really gone, set
LUMASTRA_ALLOW_EMPTY_TASK_VALKEY=trueonce to start anyway; those runs are then lost. -
It refuses to start when the task Valkey’s eviction policy is not
noeviction. SettingLUMASTRA_ALLOW_EVICTING_TASK_VALKEY=trueoverrides this; it is not recommended, because a full Valkey can then drop queued jobs and locks. -
It logs a warning when the task Valkey has AOF off.
-
When the web container sees no worker heartbeat, it logs an error and admins see a banner. The worker is not running, or it cannot reach the task Valkey; check
docker compose logs lumastra-worker. -
It refuses to start when
CACHE_URLandTASK_CACHE_URLreach the same Valkey server under two names (for examplelocalhostand127.0.0.1). -
The cache Valkey is optional. When it is down, Lumastra keeps running without it (slower, every lookup is a cache miss) and reconnects in the background; only the task Valkey decides whether a container is ready.
Never point CACHE_URL and TASK_CACHE_URL at the same Valkey, not even with
different database numbers; startup rejects it. On Kubernetes the Helm chart creates both
Valkeys for you; see Valkey.
Live TV stream proxy
Section titled “Live TV stream proxy”Only needed when LIVETV_SOURCE=standalone (you bring your own M3U/EPG). Add a proxy
service that uses start-proxy.sh, and point the app at it:
proxy: image: ghcr.io/lumastra/lumastra:latest restart: unless-stopped cap_drop: [ALL] security_opt: [no-new-privileges:true] command: ["sh", "start-proxy.sh"] environment: STREAM_PROXY_SECRET: ${STREAM_PROXY_SECRET} PORT: "3000" # the proxy writes its cluster heartbeat to the task Valkey TASK_CACHE_URL: "redis://default:${TASK_CACHE_PASSWORD:-${CACHE_PASSWORD}}@valkey:6379/0" volumes: - ffmpeg-cache:/config/bin:ro depends_on: valkey: { condition: service_healthy }Declare ffmpeg-cache: in the compose file’s top-level volumes: and mount
ffmpeg-cache:/config/bin on the app and worker too. Start the app first so it
populates the binary cache. The proxy gets only a read-only binary cache;
the rest of /config contains master.key and must stay private to the app and worker.
Using command preserves the image’s tini supervisor, which reaps FFmpeg children.
A proxy restart drops every live channel. With the same tag as the app, every
docker compose pull restarts it. To avoid that, give the proxy its own release tag (for
example ghcr.io/lumastra/lumastra:0.1.0) and change it only when the release notes say
“Live TV proxy: update needed”. Admin → System health → Cluster also warns when the running
proxy is behind (“Update the Live TV proxy image to …”).
LIVETV_SOURCE=standaloneSTREAM_PROXY_URL=http://proxy:3000STREAM_PROXY_SECRET=<openssl rand -hex 24>More on how it works: Live TV architecture.
Sonic embedder sidecar
Section titled “Sonic embedder sidecar”The sonic sidecar provides MSCLAP embeddings and the Music Map projection. Use it
for sonic music features; its published image is
ghcr.io/lumastra/lumastra-sonic-embedder.
The docker-compose.example.yml
already defines it as the lumastra-sonic-embedder service behind the sonic profile,
so a plain docker compose up -d never starts it. To turn it on:
-
Mount your media exactly like the app does. Lumastra hands the sidecar the file paths it stored when it scanned (for example
/data/media/music/…), so the sidecar needs the same container path, not just the same host folder. Replace the placeholder- /path/to/media:/data/media:roon the sidecar with the same line you put on thelumastraservice. With a different container path every track fails withNo such file or directory. -
Create the read-only database role the sidecar connects as, once, after Lumastra’s first start has created the schema. Pick a password, put it in
.envasSONIC_READER_PASSWORD, and run:Terminal window docker compose exec lumastra-postgres psql -U lumastra -d lumastra -c "CREATE ROLE sonic_reader LOGIN PASSWORD '<same password>';GRANT CONNECT ON DATABASE lumastra TO sonic_reader;GRANT USAGE ON SCHEMA public TO sonic_reader;GRANT SELECT ON media_audio_embeddings TO sonic_reader;" -
Point the app at it. Uncomment
SONIC_EMBEDDER_URLon thelumastraservice (and on the worker, if you run a split worker). Leave it commented out while the sidecar isn’t running: the setup wizard treats a set URL as “sidecar available” and turns the sonic features on. The wizard’s Check your install screen shows whether the sidecar answers. -
Start it with
docker compose --profile sonic up -d. Stop everything withdocker compose --profile sonic down; without the profile,downleaves the sidecar running and can’t remove the network.
The service as shipped in the example file, for reference:
lumastra-sonic-embedder: profiles: ['sonic'] image: ghcr.io/lumastra/lumastra-sonic-embedder:stable restart: unless-stopped environment: DATABASE_URL: 'postgresql://sonic_reader:${SONIC_READER_PASSWORD:-}@lumastra-postgres:5432/lumastra' MODEL_ID: 'msclap-2023' SONIC_EMBEDDER_THREADS: '4' # set explicitly; memory grows with threads (see below) # SONIC_MODEL_FP: /models/clap-2023.pth # air-gap: load local weights (see below) # Hardened (supported): read_only: true tmpfs: [/tmp] cap_drop: [ALL] security_opt: [no-new-privileges:true] deploy: resources: limits: { memory: 6g, cpus: '4' } volumes: - /path/to/media:/data/media:ro # the SAME line as on the lumastra service - sonic-model-cache:/app/.cache/huggingface # persist downloaded CLAP weights (HF_HOME) # - /path/to/clap-2023.pth:/models/clap-2023.pth:ro # air-gap: mount local weights depends_on: lumastra-postgres: { condition: service_healthy }# on the lumastra (web) service — and the worker, if you run a split worker:environment: SONIC_EMBEDDER_URL: 'http://lumastra-sonic-embedder:8000'The sidecar image is amd64 only; on an arm64 host leave the profile off.
Then enable the features you want. They are all off by default.
FEATURE_MUSIC_SONIC is the master switch (it also needs
FEATURE_AI_RECOMMENDATIONS); the per-surface flags
(FEATURE_MUSIC_MAP, FEATURE_MUSIC_CLUSTERS, FEATURE_MUSIC_TEXT_SOUND,
FEATURE_MUSIC_SONIC_TASTE, FEATURE_MUSIC_SONIC_QUEUE, FEATURE_MUSIC_ALCHEMY,
FEATURE_MUSIC_LYRICS_SEARCH) each require it. The
Music discovery guide walks through each flag and the
one-time backfill.
Sonic sidecar memory
Section titled “Sonic sidecar memory”The sidecar sizes itself from its container memory limit (memory: under
deploy.resources.limits), so that limit is the one setting that matters. Each heavy
job is admitted by a memory gate: a job that doesn’t fit next to the running ones
waits, and a file that wouldn’t fit even alone is refused instead of crashing the
sidecar. Embedding reads only a short window of each track, so long tracks cost about
the same as short ones. Music analysis reads the track in chunks: about 0.5 GB plus
22 MB per minute of audio, so a 30-minute track needs about 0.9 GB on top of the
loaded model and a 2-hour mix about 2.7 GB.
| Box | Sidecar memory | What to expect |
|---|---|---|
| Small NAS / mini PC | 3g | Text encoder loads on demand. Everything embeds; analysis covers the 30-minute default, up to ~1 hour |
| Home server | 6g (the example file) | Every job; analysis up to the sidecar’s 2-hour cap |
| Large library | 10g or more | Text encoder stays loaded; more room for two jobs at once |
Measured with 4 threads. Lumastra skips tracks longer than
MUSIC_ANALYSIS_MAX_DURATION_MINUTES (30 by default, see
Organizing music); a track too big for the memory limit is
refused, not crashed on.
What you see when it’s too small. A refused track shows in the Failures tab of
the music-analysis (or music-embedding-generation) task with
Sonic sidecar memory too small (code exceeds_memory_budget). Raise the sidecar’s
memory limit, recreate the container, then use Retry all failed items. When the
sidecar is only busy, it answers with HTTP 429 and a Retry-After; Lumastra waits and
sends the request again without marking the track as failed.
The defaults fit most installs. To tune them, set these on the sidecar:
| Variable | Default | What it does |
|---|---|---|
SONIC_MEMORY_LIMIT_MB | the container limit, else half the host RAM | Memory the sidecar plans with. Normally leave unset |
SONIC_MEMORY_RESERVE_MB | 512 or 10 % of the limit, whichever is larger | Headroom kept out of the job budget |
SONIC_MAX_CONCURRENT_JOBS | CPUs ÷ threads, at most 2 | Heavy jobs at once (the memory gate may run fewer) |
SONIC_ADMISSION_QUEUE_TIMEOUT_SEC | 120 | How long a request may wait for memory before a 429 |
SONIC_TEXT_ENCODER | auto (loaded at 8 GiB or more, otherwise on demand) | warm, lazy or auto. Only Sound Search uses the text encoder |
SONIC_TEXT_ENCODER_IDLE_SEC | 900 | Idle time before an on-demand text encoder is unloaded again |
SONIC_ANALYZE_MAX_DURATION_SEC | 7200 | Longest file the sidecar analyses; longer ones are refused |
MALLOC_ARENA_MAX | 2 (set in the image) | glibc memory arenas; fewer arenas hold on to less freed memory |
GET /health on the sidecar reports the limit, the budget and the running jobs under
memory.
Keep the sidecar running to avoid loading the model for every Sound Search query. An NVIDIA GPU speeds initial embedding backfill; CPU also works.
The sidecar uses Microsoft’s CLAP-2023 model (via the msclap package). The
weights download at runtime from HuggingFace
(microsoft/msclap) on first load.
Lumastra does not bundle or redistribute them. The msclap/CLAP code is MIT;
the weights are Microsoft’s, so review the
model card for the exact weight terms.
The sonic-model-cache:/app/.cache/huggingface mount above (the image sets
HF_HOME there) persists the downloaded weights across recreates. Declare it under
the top-level volumes: like the other named volumes.
Log rotation
Section titled “Log rotation”The example compose file’s services use Docker’s default json-file log driver with
size-based rotation, so container logs can’t grow unbounded on disk:
| Service | max-size | max-file | Effective cap | Purpose |
|---|---|---|---|---|
lumastra (app) | 20m | 10 | ~200 MB | API server, request logs, high write volume |
worker | 20m | 10 | ~200 MB | Background job processing, sync operations |
postgres | 10m | 5 | ~50 MB | Database logs, query errors |
valkey | 5m | 3 | ~15 MB | Cache operations, job queue events |
Each service carries a logging stanza:
logging: driver: json-file options: max-size: "20m" max-file: "10" compress: "true"Docker rotates files at max-size and deletes the oldest after max-file rotations.
compress: "true" gzips rotated logs.
If your production compose file is managed separately from the example (hand-edited on
the host, or through a container-management UI), add the same logging stanza to each
service manually, then recreate the containers. The setting only takes effect on the
next container start, not by editing the running one:
docker compose up -dVerify it took effect:
docker inspect <container_id> --format '{{json .HostConfig.LogConfig}}'Expect:
{ "Type": "json-file", "Config": { "compress": "true", "max-file": "10", "max-size": "20m" }}An empty Config means the container hasn’t been restarted since you added the
stanza. docker compose down && docker compose up -d picks it up.
For multiple hosts, add centralized logging if you need cross-host search or longer retention; Docker rotates logs independently on each host.
Resource sizing
Section titled “Resource sizing”| Component | Minimum | Recommended | Notes |
|---|---|---|---|
| App / worker | 1 GB | 2–4 GB | Embeddings and ffmpeg peak above 1 GB |
| PostgreSQL | 256 MB | 512 MB–1 GB | Grows with the pgvector index |
| Valkey | 256 MB | 512 MB–1 GB | Idle use is about 10 MB per instance; the cache is capped |
| Total | 4 GB | 8 GB | 4 GB only for a small library in low-RAM mode; the background tasks need the headroom |
For a hard Valkey memory cap, use the separate cache/task layout under Production Valkey layout.
The image does not bundle ffmpeg: on first start each transcoding container downloads
the pinned jellyfin-ffmpeg release from GitHub into the ffmpeg-cache volume (checked
against a SHA-256 in the image), so later starts skip it. For an offline host set
FFMPEG_AUTO_DOWNLOAD=false and provide the binaries at FFMPEG_PATH / FFPROBE_PATH.
Transcoding is software by default. To offload it
to a GPU, set GPU_ACCELERATION and pass the device through. See
Hardware (GPU) transcoding.