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¶
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.- The new app processes are started and the proxy is re-registered.
- Phase B —
[[schedule]]blocks upsert by name into the schedules table. The scheduler is reloaded so the new cron expressions take effect immediately. - 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.
Phase A failure aborts the deploy (the new bundle never starts) with HTTP 400 (validation) or 500 (DB error). Phase B failure returns HTTP 500 but the new bundle is already durable and serving traffic — the schedule set may be incomplete; the next deploy converges because the upsert is idempotent. Phase C failure likewise returns HTTP 500 with the bundle already live; re-deploying re-runs the 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. 0 disables hibernation. -1 resets the field to the server default (the same convention as shinyhub apps set --hibernate-timeout -1). |
replicas |
int ≥ 1 | Number of identical replica processes serving this app. 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 replicas kept running when the app idles. 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). 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. |
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] 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 | Upper bound. Must be >= min_replicas when enabled and may not exceed the runtime max_replicas ceiling. |
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.
[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 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. |
run_on_register |
no | When true, fire this schedule once on first registration if the app has never had a successful run of it, warming the cache on a fresh deploy. Re-deploys of an already-warmed schedule do not re-fire. See First-fire on register. |
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"
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.
First-fire on register (run_on_register)¶
Setting run_on_register = true makes the platform fire the schedule once,
asynchronously, the first time it is registered on an app that has never had a
successful run of it. This warms the app's cache on a fresh deploy without
re-blocking every deploy the way a deploy-time [[hook]] would.
The gate is "has this schedule ever succeeded?": a brand-new schedule fires; a
schedule that has already succeeded is never re-fired by a re-deploy. A failed
first-fire is non-fatal (the deploy stays live and durable) and is re-attempted
on the next deploy until a run succeeds. A disabled schedule is never
first-fired. If a re-deploy arrives while a first-fire is still running, the gate
is still open (no success yet) and a second fire is dispatched, which the
schedule's overlap policy (default skip) records as skipped_overlap rather
than running the job twice.
By default the fire is fire-and-forget: the deploy returns immediately and the
run warms the cache in the background. Pass --wait-for-warm to shinyhub deploy
or shinyhub fleet apply to block until the run completes (within the deploy's
wait/health timeout); a genuine warm failure then exits non-zero, while a
skipped_overlap (another run is already warming the same schedule) is treated
as "in progress", not a failure. Waiting does not reload a replica that already
read the old cache at startup. For that application pattern, pass
--restart-after-warm instead; it implies the wait and cycles serving replicas
only after every first-fire succeeds. An app that was deliberately stopped
remains stopped and sees the warmed data on its next start. The imperative
shinyhub schedule add --run-on-register fires the same way and reports the
triggered run id (add --follow to stream it).
[[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, "first_fire": { "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 schedule entry carries its schedule_id; a first_fire object with the
dispatched run_id is present only when run_on_register fired a run on this
deploy. shinyhub fleet apply --json surfaces the same data per app under a
first_fires 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.
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). - Starts the two replicas behind the proxy.
- Upserts the
nightly-fetchschedule (Phase B); the scheduler reloads and the new cron takes effect immediately.
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.