Self-hosting a node
A Tabbify supervisor joins the private mesh, advertises the capabilities present on its host, and can run a runner for a selected application. Joining makes the host eligible; it does not move existing workloads or create additional copies automatically.
Apps deploy as CPU-only Firecracker microVMs built from OCI images. A host can
advertise firecracker when KVM is usable, docker when it can build images with
a daemon, and builder when it is designated as a build host.
Prerequisites
The mesh transport is userspace WireGuard over a TUN device. Every node needs:
NET_ADMINcapability and/dev/net/tun— to open the TUN device and join the mesh./dev/kvm— only if you want the supervisor to run apps (the Firecrackerfirecrackertag).- A Docker socket — only if you want this node to build images (the
dockertag).
A supervisor never escalates past what you hand it. A node with no /dev/kvm, no Docker daemon, and no builder designation joins the mesh with just the base supervisor tag — it's a mesh peer but advertises no capabilities. Add /dev/kvm and it advertises firecracker; give it a Docker daemon and it advertises docker; designate it a build host (SUPERVISOR_BUILDER) and it advertises builder. Those tags are what the node matches an app against when it places work.
Supported systemd Linux
Debian, Ubuntu, Arch, Fedora — x86_64 or aarch64:
curl -fsSL https://get.tabbify.io/supervisor | sudo TABBIFY_JOIN_TOKEN='<your-join-token>' sh
Get the join token from the admin panel: your network → Add a node (it mints
a token scoped to your network with the right tags baked in). The coordinator
validates it — without a token the node is rejected with a 401 and never
joins. The current managed production data plane is relay-only, so configure
TABBIFY_MESH_RELAY_ONLY=true for a host intended to join that environment:
curl -fsSL https://get.tabbify.io/supervisor \
| sudo TABBIFY_JOIN_TOKEN='<your-join-token>' TABBIFY_MESH_RELAY_ONLY=true sh
The installer resolves the latest release, verifies its sha256, lays down the
same versioned layout the self-update engine owns, auto-installs the build-host
tools (git, e2fsprogs, iproute2, iptables, busybox), fetches the
Firecracker guest kernel plus the firecracker/oras helpers (best-effort —
the node joins the mesh even without /dev/kvm, it just won't be able to boot
microVMs until KVM is present), installs the systemd units including the OTA
auto-update timer, and starts the node. With /dev/kvm present it advertises
firecracker and can build inside microVMs by default (docker-less). The last
lines tell you the node's overlay address and control API. Re-running it
upgrades.
NixOS: one rebuild
NixOS gets the declarative path — the module ships as a flake. Pick whichever matches your setup.
Flakes-based system — add the input and import the module in the host's modules:
inputs.tabbify.url = "github:tabbify-io/tabbify-service-supervisor";
imports = [ tabbify.nixosModules.node ];
Classic (non-flakes) configuration.nix — the simplest path, and it needs no experimental features: drop the module file in /etc/nixos/ and import it.
sudo curl -fsSL -o /etc/nixos/tabbify-node.nix \
https://raw.githubusercontent.com/tabbify-io/tabbify-service-supervisor/main/nixos/tabbify-node.nix
then add ./tabbify-node.nix to the existing imports list — don't create a second imports (a duplicate attribute fails evaluation):
imports = [
./hardware-configuration.nix
./tabbify-node.nix
];
Prefer to pull it from GitHub instead of vendoring the file? The
builtins.getFlakeone-liner works too, but only with flakes turned on — addnix.settings.experimental-features = [ "nix-command" "flakes" ];and pass--option experimental-features "nix-command flakes"on the first rebuild:imports = [ (builtins.getFlake "github:tabbify-io/tabbify-service-supervisor").nixosModules.node ];
Open tabbify-node.nix and set nodeName (the fleet display name); on an AMD box switch kvm-intel → kvm-amd. Then:
sudo nixos-rebuild switch
That's it. The module loads the tun and kvm-intel modules (switch to kvm-amd on AMD), installs the host tools the Firecracker runtime shells out to (firecracker, iproute2, e2fsprogs, busybox, oras), and wires four systemd units: tabbify-fetch bootstraps the supervisor binaries + a Firecracker guest kernel over anonymous HTTPS on first boot, tabbify-supervisor runs the node (Type=notify, Restart=on-failure, runs as root for TUN + KVM), tabbify-appsrv serves staged artifacts on :9000, and tabbify-update does a health-gated self-update on demand (systemctl start tabbify-update). No AWS account needed. One thing to supply: the join token — drop it in /etc/tabbify/supervisor.env (TABBIFY_JOIN_TOKEN=<jwt>, chmod 600) before the rebuild (keep it out of the Nix store, which is world-readable), grab it from the admin (your network → Add a node). Set nodeName for the fleet display name and use TABBIFY_MESH_RELAY_ONLY=true for the current managed production transport. The node then joins as an eligible target; an application runs there only when that host is selected.
Docker: one run
Same node, packaged. The lean image joins the mesh as a bare supervisor; you opt into capabilities by what you expose at launch:
docker run -d --name tbf-sup --restart=on-failure \
--device /dev/net/tun --cap-add NET_ADMIN \
-e TABBIFY_JOIN_TOKEN='<your-join-token>' \
-v tbf-state:/var/lib/tabbify \
tabbify-supervisor
The -e TABBIFY_JOIN_TOKEN is required (admin → your network → Add a node) — without it the coordinator rejects the node (401). Add -e TABBIFY_MESH_RELAY_ONLY=true for the current production transport. Add --device /dev/kvm (with the :fc image) to advertise the firecracker tag and run CPU microVMs, or mount /var/run/docker.sock plus --network host to advertise docker and build images. The coordinator is baked into the image, so there's nothing to point at; name the node with -e SUPERVISOR_NAME=edge-fra-1. The tbf-state volume holds the mesh identity (mesh-identity.json), so a restart reclaims the same ULA. Drop the volume and the node rejoins as a fresh peer.
Prefer a declarative manifest? tcli node up -f node.toml renders the docker run from a node.toml ([backend] restart, [capabilities] firecracker/docker, [resources] cpus/memory_mb); add --dry-run to print the command without launching.
Verify and operate
Once your node has joined, the public node API lists it in the mesh roster. The coordinator itself is a control-plane service that lives on the overlay and isn't a user endpoint — query the roster through api.tabbify.io instead, with your Tabbify API token:
curl -s https://api.tabbify.io/v1/supervisors \
-H "Authorization: Bearer $TABBIFY_TOKEN" \
| jq '.[] | {ula, display_name, tags}'
Your node appears with its overlay ULA and the capability tags it advertised. The supervisor's own control API binds the peer ULA ([my_ula]:8730), reachable only from inside the mesh — not host loopback. For a quick local sanity check you can run the image in loopback dev mode (no mesh):
docker run --rm -p 8730:8730 tabbify-supervisor --no-mesh --bind 0.0.0.0:8730
curl -s localhost:8730/health # {"status":"ok","firecracker":…,"docker":…,"ula":…}
Current limitations
Be clear-eyed about the current state. Joining is gated by a per-network join
token whose claims determine the network and tags; see auth.
Certificate coverage is not uniform across every platform hop. In the Docker
path supervisord is PID 1, so its death stops the container and its runners;
--restart provides cold local recovery. Firecracker needs /dev/kvm, and host
failure does not automatically relocate a workload. The systemd and NixOS paths
fetch the Firecracker kernel and helpers; the base Docker image carries them only
in the :fc variant. See the deploy pipeline.