Fleet manifest (fleet.toml)¶
shinyhub fleet reconciles a whole set of apps against a single declarative
manifest, the way kubectl apply reconciles a cluster against a directory of
YAML. You describe the apps you want, their source, visibility, and
fleet-managed config; the CLI computes the difference against what the server
actually runs and converges it.
Reconcile is client-orchestrated: the CLI fetches server state, builds the plan locally, and drives the existing per-app deploy and patch APIs. There is no server-side fleet controller and no new privileged endpoint.
Fleet commands read fleet.toml from the working directory when -f is
omitted. The previous name shinyhub-fleet.toml is still read as a fallback
when fleet.toml is absent (with a one-line deprecation note on stderr), so
existing repositories keep working; rename the file to fleet.toml at your
convenience. Pass -f <path> to point at any other location.
fleet_id = "prod-eu"
[[bundle_file]]
from = "_shared/plotly_template.py"
to = "helpers/plotly_template.py"
consumers = ["sales-dashboard"]
[[app]]
slug = "sales-dashboard"
source = "./apps/sales-dashboard"
visibility = "private"
[app.config]
hibernate_timeout_minutes = 30
replicas = 2
max_sessions_per_replica = 10
[[app]]
slug = "status-page"
source = "git+https://github.com/acme/[email protected]#deploy/status"
visibility = "public"
Fields¶
Top level¶
| Field | Required | Meaning |
|---|---|---|
fleet_id |
yes | Ownership scope. Must match [a-z0-9-], 1-64 chars. Stamped onto every app this manifest manages as managed_by = fleet:<fleet_id>. |
[[app]] |
yes (>=1) | One block per app the fleet should own. |
[[project]] |
no | One block per project the fleet should name. Optional: an app can declare a project without a matching block, and the project is then created unnamed. |
[[bundle_file]] |
no | Copy one canonical local file into the bundles of explicitly named consumers. |
[[app]]¶
| Field | Required | Meaning |
|---|---|---|
slug |
yes | App slug. Unique within the manifest; a duplicate is a validation error. |
source |
yes | Where the bundle comes from (see Source resolution). |
visibility |
no | private (default), shared, or public. |
[app.config] |
no | Fleet-managed app settings (see Config). |
[app.config] - fleet-managed settings¶
| Field | Type | Meaning |
|---|---|---|
name |
string 1..128 | Friendly display name shown on the dashboard card and the detail heading. Trimmed; may not be empty. Distinct from slug, which is the URL identifier and is not settable here. |
description |
string 0..280 | One-line description shown under the name. Trimmed; "" is a real value that clears it. |
project |
string | Project slug that groups this app on the dashboard, the launchpad and the sidebar. Must match [a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?. "" is a real value that ungroups the app. The project row is created automatically if it does not exist. |
hibernate_timeout_minutes |
int | Idle minutes before hibernation. -1 resets to the server default, 0 disables hibernation, and a positive value sets the timeout. |
replicas |
int >= 1 |
Number of replica processes. See scaling. |
max_sessions_per_replica |
int >= 1 |
Per-replica admission cap for new cookieless sessions. |
autoscale |
inline table | Session-saturation autoscale policy. Reconciled atomically and drift-protected as one unit. See autoscale below. |
Only the keys you declare here are owned by the fleet manifest. An omitted key
may still be owned by the source bundle's shinyhub.toml; if neither manifest
declares it, a value set through the UI or CLI survives untouched.
In other words, [app] keys are a sparse overlay: omitting a key leaves its
current server value in place; omission does not reset it to the default.
To inherit the global hibernation timeout explicitly, declare
hibernate_timeout_minutes = -1. The adjacent value 0 means never
hibernate, not "use the default".
Declaring name or description therefore makes the manifest their owner: a
rename in the dashboard shows up as drift on the next fleet plan and is
reverted by fleet apply. plan renders both quoted, so an empty or
space-padded value is visible rather than rendering as nothing:
[app.config] autoscale¶
Manage the per-app autoscale policy from the fleet manifest so it is reconciled
on every fleet apply and drift back to the manifest is corrected:
| Key | Type | Meaning |
|---|---|---|
enabled |
bool | Required. Turn the policy on or off. Also gated on the global runtime.autoscale.enabled server flag. |
min_replicas / max_replicas |
int | Steady-state bounds the controller stays within. When enabled, min_replicas >= 1, max_replicas >= min_replicas, and max_replicas may not exceed the runtime steady-state max_replicas ceiling. A scheduled serving-data roll may temporarily admit one memory-checked surge replica above it without changing the configured count. |
target |
float (0,1] |
Target average active sessions per replica as a fraction of the per-replica cap. 0 inherits the runtime default. |
The block reconciles atomically (all four columns) and is one drift unit: fleet
plan shows a single line (e.g. autoscale off -> on(1-8 @ 0.80)) when the
server policy differs from the manifest. It is the same policy the bundle
shinyhub.toml [app] autoscale block sets; per config precedence
the fleet manifest wins. See Autoscaling for behaviour.
[[project]] - project display metadata¶
A project is created automatically the first time an app declares it, with no
name and no icon. A [[project]] block declares that metadata so a fleet
manifest can produce a fully labelled dashboard on its own:
fleet_id = "acme"
[[project]]
slug = "reporting"
name = "Quarterly Reporting"
description = "Finance-facing dashboards"
icon = "📊"
[[app]]
slug = "revenue"
source = "./apps/revenue"
[app.config]
project = "reporting"
| Field | Required | Meaning |
|---|---|---|
slug |
yes | Project slug. Unique within the manifest; a duplicate is a validation error. Same charset as an app slug. |
name |
no | Display name, up to 128 characters. Trimmed. "" is a real value that clears it. |
description |
no | One-line description, up to 280 characters. Trimmed. |
icon |
no | A single emoji shown beside the group heading. "" clears it. |
Only the keys you declare are reconciled, matching [app.config]: an omitted
key is not asserted, so a name set through the dashboard survives a manifest
that declares only an icon.
A [[project]] block that no [[app]] references is a validation error. The
fleet manifest reconciles what it declares; a project that no app joins would
be created and then never converge, since nothing in the manifest can restore
it if someone deletes it.
Projects are reconciled before apps in a single fleet apply, so an app
that creates its project lands in one that is already named.
fleet apply --prune never deletes a project. Projects are a shared namespace
that apps outside this fleet can also be in, and a project holding no apps is
a valid state an operator may be preparing. Remove one with
shinyhub projects rm <slug>.
Source resolution¶
source is resolved one of two ways:
- Local path. A relative path is resolved against the directory
containing the manifest, not the current working directory, so a manifest
is portable regardless of where
shinyhub fleetis run from. The path must exist; existence is checked in a pre-flight step before any change is made. - Git URL.
git+<url>[@ref][#subdir].@refpins a branch, tag, or commit;#subdirdeploys a subdirectory of the repository as the bundle root. The URL format is validated when the manifest is parsed; the clone happens during pre-flight.
Shared bundle inputs¶
Use [[bundle_file]] when a small number of files are intentionally identical
across several local app bundles:
[[bundle_file]]
from = "_shared/plotly_template.py"
to = "helpers/plotly_template.py"
consumers = ["sales", "operations"]
from resolves against the directory containing fleet.toml. to is the
path inside each consumer's bundle. consumers is explicit: an app receives
the file only when its slug is listed. V1 supports local app sources only; a
declaration that names a git+ consumer fails validation. This is bundle-time
composition, not a shared runtime or shared environment. Each resulting bundle
is still self-contained and independently deployable.
Validation is intentionally strict:
fromandtomust be normalized, portable, relative slash paths. The source must be a regular file inside the manifest root. Every source path component is checked without following symlinks; this also rejects the common monorepo layout where_shareditself is a symlink.- A destination may not already exist in the app source. Exact and file/directory-prefix conflicts between declarations are also errors.
.shinyhubignore(or its.gitignorefallback) applies to the destination and every destination ancestor. Declaring an ignored destination is an error, not a silent omission. The normal reserved-path, extension, and file-size bundle rules apply too.shinyhub.toml,.shinyhubignore, and.gitignorecannot be composed. These control how the base bundle is interpreted and must live in the app source.
Preflight resolves and snapshots every canonical shared source before any
server mutation. The same source is read once per invocation even when several
apps consume it, and each successful consumer deploy uses that immutable
snapshot. A later edit during the apply is picked up on the next invocation.
Executable owner mode is normalized to 0755; other files use 0644, with a
fixed archive timestamp for reproducible bundles. The checks reduce file-swap
risk but are not a hostile-filesystem sandbox; V1 assumes the local checkout is
trusted while the command runs.
Plan output reports each declaration's consumers and which of those consumers already have a planned source-bearing action. That is fan-out visibility, not file-level remote causality: the server stores a bundle digest, not the prior contents of each shared file. Apply remains non-atomic and continue-on-error, so one consumer can fail after another has deployed; the report names that partial outcome.
A shared uv.toml is a common case: it gives every consumer the same uv
settings, such as a mirror index, without repeating them in each
pyproject.toml.
A project-level uv.toml replaces the [tool.uv] section of the app's
pyproject.toml entirely (uv ignores that section and warns), so move any
[tool.uv] settings a consumer needs into the shared file. Index variables in
the server or app environment (UV_DEFAULT_INDEX, UV_INDEX_URL, ...) take
precedence over the file. A consumer that ships a uv.lock is installed exactly
as locked (see Shipped uv.lock files),
so lock it with the same settings: uv lock --config-file _shared/uv.toml.
git+ consumers are not covered; commit the uv.toml to their repository.
Single-app shinyhub run, shinyhub plan, and shinyhub deploy do not compose
fleet inputs. When they can discover a valid nearest-parent fleet manifest for
the selected local source, they warn on stderr and point to the corresponding
fleet command. Discovery is best-effort and cannot find a manifest supplied
elsewhere with -f; absence of a warning is not proof that the source has no
fleet composition.
To migrate hand-copied files, add the canonical files under a directory such
as _shared, declare their destinations and consumers, and delete the old
vendored targets in the same commit. Then run shinyhub fleet validate, review
shinyhub fleet plan, and converge with shinyhub fleet apply.
Develop a composed consumer through the same declaration rather than restoring a vendored copy:
This offline command validates and runs only the selected local app; an unrelated app with a missing local source does not block focused development. The whole manifest's syntax and cross-references must still be valid. Shared file and app-source edits trigger staged reload, while a broken or missing shared input leaves the current healthy process online. Manifest-structure edits require restarting the command. See Fleet development for selection, shared inputs, generated state, data directories, and concurrency behavior.
Config precedence¶
When the same setting can come from more than one place, the fleet manifest wins:
- Fleet manifest
[app.config]- highest. A declared key is enforced on every apply; out-of-band drift is corrected back. This includes the deploy apply performs itself: when a new bundle stores its own value for a key the fleet declares, apply re-reads the app after the deploy, sends the keys that now differ, and reports the app as failed (exit 4) if the server still does not hold the declared value. - Bundle
shinyhub.toml[app]- durable settings are checked on every plan/apply and corrected with a config PATCH even when the bundle digest is unchanged. Boot-only settings such ascommandand startup/build timeouts continue to take effect when the bundle is deployed or started. - Server default - lowest.
If neither manifest declares a durable key, a setting managed through the UI or CLI remains outside fleet reconciliation.
Strict-mode parsing¶
Parsing reports every problem it finds, compiler-style, not just the first.
Unknown keys are rejected with a "did you mean" suggestion (a typo such as
replcias does not silently no-op). fleet_id is required and syntax-checked;
each app must have a slug and a source; duplicate slugs and invalid visibility
values are errors. A manifest with any problem is never used to make changes.
Workflow¶
1. shinyhub fleet init¶
Scaffold a manifest from the apps already deployed on the server:
Writes fleet.toml containing fleet_id and one [[app]] block per
existing app, slug-sorted, with each app's current visibility, config, and
project membership. Referenced projects are emitted once as [[project]]
blocks so grouping survives an init/plan/apply round trip.
With --source-root <dir> each source is set to <dir>/<slug> and the file
is immediately plan-able. Without it the source line is left commented so
you set each path explicitly; an unset source trips the pre-flight check with
a precise message rather than a confusing parse error.
--fleet-id is required (prompted when run interactively); the file is not
overwritten unless --force is passed. Even with --force, init refuses to
replace an existing manifest that contains [[bundle_file]] declarations,
because regenerating app inventory cannot preserve those hand-authored inputs.
2. shinyhub fleet validate¶
Validate the complete manifest locally before contacting a server:
Besides schema and source checks, validation resolves every shared input for every consumer and rejects missing or escaping sources, symlinks, target collisions, ignored destinations, and bundle-policy violations. This is the authoritative offline pre-merge gate for supported local consumers.
3. shinyhub fleet plan¶
Show what apply would do, and change nothing:
plan recomputes the diff from live server state every time; it never
replays a saved plan. --detailed-exitcode makes it exit 2 when changes
are pending (useful in CI gates). --json emits a stable machine-readable
envelope; -q/--quiet collapses to the summary.
Before the diff is printed, plan rehearses every deploy the diff implies
against the server. For each app it would create, adopt, or redeploy, the
server runs the validators its deploy endpoint runs (bundle manifest policy,
schedule topology such as a data-producing [[schedule]] under grouped
isolation, replica and autoscale ceilings, tier compatibility, colocated
shared-data placement) on the bundle's shinyhub.toml and the app's
[app.config] settings, and changes nothing. The app type it judges is the
one the server will detect from the upload, so an entrypoint that arrives
through a [[bundle_file]] input or is excluded by .shinyhubignore counts
as it will at deploy. A rejection is printed with the deploy's own message and
the command exits 1, so a manifest the server would refuse fails in CI
before any app has been converged. A question the server did not answer (a
connection failure, a 5xx, a rejected credential) is reported with that
failure's own kind and exit code, never as a rejection. Servers that advertise
deploy_preflight answer the full check; servers with only
runtime_capabilities are asked the runtime-topology question for bundles
that declare producer or roll schedules; older servers are not asked.
Servers that advertise stale_uv_lock_refusal refuse an upload whose
uv.lock no longer records what its pyproject.toml declares; the plan runs
that check itself on the archive it would upload and reports it with the
upload's message, beside any rejection the server returned.
Config-only drift is not rehearsed, because the server validates that patch in
place before writing.
An operational setting omitted from both manifest layers is not drift and is
never changed by apply. When its stored value is an override rather than the
field's unset/default representation, plan nevertheless labels it
unmanaged in the app row. JSON schema version 3 exposes the same information
as apps[].unmanaged, with key, server, and default fields. This signal
is informational and does not change the action or detailed exit code. Schema
version 3 also exposes bundle_files[] in both plan and apply envelopes, with
the declared consumers and the source-bearing planned_consumers.
4. shinyhub fleet apply¶
Converge the fleet:
apply recomputes the same diff as plan (a prior plan is never replayed),
then for each app, in order: deploys changed apps, reconciles durable config
declared by either manifest (with [app.config] taking precedence), and stamps
ownership. Convergence is non-atomic and
continue-on-error: one failing app does not abort the rest, and the exit code
reflects the worst outcome. The server rehearsal plan performs runs first,
before the first mutation: a bundle any app's deploy would reject stops the
run with exit 1 and nothing changed, instead of leaving the apps ahead of it
converged and the rest untouched.
A config PATCH that changes the pool shape of a running app (replicas,
placement, resource limits, worker settings) is stored at once and applied in
the background. On servers that advertise the redeploy_outcome capability,
apply waits for that background redeploy and judges its recorded outcome
within the --health-timeout budget (seconds, default 120). completed, or a skip because nothing was running,
passes once the app is serving. partial, failed, or any other skip fails
the app with the server's reason and a shinyhub apps restart remedy, and the
fleet is not recorded in sync. fleet plan warns about any app whose last
settings redeploy did not apply. Against an older server, apply warns that the
change was not verified to reach the running pool.
Adopting an existing app does not create a deployment when its non-empty content digest and every declared setting already match. On servers that support fleet preconditions, apply transfers ownership with one conditional metadata update that asserts both the observed digest and prior owner. A source or declared-config difference still follows the normal convergence path. Older servers without precondition support conservatively redeploy during adoption rather than trusting a stale observation.
| Flag | Effect |
|---|---|
--dry-run |
Identical to fleet plan; makes no changes. |
--adopt |
Take ownership of in-scope apps that exist but are not yet fleet-managed. Without it, an un-owned app in scope is reported, not modified. |
--prune |
Delete fleet-owned apps that are absent from the manifest. This also removes their persistent data directory and all bundles. |
-y/--yes |
Skip the interactive destructive-action confirmation. --prune in a non-interactive shell requires --yes. |
--retries N |
Retry attempts after the first for deploys and transient config PATCH failures. Default 1 (so two attempts total). |
--wait-for-warm |
Ask the server to reconcile every persisted enabled deploy-triggered schedule, including on unchanged apps; wait for each exact durable obligation and require the authoritative producer state to match the current digest and command. |
--warm-timeout DURATION |
Per-app deadline shared by deploy-run waits, bundle-convergence checks, and stale-schedule recovery. Default 15 minutes. |
--verify-schedules |
Read-only: require cron freshness and authoritative producer convergence for every enabled schedule, and reject unresolved producer-write uncertainty even if its schedule was later disabled; never dispatches work. |
--refresh-stale |
Refresh persisted enabled schedules whose cron freshness is overdue, including on unchanged apps, then verify schedules. Joins an existing run or starts one when still stale. Requires server support; implies --verify-schedules. |
--verify-health |
Require every app, unchanged as well as changed, to be in a state it serves from without operator action: running, idle, or parked (hibernated after its idle timeout, suspended). A parked app wakes on its first request and passes in one poll; the gate never wakes it, so a broken bundle in a hibernated app surfaces on wake, not here. Intentionally stopped apps remain excluded. The post-deploy wait for changed apps is stricter and still requires a serving replica. |
--restart-after-warm |
After convergence repairs an already-running or unchanged app, cycle replicas so startup-loaded caches see the new data. Pre-start deploy/rollback producers already run before replica boot and do not cause a redundant cycle. Deliberately stopped apps stay stopped. |
--allow-unsafe-degraded-prune |
Permit prune against a server without precondition support, accepting a documented race (see Degraded mode). |
--json |
Emit the machine-readable result envelope. |
-q/--quiet |
Collapse to the summary plus result line. |
--provenance auto\|none |
Detect CI attribution (default) or intentionally omit it. |
To recover overdue data during apply:
Refreshes run sequentially within each app, bounded across apps by
--concurrency. The server rechecks freshness before starting work and joins
an already active run even when the schedule normally permits concurrent runs.
The CLI waits for that exact run to succeed and checks freshness and producer
compatibility again. Fresh or disabled schedules do not start work. Subsequent
cron ticks retain their configured overlap behavior.
--refresh-stale repairs overdue data; --wait-for-warm handles deployment
producer convergence. When combined, warm convergence must pass first. A
failed producer is not retried automatically. Add --restart-after-warm when
serving processes must reload startup-cached data after recovery; deliberately
stopped apps stay stopped. With --verify-health, health is checked after the
restart.
The warm deadline also bounds refresh requests and waiting. A CLI timeout does
not cancel an accepted server job. Ambiguous admission responses are not
retried automatically; inspect schedule status before retrying. --dry-run
never refreshes, and unsupported servers are rejected before apply mutations.
Repeated staleness still requires investigating the recurring scheduler or
producer failures; refreshing during apply does not change cron behavior.
An app slug is its durable URL identity and cannot be renamed in place. To
replace a fleet app with a new slug, edit the slug and run fleet plan, then
apply with --prune. The new app is created and the old fleet-owned app is
permanently deleted, including its persistent data and deployment history. If
the old and new slugs have the same declared friendly name, fleet apply
refuses to create the duplicate unless --prune is present.
Deployment provenance¶
On servers that advertise the fleet_provenance capability, fleet apply
registers one immutable run before it makes any changes. Every deployment and
audit event produced by that apply carries the run ID, so the dashboard can
link the live version and deployment history back to its source.
Current servers also allocate a monotonic sequence to each run, heartbeat it while the CLI is active, and record an immutable terminal result. This has two important failure semantics: an older overlapping apply cannot overwrite app convergence recorded by a newer run, and a process killed before it reports a result remains observably abandoned rather than looking successful. If the CLI cannot persist the terminal result after convergence, the apply itself exits partial and reports the run-recording failure instead of printing a false OK.
An identical apply is recorded as a new audit run and refreshes the app's
checked_at convergence timestamp, but it remains unchanged: it does not
advance the last application timestamp or provenance and does not create a new
release. Changing only which already-matching fields the manifest declares
does advance the declaration application, because the fleet ownership baseline
itself changed even though no live app value needed patching.
GitLab CI works without extra credentials. In auto mode the CLI reads
GitLab's predefined CI_PIPELINE_*, CI_JOB_*, CI_COMMIT_*, and
CI_MERGE_REQUEST_* variables. It stores a bounded snapshot containing the
pipeline, job, commit/ref, and optional merge request links; ShinyHub does not
call the GitLab API or store a GitLab token.
Provider-neutral overrides are available for other CI systems:
shinyhub fleet apply \
--source-provider buildkite \
--source-label "Production pipeline #812" \
--source-url "https://buildkite.example/pipelines/812" \
--revision "$GIT_COMMIT" \
--revision-ref "$GIT_BRANCH"
The full override set is --source-provider, --source-label,
--source-url, --job-label, --job-url, --revision, --revision-ref,
--revision-url, --change-label, and --change-url. Links must use HTTPS.
Use --provenance=none to opt out; it cannot be combined with explicit
provenance flags. Older servers simply receive the existing run header and
continue normally without attribution.
--prune is guarded: when prune candidates exist and prune will actually
run, an interactive run asks you to type the word prune to confirm.
Ownership¶
Every app a manifest manages is stamped managed_by = fleet:<fleet_id>.
This marker is what makes --prune safe: prune only ever deletes apps that
carry this fleet's marker and are absent from the manifest. An app with no
marker, or a different fleet's marker, is never pruned. The same predicate
drives the read-only dashboard surface.
Degraded mode¶
Fleet preconditions let apply patch config and prune against a precise
expected state (a compare-and-set). If the server does not advertise
precondition support, apply runs in degraded mode:
- Source deploys cannot be fenced against the planned digest and owner, so a concurrent writer can win the upload race. On a current server, a mismatch is a pre-mutation conflict and the bundle is not promoted.
- Config patches fall back to a re-GET immediately before the write (a smaller TOCTOU window, not zero).
--pruneis disabled unless--allow-unsafe-degraded-pruneis set, which accepts the documented race that an app could change between the read and the delete.
fleet plan and apply print a warning when degraded mode is in effect.
Exit codes¶
plan and apply share one exit-code contract. apply returns the highest
applicable code.
| Code | Meaning |
|---|---|
0 |
Success, or a report was printed (including --dry-run and a clean plan). |
1 |
Usage error, manifest validation failure, or a deploy the server rehearsed and rejected (before any change, in both plan and apply). |
2 |
plan --detailed-exitcode only: changes are pending. |
3 |
Transport or auth error (could not reach the server / not logged in). |
4 |
Partial: at least one app failed after retries. |
5 |
Conflicts: at least one app was skipped on a precondition 409. |
Prune candidates that are skipped because --prune was not passed do not
change the exit code; they are reported and the run is still 0.
shinyhub fleet status¶
status is the manifest-less companion to plan: it makes one read-only
GET and lists every app the server knows with its fleet ownership marker
and live deployment digest, no manifest required. It never makes changes and
returns 0 (overview printed) or 3 (transport / auth error). --json and
-q/--quiet behave as elsewhere. Use it for a quick ownership overview;
use plan when you want the diff against a specific manifest.
Dashboard surface¶
The dashboard reflects fleet ownership read-only. It is a status view, not a control surface; there is no apply, prune, or drift action in the UI.
- Ownership badge. Apps managed by a fleet show a
managed by fleet:<fleet_id>badge in the grid and on the app detail view, with a tooltip explaining the marker. - Segment filter. The apps view adds an All / Fleet-managed / Unmanaged selector (the choice is remembered across reloads) so an operator can see at a glance which apps are under fleet control.
- Live deployment digest. The app detail view shows the live content
digest of the running deployment. This is the digest of what is deployed
now, not a conformance signal: it does not by itself tell you whether the
app matches the manifest. Run
fleet planfor that. The UI labels the value accordingly so it is not mistaken for a drift indicator. - Deployment source. A quiet header strip identifies how the live version was produced. Fleet deploys link back to their pipeline, commit/ref, and optional merge request; dashboard, CLI, API, and rollback actions name their channel and authenticated operator. Deployment history repeats the source per release. Rows created before source tracking say only that no source was recorded, without implying that a manual deployment is an error.