Skip to content

Contract: stack.toml

Status: Accepted

The interface between lemonfiber and lemonfiber-media-stack. Everything lemonfiber knows about the stack comes from this file; it knows nothing about Sonarr that isn’t declared here.

Satisfies: B1-R2, B1-R3, F1-R5, F1-R9, F2-R1F2-R4


Adding a service must not require a Rust change (F1-R5), and forms must be data rather than code (B1-R2). Both demand that per-service knowledge live somewhere lemonfiber reads rather than somewhere it compiles.

The alternative — a match over service names in Rust — means every stack change is a release, third-party stacks are impossible, and the “it’s just Compose” guarantee quietly stops being true.

stack.toml, at the root of a stack directory, beside compose.yml.

schema_version = 1
stack_version = "1.0.0"
min_cli_version = "0.4.0"
[[profile]] # 12 of these
[[form]] # 11 of these
[[service]] # 19 of these

An integer naming the manifest format generation. Incremented only on a breaking structural change.

lemonfiber refuses a manifest whose schema_version it does not implement, with both versions named (F1-R9). This turns version skew into a clear error rather than an obscure Compose failure — and because the stack is embedded at build time (ADR-0005), it is usually caught at compile time rather than reaching a user at all.

stack_version is the content version and is semver; it moves when services or forms change. min_cli_version lets a stack refuse an older binary.

[[profile]]
id = "tv"
name = "Television"
description = "Automated television acquisition"
[[profile]]
id = "torrent"
name = "Torrents"
description = "Torrent downloading, VPN-isolated"
protocol = "torrent"
Field Type Required Notes
id string Unique. Must match a Compose profile name exactly.
name string Human-facing
description string Shown in form previews
protocol enum usenet | torrent. Present only on a profile that cannot run without a configured provider.

protocol — what makes the intersection possible

Section titled “protocol — what makes the intersection possible”

B1-R4 requires a closure to be intersected with the operator’s configured protocols before anything starts. Nothing in this contract said which profiles that applies to, so the only way to satisfy it was for lemonfiber to know the strings usenet and torrent — per-service knowledge in code, which is the one thing this file exists to prevent.

protocol supplies it as data. A profile that declares one cannot run unless the operator has configured that provider; a profile that declares none is never narrowed away.

The consequence of getting this wrong is not cosmetic. A torrent profile started without a configured VPN brings up the tunnel container with no credentials, and the failure mode of a VPN that is not actually protecting anything is the one failure in this product with consequences outside the machine.

A fork that renames its profiles keeps working, which was the point.

[[form]]
id = "tv"
name = "TV"
description = "Search, download and automate television"
profiles = ["search", "usenet", "torrent", "tv", "subs"]
composable = true
Field Type Required Notes
id string Unique
name string Human-facing
description string One line, plain language
profiles array The closure. Every entry MUST reference a declared profile.
composable bool Default true. May be combined with other forms (B1-R5).

A form’s profiles list is the complete closure, written out. Dependencies are not inferred — tv names search explicitly rather than lemonfiber deducing that Sonarr needs indexers.

That verbosity is deliberate: inference would require lemonfiber to understand each service’s semantics, which is exactly the coupling this contract exists to avoid.

[[service]]
id = "sonarr"
name = "Sonarr"
profile = "tv"
image = "lscr.io/linuxserver/sonarr"
tag = "4.0.15"
port = 8989
bind = "loopback"
health = { kind = "http", path = "/ping", timeout_s = 60 }
api = { kind = "servarr", key_source = "config-xml", path = "/config/config.xml" }
criticality = "core"
license = "GPL-3.0-only"
upstream = "https://github.com/Sonarr/Sonarr"
last_release = "2026-06-26"
describes = "Watches for new episodes and fetches them"
without_it = "Find and download episodes yourself"
media_types = ["tv"]
Field Type Required Notes
id string Unique. MUST match the Compose service name.
name string Human-facing
profile string Exactly one (B1-R1). MUST reference a declared profile.
image string Without tag
tag string Explicit version. A floating tag fails validation (E1-R1).
port integer Primary UI/API port. Omitted for services with no listener.
bind enum ✔ if port loopback | lan. Enforces C6’s two-tier policy.
health table See below. Absent means lifecycle waits on container state only.
api table How lemonfiber talks to it for seeding. Absent means no API integration.
criticality enum critical | core | important | enhancing | optional (F2-R3)
license string SPDX identifier. A non-OSI value fails validation (F2-R5, F2-R12).
upstream string Project URL, for maintenance review (F2-R14)
last_release string YYYY-MM-DD. The latest upstream release, not the pinned one — an abandonment signal, refreshed when the pin is reviewed (F2-R14)
describes string What it does for the operator (F2-R1)
without_it string Consequence of its absence (F2-R2)
media_types array Which media types it handles; drives root-folder seeding
depends_on array Same profile only. Cross-profile entries fail validation (B1-R14).
capabilities array e.g. ["NET_ADMIN"]. Any entry beyond an allow-list fails validation.
host_managed bool true for native-mode Jellyfin — lifecycle is the OS’s (B2-R15)
health = { kind = "http", path = "/ping", timeout_s = 60 }
health = { kind = "tcp", timeout_s = 30 }
health = { kind = "container" }

