this repo has no description
1---
2title: Tangled docs
3author: The Tangled Contributors
4date: 21 Sun, Dec 2025
5---
6
7# Introduction
8
9Tangled is a decentralized code hosting and collaboration
10platform. Every component of Tangled is open-source and
11self-hostable. [tangled.org](https://tangled.org) also
12provides hosting and CI services that are free to use.
13
14There are several models for decentralized code
15collaboration platforms, ranging from ActivityPub’s
16(Forgejo) federated model, to Radicle’s entirely P2P model.
17Our approach attempts to be the best of both worlds by
18adopting the AT Protocol—a protocol for building decentralized
19social applications with a central identity
20
21Our approach to this is the idea of “knots”. Knots are
22lightweight, headless servers that enable users to host Git
23repositories with ease. Knots are designed for either single
24or multi-tenant use which is perfect for self-hosting on a
25Raspberry Pi at home, or larger “community” servers. By
26default, Tangled provides managed knots where you can host
27your repositories for free.
28
29The appview at tangled.org acts as a consolidated "view"
30into the whole network, allowing users to access, clone and
31contribute to repositories hosted across different knots
32seamlessly.
33
34# Quick start guide
35
36## Login or sign up
37
38You can [login](https://tangled.org) by using your AT Protocol
39account. If you are unclear on what that means, simply head
40to the [signup](https://tangled.org/signup) page and create
41an account. By doing so, you will be choosing Tangled as
42your account provider (you will be granted a handle of the
43form `user.tngl.sh`).
44
45In the AT Protocol network, users are free to choose their account
46provider (known as a "Personal Data Service", or PDS), and
47login to applications that support AT accounts.
48
49You can think of it as "one account for all of the atmosphere"!
50
51If you already have an AT account (you may have one if you
52signed up to Bluesky, for example), you can login with the
53same handle on Tangled (so just use `user.bsky.social` on
54the login page).
55
56## Add an SSH key
57
58Once you are logged in, you can start creating repositories
59and pushing code. Tangled supports pushing git repositories
60over SSH.
61
62First, you'll need to generate an SSH key if you don't
63already have one:
64
65```bash
66ssh-keygen -t ed25519 -C "foo@bar.com"
67```
68
69When prompted, save the key to the default location
70(`~/.ssh/id_ed25519`) and optionally set a passphrase.
71
72Copy your public key to your clipboard:
73
74```bash
75# on X11
76cat ~/.ssh/id_ed25519.pub | xclip -sel c
77
78# on wayland
79cat ~/.ssh/id_ed25519.pub | wl-copy
80
81# on macos
82cat ~/.ssh/id_ed25519.pub | pbcopy
83```
84
85Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key',
86paste your public key, give it a descriptive name, and hit
87save.
88
89## Create a repository
90
91Once your SSH key is added, create your first repository:
92
931. Hit the green `+` icon on the topbar, and select
94 repository
952. Enter a repository name
963. Add a description
974. Choose a knotserver to host this repository on
985. Hit create
99
100Knots are self-hostable, lightweight Git servers that can
101host your repository. Unlike traditional code forges, your
102code can live on any server. Read the [Knots](TODO) section
103for more.
104
105## Configure SSH
106
107To ensure Git uses the correct SSH key and connects smoothly
108to Tangled, add this configuration to your `~/.ssh/config`
109file:
110
111```
112Host tangled.org
113 Hostname tangled.org
114 User git
115 IdentityFile ~/.ssh/id_ed25519
116 AddressFamily inet
117```
118
119This tells SSH to use your specific key when connecting to
120Tangled and prevents authentication issues if you have
121multiple SSH keys.
122
123Note that this configuration only works for knotservers that
124are hosted by tangled.org. If you use a custom knot, refer
125to the [Knots](TODO) section.
126
127## Push your first repository
128
129Initialize a new Git repository:
130
131```bash
132mkdir my-project
133cd my-project
134
135git init
136echo "# My Project" > README.md
137```
138
139Add some content and push!
140
141```bash
142git add README.md
143git commit -m "Initial commit"
144git remote add origin git@tangled.org:user.tngl.sh/my-project
145git push -u origin main
146```
147
148That's it! Your code is now hosted on Tangled.
149
150## Migrating an existing repository
151
152Moving your repositories from GitHub, GitLab, Bitbucket, or
153any other Git forge to Tangled is straightforward. You'll
154simply change your repository's remote URL. At the moment,
155Tangled does not have any tooling to migrate data such as
156GitHub issues or pull requests.
157
158First, create a new repository on tangled.org as described
159in the [Quick Start Guide](#create-a-repository).
160
161Navigate to your existing local repository:
162
163```bash
164cd /path/to/your/existing/repo
165```
166
167You can inspect your existing Git remote like so:
168
169```bash
170git remote -v
171```
172
173You'll see something like:
174
175```
176origin git@github.com:username/my-project (fetch)
177origin git@github.com:username/my-project (push)
178```
179
180Update the remote URL to point to tangled:
181
182```bash
183git remote set-url origin git@tangled.org:user.tngl.sh/my-project
184```
185
186Verify the change:
187
188```bash
189git remote -v
190```
191
192You should now see:
193
194```
195origin git@tangled.org:user.tngl.sh/my-project (fetch)
196origin git@tangled.org:user.tngl.sh/my-project (push)
197```
198
199Push all your branches and tags to Tangled:
200
201```bash
202git push -u origin --all
203git push -u origin --tags
204```
205
206Your repository is now migrated to Tangled! All commit
207history, branches, and tags have been preserved.
208
209## Mirroring a repository to Tangled
210
211If you want to maintain your repository on multiple forges
212simultaneously, for example, keeping your primary repository
213on GitHub while mirroring to Tangled for backup or
214redundancy, you can do so by adding multiple remotes.
215
216You can configure your local repository to push to both
217Tangled and, say, GitHub. You may already have the following
218setup:
219
220```
221$ git remote -v
222origin git@github.com:username/my-project (fetch)
223origin git@github.com:username/my-project (push)
224```
225
226Now add Tangled as an additional push URL to the same
227remote:
228
229```bash
230git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project
231```
232
233You also need to re-add the original URL as a push
234destination (Git replaces the push URL when you use `--add`
235the first time):
236
237```bash
238git remote set-url --add --push origin git@github.com:username/my-project
239```
240
241Verify your configuration:
242
243```
244$ git remote -v
245origin git@github.com:username/repo (fetch)
246origin git@tangled.org:username/my-project (push)
247origin git@github.com:username/repo (push)
248```
249
250Notice that there's one fetch URL (the primary remote) and
251two push URLs. Now, whenever you push, Git will
252automatically push to both remotes:
253
254```bash
255git push origin main
256```
257
258This single command pushes your `main` branch to both GitHub
259and Tangled simultaneously.
260
261To push all branches and tags:
262
263```bash
264git push origin --all
265git push origin --tags
266```
267
268If you prefer more control over which remote you push to,
269you can maintain separate remotes:
270
271```bash
272git remote add github git@github.com:username/my-project
273git remote add tangled git@tangled.org:username/my-project
274```
275
276Then push to each explicitly:
277
278```bash
279git push github main
280git push tangled main
281```
282
283# Knot self-hosting guide
284
285So you want to run your own knot server? Great! Here are a few prerequisites:
286
2871. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
2882. A (sub)domain name. People generally use `knot.example.com`.
2893. A valid SSL certificate for your domain.
290
291## NixOS
292
293Refer to the [knot
294module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix)
295for a full list of options. Sample configurations:
296
297- [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85)
298- [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25)
299
300## Docker
301
302Refer to
303[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker).
304Note that this is community maintained.
305
306## Manual setup
307
308First, clone this repository:
309
310```
311git clone https://tangled.org/@tangled.org/core
312```
313
314Then, build the `knot` CLI. This is the knot administration
315and operation tool. For the purpose of this guide, we're
316only concerned with these subcommands:
317
318 * `knot server`: the main knot server process, typically
319 run as a supervised service
320 * `knot guard`: handles role-based access control for git
321 over SSH (you'll never have to run this yourself)
322 * `knot keys`: fetches SSH keys associated with your knot;
323 we'll use this to generate the SSH
324 `AuthorizedKeysCommand`
325
326```
327cd core
328export CGO_ENABLED=1
329go build -o knot ./cmd/knot
330```
331
332Next, move the `knot` binary to a location owned by `root` --
333`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
334
335```
336sudo mv knot /usr/local/bin/knot
337sudo chown root:root /usr/local/bin/knot
338```
339
340This is necessary because SSH `AuthorizedKeysCommand` requires [really
341specific permissions](https://stackoverflow.com/a/27638306). The
342`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
343retrieve a user's public SSH keys dynamically for authentication. Let's
344set that up.
345
346```
347sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
348Match User git
349 AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
350 AuthorizedKeysCommandUser nobody
351EOF
352```
353
354Then, reload `sshd`:
355
356```
357sudo systemctl reload ssh
358```
359
360Next, create the `git` user. We'll use the `git` user's home directory
361to store repositories:
362
363```
364sudo adduser git
365```
366
367Create `/home/git/.knot.env` with the following, updating the values as
368necessary. The `KNOT_SERVER_OWNER` should be set to your
369DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
370
371```
372KNOT_REPO_SCAN_PATH=/home/git
373KNOT_SERVER_HOSTNAME=knot.example.com
374APPVIEW_ENDPOINT=https://tangled.org
375KNOT_SERVER_OWNER=did:plc:foobar
376KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
377KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
378```
379
380If you run a Linux distribution that uses systemd, you can use the provided
381service file to run the server. Copy
382[`knotserver.service`](/systemd/knotserver.service)
383to `/etc/systemd/system/`. Then, run:
384
385```
386systemctl enable knotserver
387systemctl start knotserver
388```
389
390The last step is to configure a reverse proxy like Nginx or Caddy to front your
391knot. Here's an example configuration for Nginx:
392
393```
394server {
395 listen 80;
396 listen [::]:80;
397 server_name knot.example.com;
398
399 location / {
400 proxy_pass http://localhost:5555;
401 proxy_set_header Host $host;
402 proxy_set_header X-Real-IP $remote_addr;
403 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
404 proxy_set_header X-Forwarded-Proto $scheme;
405 }
406
407 # wss endpoint for git events
408 location /events {
409 proxy_set_header X-Forwarded-For $remote_addr;
410 proxy_set_header Host $http_host;
411 proxy_set_header Upgrade websocket;
412 proxy_set_header Connection Upgrade;
413 proxy_pass http://localhost:5555;
414 }
415 # additional config for SSL/TLS go here.
416}
417
418```
419
420Remember to use Let's Encrypt or similar to procure a certificate for your
421knot domain.
422
423You should now have a running knot server! You can finalize
424your registration by hitting the `verify` button on the
425[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
426a record on your PDS to announce the existence of the knot.
427
428### Custom paths
429
430(This section applies to manual setup only. Docker users should edit the mounts
431in `docker-compose.yml` instead.)
432
433Right now, the database and repositories of your knot lives in `/home/git`. You
434can move these paths if you'd like to store them in another folder. Be careful
435when adjusting these paths:
436
437* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
438any possible side effects. Remember to restart it once you're done.
439* Make backups before moving in case something goes wrong.
440* Make sure the `git` user can read and write from the new paths.
441
442#### Database
443
444As an example, let's say the current database is at `/home/git/knotserver.db`,
445and we want to move it to `/home/git/database/knotserver.db`.
446
447Copy the current database to the new location. Make sure to copy the `.db-shm`
448and `.db-wal` files if they exist.
449
450```
451mkdir /home/git/database
452cp /home/git/knotserver.db* /home/git/database
453```
454
455In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
456the new file path (_not_ the directory):
457
458```
459KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
460```
461
462#### Repositories
463
464As an example, let's say the repositories are currently in `/home/git`, and we
465want to move them into `/home/git/repositories`.
466
467Create the new folder, then move the existing repositories (if there are any):
468
469```
470mkdir /home/git/repositories
471# move all DIDs into the new folder; these will vary for you!
472mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
473```
474
475In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
476to the new directory:
477
478```
479KNOT_REPO_SCAN_PATH=/home/git/repositories
480```
481
482Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
483repository path:
484
485```
486sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
487Match User git
488 AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
489 AuthorizedKeysCommandUser nobody
490EOF
491```
492
493Make sure to restart your SSH server!
494
495#### MOTD (message of the day)
496
497To configure the MOTD used ("Welcome to this knot!" by default), edit the
498`/home/git/motd` file:
499
500```
501printf "Hi from this knot!\n" > /home/git/motd
502```
503
504Note that you should add a newline at the end if setting a non-empty message
505since the knot won't do this for you.
506
507# Spindles
508
509## Pipelines
510
511Spindle workflows allow you to write CI/CD pipelines in a
512simple format. They're located in the `.tangled/workflows`
513directory at the root of your repository, and are defined
514using YAML.
515
516The fields are:
517
518- [Trigger](#trigger): A **required** field that defines
519 when a workflow should be triggered.
520- [Engine](#engine): A **required** field that defines which
521 engine a workflow should run on.
522- [Clone options](#clone-options): An **optional** field
523 that defines how the repository should be cloned.
524- [Dependencies](#dependencies): An **optional** field that
525 allows you to list dependencies you may need.
526- [Environment](#environment): An **optional** field that
527 allows you to define environment variables.
528- [Steps](#steps): An **optional** field that allows you to
529 define what steps should run in the workflow.
530
531### Trigger
532
533The first thing to add to a workflow is the trigger, which
534defines when a workflow runs. This is defined using a `when`
535field, which takes in a list of conditions. Each condition
536has the following fields:
537
538- `event`: This is a **required** field that defines when
539 your workflow should run. It's a list that can take one or
540 more of the following values:
541 - `push`: The workflow should run every time a commit is
542 pushed to the repository.
543 - `pull_request`: The workflow should run every time a
544 pull request is made or updated.
545 - `manual`: The workflow can be triggered manually.
546- `branch`: Defines which branches the workflow should run
547 for. If used with the `push` event, commits to the
548 branch(es) listed here will trigger the workflow. If used
549 with the `pull_request` event, updates to pull requests
550 targeting the branch(es) listed here will trigger the
551 workflow. This field has no effect with the `manual`
552 event. Supports glob patterns using `*` and `**` (e.g.,
553 `main`, `develop`, `release-*`). Either `branch` or `tag`
554 (or both) must be specified for `push` events.
555- `tag`: Defines which tags the workflow should run for.
556 Only used with the `push` event - when tags matching the
557 pattern(s) listed here are pushed, the workflow will
558 trigger. This field has no effect with `pull_request` or
559 `manual` events. Supports glob patterns using `*` and `**`
560 (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or
561 `tag` (or both) must be specified for `push` events.
562
563For example, if you'd like to define a workflow that runs
564when commits are pushed to the `main` and `develop`
565branches, or when pull requests that target the `main`
566branch are updated, or manually, you can do so with:
567
568```yaml
569when:
570 - event: ["push", "manual"]
571 branch: ["main", "develop"]
572 - event: ["pull_request"]
573 branch: ["main"]
574```
575
576You can also trigger workflows on tag pushes. For instance,
577to run a deployment workflow when tags matching `v*` are
578pushed:
579
580```yaml
581when:
582 - event: ["push"]
583 tag: ["v*"]
584```
585
586You can even combine branch and tag patterns in a single
587constraint (the workflow triggers if either matches):
588
589```yaml
590when:
591 - event: ["push"]
592 branch: ["main", "release-*"]
593 tag: ["v*", "stable"]
594```
595
596### Engine
597
598Next is the engine on which the workflow should run, defined
599using the **required** `engine` field. The currently
600supported engines are:
601
602- `nixery`: This uses an instance of
603 [Nixery](https://nixery.dev) to run steps, which allows
604 you to add [dependencies](#dependencies) from
605 Nixpkgs (https://github.com/NixOS/nixpkgs). You can
606 search for packages on https://search.nixos.org, and
607 there's a pretty good chance the package(s) you're looking
608 for will be there.
609
610Example:
611
612```yaml
613engine: "nixery"
614```
615
616### Clone options
617
618When a workflow starts, the first step is to clone the
619repository. You can customize this behavior using the
620**optional** `clone` field. It has the following fields:
621
622- `skip`: Setting this to `true` will skip cloning the
623 repository. This can be useful if your workflow is doing
624 something that doesn't require anything from the
625 repository itself. This is `false` by default.
626- `depth`: This sets the number of commits, or the "clone
627 depth", to fetch from the repository. For example, if you
628 set this to 2, the last 2 commits will be fetched. By
629 default, the depth is set to 1, meaning only the most
630 recent commit will be fetched, which is the commit that
631 triggered the workflow.
632- `submodules`: If you use Git submodules
633 (https://git-scm.com/book/en/v2/Git-Tools-Submodules)
634 in your repository, setting this field to `true` will
635 recursively fetch all submodules. This is `false` by
636 default.
637
638The default settings are:
639
640```yaml
641clone:
642 skip: false
643 depth: 1
644 submodules: false
645```
646
647### Dependencies
648
649Usually when you're running a workflow, you'll need
650additional dependencies. The `dependencies` field lets you
651define which dependencies to get, and from where. It's a
652key-value map, with the key being the registry to fetch
653dependencies from, and the value being the list of
654dependencies to fetch.
655
656Say you want to fetch Node.js and Go from `nixpkgs`, and a
657package called `my_pkg` you've made from your own registry
658at your repository at
659`https://tangled.org/@example.com/my_pkg`. You can define
660those dependencies like so:
661
662```yaml
663dependencies:
664 # nixpkgs
665 nixpkgs:
666 - nodejs
667 - go
668 # custom registry
669 git+https://tangled.org/@example.com/my_pkg:
670 - my_pkg
671```
672
673Now these dependencies are available to use in your
674workflow!
675
676### Environment
677
678The `environment` field allows you define environment
679variables that will be available throughout the entire
680workflow. **Do not put secrets here, these environment
681variables are visible to anyone viewing the repository. You
682can add secrets for pipelines in your repository's
683settings.**
684
685Example:
686
687```yaml
688environment:
689 GOOS: "linux"
690 GOARCH: "arm64"
691 NODE_ENV: "production"
692 MY_ENV_VAR: "MY_ENV_VALUE"
693```
694
695### Steps
696
697The `steps` field allows you to define what steps should run
698in the workflow. It's a list of step objects, each with the
699following fields:
700
701- `name`: This field allows you to give your step a name.
702 This name is visible in your workflow runs, and is used to
703 describe what the step is doing.
704- `command`: This field allows you to define a command to
705 run in that step. The step is run in a Bash shell, and the
706 logs from the command will be visible in the pipelines
707 page on the Tangled website. The
708 [dependencies](#dependencies) you added will be available
709 to use here.
710- `environment`: Similar to the global
711 [environment](#environment) config, this **optional**
712 field is a key-value map that allows you to set
713 environment variables for the step. **Do not put secrets
714 here, these environment variables are visible to anyone
715 viewing the repository. You can add secrets for pipelines
716 in your repository's settings.**
717
718Example:
719
720```yaml
721steps:
722 - name: "Build backend"
723 command: "go build"
724 environment:
725 GOOS: "darwin"
726 GOARCH: "arm64"
727 - name: "Build frontend"
728 command: "npm run build"
729 environment:
730 NODE_ENV: "production"
731```
732
733### Complete workflow
734
735```yaml
736# .tangled/workflows/build.yml
737
738when:
739 - event: ["push", "manual"]
740 branch: ["main", "develop"]
741 - event: ["pull_request"]
742 branch: ["main"]
743
744engine: "nixery"
745
746# using the default values
747clone:
748 skip: false
749 depth: 1
750 submodules: false
751
752dependencies:
753 # nixpkgs
754 nixpkgs:
755 - nodejs
756 - go
757 # custom registry
758 git+https://tangled.org/@example.com/my_pkg:
759 - my_pkg
760
761environment:
762 GOOS: "linux"
763 GOARCH: "arm64"
764 NODE_ENV: "production"
765 MY_ENV_VAR: "MY_ENV_VALUE"
766
767steps:
768 - name: "Build backend"
769 command: "go build"
770 environment:
771 GOOS: "darwin"
772 GOARCH: "arm64"
773 - name: "Build frontend"
774 command: "npm run build"
775 environment:
776 NODE_ENV: "production"
777```
778
779If you want another example of a workflow, you can look at
780the one [Tangled uses to build the
781project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).
782
783## Self-hosting guide
784
785### Prerequisites
786
787* Go
788* Docker (the only supported backend currently)
789
790### Configuration
791
792Spindle is configured using environment variables. The following environment variables are available:
793
794* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
795* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
796* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
797* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
798* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
799* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
800* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
801* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
802* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
803
804### Running spindle
805
8061. **Set the environment variables.** For example:
807
808 ```shell
809 export SPINDLE_SERVER_HOSTNAME="your-hostname"
810 export SPINDLE_SERVER_OWNER="your-did"
811 ```
812
8132. **Build the Spindle binary.**
814
815 ```shell
816 cd core
817 go mod download
818 go build -o cmd/spindle/spindle cmd/spindle/main.go
819 ```
820
8213. **Create the log directory.**
822
823 ```shell
824 sudo mkdir -p /var/log/spindle
825 sudo chown $USER:$USER -R /var/log/spindle
826 ```
827
8284. **Run the Spindle binary.**
829
830 ```shell
831 ./cmd/spindle/spindle
832 ```
833
834Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
835
836## Architecture
837
838Spindle is a small CI runner service. Here's a high-level overview of how it operates:
839
840* Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
841[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
842* When a new repo record comes through (typically when you add a spindle to a
843repo from the settings), spindle then resolves the underlying knot and
844subscribes to repo events (see:
845[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
846* The spindle engine then handles execution of the pipeline, with results and
847logs beamed on the spindle event stream over WebSocket
848
849### The engine
850
851At present, the only supported backend is Docker (and Podman, if Docker
852compatibility is enabled, so that `/run/docker.sock` is created). spindle
853executes each step in the pipeline in a fresh container, with state persisted
854across steps within the `/tangled/workspace` directory.
855
856The base image for the container is constructed on the fly using
857[Nixery](https://nixery.dev), which is handy for caching layers for frequently
858used packages.
859
860The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines).
861
862## Secrets with openbao
863
864This document covers setting up spindle to use OpenBao for secrets
865management via OpenBao Proxy instead of the default SQLite backend.
866
867### Overview
868
869Spindle now uses OpenBao Proxy for secrets management. The proxy handles
870authentication automatically using AppRole credentials, while spindle
871connects to the local proxy instead of directly to the OpenBao server.
872
873This approach provides better security, automatic token renewal, and
874simplified application code.
875
876### Installation
877
878Install OpenBao from Nixpkgs:
879
880```bash
881nix shell nixpkgs#openbao # for a local server
882```
883
884### Setup
885
886The setup process can is documented for both local development and production.
887
888#### Local development
889
890Start OpenBao in dev mode:
891
892```bash
893bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
894```
895
896This starts OpenBao on `http://localhost:8201` with a root token.
897
898Set up environment for bao CLI:
899
900```bash
901export BAO_ADDR=http://localhost:8200
902export BAO_TOKEN=root
903```
904
905#### Production
906
907You would typically use a systemd service with a
908configuration file. Refer to
909[@tangled.org/infra](https://tangled.org/@tangled.org/infra)
910for how this can be achieved using Nix.
911
912Then, initialize the bao server:
913
914```bash
915bao operator init -key-shares=1 -key-threshold=1
916```
917
918This will print out an unseal key and a root key. Save them
919somewhere (like a password manager). Then unseal the vault
920to begin setting it up:
921
922```bash
923bao operator unseal <unseal_key>
924```
925
926All steps below remain the same across both dev and
927production setups.
928
929#### Configure openbao server
930
931Create the spindle KV mount:
932
933```bash
934bao secrets enable -path=spindle -version=2 kv
935```
936
937Set up AppRole authentication and policy:
938
939Create a policy file `spindle-policy.hcl`:
940
941```hcl
942# Full access to spindle KV v2 data
943path "spindle/data/*" {
944 capabilities = ["create", "read", "update", "delete"]
945}
946
947# Access to metadata for listing and management
948path "spindle/metadata/*" {
949 capabilities = ["list", "read", "delete", "update"]
950}
951
952# Allow listing at root level
953path "spindle/" {
954 capabilities = ["list"]
955}
956
957# Required for connection testing and health checks
958path "auth/token/lookup-self" {
959 capabilities = ["read"]
960}
961```
962
963Apply the policy and create an AppRole:
964
965```bash
966bao policy write spindle-policy spindle-policy.hcl
967bao auth enable approle
968bao write auth/approle/role/spindle \
969 token_policies="spindle-policy" \
970 token_ttl=1h \
971 token_max_ttl=4h \
972 bind_secret_id=true \
973 secret_id_ttl=0 \
974 secret_id_num_uses=0
975```
976
977Get the credentials:
978
979```bash
980# Get role ID (static)
981ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
982
983# Generate secret ID
984SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
985
986echo "Role ID: $ROLE_ID"
987echo "Secret ID: $SECRET_ID"
988```
989
990#### Create proxy configuration
991
992Create the credential files:
993
994```bash
995# Create directory for OpenBao files
996mkdir -p /tmp/openbao
997
998# Save credentials
999echo "$ROLE_ID" > /tmp/openbao/role-id
1000echo "$SECRET_ID" > /tmp/openbao/secret-id
1001chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
1002```
1003
1004Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
1005
1006```hcl
1007# OpenBao server connection
1008vault {
1009 address = "http://localhost:8200"
1010}
1011
1012# Auto-Auth using AppRole
1013auto_auth {
1014 method "approle" {
1015 mount_path = "auth/approle"
1016 config = {
1017 role_id_file_path = "/tmp/openbao/role-id"
1018 secret_id_file_path = "/tmp/openbao/secret-id"
1019 }
1020 }
1021
1022 # Optional: write token to file for debugging
1023 sink "file" {
1024 config = {
1025 path = "/tmp/openbao/token"
1026 mode = 0640
1027 }
1028 }
1029}
1030
1031# Proxy listener for spindle
1032listener "tcp" {
1033 address = "127.0.0.1:8201"
1034 tls_disable = true
1035}
1036
1037# Enable API proxy with auto-auth token
1038api_proxy {
1039 use_auto_auth_token = true
1040}
1041
1042# Enable response caching
1043cache {
1044 use_auto_auth_token = true
1045}
1046
1047# Logging
1048log_level = "info"
1049```
1050
1051#### Start the proxy
1052
1053Start OpenBao Proxy:
1054
1055```bash
1056bao proxy -config=/tmp/openbao/proxy.hcl
1057```
1058
1059The proxy will authenticate with OpenBao and start listening on
1060`127.0.0.1:8201`.
1061
1062#### Configure spindle
1063
1064Set these environment variables for spindle:
1065
1066```bash
1067export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
1068export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
1069export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
1070```
1071
1072On startup, spindle will now connect to the local proxy,
1073which handles all authentication automatically.
1074
1075### Production setup for proxy
1076
1077For production, you'll want to run the proxy as a service:
1078
1079Place your production configuration in
1080`/etc/openbao/proxy.hcl` with proper TLS settings for the
1081vault connection.
1082
1083### Verifying setup
1084
1085Test the proxy directly:
1086
1087```bash
1088# Check proxy health
1089curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
1090
1091# Test token lookup through proxy
1092curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
1093```
1094
1095Test OpenBao operations through the server:
1096
1097```bash
1098# List all secrets
1099bao kv list spindle/
1100
1101# Add a test secret via the spindle API, then check it exists
1102bao kv list spindle/repos/
1103
1104# Get a specific secret
1105bao kv get spindle/repos/your_repo_path/SECRET_NAME
1106```
1107
1108### How it works
1109
1110- Spindle connects to OpenBao Proxy on localhost (typically
1111 port 8200 or 8201)
1112- The proxy authenticates with OpenBao using AppRole
1113 credentials
1114- All spindle requests go through the proxy, which injects
1115 authentication tokens
1116- Secrets are stored at
1117 `spindle/repos/{sanitized_repo_path}/{secret_key}`
1118- Repository paths like `did:plc:alice/myrepo` become
1119 `did_plc_alice_myrepo`
1120- The proxy handles all token renewal automatically
1121- Spindle no longer manages tokens or authentication
1122 directly
1123
1124### Troubleshooting
1125
1126**Connection refused**: Check that the OpenBao Proxy is
1127running and listening on the configured address.
1128
1129**403 errors**: Verify the AppRole credentials are correct
1130and the policy has the necessary permissions.
1131
1132**404 route errors**: The spindle KV mount probably doesn't
1133exist—run the mount creation step again.
1134
1135**Proxy authentication failures**: Check the proxy logs and
1136verify the role-id and secret-id files are readable and
1137contain valid credentials.
1138
1139**Secret not found after writing**: This can indicate policy
1140permission issues. Verify the policy includes both
1141`spindle/data/*` and `spindle/metadata/*` paths with
1142appropriate capabilities.
1143
1144Check proxy logs:
1145
1146```bash
1147# If running as systemd service
1148journalctl -u openbao-proxy -f
1149
1150# If running directly, check the console output
1151```
1152
1153Test AppRole authentication manually:
1154
1155```bash
1156bao write auth/approle/login \
1157 role_id="$(cat /tmp/openbao/role-id)" \
1158 secret_id="$(cat /tmp/openbao/secret-id)"
1159```
1160
1161# Migrating knots and spindles
1162
1163Sometimes, non-backwards compatible changes are made to the
1164knot/spindle XRPC APIs. If you host a knot or a spindle, you
1165will need to follow this guide to upgrade. Typically, this
1166only requires you to deploy the newest version.
1167
1168This document is laid out in reverse-chronological order.
1169Newer migration guides are listed first, and older guides
1170are further down the page.
1171
1172## Upgrading from v1.8.x
1173
1174After v1.8.2, the HTTP API for knots and spindles has been
1175deprecated and replaced with XRPC. Repositories on outdated
1176knots will not be viewable from the appview. Upgrading is
1177straightforward however.
1178
1179For knots:
1180
1181- Upgrade to the latest tag (v1.9.0 or above)
1182- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1183 hit the "retry" button to verify your knot
1184
1185For spindles:
1186
1187- Upgrade to the latest tag (v1.9.0 or above)
1188- Head to the [spindle
1189 dashboard](https://tangled.org/settings/spindles) and hit the
1190 "retry" button to verify your spindle
1191
1192## Upgrading from v1.7.x
1193
1194After v1.7.0, knot secrets have been deprecated. You no
1195longer need a secret from the appview to run a knot. All
1196authorized commands to knots are managed via [Inter-Service
1197Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
1198Knots will be read-only until upgraded.
1199
1200Upgrading is quite easy, in essence:
1201
1202- `KNOT_SERVER_SECRET` is no more, you can remove this
1203 environment variable entirely
1204- `KNOT_SERVER_OWNER` is now required on boot, set this to
1205 your DID. You can find your DID in the
1206 [settings](https://tangled.org/settings) page.
1207- Restart your knot once you have replaced the environment
1208 variable
1209- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1210 hit the "retry" button to verify your knot. This simply
1211 writes a `sh.tangled.knot` record to your PDS.
1212
1213If you use the nix module, simply bump the flake to the
1214latest revision, and change your config block like so:
1215
1216```diff
1217 services.tangled.knot = {
1218 enable = true;
1219 server = {
1220- secretFile = /path/to/secret;
1221+ owner = "did:plc:foo";
1222 };
1223 };
1224```
1225
1226# Hacking on Tangled
1227
1228We highly recommend [installing
1229Nix](https://nixos.org/download/) (the package manager)
1230before working on the codebase. The Nix flake provides a lot
1231of helpers to get started and most importantly, builds and
1232dev shells are entirely deterministic.
1233
1234To set up your dev environment:
1235
1236```bash
1237nix develop
1238```
1239
1240Non-Nix users can look at the `devShell` attribute in the
1241`flake.nix` file to determine necessary dependencies.
1242
1243## Running the appview
1244
1245The Nix flake also exposes a few `app` attributes (run `nix
1246flake show` to see a full list of what the flake provides),
1247one of the apps runs the appview with the `air`
1248live-reloader:
1249
1250```bash
1251TANGLED_DEV=true nix run .#watch-appview
1252
1253# TANGLED_DB_PATH might be of interest to point to
1254# different sqlite DBs
1255
1256# in a separate shell, you can live-reload tailwind
1257nix run .#watch-tailwind
1258```
1259
1260To authenticate with the appview, you will need Redis and
1261OAuth JWKs to be set up:
1262
1263```
1264# OAuth JWKs should already be set up by the Nix devshell:
1265echo $TANGLED_OAUTH_CLIENT_SECRET
1266z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
1267
1268echo $TANGLED_OAUTH_CLIENT_KID
12691761667908
1270
1271# if not, you can set it up yourself:
1272goat key generate -t P-256
1273Key Type: P-256 / secp256r1 / ES256 private key
1274Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
1275 z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
1276Public Key (DID Key Syntax): share or publish this (eg, in DID document)
1277 did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
1278
1279# the secret key from above
1280export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
1281
1282# Run Redis in a new shell to store OAuth sessions
1283redis-server
1284```
1285
1286## Running knots and spindles
1287
1288An end-to-end knot setup requires setting up a machine with
1289`sshd`, `AuthorizedKeysCommand`, and a Git user, which is
1290quite cumbersome. So the Nix flake provides a
1291`nixosConfiguration` to do so.
1292
1293<details>
1294 <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
1295
1296 In order to build Tangled's dev VM on macOS, you will
1297 first need to set up a Linux Nix builder. The recommended
1298 way to do so is to run a [`darwin.linux-builder`
1299 VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
1300 and to register it in `nix.conf` as a builder for Linux
1301 with the same architecture as your Mac (`linux-aarch64` if
1302 you are using Apple Silicon).
1303
1304 > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
1305 > the Tangled repo so that it doesn't conflict with the other VM. For example,
1306 > you can do
1307 >
1308 > ```shell
1309 > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
1310 > ```
1311 >
1312 > to store the builder VM in a temporary dir.
1313 >
1314 > You should read and follow [all the other intructions][darwin builder vm] to
1315 > avoid subtle problems.
1316
1317 Alternatively, you can use any other method to set up a
1318 Linux machine with Nix installed that you can `sudo ssh`
1319 into (in other words, root user on your Mac has to be able
1320 to ssh into the Linux machine without entering a password)
1321 and that has the same architecture as your Mac. See
1322 [remote builder
1323 instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
1324 for how to register such a builder in `nix.conf`.
1325
1326 > WARNING: If you'd like to use
1327 > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
1328 > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
1329 > ssh` works can be tricky. It seems to be [possible with
1330 > Orbstack](https://github.com/orgs/orbstack/discussions/1669).
1331
1332</details>
1333
1334To begin, grab your DID from http://localhost:3000/settings.
1335Then, set `TANGLED_VM_KNOT_OWNER` and
1336`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
1337lightweight NixOS VM like so:
1338
1339```bash
1340nix run --impure .#vm
1341
1342# type `poweroff` at the shell to exit the VM
1343```
1344
1345This starts a knot on port 6444, a spindle on port 6555
1346with `ssh` exposed on port 2222.
1347
1348Once the services are running, head to
1349http://localhost:3000/settings/knots and hit "Verify". It should
1350verify the ownership of the services instantly if everything
1351went smoothly.
1352
1353You can push repositories to this VM with this ssh config
1354block on your main machine:
1355
1356```bash
1357Host nixos-shell
1358 Hostname localhost
1359 Port 2222
1360 User git
1361 IdentityFile ~/.ssh/my_tangled_key
1362```
1363
1364Set up a remote called `local-dev` on a git repo:
1365
1366```bash
1367git remote add local-dev git@nixos-shell:user/repo
1368git push local-dev main
1369```
1370
1371The above VM should already be running a spindle on
1372`localhost:6555`. Head to http://localhost:3000/settings/spindles and
1373hit "Verify". You can then configure each repository to use
1374this spindle and run CI jobs.
1375
1376Of interest when debugging spindles:
1377
1378```
1379# Service logs from journald:
1380journalctl -xeu spindle
1381
1382# CI job logs from disk:
1383ls /var/log/spindle
1384
1385# Debugging spindle database:
1386sqlite3 /var/lib/spindle/spindle.db
1387
1388# litecli has a nicer REPL interface:
1389litecli /var/lib/spindle/spindle.db
1390```
1391
1392If for any reason you wish to disable either one of the
1393services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
1394`services.tangled.spindle.enable` (or
1395`services.tangled.knot.enable`) to `false`.
1396
1397# Contribution guide
1398
1399## Commit guidelines
1400
1401We follow a commit style similar to the Go project. Please keep commits:
1402
1403* **atomic**: each commit should represent one logical change
1404* **descriptive**: the commit message should clearly describe what the
1405change does and why it's needed
1406
1407### Message format
1408
1409```
1410<service/top-level directory>/<affected package/directory>: <short summary of change>
1411
1412Optional longer description can go here, if necessary. Explain what the
1413change does and why, especially if not obvious. Reference relevant
1414issues or PRs when applicable. These can be links for now since we don't
1415auto-link issues/PRs yet.
1416```
1417
1418Here are some examples:
1419
1420```
1421appview/state: fix token expiry check in middleware
1422
1423The previous check did not account for clock drift, leading to premature
1424token invalidation.
1425```
1426
1427```
1428knotserver/git/service: improve error checking in upload-pack
1429```
1430
1431
1432### General notes
1433
1434- PRs get merged "as-is" (fast-forward)—like applying a patch-series
1435using `git am`. At present, there is no squashing—so please author
1436your commits as they would appear on `master`, following the above
1437guidelines.
1438- If there is a lot of nesting, for example "appview:
1439pages/templates/repo/fragments: ...", these can be truncated down to
1440just "appview: repo/fragments: ...". If the change affects a lot of
1441subdirectories, you may abbreviate to just the top-level names, e.g.
1442"appview: ..." or "knotserver: ...".
1443- Keep commits lowercased with no trailing period.
1444- Use the imperative mood in the summary line (e.g., "fix bug" not
1445"fixed bug" or "fixes bug").
1446- Try to keep the summary line under 72 characters, but we aren't too
1447fussed about this.
1448- Follow the same formatting for PR titles if filled manually.
1449- Don't include unrelated changes in the same commit.
1450- Avoid noisy commit messages like "wip" or "final fix"—rewrite history
1451before submitting if necessary.
1452
1453## Code formatting
1454
1455We use a variety of tools to format our code, and multiplex them with
1456[`treefmt`](https://treefmt.com). All you need to do to format your changes
1457is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
1458
1459## Proposals for bigger changes
1460
1461Small fixes like typos, minor bugs, or trivial refactors can be
1462submitted directly as PRs.
1463
1464For larger changes—especially those introducing new features, significant
1465refactoring, or altering system behavior—please open a proposal first. This
1466helps us evaluate the scope, design, and potential impact before implementation.
1467
1468Create a new issue titled:
1469
1470```
1471proposal: <affected scope>: <summary of change>
1472```
1473
1474In the description, explain:
1475
1476- What the change is
1477- Why it's needed
1478- How you plan to implement it (roughly)
1479- Any open questions or tradeoffs
1480
1481We'll use the issue thread to discuss and refine the idea before moving
1482forward.
1483
1484## Developer Certificate of Origin (DCO)
1485
1486We require all contributors to certify that they have the right to
1487submit the code they're contributing. To do this, we follow the
1488[Developer Certificate of Origin
1489(DCO)](https://developercertificate.org/).
1490
1491By signing your commits, you're stating that the contribution is your
1492own work, or that you have the right to submit it under the project's
1493license. This helps us keep things clean and legally sound.
1494
1495To sign your commit, just add the `-s` flag when committing:
1496
1497```sh
1498git commit -s -m "your commit message"
1499```
1500
1501This appends a line like:
1502
1503```
1504Signed-off-by: Your Name <your.email@example.com>
1505```
1506
1507We won't merge commits if they aren't signed off. If you forget, you can
1508amend the last commit like this:
1509
1510```sh
1511git commit --amend -s
1512```
1513
1514If you're submitting a PR with multiple commits, make sure each one is
1515signed.
1516
1517For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
1518to make it sign off commits in the tangled repo:
1519
1520```shell
1521# Safety check, should say "No matching config key..."
1522jj config list templates.commit_trailers
1523# The command below may need to be adjusted if the command above returned something.
1524jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
1525```
1526
1527Refer to the [jujutsu
1528documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
1529for more information.