···11----
22-title: Tangled docs
33-author: The Tangled Contributors
44-date: 21 Sun, Dec 2025
55-abstract: |
66- Tangled is a decentralized code hosting and collaboration
77- platform. Every component of Tangled is open-source and
88- self-hostable. [tangled.org](https://tangled.org) also
99- provides hosting and CI services that are free to use.
1010-1111- There are several models for decentralized code
1212- collaboration platforms, ranging from ActivityPub’s
1313- (Forgejo) federated model, to Radicle’s entirely P2P model.
1414- Our approach attempts to be the best of both worlds by
1515- adopting the AT Protocol—a protocol for building decentralized
1616- social applications with a central identity
1717-1818- Our approach to this is the idea of “knots”. Knots are
1919- lightweight, headless servers that enable users to host Git
2020- repositories with ease. Knots are designed for either single
2121- or multi-tenant use which is perfect for self-hosting on a
2222- Raspberry Pi at home, or larger “community” servers. By
2323- default, Tangled provides managed knots where you can host
2424- your repositories for free.
2525-2626- The appview at tangled.org acts as a consolidated "view"
2727- into the whole network, allowing users to access, clone and
2828- contribute to repositories hosted across different knots
2929- seamlessly.
3030----
3131-3232-# Quick start guide
3333-3434-## Login or sign up
3535-3636-You can [login](https://tangled.org) by using your AT Protocol
3737-account. If you are unclear on what that means, simply head
3838-to the [signup](https://tangled.org/signup) page and create
3939-an account. By doing so, you will be choosing Tangled as
4040-your account provider (you will be granted a handle of the
4141-form `user.tngl.sh`).
4242-4343-In the AT Protocol network, users are free to choose their account
4444-provider (known as a "Personal Data Service", or PDS), and
4545-login to applications that support AT accounts.
4646-4747-You can think of it as "one account for all of the atmosphere"!
4848-4949-If you already have an AT account (you may have one if you
5050-signed up to Bluesky, for example), you can login with the
5151-same handle on Tangled (so just use `user.bsky.social` on
5252-the login page).
5353-5454-## Add an SSH key
5555-5656-Once you are logged in, you can start creating repositories
5757-and pushing code. Tangled supports pushing git repositories
5858-over SSH.
5959-6060-First, you'll need to generate an SSH key if you don't
6161-already have one:
6262-6363-```bash
6464-ssh-keygen -t ed25519 -C "foo@bar.com"
6565-```
6666-6767-When prompted, save the key to the default location
6868-(`~/.ssh/id_ed25519`) and optionally set a passphrase.
6969-7070-Copy your public key to your clipboard:
7171-7272-```bash
7373-# on X11
7474-cat ~/.ssh/id_ed25519.pub | xclip -sel c
7575-7676-# on wayland
7777-cat ~/.ssh/id_ed25519.pub | wl-copy
7878-7979-# on macos
8080-cat ~/.ssh/id_ed25519.pub | pbcopy
8181-```
8282-8383-Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key',
8484-paste your public key, give it a descriptive name, and hit
8585-save.
8686-8787-## Create a repository
8888-8989-Once your SSH key is added, create your first repository:
9090-9191-1. Hit the green `+` icon on the topbar, and select
9292- repository
9393-2. Enter a repository name
9494-3. Add a description
9595-4. Choose a knotserver to host this repository on
9696-5. Hit create
9797-9898-Knots are self-hostable, lightweight Git servers that can
9999-host your repository. Unlike traditional code forges, your
100100-code can live on any server. Read the [Knots](TODO) section
101101-for more.
102102-103103-## Configure SSH
104104-105105-To ensure Git uses the correct SSH key and connects smoothly
106106-to Tangled, add this configuration to your `~/.ssh/config`
107107-file:
108108-109109-```
110110-Host tangled.org
111111- Hostname tangled.org
112112- User git
113113- IdentityFile ~/.ssh/id_ed25519
114114- AddressFamily inet
115115-```
116116-117117-This tells SSH to use your specific key when connecting to
118118-Tangled and prevents authentication issues if you have
119119-multiple SSH keys.
120120-121121-Note that this configuration only works for knotservers that
122122-are hosted by tangled.org. If you use a custom knot, refer
123123-to the [Knots](TODO) section.
124124-125125-## Push your first repository
126126-127127-Initialize a new Git repository:
128128-129129-```bash
130130-mkdir my-project
131131-cd my-project
132132-133133-git init
134134-echo "# My Project" > README.md
135135-```
136136-137137-Add some content and push!
138138-139139-```bash
140140-git add README.md
141141-git commit -m "Initial commit"
142142-git remote add origin git@tangled.org:user.tngl.sh/my-project
143143-git push -u origin main
144144-```
145145-146146-That's it! Your code is now hosted on Tangled.
147147-148148-## Migrating an existing repository
149149-150150-Moving your repositories from GitHub, GitLab, Bitbucket, or
151151-any other Git forge to Tangled is straightforward. You'll
152152-simply change your repository's remote URL. At the moment,
153153-Tangled does not have any tooling to migrate data such as
154154-GitHub issues or pull requests.
155155-156156-First, create a new repository on tangled.org as described
157157-in the [Quick Start Guide](#create-a-repository).
158158-159159-Navigate to your existing local repository:
160160-161161-```bash
162162-cd /path/to/your/existing/repo
163163-```
164164-165165-You can inspect your existing Git remote like so:
166166-167167-```bash
168168-git remote -v
169169-```
170170-171171-You'll see something like:
172172-173173-```bash
174174-origin git@github.com:username/my-project.git (fetch)
175175-origin git@github.com:username/my-project.git (push)
176176-```
177177-178178-Update the remote URL to point to tangled:
179179-180180-```bash
181181-git remote set-url origin git@tangled.org:user.tngl.sh/my-project
182182-```
183183-184184-Verify the change:
185185-186186-```bash
187187-git remote -v
188188-```
189189-190190-You should now see:
191191-192192-```bash
193193-origin git@tangled.org:user.tngl.sh/my-project (fetch)
194194-origin git@tangled.org:user.tngl.sh/my-project (push)
195195-```
196196-197197-Push all your branches and tags to Tangled:
198198-199199-```bash
200200-git push -u origin --all
201201-git push -u origin --tags
202202-```
203203-204204-Your repository is now migrated to Tangled! All commit
205205-history, branches, and tags have been preserved.
206206-207207-## Mirroring a repository to Tangled
208208-209209-If you want to maintain your repository on multiple forges
210210-simultaneously, for example, keeping your primary repository
211211-on GitHub while mirroring to Tangled for backup or
212212-redundancy, you can do so by adding [multiple remotes](https://git-scm.com/docs/git-push#_remotes).
213213-214214-You can configure your local repository to push to both
215215-Tangled and, say, GitHub. You may already have the following
216216-setup:
217217-218218-```bash
219219-$ git remote -v
220220-origin git@github.com:username/my-project.git (fetch)
221221-origin git@github.com:username/my-project.git (push)
222222-```
223223-224224-Now add Tangled as an additional push URL to the same
225225-remote:
226226-227227-```bash
228228-git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project
229229-```
230230-231231-You also need to re-add the original URL as a push
232232-destination (Git will now use the original URL to fetch only):
233233-234234-```bash
235235-git remote set-url --add --push origin git@github.com:username/my-project.git
236236-```
237237-238238-Verify your configuration:
239239-240240-```bash
241241-$ git remote -v
242242-origin git@github.com:username/my-project.git (fetch)
243243-origin git@tangled.org:user.tngl.sh/my-project (push)
244244-origin git@github.com:username/my-project.git (push)
245245-```
246246-247247-Notice that there's one fetch URL (the primary remote) and
248248-two push URLs. Now, whenever you push, Git will
249249-automatically push to both remotes:
250250-251251-```bash
252252-git push origin main
253253-```
254254-255255-This single command pushes your `main` branch to both GitHub
256256-and Tangled simultaneously.
257257-258258-To push all branches and tags:
259259-260260-```bash
261261-git push origin --all
262262-git push origin --tags
263263-```
264264-265265-If you prefer more control over which remote you push to,
266266-you can maintain separate remotes:
267267-268268-```bash
269269-git remote add github git@github.com:username/my-project.git
270270-git remote add tangled git@tangled.org:user.tngl.sh/my-project
271271-```
272272-273273-Then push to each explicitly:
274274-275275-```bash
276276-git push github main
277277-git push tangled main
278278-```
279279-280280-# Hosting websites on Tangled
281281-282282-You can serve static websites directly from your git repositories on
283283-Tangled. If you've used GitHub Pages or Codeberg Pages, this should feel
284284-familiar.
285285-286286-## Overview
287287-288288-Every user gets a sites domain. If you signed up through Tangled's own
289289-PDS (`tngl.sh`), your sites domain is automatically
290290-`<your-handle>.tngl.sh` no setup needed. Otherwise, you can claim a
291291-`<subdomain>.tngl.io` domain from your settings.
292292-293293-You can serve multiple sites per domain:
294294-295295-- One **index site** served at the root of your domain (e.g.
296296- `alice.tngl.sh`)
297297-- Any number of **sub-path sites** served under the repository name
298298- (e.g. `alice.tngl.sh/my-project`)
299299-300300-## Claiming a domain
301301-302302-If you don't have a `tngl.sh` handle, you need to claim a domain before
303303-publishing sites:
304304-305305-1. Go to **Settings → Sites**
306306-2. Enter a subdomain (e.g. `alice` to claim `alice.tngl.io`)
307307-3. Click **claim**
308308-309309-You can only hold one domain at a time. Releasing a domain puts it in a
310310-30-day cooldown before anyone else can claim it.
311311-312312-## Configuring a site for a repository
313313-314314-1. Navigate to your repository
315315-2. Go to **Settings → Sites**
316316-3. Choose a **branch** to deploy from
317317-4. Set the **deploy directory** — the path within the repository
318318- containing your `index.html`. Use `/` for the root, or a subdirectory
319319- like `/docs` or `/public`
320320-5. Choose the **site type**:
321321- - **Index site** — served at the root of your domain (e.g.
322322- `alice.tngl.sh`)
323323- - **Sub-path site** — served under the repository name (e.g.
324324- `alice.tngl.sh/my-project`)
325325-6. Click **save**
326326-327327-The site will be deployed automatically. You can see the status of your
328328-previous deploys in the **Recent Deploys** section at the bottom of the
329329-page.
330330-331331-Sites are redeployed automatically on every push to the configured
332332-branch.
333333-334334-## Custom domains
335335-336336-Tangled currently doesn't support custom domains for sites. This will be
337337-added in a future update.
338338-339339-## Deploy directory
340340-341341-The deploy directory is the path within your repository that Tangled
342342-serves as the site root. It must contain an `index.html`.
343343-344344-| Deploy directory | Result |
345345-|---|---|
346346-| `/` | Serves the repository root |
347347-| `/docs` | Serves the `docs/` subdirectory |
348348-| `/public` | Serves the `public/` subdirectory |
349349-350350-Directories are served with automatic `index.html` resolution -- a
351351-request to `/about` will serve `/about/index.html` if it exists.
352352-353353-## Site types
354354-355355-| Type | URL |
356356-|---|---|
357357-| Index site | `alice.tngl.sh` |
358358-| Sub-path site | `alice.tngl.sh/my-project` |
359359-360360-Only one repository can be the index site for a given domain at a time.
361361-If another repository already holds the index site, you will see a
362362-notice in the settings and only the sub-path option will be available.
363363-364364-## Deploy triggers
365365-366366-A deployment is triggered automatically when:
367367-368368-- You push to the configured branch
369369-- You change the site configuration (branch, deploy directory, or site
370370- type)
371371-372372-## Disabling a site
373373-374374-To stop serving a site, go to **Settings → Sites** in your repository
375375-and click **Disable**. This removes the site configuration and stops
376376-serving the site. The deployed files are also deleted from storage.
377377-378378-Releasing your domain from **Settings → Sites** at the account level
379379-will disable all sites associated with it and delete their files.
380380-381381-382382-# Knot self-hosting guide
383383-384384-So you want to run your own knot server? Great! Here are a few prerequisites:
385385-386386-1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
387387-2. A (sub)domain name. People generally use `knot.example.com`.
388388-3. A valid SSL certificate for your domain.
389389-390390-## NixOS
391391-392392-Refer to the [knot
393393-module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix)
394394-for a full list of options. Sample configurations:
395395-396396-- [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85)
397397-- [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/c2b644c214d278af12523618de952ee2eab1af3d/hosts/marvin/services/tangled.nix#L15-26)
398398-399399-## Docker
400400-401401-Refer to
402402-[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker).
403403-Note that this is community maintained.
404404-405405-## Manual setup
406406-407407-First, clone this repository:
408408-409409-```
410410-git clone https://tangled.org/@tangled.org/core
411411-```
412412-413413-Then, build the `knot` CLI. This is the knot administration
414414-and operation tool. For the purpose of this guide, we're
415415-only concerned with these subcommands:
416416-417417-- `knot server`: the main knot server process, typically
418418- run as a supervised service
419419-- `knot guard`: handles role-based access control for git
420420- over SSH (you'll never have to run this yourself)
421421-- `knot keys`: fetches SSH keys associated with your knot;
422422- we'll use this to generate the SSH
423423- `AuthorizedKeysCommand`
424424-425425-```
426426-cd core
427427-export CGO_ENABLED=1
428428-go build -o knot ./cmd/knot
429429-```
430430-431431-Next, move the `knot` binary to a location owned by `root` --
432432-`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
433433-434434-```
435435-sudo mv knot /usr/local/bin/knot
436436-sudo chown root:root /usr/local/bin/knot
437437-```
438438-439439-This is necessary because SSH `AuthorizedKeysCommand` requires [really
440440-specific permissions](https://stackoverflow.com/a/27638306). The
441441-`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
442442-retrieve a user's public SSH keys dynamically for authentication. Let's
443443-set that up.
444444-445445-```
446446-sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
447447-Match User git
448448- AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
449449- AuthorizedKeysCommandUser nobody
450450-EOF
451451-```
452452-453453-Then, reload `sshd`:
454454-455455-```
456456-sudo systemctl reload ssh
457457-```
458458-459459-Next, create the `git` user. We'll use the `git` user's home directory
460460-to store repositories:
461461-462462-```
463463-sudo adduser git
464464-```
465465-466466-Create `/home/git/.knot.env` with the following, updating the values as
467467-necessary. The `KNOT_SERVER_OWNER` should be set to your
468468-DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
469469-470470-```
471471-KNOT_REPO_SCAN_PATH=/home/git
472472-KNOT_SERVER_HOSTNAME=knot.example.com
473473-APPVIEW_ENDPOINT=https://tangled.org
474474-KNOT_SERVER_OWNER=did:plc:foobar
475475-KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
476476-KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
477477-```
478478-479479-If you run a Linux distribution that uses systemd, you can
480480-use the provided service file to run the server. Copy
481481-[`knotserver.service`](https://tangled.org/tangled.org/core/blob/master/systemd/knotserver.service)
482482-to `/etc/systemd/system/`. Then, run:
483483-484484-```
485485-systemctl enable knotserver
486486-systemctl start knotserver
487487-```
488488-489489-The last step is to configure a reverse proxy like Nginx or Caddy to front your
490490-knot. Here's an example configuration for Nginx:
491491-492492-```
493493-server {
494494- listen 80;
495495- listen [::]:80;
496496- server_name knot.example.com;
497497-498498- location / {
499499- proxy_pass http://localhost:5555;
500500- proxy_set_header Host $host;
501501- proxy_set_header X-Real-IP $remote_addr;
502502- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
503503- proxy_set_header X-Forwarded-Proto $scheme;
504504- }
505505-506506- # wss endpoint for git events
507507- location /events {
508508- proxy_set_header X-Forwarded-For $remote_addr;
509509- proxy_set_header Host $http_host;
510510- proxy_set_header Upgrade websocket;
511511- proxy_set_header Connection Upgrade;
512512- proxy_pass http://localhost:5555;
513513- }
514514- # additional config for SSL/TLS go here.
515515-}
516516-517517-```
518518-519519-Remember to use Let's Encrypt or similar to procure a certificate for your
520520-knot domain.
521521-522522-You should now have a running knot server! You can finalize
523523-your registration by hitting the `verify` button on the
524524-[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
525525-a record on your PDS to announce the existence of the knot.
526526-527527-### Custom paths
528528-529529-(This section applies to manual setup only. Docker users should edit the mounts
530530-in `docker-compose.yml` instead.)
531531-532532-Right now, the database and repositories of your knot lives in `/home/git`. You
533533-can move these paths if you'd like to store them in another folder. Be careful
534534-when adjusting these paths:
535535-536536-- Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
537537- any possible side effects. Remember to restart it once you're done.
538538-- Make backups before moving in case something goes wrong.
539539-- Make sure the `git` user can read and write from the new paths.
540540-541541-#### Database
542542-543543-As an example, let's say the current database is at `/home/git/knotserver.db`,
544544-and we want to move it to `/home/git/database/knotserver.db`.
545545-546546-Copy the current database to the new location. Make sure to copy the `.db-shm`
547547-and `.db-wal` files if they exist.
548548-549549-```
550550-mkdir /home/git/database
551551-cp /home/git/knotserver.db* /home/git/database
552552-```
553553-554554-In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
555555-the new file path (_not_ the directory):
556556-557557-```
558558-KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
559559-```
560560-561561-#### Repositories
562562-563563-As an example, let's say the repositories are currently in `/home/git`, and we
564564-want to move them into `/home/git/repositories`.
565565-566566-Create the new folder, then move the existing repositories (if there are any):
567567-568568-```
569569-mkdir /home/git/repositories
570570-# move all DIDs into the new folder; these will vary for you!
571571-mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
572572-```
573573-574574-In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
575575-to the new directory:
576576-577577-```
578578-KNOT_REPO_SCAN_PATH=/home/git/repositories
579579-```
580580-581581-Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
582582-repository path:
583583-584584-```
585585-sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
586586-Match User git
587587- AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
588588- AuthorizedKeysCommandUser nobody
589589-EOF
590590-```
591591-592592-Make sure to restart your SSH server!
593593-594594-#### MOTD (message of the day)
595595-596596-To configure the MOTD used ("Welcome to this knot!" by default), edit the
597597-`/home/git/motd` file:
598598-599599-```
600600-printf "Hi from this knot!\n" > /home/git/motd
601601-```
602602-603603-Note that you should add a newline at the end if setting a non-empty message
604604-since the knot won't do this for you.
605605-606606-## Secure Mode
607607-608608-Secure Mode isolates each `git` subprocess to the repository it is
609609-operating on, using two mechanisms:
610610-611611-- **Linux Landlock** restricts the filesystem paths the subprocess
612612- can access -- it can only read/write its own repository and the
613613- system directories it needs to run.
614614-- **UID isolation** runs each subprocess as a virtual UID assigned
615615- to the repository owner, so that repositories belonging to
616616- different owners are isolated from each other at the OS level
617617- even if Landlock were somehow bypassed.
618618-619619-Secure Mode requires:
620620-621621-- Linux kernel >= 5.19 (Landlock V2). This is the minimum needed
622622- for `git push` to work, because receive-pack's quarantine
623623- migration uses cross-directory rename which requires the
624624- Landlock `REFER` access right (added in V2). Kernels 5.13-5.18
625625- support Landlock V1 and clones will work, but pushes will fail
626626- with cross-device link errors. On kernels without any Landlock
627627- support (< 5.13), the sandbox call is a no-op: UID isolation
628628- still applies but no filesystem restriction is enforced.
629629-- `CAP_SETUID`, `CAP_SETGID`, and `CAP_CHOWN` available to the
630630- knot process. The NixOS module grants these automatically; for
631631- manual setups see the `setcap` step below.
632632-633633-### NixOS
634634-635635-Add `server.secureMode = true;` to your knot module configuration:
636636-637637-```nix
638638-services.tangled.knot = {
639639- server.secureMode = true;
640640- # ... other options
641641-};
642642-```
643643-644644-The NixOS module handles everything else automatically:
645645-646646-- Grants the required capabilities to the knot service via
647647- `AmbientCapabilities` in the systemd unit.
648648-- Installs a capability-bearing wrapper at
649649- `/run/wrappers/bin/knot` via `security.wrappers`, so that
650650- SSH-invoked git operations (pushes) also run under the correct
651651- UID without requiring the service to run as root.
652652-- Runs `knot migrate-isolation` at service start to chown
653653- existing repositories to their virtual UIDs.
654654-655655-### Manual setup
656656-657657-**Step 1.** Grant the required capabilities to the knot binary.
658658-This allows the knot process to switch to virtual UIDs at runtime
659659-without running as root. You will need to repeat this step
660660-whenever the binary is updated.
661661-662662-```
663663-sudo setcap cap_setuid,cap_setgid,cap_chown+eip /usr/local/bin/knot
664664-```
665665-666666-**Step 2.** Run the migration tool to assign virtual UIDs to all
667667-existing repositories and set their filesystem permissions. This
668668-must be run as root:
669669-670670-```
671671-sudo knot migrate-isolation \
672672- --git-dir /home/git \
673673- --db /home/git/knotserver.db \
674674- --internal-api 127.0.0.1:5444
675675-```
676676-677677-You can re-run this at any time with `--force` to reapply
678678-permissions (e.g. after a manual repair or after updating the
679679-binary).
680680-681681-**Step 2a.** Ensure the home directory is traversable by
682682-non-group users. Git subprocesses run as virtual UIDs that are
683683-not in the git group, and they need to resolve
684684-`$HOME/.config/git/config` to load the global config:
685685-686686-```
687687-sudo chmod o+x /home/git
688688-```
689689-690690-This adds only the execute bit, not read -- the virtual UIDs can
691691-traverse to known paths but cannot list directory contents.
692692-693693-**Step 3.** Enable Secure Mode in your environment file:
694694-695695-```
696696-KNOT_SERVER_SECURE_MODE=true
697697-```
698698-699699-Or pass it as a flag:
700700-701701-```
702702-knot server --secure-mode
703703-```
704704-705705-**Step 4.** Regenerate the `AuthorizedKeysCommand` with the
706706-`-secure-mode` flag. This causes `knot keys` to emit guard
707707-command lines that include `-secure-mode`, so SSH pushes also
708708-get UID isolation:
709709-710710-```
711711-sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
712712-Match User git
713713- AuthorizedKeysCommand /usr/local/bin/knot keys \
714714- -o authorized-keys -secure-mode
715715- AuthorizedKeysCommandUser nobody
716716-EOF
717717-```
718718-719719-Reload `sshd` after making this change.
720720-721721-> **Note:** the server will refuse to start in Secure Mode if any
722722-> repositories have not yet been isolation-migrated. Re-run
723723-> `migrate-isolation` if you see this error.
724724-725725-## Troubleshooting
726726-727727-If you run your own knot, you may run into some of these
728728-common issues. You can always join the
729729-[IRC](https://web.libera.chat/#tangled) or
730730-[Discord](https://chat.tangled.org/) if this section does
731731-not help.
732732-733733-### Unable to push
734734-735735-If you are unable to push to your knot or repository:
736736-737737-1. First, ensure that you have added your SSH public key to
738738- your account
739739-2. Check to see that your knot has synced the key by running
740740- `knot keys`
741741-3. Check to see if git is supplying the correct private key
742742- when pushing: `GIT_SSH_COMMAND="ssh -v" git push ...`
743743-4. Check to see if `sshd` on the knot is rejecting the push
744744- for some reason: `journalctl -xeu ssh` (or `sshd`,
745745- depending on your machine). These logs are unavailable if
746746- using docker.
747747-5. Check to see if the knot itself is rejecting the push,
748748- depending on your setup, the logs might be in one of the
749749- following paths:
750750- - `/tmp/knotguard.log`
751751- - `/home/git/log`
752752- - `/home/git/guard.log`
753753-754754-# Spindles
755755-756756-## Pipelines
757757-758758-Spindle workflows allow you to write CI/CD pipelines in a
759759-simple format. They're located in the `.tangled/workflows`
760760-directory at the root of your repository, and are defined
761761-using YAML.
762762-763763-A workflow has a set of common fields that apply no matter
764764-which engine you pick:
765765-766766-- [Trigger](#trigger): A **required** field that defines
767767- when a workflow should be triggered.
768768-- [Engine](#engine): A **required** field that defines which
769769- engine a workflow should run on.
770770-- [Clone options](#clone-options): An **optional** field
771771- that defines how the repository should be cloned.
772772-- [Environment](#environment): An **optional** field that
773773- allows you to define environment variables.
774774-- [Steps](#steps): An **optional** field that allows you to
775775- define what steps should run in the workflow.
776776-777777-On top of these, each engine has its own options for things
778778-like dependencies and images. See [Engines](#engines) for
779779-the per-engine fields.
780780-781781-### Trigger
782782-783783-The first thing to add to a workflow is the trigger, which
784784-defines when a workflow runs. This is defined using a `when`
785785-field, which takes in a list of conditions. Each condition
786786-has the following fields:
787787-788788-- `event`: This is a **required** field that defines when
789789- your workflow should run. It's a list that can take one or
790790- more of the following values:
791791- - `push`: The workflow should run every time a commit is
792792- pushed to the repository.
793793- - `pull_request`: The workflow should run every time a
794794- pull request is made or updated.
795795- - `manual`: The workflow can be triggered manually.
796796-- `branch`: Defines which branches the workflow should run
797797- for. If used with the `push` event, commits to the
798798- branch(es) listed here will trigger the workflow. If used
799799- with the `pull_request` event, updates to pull requests
800800- targeting the branch(es) listed here will trigger the
801801- workflow. This field has no effect with the `manual`
802802- event. Supports glob patterns using `*` and `**` (e.g.,
803803- `main`, `develop`, `release-*`). Either `branch` or `tag`
804804- (or both) must be specified for `push` events.
805805-- `tag`: Defines which tags the workflow should run for.
806806- Only used with the `push` event - when tags matching the
807807- pattern(s) listed here are pushed, the workflow will
808808- trigger. This field has no effect with `pull_request` or
809809- `manual` events. Supports glob patterns using `*` and `**`
810810- (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or
811811- `tag` (or both) must be specified for `push` events.
812812-813813-For example, if you'd like to define a workflow that runs
814814-when commits are pushed to the `main` and `develop`
815815-branches, or when pull requests that target the `main`
816816-branch are updated, or manually, you can do so with:
817817-818818-```yaml
819819-when:
820820- - event: ["push", "manual"]
821821- branch: ["main", "develop"]
822822- - event: ["pull_request"]
823823- branch: ["main"]
824824-```
825825-826826-You can also trigger workflows on tag pushes. For instance,
827827-to run a deployment workflow when tags matching `v*` are
828828-pushed:
829829-830830-```yaml
831831-when:
832832- - event: ["push"]
833833- tag: ["v*"]
834834-```
835835-836836-You can even combine branch and tag patterns in a single
837837-constraint (the workflow triggers if either matches):
838838-839839-```yaml
840840-when:
841841- - event: ["push"]
842842- branch: ["main", "release-*"]
843843- tag: ["v*", "stable"]
844844-```
845845-846846-To skip CI for a push, pass a Git push option:
847847-848848-```sh
849849-git push -o skip-ci
850850-```
851851-852852-`ci-skip` is also accepted.
853853-854854-### Engine
855855-856856-Next is the engine on which the workflow should run, defined
857857-using the **required** `engine` field. The currently
858858-supported engines are:
859859-860860-- `nixery`: This uses an instance of
861861- [Nixery](https://nixery.dev) to run steps, which allows
862862- you to add [dependencies](#dependencies) from
863863- Nixpkgs (https://github.com/NixOS/nixpkgs). You can
864864- search for packages on https://search.nixos.org, and
865865- there's a pretty good chance the package(s) you're looking
866866- for will be there.
867867- See [Nixery engine](#nixery-engine).
868868-- `microvm`: Runs the whole workflow inside its own
869869- microVM. Has configuration features for NixOS images
870870- that will let you enable services, do Docker-in-VM, etc.
871871- See [microVM engine](#microvm-engine).
872872-873873-Example:
874874-875875-```yaml
876876-engine: "nixery"
877877-```
878878-879879-Each engine also adds its own workflow fields (dependencies,
880880-images, services, and so on). These are documented under
881881-[Engines](#engines).
882882-883883-### Clone options
884884-885885-When a workflow starts, the first step is to clone the
886886-repository. You can customize this behavior using the
887887-**optional** `clone` field. It has the following fields:
888888-889889-- `skip`: Setting this to `true` will skip cloning the
890890- repository. This can be useful if your workflow is doing
891891- something that doesn't require anything from the
892892- repository itself. This is `false` by default.
893893-- `depth`: This sets the number of commits, or the "clone
894894- depth", to fetch from the repository. For example, if you
895895- set this to 2, the last 2 commits will be fetched. By
896896- default, the depth is set to 1, meaning only the most
897897- recent commit will be fetched, which is the commit that
898898- triggered the workflow.
899899-- `submodules`: If you use Git submodules
900900- (https://git-scm.com/book/en/v2/Git-Tools-Submodules)
901901- in your repository, setting this field to `true` will
902902- recursively fetch all submodules. This is `false` by
903903- default.
904904-905905-The default settings are:
906906-907907-```yaml
908908-clone:
909909- skip: false
910910- depth: 1
911911- submodules: false
912912-```
913913-914914-### Environment
915915-916916-The `environment` field allows you define environment
917917-variables that will be available throughout the entire
918918-workflow. **Do not put secrets here, these environment
919919-variables are visible to anyone viewing the repository. You
920920-can add secrets for pipelines in your repository's
921921-settings.**
922922-923923-Example:
924924-925925-```yaml
926926-environment:
927927- GOOS: "linux"
928928- GOARCH: "arm64"
929929- NODE_ENV: "production"
930930- MY_ENV_VAR: "MY_ENV_VALUE"
931931-```
932932-933933-By default, the following environment variables are set:
934934-935935-- `CI` - Always set to `true` to indicate a CI environment
936936-- `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline
937937-- `TANGLED_PIPELINE_KIND` - One of `push`, `pull_request` or
938938- `manual`
939939-- `TANGLED_REPO_KNOT` - The repository's knot hostname
940940-- `TANGLED_REPO_DID` - The DID of the repository owner
941941-- `TANGLED_REPO_NAME` - The name of the repository
942942-- `TANGLED_REPO_DEFAULT_BRANCH` - The default branch of the
943943- repository
944944-- `TANGLED_REPO_URL` - The full URL to the repository
945945-946946-These variables are only available when the pipeline is
947947-triggered by a push:
948948-949949-- `TANGLED_REF` - The full git reference (e.g.,
950950- `refs/heads/main` or `refs/tags/v1.0.0`)
951951-- `TANGLED_REF_NAME` - The short name of the reference
952952- (e.g., `main` or `v1.0.0`)
953953-- `TANGLED_REF_TYPE` - The type of reference, either
954954- `branch` or `tag`
955955-- `TANGLED_SHA` - The commit SHA that triggered the pipeline
956956-- `TANGLED_COMMIT_SHA` - Alias for `TANGLED_SHA`
957957-958958-These variables are only available when the pipeline is
959959-triggered by a pull request:
960960-961961-- `TANGLED_PR_SOURCE_BRANCH` - The source branch of the pull
962962- request
963963-- `TANGLED_PR_TARGET_BRANCH` - The target branch of the pull
964964- request
965965-- `TANGLED_PR_SOURCE_SHA` - The commit SHA of the source
966966- branch
967967-968968-### Steps
969969-970970-The `steps` field allows you to define what steps should run
971971-in the workflow. It's a list of step objects, each with the
972972-following fields:
973973-974974-- `name`: This field allows you to give your step a name.
975975- This name is visible in your workflow runs, and is used to
976976- describe what the step is doing.
977977-- `command`: This field allows you to define a command to
978978- run in that step. The step is run in a Bash shell, and the
979979- logs from the command will be visible in the pipelines
980980- page on the Tangled website. Any dependencies you added in
981981- your engine's section (see [Engines](#engines)) will be
982982- available to use here.
983983-- `environment`: Similar to the global
984984- [environment](#environment) config, this **optional**
985985- field is a key-value map that allows you to set
986986- environment variables for the step. **Do not put secrets
987987- here, these environment variables are visible to anyone
988988- viewing the repository. You can add secrets for pipelines
989989- in your repository's settings.**
990990-991991-Example:
992992-993993-```yaml
994994-steps:
995995- - name: "Build backend"
996996- command: "go build"
997997- environment:
998998- GOOS: "darwin"
999999- GOARCH: "arm64"
10001000- - name: "Build frontend"
10011001- command: "npm run build"
10021002- environment:
10031003- NODE_ENV: "production"
10041004-```
10051005-10061006-## Engines
10071007-10081008-The common fields above apply to every workflow. Each engine
10091009-then adds its own fields on top. Pick an engine with the
10101010-[`engine`](#engine) field and use the matching section below.
10111011-10121012-### Nixery engine
10131013-10141014-#### Dependencies
10151015-10161016-When you're running a workflow you'll usually need additional
10171017-dependencies. The `dependencies` field lets you define which
10181018-dependencies to get, and from where. It's a key-value map,
10191019-with the key being the registry to fetch dependencies from,
10201020-and the value being the list of dependencies to fetch.
10211021-10221022-The registry URL syntax can be found [on the nix
10231023-manual](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-registry-add).
10241024-10251025-Say you want to fetch Node.js and Go from `nixpkgs`, and a
10261026-package called `my_pkg` you've made from your own registry
10271027-at your repository at
10281028-`https://tangled.org/@example.com/my_pkg`. You can define
10291029-those dependencies like so:
10301030-10311031-```yaml
10321032-dependencies:
10331033- # nixpkgs
10341034- nixpkgs:
10351035- - nodejs
10361036- - go
10371037- # unstable
10381038- nixpkgs/nixpkgs-unstable:
10391039- - bun
10401040- # custom registry
10411041- git+https://tangled.org/@example.com/my_pkg:
10421042- - my_pkg
10431043-```
10441044-10451045-Now these dependencies are available to use in your
10461046-workflow!
10471047-10481048-#### Complete nixery workflow
10491049-10501050-```yaml
10511051-# .tangled/workflows/build.yml
10521052-10531053-when:
10541054- - event: ["push", "manual"]
10551055- branch: ["main", "develop"]
10561056- - event: ["pull_request"]
10571057- branch: ["main"]
10581058-10591059-engine: "nixery"
10601060-10611061-# using the default values
10621062-clone:
10631063- skip: false
10641064- depth: 1
10651065- submodules: false
10661066-10671067-dependencies:
10681068- # nixpkgs
10691069- nixpkgs:
10701070- - nodejs
10711071- - go
10721072- # custom registry
10731073- git+https://tangled.org/@example.com/my_pkg:
10741074- - my_pkg
10751075-10761076-environment:
10771077- GOOS: "linux"
10781078- GOARCH: "arm64"
10791079- NODE_ENV: "production"
10801080- MY_ENV_VAR: "MY_ENV_VALUE"
10811081-10821082-steps:
10831083- - name: "Build backend"
10841084- command: "go build"
10851085- environment:
10861086- GOOS: "darwin"
10871087- GOARCH: "arm64"
10881088- - name: "Build frontend"
10891089- command: "npm run build"
10901090- environment:
10911091- NODE_ENV: "production"
10921092-```
10931093-10941094-If you want another example of a workflow, you can look at
10951095-the one [Tangled uses to build the
10961096-project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).
10971097-10981098-### microVM engine
10991099-11001100-#### Image
11011101-11021102-A workflow picks the image to boot with the top-level `image`
11031103-field:
11041104-11051105-```yaml
11061106-engine: microvm
11071107-image: nixos
11081108-```
11091109-11101110-There are two flavours of images:
11111111-11121112-- **NixOS images** (e.g. `nixos`): the whole guest is built
11131113- with Nix, so you can configure it from the workflow file
11141114- itself. The `dependencies`, `services`, `virtualisation`,
11151115- `registry` and `caches` fields below are all understood
11161116- here, and the guest builds and activates that configuration
11171117- before any of your steps run.
11181118-- **Non-NixOS images** (e.g. `alpine`): there's no NixOS to
11191119- configure, so the workflow-level config fields above have
11201120- no effect. You still get a full machine to run steps in.
11211121-11221122-The available image names depend on what the spindle operator
11231123-has installed. `nixos` and `alpine` are examples. If `image`
11241124-is omitted, the spindle's configured default image is used.
11251125-11261126-#### Dependencies
11271127-11281128-On the microVM engine, `dependencies` is a flat list of
11291129-packages that are made available to every step. This field
11301130-only applies to **NixOS images**; for other images you can
11311131-use the package manager included in a step.
11321132-11331133-The guest builds a [`nix develop`](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-develop)-style
11341134-devshell from your dependencies and uses it for each step,
11351135-so you can, for example, add `pkg-config` and `openssl` and
11361136-have the `openssl-sys` crate while compiling a Rust project
11371137-just work.
11381138-11391139-A bare name like `go` is looked up in nixpkgs. You can also
11401140-point at any flake with the `flakeref#attr` syntax, so
11411141-`github:nixos/nixpkgs#hello` pulls `hello` straight out of
11421142-that flake.
11431143-11441144-```yaml
11451145-dependencies:
11461146- - go
11471147- - github:nixos/nixpkgs#hello
11481148-```
11491149-11501150-#### Registry
11511151-11521152-The `registry` field remaps flake references, the same way
11531153-`nix registry` does. This lets you pin or alias the flakes
11541154-used by `dependencies`.
11551155-11561156-For example, pin `nixpkgs` to `nixos-unstable` so that the
11571157-bare `go` above resolves from unstable, and alias your own
11581158-flake so you can use `myflake#tool` in `dependencies`:
11591159-11601160-```yaml
11611161-registry:
11621162- nixpkgs: github:nixos/nixpkgs/nixos-unstable
11631163- myflake: github:me/x
11641164-```
11651165-11661166-#### Caches
11671167-11681168-The `caches` field is a map of Nix binary cache URL to its
11691169-trusted public key. These are fed into the spindle's read
11701170-proxy, so the guest can substitute prebuilt paths from them
11711171-instead of building everything from scratch.
11721172-11731173-```yaml
11741174-caches:
11751175- https://nix-community.cachix.org: "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
11761176-```
11771177-11781178-#### Services and virtualisation
11791179-11801180-The `services` and `virtualisation` fields are passed straight
11811181-through to NixOS. Anything you could write under
11821182-`services.*` or `virtualisation.*` in a NixOS configuration,
11831183-you can write here, and it's brought up before any of your
11841184-steps run.
11851185-11861186-As a convenience, `true` works as shorthand for
11871187-`.enable = true` anywhere an `enable` option exists (e.g.
11881188-`virtualisation.docker: true`).
11891189-11901190-```yaml
11911191-services:
11921192- postgresql:
11931193- enable: true
11941194- ensureDatabases: ["spindle-workflow"]
11951195- ensureUsers:
11961196- - name: spindle-workflow
11971197- ensureDBOwnership: true
11981198-11991199-virtualisation:
12001200- docker: true
12011201-```
12021202-12031203-#### Recipes
12041204-12051205-##### Lint, test and build a Node project
12061206-12071207-```yaml
12081208-when:
12091209- - event: ["push", "pull_request"]
12101210- branch: ["main"]
12111211-12121212-engine: microvm
12131213-image: nixos
12141214-12151215-dependencies:
12161216- - pnpm
12171217-12181218-steps:
12191219- - name: "Install dependencies"
12201220- command: pnpm install --frozen-lockfile
12211221- - name: "Lint and test"
12221222- command: |
12231223- pnpm run lint
12241224- pnpm test
12251225- - name: "Build"
12261226- command: pnpm run build
12271227-```
12281228-12291229-##### Check formatting
12301230-12311231-```yaml
12321232-when:
12331233- - event: ["push", "pull_request"]
12341234- branch: ["main"]
12351235-12361236-engine: microvm
12371237-image: alpine # slimmer image for checking the formatting
12381238-12391239-steps:
12401240- - name: "Install go"
12411241- command: apk add go
12421242- - name: "Check formatting"
12431243- command: test -z $(gofmt -l .)
12441244-```
12451245-12461246-##### Build a Rust project that links OpenSSL
12471247-12481248-```yaml
12491249-when:
12501250- - event: ["push", "pull_request"]
12511251- branch: ["main"]
12521252-12531253-engine: microvm
12541254-image: nixos
12551255-12561256-dependencies:
12571257- - gcc
12581258- - cargo
12591259- - rustc
12601260- - clippy
12611261- - rustfmt
12621262- - pkg-config # exports PKG_CONFIG_PATH for the libraries below
12631263- - openssl # the C library + headers openssl-sys links against
12641264-12651265-steps:
12661266- - name: "Check formatting"
12671267- command: cargo fmt --check
12681268- - name: "Clippy"
12691269- command: cargo clippy --all-targets -- -D warnings
12701270- - name: "Test"
12711271- command: cargo test --all
12721272- - name: "Release build"
12731273- command: cargo build --release
12741274-```
12751275-12761276-##### Run migrations and integration tests against PostgreSQL
12771277-12781278-```yaml
12791279-when:
12801280- - event: ["push", "pull_request"]
12811281- branch: ["main"]
12821282-12831283-engine: microvm
12841284-image: nixos
12851285-12861286-environment:
12871287- DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql"
12881288-12891289-dependencies:
12901290- - gcc
12911291- - cargo
12921292- - rustc
12931293- - pkg-config
12941294- - openssl
12951295- - sqlx-cli
12961296-12971297-services:
12981298- postgresql:
12991299- enable: true
13001300- # has to be same name as the user for peer auth to work automatically
13011301- ensureDatabases: ["spindle-workflow"]
13021302- ensureUsers:
13031303- - name: spindle-workflow
13041304- ensureDBOwnership: true
13051305-13061306-steps:
13071307- - name: "Run migrations"
13081308- command: sqlx migrate run
13091309- - name: "Integration tests"
13101310- command: cargo test --all
13111311-```
13121312-13131313-##### Build and push a Docker image on tag
13141314-13151315-```yaml
13161316-when:
13171317- - event: ["push"]
13181318- tag: ["v*"]
13191319-13201320-engine: microvm
13211321-image: nixos
13221322-13231323-virtualisation:
13241324- docker: true
13251325-13261326-steps:
13271327- - name: "Build and push to ghcr.io"
13281328- command: |
13291329- set -euo pipefail
13301330-13311331- echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
13321332- image="ghcr.io/$REGISTRY_USER/myapp:$TANGLED_REF_NAME"
13331333-13341334- docker build -t "$image" -t "ghcr.io/$REGISTRY_USER/myapp:latest" .
13351335- docker push "$image"
13361336- docker push "ghcr.io/$REGISTRY_USER/myapp:latest"
13371337-```
13381338-13391339-##### Deploy to Cloudflare Workers on tag
13401340-13411341-```yaml
13421342-# .tangled/workflows/deploy.yml
13431343-when:
13441344- - event: ["push"]
13451345- tag: ["v*"]
13461346-13471347-engine: microvm
13481348-image: nixos
13491349-13501350-dependencies:
13511351- - pnpm
13521352-13531353-steps:
13541354- - name: "Install dependencies"
13551355- command: pnpm install --frozen-lockfile
13561356- - name: "Deploy worker"
13571357- # `wrangler` picks up `CLOUDFLARE_API_TOKEN` from the env.
13581358- # set it under **Settings → Secrets**.
13591359- command: pnpm exec wrangler deploy
13601360-```
13611361-13621362-##### Publish a release artifact
13631363-13641364-```yaml
13651365-when:
13661366- - event: ["push"]
13671367- tag: ["v*"] # trigger on versions
13681368-13691369-engine: microvm
13701370-image: nixos
13711371-13721372-dependencies:
13731373- - go
13741374-13751375-steps:
13761376- - name: "Build release binary"
13771377- command: |
13781378- mkdir -p dist
13791379- CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o dist/myapp ./cmd/myapp
13801380-13811381- - name: "Publish artifact record"
13821382- command: |
13831383- set -euo pipefail
13841384- # change this if you're not on `tngl.sh`
13851385- PDS="https://tngl.sh"
13861386- # also update this to your handle or did
13871387- ATP_IDENTIFIER="user.tngl.sh"
13881388- ARTIFACT_PATH="dist/myapp"
13891389- ARTIFACT_NAME="myapp"
13901390-13911391- # set `ATP_APP_PASSWORD` under **Settings → Secrets**
13921392- session=$(curl -fsS -X POST "$PDS/xrpc/com.atproto.server.createSession" \
13931393- -H "Content-Type: application/json" \
13941394- -d "{\"identifier\":\"$ATP_IDENTIFIER\",\"password\":\"$ATP_APP_PASSWORD\"}")
13951395- jwt=$(echo "$session" | jq -r .accessJwt)
13961396- did=$(echo "$session" | jq -r .did)
13971397-13981398- # upload the binary as a blob
13991399- blob=$(curl -fsS -X POST "$PDS/xrpc/com.atproto.repo.uploadBlob" \
14001400- -H "Authorization: Bearer $jwt" \
14011401- -H "Content-Type: application/octet-stream" \
14021402- --data-binary @"$ARTIFACT_PATH")
14031403-14041404- # note that this requires an annotated tag (`git tag -a v1.0.0 -m ...`)
14051405- tag_hash=$(git rev-parse "$TANGLED_REF_NAME^{tag}")
14061406- tag_bytes=$(printf '%s' "$tag_hash" | xxd -r -p | base64 | tr -d '=')
14071407-14081408- # the sh.tangled.repo.artifact record for your artifact
14091409- record=$(jq -n \
14101410- --arg did "$did" \
14111411- --arg tag "$tag_bytes" \
14121412- --arg name "$ARTIFACT_NAME" \
14131413- --arg repo "$TANGLED_REPO_URL" \
14141414- --arg created "$(date -Iseconds)" \
14151415- --argjson blob "$(echo "$blob" | jq .blob)" '{
14161416- repo: $did,
14171417- collection: "sh.tangled.repo.artifact",
14181418- validate: false,
14191419- record: {
14201420- "$type": "sh.tangled.repo.artifact",
14211421- tag: {"$bytes": $tag},
14221422- name: $name,
14231423- repo: $repo,
14241424- artifact: $blob,
14251425- createdAt: $created
14261426- }
14271427- }')
14281428-14291429- # create the record on the PDS
14301430- curl -fsS -X POST "$PDS/xrpc/com.atproto.repo.createRecord" \
14311431- -H "Authorization: Bearer $jwt" \
14321432- -H "Content-Type: application/json" \
14331433- -d "$record"
14341434-```
14351435-14361436-## Self-hosting guide
14371437-14381438-### Prerequisites
14391439-14401440-- Go
14411441-- For the **nixery** engine: Docker (or Podman with Docker
14421442- compatibility enabled).
14431443-- For the **microVM** engine: a Linux host with KVM, plus the
14441444- microVM host dependencies described in [Running microVM
14451445- workflows](#running-microvm-workflows).
14461446-14471447-### Configuration
14481448-14491449-Spindle is configured using environment variables. The following environment variables are available:
14501450-14511451-- `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
14521452-- `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
14531453-- `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
14541454-- `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
14551455-- `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
14561456-- `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
14571457-- `SPINDLE_SERVER_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
14581458-- `SPINDLE_SERVER_DOCKER_SOCKET`: Path to Docker socket to expose to invoked Spindle containers (default: `""`).
14591459-- `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
14601460-- `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
14611461-14621462-For the microVM engine, the following are also available
14631463-(prefix `SPINDLE_MICROVM_PIPELINES_`):
14641464-14651465-- `SPINDLE_MICROVM_PIPELINES_IMAGE_DIR`: Directory containing
14661466- microVM images (**required** to use the engine). See
14671467- [Running microVM workflows](#running-microvm-workflows).
14681468-- `SPINDLE_MICROVM_PIPELINES_DEFAULT_IMAGE`: Image used when a
14691469- workflow doesn't set `image` (default: `"nixos-x86_64"`).
14701470-- `SPINDLE_MICROVM_PIPELINES_OVERLAY_DIR`: Where per-workflow
14711471- temporary disks are created (default: the system temp dir).
14721472-- `SPINDLE_MICROVM_PIPELINES_ENABLE_KVM`: Use KVM hardware
14731473- acceleration (default: `true`). Without KVM, guests fall
14741474- back to slow software emulation.
14751475-- `SPINDLE_MICROVM_PIPELINES_WORKFLOW_TIMEOUT`: Default
14761476- workflow timeout (default: `"5m"`).
14771477-14781478-Optional resource limits (a value of `0` disables that
14791479-limit). The limits cap usage across all running microVM
14801480-workflows:
14811481-14821482-- `SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_MEMORY_MIB`
14831483-- `SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_VCPUS`
14841484-- `SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_DISK_MIB`
14851485-14861486-Optional cgroup enforcement:
14871487-14881488-- `SPINDLE_MICROVM_PIPELINES_ENABLE_CGROUPS`: Place each
14891489- workflow's QEMU and slirp4netns in a per-workflow cgroup=
14901490- (default: `false`).
14911491-- `SPINDLE_MICROVM_PIPELINES_CGROUP_PARENT`: Parent cgroup;
14921492- `self` resolves the spindle service's own cgroup (default:
14931493- `"self"`).
14941494-- `SPINDLE_MICROVM_PIPELINES_CGROUP_PIDS_MAX`: Max processes
14951495- per workflow cgroup (default: `4096`).
14961496-- `SPINDLE_MICROVM_PIPELINES_CGROUP_SWAP_MAX_MIB`: Max swap
14971497- per workflow cgroup (default: `0`, no swap).
14981498-- `SPINDLE_MICROVM_PIPELINES_CGROUP_SUPERVISOR_MEMORY_MIN_MIB`:
14991499- Memory protected for spindle itself so it isn't OOM-killed
15001500- before the workflows (default: `512`).
15011501-15021502-To push paths built inside microVMs back to a shared Nix
15031503-cache (and read from it), configure the cache (prefix
15041504-`SPINDLE_NIX_CACHE_`):
15051505-15061506-- `SPINDLE_NIX_CACHE_READ_URLS`: Comma-separated binary cache
15071507- URLs the guest reads from.
15081508-- `SPINDLE_NIX_CACHE_TRUSTED_PUBLIC_KEYS`: Comma-separated
15091509- trusted public keys for those caches.
15101510-- `SPINDLE_NIX_CACHE_UPLOAD_URL`: Cache URL that paths built
15111511- in the guest are uploaded to.
15121512-15131513-### Running spindle
15141514-15151515-1. **Set the environment variables.** For example:
15161516-15171517- ```shell
15181518- export SPINDLE_SERVER_HOSTNAME="your-hostname"
15191519- export SPINDLE_SERVER_OWNER="your-did"
15201520- ```
15211521-15221522-2. **Build the Spindle binary.**
15231523-15241524- ```shell
15251525- cd core
15261526- go mod download
15271527- go build -o cmd/spindle/spindle cmd/spindle/main.go
15281528- ```
15291529-15301530-3. **Create the log directory.**
15311531-15321532- ```shell
15331533- sudo mkdir -p /var/log/spindle
15341534- sudo chown $USER:$USER -R /var/log/spindle
15351535- ```
15361536-15371537-4. **Run the Spindle binary.**
15381538-15391539- ```shell
15401540- ./cmd/spindle/spindle
15411541- ```
15421542-15431543-Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
15441544-15451545-### Running microVM workflows
15461546-15471547-The microVM engine needs a few extra things on the host, and
15481548-it needs images to boot.
15491549-15501550-#### Host dependencies
15511551-15521552-microVM workflows depend on a handful of host tools and
15531553-devices. spindle checks for the ones an image needs right
15541554-before it launches, so a missing dependency surfaces as a
15551555-clear error. You'll need:
15561556-15571557-- `qemu`: the runner. The QEMU binary for the image's arch
15581558- must be present (e.g. `qemu-system-x86_64`).
15591559-- `mkfs.ext4` (from `e2fsprogs`): to format the per-workflow
15601560- writable volumes.
15611561-- [`slirp4netns`](https://github.com/rootless-containers/slirp4netns#install),
15621562- `ip` (from `iproute2`), `mount` and `unshare` (from `util-linux`):
15631563- used to sandbox guest networking.
15641564-- `/dev/kvm`: for hardware acceleration (unless you disable
15651565- KVM with `SPINDLE_MICROVM_PIPELINES_ENABLE_KVM=false`).
15661566-- `/dev/vhost-vsock`: used by QEMU to enable guest-to-host vsock
15671567- communication.
15681568-- `/dev/vsock`: used by spindle on the host to listen on vsock ports
15691569- and accept guest agent connections.
15701570-- `/dev/net/tun`: required by `slirp4netns` to set up tap devices
15711571- for sandboxed guest networking.
15721572-15731573-On NixOS, the [spindle
15741574-module](https://tangled.org/tangled.org/core/blob/master/nix/modules/spindle.nix)
15751575-puts `qemu`, `e2fsprogs`, `slirp4netns`, `iproute2` and
15761576-`util-linux` on the service's `PATH` for you.
15771577-15781578-#### Container virtualization
15791579-15801580-Running `spindle` inside a container (e.g., Docker, Podman, or LXD) requires passing host device nodes into the container and granting the runtime additional privileges. Because spindle uses nested namespaces (`unshare`) and helper tools (`slirp4netns`), the container configuration will require:
15811581-15821582-- `/dev/vsock`, `/dev/vhost-vsock`, `/dev/kvm`, and `/dev/net/tun` mapped from the host.
15831583-- `NET_ADMIN` and `SYS_ADMIN` capabilities to manage network namespaces and mounts inside the container.
15841584-- Relaxed seccomp filters (e.g., `seccomp=unconfined`) and SELinux/AppArmor containment if they restrict namespace creation or device access.
15851585-15861586-#### Building images
15871587-15881588-Images are built with Nix. The flake exposes packages for the
15891589-two stock images (use the `-tarball` prefixed ones for a gzipped
15901590-tarball you can copy to another host):
15911591-15921592-```shell
15931593-# a NixOS image
15941594-nix build .#spindle-nixos-image
15951595-# an Alpine image
15961596-nix build .#spindle-alpine-image
15971597-```
15981598-15991599-#### Installing images
16001600-16011601-Spindle looks for images in
16021602-`SPINDLE_MICROVM_PIPELINES_IMAGE_DIR`. An image is resolved by
16031603-the name a workflow puts in its `image` field, matched
16041604-literally against what's on disk:
16051605-16061606-1. a directory `<name>/` containing a `spec.json` (next to the
16071607- kernel/initrd/store-disk), or
16081608-2. a flat `<name>.json` self-contained spec.
16091609-16101610-Resolution depends only on the name and what's on disk, never
16111611-on the host doing the resolving, so the same workflow resolves
16121612-to the same image on every spindle. If you keep multiple
16131613-arches side by side, you can name them `<name>-<arch>` (e.g.
16141614-`nixos-x86_64`, `alpine-aarch64`); the suffix is just part of
16151615-the name. To make a name like `nixos` work if you are hosting
16161616-multiple arches, you can use symlinks.
16171617-16181618-On NixOS, you'll most likely want to use `systemd.tmpfiles.rules`
16191619-to set these up declaratively.
16201620-16211621-## Architecture
16221622-16231623-Spindle is a small CI runner service. Here's a high-level overview of how it operates:
16241624-16251625-- Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
16261626- [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
16271627-- When a new repo record comes through (typically when you add a spindle to a
16281628- repo from the settings), spindle then resolves the underlying knot and
16291629- subscribes to repo events (see:
16301630- [`sh.tangled.pipeline`](/lexicons/pipeline.json)).
16311631-- The spindle engine then handles execution of the pipeline, with results and
16321632- logs beamed on the spindle event stream over WebSocket
16331633-16341634-### The engines
16351635-16361636-Spindle has two execution backends, picked per-workflow with
16371637-the [`engine`](#engine) field:
16381638-16391639-- **nixery**: executes each step in a fresh Docker container
16401640- (Podman works too, if Docker compatibility is enabled so
16411641- that `/run/docker.sock` is created), with state persisted
16421642- across steps within the `/tangled/workspace` directory. The
16431643- base image for the container is constructed on the fly using
16441644- [Nixery](https://nixery.dev), which is/rhandy for caching
16451645- layers for frequently used packages.
16461646-- **microvm**: runs the whole workflow inside its own
16471647- microVM, supporting different images, with extra
16481648- configuration for NixOS images (e.g. services in workflow file)
16491649- See the [engine
16501650- README](https://tangled.org/tangled.org/core/blob/master/spindle/engines/microvm/README.md)
16511651- for the architecture in depth.
16521652-16531653-The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines).
16541654-16551655-## Secrets with openbao
16561656-16571657-This document covers setting up spindle to use OpenBao for secrets
16581658-management via OpenBao Proxy instead of the default SQLite backend.
16591659-16601660-### Overview
16611661-16621662-Spindle now uses OpenBao Proxy for secrets management. The proxy handles
16631663-authentication automatically using AppRole credentials, while spindle
16641664-connects to the local proxy instead of directly to the OpenBao server.
16651665-16661666-This approach provides better security, automatic token renewal, and
16671667-simplified application code.
16681668-16691669-### Installation
16701670-16711671-Install OpenBao from Nixpkgs:
16721672-16731673-```bash
16741674-nix shell nixpkgs#openbao # for a local server
16751675-```
16761676-16771677-### Setup
16781678-16791679-The setup process can is documented for both local development and production.
16801680-16811681-#### Local development
16821682-16831683-Start OpenBao in dev mode:
16841684-16851685-```bash
16861686-bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
16871687-```
16881688-16891689-This starts OpenBao on `http://localhost:8201` with a root token.
16901690-16911691-Set up environment for bao CLI:
16921692-16931693-```bash
16941694-export BAO_ADDR=http://localhost:8200
16951695-export BAO_TOKEN=root
16961696-```
16971697-16981698-#### Production
16991699-17001700-You would typically use a systemd service with a
17011701-configuration file. Refer to
17021702-[@tangled.org/infra](https://tangled.org/@tangled.org/infra)
17031703-for how this can be achieved using Nix.
17041704-17051705-Then, initialize the bao server:
17061706-17071707-```bash
17081708-bao operator init -key-shares=1 -key-threshold=1
17091709-```
17101710-17111711-This will print out an unseal key and a root key. Save them
17121712-somewhere (like a password manager). Then unseal the vault
17131713-to begin setting it up:
17141714-17151715-```bash
17161716-bao operator unseal <unseal_key>
17171717-```
17181718-17191719-All steps below remain the same across both dev and
17201720-production setups.
17211721-17221722-#### Configure openbao server
17231723-17241724-Create the spindle KV mount:
17251725-17261726-```bash
17271727-bao secrets enable -path=spindle -version=2 kv
17281728-```
17291729-17301730-Set up AppRole authentication and policy:
17311731-17321732-Create a policy file `spindle-policy.hcl`:
17331733-17341734-```hcl
17351735-# Full access to spindle KV v2 data
17361736-path "spindle/data/*" {
17371737- capabilities = ["create", "read", "update", "delete"]
17381738-}
17391739-17401740-# Access to metadata for listing and management
17411741-path "spindle/metadata/*" {
17421742- capabilities = ["list", "read", "delete", "update"]
17431743-}
17441744-17451745-# Allow listing at root level
17461746-path "spindle/" {
17471747- capabilities = ["list"]
17481748-}
17491749-17501750-# Required for connection testing and health checks
17511751-path "auth/token/lookup-self" {
17521752- capabilities = ["read"]
17531753-}
17541754-```
17551755-17561756-Apply the policy and create an AppRole:
17571757-17581758-```bash
17591759-bao policy write spindle-policy spindle-policy.hcl
17601760-bao auth enable approle
17611761-bao write auth/approle/role/spindle \
17621762- token_policies="spindle-policy" \
17631763- token_ttl=1h \
17641764- token_max_ttl=4h \
17651765- bind_secret_id=true \
17661766- secret_id_ttl=0 \
17671767- secret_id_num_uses=0
17681768-```
17691769-17701770-Get the credentials:
17711771-17721772-```bash
17731773-# Get role ID (static)
17741774-ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
17751775-17761776-# Generate secret ID
17771777-SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
17781778-17791779-echo "Role ID: $ROLE_ID"
17801780-echo "Secret ID: $SECRET_ID"
17811781-```
17821782-17831783-#### Create proxy configuration
17841784-17851785-Create the credential files:
17861786-17871787-```bash
17881788-# Create directory for OpenBao files
17891789-mkdir -p /tmp/openbao
17901790-17911791-# Save credentials
17921792-echo "$ROLE_ID" > /tmp/openbao/role-id
17931793-echo "$SECRET_ID" > /tmp/openbao/secret-id
17941794-chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
17951795-```
17961796-17971797-Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
17981798-17991799-```hcl
18001800-# OpenBao server connection
18011801-vault {
18021802- address = "http://localhost:8200"
18031803-}
18041804-18051805-# Auto-Auth using AppRole
18061806-auto_auth {
18071807- method "approle" {
18081808- mount_path = "auth/approle"
18091809- config = {
18101810- role_id_file_path = "/tmp/openbao/role-id"
18111811- secret_id_file_path = "/tmp/openbao/secret-id"
18121812- }
18131813- }
18141814-18151815- # Optional: write token to file for debugging
18161816- sink "file" {
18171817- config = {
18181818- path = "/tmp/openbao/token"
18191819- mode = 0640
18201820- }
18211821- }
18221822-}
18231823-18241824-# Proxy listener for spindle
18251825-listener "tcp" {
18261826- address = "127.0.0.1:8201"
18271827- tls_disable = true
18281828-}
18291829-18301830-# Enable API proxy with auto-auth token
18311831-api_proxy {
18321832- use_auto_auth_token = true
18331833-}
18341834-18351835-# Enable response caching
18361836-cache {
18371837- use_auto_auth_token = true
18381838-}
18391839-18401840-# Logging
18411841-log_level = "info"
18421842-```
18431843-18441844-#### Start the proxy
18451845-18461846-Start OpenBao Proxy:
18471847-18481848-```bash
18491849-bao proxy -config=/tmp/openbao/proxy.hcl
18501850-```
18511851-18521852-The proxy will authenticate with OpenBao and start listening on
18531853-`127.0.0.1:8201`.
18541854-18551855-#### Configure spindle
18561856-18571857-Set these environment variables for spindle:
18581858-18591859-```bash
18601860-export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
18611861-export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
18621862-export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
18631863-```
18641864-18651865-On startup, spindle will now connect to the local proxy,
18661866-which handles all authentication automatically.
18671867-18681868-### Production setup for proxy
18691869-18701870-For production, you'll want to run the proxy as a service:
18711871-18721872-Place your production configuration in
18731873-`/etc/openbao/proxy.hcl` with proper TLS settings for the
18741874-vault connection.
18751875-18761876-### Verifying setup
18771877-18781878-Test the proxy directly:
18791879-18801880-```bash
18811881-# Check proxy health
18821882-curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
18831883-18841884-# Test token lookup through proxy
18851885-curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
18861886-```
18871887-18881888-Test OpenBao operations through the server:
18891889-18901890-```bash
18911891-# List all secrets
18921892-bao kv list spindle/
18931893-18941894-# Add a test secret via the spindle API, then check it exists
18951895-bao kv list spindle/repos/
18961896-18971897-# Get a specific secret
18981898-bao kv get spindle/repos/your_repo_path/SECRET_NAME
18991899-```
19001900-19011901-### How it works
19021902-19031903-- Spindle connects to OpenBao Proxy on localhost (typically
19041904- port 8200 or 8201)
19051905-- The proxy authenticates with OpenBao using AppRole
19061906- credentials
19071907-- All spindle requests go through the proxy, which injects
19081908- authentication tokens
19091909-- Secrets are stored at
19101910- `spindle/repos/{sanitized_repo_path}/{secret_key}`
19111911-- Repository paths like `did:plc:alice/myrepo` become
19121912- `did_plc_alice_myrepo`
19131913-- The proxy handles all token renewal automatically
19141914-- Spindle no longer manages tokens or authentication
19151915- directly
19161916-19171917-### Troubleshooting
19181918-19191919-**Connection refused**: Check that the OpenBao Proxy is
19201920-running and listening on the configured address.
19211921-19221922-**403 errors**: Verify the AppRole credentials are correct
19231923-and the policy has the necessary permissions.
19241924-19251925-**404 route errors**: The spindle KV mount probably doesn't
19261926-exist—run the mount creation step again.
19271927-19281928-**Proxy authentication failures**: Check the proxy logs and
19291929-verify the role-id and secret-id files are readable and
19301930-contain valid credentials.
19311931-19321932-**Secret not found after writing**: This can indicate policy
19331933-permission issues. Verify the policy includes both
19341934-`spindle/data/*` and `spindle/metadata/*` paths with
19351935-appropriate capabilities.
19361936-19371937-Check proxy logs:
19381938-19391939-```bash
19401940-# If running as systemd service
19411941-journalctl -u openbao-proxy -f
19421942-19431943-# If running directly, check the console output
19441944-```
19451945-19461946-Test AppRole authentication manually:
19471947-19481948-```bash
19491949-bao write auth/approle/login \
19501950- role_id="$(cat /tmp/openbao/role-id)" \
19511951- secret_id="$(cat /tmp/openbao/secret-id)"
19521952-```
19531953-19541954-# Webhooks
19551955-19561956-Webhooks allow you to receive HTTP POST notifications when events occur in your repositories. This enables you to integrate Tangled with external services, trigger CI/CD pipelines, send notifications, or automate workflows.
19571957-19581958-## Overview
19591959-19601960-Webhooks send HTTP POST requests to URLs you configure whenever specific events happen. Currently, Tangled supports push, repository rename, and pull request events, with more event types coming soon.
19611961-19621962-## Configuring webhooks
19631963-19641964-To set up a webhook for your repository:
19651965-19661966-1. Navigate to your repository
19671967-2. Go to **Settings → Hooks**
19681968-3. Click **new webhook**
19691969-4. Configure your webhook:
19701970- - **Payload URL**: The endpoint that will receive the webhook POST requests
19711971- - **Secret**: An optional secret key for verifying webhook authenticity (leave blank to send unsigned webhooks)
19721972- - **Events**: Select which events trigger the webhook
19731973- - **Active**: Toggle whether the webhook is enabled
19741974-19751975-## Webhook payload
19761976-19771977-### Push
19781978-19791979-When a push event occurs, Tangled sends a POST request with a JSON payload of the format:
19801980-19811981-```json
19821982-{
19831983- "after": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5",
19841984- "before": "c04ddf64eddc90e4e2a9846ba3b43e67a0e2865e",
19851985- "pusher": {
19861986- "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
19871987- },
19881988- "ref": "refs/heads/main",
19891989- "repository": {
19901990- "clone_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
19911991- "created_at": "2025-09-15T08:57:23Z",
19921992- "description": "an example repository",
19931993- "fork": false,
19941994- "full_name": "did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
19951995- "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
19961996- "name": "some-repo",
19971997- "open_issues_count": 5,
19981998- "owner": {
19991999- "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
20002000- },
20012001- "ssh_url": "ssh://git@tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
20022002- "stars_count": 1,
20032003- "updated_at": "2025-09-15T08:57:23Z"
20042004- }
20052005-}
20062006-```
20072007-20082008-### Pull request
20092009-20102010-Pull request events are sent as separate event types, so you can subscribe to
20112011-exactly the transitions you care about:
20122012-20132013-- `pull_request:created` — a pull request was opened
20142014-- `pull_request:resubmitted` — a new round (revision) was pushed to a pull request
20152015-- `pull_request:merged` — a pull request was merged
20162016-- `pull_request:closed` — a pull request was closed
20172017-- `pull_request:reopened` — a closed pull request was reopened
20182018-20192019-All pull request events share the same payload format:
20202020-20212021-```json
20222022-{
20232023- "action": "created",
20242024- "pull_request": {
20252025- "number": 4,
20262026- "title": "add dark mode",
20272027- "body": "implements dark mode as discussed in #2",
20282028- "state": "open",
20292029- "target_branch": "main",
20302030- "source": {
20312031- "branch": "dark-mode",
20322032- "sha": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5"
20332033- },
20342034- "round_number": 0,
20352035- "owner": {
20362036- "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
20372037- },
20382038- "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo/pulls/4",
20392039- "patch_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo/pulls/4/round/0.patch",
20402040- "created_at": "2025-09-15T08:57:23Z"
20412041- },
20422042- "repository": { ... },
20432043- "sender": {
20442044- "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
20452045- }
20462046-}
20472047-```
20482048-20492049-Notes:
20502050-20512051-- `action` mirrors the event type suffix (`created`, `resubmitted`, `merged`, `closed`, `reopened`).
20522052-- `repository` has the same format as in the push payload.
20532053-- The patch itself is not embedded in the payload (patches can be large); fetch it from `patch_url` instead. `round_number` identifies the latest round, and `patch_url` always points at that round's patch.
20542054-- `source` is only present for branch-based and fork-based pull requests; it is omitted for patch-based pulls. For fork-based pulls, `source.repo` contains the DID of the source repository.
20552055-- `sender` is the user who performed the action.
20562056-20572057-## HTTP headers
20582058-20592059-Each webhook request includes the following headers:
20602060-20612061-- `Content-Type: application/json`
20622062-- `User-Agent: Tangled-Hook/<short-sha>` — User agent with short SHA of the commit (push events); `Tangled-Hook/pull_request` for pull request events
20632063-- `X-Tangled-Event: push` — The full event type (e.g. `push`, `pull_request:merged`)
20642064-- `X-Tangled-Hook-ID: <webhook-id>` — The webhook ID
20652065-- `X-Tangled-Delivery: <uuid>` — Unique delivery ID
20662066-- `X-Tangled-Signature-256: sha256=<hmac>` — HMAC-SHA256 signature (if secret configured)
20672067-20682068-## Verifying webhook signatures
20692069-20702070-If you configured a secret, you should verify the webhook signature to ensure requests are authentic. For example, in Go:
20712071-20722072-```go
20732073-package main
20742074-20752075-import (
20762076- "crypto/hmac"
20772077- "crypto/sha256"
20782078- "encoding/hex"
20792079- "io"
20802080- "net/http"
20812081- "strings"
20822082-)
20832083-20842084-func verifySignature(payload []byte, signatureHeader, secret string) bool {
20852085- // Remove 'sha256=' prefix from signature header
20862086- signature := strings.TrimPrefix(signatureHeader, "sha256=")
20872087-20882088- // Compute expected signature
20892089- mac := hmac.New(sha256.New, []byte(secret))
20902090- mac.Write(payload)
20912091- expected := hex.EncodeToString(mac.Sum(nil))
20922092-20932093- // Use constant-time comparison to prevent timing attacks
20942094- return hmac.Equal([]byte(signature), []byte(expected))
20952095-}
20962096-20972097-func webhookHandler(w http.ResponseWriter, r *http.Request) {
20982098- // Read the request body
20992099- payload, err := io.ReadAll(r.Body)
21002100- if err != nil {
21012101- http.Error(w, "Bad request", http.StatusBadRequest)
21022102- return
21032103- }
21042104-21052105- // Get signature from header
21062106- signatureHeader := r.Header.Get("X-Tangled-Signature-256")
21072107-21082108- // Verify signature
21092109- if signatureHeader != "" && verifySignature(payload, signatureHeader, yourSecret) {
21102110- // Webhook is authentic, process it
21112111- processWebhook(payload)
21122112- w.WriteHeader(http.StatusOK)
21132113- } else {
21142114- http.Error(w, "Invalid signature", http.StatusUnauthorized)
21152115- }
21162116-}
21172117-```
21182118-21192119-## Delivery retries
21202120-21212121-Webhooks are automatically retried on failure:
21222122-21232123-- **3 total attempts** (1 initial + 2 retries)
21242124-- **Exponential backoff** starting at 1 second, max 10 seconds
21252125-- **Retried on**:
21262126- - Network errors
21272127- - HTTP 5xx server errors
21282128-- **Not retried on**:
21292129- - HTTP 4xx client errors (bad request, unauthorized, etc.)
21302130-21312131-### Timeouts
21322132-21332133-Webhook requests timeout after 30 seconds. If your endpoint needs more time:
21342134-21352135-1. Respond with 200 OK immediately
21362136-2. Process the webhook asynchronously in the background
21372137-21382138-## Example integrations
21392139-21402140-### Discord notifications
21412141-21422142-```javascript
21432143-app.post("/webhook", (req, res) => {
21442144- const payload = req.body;
21452145-21462146- fetch("https://discord.com/api/webhooks/...", {
21472147- method: "POST",
21482148- headers: { "Content-Type": "application/json" },
21492149- body: JSON.stringify({
21502150- content: `New push to ${payload.repository.full_name}`,
21512151- embeds: [
21522152- {
21532153- title: `${payload.pusher.did} pushed to ${payload.ref}`,
21542154- url: payload.repository.html_url,
21552155- color: 0x00ff00,
21562156- },
21572157- ],
21582158- }),
21592159- });
21602160-21612161- res.status(200).send("OK");
21622162-});
21632163-```
21642164-21652165-# Migrating knots and spindles
21662166-21672167-Sometimes, non-backwards compatible changes are made to the
21682168-knot/spindle XRPC APIs. If you host a knot or a spindle, you
21692169-will need to follow this guide to upgrade. Typically, this
21702170-only requires you to deploy the newest version.
21712171-21722172-This document is laid out in reverse-chronological order.
21732173-Newer migration guides are listed first, and older guides
21742174-are further down the page.
21752175-21762176-## Upgrading to v1.16.0-alpha
21772177-21782178-Starting with v1.16.0-alpha, spindles own CI pipeline data
21792179-directly. The appview no longer stores pipeline runs or follows
21802180-spindle event streams for pipeline history. Instead, it asks the
21812181-configured spindle for pipeline lists, single pipeline details,
21822182-workflow logs, retries, and cancellations over XRPC.
21832183-21842184-This means that existing pipeline logs / runs won't appear after
21852185-you upgrade. Existing pipeline history from the appview cannot be
21862186-automatically migrated. If you want to migrate your data, you can
21872187-reach out to us and we will send you an SQL file that'll add the data
21882188-into your spindle.
21892189-21902190-- Upgrade to the latest tag (v1.16.0 or above)
21912191-- Head to the [spindle
21922192- dashboard](https://tangled.org/settings/spindles) and hit the
21932193- "retry" button to verify your spindle
21942194-21952195-## Upgrading to v1.15.0-alpha
21962196-21972197-With v1.15.0-alpha, a knot itself owns its members and
21982198-per-repo collaborators directly. Previously this data was sourced from
21992199-PDS records (`sh.tangled.knot.member` and `sh.tangled.repo.collaborator`)
22002200-that the appview and the knot both read off the firehose.
22012201-The knot is now the source of truth and serves them over XRPC instead:
22022202-22032203-- `sh.tangled.knot.addMember`, `sh.tangled.knot.removeMember`, `sh.tangled.knot.listMembers`
22042204-- `sh.tangled.repo.addCollaborator`, `sh.tangled.repo.removeCollaborator`, `sh.tangled.repo.listCollaborators`
22052205-22062206-Until your knot is upgraded, the appview keeps reading its
22072207-members and collaborators from the old firehose-sourced records.
22082208-Upgrade to move your knot onto knot-owned access control.
22092209-22102210-- Upgrade to the latest tag (v1.15.0 or above)
22112211-- Head to the [knot dashboard](https://tangled.org/settings/knots) and
22122212- hit the "retry" button to verify your knot
22132213-22142214-## Upgrading to v1.14.0-alpha
22152215-22162216-Starting with v1.14.0-alpha, the fully knot uses the repoDID as its
22172217-canonical handle for repositories. This unlocks repository
22182218-renames from the appview UI and changes the wire format for
22192219-the following lexicons (`sh.tangled.repo.pull`, `sh.tangled.repo.collaborator`,
22202220-`sh.tangled.repo.issue`, `sh.tangled.git.refUpdate`).
22212221-22222222-Knots that have not been upgraded may silently drop new push
22232223-events, pull requests, issues, and collaborator invites for
22242224-repositories they host until upgraded. So upgrade please!!!
22252225-22262226-- Upgrade to the latest tag (v1.14.0 or above)
22272227-- Head to the [knot dashboard](https://tangled.org/settings/knots) and
22282228- hit the "retry" button to verify your knot
22292229-22302230-## Upgrading to v1.13.0-alpha
22312231-22322232-Starting with v1.13.0-alpha, every repository on a knot is
22332233-assigned a DID. This makes repositories stable across
22342234-renames and transfers.
22352235-22362236-When you upgrade your knot to this version, the server will
22372237-automatically mint DIDs for all existing repositories on
22382238-startup. This is a one-time process and you may see
22392239-additional log output during the first boot as DIDs are
22402240-assigned.
22412241-22422242-- Upgrade to the latest tag (v1.13.0 or above)
22432243-- Head to the [knot dashboard](https://tangled.org/settings/knots) and
22442244- hit the "retry" button to verify your knot
22452245-22462246-## Upgrading from v1.8.x
22472247-22482248-After v1.8.2, the HTTP API for knots and spindles has been
22492249-deprecated and replaced with XRPC. Repositories on outdated
22502250-knots will not be viewable from the appview. Upgrading is
22512251-straightforward however.
22522252-22532253-For knots:
22542254-22552255-- Upgrade to the latest tag (v1.9.0 or above)
22562256-- Head to the [knot dashboard](https://tangled.org/settings/knots) and
22572257- hit the "retry" button to verify your knot
22582258-22592259-For spindles:
22602260-22612261-- Upgrade to the latest tag (v1.9.0 or above)
22622262-- Head to the [spindle
22632263- dashboard](https://tangled.org/settings/spindles) and hit the
22642264- "retry" button to verify your spindle
22652265-22662266-## Upgrading from v1.7.x
22672267-22682268-After v1.7.0, knot secrets have been deprecated. You no
22692269-longer need a secret from the appview to run a knot. All
22702270-authorized commands to knots are managed via [Inter-Service
22712271-Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
22722272-Knots will be read-only until upgraded.
22732273-22742274-Upgrading is quite easy, in essence:
22752275-22762276-- `KNOT_SERVER_SECRET` is no more, you can remove this
22772277- environment variable entirely
22782278-- `KNOT_SERVER_OWNER` is now required on boot, set this to
22792279- your DID. You can find your DID in the
22802280- [settings](https://tangled.org/settings) page.
22812281-- Restart your knot once you have replaced the environment
22822282- variable
22832283-- Head to the [knot dashboard](https://tangled.org/settings/knots) and
22842284- hit the "retry" button to verify your knot. This simply
22852285- writes a `sh.tangled.knot` record to your PDS.
22862286-22872287-If you use the nix module, simply bump the flake to the
22882288-latest revision, and change your config block like so:
22892289-22902290-```diff
22912291- services.tangled.knot = {
22922292- enable = true;
22932293- server = {
22942294-- secretFile = /path/to/secret;
22952295-+ owner = "did:plc:foo";
22962296- };
22972297- };
22982298-```
22992299-23002300-# Bobbin
23012301-23022302-Bobbin is an API appview for Tangled records. It serves XRPC
23032303-endpoints for `sh.tangled.*`, with it you can get repos,
23042304-issues, pulls, comments, follows, stars, labels, pipelines,
23052305-and profiles. It is read-only, there is no auth, since that
23062306-should all be handled direct-to-PDS and knot respectively.
23072307-23082308-**Bobbin has no permanent storage**.
23092309-23102310-It is only a glorified edge index, in the graph theory
23112311-sense. Additionally it has a record cache, re-filled on
23122312-demand. All other data that Bobbin serves comes live from
23132313-PDSes & knots.
23142314-23152315-## What Bobbin needs
23162316-23172317-The way that Bobbin is able to pull off being
23182318-so stateless is by moving state upstream.
23192319-Primarily it depends on an instance of
23202320-[Hydrant](https://tangled.org/did:plc:6v3ul2ptnqctyxwkz5ti4amn)
23212321-, which is the service that gives an event stream
23222322-for Bobbin to quickly backfill from on every restart.
23232323-Backfilling ought to take less than a couple of minutes
23242324-maximum. If the upstream instance of Hydrant fails
23252325-while Bobbin is live, its list/count endpoints stop
23262326-advancing and report a stale cursor. Single-lookups
23272327-will continue working, due to the second dependency:
23282328-[Slingshot](https://tangled.org/did:plc:c7mc2fn47ihdihul4vjwsuy3/tree/main/slingshot).
23292329-Slingshot fetches individual records & resolves identities.
23302330-If the upstream instance of Slingshot fails, single-lookups
23312331-will fail with a `502` error. There are some aggregation
23322332-endpoints that use Slingshot for hydrating, which will also
23332333-fail.
23342334-23352335-A soft dependency that ought to exist for Bobbin to operate
23362336-correctly is simply the plethora of knots that are out
23372337-there, that Bobbin talks to directly for git data and, for
23382338-knots at v1.15+, members & collaborators.
23392339-23402340-## Building Bobbin
23412341-23422342-Bobbin is under [Tangled's core monorepo, under bobbin/](https://tangled.org/did:plc:j5hmlfdrwkvtxm7cjmu7j2is/tree/master/bobbin).
23432343-Here's an easy local debug-build:
23442344-23452345-```sh
23462346-cargo build -p bobbin
23472347-```
23482348-23492349-Bobbin loves being in a container. When using
23502350-`bobbin/containerfiles/bobbin.Containerfile`, it runs `cargo
23512351-build --release --bin bobbin --package bobbin` within a
23522352-little Debian runtime, exposing port 8090.
23532353-23542354-## Configuration
23552355-23562356-The best way to configure Bobbin is via a toml config file.
23572357-There's an `example.toml` in [Bobbin's subdir](https://tangled.org/did:plc:j5hmlfdrwkvtxm7cjmu7j2is/blob/master/bobbin/example.toml).
23582358-Every value is overridable by a `BOBBIN_*` env var.
23592359-The load order is env, then `--config <path>`, then
23602360-`/etc/bobbin/config.toml`, then built-in defaults.
23612361-23622362-Load and check a config without starting the server:
23632363-23642364-```sh
23652365-bobbin --config config.toml validate
23662366-```
23672367-23682368-Minimal config is the two upstream URLs. The hydrant URL
23692369-takes `ws://` or `wss://`. An `http://` or `https://`
23702370-URL is rewritten to the matching websocket scheme at
23712371-connection-time.
23722372-23732373-```toml
23742374-[server]
23752375-binds = ["127.0.0.1:8090"]
23762376-23772377-# Loopback-only & can leave empty to disable debug introspection.
23782378-debug_bind = "127.0.0.1:8091"
23792379-23802380-[hydrant]
23812381-url = "https://hydrant.example.com"
23822382-23832383-[slingshot]
23842384-url = "https://slingshot.example.com"
23852385-```
23862386-23872387-> 🦪 Lewis
23882388->
23892389-> At time of writing, we (Tangled) don't host public
23902390-> instances of Hydrant or Slingshot. You will have to
23912391-> find public instances or spin these up yourself! :P
23922392-23932393-Take a gander in the project's example.toml for an
23942394-exhaustive list of things to configure.
23952395-23962396-You will discover fun things such as a configurable adaptive
23972397-loop that watches the cgroup memory limit & throttles heavy
23982398-requests under pressure. It only works if it detects a
23992399-cgroup limit is present. The config for that is in the
24002400-`[backpressure]` block of the config template.
24012401-24022402-## Running Bobbin
24032403-24042404-Start the server using a config toml:
24052405-24062406-```bash
24072407-bobbin --config config.toml
24082408-```
24092409-Bobbin wakes up in a cold sweat and immediately gets to
24102410-work:
24112411-1. It binds its listeners, connects to the Hydrant stream
24122412- in the background.
24132413-2. It serves requests from the first
24142414- moment it's alive, even before the Hydrant stream connects
24152415- or finishes catching up. Having a cold Hydrant itself
24162416- costs only latency and approximate counts.
24172417-24182418-## The API
24192419-24202420-**Single lookups** take a record's AT-URI.
24212421-24222422-- `getRepo` takes the repo URI:
24232423-24242424-```sh
24252425-curl "$BOBBIN/xrpc/sh.tangled.repo.getRepo?repo=at://did:plc:boltless/sh.tangled.repo/squid"
24262426-```
24272427-```json
24282428-{
24292429- "uri": "at://did:plc:boltless/sh.tangled.repo/squid",
24302430- "cid": "bafyrei...",
24312431- "value": { "$type": "sh.tangled.repo", "knot": "knot1.tangled.sh", "description": "...", "createdAt": "..." }
24322432-}
24332433-```
24342434-24352435-- `getProfile` takes the full profile record URI, so a bare
24362436- handle or DID will not resolve:
24372437-24382438-```sh
24392439-curl "$BOBBIN/xrpc/sh.tangled.actor.getProfile?actor=at://did:plc:boltless/sh.tangled.actor.profile/self"
24402440-```
24412441-24422442-- If Slingshot cannot serve the record, the response is `502`:
24432443-24442444-```json
24452445-{ "error": "UpstreamFailed", "message": "upstream unavailable: ..." }
24462446-```
24472447-24482448-**Aggregation** endpoints come in `list*` and `count*` pairs,
24492449-each with a `*By` sibling, and require a `subject` query param.
24502450-24512451-- `listRepos` and `countRepos` key on the owner DID:
24522452-24532453-```sh
24542454-curl "$BOBBIN/xrpc/sh.tangled.repo.countRepos?subject=did:plc:boltless"
24552455-```
24562456-```json
24572457-{ "count": 7, "distinctAuthors": 1 }
24582458-```
24592459-24602460-```sh
24612461-curl "$BOBBIN/xrpc/sh.tangled.repo.listRepos?subject=did:plc:boltless&limit=3"
24622462-```
24632463-```json
24642464-{ "items": [ { "uri": "at://did:plc:boltless/sh.tangled.repo/squid", "cid": "bafyrei...", "value": { } } ], "cursor": null }
24652465-```
24662466-24672467-- Bobbin validates the subject per collection. Here a repo URI
24682468- is passed where a bare DID is required, so the call returns a
24692469- `400`:
24702470-24712471-```sh
24722472-curl "$BOBBIN/xrpc/sh.tangled.graph.listFollows?subject=at://did:plc:boltless/sh.tangled.repo/squid"
24732473-```
24742474-```json
24752475-{ "error": "InvalidRequest", "message": "invalid request: subject must be a bare did, got at-uri with collection sh.tangled.repo" }
24762476-```
24772477-24782478-**Search** is a single endpoint over an in-mem full-text
24792479-index:
24802480-24812481-```sh
24822482-curl "$BOBBIN/xrpc/sh.tangled.search.query?q=tangled&limit=2"
24832483-```
24842484-```json
24852485-{ "hits": [ { "uri": "at://...", "cid": "...", "nsid": "sh.tangled.repo", "score": 27.1, "value": { } } ], "cursor": null }
24862486-```
24872487-24882488-**Git data** such as blob, tree, diff, log, and archive proxies
24892489-straight to the repo's knot, streamed back without caching.
24902490-24912491-## Coverage and warm-up
24922492-24932493-- While the edge index is catching up from Hydrant,
24942494- the aggregation count is a lower bound & may still climb.
24952495-- One endpoint reports how far along the backfill it is:
24962496-24972497-```sh
24982498-curl "$BOBBIN/xrpc/sh.tangled.bobbin.getCoverage"
24992499-```
25002500-25012501-While warming up:
25022502-25032503-```json
25042504-{ "ready": false, "eventsProcessed": 45588, "lastCursor": 51658 }
25052505-```
25062506-25072507-Once caught up, Bobbin flips to ready:
25082508-25092509-```json
25102510-{ "ready": true, "eventsProcessed": 106085, "lastCursor": 116527 }
25112511-```
25122512-25132513-If starting up Hydrant for the first time, Hydrant itself
25142514-will take a decent while (a couple of hours) to backfill
25152515-from PDSes. Hydrant stores its backfill on disk. Bobbin
25162516-restart reaches `ready` in minutes by replaying event from
25172517-an already-populated Hydrant. If your Hydrant is new, expect
25182518-Bobbin to backfill in that same couple of hours that Hydrant
25192519-takes.
25202520-25212521-## Loose ends and not-gonna-impl
25222522-25232523-- **No coverage signal for per-knot rosters yet.**
25242524- Coverage tracks the hydrant stream only. A v1.15 knot
25252525- that is unreachable serves a stale or empty member set
25262526- with nothing to flag it.
25272527-- **Knot eventstream fan-out isn't pooled.**
25282528- Bobbin opens one websocket per v1.15
25292529- knot on top of the hydrant subscription. A network with
25302530- thousands of knots wants pooling or a shared subscription.
25312531-- **No sequential issue or PR numbers.** bobbin returns rkeys,
25322532- not `#42` style ids like the web appview. A client
25332533- deriving a display number does it from creation order. But
25342534- why bother? rkeys are the IDs.
25352535-25362536-# Hacking on Tangled
25372537-25382538-We highly recommend [installing
25392539-Nix](https://nixos.org/download/) (the package manager)
25402540-before working on the codebase. The Nix flake provides a lot
25412541-of helpers to get started and most importantly, builds and
25422542-dev shells are entirely deterministic.
25432543-25442544-To set up your dev environment:
25452545-25462546-```bash
25472547-nix develop
25482548-```
25492549-25502550-Non-Nix users can look at the `devShell` attribute in the
25512551-`flake.nix` file to determine necessary dependencies.
25522552-25532553-## Running the appview
25542554-25552555-The appview requires Redis and OAuth JWKs. Start these
25562556-first, before launching the appview itself.
25572557-25582558-```bash
25592559-# OAuth JWKs should already be set up by the Nix devshell:
25602560-echo $TANGLED_OAUTH_CLIENT_SECRET
25612561-z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
25622562-25632563-echo $TANGLED_OAUTH_CLIENT_KID
25642564-1761667908
25652565-25662566-# if not, you can set it up yourself:
25672567-goat key generate -t P-256
25682568-Key Type: P-256 / secp256r1 / ES256 private key
25692569-Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
25702570- z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
25712571-Public Key (DID Key Syntax): share or publish this (eg, in DID document)
25722572- did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
25732573-25742574-# the secret key from above
25752575-export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
25762576-25772577-# Run Redis in a new shell to store OAuth sessions
25782578-redis-server
25792579-```
25802580-25812581-The Nix flake exposes a few `app` attributes (run `nix
25822582-flake show` to see a full list of what the flake provides),
25832583-one of the apps runs the appview with the `air`
25842584-live-reloader:
25852585-25862586-```bash
25872587-TANGLED_DEV=true nix run .#watch-appview
25882588-25892589-# TANGLED_DB_PATH might be of interest to point to
25902590-# different sqlite DBs
25912591-25922592-# in a separate shell, you can live-reload tailwind
25932593-nix run .#watch-tailwind
25942594-```
25952595-25962596-## Running knots and spindles
25972597-25982598-An end-to-end knot setup requires setting up a machine with
25992599-`sshd`, `AuthorizedKeysCommand`, and a Git user, which is
26002600-quite cumbersome. So the Nix flake provides a
26012601-`nixosConfiguration` to do so.
26022602-26032603-<details>
26042604- <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
26052605-26062606-In order to build Tangled's dev VM on macOS, you will
26072607-first need to set up a Linux Nix builder. The recommended
26082608-way to do so is to run a [`darwin.linux-builder`
26092609-VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
26102610-and to register it in `nix.conf` as a builder for Linux
26112611-with the same architecture as your Mac (`linux-aarch64` if
26122612-you are using Apple Silicon).
26132613-26142614-If you're on nix-darwin, you can simply add
26152615-26162616-```
26172617-nix.linux-builder.enable = true;
26182618-```
26192619-26202620-to your host's `configuration.nix`.
26212621-26222622-Alternatively, you can use any other method to set up a
26232623-Linux machine with Nix installed that you can `sudo ssh`
26242624-into (in other words, root user on your Mac has to be able
26252625-to ssh into the Linux machine without entering a password)
26262626-and that has the same architecture as your Mac. See
26272627-[remote builder
26282628-instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
26292629-for how to register such a builder in `nix.conf`.
26302630-26312631-> WARNING: If you'd like to use
26322632-> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
26332633-> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
26342634-ssh` works can be tricky. It seems to be [possible with
26352635-> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
26362636-26372637-</details>
26382638-26392639-To begin, grab your DID from http://localhost:3000/settings.
26402640-Then, set `TANGLED_VM_KNOT_OWNER` and
26412641-`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
26422642-lightweight NixOS VM like so:
26432643-26442644-```bash
26452645-nix run --impure .#vm
26462646-26472647-# type `poweroff` at the shell to exit the VM
26482648-```
26492649-26502650-This starts a knot on port 6444, a spindle on port 6555
26512651-with `ssh` exposed on port 2222.
26522652-26532653-Once the services are running, head to
26542654-http://localhost:3000/settings/knots and hit "Verify". It should
26552655-verify the ownership of the services instantly if everything
26562656-went smoothly.
26572657-26582658-You can push repositories to this VM with this ssh config
26592659-block on your main machine:
26602660-26612661-```bash
26622662-Host nixos-shell
26632663- Hostname localhost
26642664- Port 2222
26652665- User git
26662666- IdentityFile ~/.ssh/my_tangled_key
26672667-```
26682668-26692669-Set up a remote called `local-dev` on a git repo:
26702670-26712671-```bash
26722672-git remote add local-dev git@nixos-shell:user/repo
26732673-git push local-dev main
26742674-```
26752675-26762676-The above VM should already be running a spindle on
26772677-`localhost:6555`. Head to http://localhost:3000/settings/spindles and
26782678-hit "Verify". You can then configure each repository to use
26792679-this spindle and run CI jobs.
26802680-26812681-Of interest when debugging spindles:
26822682-26832683-```
26842684-# Service logs from journald:
26852685-journalctl -xeu spindle
26862686-26872687-# CI job logs from disk:
26882688-ls /var/log/spindle
26892689-26902690-# Debugging spindle database:
26912691-sqlite3 /var/lib/spindle/spindle.db
26922692-26932693-# litecli has a nicer REPL interface:
26942694-litecli /var/lib/spindle/spindle.db
26952695-```
26962696-26972697-If for any reason you wish to disable either one of the
26982698-services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
26992699-`services.tangled.spindle.enable` (or
27002700-`services.tangled.knot.enable`) to `false`.
27012701-27022702-# Contribution guide
27032703-27042704-## Commit guidelines
27052705-27062706-We follow a commit style similar to the Go project. Please keep commits:
27072707-27082708-- **atomic**: each commit should represent one logical change
27092709-- **descriptive**: the commit message should clearly describe what the
27102710- change does and why it's needed
27112711-27122712-### Message format
27132713-27142714-```
27152715-<service/top-level directory>/<affected package/directory>: <short summary of change>
27162716-27172717-Optional longer description can go here, if necessary. Explain what the
27182718-change does and why, especially if not obvious. Reference relevant
27192719-issues or PRs when applicable. These can be links for now since we don't
27202720-auto-link issues/PRs yet.
27212721-```
27222722-27232723-Here are some examples:
27242724-27252725-```
27262726-appview/state: fix token expiry check in middleware
27272727-27282728-The previous check did not account for clock drift, leading to premature
27292729-token invalidation.
27302730-```
27312731-27322732-```
27332733-knotserver/git/service: improve error checking in upload-pack
27342734-```
27352735-27362736-### General notes
27372737-27382738-- PRs get merged "as-is" (fast-forward)—like applying a patch-series
27392739- using `git am`. At present, there is no squashing—so please author
27402740- your commits as they would appear on `master`, following the above
27412741- guidelines.
27422742-- If there is a lot of nesting, for example "appview:
27432743- pages/templates/repo/fragments: ...", these can be truncated down to
27442744- just "appview: repo/fragments: ...". If the change affects a lot of
27452745- subdirectories, you may abbreviate to just the top-level names, e.g.
27462746- "appview: ..." or "knotserver: ...".
27472747-- Keep commits lowercased with no trailing period.
27482748-- Use the imperative mood in the summary line (e.g., "fix bug" not
27492749- "fixed bug" or "fixes bug").
27502750-- Try to keep the summary line under 72 characters, but we aren't too
27512751- fussed about this.
27522752-- Follow the same formatting for PR titles if filled manually.
27532753-- Don't include unrelated changes in the same commit.
27542754-- Avoid noisy commit messages like "wip" or "final fix"—rewrite history
27552755- before submitting if necessary.
27562756-27572757-## Code formatting
27582758-27592759-We use a variety of tools to format our code, and multiplex them with
27602760-[`treefmt`](https://treefmt.com). All you need to do to format your changes
27612761-is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
27622762-27632763-## Proposals for bigger changes
27642764-27652765-Small fixes like typos, minor bugs, or trivial refactors can be
27662766-submitted directly as PRs.
27672767-27682768-For larger changes—especially those introducing new features, significant
27692769-refactoring, or altering system behavior—please open a proposal first. This
27702770-helps us evaluate the scope, design, and potential impact before implementation.
27712771-27722772-Create a new issue titled:
27732773-27742774-```
27752775-proposal: <affected scope>: <summary of change>
27762776-```
27772777-27782778-In the description, explain:
27792779-27802780-- What the change is
27812781-- Why it's needed
27822782-- How you plan to implement it (roughly)
27832783-- Any open questions or tradeoffs
27842784-27852785-We'll use the issue thread to discuss and refine the idea before moving
27862786-forward.
27872787-27882788-## Developer Certificate of Origin (DCO)
27892789-27902790-We require all contributors to certify that they have the right to
27912791-submit the code they're contributing. To do this, we follow the
27922792-[Developer Certificate of Origin
27932793-(DCO)](https://developercertificate.org/).
27942794-27952795-By signing your commits, you're stating that the contribution is your
27962796-own work, or that you have the right to submit it under the project's
27972797-license. This helps us keep things clean and legally sound.
27982798-27992799-To sign your commit, just add the `-s` flag when committing:
28002800-28012801-```sh
28022802-git commit -s -m "your commit message"
28032803-```
28042804-28052805-This appends a line like:
28062806-28072807-```
28082808-Signed-off-by: Your Name <your.email@example.com>
28092809-```
28102810-28112811-We won't merge commits if they aren't signed off. If you forget, you can
28122812-amend the last commit like this:
28132813-28142814-```sh
28152815-git commit --amend -s
28162816-```
28172817-28182818-If you're submitting a PR with multiple commits, make sure each one is
28192819-signed.
28202820-28212821-For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
28222822-to make it sign off commits in the tangled repo:
28232823-28242824-```shell
28252825-# Safety check, should say "No matching config key..."
28262826-jj config list templates.commit_trailers
28272827-# The command below may need to be adjusted if the command above returned something.
28282828-jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
28292829-```
28302830-28312831-Refer to the [jujutsu
28322832-documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
28332833-for more information.
28342834-28352835-# Troubleshooting guide
28362836-28372837-## Login issues
28382838-28392839-Owing to the distributed nature of OAuth on AT Protocol, you
28402840-may run into issues with logging in. If you run a
28412841-self-hosted PDS:
28422842-28432843-- You may need to ensure that your PDS is timesynced using
28442844- NTP:
28452845- - Enable the `ntpd` service
28462846- - Run `ntpd -qg` to synchronize your clock
28472847-- You may need to increase the default request timeout:
28482848- `NODE_OPTIONS="--network-family-autoselection-attempt-timeout=500"`
28492849-28502850-## Empty punchcard
28512851-28522852-For Tangled to register commits that you make across the
28532853-network, you need to setup one of following:
28542854-28552855-- The committer email should be a verified email associated
28562856- to your account. You can add and verify emails on the
28572857- settings page.
28582858-- Or, the committer email should be set to your account's
28592859- DID: `git config user.email "did:plc:foobar"`. You can find
28602860- your account's DID on the settings page
28612861-28622862-## Commit is not marked as verified
28632863-28642864-Tangled only supports SSH commit signatures. Ensure the SSH public key you use
28652865-for signing is uploaded to Tangled.
28662866-28672867-To sign commits using an SSH key with git:
28682868-28692869-```
28702870-git config --global gpg.format ssh
28712871-git config --global user.signingkey ~/.ssh/tangled-key
28722872-```
28732873-28742874-To sign commits using an SSH key with jj, add this to your
28752875-config:
28762876-28772877-```
28782878-[signing]
28792879-behavior = "own"
28802880-backend = "ssh"
28812881-key = "~/.ssh/tangled-key"
28822882-```
28832883-28842884-## Self-hosted knot issues
28852885-28862886-If you need help troubleshooting a self-hosted knot, check
28872887-out the [knot troubleshooting
28882888-guide](/knot-self-hosting-guide.html#troubleshooting).
···11+---
22+title: Contribution guide
33+description: Commit guidelines, code formatting, proposals, and DCO.
44+---
55+66+77+## Commit guidelines
88+99+We follow a commit style similar to the Go project. Please keep commits:
1010+1111+- **atomic**: each commit should represent one logical change
1212+- **descriptive**: the commit message should clearly describe what the
1313+ change does and why it's needed
1414+1515+### Message format
1616+1717+```
1818+<service/top-level directory>/<affected package/directory>: <short summary of change>
1919+2020+Optional longer description can go here, if necessary. Explain what the
2121+change does and why, especially if not obvious. Reference relevant
2222+issues or PRs when applicable. These can be links for now since we don't
2323+auto-link issues/PRs yet.
2424+```
2525+2626+Here are some examples:
2727+2828+```
2929+appview/state: fix token expiry check in middleware
3030+3131+The previous check did not account for clock drift, leading to premature
3232+token invalidation.
3333+```
3434+3535+```
3636+knotserver/git/service: improve error checking in upload-pack
3737+```
3838+3939+### General notes
4040+4141+- PRs get merged "as-is" (fast-forward)—like applying a patch-series
4242+ using `git am`. At present, there is no squashing—so please author
4343+ your commits as they would appear on `master`, following the above
4444+ guidelines.
4545+- If there is a lot of nesting, for example "appview:
4646+ pages/templates/repo/fragments: ...", these can be truncated down to
4747+ just "appview: repo/fragments: ...". If the change affects a lot of
4848+ subdirectories, you may abbreviate to just the top-level names, e.g.
4949+ "appview: ..." or "knotserver: ...".
5050+- Keep commits lowercased with no trailing period.
5151+- Use the imperative mood in the summary line (e.g., "fix bug" not
5252+ "fixed bug" or "fixes bug").
5353+- Try to keep the summary line under 72 characters, but we aren't too
5454+ fussed about this.
5555+- Follow the same formatting for PR titles if filled manually.
5656+- Don't include unrelated changes in the same commit.
5757+- Avoid noisy commit messages like "wip" or "final fix"—rewrite history
5858+ before submitting if necessary.
5959+6060+## Code formatting
6161+6262+We use a variety of tools to format our code, and multiplex them with
6363+[`treefmt`](https://treefmt.com). All you need to do to format your changes
6464+is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
6565+6666+## Proposals for bigger changes
6767+6868+Small fixes like typos, minor bugs, or trivial refactors can be
6969+submitted directly as PRs.
7070+7171+For larger changes—especially those introducing new features, significant
7272+refactoring, or altering system behavior—please open a proposal first. This
7373+helps us evaluate the scope, design, and potential impact before implementation.
7474+7575+Create a new issue titled:
7676+7777+```
7878+proposal: <affected scope>: <summary of change>
7979+```
8080+8181+In the description, explain:
8282+8383+- What the change is
8484+- Why it's needed
8585+- How you plan to implement it (roughly)
8686+- Any open questions or tradeoffs
8787+8888+We'll use the issue thread to discuss and refine the idea before moving
8989+forward.
9090+9191+## Developer Certificate of Origin (DCO)
9292+9393+We require all contributors to certify that they have the right to
9494+submit the code they're contributing. To do this, we follow the
9595+[Developer Certificate of Origin
9696+(DCO)](https://developercertificate.org/).
9797+9898+By signing your commits, you're stating that the contribution is your
9999+own work, or that you have the right to submit it under the project's
100100+license. This helps us keep things clean and legally sound.
101101+102102+To sign your commit, just add the `-s` flag when committing:
103103+104104+```sh
105105+git commit -s -m "your commit message"
106106+```
107107+108108+This appends a line like:
109109+110110+```
111111+Signed-off-by: Your Name <your.email@example.com>
112112+```
113113+114114+We won't merge commits if they aren't signed off. If you forget, you can
115115+amend the last commit like this:
116116+117117+```sh
118118+git commit --amend -s
119119+```
120120+121121+If you're submitting a PR with multiple commits, make sure each one is
122122+signed.
123123+124124+For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
125125+to make it sign off commits in the tangled repo:
126126+127127+```shell
128128+# Safety check, should say "No matching config key..."
129129+jj config list templates.commit_trailers
130130+# The command below may need to be adjusted if the command above returned something.
131131+jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
132132+```
133133+134134+Refer to the [jujutsu
135135+documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
136136+for more information.
···11+---
22+title: Hacking on Tangled
33+description: Set up a dev environment and run the appview, knots, and spindles locally.
44+---
55+66+77+We highly recommend [installing
88+Nix](https://nixos.org/download/) (the package manager)
99+before working on the codebase. The Nix flake provides a lot
1010+of helpers to get started and most importantly, builds and
1111+dev shells are entirely deterministic.
1212+1313+To set up your dev environment:
1414+1515+```bash
1616+nix develop
1717+```
1818+1919+Non-Nix users can look at the `devShell` attribute in the
2020+`flake.nix` file to determine necessary dependencies.
2121+2222+## Running the appview
2323+2424+The appview requires Redis and OAuth JWKs. Start these
2525+first, before launching the appview itself.
2626+2727+```bash
2828+# OAuth JWKs should already be set up by the Nix devshell:
2929+echo $TANGLED_OAUTH_CLIENT_SECRET
3030+z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
3131+3232+echo $TANGLED_OAUTH_CLIENT_KID
3333+1761667908
3434+3535+# if not, you can set it up yourself:
3636+goat key generate -t P-256
3737+Key Type: P-256 / secp256r1 / ES256 private key
3838+Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
3939+ z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
4040+Public Key (DID Key Syntax): share or publish this (eg, in DID document)
4141+ did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
4242+4343+# the secret key from above
4444+export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
4545+4646+# Run Redis in a new shell to store OAuth sessions
4747+redis-server
4848+```
4949+5050+The Nix flake exposes a few `app` attributes (run `nix
5151+flake show` to see a full list of what the flake provides),
5252+one of the apps runs the appview with the `air`
5353+live-reloader:
5454+5555+```bash
5656+TANGLED_DEV=true nix run .#watch-appview
5757+5858+# TANGLED_DB_PATH might be of interest to point to
5959+# different sqlite DBs
6060+6161+# in a separate shell, you can live-reload tailwind
6262+nix run .#watch-tailwind
6363+```
6464+6565+## Running knots and spindles
6666+6767+An end-to-end knot setup requires setting up a machine with
6868+`sshd`, `AuthorizedKeysCommand`, and a Git user, which is
6969+quite cumbersome. So the Nix flake provides a
7070+`nixosConfiguration` to do so.
7171+7272+<details>
7373+ <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
7474+7575+In order to build Tangled's dev VM on macOS, you will
7676+first need to set up a Linux Nix builder. The recommended
7777+way to do so is to run a [`darwin.linux-builder`
7878+VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
7979+and to register it in `nix.conf` as a builder for Linux
8080+with the same architecture as your Mac (`linux-aarch64` if
8181+you are using Apple Silicon).
8282+8383+If you're on nix-darwin, you can simply add
8484+8585+```
8686+nix.linux-builder.enable = true;
8787+```
8888+8989+to your host's `configuration.nix`.
9090+9191+Alternatively, you can use any other method to set up a
9292+Linux machine with Nix installed that you can `sudo ssh`
9393+into (in other words, root user on your Mac has to be able
9494+to ssh into the Linux machine without entering a password)
9595+and that has the same architecture as your Mac. See
9696+[remote builder
9797+instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
9898+for how to register such a builder in `nix.conf`.
9999+100100+> WARNING: If you'd like to use
101101+> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
102102+> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
103103+ssh` works can be tricky. It seems to be [possible with
104104+> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
105105+106106+</details>
107107+108108+To begin, grab your DID from http://localhost:3000/settings.
109109+Then, set `TANGLED_VM_KNOT_OWNER` and
110110+`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
111111+lightweight NixOS VM like so:
112112+113113+```bash
114114+nix run --impure .#vm
115115+116116+# type `poweroff` at the shell to exit the VM
117117+```
118118+119119+This starts a knot on port 6444, a spindle on port 6555
120120+with `ssh` exposed on port 2222.
121121+122122+Once the services are running, head to
123123+http://localhost:3000/settings/knots and hit "Verify". It should
124124+verify the ownership of the services instantly if everything
125125+went smoothly.
126126+127127+You can push repositories to this VM with this ssh config
128128+block on your main machine:
129129+130130+```bash
131131+Host nixos-shell
132132+ Hostname localhost
133133+ Port 2222
134134+ User git
135135+ IdentityFile ~/.ssh/my_tangled_key
136136+```
137137+138138+Set up a remote called `local-dev` on a git repo:
139139+140140+```bash
141141+git remote add local-dev git@nixos-shell:user/repo
142142+git push local-dev main
143143+```
144144+145145+The above VM should already be running a spindle on
146146+`localhost:6555`. Head to http://localhost:3000/settings/spindles and
147147+hit "Verify". You can then configure each repository to use
148148+this spindle and run CI jobs.
149149+150150+Of interest when debugging spindles:
151151+152152+```
153153+# Service logs from journald:
154154+journalctl -xeu spindle
155155+156156+# CI job logs from disk:
157157+ls /var/log/spindle
158158+159159+# Debugging spindle database:
160160+sqlite3 /var/lib/spindle/spindle.db
161161+162162+# litecli has a nicer REPL interface:
163163+litecli /var/lib/spindle/spindle.db
164164+```
165165+166166+If for any reason you wish to disable either one of the
167167+services in the VM, modify [nix/vm.nix](https://tangled.org/@tangled.org/core/blob/master/nix/vm.nix) and set
168168+`services.tangled.spindle.enable` (or
169169+`services.tangled.knot.enable`) to `false`.
···11+---
22+title: Tangled Docs
33+description: The next-generation social coding platform.
44+head:
55+ - tag: title
66+ content: Tangled Docs
77+template: splash
88+hero:
99+ image:
1010+ html: |
1111+ <pre class="ascii-dolly" aria-hidden="true">
1212+ ..... :=*#%@@@%#+:
1313+ :=*%%@@@%#*=#@@@@@@@@@@@@%=
1414+ :#@@@@@@@@@@@@@@@@@@@@@@@@@@@#.
1515+ =@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#
1616+ .-*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*-.
1717+ =%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*:
1818+ .#@@@@@@@@@@@@@@@@@#%@@@%*#%#*%@@@@@@@@@@@@@=
1919+ *@@@@@@@@@@@@@*:--: .---. .%@@@@@@@@@@@@@=
2020+ @@@@@@@@@@@@#: -@@@@@@@@@@@@@%
2121+ %@@@@@@@@@@* .-. :=: :@@@@@@@@@@@@@
2222+ +@@@@@@@@@+ #@# :@@+ =@@@@@@@@@@@*
2323+ +@@@@@@@# :@@+ =@@: %@@@@@@@@@#.
2424+ :#@@@@@* . .@@+ *@@. #@@@@@@@@+
2525+ .#@@@@*==*@: -=. -#+ -: :@@@@@@#=.
2626+ :@@@@@@@@@@+ =@@#**%@@@@@@+
2727+ #@@@@@@@@@@%: .*@@@@@@@@@@@@@@:
2828+ %@@@@@@@@@@@%=:. .:+%@@@@@@@@@@@@@@@*
2929+ +@@@@@@@@@@@@@@%%%%@@@@@@@@@@@@@@@@@@*
3030+ .#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-
3131+ +@@@@@@@@@@@@@@#*@@@@@@@@@@@@@@@@@+
3232+ .=#%@@@@@@@%*- -%@@@@@@@@@@@@@#-
3333+ .:-===-. -+#%@@@@@%#+:
3434+ .::::..
3535+ </pre>
3636+ title: Docs and self-hosting guides for Tangled
3737+ tagline: Tangled is a decentralized code hosting and collaboration platform built on the AT Protocol. Every component is open-source and self-hostable.
3838+ actions:
3939+ - text: Quick start
4040+ link: /quick-start/
4141+ # lucide arrow-right, same as the appview home CTAs
4242+ icon: '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>'
4343+ variant: primary
4444+ - text: What is Tangled?
4545+ link: "#what-is-tangled"
4646+ variant: minimal
4747+---
4848+4949+import FeatureCard from "../../components/FeatureCard.astro";
5050+import {
5151+ Rocket,
5252+ Server,
5353+ Workflow,
5454+ Globe,
5555+ Webhook,
5656+ Cable,
5757+ Hammer,
5858+ GitPullRequest,
5959+ LifeBuoy,
6060+ ArrowUpCircle,
6161+} from "lucide-astro";
6262+6363+<div class="feature-grid">
6464+ <FeatureCard title="Quick start" href="/quick-start/">
6565+ <Rocket slot="icon" />
6666+ Create an account, add your SSH key, and push your first repository.
6767+ </FeatureCard>
6868+ <FeatureCard title="Knots" href="/knots/">
6969+ <Server slot="icon" />
7070+ Lightweight, headless git servers. Run one on anything from a Raspberry Pi
7171+ to a community server.
7272+ </FeatureCard>
7373+ <FeatureCard title="Spindles" href="/spindles/">
7474+ <Workflow slot="icon" />
7575+ CI/CD runners for your repositories. Write workflows, or self-host your own
7676+ spindle.
7777+ </FeatureCard>
7878+ <FeatureCard title="Sites" href="/sites/">
7979+ <Globe slot="icon" />
8080+ Serve static websites directly from your git repositories.
8181+ </FeatureCard>
8282+ <FeatureCard title="Webhooks" href="/reference/webhooks/">
8383+ <Webhook slot="icon" />
8484+ Receive HTTP notifications on repository events and integrate with external
8585+ services.
8686+ </FeatureCard>
8787+ <FeatureCard title="Bobbin" href="/reference/bobbin/">
8888+ <Cable slot="icon" />
8989+ A read-only XRPC API appview for Tangled records.
9090+ </FeatureCard>
9191+</div>
9292+9393+<section class="landing-section">
9494+9595+## What is Tangled?
9696+9797+Tangled is a social coding platform built on an [open
9898+protocol](https://atproto.com).
9999+100100+There are several models for decentralized code collaboration platforms,
101101+ranging from ActivityPub's (Forgejo) federated model, to Radicle's
102102+entirely P2P model. Our approach attempts to be the best of both worlds
103103+by adopting the [AT Protocol](https://atproto.com) — a protocol for
104104+building decentralized social applications with a central identity.
105105+106106+Central to our approach is the idea of **knots**. Knots are lightweight,
107107+headless servers that enable users to host git repositories with ease.
108108+Knots are designed for either single or multi-tenant use, which is
109109+perfect for self-hosting on a Raspberry Pi at home, or larger
110110+"community" servers. By default, Tangled provides managed knots where
111111+you can host your repositories for free.
112112+113113+The appview at [tangled.org](https://tangled.org) acts as a consolidated
114114+"view" into the whole network, allowing users to access, clone and
115115+contribute to repositories hosted across different knots seamlessly.
116116+117117+Curious about the protocol choice? Read [why AT Protocol](/why-atproto/).
118118+119119+</section>
120120+121121+<section class="landing-section">
122122+123123+## Resources
124124+125125+<div class="feature-grid">
126126+ <FeatureCard title="Hacking on Tangled" href="/contributing/hacking/">
127127+ <Hammer slot="icon" />
128128+ Set up a dev environment and run the appview, knots, and spindles locally.
129129+ </FeatureCard>
130130+ <FeatureCard title="Contribution guide" href="/contributing/guide/">
131131+ <GitPullRequest slot="icon" />
132132+ Commit guidelines, code formatting, proposals, and signing your work.
133133+ </FeatureCard>
134134+ <FeatureCard title="Migrations" href="/reference/migrations/">
135135+ <ArrowUpCircle slot="icon" />
136136+ Upgrade guides for self-hosted knots and spindles.
137137+ </FeatureCard>
138138+ <FeatureCard title="Troubleshooting" href="/troubleshooting/">
139139+ <LifeBuoy slot="icon" />
140140+ Common issues with login, punchcards, and commit verification.
141141+ </FeatureCard>
142142+</div>
143143+144144+</section>
···11+---
22+title: What are knots?
33+description: Knots are lightweight, headless git servers — the foundation of Tangled's decentralized architecture.
44+sidebar:
55+ order: 0
66+---
77+88+Knots are the foundation of Tangled's distributed architecture:
99+lightweight, headless servers that host git repositories. They are
1010+designed to be trivial to run, whether that's a single-tenant
1111+setup on a Raspberry Pi at home, or a larger multi-tenant
1212+"community" server shared by many users.
1313+1414+There are several models for decentralized code collaboration,
1515+ranging from ActivityPub's (Forgejo) federated model to Radicle's
1616+entirely P2P model. Tangled takes a hybrid approach by building on
1717+the [AT Protocol](https://atproto.com): decentralized data and
1818+hosting, with a central identity. Your repositories live on a knot
1919+you choose (or run yourself), but your identity — and things like
2020+issues, pull requests, stars and follows — live in your AT
2121+Protocol account, owned by you.
2222+2323+The appview at [tangled.org](https://tangled.org) ties the network
2424+together: it acts as a consolidated "view" into repositories
2525+hosted across all knots, so you can access, clone, and contribute
2626+to any of them seamlessly. A repository on a home server appears
2727+alongside one on a community knot, with no fragmentation.
2828+2929+## What a knot does
3030+3131+A knot:
3232+3333+- serves git over SSH (pushes) and HTTP (clones, fetches)
3434+- emits events — pushes, ref updates — that the appview and other
3535+ services consume
3636+- owns its member list and per-repository collaborators, served
3737+ over XRPC (as of v1.15)
3838+- assigns each repository a DID (as of v1.13), making repositories
3939+ stable across renames and transfers
4040+4141+By default, Tangled provides managed knots where you can host your
4242+repositories for free — you never have to run one yourself. But if
4343+you want your code on your own hardware, self-hosting is a first-class
4444+workflow.
4545+4646+## Run your own
4747+4848+Head to the [self-hosting guide](/knots/self-hosting/) to set one
4949+up — there's a NixOS module, a Docker Compose setup, and a manual
5050+installation path. If you run into trouble, check the
5151+[troubleshooting section](/knots/self-hosting/#troubleshooting),
5252+and keep an eye on the [migration guides](/reference/migrations/)
5353+when upgrading.
5454+5555+## Further reading
5656+5757+- [Introducing Tangled](https://blog.tangled.org/intro) — the
5858+ original announcement, with more on the thinking behind knots
5959+- [Hacking on Tangled](/contributing/hacking/) — run a knot
6060+ locally in a dev VM
···11+---
22+title: Self-hosting a knot
33+description: "Run your own knot: a lightweight, headless git server."
44+---
55+66+77+So you want to run your own knot server? Great! Here are a few prerequisites:
88+99+1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
1010+2. A (sub)domain name. People generally use `knot.example.com`.
1111+3. A valid SSL certificate for your domain.
1212+1313+## NixOS
1414+1515+Refer to the [knot
1616+module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix)
1717+for a full list of options. Sample configurations:
1818+1919+- [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85)
2020+- [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/c2b644c214d278af12523618de952ee2eab1af3d/hosts/marvin/services/tangled.nix#L15-26)
2121+2222+## Docker
2323+2424+Refer to
2525+[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker).
2626+Note that this is community maintained.
2727+2828+## Manual setup
2929+3030+First, clone this repository:
3131+3232+```
3333+git clone https://tangled.org/@tangled.org/core
3434+```
3535+3636+Then, build the `knot` CLI. This is the knot administration
3737+and operation tool. For the purpose of this guide, we're
3838+only concerned with these subcommands:
3939+4040+- `knot server`: the main knot server process, typically
4141+ run as a supervised service
4242+- `knot guard`: handles role-based access control for git
4343+ over SSH (you'll never have to run this yourself)
4444+- `knot keys`: fetches SSH keys associated with your knot;
4545+ we'll use this to generate the SSH
4646+ `AuthorizedKeysCommand`
4747+4848+```
4949+cd core
5050+export CGO_ENABLED=1
5151+go build -o knot ./cmd/knot
5252+```
5353+5454+Next, move the `knot` binary to a location owned by `root` --
5555+`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
5656+5757+```
5858+sudo mv knot /usr/local/bin/knot
5959+sudo chown root:root /usr/local/bin/knot
6060+```
6161+6262+This is necessary because SSH `AuthorizedKeysCommand` requires [really
6363+specific permissions](https://stackoverflow.com/a/27638306). The
6464+`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
6565+retrieve a user's public SSH keys dynamically for authentication. Let's
6666+set that up.
6767+6868+```
6969+sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
7070+Match User git
7171+ AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
7272+ AuthorizedKeysCommandUser nobody
7373+EOF
7474+```
7575+7676+Then, reload `sshd`:
7777+7878+```
7979+sudo systemctl reload ssh
8080+```
8181+8282+Next, create the `git` user. We'll use the `git` user's home directory
8383+to store repositories:
8484+8585+```
8686+sudo adduser git
8787+```
8888+8989+Create `/home/git/.knot.env` with the following, updating the values as
9090+necessary. The `KNOT_SERVER_OWNER` should be set to your
9191+DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
9292+9393+```
9494+KNOT_REPO_SCAN_PATH=/home/git
9595+KNOT_SERVER_HOSTNAME=knot.example.com
9696+APPVIEW_ENDPOINT=https://tangled.org
9797+KNOT_SERVER_OWNER=did:plc:foobar
9898+KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
9999+KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
100100+```
101101+102102+If you run a Linux distribution that uses systemd, you can
103103+use the provided service file to run the server. Copy
104104+[`knotserver.service`](https://tangled.org/tangled.org/core/blob/master/systemd/knotserver.service)
105105+to `/etc/systemd/system/`. Then, run:
106106+107107+```
108108+systemctl enable knotserver
109109+systemctl start knotserver
110110+```
111111+112112+The last step is to configure a reverse proxy like Nginx or Caddy to front your
113113+knot. Here's an example configuration for Nginx:
114114+115115+```
116116+server {
117117+ listen 80;
118118+ listen [::]:80;
119119+ server_name knot.example.com;
120120+121121+ location / {
122122+ proxy_pass http://localhost:5555;
123123+ proxy_set_header Host $host;
124124+ proxy_set_header X-Real-IP $remote_addr;
125125+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
126126+ proxy_set_header X-Forwarded-Proto $scheme;
127127+ }
128128+129129+ # wss endpoint for git events
130130+ location /events {
131131+ proxy_set_header X-Forwarded-For $remote_addr;
132132+ proxy_set_header Host $http_host;
133133+ proxy_set_header Upgrade websocket;
134134+ proxy_set_header Connection Upgrade;
135135+ proxy_pass http://localhost:5555;
136136+ }
137137+ # additional config for SSL/TLS go here.
138138+}
139139+140140+```
141141+142142+Remember to use Let's Encrypt or similar to procure a certificate for your
143143+knot domain.
144144+145145+You should now have a running knot server! You can finalize
146146+your registration by hitting the `verify` button on the
147147+[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
148148+a record on your PDS to announce the existence of the knot.
149149+150150+### Custom paths
151151+152152+(This section applies to manual setup only. Docker users should edit the mounts
153153+in `docker-compose.yml` instead.)
154154+155155+Right now, the database and repositories of your knot lives in `/home/git`. You
156156+can move these paths if you'd like to store them in another folder. Be careful
157157+when adjusting these paths:
158158+159159+- Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
160160+ any possible side effects. Remember to restart it once you're done.
161161+- Make backups before moving in case something goes wrong.
162162+- Make sure the `git` user can read and write from the new paths.
163163+164164+#### Database
165165+166166+As an example, let's say the current database is at `/home/git/knotserver.db`,
167167+and we want to move it to `/home/git/database/knotserver.db`.
168168+169169+Copy the current database to the new location. Make sure to copy the `.db-shm`
170170+and `.db-wal` files if they exist.
171171+172172+```
173173+mkdir /home/git/database
174174+cp /home/git/knotserver.db* /home/git/database
175175+```
176176+177177+In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
178178+the new file path (_not_ the directory):
179179+180180+```
181181+KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
182182+```
183183+184184+#### Repositories
185185+186186+As an example, let's say the repositories are currently in `/home/git`, and we
187187+want to move them into `/home/git/repositories`.
188188+189189+Create the new folder, then move the existing repositories (if there are any):
190190+191191+```
192192+mkdir /home/git/repositories
193193+# move all DIDs into the new folder; these will vary for you!
194194+mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
195195+```
196196+197197+In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
198198+to the new directory:
199199+200200+```
201201+KNOT_REPO_SCAN_PATH=/home/git/repositories
202202+```
203203+204204+Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
205205+repository path:
206206+207207+```
208208+sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
209209+Match User git
210210+ AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
211211+ AuthorizedKeysCommandUser nobody
212212+EOF
213213+```
214214+215215+Make sure to restart your SSH server!
216216+217217+#### MOTD (message of the day)
218218+219219+To configure the MOTD used ("Welcome to this knot!" by default), edit the
220220+`/home/git/motd` file:
221221+222222+```
223223+printf "Hi from this knot!\n" > /home/git/motd
224224+```
225225+226226+Note that you should add a newline at the end if setting a non-empty message
227227+since the knot won't do this for you.
228228+229229+## Secure Mode
230230+231231+Secure Mode isolates each `git` subprocess to the repository it is
232232+operating on, using two mechanisms:
233233+234234+- **Linux Landlock** restricts the filesystem paths the subprocess
235235+ can access -- it can only read/write its own repository and the
236236+ system directories it needs to run.
237237+- **UID isolation** runs each subprocess as a virtual UID assigned
238238+ to the repository owner, so that repositories belonging to
239239+ different owners are isolated from each other at the OS level
240240+ even if Landlock were somehow bypassed.
241241+242242+Secure Mode requires:
243243+244244+- Linux kernel >= 5.19 (Landlock V2). This is the minimum needed
245245+ for `git push` to work, because receive-pack's quarantine
246246+ migration uses cross-directory rename which requires the
247247+ Landlock `REFER` access right (added in V2). Kernels 5.13-5.18
248248+ support Landlock V1 and clones will work, but pushes will fail
249249+ with cross-device link errors. On kernels without any Landlock
250250+ support (< 5.13), the sandbox call is a no-op: UID isolation
251251+ still applies but no filesystem restriction is enforced.
252252+- `CAP_SETUID`, `CAP_SETGID`, and `CAP_CHOWN` available to the
253253+ knot process. The NixOS module grants these automatically; for
254254+ manual setups see the `setcap` step below.
255255+256256+### NixOS
257257+258258+Add `server.secureMode = true;` to your knot module configuration:
259259+260260+```nix
261261+services.tangled.knot = {
262262+ server.secureMode = true;
263263+ # ... other options
264264+};
265265+```
266266+267267+The NixOS module handles everything else automatically:
268268+269269+- Grants the required capabilities to the knot service via
270270+ `AmbientCapabilities` in the systemd unit.
271271+- Installs a capability-bearing wrapper at
272272+ `/run/wrappers/bin/knot` via `security.wrappers`, so that
273273+ SSH-invoked git operations (pushes) also run under the correct
274274+ UID without requiring the service to run as root.
275275+- Runs `knot migrate-isolation` at service start to chown
276276+ existing repositories to their virtual UIDs.
277277+278278+### Manual setup
279279+280280+**Step 1.** Grant the required capabilities to the knot binary.
281281+This allows the knot process to switch to virtual UIDs at runtime
282282+without running as root. You will need to repeat this step
283283+whenever the binary is updated.
284284+285285+```
286286+sudo setcap cap_setuid,cap_setgid,cap_chown+eip /usr/local/bin/knot
287287+```
288288+289289+**Step 2.** Run the migration tool to assign virtual UIDs to all
290290+existing repositories and set their filesystem permissions. This
291291+must be run as root:
292292+293293+```
294294+sudo knot migrate-isolation \
295295+ --git-dir /home/git \
296296+ --db /home/git/knotserver.db \
297297+ --internal-api 127.0.0.1:5444
298298+```
299299+300300+You can re-run this at any time with `--force` to reapply
301301+permissions (e.g. after a manual repair or after updating the
302302+binary).
303303+304304+**Step 2a.** Ensure the home directory is traversable by
305305+non-group users. Git subprocesses run as virtual UIDs that are
306306+not in the git group, and they need to resolve
307307+`$HOME/.config/git/config` to load the global config:
308308+309309+```
310310+sudo chmod o+x /home/git
311311+```
312312+313313+This adds only the execute bit, not read -- the virtual UIDs can
314314+traverse to known paths but cannot list directory contents.
315315+316316+**Step 3.** Enable Secure Mode in your environment file:
317317+318318+```
319319+KNOT_SERVER_SECURE_MODE=true
320320+```
321321+322322+Or pass it as a flag:
323323+324324+```
325325+knot server --secure-mode
326326+```
327327+328328+**Step 4.** Regenerate the `AuthorizedKeysCommand` with the
329329+`-secure-mode` flag. This causes `knot keys` to emit guard
330330+command lines that include `-secure-mode`, so SSH pushes also
331331+get UID isolation:
332332+333333+```
334334+sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
335335+Match User git
336336+ AuthorizedKeysCommand /usr/local/bin/knot keys \
337337+ -o authorized-keys -secure-mode
338338+ AuthorizedKeysCommandUser nobody
339339+EOF
340340+```
341341+342342+Reload `sshd` after making this change.
343343+344344+> **Note:** the server will refuse to start in Secure Mode if any
345345+> repositories have not yet been isolation-migrated. Re-run
346346+> `migrate-isolation` if you see this error.
347347+348348+## Troubleshooting
349349+350350+If you run your own knot, you may run into some of these
351351+common issues. You can always join the
352352+[IRC](https://web.libera.chat/#tangled) or
353353+[Discord](https://chat.tangled.org/) if this section does
354354+not help.
355355+356356+### Unable to push
357357+358358+If you are unable to push to your knot or repository:
359359+360360+1. First, ensure that you have added your SSH public key to
361361+ your account
362362+2. Check to see that your knot has synced the key by running
363363+ `knot keys`
364364+3. Check to see if git is supplying the correct private key
365365+ when pushing: `GIT_SSH_COMMAND="ssh -v" git push ...`
366366+4. Check to see if `sshd` on the knot is rejecting the push
367367+ for some reason: `journalctl -xeu ssh` (or `sshd`,
368368+ depending on your machine). These logs are unavailable if
369369+ using docker.
370370+5. Check to see if the knot itself is rejecting the push,
371371+ depending on your setup, the logs might be in one of the
372372+ following paths:
373373+ - `/tmp/knotguard.log`
374374+ - `/home/git/log`
375375+ - `/home/git/guard.log`
···11+---
22+title: Quick start
33+description: Create an account, add an SSH key, and push your first repository to Tangled.
44+---
55+66+77+## Login or sign up
88+99+You can [login](https://tangled.org) by using your AT Protocol
1010+account. If you are unclear on what that means, simply head
1111+to the [signup](https://tangled.org/signup) page and create
1212+an account. By doing so, you will be choosing Tangled as
1313+your account provider (you will be granted a handle of the
1414+form `user.tngl.sh`).
1515+1616+In the AT Protocol network, users are free to choose their account
1717+provider (known as a "Personal Data Service", or PDS), and
1818+login to applications that support AT accounts.
1919+2020+You can think of it as "one account for all of the atmosphere"!
2121+2222+If you already have an AT account (you may have one if you
2323+signed up to Bluesky, for example), you can login with the
2424+same handle on Tangled (so just use `user.bsky.social` on
2525+the login page).
2626+2727+## Add an SSH key
2828+2929+Once you are logged in, you can start creating repositories
3030+and pushing code. Tangled supports pushing git repositories
3131+over SSH.
3232+3333+First, you'll need to generate an SSH key if you don't
3434+already have one:
3535+3636+```bash
3737+ssh-keygen -t ed25519 -C "foo@bar.com"
3838+```
3939+4040+When prompted, save the key to the default location
4141+(`~/.ssh/id_ed25519`) and optionally set a passphrase.
4242+4343+Copy your public key to your clipboard:
4444+4545+```bash
4646+# on X11
4747+cat ~/.ssh/id_ed25519.pub | xclip -sel c
4848+4949+# on wayland
5050+cat ~/.ssh/id_ed25519.pub | wl-copy
5151+5252+# on macos
5353+cat ~/.ssh/id_ed25519.pub | pbcopy
5454+```
5555+5656+Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key',
5757+paste your public key, give it a descriptive name, and hit
5858+save.
5959+6060+## Create a repository
6161+6262+Once your SSH key is added, create your first repository:
6363+6464+1. Hit the green `+` icon on the topbar, and select
6565+ repository
6666+2. Enter a repository name
6767+3. Add a description
6868+4. Choose a knotserver to host this repository on
6969+5. Hit create
7070+7171+Knots are self-hostable, lightweight Git servers that can
7272+host your repository. Unlike traditional code forges, your
7373+code can live on any server. Read the [Knots](/knots/self-hosting/) section
7474+for more.
7575+7676+## Configure SSH
7777+7878+To ensure Git uses the correct SSH key and connects smoothly
7979+to Tangled, add this configuration to your `~/.ssh/config`
8080+file:
8181+8282+```
8383+Host tangled.org
8484+ Hostname tangled.org
8585+ User git
8686+ IdentityFile ~/.ssh/id_ed25519
8787+ AddressFamily inet
8888+```
8989+9090+This tells SSH to use your specific key when connecting to
9191+Tangled and prevents authentication issues if you have
9292+multiple SSH keys.
9393+9494+Note that this configuration only works for knotservers that
9595+are hosted by tangled.org. If you use a custom knot, refer
9696+to the [Knots](/knots/self-hosting/) section.
9797+9898+## Push your first repository
9999+100100+Initialize a new Git repository:
101101+102102+```bash
103103+mkdir my-project
104104+cd my-project
105105+106106+git init
107107+echo "# My Project" > README.md
108108+```
109109+110110+Add some content and push!
111111+112112+```bash
113113+git add README.md
114114+git commit -m "Initial commit"
115115+git remote add origin git@tangled.org:user.tngl.sh/my-project
116116+git push -u origin main
117117+```
118118+119119+That's it! Your code is now hosted on Tangled.
120120+121121+## Migrating an existing repository
122122+123123+Moving your repositories from GitHub, GitLab, Bitbucket, or
124124+any other Git forge to Tangled is straightforward. You'll
125125+simply change your repository's remote URL. At the moment,
126126+Tangled does not have any tooling to migrate data such as
127127+GitHub issues or pull requests.
128128+129129+First, create a new repository on tangled.org as described
130130+in the [Quick Start Guide](#create-a-repository).
131131+132132+Navigate to your existing local repository:
133133+134134+```bash
135135+cd /path/to/your/existing/repo
136136+```
137137+138138+You can inspect your existing Git remote like so:
139139+140140+```bash
141141+git remote -v
142142+```
143143+144144+You'll see something like:
145145+146146+```bash
147147+origin git@github.com:username/my-project.git (fetch)
148148+origin git@github.com:username/my-project.git (push)
149149+```
150150+151151+Update the remote URL to point to tangled:
152152+153153+```bash
154154+git remote set-url origin git@tangled.org:user.tngl.sh/my-project
155155+```
156156+157157+Verify the change:
158158+159159+```bash
160160+git remote -v
161161+```
162162+163163+You should now see:
164164+165165+```bash
166166+origin git@tangled.org:user.tngl.sh/my-project (fetch)
167167+origin git@tangled.org:user.tngl.sh/my-project (push)
168168+```
169169+170170+Push all your branches and tags to Tangled:
171171+172172+```bash
173173+git push -u origin --all
174174+git push -u origin --tags
175175+```
176176+177177+Your repository is now migrated to Tangled! All commit
178178+history, branches, and tags have been preserved.
179179+180180+## Mirroring a repository to Tangled
181181+182182+If you want to maintain your repository on multiple forges
183183+simultaneously, for example, keeping your primary repository
184184+on GitHub while mirroring to Tangled for backup or
185185+redundancy, you can do so by adding [multiple remotes](https://git-scm.com/docs/git-push#_remotes).
186186+187187+You can configure your local repository to push to both
188188+Tangled and, say, GitHub. You may already have the following
189189+setup:
190190+191191+```bash
192192+$ git remote -v
193193+origin git@github.com:username/my-project.git (fetch)
194194+origin git@github.com:username/my-project.git (push)
195195+```
196196+197197+Now add Tangled as an additional push URL to the same
198198+remote:
199199+200200+```bash
201201+git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project
202202+```
203203+204204+You also need to re-add the original URL as a push
205205+destination (Git will now use the original URL to fetch only):
206206+207207+```bash
208208+git remote set-url --add --push origin git@github.com:username/my-project.git
209209+```
210210+211211+Verify your configuration:
212212+213213+```bash
214214+$ git remote -v
215215+origin git@github.com:username/my-project.git (fetch)
216216+origin git@tangled.org:user.tngl.sh/my-project (push)
217217+origin git@github.com:username/my-project.git (push)
218218+```
219219+220220+Notice that there's one fetch URL (the primary remote) and
221221+two push URLs. Now, whenever you push, Git will
222222+automatically push to both remotes:
223223+224224+```bash
225225+git push origin main
226226+```
227227+228228+This single command pushes your `main` branch to both GitHub
229229+and Tangled simultaneously.
230230+231231+To push all branches and tags:
232232+233233+```bash
234234+git push origin --all
235235+git push origin --tags
236236+```
237237+238238+If you prefer more control over which remote you push to,
239239+you can maintain separate remotes:
240240+241241+```bash
242242+git remote add github git@github.com:username/my-project.git
243243+git remote add tangled git@tangled.org:user.tngl.sh/my-project
244244+```
245245+246246+Then push to each explicitly:
247247+248248+```bash
249249+git push github main
250250+git push tangled main
251251+```
···11+---
22+title: Bobbin
33+description: A read-only XRPC API appview for Tangled records.
44+---
55+66+77+Bobbin is an API appview for Tangled records. It serves XRPC
88+endpoints for `sh.tangled.*`, with it you can get repos,
99+issues, pulls, comments, follows, stars, labels, pipelines,
1010+and profiles. It is read-only, there is no auth, since that
1111+should all be handled direct-to-PDS and knot respectively.
1212+1313+**Bobbin has no permanent storage**.
1414+1515+It is only a glorified edge index, in the graph theory
1616+sense. Additionally it has a record cache, re-filled on
1717+demand. All other data that Bobbin serves comes live from
1818+PDSes & knots.
1919+2020+## What Bobbin needs
2121+2222+The way that Bobbin is able to pull off being
2323+so stateless is by moving state upstream.
2424+Primarily it depends on an instance of
2525+[Hydrant](https://tangled.org/did:plc:6v3ul2ptnqctyxwkz5ti4amn)
2626+, which is the service that gives an event stream
2727+for Bobbin to quickly backfill from on every restart.
2828+Backfilling ought to take less than a couple of minutes
2929+maximum. If the upstream instance of Hydrant fails
3030+while Bobbin is live, its list/count endpoints stop
3131+advancing and report a stale cursor. Single-lookups
3232+will continue working, due to the second dependency:
3333+[Slingshot](https://tangled.org/did:plc:c7mc2fn47ihdihul4vjwsuy3/tree/main/slingshot).
3434+Slingshot fetches individual records & resolves identities.
3535+If the upstream instance of Slingshot fails, single-lookups
3636+will fail with a `502` error. There are some aggregation
3737+endpoints that use Slingshot for hydrating, which will also
3838+fail.
3939+4040+A soft dependency that ought to exist for Bobbin to operate
4141+correctly is simply the plethora of knots that are out
4242+there, that Bobbin talks to directly for git data and, for
4343+knots at v1.15+, members & collaborators.
4444+4545+## Building Bobbin
4646+4747+Bobbin is under [Tangled's core monorepo, under bobbin/](https://tangled.org/did:plc:j5hmlfdrwkvtxm7cjmu7j2is/tree/master/bobbin).
4848+Here's an easy local debug-build:
4949+5050+```sh
5151+cargo build -p bobbin
5252+```
5353+5454+Bobbin loves being in a container. When using
5555+`bobbin/containerfiles/bobbin.Containerfile`, it runs `cargo
5656+build --release --bin bobbin --package bobbin` within a
5757+little Debian runtime, exposing port 8090.
5858+5959+## Configuration
6060+6161+The best way to configure Bobbin is via a toml config file.
6262+There's an `example.toml` in [Bobbin's subdir](https://tangled.org/did:plc:j5hmlfdrwkvtxm7cjmu7j2is/blob/master/bobbin/example.toml).
6363+Every value is overridable by a `BOBBIN_*` env var.
6464+The load order is env, then `--config <path>`, then
6565+`/etc/bobbin/config.toml`, then built-in defaults.
6666+6767+Load and check a config without starting the server:
6868+6969+```sh
7070+bobbin --config config.toml validate
7171+```
7272+7373+Minimal config is the two upstream URLs. The hydrant URL
7474+takes `ws://` or `wss://`. An `http://` or `https://`
7575+URL is rewritten to the matching websocket scheme at
7676+connection-time.
7777+7878+```toml
7979+[server]
8080+binds = ["127.0.0.1:8090"]
8181+8282+# Loopback-only & can leave empty to disable debug introspection.
8383+debug_bind = "127.0.0.1:8091"
8484+8585+[hydrant]
8686+url = "https://hydrant.example.com"
8787+8888+[slingshot]
8989+url = "https://slingshot.example.com"
9090+```
9191+9292+> 🦪 Lewis
9393+>
9494+> At time of writing, we (Tangled) don't host public
9595+> instances of Hydrant or Slingshot. You will have to
9696+> find public instances or spin these up yourself! :P
9797+9898+Take a gander in the project's example.toml for an
9999+exhaustive list of things to configure.
100100+101101+You will discover fun things such as a configurable adaptive
102102+loop that watches the cgroup memory limit & throttles heavy
103103+requests under pressure. It only works if it detects a
104104+cgroup limit is present. The config for that is in the
105105+`[backpressure]` block of the config template.
106106+107107+## Running Bobbin
108108+109109+Start the server using a config toml:
110110+111111+```bash
112112+bobbin --config config.toml
113113+```
114114+Bobbin wakes up in a cold sweat and immediately gets to
115115+work:
116116+1. It binds its listeners, connects to the Hydrant stream
117117+ in the background.
118118+2. It serves requests from the first
119119+ moment it's alive, even before the Hydrant stream connects
120120+ or finishes catching up. Having a cold Hydrant itself
121121+ costs only latency and approximate counts.
122122+123123+## The API
124124+125125+**Single lookups** take a record's AT-URI.
126126+127127+- `getRepo` takes the repo URI:
128128+129129+```sh
130130+curl "$BOBBIN/xrpc/sh.tangled.repo.getRepo?repo=at://did:plc:boltless/sh.tangled.repo/squid"
131131+```
132132+```json
133133+{
134134+ "uri": "at://did:plc:boltless/sh.tangled.repo/squid",
135135+ "cid": "bafyrei...",
136136+ "value": { "$type": "sh.tangled.repo", "knot": "knot1.tangled.sh", "description": "...", "createdAt": "..." }
137137+}
138138+```
139139+140140+- `getProfile` takes the full profile record URI, so a bare
141141+ handle or DID will not resolve:
142142+143143+```sh
144144+curl "$BOBBIN/xrpc/sh.tangled.actor.getProfile?actor=at://did:plc:boltless/sh.tangled.actor.profile/self"
145145+```
146146+147147+- If Slingshot cannot serve the record, the response is `502`:
148148+149149+```json
150150+{ "error": "UpstreamFailed", "message": "upstream unavailable: ..." }
151151+```
152152+153153+**Aggregation** endpoints come in `list*` and `count*` pairs,
154154+each with a `*By` sibling, and require a `subject` query param.
155155+156156+- `listRepos` and `countRepos` key on the owner DID:
157157+158158+```sh
159159+curl "$BOBBIN/xrpc/sh.tangled.repo.countRepos?subject=did:plc:boltless"
160160+```
161161+```json
162162+{ "count": 7, "distinctAuthors": 1 }
163163+```
164164+165165+```sh
166166+curl "$BOBBIN/xrpc/sh.tangled.repo.listRepos?subject=did:plc:boltless&limit=3"
167167+```
168168+```json
169169+{ "items": [ { "uri": "at://did:plc:boltless/sh.tangled.repo/squid", "cid": "bafyrei...", "value": { } } ], "cursor": null }
170170+```
171171+172172+- Bobbin validates the subject per collection. Here a repo URI
173173+ is passed where a bare DID is required, so the call returns a
174174+ `400`:
175175+176176+```sh
177177+curl "$BOBBIN/xrpc/sh.tangled.graph.listFollows?subject=at://did:plc:boltless/sh.tangled.repo/squid"
178178+```
179179+```json
180180+{ "error": "InvalidRequest", "message": "invalid request: subject must be a bare did, got at-uri with collection sh.tangled.repo" }
181181+```
182182+183183+**Search** is a single endpoint over an in-mem full-text
184184+index:
185185+186186+```sh
187187+curl "$BOBBIN/xrpc/sh.tangled.search.query?q=tangled&limit=2"
188188+```
189189+```json
190190+{ "hits": [ { "uri": "at://...", "cid": "...", "nsid": "sh.tangled.repo", "score": 27.1, "value": { } } ], "cursor": null }
191191+```
192192+193193+**Git data** such as blob, tree, diff, log, and archive proxies
194194+straight to the repo's knot, streamed back without caching.
195195+196196+## Coverage and warm-up
197197+198198+- While the edge index is catching up from Hydrant,
199199+ the aggregation count is a lower bound & may still climb.
200200+- One endpoint reports how far along the backfill it is:
201201+202202+```sh
203203+curl "$BOBBIN/xrpc/sh.tangled.bobbin.getCoverage"
204204+```
205205+206206+While warming up:
207207+208208+```json
209209+{ "ready": false, "eventsProcessed": 45588, "lastCursor": 51658 }
210210+```
211211+212212+Once caught up, Bobbin flips to ready:
213213+214214+```json
215215+{ "ready": true, "eventsProcessed": 106085, "lastCursor": 116527 }
216216+```
217217+218218+If starting up Hydrant for the first time, Hydrant itself
219219+will take a decent while (a couple of hours) to backfill
220220+from PDSes. Hydrant stores its backfill on disk. Bobbin
221221+restart reaches `ready` in minutes by replaying event from
222222+an already-populated Hydrant. If your Hydrant is new, expect
223223+Bobbin to backfill in that same couple of hours that Hydrant
224224+takes.
225225+226226+## Loose ends and not-gonna-impl
227227+228228+- **No coverage signal for per-knot rosters yet.**
229229+ Coverage tracks the hydrant stream only. A v1.15 knot
230230+ that is unreachable serves a stale or empty member set
231231+ with nothing to flag it.
232232+- **Knot eventstream fan-out isn't pooled.**
233233+ Bobbin opens one websocket per v1.15
234234+ knot on top of the hydrant subscription. A network with
235235+ thousands of knots wants pooling or a shared subscription.
236236+- **No sequential issue or PR numbers.** bobbin returns rkeys,
237237+ not `#42` style ids like the web appview. A client
238238+ deriving a display number does it from creation order. But
239239+ why bother? rkeys are the IDs.
···11+---
22+title: Migrating knots and spindles
33+description: Upgrade guides for non-backwards compatible knot/spindle changes.
44+---
55+66+77+Sometimes, non-backwards compatible changes are made to the
88+knot/spindle XRPC APIs. If you host a knot or a spindle, you
99+will need to follow this guide to upgrade. Typically, this
1010+only requires you to deploy the newest version.
1111+1212+This document is laid out in reverse-chronological order.
1313+Newer migration guides are listed first, and older guides
1414+are further down the page.
1515+1616+## Upgrading to v1.16.0-alpha
1717+1818+Starting with v1.16.0-alpha, spindles own CI pipeline data
1919+directly. The appview no longer stores pipeline runs or follows
2020+spindle event streams for pipeline history. Instead, it asks the
2121+configured spindle for pipeline lists, single pipeline details,
2222+workflow logs, retries, and cancellations over XRPC.
2323+2424+This means that existing pipeline logs / runs won't appear after
2525+you upgrade. Existing pipeline history from the appview cannot be
2626+automatically migrated. If you want to migrate your data, you can
2727+reach out to us and we will send you an SQL file that'll add the data
2828+into your spindle.
2929+3030+- Upgrade to the latest tag (v1.16.0 or above)
3131+- Head to the [spindle
3232+ dashboard](https://tangled.org/settings/spindles) and hit the
3333+ "retry" button to verify your spindle
3434+3535+## Upgrading to v1.15.0-alpha
3636+3737+With v1.15.0-alpha, a knot itself owns its members and
3838+per-repo collaborators directly. Previously this data was sourced from
3939+PDS records (`sh.tangled.knot.member` and `sh.tangled.repo.collaborator`)
4040+that the appview and the knot both read off the firehose.
4141+The knot is now the source of truth and serves them over XRPC instead:
4242+4343+- `sh.tangled.knot.addMember`, `sh.tangled.knot.removeMember`, `sh.tangled.knot.listMembers`
4444+- `sh.tangled.repo.addCollaborator`, `sh.tangled.repo.removeCollaborator`, `sh.tangled.repo.listCollaborators`
4545+4646+Until your knot is upgraded, the appview keeps reading its
4747+members and collaborators from the old firehose-sourced records.
4848+Upgrade to move your knot onto knot-owned access control.
4949+5050+- Upgrade to the latest tag (v1.15.0 or above)
5151+- Head to the [knot dashboard](https://tangled.org/settings/knots) and
5252+ hit the "retry" button to verify your knot
5353+5454+## Upgrading to v1.14.0-alpha
5555+5656+Starting with v1.14.0-alpha, the fully knot uses the repoDID as its
5757+canonical handle for repositories. This unlocks repository
5858+renames from the appview UI and changes the wire format for
5959+the following lexicons (`sh.tangled.repo.pull`, `sh.tangled.repo.collaborator`,
6060+`sh.tangled.repo.issue`, `sh.tangled.git.refUpdate`).
6161+6262+Knots that have not been upgraded may silently drop new push
6363+events, pull requests, issues, and collaborator invites for
6464+repositories they host until upgraded. So upgrade please!!!
6565+6666+- Upgrade to the latest tag (v1.14.0 or above)
6767+- Head to the [knot dashboard](https://tangled.org/settings/knots) and
6868+ hit the "retry" button to verify your knot
6969+7070+## Upgrading to v1.13.0-alpha
7171+7272+Starting with v1.13.0-alpha, every repository on a knot is
7373+assigned a DID. This makes repositories stable across
7474+renames and transfers.
7575+7676+When you upgrade your knot to this version, the server will
7777+automatically mint DIDs for all existing repositories on
7878+startup. This is a one-time process and you may see
7979+additional log output during the first boot as DIDs are
8080+assigned.
8181+8282+- Upgrade to the latest tag (v1.13.0 or above)
8383+- Head to the [knot dashboard](https://tangled.org/settings/knots) and
8484+ hit the "retry" button to verify your knot
8585+8686+## Upgrading from v1.8.x
8787+8888+After v1.8.2, the HTTP API for knots and spindles has been
8989+deprecated and replaced with XRPC. Repositories on outdated
9090+knots will not be viewable from the appview. Upgrading is
9191+straightforward however.
9292+9393+For knots:
9494+9595+- Upgrade to the latest tag (v1.9.0 or above)
9696+- Head to the [knot dashboard](https://tangled.org/settings/knots) and
9797+ hit the "retry" button to verify your knot
9898+9999+For spindles:
100100+101101+- Upgrade to the latest tag (v1.9.0 or above)
102102+- Head to the [spindle
103103+ dashboard](https://tangled.org/settings/spindles) and hit the
104104+ "retry" button to verify your spindle
105105+106106+## Upgrading from v1.7.x
107107+108108+After v1.7.0, knot secrets have been deprecated. You no
109109+longer need a secret from the appview to run a knot. All
110110+authorized commands to knots are managed via [Inter-Service
111111+Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
112112+Knots will be read-only until upgraded.
113113+114114+Upgrading is quite easy, in essence:
115115+116116+- `KNOT_SERVER_SECRET` is no more, you can remove this
117117+ environment variable entirely
118118+- `KNOT_SERVER_OWNER` is now required on boot, set this to
119119+ your DID. You can find your DID in the
120120+ [settings](https://tangled.org/settings) page.
121121+- Restart your knot once you have replaced the environment
122122+ variable
123123+- Head to the [knot dashboard](https://tangled.org/settings/knots) and
124124+ hit the "retry" button to verify your knot. This simply
125125+ writes a `sh.tangled.knot` record to your PDS.
126126+127127+If you use the nix module, simply bump the flake to the
128128+latest revision, and change your config block like so:
129129+130130+```diff
131131+ services.tangled.knot = {
132132+ enable = true;
133133+ server = {
134134+- secretFile = /path/to/secret;
135135++ owner = "did:plc:foo";
136136+ };
137137+ };
138138+```
···11+---
22+title: Webhooks
33+description: Receive HTTP POST notifications when events occur in your repositories.
44+---
55+66+77+Webhooks allow you to receive HTTP POST notifications when events occur in your repositories. This enables you to integrate Tangled with external services, trigger CI/CD pipelines, send notifications, or automate workflows.
88+99+## Overview
1010+1111+Webhooks send HTTP POST requests to URLs you configure whenever specific events happen. Currently, Tangled supports push, repository rename, and pull request events, with more event types coming soon.
1212+1313+## Configuring webhooks
1414+1515+To set up a webhook for your repository:
1616+1717+1. Navigate to your repository
1818+2. Go to **Settings → Hooks**
1919+3. Click **new webhook**
2020+4. Configure your webhook:
2121+ - **Payload URL**: The endpoint that will receive the webhook POST requests
2222+ - **Secret**: An optional secret key for verifying webhook authenticity (leave blank to send unsigned webhooks)
2323+ - **Events**: Select which events trigger the webhook
2424+ - **Active**: Toggle whether the webhook is enabled
2525+2626+## Webhook payload
2727+2828+### Push
2929+3030+When a push event occurs, Tangled sends a POST request with a JSON payload of the format:
3131+3232+```json
3333+{
3434+ "after": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5",
3535+ "before": "c04ddf64eddc90e4e2a9846ba3b43e67a0e2865e",
3636+ "pusher": {
3737+ "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
3838+ },
3939+ "ref": "refs/heads/main",
4040+ "repository": {
4141+ "clone_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
4242+ "created_at": "2025-09-15T08:57:23Z",
4343+ "description": "an example repository",
4444+ "fork": false,
4545+ "full_name": "did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
4646+ "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
4747+ "name": "some-repo",
4848+ "open_issues_count": 5,
4949+ "owner": {
5050+ "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
5151+ },
5252+ "ssh_url": "ssh://git@tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
5353+ "stars_count": 1,
5454+ "updated_at": "2025-09-15T08:57:23Z"
5555+ }
5656+}
5757+```
5858+5959+### Pull request
6060+6161+Pull request events are sent as separate event types, so you can subscribe to
6262+exactly the transitions you care about:
6363+6464+- `pull_request:created` — a pull request was opened
6565+- `pull_request:resubmitted` — a new round (revision) was pushed to a pull request
6666+- `pull_request:merged` — a pull request was merged
6767+- `pull_request:closed` — a pull request was closed
6868+- `pull_request:reopened` — a closed pull request was reopened
6969+7070+All pull request events share the same payload format:
7171+7272+```json
7373+{
7474+ "action": "created",
7575+ "pull_request": {
7676+ "number": 4,
7777+ "title": "add dark mode",
7878+ "body": "implements dark mode as discussed in #2",
7979+ "state": "open",
8080+ "target_branch": "main",
8181+ "source": {
8282+ "branch": "dark-mode",
8383+ "sha": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5"
8484+ },
8585+ "round_number": 0,
8686+ "owner": {
8787+ "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
8888+ },
8989+ "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo/pulls/4",
9090+ "patch_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo/pulls/4/round/0.patch",
9191+ "created_at": "2025-09-15T08:57:23Z"
9292+ },
9393+ "repository": { ... },
9494+ "sender": {
9595+ "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
9696+ }
9797+}
9898+```
9999+100100+Notes:
101101+102102+- `action` mirrors the event type suffix (`created`, `resubmitted`, `merged`, `closed`, `reopened`).
103103+- `repository` has the same format as in the push payload.
104104+- The patch itself is not embedded in the payload (patches can be large); fetch it from `patch_url` instead. `round_number` identifies the latest round, and `patch_url` always points at that round's patch.
105105+- `source` is only present for branch-based and fork-based pull requests; it is omitted for patch-based pulls. For fork-based pulls, `source.repo` contains the DID of the source repository.
106106+- `sender` is the user who performed the action.
107107+108108+## HTTP headers
109109+110110+Each webhook request includes the following headers:
111111+112112+- `Content-Type: application/json`
113113+- `User-Agent: Tangled-Hook/<short-sha>` — User agent with short SHA of the commit (push events); `Tangled-Hook/pull_request` for pull request events
114114+- `X-Tangled-Event: push` — The full event type (e.g. `push`, `pull_request:merged`)
115115+- `X-Tangled-Hook-ID: <webhook-id>` — The webhook ID
116116+- `X-Tangled-Delivery: <uuid>` — Unique delivery ID
117117+- `X-Tangled-Signature-256: sha256=<hmac>` — HMAC-SHA256 signature (if secret configured)
118118+119119+## Verifying webhook signatures
120120+121121+If you configured a secret, you should verify the webhook signature to ensure requests are authentic. For example, in Go:
122122+123123+```go
124124+package main
125125+126126+import (
127127+ "crypto/hmac"
128128+ "crypto/sha256"
129129+ "encoding/hex"
130130+ "io"
131131+ "net/http"
132132+ "strings"
133133+)
134134+135135+func verifySignature(payload []byte, signatureHeader, secret string) bool {
136136+ // Remove 'sha256=' prefix from signature header
137137+ signature := strings.TrimPrefix(signatureHeader, "sha256=")
138138+139139+ // Compute expected signature
140140+ mac := hmac.New(sha256.New, []byte(secret))
141141+ mac.Write(payload)
142142+ expected := hex.EncodeToString(mac.Sum(nil))
143143+144144+ // Use constant-time comparison to prevent timing attacks
145145+ return hmac.Equal([]byte(signature), []byte(expected))
146146+}
147147+148148+func webhookHandler(w http.ResponseWriter, r *http.Request) {
149149+ // Read the request body
150150+ payload, err := io.ReadAll(r.Body)
151151+ if err != nil {
152152+ http.Error(w, "Bad request", http.StatusBadRequest)
153153+ return
154154+ }
155155+156156+ // Get signature from header
157157+ signatureHeader := r.Header.Get("X-Tangled-Signature-256")
158158+159159+ // Verify signature
160160+ if signatureHeader != "" && verifySignature(payload, signatureHeader, yourSecret) {
161161+ // Webhook is authentic, process it
162162+ processWebhook(payload)
163163+ w.WriteHeader(http.StatusOK)
164164+ } else {
165165+ http.Error(w, "Invalid signature", http.StatusUnauthorized)
166166+ }
167167+}
168168+```
169169+170170+## Delivery retries
171171+172172+Webhooks are automatically retried on failure:
173173+174174+- **3 total attempts** (1 initial + 2 retries)
175175+- **Exponential backoff** starting at 1 second, max 10 seconds
176176+- **Retried on**:
177177+ - Network errors
178178+ - HTTP 5xx server errors
179179+- **Not retried on**:
180180+ - HTTP 4xx client errors (bad request, unauthorized, etc.)
181181+182182+### Timeouts
183183+184184+Webhook requests timeout after 30 seconds. If your endpoint needs more time:
185185+186186+1. Respond with 200 OK immediately
187187+2. Process the webhook asynchronously in the background
188188+189189+## Example integrations
190190+191191+### Discord notifications
192192+193193+```javascript
194194+app.post("/webhook", (req, res) => {
195195+ const payload = req.body;
196196+197197+ fetch("https://discord.com/api/webhooks/...", {
198198+ method: "POST",
199199+ headers: { "Content-Type": "application/json" },
200200+ body: JSON.stringify({
201201+ content: `New push to ${payload.repository.full_name}`,
202202+ embeds: [
203203+ {
204204+ title: `${payload.pusher.did} pushed to ${payload.ref}`,
205205+ url: payload.repository.html_url,
206206+ color: 0x00ff00,
207207+ },
208208+ ],
209209+ }),
210210+ });
211211+212212+ res.status(200).send("OK");
213213+});
214214+```
215215+
···11+---
22+title: Hosting websites
33+description: Serve static websites directly from your git repositories on Tangled.
44+---
55+66+77+You can serve static websites directly from your git repositories on
88+Tangled. If you've used GitHub Pages or Codeberg Pages, this should feel
99+familiar.
1010+1111+## Overview
1212+1313+Every user gets a sites domain. If you signed up through Tangled's own
1414+PDS (`tngl.sh`), your sites domain is automatically
1515+`<your-handle>.tngl.sh` no setup needed. Otherwise, you can claim a
1616+`<subdomain>.tngl.io` domain from your settings.
1717+1818+You can serve multiple sites per domain:
1919+2020+- One **index site** served at the root of your domain (e.g.
2121+ `alice.tngl.sh`)
2222+- Any number of **sub-path sites** served under the repository name
2323+ (e.g. `alice.tngl.sh/my-project`)
2424+2525+## Claiming a domain
2626+2727+If you don't have a `tngl.sh` handle, you need to claim a domain before
2828+publishing sites:
2929+3030+1. Go to **Settings → Sites**
3131+2. Enter a subdomain (e.g. `alice` to claim `alice.tngl.io`)
3232+3. Click **claim**
3333+3434+You can only hold one domain at a time. Releasing a domain puts it in a
3535+30-day cooldown before anyone else can claim it.
3636+3737+## Configuring a site for a repository
3838+3939+1. Navigate to your repository
4040+2. Go to **Settings → Sites**
4141+3. Choose a **branch** to deploy from
4242+4. Set the **deploy directory** — the path within the repository
4343+ containing your `index.html`. Use `/` for the root, or a subdirectory
4444+ like `/docs` or `/public`
4545+5. Choose the **site type**:
4646+ - **Index site** — served at the root of your domain (e.g.
4747+ `alice.tngl.sh`)
4848+ - **Sub-path site** — served under the repository name (e.g.
4949+ `alice.tngl.sh/my-project`)
5050+6. Click **save**
5151+5252+The site will be deployed automatically. You can see the status of your
5353+previous deploys in the **Recent Deploys** section at the bottom of the
5454+page.
5555+5656+Sites are redeployed automatically on every push to the configured
5757+branch.
5858+5959+## Custom domains
6060+6161+Tangled currently doesn't support custom domains for sites. This will be
6262+added in a future update.
6363+6464+## Deploy directory
6565+6666+The deploy directory is the path within your repository that Tangled
6767+serves as the site root. It must contain an `index.html`.
6868+6969+| Deploy directory | Result |
7070+|---|---|
7171+| `/` | Serves the repository root |
7272+| `/docs` | Serves the `docs/` subdirectory |
7373+| `/public` | Serves the `public/` subdirectory |
7474+7575+Directories are served with automatic `index.html` resolution -- a
7676+request to `/about` will serve `/about/index.html` if it exists.
7777+7878+## Site types
7979+8080+| Type | URL |
8181+|---|---|
8282+| Index site | `alice.tngl.sh` |
8383+| Sub-path site | `alice.tngl.sh/my-project` |
8484+8585+Only one repository can be the index site for a given domain at a time.
8686+If another repository already holds the index site, you will see a
8787+notice in the settings and only the sub-path option will be available.
8888+8989+## Deploy triggers
9090+9191+A deployment is triggered automatically when:
9292+9393+- You push to the configured branch
9494+- You change the site configuration (branch, deploy directory, or site
9595+ type)
9696+9797+## Disabling a site
9898+9999+To stop serving a site, go to **Settings → Sites** in your repository
100100+and click **Disable**. This removes the site configuration and stops
101101+serving the site. The deployed files are also deleted from storage.
102102+103103+Releasing your domain from **Settings → Sites** at the account level
104104+will disable all sites associated with it and delete their files.
105105+
···11+---
22+title: Architecture
33+description: How spindle listens for records and executes pipelines.
44+---
55+66+77+Spindle is a small CI runner service. Here's a high-level overview of how it operates:
88+99+- Listens for [`sh.tangled.spindle.member`](https://tangled.org/@tangled.org/core/blob/master/lexicons/spindle/member.json) and
1010+ [`sh.tangled.repo`](https://tangled.org/@tangled.org/core/blob/master/lexicons/repo.json) records on the Jetstream.
1111+- When a new repo record comes through (typically when you add a spindle to a
1212+ repo from the settings), spindle then resolves the underlying knot and
1313+ subscribes to repo events (see:
1414+ [`sh.tangled.pipeline`](https://tangled.org/@tangled.org/core/blob/master/lexicons/pipeline.json)).
1515+- The spindle engine then handles execution of the pipeline, with results and
1616+ logs beamed on the spindle event stream over WebSocket
1717+1818+## The engines
1919+2020+Spindle has two execution backends, picked per-workflow with
2121+the [`engine`](#engine) field:
2222+2323+- **nixery**: executes each step in a fresh Docker container
2424+ (Podman works too, if Docker compatibility is enabled so
2525+ that `/run/docker.sock` is created), with state persisted
2626+ across steps within the `/tangled/workspace` directory. The
2727+ base image for the container is constructed on the fly using
2828+ [Nixery](https://nixery.dev), which is/rhandy for caching
2929+ layers for frequently used packages.
3030+- **microvm**: runs the whole workflow inside its own
3131+ microVM, supporting different images, with extra
3232+ configuration for NixOS images (e.g. services in workflow file)
3333+ See the [engine
3434+ README](https://tangled.org/tangled.org/core/blob/master/spindle/engines/microvm/README.md)
3535+ for the architecture in depth.
3636+3737+The pipeline manifest is [specified here](/spindles/workflows/#pipelines).
···11+---
22+title: What are spindles?
33+description: Spindles are Tangled's CI runners, built atop Nix and the AT Protocol.
44+sidebar:
55+ order: 0
66+---
77+88+Spindle is Tangled's continuous integration runner, built atop Nix
99+and the AT Protocol. Like knots, spindles are self-hostable by
1010+design: you can use a managed spindle, or run your own and point
1111+your repositories at it.
1212+1313+A spindle subscribes to repository events. When you push code or
1414+open a pull request, the knot hosting the repository emits an
1515+event; the spindle picks it up, reads the [workflow
1616+manifests](/spindles/workflows/) in `.tangled/workflows/`, spins
1717+up an execution environment, and runs your steps. Status and logs
1818+stream back over websockets, so you can watch runs live from the
1919+appview (or [tail them over SSH](https://blog.tangled.org/ssh)).
2020+2121+Workflow manifests are intentionally simple. There is no
2222+"marketplace" of workflows or complex job orchestration —
2323+dependencies are just [nixpkgs](https://search.nixos.org)
2424+packages, listed by name, with no toolchain setup.
2525+2626+## Engines
2727+2828+Spindles can execute workflows on two engines:
2929+3030+- **nixery** runs each step in an OCI container (Docker, or
3131+ Podman with Docker compatibility). Images are built on the fly
3232+ by [Nixery](https://nixery.dev), which turns a list of Nix
3333+ packages into a layered container image.
3434+- **microVM** runs the whole workflow inside a QEMU microVM — a
3535+ VM with the boring parts removed: no BIOS, no PCI bus to probe,
3636+ no emulated graphics. Boot times are fast, isolation is strong
3737+ (isolated network namespaces, DNS filtering, blackholed
3838+ special-use IP ranges), and workflows can declare NixOS
3939+ configuration directly — `services.postgresql.enable: true` gets
4040+ you a running database. Existing nixery workflows port over by
4141+ changing `engine: nixery` to `engine: microvm`.
4242+4343+Secrets use AT Protocol [service
4444+auth](https://atproto.com/specs/xrpc#inter-service-authentication-jwt):
4545+the appview makes a signed request using the logged-in user's DID
4646+key, and the spindle verifies the signature against the public key
4747+in their DID document. No shared secrets between services.
4848+4949+## Where to go next
5050+5151+- [Workflows](/spindles/workflows/) — the manifest format:
5252+ triggers, dependencies, engines, and example recipes
5353+- [Self-hosting a spindle](/spindles/self-hosting/) — run your own
5454+ CI on your own machines
5555+- [Secrets with OpenBao](/spindles/secrets/) — back spindle
5656+ secrets with OpenBao instead of SQLite
5757+- [Architecture](/spindles/architecture/) — how spindle consumes
5858+ records and executes pipelines
5959+6060+## Further reading
6161+6262+- [Introducing spindle](https://blog.tangled.org/ci) — the
6363+ announcement post
6464+- [Tangled CI runs on microVMs](https://blog.tangled.org/spindle-microvm)
6565+ — a deep dive into the QEMU-based microVM engine
···11+---
22+title: Secrets with OpenBao
33+description: Use OpenBao Proxy for spindle secrets management instead of SQLite.
44+---
55+66+77+This document covers setting up spindle to use OpenBao for secrets
88+management via OpenBao Proxy instead of the default SQLite backend.
99+1010+## Overview
1111+1212+Spindle now uses OpenBao Proxy for secrets management. The proxy handles
1313+authentication automatically using AppRole credentials, while spindle
1414+connects to the local proxy instead of directly to the OpenBao server.
1515+1616+This approach provides better security, automatic token renewal, and
1717+simplified application code.
1818+1919+## Installation
2020+2121+Install OpenBao from Nixpkgs:
2222+2323+```bash
2424+nix shell nixpkgs#openbao # for a local server
2525+```
2626+2727+## Setup
2828+2929+The setup process can is documented for both local development and production.
3030+3131+### Local development
3232+3333+Start OpenBao in dev mode:
3434+3535+```bash
3636+bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
3737+```
3838+3939+This starts OpenBao on `http://localhost:8201` with a root token.
4040+4141+Set up environment for bao CLI:
4242+4343+```bash
4444+export BAO_ADDR=http://localhost:8200
4545+export BAO_TOKEN=root
4646+```
4747+4848+### Production
4949+5050+You would typically use a systemd service with a
5151+configuration file. Refer to
5252+[@tangled.org/infra](https://tangled.org/@tangled.org/infra)
5353+for how this can be achieved using Nix.
5454+5555+Then, initialize the bao server:
5656+5757+```bash
5858+bao operator init -key-shares=1 -key-threshold=1
5959+```
6060+6161+This will print out an unseal key and a root key. Save them
6262+somewhere (like a password manager). Then unseal the vault
6363+to begin setting it up:
6464+6565+```bash
6666+bao operator unseal <unseal_key>
6767+```
6868+6969+All steps below remain the same across both dev and
7070+production setups.
7171+7272+### Configure openbao server
7373+7474+Create the spindle KV mount:
7575+7676+```bash
7777+bao secrets enable -path=spindle -version=2 kv
7878+```
7979+8080+Set up AppRole authentication and policy:
8181+8282+Create a policy file `spindle-policy.hcl`:
8383+8484+```hcl
8585+# Full access to spindle KV v2 data
8686+path "spindle/data/*" {
8787+ capabilities = ["create", "read", "update", "delete"]
8888+}
8989+9090+# Access to metadata for listing and management
9191+path "spindle/metadata/*" {
9292+ capabilities = ["list", "read", "delete", "update"]
9393+}
9494+9595+# Allow listing at root level
9696+path "spindle/" {
9797+ capabilities = ["list"]
9898+}
9999+100100+# Required for connection testing and health checks
101101+path "auth/token/lookup-self" {
102102+ capabilities = ["read"]
103103+}
104104+```
105105+106106+Apply the policy and create an AppRole:
107107+108108+```bash
109109+bao policy write spindle-policy spindle-policy.hcl
110110+bao auth enable approle
111111+bao write auth/approle/role/spindle \
112112+ token_policies="spindle-policy" \
113113+ token_ttl=1h \
114114+ token_max_ttl=4h \
115115+ bind_secret_id=true \
116116+ secret_id_ttl=0 \
117117+ secret_id_num_uses=0
118118+```
119119+120120+Get the credentials:
121121+122122+```bash
123123+# Get role ID (static)
124124+ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
125125+126126+# Generate secret ID
127127+SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
128128+129129+echo "Role ID: $ROLE_ID"
130130+echo "Secret ID: $SECRET_ID"
131131+```
132132+133133+### Create proxy configuration
134134+135135+Create the credential files:
136136+137137+```bash
138138+# Create directory for OpenBao files
139139+mkdir -p /tmp/openbao
140140+141141+# Save credentials
142142+echo "$ROLE_ID" > /tmp/openbao/role-id
143143+echo "$SECRET_ID" > /tmp/openbao/secret-id
144144+chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
145145+```
146146+147147+Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
148148+149149+```hcl
150150+# OpenBao server connection
151151+vault {
152152+ address = "http://localhost:8200"
153153+}
154154+155155+# Auto-Auth using AppRole
156156+auto_auth {
157157+ method "approle" {
158158+ mount_path = "auth/approle"
159159+ config = {
160160+ role_id_file_path = "/tmp/openbao/role-id"
161161+ secret_id_file_path = "/tmp/openbao/secret-id"
162162+ }
163163+ }
164164+165165+ # Optional: write token to file for debugging
166166+ sink "file" {
167167+ config = {
168168+ path = "/tmp/openbao/token"
169169+ mode = 0640
170170+ }
171171+ }
172172+}
173173+174174+# Proxy listener for spindle
175175+listener "tcp" {
176176+ address = "127.0.0.1:8201"
177177+ tls_disable = true
178178+}
179179+180180+# Enable API proxy with auto-auth token
181181+api_proxy {
182182+ use_auto_auth_token = true
183183+}
184184+185185+# Enable response caching
186186+cache {
187187+ use_auto_auth_token = true
188188+}
189189+190190+# Logging
191191+log_level = "info"
192192+```
193193+194194+### Start the proxy
195195+196196+Start OpenBao Proxy:
197197+198198+```bash
199199+bao proxy -config=/tmp/openbao/proxy.hcl
200200+```
201201+202202+The proxy will authenticate with OpenBao and start listening on
203203+`127.0.0.1:8201`.
204204+205205+### Configure spindle
206206+207207+Set these environment variables for spindle:
208208+209209+```bash
210210+export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
211211+export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
212212+export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
213213+```
214214+215215+On startup, spindle will now connect to the local proxy,
216216+which handles all authentication automatically.
217217+218218+## Production setup for proxy
219219+220220+For production, you'll want to run the proxy as a service:
221221+222222+Place your production configuration in
223223+`/etc/openbao/proxy.hcl` with proper TLS settings for the
224224+vault connection.
225225+226226+## Verifying setup
227227+228228+Test the proxy directly:
229229+230230+```bash
231231+# Check proxy health
232232+curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
233233+234234+# Test token lookup through proxy
235235+curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
236236+```
237237+238238+Test OpenBao operations through the server:
239239+240240+```bash
241241+# List all secrets
242242+bao kv list spindle/
243243+244244+# Add a test secret via the spindle API, then check it exists
245245+bao kv list spindle/repos/
246246+247247+# Get a specific secret
248248+bao kv get spindle/repos/your_repo_path/SECRET_NAME
249249+```
250250+251251+## How it works
252252+253253+- Spindle connects to OpenBao Proxy on localhost (typically
254254+ port 8200 or 8201)
255255+- The proxy authenticates with OpenBao using AppRole
256256+ credentials
257257+- All spindle requests go through the proxy, which injects
258258+ authentication tokens
259259+- Secrets are stored at
260260+ `spindle/repos/{sanitized_repo_path}/{secret_key}`
261261+- Repository paths like `did:plc:alice/myrepo` become
262262+ `did_plc_alice_myrepo`
263263+- The proxy handles all token renewal automatically
264264+- Spindle no longer manages tokens or authentication
265265+ directly
266266+267267+## Troubleshooting
268268+269269+**Connection refused**: Check that the OpenBao Proxy is
270270+running and listening on the configured address.
271271+272272+**403 errors**: Verify the AppRole credentials are correct
273273+and the policy has the necessary permissions.
274274+275275+**404 route errors**: The spindle KV mount probably doesn't
276276+exist—run the mount creation step again.
277277+278278+**Proxy authentication failures**: Check the proxy logs and
279279+verify the role-id and secret-id files are readable and
280280+contain valid credentials.
281281+282282+**Secret not found after writing**: This can indicate policy
283283+permission issues. Verify the policy includes both
284284+`spindle/data/*` and `spindle/metadata/*` paths with
285285+appropriate capabilities.
286286+287287+Check proxy logs:
288288+289289+```bash
290290+# If running as systemd service
291291+journalctl -u openbao-proxy -f
292292+293293+# If running directly, check the console output
294294+```
295295+296296+Test AppRole authentication manually:
297297+298298+```bash
299299+bao write auth/approle/login \
300300+ role_id="$(cat /tmp/openbao/role-id)" \
301301+ secret_id="$(cat /tmp/openbao/secret-id)"
302302+```
···11+---
22+title: Self-hosting a spindle
33+description: Run your own spindle CI runner, including microVM workflows.
44+---
55+66+77+## Prerequisites
88+99+- Go
1010+- For the **nixery** engine: Docker (or Podman with Docker
1111+ compatibility enabled).
1212+- For the **microVM** engine: a Linux host with KVM, plus the
1313+ microVM host dependencies described in [Running microVM
1414+ workflows](#running-microvm-workflows).
1515+1616+## Configuration
1717+1818+Spindle is configured using environment variables. The following environment variables are available:
1919+2020+- `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
2121+- `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
2222+- `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
2323+- `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
2424+- `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
2525+- `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
2626+- `SPINDLE_SERVER_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
2727+- `SPINDLE_SERVER_DOCKER_SOCKET`: Path to Docker socket to expose to invoked Spindle containers (default: `""`).
2828+- `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
2929+- `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
3030+3131+For the microVM engine, the following are also available
3232+(prefix `SPINDLE_MICROVM_PIPELINES_`):
3333+3434+- `SPINDLE_MICROVM_PIPELINES_IMAGE_DIR`: Directory containing
3535+ microVM images (**required** to use the engine). See
3636+ [Running microVM workflows](#running-microvm-workflows).
3737+- `SPINDLE_MICROVM_PIPELINES_DEFAULT_IMAGE`: Image used when a
3838+ workflow doesn't set `image` (default: `"nixos-x86_64"`).
3939+- `SPINDLE_MICROVM_PIPELINES_OVERLAY_DIR`: Where per-workflow
4040+ temporary disks are created (default: the system temp dir).
4141+- `SPINDLE_MICROVM_PIPELINES_ENABLE_KVM`: Use KVM hardware
4242+ acceleration (default: `true`). Without KVM, guests fall
4343+ back to slow software emulation.
4444+- `SPINDLE_MICROVM_PIPELINES_WORKFLOW_TIMEOUT`: Default
4545+ workflow timeout (default: `"5m"`).
4646+4747+Optional resource limits (a value of `0` disables that
4848+limit). The limits cap usage across all running microVM
4949+workflows:
5050+5151+- `SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_MEMORY_MIB`
5252+- `SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_VCPUS`
5353+- `SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_DISK_MIB`
5454+5555+Optional cgroup enforcement:
5656+5757+- `SPINDLE_MICROVM_PIPELINES_ENABLE_CGROUPS`: Place each
5858+ workflow's QEMU and slirp4netns in a per-workflow cgroup=
5959+ (default: `false`).
6060+- `SPINDLE_MICROVM_PIPELINES_CGROUP_PARENT`: Parent cgroup;
6161+ `self` resolves the spindle service's own cgroup (default:
6262+ `"self"`).
6363+- `SPINDLE_MICROVM_PIPELINES_CGROUP_PIDS_MAX`: Max processes
6464+ per workflow cgroup (default: `4096`).
6565+- `SPINDLE_MICROVM_PIPELINES_CGROUP_SWAP_MAX_MIB`: Max swap
6666+ per workflow cgroup (default: `0`, no swap).
6767+- `SPINDLE_MICROVM_PIPELINES_CGROUP_SUPERVISOR_MEMORY_MIN_MIB`:
6868+ Memory protected for spindle itself so it isn't OOM-killed
6969+ before the workflows (default: `512`).
7070+7171+To push paths built inside microVMs back to a shared Nix
7272+cache (and read from it), configure the cache (prefix
7373+`SPINDLE_NIX_CACHE_`):
7474+7575+- `SPINDLE_NIX_CACHE_READ_URLS`: Comma-separated binary cache
7676+ URLs the guest reads from.
7777+- `SPINDLE_NIX_CACHE_TRUSTED_PUBLIC_KEYS`: Comma-separated
7878+ trusted public keys for those caches.
7979+- `SPINDLE_NIX_CACHE_UPLOAD_URL`: Cache URL that paths built
8080+ in the guest are uploaded to.
8181+8282+## Running spindle
8383+8484+1. **Set the environment variables.** For example:
8585+8686+ ```shell
8787+ export SPINDLE_SERVER_HOSTNAME="your-hostname"
8888+ export SPINDLE_SERVER_OWNER="your-did"
8989+ ```
9090+9191+2. **Build the Spindle binary.**
9292+9393+ ```shell
9494+ cd core
9595+ go mod download
9696+ go build -o cmd/spindle/spindle cmd/spindle/main.go
9797+ ```
9898+9999+3. **Create the log directory.**
100100+101101+ ```shell
102102+ sudo mkdir -p /var/log/spindle
103103+ sudo chown $USER:$USER -R /var/log/spindle
104104+ ```
105105+106106+4. **Run the Spindle binary.**
107107+108108+ ```shell
109109+ ./cmd/spindle/spindle
110110+ ```
111111+112112+Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
113113+114114+## Running microVM workflows
115115+116116+The microVM engine needs a few extra things on the host, and
117117+it needs images to boot.
118118+119119+### Host dependencies
120120+121121+microVM workflows depend on a handful of host tools and
122122+devices. spindle checks for the ones an image needs right
123123+before it launches, so a missing dependency surfaces as a
124124+clear error. You'll need:
125125+126126+- `qemu`: the runner. The QEMU binary for the image's arch
127127+ must be present (e.g. `qemu-system-x86_64`).
128128+- `mkfs.ext4` (from `e2fsprogs`): to format the per-workflow
129129+ writable volumes.
130130+- [`slirp4netns`](https://github.com/rootless-containers/slirp4netns#install),
131131+ `ip` (from `iproute2`), `mount` and `unshare` (from `util-linux`):
132132+ used to sandbox guest networking.
133133+- `/dev/kvm`: for hardware acceleration (unless you disable
134134+ KVM with `SPINDLE_MICROVM_PIPELINES_ENABLE_KVM=false`).
135135+- `/dev/vhost-vsock`: used by QEMU to enable guest-to-host vsock
136136+ communication.
137137+- `/dev/vsock`: used by spindle on the host to listen on vsock ports
138138+ and accept guest agent connections.
139139+- `/dev/net/tun`: required by `slirp4netns` to set up tap devices
140140+ for sandboxed guest networking.
141141+142142+On NixOS, the [spindle
143143+module](https://tangled.org/tangled.org/core/blob/master/nix/modules/spindle.nix)
144144+puts `qemu`, `e2fsprogs`, `slirp4netns`, `iproute2` and
145145+`util-linux` on the service's `PATH` for you.
146146+147147+### Container virtualization
148148+149149+Running `spindle` inside a container (e.g., Docker, Podman, or LXD) requires passing host device nodes into the container and granting the runtime additional privileges. Because spindle uses nested namespaces (`unshare`) and helper tools (`slirp4netns`), the container configuration will require:
150150+151151+- `/dev/vsock`, `/dev/vhost-vsock`, `/dev/kvm`, and `/dev/net/tun` mapped from the host.
152152+- `NET_ADMIN` and `SYS_ADMIN` capabilities to manage network namespaces and mounts inside the container.
153153+- Relaxed seccomp filters (e.g., `seccomp=unconfined`) and SELinux/AppArmor containment if they restrict namespace creation or device access.
154154+155155+### Building images
156156+157157+Images are built with Nix. The flake exposes packages for the
158158+two stock images (use the `-tarball` prefixed ones for a gzipped
159159+tarball you can copy to another host):
160160+161161+```shell
162162+# a NixOS image
163163+nix build .#spindle-nixos-image
164164+# an Alpine image
165165+nix build .#spindle-alpine-image
166166+```
167167+168168+### Installing images
169169+170170+Spindle looks for images in
171171+`SPINDLE_MICROVM_PIPELINES_IMAGE_DIR`. An image is resolved by
172172+the name a workflow puts in its `image` field, matched
173173+literally against what's on disk:
174174+175175+1. a directory `<name>/` containing a `spec.json` (next to the
176176+ kernel/initrd/store-disk), or
177177+2. a flat `<name>.json` self-contained spec.
178178+179179+Resolution depends only on the name and what's on disk, never
180180+on the host doing the resolving, so the same workflow resolves
181181+to the same image on every spindle. If you keep multiple
182182+arches side by side, you can name them `<name>-<arch>` (e.g.
183183+`nixos-x86_64`, `alpine-aarch64`); the suffix is just part of
184184+the name. To make a name like `nixos` work if you are hosting
185185+multiple arches, you can use symlinks.
186186+187187+On NixOS, you'll most likely want to use `systemd.tmpfiles.rules`
188188+to set these up declaratively.
189189+
···11+---
22+title: Workflows
33+description: Write CI/CD pipelines for your repositories using spindle workflows.
44+---
55+66+77+## Pipelines
88+99+Spindle workflows allow you to write CI/CD pipelines in a
1010+simple format. They're located in the `.tangled/workflows`
1111+directory at the root of your repository, and are defined
1212+using YAML.
1313+1414+A workflow has a set of common fields that apply no matter
1515+which engine you pick:
1616+1717+- [Trigger](#trigger): A **required** field that defines
1818+ when a workflow should be triggered.
1919+- [Engine](#engine): A **required** field that defines which
2020+ engine a workflow should run on.
2121+- [Clone options](#clone-options): An **optional** field
2222+ that defines how the repository should be cloned.
2323+- [Environment](#environment): An **optional** field that
2424+ allows you to define environment variables.
2525+- [Steps](#steps): An **optional** field that allows you to
2626+ define what steps should run in the workflow.
2727+2828+On top of these, each engine has its own options for things
2929+like dependencies and images. See [Engines](#engines) for
3030+the per-engine fields.
3131+3232+### Trigger
3333+3434+The first thing to add to a workflow is the trigger, which
3535+defines when a workflow runs. This is defined using a `when`
3636+field, which takes in a list of conditions. Each condition
3737+has the following fields:
3838+3939+- `event`: This is a **required** field that defines when
4040+ your workflow should run. It's a list that can take one or
4141+ more of the following values:
4242+ - `push`: The workflow should run every time a commit is
4343+ pushed to the repository.
4444+ - `pull_request`: The workflow should run every time a
4545+ pull request is made or updated.
4646+ - `manual`: The workflow can be triggered manually.
4747+- `branch`: Defines which branches the workflow should run
4848+ for. If used with the `push` event, commits to the
4949+ branch(es) listed here will trigger the workflow. If used
5050+ with the `pull_request` event, updates to pull requests
5151+ targeting the branch(es) listed here will trigger the
5252+ workflow. This field has no effect with the `manual`
5353+ event. Supports glob patterns using `*` and `**` (e.g.,
5454+ `main`, `develop`, `release-*`). Either `branch` or `tag`
5555+ (or both) must be specified for `push` events.
5656+- `tag`: Defines which tags the workflow should run for.
5757+ Only used with the `push` event - when tags matching the
5858+ pattern(s) listed here are pushed, the workflow will
5959+ trigger. This field has no effect with `pull_request` or
6060+ `manual` events. Supports glob patterns using `*` and `**`
6161+ (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or
6262+ `tag` (or both) must be specified for `push` events.
6363+6464+For example, if you'd like to define a workflow that runs
6565+when commits are pushed to the `main` and `develop`
6666+branches, or when pull requests that target the `main`
6767+branch are updated, or manually, you can do so with:
6868+6969+```yaml
7070+when:
7171+ - event: ["push", "manual"]
7272+ branch: ["main", "develop"]
7373+ - event: ["pull_request"]
7474+ branch: ["main"]
7575+```
7676+7777+You can also trigger workflows on tag pushes. For instance,
7878+to run a deployment workflow when tags matching `v*` are
7979+pushed:
8080+8181+```yaml
8282+when:
8383+ - event: ["push"]
8484+ tag: ["v*"]
8585+```
8686+8787+You can even combine branch and tag patterns in a single
8888+constraint (the workflow triggers if either matches):
8989+9090+```yaml
9191+when:
9292+ - event: ["push"]
9393+ branch: ["main", "release-*"]
9494+ tag: ["v*", "stable"]
9595+```
9696+9797+To skip CI for a push, pass a Git push option:
9898+9999+```sh
100100+git push -o skip-ci
101101+```
102102+103103+`ci-skip` is also accepted.
104104+105105+### Engine
106106+107107+Next is the engine on which the workflow should run, defined
108108+using the **required** `engine` field. The currently
109109+supported engines are:
110110+111111+- `nixery`: This uses an instance of
112112+ [Nixery](https://nixery.dev) to run steps, which allows
113113+ you to add [dependencies](#dependencies) from
114114+ Nixpkgs (https://github.com/NixOS/nixpkgs). You can
115115+ search for packages on https://search.nixos.org, and
116116+ there's a pretty good chance the package(s) you're looking
117117+ for will be there.
118118+ See [Nixery engine](#nixery-engine).
119119+- `microvm`: Runs the whole workflow inside its own
120120+ microVM. Has configuration features for NixOS images
121121+ that will let you enable services, do Docker-in-VM, etc.
122122+ See [microVM engine](#microvm-engine).
123123+124124+Example:
125125+126126+```yaml
127127+engine: "nixery"
128128+```
129129+130130+Each engine also adds its own workflow fields (dependencies,
131131+images, services, and so on). These are documented under
132132+[Engines](#engines).
133133+134134+### Clone options
135135+136136+When a workflow starts, the first step is to clone the
137137+repository. You can customize this behavior using the
138138+**optional** `clone` field. It has the following fields:
139139+140140+- `skip`: Setting this to `true` will skip cloning the
141141+ repository. This can be useful if your workflow is doing
142142+ something that doesn't require anything from the
143143+ repository itself. This is `false` by default.
144144+- `depth`: This sets the number of commits, or the "clone
145145+ depth", to fetch from the repository. For example, if you
146146+ set this to 2, the last 2 commits will be fetched. By
147147+ default, the depth is set to 1, meaning only the most
148148+ recent commit will be fetched, which is the commit that
149149+ triggered the workflow.
150150+- `submodules`: If you use Git submodules
151151+ (https://git-scm.com/book/en/v2/Git-Tools-Submodules)
152152+ in your repository, setting this field to `true` will
153153+ recursively fetch all submodules. This is `false` by
154154+ default.
155155+156156+The default settings are:
157157+158158+```yaml
159159+clone:
160160+ skip: false
161161+ depth: 1
162162+ submodules: false
163163+```
164164+165165+### Environment
166166+167167+The `environment` field allows you define environment
168168+variables that will be available throughout the entire
169169+workflow. **Do not put secrets here, these environment
170170+variables are visible to anyone viewing the repository. You
171171+can add secrets for pipelines in your repository's
172172+settings.**
173173+174174+Example:
175175+176176+```yaml
177177+environment:
178178+ GOOS: "linux"
179179+ GOARCH: "arm64"
180180+ NODE_ENV: "production"
181181+ MY_ENV_VAR: "MY_ENV_VALUE"
182182+```
183183+184184+By default, the following environment variables are set:
185185+186186+- `CI` - Always set to `true` to indicate a CI environment
187187+- `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline
188188+- `TANGLED_PIPELINE_KIND` - One of `push`, `pull_request` or
189189+ `manual`
190190+- `TANGLED_REPO_KNOT` - The repository's knot hostname
191191+- `TANGLED_REPO_DID` - The DID of the repository owner
192192+- `TANGLED_REPO_NAME` - The name of the repository
193193+- `TANGLED_REPO_DEFAULT_BRANCH` - The default branch of the
194194+ repository
195195+- `TANGLED_REPO_URL` - The full URL to the repository
196196+197197+These variables are only available when the pipeline is
198198+triggered by a push:
199199+200200+- `TANGLED_REF` - The full git reference (e.g.,
201201+ `refs/heads/main` or `refs/tags/v1.0.0`)
202202+- `TANGLED_REF_NAME` - The short name of the reference
203203+ (e.g., `main` or `v1.0.0`)
204204+- `TANGLED_REF_TYPE` - The type of reference, either
205205+ `branch` or `tag`
206206+- `TANGLED_SHA` - The commit SHA that triggered the pipeline
207207+- `TANGLED_COMMIT_SHA` - Alias for `TANGLED_SHA`
208208+209209+These variables are only available when the pipeline is
210210+triggered by a pull request:
211211+212212+- `TANGLED_PR_SOURCE_BRANCH` - The source branch of the pull
213213+ request
214214+- `TANGLED_PR_TARGET_BRANCH` - The target branch of the pull
215215+ request
216216+- `TANGLED_PR_SOURCE_SHA` - The commit SHA of the source
217217+ branch
218218+219219+### Steps
220220+221221+The `steps` field allows you to define what steps should run
222222+in the workflow. It's a list of step objects, each with the
223223+following fields:
224224+225225+- `name`: This field allows you to give your step a name.
226226+ This name is visible in your workflow runs, and is used to
227227+ describe what the step is doing.
228228+- `command`: This field allows you to define a command to
229229+ run in that step. The step is run in a Bash shell, and the
230230+ logs from the command will be visible in the pipelines
231231+ page on the Tangled website. Any dependencies you added in
232232+ your engine's section (see [Engines](#engines)) will be
233233+ available to use here.
234234+- `environment`: Similar to the global
235235+ [environment](#environment) config, this **optional**
236236+ field is a key-value map that allows you to set
237237+ environment variables for the step. **Do not put secrets
238238+ here, these environment variables are visible to anyone
239239+ viewing the repository. You can add secrets for pipelines
240240+ in your repository's settings.**
241241+242242+Example:
243243+244244+```yaml
245245+steps:
246246+ - name: "Build backend"
247247+ command: "go build"
248248+ environment:
249249+ GOOS: "darwin"
250250+ GOARCH: "arm64"
251251+ - name: "Build frontend"
252252+ command: "npm run build"
253253+ environment:
254254+ NODE_ENV: "production"
255255+```
256256+257257+## Engines
258258+259259+The common fields above apply to every workflow. Each engine
260260+then adds its own fields on top. Pick an engine with the
261261+[`engine`](#engine) field and use the matching section below.
262262+263263+### Nixery engine
264264+265265+#### Dependencies
266266+267267+When you're running a workflow you'll usually need additional
268268+dependencies. The `dependencies` field lets you define which
269269+dependencies to get, and from where. It's a key-value map,
270270+with the key being the registry to fetch dependencies from,
271271+and the value being the list of dependencies to fetch.
272272+273273+The registry URL syntax can be found [on the nix
274274+manual](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-registry-add).
275275+276276+Say you want to fetch Node.js and Go from `nixpkgs`, and a
277277+package called `my_pkg` you've made from your own registry
278278+at your repository at
279279+`https://tangled.org/@example.com/my_pkg`. You can define
280280+those dependencies like so:
281281+282282+```yaml
283283+dependencies:
284284+ # nixpkgs
285285+ nixpkgs:
286286+ - nodejs
287287+ - go
288288+ # unstable
289289+ nixpkgs/nixpkgs-unstable:
290290+ - bun
291291+ # custom registry
292292+ git+https://tangled.org/@example.com/my_pkg:
293293+ - my_pkg
294294+```
295295+296296+Now these dependencies are available to use in your
297297+workflow!
298298+299299+#### Complete nixery workflow
300300+301301+```yaml
302302+# .tangled/workflows/build.yml
303303+304304+when:
305305+ - event: ["push", "manual"]
306306+ branch: ["main", "develop"]
307307+ - event: ["pull_request"]
308308+ branch: ["main"]
309309+310310+engine: "nixery"
311311+312312+# using the default values
313313+clone:
314314+ skip: false
315315+ depth: 1
316316+ submodules: false
317317+318318+dependencies:
319319+ # nixpkgs
320320+ nixpkgs:
321321+ - nodejs
322322+ - go
323323+ # custom registry
324324+ git+https://tangled.org/@example.com/my_pkg:
325325+ - my_pkg
326326+327327+environment:
328328+ GOOS: "linux"
329329+ GOARCH: "arm64"
330330+ NODE_ENV: "production"
331331+ MY_ENV_VAR: "MY_ENV_VALUE"
332332+333333+steps:
334334+ - name: "Build backend"
335335+ command: "go build"
336336+ environment:
337337+ GOOS: "darwin"
338338+ GOARCH: "arm64"
339339+ - name: "Build frontend"
340340+ command: "npm run build"
341341+ environment:
342342+ NODE_ENV: "production"
343343+```
344344+345345+If you want another example of a workflow, you can look at
346346+the one [Tangled uses to build the
347347+project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).
348348+349349+### microVM engine
350350+351351+#### Image
352352+353353+A workflow picks the image to boot with the top-level `image`
354354+field:
355355+356356+```yaml
357357+engine: microvm
358358+image: nixos
359359+```
360360+361361+There are two flavours of images:
362362+363363+- **NixOS images** (e.g. `nixos`): the whole guest is built
364364+ with Nix, so you can configure it from the workflow file
365365+ itself. The `dependencies`, `services`, `virtualisation`,
366366+ `registry` and `caches` fields below are all understood
367367+ here, and the guest builds and activates that configuration
368368+ before any of your steps run.
369369+- **Non-NixOS images** (e.g. `alpine`): there's no NixOS to
370370+ configure, so the workflow-level config fields above have
371371+ no effect. You still get a full machine to run steps in.
372372+373373+The available image names depend on what the spindle operator
374374+has installed. `nixos` and `alpine` are examples. If `image`
375375+is omitted, the spindle's configured default image is used.
376376+377377+#### Dependencies
378378+379379+On the microVM engine, `dependencies` is a flat list of
380380+packages that are made available to every step. This field
381381+only applies to **NixOS images**; for other images you can
382382+use the package manager included in a step.
383383+384384+The guest builds a [`nix develop`](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-develop)-style
385385+devshell from your dependencies and uses it for each step,
386386+so you can, for example, add `pkg-config` and `openssl` and
387387+have the `openssl-sys` crate while compiling a Rust project
388388+just work.
389389+390390+A bare name like `go` is looked up in nixpkgs. You can also
391391+point at any flake with the `flakeref#attr` syntax, so
392392+`github:nixos/nixpkgs#hello` pulls `hello` straight out of
393393+that flake.
394394+395395+```yaml
396396+dependencies:
397397+ - go
398398+ - github:nixos/nixpkgs#hello
399399+```
400400+401401+#### Registry
402402+403403+The `registry` field remaps flake references, the same way
404404+`nix registry` does. This lets you pin or alias the flakes
405405+used by `dependencies`.
406406+407407+For example, pin `nixpkgs` to `nixos-unstable` so that the
408408+bare `go` above resolves from unstable, and alias your own
409409+flake so you can use `myflake#tool` in `dependencies`:
410410+411411+```yaml
412412+registry:
413413+ nixpkgs: github:nixos/nixpkgs/nixos-unstable
414414+ myflake: github:me/x
415415+```
416416+417417+#### Caches
418418+419419+The `caches` field is a map of Nix binary cache URL to its
420420+trusted public key. These are fed into the spindle's read
421421+proxy, so the guest can substitute prebuilt paths from them
422422+instead of building everything from scratch.
423423+424424+```yaml
425425+caches:
426426+ https://nix-community.cachix.org: "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
427427+```
428428+429429+#### Services and virtualisation
430430+431431+The `services` and `virtualisation` fields are passed straight
432432+through to NixOS. Anything you could write under
433433+`services.*` or `virtualisation.*` in a NixOS configuration,
434434+you can write here, and it's brought up before any of your
435435+steps run.
436436+437437+As a convenience, `true` works as shorthand for
438438+`.enable = true` anywhere an `enable` option exists (e.g.
439439+`virtualisation.docker: true`).
440440+441441+```yaml
442442+services:
443443+ postgresql:
444444+ enable: true
445445+ ensureDatabases: ["spindle-workflow"]
446446+ ensureUsers:
447447+ - name: spindle-workflow
448448+ ensureDBOwnership: true
449449+450450+virtualisation:
451451+ docker: true
452452+```
453453+454454+#### Recipes
455455+456456+##### Lint, test and build a Node project
457457+458458+```yaml
459459+when:
460460+ - event: ["push", "pull_request"]
461461+ branch: ["main"]
462462+463463+engine: microvm
464464+image: nixos
465465+466466+dependencies:
467467+ - pnpm
468468+469469+steps:
470470+ - name: "Install dependencies"
471471+ command: pnpm install --frozen-lockfile
472472+ - name: "Lint and test"
473473+ command: |
474474+ pnpm run lint
475475+ pnpm test
476476+ - name: "Build"
477477+ command: pnpm run build
478478+```
479479+480480+##### Check formatting
481481+482482+```yaml
483483+when:
484484+ - event: ["push", "pull_request"]
485485+ branch: ["main"]
486486+487487+engine: microvm
488488+image: alpine # slimmer image for checking the formatting
489489+490490+steps:
491491+ - name: "Install go"
492492+ command: apk add go
493493+ - name: "Check formatting"
494494+ command: test -z $(gofmt -l .)
495495+```
496496+497497+##### Build a Rust project that links OpenSSL
498498+499499+```yaml
500500+when:
501501+ - event: ["push", "pull_request"]
502502+ branch: ["main"]
503503+504504+engine: microvm
505505+image: nixos
506506+507507+dependencies:
508508+ - gcc
509509+ - cargo
510510+ - rustc
511511+ - clippy
512512+ - rustfmt
513513+ - pkg-config # exports PKG_CONFIG_PATH for the libraries below
514514+ - openssl # the C library + headers openssl-sys links against
515515+516516+steps:
517517+ - name: "Check formatting"
518518+ command: cargo fmt --check
519519+ - name: "Clippy"
520520+ command: cargo clippy --all-targets -- -D warnings
521521+ - name: "Test"
522522+ command: cargo test --all
523523+ - name: "Release build"
524524+ command: cargo build --release
525525+```
526526+527527+##### Run migrations and integration tests against PostgreSQL
528528+529529+```yaml
530530+when:
531531+ - event: ["push", "pull_request"]
532532+ branch: ["main"]
533533+534534+engine: microvm
535535+image: nixos
536536+537537+environment:
538538+ DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql"
539539+540540+dependencies:
541541+ - gcc
542542+ - cargo
543543+ - rustc
544544+ - pkg-config
545545+ - openssl
546546+ - sqlx-cli
547547+548548+services:
549549+ postgresql:
550550+ enable: true
551551+ # has to be same name as the user for peer auth to work automatically
552552+ ensureDatabases: ["spindle-workflow"]
553553+ ensureUsers:
554554+ - name: spindle-workflow
555555+ ensureDBOwnership: true
556556+557557+steps:
558558+ - name: "Run migrations"
559559+ command: sqlx migrate run
560560+ - name: "Integration tests"
561561+ command: cargo test --all
562562+```
563563+564564+##### Build and push a Docker image on tag
565565+566566+```yaml
567567+when:
568568+ - event: ["push"]
569569+ tag: ["v*"]
570570+571571+engine: microvm
572572+image: nixos
573573+574574+virtualisation:
575575+ docker: true
576576+577577+steps:
578578+ - name: "Build and push to ghcr.io"
579579+ command: |
580580+ set -euo pipefail
581581+582582+ echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
583583+ image="ghcr.io/$REGISTRY_USER/myapp:$TANGLED_REF_NAME"
584584+585585+ docker build -t "$image" -t "ghcr.io/$REGISTRY_USER/myapp:latest" .
586586+ docker push "$image"
587587+ docker push "ghcr.io/$REGISTRY_USER/myapp:latest"
588588+```
589589+590590+##### Deploy to Cloudflare Workers on tag
591591+592592+```yaml
593593+# .tangled/workflows/deploy.yml
594594+when:
595595+ - event: ["push"]
596596+ tag: ["v*"]
597597+598598+engine: microvm
599599+image: nixos
600600+601601+dependencies:
602602+ - pnpm
603603+604604+steps:
605605+ - name: "Install dependencies"
606606+ command: pnpm install --frozen-lockfile
607607+ - name: "Deploy worker"
608608+ # `wrangler` picks up `CLOUDFLARE_API_TOKEN` from the env.
609609+ # set it under **Settings → Secrets**.
610610+ command: pnpm exec wrangler deploy
611611+```
612612+613613+##### Publish a release artifact
614614+615615+```yaml
616616+when:
617617+ - event: ["push"]
618618+ tag: ["v*"] # trigger on versions
619619+620620+engine: microvm
621621+image: nixos
622622+623623+dependencies:
624624+ - go
625625+626626+steps:
627627+ - name: "Build release binary"
628628+ command: |
629629+ mkdir -p dist
630630+ CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o dist/myapp ./cmd/myapp
631631+632632+ - name: "Publish artifact record"
633633+ command: |
634634+ set -euo pipefail
635635+ # change this if you're not on `tngl.sh`
636636+ PDS="https://tngl.sh"
637637+ # also update this to your handle or did
638638+ ATP_IDENTIFIER="user.tngl.sh"
639639+ ARTIFACT_PATH="dist/myapp"
640640+ ARTIFACT_NAME="myapp"
641641+642642+ # set `ATP_APP_PASSWORD` under **Settings → Secrets**
643643+ session=$(curl -fsS -X POST "$PDS/xrpc/com.atproto.server.createSession" \
644644+ -H "Content-Type: application/json" \
645645+ -d "{\"identifier\":\"$ATP_IDENTIFIER\",\"password\":\"$ATP_APP_PASSWORD\"}")
646646+ jwt=$(echo "$session" | jq -r .accessJwt)
647647+ did=$(echo "$session" | jq -r .did)
648648+649649+ # upload the binary as a blob
650650+ blob=$(curl -fsS -X POST "$PDS/xrpc/com.atproto.repo.uploadBlob" \
651651+ -H "Authorization: Bearer $jwt" \
652652+ -H "Content-Type: application/octet-stream" \
653653+ --data-binary @"$ARTIFACT_PATH")
654654+655655+ # note that this requires an annotated tag (`git tag -a v1.0.0 -m ...`)
656656+ tag_hash=$(git rev-parse "$TANGLED_REF_NAME^{tag}")
657657+ tag_bytes=$(printf '%s' "$tag_hash" | xxd -r -p | base64 | tr -d '=')
658658+659659+ # the sh.tangled.repo.artifact record for your artifact
660660+ record=$(jq -n \
661661+ --arg did "$did" \
662662+ --arg tag "$tag_bytes" \
663663+ --arg name "$ARTIFACT_NAME" \
664664+ --arg repo "$TANGLED_REPO_URL" \
665665+ --arg created "$(date -Iseconds)" \
666666+ --argjson blob "$(echo "$blob" | jq .blob)" '{
667667+ repo: $did,
668668+ collection: "sh.tangled.repo.artifact",
669669+ validate: false,
670670+ record: {
671671+ "$type": "sh.tangled.repo.artifact",
672672+ tag: {"$bytes": $tag},
673673+ name: $name,
674674+ repo: $repo,
675675+ artifact: $blob,
676676+ createdAt: $created
677677+ }
678678+ }')
679679+680680+ # create the record on the PDS
681681+ curl -fsS -X POST "$PDS/xrpc/com.atproto.repo.createRecord" \
682682+ -H "Authorization: Bearer $jwt" \
683683+ -H "Content-Type: application/json" \
684684+ -d "$record"
685685+```
686686+
···11+---
22+title: Troubleshooting
33+description: Common issues with login, punchcards, commit verification, and knots.
44+---
55+66+77+## Login issues
88+99+Owing to the distributed nature of OAuth on AT Protocol, you
1010+may run into issues with logging in. If you run a
1111+self-hosted PDS:
1212+1313+- You may need to ensure that your PDS is timesynced using
1414+ NTP:
1515+ - Enable the `ntpd` service
1616+ - Run `ntpd -qg` to synchronize your clock
1717+- You may need to increase the default request timeout:
1818+ `NODE_OPTIONS="--network-family-autoselection-attempt-timeout=500"`
1919+2020+## Empty punchcard
2121+2222+For Tangled to register commits that you make across the
2323+network, you need to setup one of following:
2424+2525+- The committer email should be a verified email associated
2626+ to your account. You can add and verify emails on the
2727+ settings page.
2828+- Or, the committer email should be set to your account's
2929+ DID: `git config user.email "did:plc:foobar"`. You can find
3030+ your account's DID on the settings page
3131+3232+## Commit is not marked as verified
3333+3434+Tangled only supports SSH commit signatures. Ensure the SSH public key you use
3535+for signing is uploaded to Tangled.
3636+3737+To sign commits using an SSH key with git:
3838+3939+```
4040+git config --global gpg.format ssh
4141+git config --global user.signingkey ~/.ssh/tangled-key
4242+```
4343+4444+To sign commits using an SSH key with jj, add this to your
4545+config:
4646+4747+```
4848+[signing]
4949+behavior = "own"
5050+backend = "ssh"
5151+key = "~/.ssh/tangled-key"
5252+```
5353+5454+## Self-hosted knot issues
5555+5656+If you need help troubleshooting a self-hosted knot, check
5757+out the [knot troubleshooting
5858+guide](/knots/self-hosting/#troubleshooting).
···11+---
22+title: Why AT Protocol?
33+description: Why Tangled is built on the AT Protocol, and what that buys you over federated or P2P code forges.
44+---
55+66+Code collaboration has one of the strongest network effects in
77+software: your issues, pull requests, reviews, stars, and
88+followers all live wherever your code lives. On a centralized
99+forge, all of that belongs to one company. Leaving means starting
1010+over — new identity, new social graph, and years of collaboration
1111+history left behind.
1212+1313+There are several models for decentralized code collaboration
1414+platforms, ranging from ActivityPub's (Forgejo) federated model,
1515+to Radicle's entirely P2P model. Each solves part of the problem:
1616+1717+- **Federation** distributes hosting, but your identity is bound
1818+ to your instance. If your instance shuts down or defederates,
1919+ you lose your account, and cross-instance collaboration is only
2020+ as good as the bridges between servers.
2121+- **P2P** gives you full sovereignty, but pushes a lot of
2222+ complexity onto every user — key management, discovery, and
2323+ availability become your problem.
2424+2525+Our approach attempts to be the best of both worlds by adopting
2626+the [AT Protocol](https://atproto.com) — a protocol for building
2727+decentralized social applications with a **central identity**.
2828+2929+## Identity that survives everything
3030+3131+On the AT Protocol, your identity is a
3232+[DID](https://atproto.com/specs/did) — a stable identifier that is
3333+independent of any server, fronted by a human-readable handle
3434+(usually a domain name you control). Your account lives on a
3535+Personal Data Server (PDS) of your choosing, and you can move
3636+between PDSes without changing who you are. Nothing on Tangled
3737+breaks when you do: your repositories, issues, and followers all
3838+reference your DID, not your server.
3939+4040+You can think of it as "one account for all of the atmosphere":
4141+the same account you use on Bluesky or any other AT Protocol
4242+application signs you into Tangled. We believe AT Protocol has
4343+greatly simplified one of the hardest parts of social software —
4444+having your friends already on it.
4545+4646+## Your data, as records you own
4747+4848+Everything social on Tangled — issues, pull requests, comments,
4949+stars, follows, profile — is stored as records under `sh.tangled.*`
5050+[lexicons](https://atproto.com/guides/lexicon) in *your* AT
5151+Protocol repository, on *your* PDS. Tangled reads them; it doesn't
5252+own them.
5353+5454+This has practical consequences:
5555+5656+- Any application can read (and build on) the same records — the
5757+ appview at [tangled.org](https://tangled.org) is one view of the
5858+ network, [Bobbin](/reference/bobbin/) is another, and anyone can
5959+ build their own from the firehose.
6060+- Deleting or migrating your account takes your collaboration
6161+ history with you.
6262+- There is no privileged write path: Tangled writes records to
6363+ your PDS like any other client would.
6464+6565+Git data itself is the one exception — repositories are too large
6666+and too specialized to live in a PDS, so they live on
6767+[knots](/knots/), which you can also self-host. Each repository
6868+gets its own DID too, making it stable across renames and
6969+transfers.
7070+7171+## Services that don't need to trust each other
7272+7373+Tangled is composed of small, independently hostable services:
7474+knots host git, [spindles](/spindles/) run CI, and the appview
7575+ties the network together. They authenticate to each other with AT
7676+Protocol [service
7777+auth](https://atproto.com/specs/xrpc#inter-service-authentication-jwt):
7878+requests are signed with the user's DID key and verified against
7979+their public DID document. No shared secrets, no API tokens to
8080+provision — any knot can trust a request from any appview, and
8181+vice versa, because identity is cryptographic and global.
8282+8383+## The tenets
8484+8585+Tangled's design goals, which the AT Protocol lets us hit all at
8686+once:
8787+8888+1. **Ownership of data** — your identity and social data are
8989+ yours, portable across servers and applications.
9090+2. **Low barrier to entry** — sign up in seconds on
9191+ [tangled.org](https://tangled.org/signup), or bring an existing
9292+ AT Protocol account. Self-hosting is optional, never required.
9393+3. **No compromise on user-experience** — the appview gives the
9494+ whole decentralized network the coherence of a single site.
9595+9696+## Further reading
9797+9898+- [Introducing Tangled](https://blog.tangled.org/intro) — the
9999+ announcement post
100100+- [What are knots?](/knots/) and [what are spindles?](/spindles/)
101101+- [AT Protocol documentation](https://atproto.com/docs)