← The specification

Lifecycle control

Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page

Status: Accepted · Audience: Operator · Area: B — Running it


Purpose

Start, stop, restart and inspect the stack — whole forms or individual services — without the operator needing to know Docker.

This is the most-used feature in the product. It must be fast, predictable, and honest about what actually happened. “Started” must mean the thing is usable, not merely that a container process exists.

Behaviour

Starting is health-gated, not process-gated

A container reaching “running” says nothing about whether the application inside it has finished initialising. The *arrs take several seconds to open their databases and bind their ports; Jellyfin takes longer on first run.

Starting therefore waits for each service to report healthy, and reports progress per service. An operator told “started” who then gets connection refused has been lied to.

Progress reaches every surface, and says whose it is

A terminal shows what the container engine writes while it pulls and while it starts, and what the wait after it is still waiting for. A start or a fetch asked for over the web API is answered with a job name and nothing else, so everything it says afterwards comes down the event stream (B2-R18). One stream carries every job’s lines, so a line a job said names that job; a surface following the start it asked for tells that start’s lines from another’s by the name it was answered with, not by when they happen to arrive (B2-R19).

Every operation states what it will affect before doing it

Stopping hunt while tv is also running stops six services, four of which tv still needs. The operator sees the affected list first.

How long it will be disturbed, where that can be said honestly

The operator deciding whether to restart hunt now or after dinner is asking one question: how long will this be down for? The stack can answer it in one of two ways, and only one of them is a duration.

Where the operation has a bound — a stop grace period, a health-check timeout, the wait a start gives a service before it gives up on it — that bound is a real number the stack already holds, and it is what gets stated. Where it does not — a teardown that lets downloads finish, a pull over an unknown connection, a start waiting on a service doing an hour of work it was in the middle of — there is no honest figure, and the operation says so rather than producing one.

What is refused is the middle: an estimate nobody can check. A duration guessed from an average is wrong in exactly the cases the operator most needs it — a large library mid-scan, a slow disk — and wrong silently, because nothing afterwards compares it to what happened. “Up to ninety seconds” and “as long as the downloads take” are both answers somebody can plan around. “About a minute”, where a minute was never measured, is not.

This is stated by the stack rather than worked out by whatever is displaying it. A surface that estimates is a second opinion with less to go on, and there is no version of that which is better than the first one being honest.

Fetching a form’s images is one of these operations. It stops nothing, and it is the one whose length depends entirely on the line: what it waits on is the images coming down, and the status reading says so beside what starting, stopping and restarting disturb, so a surface away from the machine can say before a fetch that it may take long and use a lot of the line without putting a figure on either (B2-R17).

Individual services are addressable

Restarting one wedged service must not require restarting the form. Operations apply to a form, a list of services, or everything.

Operations are idempotent

Starting an already-running form is a no-op that reports as such, not an error and not a restart. Stopping something already stopped likewise.

Order is derived, not configured

Because no depends_on crosses a profile boundary, services can start in any order. Where genuine ordering exists — qBittorrent requiring Gluetun’s network namespace — it lives within a single profile and is handled by Compose.

The corollary matters for stopping: the VPN must be stopped last, since tearing down Gluetun first would drop qBittorrent’s networking out from under it.

The underlying command is always inspectable

--dry-run prints the exact docker compose invocation without executing it. This serves debugging, learning, and the operator who wants to run it themselves (ADR-0001).

Status answers “is it working”, not “is it running”

Per-service status distinguishes: not created, created but stopped, starting, healthy, unhealthy, restart-looping, and stopped-with-error. “Up” is not a status — it’s an ambiguity.

A container a start created and that never ran is not stopped. lemonfiber never creates a container without starting it, so one left created is a start that did not happen — most often because something it depends on never became ready — and it is reported as failed. Reported as stopped, it would read as something the operator turned off, and a form holding it would stop counting as brought the moment the operator asked for it.

A start that did not complete says what did not come back

A start can fail before anything settles: the engine refuses it, or a dependency never becomes ready and the service that needs it is left created. The report still names the state of every service the start addressed, and of the services those depend on, read once when the start ends rather than waited for. A failed start that says nothing about any service reads as one where nothing fell short, which is the opposite of what happened. See G7 for the one-line rollup.

States

Per service:

