Spindle runbook #
Commands run from this checkout on the owner's Mac, unless a step names another location. Only the lead operates deployed systems. Steps marked (not yet drilled) need live verification.
Architecture and state #
knot (tangled.org) --events--> mill (Railway, spindle.demi.dev)
^ |
| | websocket /mill
v v
executor (home NixOS VM, nixery on Docker)
|
v
Railway S3 bucket
(also read by the mill)
The executor dials wss://spindle.demi.dev/mill. It needs no inbound internet ports.
| Piece | State and location |
|---|---|
| Mill | Volume mill-data at /data: spindle.db, spindle.db-wal, spindle.db-shm, spindle.db.lock, and hydrant/. The mill creates repos/ and logs/ on demand. |
| Identity | Owner's Mac: ~/.config/spindle/spindle-key.pem. Password manager: backup. Railway: SPINDLE_SIGNING_KEY_PEM. The entrypoint writes /run/spindle/key.pem. This runtime copy is not a backup. |
| Executor | VM spindle-exec-1: /var/lib/spindle/ holds SQLite, the event outbox and local job state. /var/lib/secrets/ holds the token and bucket credentials. The Nix store contains no secrets. |
| Bucket | Railway resource spindle-artifacts, region iad: workflow artifacts and logs. The actual S3 bucket name comes from the BUCKET reference variable. |
Operator context #
Replace the placeholders in commands with these values:
| Placeholder or resource | Value |
|---|---|
<project> |
5caa03b7-45a1-4867-9147-3d7325a53fc6 (project spindle) |
<env> |
7b5decec-ce78-45f0-b6c0-996bb87323a1 (production) |
<mill> |
e07b46fb-a540-4bed-a051-5760517043d4 (service mill) |
| Executor SSH | ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 |
| Current registration | home-1-rotation-1, labels linux and x86_64, 4 seats |
| Railway domain | mill-production-da13.up.railway.app |
| Custom domain | spindle.demi.dev. Namecheap CNAME spindle → 1hbtrau7.up.railway.app, TXT _railway-verify.spindle |
Do not touch other VMs or containers on the Unraid host (root@192.168.1.164).
Keep PORT=6555 and RAILWAY_DOCKERFILE_PATH=mill/Dockerfile in Railway. Railway healthchecks use PORT.
Run every mill CLI command as uid 10001 (spindle), with --db /data/spindle.db:
setpriv --reuid=spindle --regid=spindle --init-groups -- spindle mill executor list --db /data/spindle.db
Root can create files that the mill cannot use. The entrypoint changes ownership of /data, not its existing contents.
Health and connection checks #
-
Check the mill endpoints:
curl https://spindle.demi.dev/_health curl https://spindle.demi.dev/.well-known/did.jsonExpected: HTTP 200 and DID
did:web:spindle.demi.dev. -
List the registrations:
railway ssh -p <project> -e <env> -s <mill> -- 'setpriv --reuid=spindle --regid=spindle --init-groups -- spindle mill executor list --db /data/spindle.db'executor listshowsNAME CREATED EXPIRES LABELS, not live connections. It never shows tokens or hashes. -
Check the mill service logs in Railway for a fresh
executor connected node=home-1-rotation-1orexecutor reconnected node=home-1-rotation-1. After rotation, use the new registration name instead ofhome-1-rotation-1. -
Check the VM journal:
ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'journalctl -u spindle | grep "connected to mill"'The VM log uses its configured hostname,
spindle-exec-1, not the mill registration name. Check the timestamp after the operation. -
Run one pipeline on
demi.dev/pneumofrom its Pipelines page. Check that it finishes green and its logs and artifacts load.
Upgrade the pinned Tangled version #
Mill and executor must run the same version. The current pin is v1.20.0 (2096d06d).
-
Bump the tag in
mill/Dockerfileandexecutor/flake.nixin one PR. -
Regenerate
executor/flake.lockwithmise run executor:lock. -
Run
mise run checkandmise run mill:buildbefore deployment. -
Wait for running pipelines to finish before deployment.
-
Deploy the mill from this checkout:
railway up --detach -p <project> -e <env> -s <mill>Railway builds
mill/Dockerfile. The first cold build took about 19 minutes. Cached builds took less than one minute. -
Copy the executor configuration to the VM:
rsync -a --delete -e "ssh -i ~/.ssh/spindle_executor_ed25519" executor/ root@192.168.1.94:/root/spindle/executor/--deleteapplies only to/root/spindle/executor/. -
Build the executor system. Replace
<nixpkgs rev from executor/flake.lock>withnodes.nixpkgs.locked.revfrom that file.ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'cd /root/spindle && nix shell "github:NixOS/nixpkgs/<nixpkgs rev from executor/flake.lock>#git" -c nix build --no-update-lock-file --out-link /root/spindle/result-toplevel "path:/root/spindle/executor#nixosConfigurations.executor.config.system.build.toplevel"'The VM has no
git. The temporary shell supplies it for thegit+httpsinput. The first source build took about 15 minutes. -
Switch the executor system with the proven detached command:
ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'P=$(readlink -f /root/spindle/result-toplevel); systemd-run --unit=spindle-switch --collect --property=Type=oneshot --setenv=PATH=/run/current-system/sw/bin:/run/wrappers/bin /run/current-system/sw/bin/sh -c "nix-env -p /nix/var/nix/profiles/system --set $P && /nix/var/nix/profiles/system/bin/switch-to-configuration switch > /root/spindle/switch.log 2>&1; echo exit=\$? >> /root/spindle/switch.log"'This performs the build-and-switch operation normally handled by
nixos-rebuild switch. No directnixos-rebuildcommand is proven here.systemd-runprotects the switch from SSH disconnection during network changes. -
Read
/root/spindle/switch.logon the VM. Check that its final line isexit=0. -
Complete all health and connection checks, including one green pipeline.
Executor tokens #
Never use shell tracing (set -x), terminal copies, command arguments or logs for secret values.
token generate stores a hash in SQLite and writes the raw token to stdout. Always redirect or pipe that output.
railway ssh writes its banner to stderr, so stdout can carry a token directly to the VM.
Generate, add and deliver (proven in ENG-468) #
ENG-468 used home-1 for the initial registration. That name is now revoked; do not reuse it.
For an existing registration, use the rotation procedure instead.
-
Disable tracing and enable pipeline failure detection in the local Bash shell:
set +x set -o pipefail -
Generate, register and deliver the token without terminal output:
railway ssh -p <project> -e <env> -s <mill> -- 'set -eu; t=/run/spindle/home-1.token; as() { setpriv --reuid=spindle --regid=spindle --init-groups -- "$@"; }; as sh -c "umask 077; spindle mill executor token generate --db /data/spindle.db > $t"; as spindle mill executor add home-1 --db /data/spindle.db --token-file "$t" --label linux --label x86_64 >&2; cat "$t"; rm -f "$t"' 2>/dev/null \ | ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'umask 077; install -d -m 700 /var/lib/secrets; cat > /var/lib/secrets/spindle-executor.token; chmod 600 /var/lib/secrets/spindle-executor.token; wc -c < /var/lib/secrets/spindle-executor.token'Output is only the byte count. If the pipeline fails or the count is zero, stop before restarting the executor. The command suppresses Railway stderr, including registration errors. A failed registration must not count as successful delivery.
-
Restart the executor after successful delivery:
ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'systemctl restart spindle' -
Complete the health and connection checks.
token generate has no expiry by default. --ttl accepts a duration, for example 720h.
add accepts only a token that token generate stored. Reusing a name with another token fails. It does not replace the hash.
Reusing the same name and token updates its labels.
Rotate home-1-rotation-1 (drilled 2026-11-01, ENG-471) #
ENG-471 drilled home-1 → home-1-rotation-1 on 2026-11-01, 17:29–17:34 UTC; pipeline 3mzbdmqmve522 was green; home-1 was revoked.
This procedure saves the old token on the VM before delivery. The old registration stays valid until the new executor reconnects.
The example rotates home-1-rotation-1 to the new, unused name home-1-rotation-2. It does not change the VM hostname or Nix configuration.
The mill derives the identity from the token. The executor does not send its configured name in the handshake.
CAUTION: Do not reset the stream during a pipeline. The reset discards unacknowledged events and pending artifact records.
A new identity has no stream cursor. Reusing the old stream can cause gap in stream seqnos errors.
-
Pause new pipeline submissions and wait for running pipelines and their log and artifact uploads to finish.
-
Run
executor listfrom health and connection checks. Check thathome-1-rotation-1exists andhome-1-rotation-2does not. For later rotations, substitute the current name and a fresh, unused name throughout this procedure. -
Disable tracing and enable pipeline failure detection with the two shell commands from the initial delivery procedure. If a
.prevfile remains from an earlier attempt, complete rollback before continuing. Save the old token before step 4:ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'cp -p /var/lib/secrets/spindle-executor.token /var/lib/secrets/spindle-executor.token.prev'If the copy fails, stop before step 4.
-
Generate, register and deliver a new token. This adapts the proven delivery command to the new name:
railway ssh -p <project> -e <env> -s <mill> -- 'set -eu; t=/run/spindle/home-1-rotation-2.token; as() { setpriv --reuid=spindle --regid=spindle --init-groups -- "$@"; }; as sh -c "umask 077; spindle mill executor token generate --db /data/spindle.db > $t"; as spindle mill executor add home-1-rotation-2 --db /data/spindle.db --token-file "$t" --label linux --label x86_64 >&2; cat "$t"; rm -f "$t"' 2>/dev/null \ | ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'umask 077; install -d -m 700 /var/lib/secrets; cat > /var/lib/secrets/spindle-executor.token; chmod 600 /var/lib/secrets/spindle-executor.token; wc -c < /var/lib/secrets/spindle-executor.token'The expected byte count is 44: 43 base64url characters plus a newline. If the pipeline fails or the count differs, complete rollback before retrying. Do not revoke the old registration. The saved
.prevfile preserves its token even if the executor restarts. -
Stop the idle executor, reset its stream, and restart it:
ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'systemctl stop spindle && spindle executor reset-stream --db /var/lib/spindle/spindle.db && systemctl restart spindle'If the command fails, complete rollback before retrying.
-
Check for a fresh mill log entry
executor connected node=home-1-rotation-2and a fresh VMconnected to millentry. If either fresh entry is absent, complete rollback before revoking the old token. -
Run one pipeline and check that it finishes green, with readable logs and artifacts.
-
Revoke the old token by its registration name:
railway ssh -p <project> -e <env> -s <mill> -- 'setpriv --reuid=spindle --regid=spindle --init-groups -- spindle mill executor revoke home-1-rotation-1 --db /data/spindle.db'revokedeletes the token hash and its registration. It rejects future connections. It does not close an existing websocket. -
Run
executor listagain. Check that only the new registration remains for this VM. After the old registration is absent, remove the saved token:ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'rm /var/lib/secrets/spindle-executor.token.prev' -
Resume pipeline submissions and record the drill evidence in the issue.
Evidence: UTC timestamps, registration lists before and after, fresh connection log entries, the green pipeline URL, and the revoke exit status. Do not include tokens, token hashes, environment values or secret-file contents.
Roll back a failed rotation (not yet drilled) #
Do not revoke home-1-rotation-1. The saved token still authenticates as the old registration.
-
Restore the old token file without printing its contents:
ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'mv /var/lib/secrets/spindle-executor.token.prev /var/lib/secrets/spindle-executor.token' -
If step 5 stopped or restarted the executor, wait until all pipelines and their log and artifact uploads finish. Stop the idle executor, reset its stream, and restart it after restoring the file:
ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'systemctl stop spindle && spindle executor reset-stream --db /var/lib/spindle/spindle.db && systemctl restart spindle'The fresh epoch starts sequence numbers at 1 for the old identity. Check for fresh connection logs under
home-1-rotation-1on the mill andconnected to millon the VM. -
Run
executor listfrom health and connection checks. Ifhome-1-rotation-2exists, revoke that incomplete registration:railway ssh -p <project> -e <env> -s <mill> -- 'setpriv --reuid=spindle --regid=spindle --init-groups -- spindle mill executor revoke home-1-rotation-2 --db /data/spindle.db'If listing or revocation fails, stop and keep the restored token. Resolve the error before retrying.
-
Check that
home-1-rotation-1exists andhome-1-rotation-2does not withexecutor list. -
Start again from step 2 of the rotation procedure. Step 3 creates a new
.prevcopy before delivery.
Bucket credentials #
/var/lib/secrets/ is root-owned, mode 700. Both secret files are root-owned, mode 600.
spindle-executor.env contains AWS_ENDPOINT_URL, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, and SPINDLE_ARTIFACT_STORES_S3_BUCKET.
The ENG-468 delivery command copies the mill's bucket variables without terminal output:
railway ssh -p <project> -e <env> -s <mill> -- 'for k in AWS_ENDPOINT_URL AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_REGION SPINDLE_ARTIFACT_STORES_S3_BUCKET; do eval "v=\${$k-}"; printf "%s=%s\n" "$k" "$v"; done' 2>/dev/null \
| ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'umask 077; cat > /var/lib/secrets/spindle-executor.env; chmod 600 /var/lib/secrets/spindle-executor.env'
After successful delivery, restart spindle. Never read the environment file into terminal output.
The module's interactive spindle wrapper overrides the bucket with an empty value (ENG-482).
For a manual server run, set the bucket explicitly without printing credentials. The DB-only executor reset-stream command does not start the server.
Signing key #
The private key lives at ~/.config/spindle/spindle-key.pem on the owner's Mac (file 600, directory 700).
The owner keeps a backup in the password manager. Railway stores the key as SPINDLE_SIGNING_KEY_PEM, outside /data.
Current compressed public key (hex, safe to share):
02914161f399872c226091dfe526f977f86414d189f2ee8e30bb39696b8acf92a7
Restore the existing key (not yet drilled) #
-
Restore the password-manager attachment to
~/.config/spindle/spindle-key.pem, without terminal output. -
Set the directory mode to 700 and the file mode to 600.
-
Set the Railway variable through stdin:
railway variable set SPINDLE_SIGNING_KEY_PEM --stdin --skip-deploys -s <mill> -e <env> -p <project> < ~/.config/spindle/spindle-key.pem railway redeploy -y -p <project> -e <env> -s <mill> -
Complete the health and connection checks.
The same key preserves the identity. Volume backups do not replace this separate key backup.
Lost key (not yet drilled) #
-
If all copies are lost, generate a replacement P-256 key into the protected file, not stdout:
umask 077 mkdir -p ~/.config/spindle chmod 700 ~/.config/spindle openssl ecparam -name prime256v1 -genkey -noout -out ~/.config/spindle/spindle-key.pem chmod 600 ~/.config/spindle/spindle-key.pem -
Save the replacement in the password manager.
-
Set the Railway variable and redeploy with the two commands from the key restore procedure.
-
Remove and re-add
spindle.demi.devat tangled.org/settings/spindles. Tangled v1.20.0 has no “verify” button. The list entry is the registration. -
Complete the health and connection checks.
-
Record the new public key in this runbook.
A replacement key changes the spindle's signing identity, even though its hostname and DID remain the same.
Mill backups and restore #
SQLite uses WAL mode. Back up the whole /data volume, including its sidecars and hydrant/, not just spindle.db.
Railway volume backups do not include variables, the signing-key backup, VM secret files or bucket objects.
Enable backups — lead, once (not yet drilled) #
- Open Railway project
spindle→production→ servicemill→ Backups. - Check that the attached volume is
mill-data, mounted at/data. - Enable a daily backup schedule. Railway retains daily backups for six days.
- Create a manual backup before the first restore drill.
- Record the successful backup timestamp and schedule in the issue, without secret values.
Railway documents the UI, retention and restore behavior in Volume backups. Backup enablement and restore are not yet proven for this service.
Restore /data (not yet drilled) #
CAUTION: A restore replaces live state with the snapshot. Newer jobs, registrations and token revocations can be lost.
-
Pause pipeline submissions and wait for running jobs to finish.
-
Stop the executor with
systemctl stop spindleon the VM. -
Select the backup timestamp in the mill service's Backups tab, in the same project and environment.
-
Click Restore and review the staged changes under Details. Railway creates a new volume at the same mount path. It retains the old volume, unmounted, with its newer backups.
-
Check that the restored volume mounts at
/data, then click Deploy. The mill entrypoint must still run as uid 10001 after startup. The restore does not restore Railway variables. -
Inspect the restored file names and ownership without reading their contents:
railway ssh -p <project> -e <env> -s <mill> -- 'ls -la /data' -
Check the endpoints and registrations from health and connection checks.
-
Reconcile the registrations with rotations since the snapshot. Revoke credentials that the snapshot restored but the lead previously revoked. If the VM token postdates the snapshot, generate and deliver a fresh token under an unused registration name.
-
Reset the stopped executor's stream, then restart it:
ssh -i ~/.ssh/spindle_executor_ed25519 root@192.168.1.94 'spindle executor reset-stream --db /var/lib/spindle/spindle.db && systemctl restart spindle'The reset accepts loss of unacknowledged events and pending artifact records. It prevents gaps against the restored mill cursor. The source also exposes
mill executor reset-cursor, notreset-acked. Clearing only the mill cursor cannot recover events the executor already removed. -
Complete all health and connection checks, including one green pipeline with readable logs and artifacts.
-
Record the snapshot timestamp, recovery results and lost interval before resuming submissions.
Keep the original volume until recovery succeeds. Do not wipe it. Railway deletes all backups of a wiped volume. The bucket stays separate. A volume restore cannot recover missing bucket objects.
Outages #
| Symptom | Behavior and action |
|---|---|
| Home server, VM or home internet unavailable | The mill queues up to 100 pending workflows. It rejects placement beyond that limit. Running jobs fail after the 45-second reconnect grace. Restore the VM or network, then check fresh connection logs and retry failed pipelines. |
| VM reboot | ENG-468 observed reconnection in 24 seconds, without manual steps. Check the journal and a green pipeline after recovery. |
| Mill redeploy | Executors reconnect automatically. Check for executor reconnected in the mill log. A redeploy that exceeds the recovery window can still fail running jobs. |
| Healthcheck fails after deployment | Check PORT=6555, the Railway service logs, and the /data mount and ownership. A registration list alone does not prove that the executor is online. |
| Bucket error | Check that both processes use the same bucket and that the VM environment file has mode 600. Do not print credential values. |
For a long outage, switch a repo to hosted CI only after Tangled fixes the hosted quota:
- Open the repo's Settings → Pipelines.
- Select Hosted by Tangled.
- Run one nixery pipeline and check that it finishes green.
The current hosted quota is 4 GiB. Nixery requests 6 GiB. The fallback does not work while that mismatch remains.
LAN isolation is proven: job containers cannot reach 192.168.1.1, 192.168.1.164, or 192.168.1.94:22.
Public tangled.org and nixery.tangled.sh returned 200. Container DNS uses only 1.1.1.1. ENG-474 tracks a second resolver.
Cost #
Railway dashboard → workspace stoopkidddd's Projects → Usage shows the usage charges. Expected cost is about $5/month for the mill service, volume and bucket. Bucket storage costs $0.015/GB-month. Volume backups add incremental storage charges. The executor uses existing home hardware.
Source basis #
ENG-465/466/468 supplied the proven deployment, delivery and recovery observations.
Undrilled procedures use Tangled v1.20.0 source:
| Source | Operational rule |
|---|---|
cmd/spindle/main.go |
token generate, add, list, revoke, reset-cursor, and executor reset-stream flags and effects |
spindle/db/mill_tokens.go, spindle/db/db.go |
Same-name token replacement fails. Revocation deletes the hash and cascades to the registration. |
spindle/mill/handler.go |
Token-derived executor identity. Authentication occurs before websocket upgrade. |
spindle/mill/mill.go, spindle/mill/restore.go |
Stream sequence continuity, reconnect grace, queue limit and restored leases |
spindle/config/config.go, nix/modules/spindle.nix |
Runtime state paths, credential loading, signing-key format and executor restart behavior |