Properties2
TypePractice
Note createdJun 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.member and sh.tangled.repo records.
  • 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) vs microvm (needs a Linux host with KVM). Cloud VMs like GCP E2 machines have no nested virtualization, so the only viable choice there is nixery. Workflows must declare engine: 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/spindle

The 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.com

Write /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:404
sudo cloudflared service install
sudo systemctl enable --now cloudflared

3. 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:

VariableMeaning
SPINDLE_SERVER_HOSTNAMEPublic hostname, e.g. ci-1.example.com (required)
SPINDLE_SERVER_OWNERYour did:plc:… (required)
SPINDLE_SERVER_LISTEN_ADDRBind address — use 127.0.0.1:6555 so it’s reachable only via the tunnel
SPINDLE_SERVER_DB_PATHsqlite DB (also stores secrets by default)
SPINDLE_NIXERY_PIPELINES_NIXERYNixery 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.target
sudo mkdir -p /var/lib/spindle /var/log/spindle
sudo systemctl daemon-reload && sudo systemctl enable --now spindle

Gotchas

These cost the most time and are not in the docs:

  • All engines are constructed at startup, even on the nixery path. The microvm engine’s config marks SPINDLE_MICROVM_PIPELINES_IMAGE_DIR as 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:web document 404 is harmless. The spindle’s identity is did: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 connected

Note 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.