State Meaning
absent No container exists
stopped Exists, not running
starting Running, health check not yet passing
healthy Running and passing health checks
unhealthy Running but failing health checks
crash-looping Repeatedly exiting and restarting
failed Exited non-zero and not restarting, or created by a start and never run

Per form: inactive, partial (some services healthy), active (all healthy), degraded (running with at least one failure).

Edge cases

Situation Behaviour
Docker daemon unreachable Detect before attempting anything; distinguish from “container failed to start”.
Service never becomes healthy Bounded wait, then report the timeout with that service’s last log lines inline. Don’t hang forever, don’t silently give up.
Service crash-loops Detect the loop rather than reporting “starting” indefinitely. Show the exit code and recent logs.
Stopping while downloads are active Report what’s in flight and offer to wait. SABnzbd and qBittorrent both resume, but the operator should choose knowingly.
Port conflict on start Name the port, the service, and the conflicting process where the OS allows.
Image missing locally Pull it, with progress. Don’t fail with “no such image”.
Image pull fails Report which image and why; leave already-started services running.
A dependency never becomes ready, and the service needing it is left created Report the dependency as it stands and the service as failed, not stopped, and keep counting the form the operator asked for.
The start command itself fails Read every addressed service and its dependencies once, and report each one’s state; never report that no service fell short.
Stopping a shared service Refuse if another active form requires it; name that form.
Gluetun stopped while qBittorrent runs Stop qBittorrent first. Ordering within the torrent profile is enforced.
Restart requested for a native-mode Jellyfin Report that it’s host-managed and print the platform-specific command; do not attempt to control the OS service manager.
Operation issued while another is in progress Serialise. Report that an operation is running rather than racing.
Asked how long a disturbance will last, with no bound to give Say it is open-ended and what it is waiting on. Do not produce a figure nothing measured.
Disk full at start Detect and report as a disk problem, not a container failure — the remedies are entirely different.

Acceptance criteria

ID Requirement
B2-R1 Starting MUST wait for health, not merely for the container to run, before reporting success.
B2-R2 Per-service progress MUST be shown during start.
B2-R3 Every operation MUST list the services it will affect before acting.
B2-R4 Operations MUST be addressable to a form, a service list, or everything.
B2-R5 Operations MUST be idempotent, and a no-op MUST report as such rather than erroring.
B2-R6 Stopping MUST stop the VPN last where a download client depends on its network namespace.
B2-R7 Stopping a service still required by another active form MUST be refused, naming that form.
B2-R8 A service that fails to become healthy MUST time out within a bounded period and MUST surface its recent logs.
B2-R9 Crash-looping MUST be reported distinctly from starting.
B2-R10 Status MUST distinguish at minimum: absent, stopped, starting, healthy, unhealthy, crash-looping, failed.
B2-R11 --dry-run MUST print the exact underlying command without executing it.
B2-R12 Missing images MUST be pulled with visible progress rather than producing an error.
B2-R13 Stopping with active downloads MUST report them and offer to wait.
B2-R14 Concurrent lifecycle operations MUST be serialised, not raced.
B2-R15 Native-mode Jellyfin MUST report as host-managed and MUST NOT be started or stopped by lemonfiber.
B2-R16 Every operation MUST state, before it acts, whether the disturbance it causes is bounded — naming that bound — or open-ended, and MUST NOT leave a surface to estimate it.
B2-R17 The status reading’s disturbs MUST carry an entry for fetching a form’s images, stating that fetch as open-ended and naming what it waits on as the images coming down the line, so that a surface can say before a fetch that it may take long and use a lot of the line without estimating either (B2-R16, N2-R24).
B2-R18 The lines the container engine writes while pulling images MUST reach the web API’s event stream as pull events, for a fetch and for a start that pulls, as they reach a terminal (B2-R12, N2-R24).
B2-R19 A start or pull line on the event stream said by work a job names MUST carry that job’s name, as the work’s accepting reply carried it, so that a surface can tie each line to the start or fetch it asked for (B2-R2, N2-R23).
B2-R20 A start that did not complete MUST report the state of every service it addressed and of the services those depend on, read once when it ends and without waiting for any of them to settle, and MUST NOT report that no service fell short (B2-R3, N16-R6).
B2-R21 A container a start created and that never ran MUST be reported as failed, not stopped, and a form holding it MUST go on counting as brought (B2-R10).