kind = "http" is checked against port + path. Startup is health-gated, not process-gated (B2-R1), so this is what “started” actually means.

api = { kind = "servarr", key_source = "config-xml", path = "/config/config.xml", version = 3 }
api = { kind = "sabnzbd", key_source = "config-ini", path = "/config/sabnzbd.ini" }
api = { kind = "qbittorrent", key_source = "generated" }
api = { kind = "seerr", key_source = "api-settings" }
api = { kind = "bindery", key_source = "api-settings" }
api = { kind = "jellyfin", key_source = "generated" }
api = { kind = "bazarr", key_source = "config-yaml", path = "/config/config/config.yaml" }

kind selects the client implementation. servarr covers Sonarr, Radarr, Lidarr and Prowlarr, since they share an API shape — which is what makes one client sufficient for four services. bazarr is its own, because it is told about the *arrs rather than being one of them, and it is told in a form body whose field names are its configuration file’s own paths flattened. jellyfin is the one media server lemonfiber sets an account on rather than reading a key from, so its key_source is generated like qBittorrent’s — it mints the administrator password by driving Jellyfin’s own first-run setup.

version is the major version of the service’s HTTP API, the /api/vN path segment. It is required for servarr and read there, because that one shape spans two versions — Sonarr and Radarr answer at /api/v3, Lidarr and Prowlarr at /api/v1 — so it is data the manifest carries rather than a guess the client makes from a service’s name. It is absent for the other kinds, whose one fixed version their client already knows.

key_source says where the credential comes from:

Value Meaning
config-xml, config-ini, config-json, config-yaml The service mints it and writes it to path; lemonfiber reads it
api-settings Retrieved over the service’s own API once authenticated
generated The service offers nothing durable to read, so lemonfiber generates the credential, sets it, and records it for its consumers (A7-R14)
none The API needs no credential at all

The four file shapes are four shapes, not one with a guess: a reader that sniffed the format would be right until a service changed it, and wrong silently. Which file a service writes is a fact about that service, so the manifest says it.

generated exists because qBittorrent mints only a temporary WebUI password and asks for it to be replaced. It also has a consumer that is not a service — the VPN’s forwarded-port push authenticates against the same WebUI API, so the recorded value has to reach the stack’s environment and not only lemonfiber’s own store.

Bindery is deliberately its own kind. It is not a Servarr application, and Prowlarr’s app sync does not cover it (D1-R15).

It also has no configuration file to read. Bindery keeps everything in SQLite and issues a per-account API key, which exists only once first-run setup has created an account — so the key is retrieved over its own API after authenticating, and path does not apply.

Validation reports every violation in one pass, each naming its location (F1-R9). Reporting one error per run turns fixing a fork into a guessing game.

Rule Failure
schema_version supported Both versions named
Every id unique within its kind Duplicate named
Every service.profile references a declared profile Both named
Every form.profiles entry references a declared profile Both named
Exactly one profile per service Service named
No depends_on crossing a profile boundary Service and target named (B1-R14)
tag is not latest or otherwise floating Service named (E1-R1)
bind present when port is Service named
license is a recognised OSI identifier Service and licence named (F2-R5)
last_release is YYYY-MM-DD and not in the future Service and value named (F2-R14)
capabilities within the allow-list Service and capability named
protocol is a permitted value Profile and value named
At most one profile per protocol Both profiles named
Manifest services match compose.yml services exactly Divergence listed both ways

That last rule matters more than it looks: a manifest describing a service that isn’t in the compose file — or vice versa — is the most likely error when adding one, and it fails in confusing ways at runtime.

The real stack, abridged to one service per profile. The full file lives in lemonfiber-media-stack.

