Skip to content

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.

ServiceImageRoleRequired
postgrespgvector/pgvector:pg18PostgreSQL 18 + pgvector: all application data and search vectorsYes
valkeyvalkey/valkey:9-alpinePersistent noeviction BullMQ/task/shared coordinationYes
valkey-cachevalkey/valkey:9-alpineDisposable evictable application cacheYes
lumastraghcr.io/lumastra/lumastra:latestWeb app (UI and API), plus durable task submissionYes
workersame imageLean TS tasks/plugins and Rust watcher childYes
proxysame imageLive TV stream proxy, only with LIVETV_SOURCE=standaloneOptional

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).

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.

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.

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.

In .env:

VariablePurposeGenerate
DATABASE_PASSWORDPostgres password (interpolated into DATABASE_URL)openssl rand -hex 24
CACHE_PASSWORDValkey password (interpolated into CACHE_URL)openssl rand -hex 24
LUMASTRA_MASTER_KEYEncrypts stored integration/plugin secretsopenssl 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:

VariablePurpose
JWT_SECRETSigns 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_IPSThe 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.

The Compose file uses two Valkeys with distinct policies:

InstanceEnvironment variablePolicyHolds
Disposable cache (valkey-cache)CACHE_URLbounded allkeys-lru, no persistenceRebuildable cache data
Task/shared coordination (valkey)TASK_CACHE_URLAOF synced every second, noevictionBullMQ 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.

VolumeMountHoldsContainers
postgres_data/var/lib/postgresqlThe databasepostgres
valkey_data/dataValkey persistence (queue state)valkey
config/configAdmin settings, extracted artwork, trickplay tilesweb and worker (shared)
cache/app/.cacheTMDB / Jellyfin image cache, avatarsweb

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:ro

If your library is on NFS, the mount options make a big difference to scan speed (use nconnect, vers=4.2, noatime, …). See Performance tuning.

The example Compose setup uses these safeguards:

  • The web app (lumastra), worker (worker) and Live TV proxy (proxy) use cap_drop: [ALL] and security_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 /config on the proxy: it holds master.key. See Live TV stream proxy.
  • postgres, valkey and valkey-cache have no env_file. They receive only their explicitly configured database/cache credentials. LUMASTRA_MASTER_KEY and other app secrets in .env are not passed to them.
  • The app port is published on 127.0.0.1 only.

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: 512

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):

Terminal window
sudo sysctl vm.overcommit_memory=1
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/99-valkey.conf

The 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.

  1. On first boot the app runs all database migrations automatically (watch docker compose logs -f).

  2. Open the app. With an empty database it redirects you to the /setup wizard. 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.

  3. Verify:

    Terminal window
    curl http://localhost:3000/api/health
    curl http://localhost:3000/api/health/config # config doctor, admins only

    The config doctor answers 401 until you are signed in as an admin; before that, run docker compose exec lumastra npm run doctor instead.

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.

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/javascript
  • application/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 on
gzip_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;

To verify, request the public web manifest (large enough to pass every proxy’s minimum-size threshold) and check for a content-encoding header:

Terminal window
curl -s -o /dev/null -D - -H 'Accept-Encoding: br, gzip' \
https://lumastra.example.com/manifest.json | grep -i content-encoding

Then 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.

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, and p, 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;

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.

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:

UploadLimitSet by
Artwork (posters, backdrops, logos)10 MBMAX_IMAGE_UPLOAD_MB (up to 50)
Library uploads, per chunk5 MBUPLOAD_CHUNK_SIZE_MB
Diagnostics bundles from the iOS and tvOS apps (/api/v1/)3 MiBfixed
Avatars2 MBfixed
Everything, ceiling50 MB in the example compose fileBODY_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.

Terminal window
docker compose pull
docker compose up -d

