Bundle manifest (shinyhub.toml)¶
A bundle may include a shinyhub.toml file at its root. The manifest is
optional - bundles without one deploy exactly as before - but when present it
is the canonical, declarative source of truth for the app's settings,
post-deploy hooks, and scheduled jobs.
Three sections are recognised: [app], [[hook]], and [[schedule]]. They
are independent; any combination (including none) is valid.
[app]
hibernate_timeout_minutes = 30
replicas = 2
max_sessions_per_replica = 10
[[hook]]
on = "post-deploy"
command = ["python", "scripts/migrate.py"]
timeout = "2m"
[[schedule]]
name = "nightly-refresh"
cron = "0 0 * * *"
cmd = "python helpers/fetch.py"
timeout_seconds = 600
Strict-mode parsing¶
Unknown top-level keys, unknown fields inside any section, and unknown
trigger values all fail the deploy at parse time with HTTP 400. A typo in
replicas (e.g. replcias) does not silently no-op - the operator sees the
error immediately. This is deliberate: declarative configuration that
silently drops values is worse than no declarative configuration.
A malformed manifest aborts the deploy before the new bundle replaces the running one; the previous deployment continues to serve traffic.
When each section is applied¶
For a stop-first deployment, deploy proceeds in this order:
- The bundle is uploaded, validated, and unzipped into a fresh version directory.
- Phase A -
[app]settings. Applied atomically to the database after the previous process is stopped and the proxy is deregistered, but before the new bundle boots. A failure here aborts the deploy with 400 (validation) or 500 (DB error); the app row is left untouched. - The new bundle's dependencies are installed (uv / renv).
[[hook]]blocks run sequentially in the bundle directory.- Deploy-triggered producers that are not satisfied for this exact candidate bundle run to durable success while the app remains stopped. Their bundle digest, command fingerprint, deployment, run, and publication generation are recorded before any consumer can start.
- Phase B -
[[schedule]]blocks reconcile atomically by name into the schedules table, and the exact declaration set is snapshotted with the deployment for rollback. The scheduler is reloaded so new cron expressions take effect immediately. - Producer state is revalidated under the publication fence. If a writer from a retiring server completed in the meantime, the candidate producer runs again. Only then are the new app processes started and proxy-registered.
- The deployment is promoted and its complete schedule convergence state is materialized.
- Phase C -
[access]group rules reconcile into the per-app group access table assource = manifest, preserving any manually-managed rules. Unlike schedules, this is declarative: a group removed from the manifest loses its manifest rule on the next deploy.
Multiplex and grouped apps can instead hand off with an unchanged parsed
manifest, no hooks, and reconciled app settings that already match the live app.
Comments and formatting are ignored by the comparison. Schedule and access
declarations are still reconciled; equality with the previous bundle does not
prove that their live rows have not drifted. A required deploy-time producer or
compatibility repair independently requires stop-first. A satisfied
first_deploy bootstrap can allow a code-only handoff; bundle_change requires
a producer run when the bundle digest changes. See redeploy without
interrupting the current version
for capacity and downtime fallback behavior.
Phase A or Phase B failure aborts the deploy before the new bundle starts. Phase B is transactional: the old declaration set remains intact rather than leaving a partly-applied schedule set. A failed candidate producer also fails closed; if it may have replaced shared data, compatibility quarantine prevents the previous consumer from being restarted against uncertain data. Phase C failure returns HTTP 500 with the bundle already live; re-deploying re-runs the declarative access reconcile.
Reloading the scheduler is a soft step: if the scheduler is not yet started (e.g. during early-startup deploys), the reload is skipped and the schedule rows are still written. The scheduler picks them up when it starts.
[app] - app-level settings¶
| Field | Type | Meaning |
|---|---|---|
name |
string 1..128 | Friendly display name shown on the dashboard card, the detail heading, and the launchpad tile. See [app] name and [app] description below. |
description |
string 0..280 | One-line description shown under the name. "" clears it. See [app] name and [app] description below. |
project |
string | Project slug grouping this app on the dashboard. Must match [a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?; "" ungroups. The project row is created automatically. Only the slug is settable here: a project's name, description and icon belong to the server (shinyhub projects set) or a fleet manifest's [[project]] block, because they describe a namespace shared by apps from many bundles. |
hibernate_timeout_minutes |
int | Idle minutes before the watcher hibernates the app. -1 inherits the global server default, 0 disables hibernation forever, and a positive value sets that many minutes (the same convention as shinyhub apps set --hibernate-timeout). |
replicas |
int ≥ 1 | Desired number of identical replica processes serving this app. Runtime surfaces report the actual replicas_running separately. See scaling. |
max_sessions_per_replica |
int 0..1000 | Per-replica admission cap for new cookieless sessions. 0 means "use the runtime default". |
render_seconds |
float 0..600 | CPU cost of one page render, used to pace admission so a burst queues on a wait page instead of stalling every session. 0 disables pacing. Applied live on deploy (no restart). See Render pacing. |
min_warm_replicas |
int 0..1000 | Minimum number of replica processes kept running when the app idles. Workers live inside a replica; this setting counts outer app processes, not [app.worker] workers. 0 (default) allows full hibernation. When set above 0, the watcher stops only enough replicas to reach this floor so the first post-idle request hits a warm process. If the stored replicas value is less than min_warm_replicas, the floor self-clamps to replicas. Absent key leaves the stored value unchanged (same declared-only semantics as replicas). Inert under [app.worker] isolation = "grouped" or "per_session" (elastic pools boot workers on demand and report idle with none running); the server accepts it and reports a manifest warning; use [app.worker] warm_spares there. See Pre-warming. |
command |
array of strings | Launch-command override. See [app] command below. |
startup_timeout_seconds |
int 1..3600 | Readiness deadline for deploy, wake, scale, rollback, and shinyhub run; default 120 seconds. Read at boot and not stored in the database. |
build_timeout_seconds |
int 30..7200 | Host-side uv/renv dependency-build deadline; default 900 seconds. Read at build time and inert for Docker runtimes. |
readiness_path |
absolute HTTP path | Path polled before a process becomes routable; default /. Queries and fragments are rejected. See Readiness. |
readiness_status |
int 100..599 | Require this exact response status. When omitted, any 2xx or 3xx response is healthy. See Readiness. |
identity_headers |
bool | Per-app identity-forwarding toggle. See [app] identity_headers below. |
usage_identity_mode |
disabled, unattributed, pseudonymous, or identified |
Usage-analytics identity override. It may be stricter than the hub policy, never more identifying. See Usage analytics. |
autoscale |
inline table | Per-app session-saturation autoscale policy. See [app] autoscale below. |
worker |
table | Elastic worker-isolation policy, including the warm-spare floor. See [app.worker] below. |
icon |
string | Single emoji app icon. See [app] icon below. |
All fields are optional. Omitted database-backed fields are left untouched: the manifest does not assert a complete stored state, so existing values set via the UI or CLI survive unless the manifest explicitly overrides them. Omitted boot-time fields use their documented platform defaults.
This bundle shinyhub.toml is the per-deploy layer. A fleet
manifest sits above it: when an app is fleet-managed, a key
declared in the fleet manifest's [app.config] is reconciled on every apply
and wins over the value set here. The full order is fleet manifest > bundle
shinyhub.toml > server default; see Config precedence.
Settings are applied in a single SQLite transaction. Shrinking replicas
removes the now-out-of-range rows from the replicas table in the same
transaction; no half-applied state is reachable.
The command, startup/build timeouts, and readiness fields are boot-time settings rather than database state. They travel with each bundle and are re-read for every process start, including local runs and rollbacks.
[app] readiness¶
By default, ShinyHub polls GET / without following redirects and accepts a
2xx or 3xx response. Apps whose root route is not a reliable health signal can
declare a dedicated path and, when useful, one exact status:
The contract is shared by deployed processes and shinyhub run, preventing a
local smoke test from passing under looser rules than production. Avoid checks
that depend on authentication or external services unless those dependencies
must genuinely block the app from receiving traffic.
[app] framework¶
Set framework = "fastapi" for app.py exporting app, or
framework = "plumber" for plumber.R. These managed launchers retain uv/renv
dependency preparation and default readiness to /openapi.json. The field is
read from the bundle on every boot, including local development and previews.
It is mutually exclusive with command. Omit it to retain existing app
inference. See Publish APIs.
[app] command¶
Override the platform's automatic launch-command inference. When set, the platform exec's this command directly (no shell) instead of detecting the app type and building its own invocation.
[app]
command = ["uv", "run", "streamlit", "run", "app.py",
"--server.port", "{port}", "--server.address", "{host}"]
Placeholders¶
The command is a template. Three tokens are substituted per replica at boot:
| Token | Substituted with |
|---|---|
{port} |
The replica's assigned TCP port. Each replica gets its own port. |
{host} |
The bind address the platform expects: 127.0.0.1 under the native runtime, 0.0.0.0 inside Docker containers. Never hardcode an address; use this placeholder so the command works correctly under both runtimes. |
{data_dir} |
The persistent data directory relative to the app's working directory. Resolves to data (a symlink the platform provisions). Use this instead of a hardcoded path to stay portable across app slugs and host layouts. |
The placeholder grammar is exactly {lowercase_word} (regex \{[a-z_]+\}).
Anything else that contains braces (${VAR}, {1..5}, {Key:) is passed
through unchanged. There is no escaping mechanism: a literal lowercase
{word} argument cannot be expressed in a command template.
Validation runs at deploy time and again at boot (which covers rollbacks to
bundles that were deployed before stricter rules). An unknown token such as
{prot} (likely a typo for {port}) fails the deploy with an error naming
the offending token rather than passing a silent mistyping through.
Semantics¶
- Type detection is skipped. A bundle with neither
app.pynorapp.Rbecomes deployable oncecommandis set. - Dependency sync is skipped. The platform does not run
uv syncorrenv::restore. To install Python dependencies, include auv runprefix with arequirements.txt(e.g.uv run --with-requirements requirements.txt python app.py ...) or manage dependencies in your own entrypoint. - Tracing auto-instrumentation is skipped. The
[tracing] autoflag and the fleet default have no effect on command-mode apps. Add theopentelemetry-instrumentwrapper explicitly in your command if you want instrumentation. - Health check is shared. The platform uses the same 2xx/3xx default and
optional
readiness_path/readiness_statuscontract as inferred apps. - The command versions with the bundle. Rolling back to an earlier
deployment boots the command that was in that deployment's
shinyhub.toml. - Commands are exec'd without a shell. No environment-variable expansion
happens in the command array. Use placeholders for the values the platform
controls (
{port},{host},{data_dir}); useshinyhub env setfor app-level env vars. - An unparseable manifest at boot is fatal. The platform does not fall back to type detection if the manifest is present but unreadable. This is intentional: silently booting the wrong server on a hand-edited bundle is worse than a clear error.
[app] identity_headers¶
Opt this app out of (or explicitly into) identity forwarding.
The field has tri-state semantics because it is stored as a nullable boolean:
| Value | Effect |
|---|---|
| absent (key not in manifest) | Inherit the global auth.identity_headers setting (the default). |
false |
Opt this app out. The proxy strips and does not inject X-Shinyhub-* headers for this app, regardless of the global setting. |
true |
Explicit opt-in. Equivalent to the absent case when the global setting is true; has no effect when the global setting is false. |
Removing the identity_headers key (or the entire [app] section) reverts
the app to inheriting the global default on the next deploy.
The global auth.identity_headers: false kill switch always wins. If the
operator has disabled identity forwarding globally, setting
identity_headers = true in a manifest has no effect. See
Identity Forwarding for the full semantics, header reference,
and JWT verification examples.
[app] autoscale¶
Declare the session-saturation autoscale policy so it travels with the bundle
and is reconciled on every deploy. Autoscale also requires the global
runtime.autoscale.enabled flag; see Autoscaling.
| Key | Type | Meaning |
|---|---|---|
enabled |
bool | Required. Turn the policy on or off. Still gated on the global runtime.autoscale.enabled flag. |
min_replicas |
int | Lower bound. Must be >= 1 when enabled. The effective floor is max(min_replicas, min_warm_replicas). |
max_replicas |
int | Steady-state upper bound. Must be >= min_replicas when enabled and 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-wide default target. |
The block is atomic: when present it writes the full policy (all four columns);
when absent the stored policy is left untouched, so a policy set with shinyhub
apps set --autoscale ... survives a deploy that does not declare one. enabled
must be stated explicitly - a block that omits it (for example only target) is
rejected, so an incomplete block can never silently persist an all-zero policy.
Bounds are range-checked 0..1000 even when disabled, so a later re-enable never
hits an out-of-range stored value. An unknown key inside the table fails the
deploy under strict-mode parsing.
[app.worker]¶
Declare how browser identities share worker processes. grouped and
per_session are single-node elastic pools; see the full
worker-isolation guide.
[app.worker]
isolation = "per_session"
max_workers = 30
warm_spares = 2
max_session_lifetime_secs = 3600
| Key | Type | Meaning |
|---|---|---|
isolation |
multiplex, grouped, or per_session |
Session-sharing model. multiplex is the default. |
grouped_size |
int >= 1 | Clients admitted to each worker in grouped mode. |
max_workers |
int >= 1 | Hard elastic worker ceiling for grouped and per_session. |
warm_spares |
int 0..max_workers |
Healthy workers kept pristine for new clients. With snapshot support they are frozen and memory-reclaimed; otherwise they remain running. Default 0. |
max_session_lifetime_secs |
int >= 0 | Absolute lifetime after a consumed worker is ready; 0 is unlimited. Waiting time as a pristine spare is not included. |
The block is reconciled as a unit when present and left untouched when absent.
Warm spares count toward max_workers and are never reused after serving a
client. Frozen spares resume the same process; this is not copy-on-write process
cloning.
A replica is an outer app process. Workers live inside that runtime pool:
min_warm_replicas counts replica processes, while [app.worker].max_workers
and warm_spares count demand-driven workers within an elastic app pool.
Under grouped or per_session isolation the pool boots workers on demand and
the app reports idle (healthy) with none running, so a min_warm_replicas
floor is accepted and stored but has no effect; the deploy response carries a
manifest warning (Note: in the CLI, manifest.warnings in the response) when a
manifest declares that combination. Use warm_spares to keep elastic workers
pre-booted.
[app] name and [app] description¶
Set the app's display metadata declaratively, so a bundle carries the label it is presented under instead of relying on someone typing it into the dashboard after the first deploy.
name is the friendly label rendered on the dashboard card, the app detail
heading, and the launchpad tile. It is not the slug: the slug is the URL
identifier (/app/<slug>/), is fixed at deploy time, and is unaffected by this
field. name is trimmed and must be 1..128 characters; an empty or
whitespace-only value is rejected rather than stored, because every surface
renders the name as the app's primary label and there is no sensible fallback.
description is the one-line subtitle shown under the name, trimmed and capped
at 280 characters. Unlike name, "" is a meaningful value: it clears the
description.
Both follow the same declared-only rule as the rest of the table: an absent key leaves the stored value alone, so a name set in the dashboard survives deploys from a manifest that stays silent. Once declared, every deploy reasserts the manifest's value over a rename made in the UI - declaring the key is what makes the manifest the owner.
Set the same values imperatively with shinyhub apps set <slug> --name "..."
--description "...".
[app] icon¶
Set the app's icon to a single emoji, declaratively.
The icon appears in ShinyHub's launchpad, app details, app switcher, lifecycle pages, and the browser tab when the running app does not provide a favicon of its own. An app-authored favicon always takes precedence. The emoji remains the tab icon rather than becoming title text; an app-authored browser title is also preserved.
The field states who owns the app's icon rather than encoding three mechanical outcomes:
| Manifest | Meaning |
|---|---|
icon = "..." |
Config owns this app's icon. |
| absent (key not in manifest) | Nobody is asserting ownership; leave whatever is stored. |
icon = "" |
Uploads own this app's icon; config stands down. |
Once the manifest declares an icon, every deploy reasserts it over an image
uploaded through the dashboard. The image bytes are retained, so icon = ""
in a later deploy brings the image back; removing the key entirely does not,
because absent means "leave alone". This matches the declared-only semantics
of every other field in this table, but is surprising if undocumented, since
an uploaded image is otherwise the only way to set an icon.
A deploy that shadows an uploaded image is reported back to the operator; see Deploy response for the wire field and sample output.
Sentinel: reset hibernate to default¶
TOML has no null literal, so the manifest uses -1 to mean "remove this
app's override and fall back to the server default":
Equivalent to shinyhub apps set --hibernate-timeout -1.
[[schedule]] - scheduled jobs¶
Each [[schedule]] block defines one cron-driven job. See
schedules for the full semantic model; the manifest
mirrors the CLI fields.
| Field | Required | Meaning |
|---|---|---|
name |
yes | Unique key within the app. Used to identify the schedule across re-deploys (upsert by name). |
cron |
yes | Standard 5-field cron expression. |
cmd |
one of | Shell-quoted command. Parsed with shell-words. |
cmd_json |
one of | TOML string containing a JSON array of argv. Use this when shell quoting is awkward. |
timeout_seconds |
no | Wall-clock cap before SIGTERM. Defaults to 3600. |
overlap |
no | skip (default), queue, or concurrent. |
missed |
no | skip (default) or run_once. |
disabled |
no | When true, the schedule row exists but the runner skips ticks. |
deploy_trigger |
no | never (default), first_deploy, or bundle_change. bundle_change requires the authoritative last writer to match the current content digest and canonical command; changing code behind an unchanged cmd therefore dispatches again. See Deploy-triggered runs. |
on_success |
no | none (default) or roll. A roll replaces serving replicas after a successful job so process-global data is re-imported. |
min_roll_interval |
no | Minimum duration between completed successful rolls, such as 1h. |
roll_fallback |
no | defer (default) retries when a surge cannot fit; restart accepts downtime and replaces the pool stop-first. Requires on_success = "roll". |
max_defer_age |
no | Capacity-deferral deadline such as 6h; 0 means unlimited. Requires on_success = "roll". |
run_on_register is intentionally no longer accepted. Use deploy_trigger =
"bundle_change" for data that must track producer code, or first_deploy for
a true one-time bootstrap. On upgrade, existing persisted schedules receive
never because the former manifest-only setting cannot be recovered from the
database; reapply the manifest with an explicit policy.
Schedules that declare producer semantics (deploy_trigger != "never" or
on_success = "roll") currently require native app placement and effective
worker_isolation = "multiplex". Native multiplex child processes have durable
replica identities and inherit the physical publication fence across a control-plane crash.
Docker, remote-worker, Fargate, and ECS launches can be accepted before their
runtime handle is durably observable, so the server rejects those producer
topologies rather than claiming a failover guarantee it cannot prove. Elastic
grouped and per_session workers are also rejected until their identities
survive owner failover. The server revalidates inherited isolation at startup
and at the physical producer boundary, so changing the fleet default cannot
silently bypass this contract.
Exactly one of cmd or cmd_json is required. Both empty or both set is
a parse error.
[[schedule]]
name = "build-cache"
cron = "*/15 * * * *"
cmd_json = '["python", "-m", "myapp.refresh", "--quiet"]'
timeout_seconds = 120
overlap = "skip"
on_success = "roll"
roll_fallback = "restart"
max_defer_age = "6h"
Upsert semantics¶
Schedules are matched by (app_id, name). The first deploy with a given
name creates the schedule (audit: schedule_create); subsequent
deploys that include the same name update it in place, preserving its
ID and audit trail (audit: schedule_update).
Schedules not present in the manifest are left alone - removing a
[[schedule]] block does NOT delete the schedule from the database. Use
shinyhub schedule delete or the UI to remove a schedule. This avoids
silently dropping schedules that were created interactively while the
manifest was being authored.
Deploy-triggered runs¶
deploy_trigger makes the intended convergence policy explicit. In particular,
bundle_change is keyed by producer bundle rather than lifetime success:
neverdoes not dispatch on deploy.first_deploydispatches until the schedule has one successful run.bundle_changedispatches until the authoritative last writer matches the app's current content digest and canonical producer command. Historical successes are not reused after a different producer publishes; rolling back therefore produces again unless the current data already came from that exact producer identity.
Every admitted run snapshots deployment_id, app_version, content_digest,
the canonical command, and its fingerprint, and executes that immutable bundle
even if another deploy arrives while it is queued. Active bundle directories
are pinned against cleanup. Run history and schedule-list responses expose the
same provenance. A successful writer atomically replaces the schedule's
authoritative producer state outside bounded run history. If an older run
finishes last, the server observes that it became the physical last writer and
reopens the current obligation. An on_success = "roll" activation is
superseded if a newer bundle becomes current before activation starts, so old
producer output cannot roll newer application code.
Promotion materializes durable obligations for every persisted enabled policy,
not just the schedules present in that upload's manifest. Admission failures
and server interruptions repair automatically. A producer process that really
runs and fails remains failed for diagnosis and requires
shinyhub schedule retry-convergence APP SCHEDULE (or a new desired producer
identity) before it runs again.
Deploy-triggered runs are a candidate-bundle startup barrier. The server
prepares the candidate environment, confirms every previous consumer has
physically stopped, runs every
unsatisfied producer synchronously against that immutable bundle, atomically
commits the complete schedule declaration batch, and only then starts candidate
consumers. Schedule admissions remain fenced through promotion; an interrupted
publication or declaration commit leaves the app stopped instead of recovering
older consumers against incompatible data or commands. Each promoted deployment
also retains an immutable snapshot of its effective schedule declarations;
rollback restores that exact snapshot and follows the same producer-before-
consumer ordering. A response marks convergence proven before consumer boot
with prestart: true, whether the authoritative producer ran in that request or
was already satisfied on entry.
Use --wait-for-warm to reconcile and require persisted policy to be satisfied
for the current producer identity, including on unchanged fleet retries. It
waits for the exact durable obligation and then rechecks the authoritative last
writer. --restart-after-warm cycles replicas only when convergence was repaired
after those replicas were already running; a pre-start deploy run does not need
another cycle.
The imperative equivalent is shinyhub schedule add --deploy-trigger
bundle_change; add --follow to stream a dispatched run.
[[hook]] - deploy lifecycle hooks¶
| Field | Required | Meaning |
|---|---|---|
on |
yes | Trigger point. Only post-deploy is supported. |
command |
yes | argv to exec. First element is resolved against the bundle's PATH. |
timeout |
no | Wall-clock cap. Defaults to 5 minutes. Accepts Go duration syntax (30s, 2m, 1h). |
Hooks run sequentially in the order they appear in the manifest. The first failing hook aborts the deploy - subsequent hooks do not run, and the new bundle does not start.
Stdout and stderr are merged into the version's deploy-hooks.log. Each hook
also writes start and completion/failure records with its duration and exit
status, so a quiet successful command is still distinguishable from a hook that
was never declared.
[[hook]]
on = "post-deploy"
command = ["python", "scripts/migrate.py"]
timeout = "2m"
[[hook]]
on = "post-deploy"
command = ["python", "scripts/seed.py"]
Hooks inherit the app's environment (including secrets injected via
shinyhub env set), but not PORT (which is per-replica and only set
when an app process starts).
Hooks run after the dependency build and before any app process starts, for
every worker-isolation mode: a grouped or per_session app gets the same
preparation as a multiplex one, even though its workers spawn on demand later.
Hooks run when a bundle is promoted, not every time it starts. Deploying, rolling forward, and changing an app's env vars all promote and therefore run them. Restarting, rolling back, scaling, and the automatic recovery after a failed deploy re-activate a bundle that already served, so they do not: your hooks already ran for it, and nothing guarantees a second run is safe. A restart is therefore not a way to re-run a hook - deploy again for that.
The other case where a declared hook does not run is a container runtime, where dependencies are installed inside the image and the host has no view of the app's environment. That skip is reported: the deploy tells you how many hooks it did not run, so bake those steps into your image entrypoint instead.
Because hooks are skipped on those paths, whatever they produce has to survive alongside the bundle. Write generated assets into the bundle directory (they are pruned with their version) or the persistent app data directory - not to a scratch location a host reboot can clear, which would leave a restarted app without them.
[access] - per-app group access rules¶
Declare which IdP groups may view or manage this app. Groups come from the
OIDC groups claim or the forward-auth groups header (see the auth docs).
[access]
viewer_groups = ["finance", "analysts"] # granted the viewer role
manager_groups = ["finance-leads"] # granted the manager role
| Field | Type | Meaning |
|---|---|---|
viewer_groups |
list of strings | groups granted viewer access to this app |
manager_groups |
list of strings | groups granted manager access to this app |
Semantics:
- Declarative. On every deploy, the manifest's
source = manifestgroup rules are reconciled to exactly the[access]block. Removing a group (or the whole block) deletes its manifest rule on the next deploy. - Manual rules win. Rules added through the UI / API / CLI (
shinyhub apps access group-grant) aresource = manualand are never modified or deleted by a manifest reconcile. If the manifest names a group that already has a manual rule, the manifest entry is skipped (reported withskipped: truein the deploy response) and the manual rule stands. - Manager wins on overlap. A group listed in both
viewer_groupsandmanager_groupsis grantedmanager. - Additive. Group access grants access; it does not restrict a
publicorsharedapp. - Group names must be non-empty (validated at parse time).
Idempotency¶
Re-deploying the same bundle yields the same state:
[app]settings are deterministic - applying twice with the same values is a no-op aside from audit-event noise.[[schedule]]upserts by name - IDs are stable across deploys; cron or command changes update the row in place.[[hook]]blocks run every deploy; they are expected to be idempotent (e.g.migrate.pyshould handle "already migrated").[access]reconciles to exactly the declared groups each deploy; re-applying the same block is a no-op, and manual rules are always preserved.
Audit events¶
Manifest application emits the same audit events as the equivalent UI/API actions:
| Action | Recorded when |
|---|---|
update_app |
Phase A changed at least one [app] field. |
schedule_create |
First time a [[schedule]] with this name is seen for this app. |
schedule_update |
Subsequent deploys that mention an existing schedule. |
reconcile_group_access |
Phase C reconciled at least one [access] group rule. |
Hook executions are logged into the deploy log but do not emit per-hook
audit events. The overall deploy is recorded as app_deploy.
Deploy response¶
When a manifest was applied, the JSON response from POST /api/apps/:slug/deploy
includes a manifest field summarising what landed. The CLI uses this to
print confirmation lines after Deployed ...:
Deployed myapp (deployment #4)
URL: https://hub.example.com/app/myapp/
Applied [app] settings: max_sessions_per_replica=10; replicas=2
Note: [app] icon "🚀" is now shown instead of this app's uploaded image.
The image is still stored. Set icon = "" in shinyhub.toml to use it.
Schedules: 1 created, 0 updated
The wire shape:
{
"slug": "myapp",
"deploy_count": 4,
...other app fields...,
"manifest": {
"app": { "replicas": 2, "max_sessions_per_replica": 10 },
"icon_shadowed_upload": true,
"schedules": [
{ "name": "nightly", "action": "created", "schedule_id": 7, "deploy_run": { "run_id": 42 } }
],
"access_groups": [
{ "group": "finance", "role": "viewer" },
{ "group": "finance-leads", "role": "manager" },
{ "group": "ops", "role": "viewer", "skipped": true }
]
}
}
manifest.app is omitted when no [app] field changed; manifest.icon_shadowed_upload
is omitted unless the manifest's icon shadowed an uploaded image (present
and true only in that case); manifest.schedules is omitted when no
[[schedule]] was upserted; manifest.access_groups is omitted when the
[access] block is empty (each entry has skipped: true when a manual rule
preempted it); the entire manifest key is omitted when the bundle has no
shinyhub.toml. Top-level app fields stay in place so scripts that read
deploy_count keep working.
When hooks are present, the top-level response also reports
hooks_declared and hooks_run; hooks_skipped is present when a non-host
runtime could not execute declared hooks. This distinguishes "no hooks in the
deployed manifest" from "all declared hooks completed" without inspecting the
server log.
Each manifest schedule entry carries its schedule_id. The top-level
schedule_convergence array is the authoritative result for every persisted
deploy-triggered policy, including schedules not mentioned by this manifest;
each entry carries its obligation state and admitted run_id when present.
shinyhub fleet apply --json surfaces waited runs per app under a deploy_runs
array (with the run's status when --wait-for-warm waited).
With --restart-after-warm, fleet JSON also reports warm_restarted: true
when serving replicas were cycled after those runs succeeded.
For a genuinely read-only data audit, run shinyhub fleet verify. It makes
only GET requests, checks serving health plus the server-computed stale state
of every enabled schedule, rejects an enabled
deploy-trigger whose authoritative producer does not match current code, and
rejects unresolved producer-write uncertainty even if the writer was later
disabled. Disabling a declaration cannot undo a possible partial physical
write. The check never dispatches a run. stale describes proven data age independently
from refreshing, which reports a live run; a schedule can be both. JSON reports
issues in a single report with the exact current and producer bundle versions
and digests. Use fleet apply --verify-schedules only when you intend to
converge the manifest first and verify this postcondition afterwards.
Worked example¶
A small app that runs a nightly fetch, has tight scaling, and applies a schema migration on every deploy:
[app]
hibernate_timeout_minutes = 0
replicas = 2
max_sessions_per_replica = 20
[[hook]]
on = "post-deploy"
command = ["python", "scripts/migrate.py"]
timeout = "5m"
[[schedule]]
name = "nightly-fetch"
cron = "0 3 * * *"
cmd = "python helpers/fetch.py"
timeout_seconds = 900
overlap = "skip"
missed = "run_once"
Deploying this bundle:
- Sets the app to never-hibernate, 2 replicas, cap 20 (Phase A, atomic).
- Installs dependencies, runs
python scripts/migrate.py(post-deploy hook). - Atomically upserts and snapshots the
nightly-fetchschedule (Phase B); the scheduler reloads and the new cron takes effect immediately. - Revalidates deploy-triggered producer state under the publication fence, then starts the two replicas behind the proxy.
A second deploy with the same manifest produces no settings change (Phase
A is a no-op), re-runs the hook (migrations are expected to be
idempotent), and updates the schedule's updated_at timestamp without
changing its ID.