Bundle structure#
A bundle is a .tar.gz archive containing your Shiny app source. At
minimum it needs:
app.R(orui.R+server.R) — the Shiny application code
Optionally include dependency metadata in one of these formats (highest
priority first): manifest.json, renv.lock, or DESCRIPTION. If none
is present, Blockyard scans your scripts to discover dependencies
automatically.
Any additional files (data, assets, R scripts sourced by the app) are included automatically when you tar the directory.
Deploying with the CLI#
The by deploy command handles bundling, uploading, and optionally waiting
for the build — all in one step:
by deploy ./my-app --waitThe CLI auto-detects your entrypoint and dependency format, creates the tar archive, and uploads it. If the app does not exist yet it is created automatically.
Pinned vs. unpinned dependencies#
By default, if your app has a DESCRIPTION file, the deploy creates an
unpinned manifest — Blockyard resolves the latest compatible versions at
build time. To lock exact versions:
by deploy ./my-app --pin --waitThis runs renv::snapshot() locally (requires R and renv) and includes the
resulting lockfile in the bundle. You can also commit a renv.lock or
manifest.json directly in your app directory — the CLI will use it as-is.
Generating a manifest without deploying#
To inspect or commit the resolved manifest before deploying:
by init ./my-app
# writes my-app/manifest.jsonChoosing a name#
The app name defaults to the directory basename. Override it with --name:
by deploy ./my-app --name sales-dashboard --waitSee the CLI reference for the full flag list.
Deploying with the REST API#
If you prefer scripting with curl:
tar -czf bundle.tar.gz -C my-app .
curl -X POST "$BLOCKYARD/api/v1/apps/<app-name>/bundles" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @bundle.tar.gzThe upload returns 202 Accepted with a task_id. The server unpacks the
archive, resolves a manifest from the bundle contents, starts a build
container to restore dependencies via pak, and
streams logs to the task endpoint.
Bundles larger than max_bundle_size (default 100 MB) are rejected with
413 Payload Too Large.
Build process#
During the build:
- The bundle is unpacked and a manifest is resolved from its contents
- pak installs packages into a library directory inside a build container
- On success, the bundle is activated as the app’s current deployment
- On failure, the bundle is marked
failedand the previous active bundle (if any) remains unchanged
You can monitor the build by streaming task logs:
# With the CLI (the easiest way — streams build logs automatically)
by deploy ./my-app --wait
# With curl (use the task_id from the bundle upload response)
curl "$BLOCKYARD/api/v1/tasks/<task-id>/logs" \
-H "Authorization: Bearer $TOKEN"Note: by logs streams worker container logs (for a running app),
not build logs. Use by deploy --wait or the task logs endpoint to
follow the build.
Accessing the app#
Once the build completes, the app is accessible at:
http://<blockyard-host>/app/<app-name>/Blockyard spawns a worker container on the first request (cold start) and proxies HTTP and WebSocket traffic to it. A session cookie pins the user to the same worker for subsequent requests.
Reconnection on network interruptions#
If the browser’s WebSocket connection drops briefly (network blip, laptop
sleep), the proxy keeps the backend connection alive for up to ws_cache_ttl
(default 60 s). When the client reconnects, it resumes the same Shiny
session transparently — no page reload, no lost state. This works for all
apps with no code changes.
For disconnects longer than the cache TTL, the session is lost and the user sees Shiny’s “Disconnected” overlay. Apps whose outputs are purely determined by their inputs can opt into Shiny’s new-session reconnection to recover automatically:
server <- function(input, output, session) {
session$allowReconnect(TRUE)
# ...
}This is not safe for apps that store state in reactiveValues, count
actionButton presses, accept file uploads, or generate random values —
that state is lost when a new session starts.
Docker worker hardening#
Worker containers run with hardened defaults:
- All Linux capabilities dropped (
--cap-drop ALL) - No new privileges (
--security-opt no-new-privileges) - Read-only root filesystem — only
/tmp(tmpfs) and the bundle mount are writable - Per-worker bridge network — each worker gets its own isolated Docker network
- Cloud metadata blocked — requests to
169.254.169.254are dropped via iptables rules to prevent SSRF against cloud instance metadata
These settings are not configurable — they are always applied. For a comparison with the process backend and guidance on choosing between them, see Backend Security.
Updating an app#
To deploy a new version, run by deploy again (or upload another bundle via
the API). Once it builds successfully, it becomes the new active bundle.
To roll back to a previous bundle:
by bundles my-app # list available bundles
by rollback my-app <id> # activate a previous bundleOld bundles are automatically pruned based on bundle_retention (default
50 per app). The active bundle is never pruned.
Refreshing unpinned dependencies#
For apps deployed with a DESCRIPTION (unpinned), you can re-resolve
packages from repositories without uploading a new bundle:
by refresh my-appThis triggers a background build that pulls the latest compatible package
versions. Use by refresh my-app --rollback to revert to the previous set.
Automatic refresh#
You can schedule periodic dependency refreshes via the REST API using a standard 5-field cron expression:
curl -X PATCH "$BLOCKYARD/api/v1/apps/my-app" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"refresh_schedule": "0 3 * * 1"}'This example refreshes every Monday at 03:00. To remove the schedule, set
refresh_schedule to an empty string. See
PATCH /api/v1/apps/{id} for details.
Disabling an app#
You can temporarily take an app offline without deleting it:
by disable my-appDisabling an app ends all active sessions, drains running workers, and
returns 503 Service Unavailable for any proxy requests. To bring it back
online:
by enable my-app