Properties2
| Type | Practice |
| Note created | Jun 29, 2026 |
A spindle is the CI/CD runner of Tangled, the git collaboration platform built on the AT Protocol. This is a guide to building and running one on a small cloud VM, using the Docker/Nixery backend and exposing it through a Cloudflare Tunnel (so it works without a dedicated domain or open inbound ports). The concrete example below is a GCP e2-medium box running Debian 11, but the steps generalize to any Linux host with Docker.
How a spindle works
A spindle is a small Go service that:
- Connects outbound to the AT Protocol Jetstream firehose and to knots (the git hosts), listening for
sh.tangled.spindle.memberandsh.tangled.reporecords. - Accepts inbound requests from the Tangled appview — it streams live pipeline logs over WebSocket and receives signed requests for secrets.
That inbound requirement is the key architectural fact: the spindle must be publicly reachable over HTTPS. Since the appview is served over HTTPS and connects via wss://, you can’t use a bare IP (no TLS cert for IPs, and mixed-content is blocked). The clean solution is a Cloudflare Tunnel: it gives a stable hostname with edge TLS and WebSocket support, and needs no open inbound firewall port.
Each pipeline step runs in a fresh container; state persists across steps in /tangled/workspace.
Two decisions before starting
- Execution backend.
nixery(Docker; images built on the fly via Nixery) vsmicrovm(needs a Linux host with KVM). Cloud VMs like GCP E2 machines have no nested virtualization, so the only viable choice there isnixery. Workflows must declareengine: nixery. - Networking. Public IP + reverse proxy (needs a domain + TLS) vs a tunnel. A Cloudflare Tunnel avoids buying a domain and avoids exposing a port.
Steps
1. Build the binary
The spindle lives in tangled.org/core. With Go and git installed:
git clone https://tangled.org/tangled.org/core ~/core
cd ~/core
go mod download
go build -o cmd/spindle/spindle ./cmd/spindle
sudo install -m 0755 cmd/spindle/spindle /usr/local/bin/spindleThe server runs via spindle run (config is entirely environment-variable driven).
2. Expose it through a Cloudflare Tunnel
Install cloudflared on the box, authorize it against your zone, then create and route a named tunnel pointing at the spindle’s local port:
cloudflared tunnel login # browser auth, pick the zone
cloudflared tunnel create ci-1
cloudflared tunnel route dns ci-1 ci-1.example.comWrite /etc/cloudflared/config.yml mapping the hostname to the local spindle, with a 404 fallback, then install it as a service:
tunnel: <TUNNEL_ID>
credentials-file: /etc/cloudflared/<TUNNEL_ID>.json
ingress:
- hostname: ci-1.example.com
service: http://127.0.0.1:6555
- service: http_status:404sudo cloudflared service install
sudo systemctl enable --now cloudflared3. Configure the spindle (read the source, not the docs)
Config is read from a prefixed struct in spindle/config/config.go. The public docs have at least one wrong variable name — trust the source. The essentials:
| Variable | Meaning |
|---|---|
SPINDLE_SERVER_HOSTNAME | Public hostname, e.g. ci-1.example.com (required) |
SPINDLE_SERVER_OWNER | Your did:plc:… (required) |
SPINDLE_SERVER_LISTEN_ADDR | Bind address — use 127.0.0.1:6555 so it’s reachable only via the tunnel |
SPINDLE_SERVER_DB_PATH | sqlite DB (also stores secrets by default) |
SPINDLE_NIXERY_PIPELINES_NIXERY | Nixery host — defaults to nixery.tangled.sh (not SPINDLE_PIPELINES_NIXERY as the docs claim) |
Resolve your DID from your handle with any AT Protocol resolver, e.g.:
curl -s "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.com"Secrets default to the sqlite provider, so OpenBao is optional.
4. Run as a systemd service
[Unit]
Description=Tangled Spindle CI runner
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
WorkingDirectory=/var/lib/spindle
Environment=SPINDLE_SERVER_HOSTNAME=ci-1.example.com
Environment=SPINDLE_SERVER_OWNER=did:plc:xxxxxxxx
Environment=SPINDLE_SERVER_LISTEN_ADDR=127.0.0.1:6555
Environment=SPINDLE_SERVER_DB_PATH=/var/lib/spindle/spindle.db
Environment=SPINDLE_SERVER_LOG_DIR=/var/log/spindle
ExecStart=/usr/local/bin/spindle run
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo mkdir -p /var/lib/spindle /var/log/spindle
sudo systemctl daemon-reload && sudo systemctl enable --now spindleGotchas
These cost the most time and are not in the docs:
- All engines are constructed at startup, even on the
nixerypath. Themicrovmengine’s config marksSPINDLE_MICROVM_PIPELINES_IMAGE_DIRas required, so the process refuses to boot without it. Set it to a stub directory you never use:Environment=SPINDLE_MICROVM_PIPELINES_IMAGE_DIR=/var/lib/spindle/microvm-images - The microvm engine calls
vsock.Listen()at init, which needs the vsock kernel modules even though you’ll never run a microVM. Load and persist them:printf "vsock\nvsock_loopback\nvhost_vsock\n" | sudo tee /etc/modules-load.d/vsock.conf sudo modprobe vsock vsock_loopback vhost_vsock - Default memory limits assume a big host. Per-job memory defaults to 6 GiB with 8 concurrent workflows — far more than a small VM has. On a ~4 GiB box, cap them or jobs get OOM-killed:
Environment=SPINDLE_SERVER_MAX_JOB_COUNT=1 Environment=SPINDLE_NIXERY_PIPELINES_MAX_JOB_MEMORY_MB=2048 Environment=SPINDLE_NIXERY_PIPELINES_MAX_CONCURRENT_WORKFLOWS=1 - The
did:webdocument 404 is harmless. The spindle’s identity isdid:web:<hostname>, but it only verifies inbound auth (resolving the caller’s DID and string-matching the audience) and never signs outbound requests, so nothing ever fetches its DID document.
Register and run a pipeline
Register the spindle by its hostname in Tangled’s settings — this writes a record under your owner DID that the running spindle picks up via Jetstream. Then add a workflow at .tangled/workflows/ci.yml in a repo. On the Nixery backend, dependencies is a map (not the flat list the repo’s own microvm examples use):
when:
- event: ["push", "pull_request"]
branch: main
engine: nixery
dependencies:
nixpkgs:
- go
- gcc
steps:
- name: build
command: |
go version
go build ./...The image auto-includes bash, git, coreutils, and nix, so a trivial smoke test can omit dependencies entirely. Push a commit and watch the run in the Tangled UI.
Verifying
curl -fsS https://ci-1.example.com/ # returns the spindle MOTD banner (200)
sudo journalctl -u spindle -f # owner registered, jetstream + firehose connectedNote on private repos
Worth knowing before adopting Tangled: all repos are public. The sh.tangled.repo lexicon has no visibility field and the repo firehose is explicitly public. Access control exists only for writes (collaborators), not reads. Private repos depend on AT Protocol’s planned permissioned data feature, which is still in early design.