The deploy pipeline
Tabbify takes an app from source to a running mesh peer through one path:
git push → GitHub Actions → the node's POST /v1/deploy. The node clones your
repo, builds it on a builder supervisor, pushes the resulting image to the
in-mesh registry, and deploys it as a Firecracker runner on
a UUID-deterministic mesh address. The node then proxies public
traffic to it at https://app.tabbify.io/app/<uuid>/.
Your laptop never builds or pushes anything — it only does a git push. See the
quickstart for the four-file walkthrough; this page is the
in-depth version.
The trigger: GitHub Actions
You don't talk to the node directly. The tabbify-io/deploy GitHub Action runs
on every push and drives the deploy for you:
- uses: actions/checkout@v4
- uses: tabbify-io/deploy@v1
with:
token: ${{ secrets.TABBIFY_TOKEN }}
The action installs tcli and runs tcli deploy --remote. Two pieces of auth
flow through it, and they are different things:
tokenis yourTABBIFY_TOKEN— the bearer credential the node checks onPOST /v1/deploy. Mint it in the console under Deploy tokens (pick a network → Manage tokens → Mint deploy token → Copy; shown once), then add it once as a repository secret.clone-tokendefaults to the Action's automatic${{ github.token }}— a per-run, repo-scoped token the node uses togit cloneyour repo. There is no GitHub App to install and no webhook to register; the clone token lives on the build host and never enters the build guest.
What the node does
tcli deploy --remote POSTs to /v1/deploy with the repo URL, git ref, clone
token, tenant, and (optionally) explicit deploy targets pulled from your
tabbify.toml. The node acknowledges with 202 {deploy_id} immediately and runs
the pipeline in a detached tokio::spawn:
1. resolve the in-mesh registry (registry ULA from the roster)
2. resolve a builder supervisor (from [build].builder, the request, or the roster)
3. resolve every deploy target to a supervisor ULA — fail fast if unresolvable
4. clone the repo at the pushed ref (using the clone token) and build an OCI image
5. push the image to the in-mesh Zot registry (/v2)
6. fan out the artifact ref to each target; each spawns a Firecracker runner
on derive_app_ula(uuid)
Because the response is async, you poll for the outcome:
GET /v1/deploy/<deploy_id>/status # 202 while pending, 200 with the result
The app UUID is deterministic. The CLI prefers an explicit --app-uuid, then
[app].id in tabbify.toml, and finally falls back to
uuid_v5(NAMESPACE_URL, repo_url) — so the same repo keeps its address (and its
data) across pushes. If the manifest has no [[deploy]] block, the node fans out
to every run-capable supervisor in the roster.
One runtime: Firecracker
Tabbify ships a single runtime. Every app is built from a Dockerfile into
an OCI image, converted to an ext4 rootfs, and booted as a Firecracker microVM
running the image's entrypoint. There is no runtime to choose: [build].kind is
always "docker" (the OCI image), and any legacy [runtime].type or
[[deploy]].runtime field in an older tabbify.toml parses fine but is ignored.
See Runtimes for the full story.
Targets are still capability-checked against each supervisor's roster tags
before any dispatch: a supervisor must carry the firecracker tag to receive a
runner, and a docker-only build host won't be handed an app it can't boot. What's
gone is the old multi-runtime selection complexity — a target now just resolves
to a supervisor ULA.
Deploying from your own terminal
The GitHub Action is just a wrapper. You can run the exact same pipeline from any
terminal with tcli:
tcli deploy --remote \
--repo-url https://github.com/tabbify-io/quickstart \
--ref main \
--clone-token "$GITHUB_TOKEN"
tcli reads TABBIFY_TOKEN for the node bearer auth and posts to
https://api.tabbify.io/v1/deploy by default. Or hit the endpoint directly with
your Tabbify API token:
curl -X POST -H "Authorization: Bearer $TABBIFY_TOKEN" \
https://api.tabbify.io/v1/deploy \
-d '{"repo_url":"https://github.com/tabbify-io/quickstart",
"git_ref":"main","tenant":"tabbify",
"app_uuid":"0191e7c2-1111-7222-8333-444455556666",
"clone_token":"<github-token>",
"manifest_toml":"[app]\nname=\"quickstart\"\n[build]\nkind=\"docker\"\n[runtime]\nport=8080\n[routes]\ndynamic_prefixes=[\"/\"]\n",
"targets":[{"supervisor":"thinkpad"}]}'
manifest_toml carries your raw tabbify.toml so the node applies [runtime]
(including port) and [routes] on spawn. Omit it and the app gets the
defaults (port 8080) — fine for an 8080 image, a crash-loop for anything else.
tcli deploy sends this for you; only a hand-rolled curl needs it explicit.
Per-target failures are collected into results and do not fail the call —
partial deploys are reported honestly. Only a build error, an unresolvable
builder or target, a capability mismatch, or a missing registry is fatal. See
the registry for the artifact store and auth for
how tokens are issued.
Where it lands: the runner
The pipeline terminates at a runner. supervisord is pure control plane: it
spawns a detached tabbify-runner, waits for its control socket to go healthy,
and hands back the app_ula. Because runners are detached, killing the
supervisor never kills the workload — a restart re-adopts living runners with no
blip. The address is metadata-free: derive_app_ula(uuid) is host-independent,
so an app keeps its ULA when it migrates between supervisors.
You can inspect the live mesh from the node:
curl -H "Authorization: Bearer $TABBIFY_TOKEN" \
https://api.tabbify.io/v1/topology
That topology view is for visibility only — addressing is always the UUID hash.
A supervisor also exposes POST /v1/apps/{uuid}/start for advanced or manual
control, but in the normal GitHub Actions flow the deploy pipeline starts runners
on the target supervisors for you; you never call it by hand.
Status: the full pipeline is verified live on AWS — git push →
GitHub Actions → tcli deploy --remote → /v1/deploy → builder supervisor →
mesh registry → Firecracker runner, reachable at https://app.tabbify.io/app/<uuid>/.
See the runtime for the runner lifecycle, routing
for the address scheme, and the CLI reference for tcli.