schema_version = 1
stack_version = "1.0.0"
min_cli_version = "0.4.0"
# ── Profiles ────────────────────────────────────────────────
[[profile]]
id = "search"
name = "Indexers"
description = "Finding things"
[[profile]]
id = "usenet"
name = "Usenet"
description = "Usenet downloading"
protocol = "usenet"
[[profile]]
id = "torrent"
name = "Torrents"
description = "Torrent downloading, VPN-isolated"
protocol = "torrent"
[[profile]]
id = "tv"
name = "Television"
description = "TV automation"
[[profile]]
id = "media"
name = "Library"
description = "Serving what you have"
# … movies, music, books, subs, tuning, dash, proxy
# ── Forms ───────────────────────────────────────────────────
[[form]]
id = "search"
name = "Search"
description = "Find things. Nothing else runs."
profiles = ["search"]
[[form]]
id = "dl"
name = "Download"
description = "You have a link — fetch it."
profiles = ["usenet", "torrent"]
[[form]]
id = "hunt"
name = "Hunt"
description = "Search and grab, manually."
profiles = ["search", "usenet", "torrent"]
[[form]]
id = "tv"
name = "TV"
description = "Search, download and automate television"
profiles = ["search", "usenet", "torrent", "tv", "subs"]
[[form]]
id = "library"
name = "Library"
description = "Serve what exists. Requires no third-party accounts."
profiles = ["media"]
[[form]]
id = "proxy"
name = "Proxy"
description = "Friendly hostnames. Layers onto any other form."
profiles = ["proxy"]
# … movies, music, books, auto, full
# ── Services ────────────────────────────────────────────────
[[service]]
id = "prowlarr"
name = "Prowlarr"
profile = "search"
image = "lscr.io/linuxserver/prowlarr"
tag = "2.5.2"
port = 9696
bind = "loopback"
health = { kind = "http", path = "/ping", timeout_s = 60 }
api = { kind = "servarr", key_source = "config-xml", path = "/config/config.xml" }
criticality = "core"
license = "GPL-3.0-only"
upstream = "https://github.com/Prowlarr/Prowlarr"
describes = "Holds your indexer accounts in one place and shares them with everything else"
without_it = "Every app needs indexers configured separately"
[[service]]
id = "gluetun"
name = "Gluetun"
profile = "torrent"
image = "qmcgaw/gluetun"
tag = "v3.40.0"
health = { kind = "container" }
criticality = "critical"
license = "MIT"
upstream = "https://github.com/qdm12/gluetun"
describes = "Routes torrent traffic through your VPN and blocks it if the VPN drops"
without_it = "Your home IP is visible to every peer"
capabilities = ["NET_ADMIN"]
[[service]]
id = "qbittorrent"
name = "qBittorrent"
profile = "torrent"
image = "lscr.io/linuxserver/qbittorrent"
tag = "5.0.3"
port = 8081
bind = "loopback"
health = { kind = "http", path = "/api/v2/app/version", timeout_s = 60 }
api = { kind = "qbittorrent", key_source = "generated" }
criticality = "core"
license = "GPL-2.0-only"
upstream = "https://github.com/qbittorrent/qBittorrent"
describes = "Downloads torrents"
without_it = "No torrent downloads"
depends_on = ["gluetun"]
[[service]]
id = "sonarr"
name = "Sonarr"
profile = "tv"
image = "lscr.io/linuxserver/sonarr"
tag = "4.0.15"
port = 8989
bind = "loopback"
health = { kind = "http", path = "/ping", timeout_s = 90 }
api = { kind = "servarr", key_source = "config-xml", path = "/config/config.xml" }
criticality = "core"
license = "GPL-3.0-only"
upstream = "https://github.com/Sonarr/Sonarr"
describes = "Watches for new episodes and fetches them"
without_it = "Find and download episodes yourself"
media_types = ["tv"]
[[service]]
id = "seerr"
name = "Seerr"
profile = "media"
image = "ghcr.io/seerr-team/seerr"
tag = "3.3.0"
port = 5055
bind = "lan"
health = { kind = "http", path = "/api/v1/status", timeout_s = 90 }
api = { kind = "seerr", key_source = "api-settings" }
criticality = "important"
license = "MIT"
upstream = "https://github.com/seerr-team/seerr"
describes = "Where the household asks for things"
without_it = "Requests come to you in person"

Note qbittorrent.depends_on = ["gluetun"] — legal because both are in torrent, and the single permitted cross-service dependency in the stack (B1-R14).

Note seerr.bind = "lan" against everything else’s loopback — the two-tier policy expressed as data rather than as a rule someone has to remember.

See versioning.

This page lives in another repository Rendered from lemonfiber/spec at 1d10402, 2026-09-09. Read the source of this page