tabbify.io
Documentation
Product directionEDGE BOX
ReferenceArchitecture

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_ADMIN capability 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 Firecracker firecracker tag).
  • A Docker socket — only if you want this node to build images (the docker tag).

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.getFlake one-liner works too, but only with flakes turned on — add nix.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.