Migrations 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.

  1. Back up first. Take a PostgreSQL dump and copy your LUMASTRA_MASTER_KEY; see Backup & recovery.

  2. 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 yes starts empty: it ignores dump.rdb, and its next shutdown overwrites the snapshot. Never add --appendonly yes to the command and restart first. Do it live, in this order. Set the password for valkey-cli through REDISCLI_AUTH instead of -a, so it does not show in the process list:

    Terminal window
    # bash; the password comes from .env, the service is your existing Valkey
    set -a; . ./.env; set +a
    VK="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:0 and aof_last_bgrewrite_status:ok. Only then continue. (These settings are lost on a restart; step 3 makes them permanent.)

  3. Edit the compose file.

    • Existing Valkey: keep the service, its volume and its password. Only now add --maxmemory-policy noeviction --appendonly yes --appendfsync everysec to its command (remove any --maxmemory limit). 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-alpine
      restart: unless-stopped
      command: >
      valkey-server --requirepass ${CACHE_PASSWORD}
      --maxmemory 1024mb --maxmemory-policy allkeys-lru
      --save "" --appendonly no
    • On the web container, remove WORKER_MODE and DEDICATED_WORKER (if you set them, the web container refuses to start) and set the two URLs. TASK_CACHE_URL is your existing Valkey, CACHE_URL is the new one. Keep the database number your old CACHE_URL used (usually /0) in TASK_CACHE_URL, so the queue is found. A TASK_CACHE_URL without 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 same config and plugins volumes and media mounts as the web container. The minimal compose file above shows the full service.

    • Make web and worker depends_on both Valkeys.

  4. 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.

  5. Check. docker compose logs lumastra-worker shows the workers starting. Run $VK CONFIG GET maxmemory-policy (expect noeviction) and $VK CONFIG GET appendonly (expect yes). 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=true once to start anyway; those runs are then lost.

  • It refuses to start when the task Valkey’s eviction policy is not noeviction. Setting LUMASTRA_ALLOW_EVICTING_TASK_VALKEY=true overrides 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_URL and TASK_CACHE_URL reach the same Valkey server under two names (for example localhost and 127.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.

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 …”).

.env
LIVETV_SOURCE=standalone
STREAM_PROXY_URL=http://proxy:3000
STREAM_PROXY_SECRET=<openssl rand -hex 24>

More on how it works: Live TV architecture.

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:

  1. 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:ro on the sidecar with the same line you put on the lumastra service. With a different container path every track fails with No such file or directory.

  2. 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 .env as SONIC_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;"
  3. Point the app at it. Uncomment SONIC_EMBEDDER_URL on the lumastra service (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.

  4. Start it with docker compose --profile sonic up -d. Stop everything with docker compose --profile sonic down; without the profile, down leaves 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.

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.

BoxSidecar memoryWhat to expect
Small NAS / mini PC3gText encoder loads on demand. Everything embeds; analysis covers the 30-minute default, up to ~1 hour
Home server6g (the example file)Every job; analysis up to the sidecar’s 2-hour cap
Large library10g or moreText 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:

VariableDefaultWhat it does
SONIC_MEMORY_LIMIT_MBthe container limit, else half the host RAMMemory the sidecar plans with. Normally leave unset
SONIC_MEMORY_RESERVE_MB512 or 10 % of the limit, whichever is largerHeadroom kept out of the job budget
SONIC_MAX_CONCURRENT_JOBSCPUs ÷ threads, at most 2Heavy jobs at once (the memory gate may run fewer)
SONIC_ADMISSION_QUEUE_TIMEOUT_SEC120How long a request may wait for memory before a 429
SONIC_TEXT_ENCODERauto (loaded at 8 GiB or more, otherwise on demand)warm, lazy or auto. Only Sound Search uses the text encoder
SONIC_TEXT_ENCODER_IDLE_SEC900Idle time before an on-demand text encoder is unloaded again
SONIC_ANALYZE_MAX_DURATION_SEC7200Longest file the sidecar analyses; longer ones are refused
MALLOC_ARENA_MAX2 (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.

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:

Servicemax-sizemax-fileEffective capPurpose
lumastra (app)20m10~200 MBAPI server, request logs, high write volume
worker20m10~200 MBBackground job processing, sync operations
postgres10m5~50 MBDatabase logs, query errors
valkey5m3~15 MBCache 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:

Terminal window
docker compose up -d

Verify it took effect:

Terminal window
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.

ComponentMinimumRecommendedNotes
App / worker1 GB2–4 GBEmbeddings and ffmpeg peak above 1 GB
PostgreSQL256 MB512 MB–1 GBGrows with the pgvector index
Valkey256 MB512 MB–1 GBIdle use is about 10 MB per instance; the cache is capped
Total4 GB8 GB4 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.