···33author: The Tangled Contributors
44date: 21 Sun, Dec 2025
55abstract: |
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.
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.
10101111- 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
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
17171818- 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.
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.
25252626- 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.
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---
31313232# Quick start guide
···131131cd my-project
132132133133git init
134134-echo "# My Project" > README.md
134134+echo "# My Project" > README.md
135135```
136136137137Add some content and push!
···313313and operation tool. For the purpose of this guide, we're
314314only concerned with these subcommands:
315315316316- * `knot server`: the main knot server process, typically
317317- run as a supervised service
318318- * `knot guard`: handles role-based access control for git
319319- over SSH (you'll never have to run this yourself)
320320- * `knot keys`: fetches SSH keys associated with your knot;
321321- we'll use this to generate the SSH
322322- `AuthorizedKeysCommand`
316316+- `knot server`: the main knot server process, typically
317317+ run as a supervised service
318318+- `knot guard`: handles role-based access control for git
319319+ over SSH (you'll never have to run this yourself)
320320+- `knot keys`: fetches SSH keys associated with your knot;
321321+ we'll use this to generate the SSH
322322+ `AuthorizedKeysCommand`
323323324324```
325325cd core
···432432can move these paths if you'd like to store them in another folder. Be careful
433433when adjusting these paths:
434434435435-* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
436436-any possible side effects. Remember to restart it once you're done.
437437-* Make backups before moving in case something goes wrong.
438438-* Make sure the `git` user can read and write from the new paths.
435435+- Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
436436+ any possible side effects. Remember to restart it once you're done.
437437+- Make backups before moving in case something goes wrong.
438438+- Make sure the `git` user can read and write from the new paths.
439439440440#### Database
441441···5195192. Check to see that your knot has synced the key by running
520520 `knot keys`
5215213. Check to see if git is supplying the correct private key
522522- when pushing: `GIT_SSH_COMMAND="ssh -v" git push ...`
522522+ when pushing: `GIT_SSH_COMMAND="ssh -v" git push ...`
5235234. Check to see if `sshd` on the knot is rejecting the push
524524 for some reason: `journalctl -xeu ssh` (or `sshd`,
525525 depending on your machine). These logs are unavailable if
···5275275. Check to see if the knot itself is rejecting the push,
528528 depending on your setup, the logs might be in one of the
529529 following paths:
530530- * `/tmp/knotguard.log`
531531- * `/home/git/log`
532532- * `/home/git/guard.log`
530530+ - `/tmp/knotguard.log`
531531+ - `/home/git/log`
532532+ - `/home/git/guard.log`
533533534534# Spindles
535535···847847848848### Prerequisites
849849850850-* Go
851851-* Docker (the only supported backend currently)
850850+- Go
851851+- Docker (the only supported backend currently)
852852853853### Configuration
854854855855Spindle is configured using environment variables. The following environment variables are available:
856856857857-* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
858858-* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
859859-* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
860860-* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
861861-* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
862862-* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
863863-* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
864864-* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
865865-* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
857857+- `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
858858+- `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
859859+- `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
860860+- `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
861861+- `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
862862+- `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
863863+- `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
864864+- `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
865865+- `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
866866867867### Running spindle
868868869869-1. **Set the environment variables.** For example:
869869+1. **Set the environment variables.** For example:
870870871871 ```shell
872872 export SPINDLE_SERVER_HOSTNAME="your-hostname"
···900900901901Spindle is a small CI runner service. Here's a high-level overview of how it operates:
902902903903-* Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
904904-[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
905905-* When a new repo record comes through (typically when you add a spindle to a
906906-repo from the settings), spindle then resolves the underlying knot and
907907-subscribes to repo events (see:
908908-[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
909909-* The spindle engine then handles execution of the pipeline, with results and
910910-logs beamed on the spindle event stream over WebSocket
903903+- Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
904904+ [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
905905+- When a new repo record comes through (typically when you add a spindle to a
906906+ repo from the settings), spindle then resolves the underlying knot and
907907+ subscribes to repo events (see:
908908+ [`sh.tangled.pipeline`](/lexicons/pipeline.json)).
909909+- The spindle engine then handles execution of the pipeline, with results and
910910+ logs beamed on the spindle event stream over WebSocket
911911912912### The engine
913913···12211221 secret_id="$(cat /tmp/openbao/secret-id)"
12221222```
1223122312241224+# Webhooks
12251225+12261226+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.
12271227+12281228+## Overview
12291229+12301230+Webhooks send HTTP POST requests to URLs you configure whenever specific events happen. Currently, Tangled supports push events, with more event types coming soon.
12311231+12321232+## Configuring webhooks
12331233+12341234+To set up a webhook for your repository:
12351235+12361236+1. Navigate to your repository settings
12371237+2. Click the "hooks" tab
12381238+3. Click "add webhook"
12391239+4. Configure your webhook:
12401240+ - **Payload URL**: The endpoint that will receive the webhook POST requests
12411241+ - **Secret**: An optional secret key for verifying webhook authenticity (auto-generated if left blank)
12421242+ - **Events**: Select which events trigger the webhook (currently only push events)
12431243+ - **Active**: Toggle whether the webhook is enabled
12441244+12451245+## Webhook payload
12461246+12471247+### Push
12481248+12491249+When a push event occurs, Tangled sends a POST request with a JSON payload of the format:
12501250+12511251+```json
12521252+{
12531253+ "after": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5",
12541254+ "before": "c04ddf64eddc90e4e2a9846ba3b43e67a0e2865e",
12551255+ "pusher": {
12561256+ "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
12571257+ },
12581258+ "ref": "refs/heads/main",
12591259+ "repository": {
12601260+ "clone_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
12611261+ "created_at": "2025-09-15T08:57:23Z",
12621262+ "description": "an example repository",
12631263+ "fork": false,
12641264+ "full_name": "did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
12651265+ "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
12661266+ "name": "some-repo",
12671267+ "open_issues_count": 5,
12681268+ "owner": {
12691269+ "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
12701270+ },
12711271+ "ssh_url": "ssh://git@tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
12721272+ "stars_count": 1,
12731273+ "updated_at": "2025-09-15T08:57:23Z"
12741274+ }
12751275+}
12761276+```
12771277+12781278+## HTTP headers
12791279+12801280+Each webhook request includes the following headers:
12811281+12821282+- `Content-Type: application/json`
12831283+- `User-Agent: Tangled-Hook/<short-sha>` — User agent with short SHA of the commit
12841284+- `X-Tangled-Event: push` — The event type
12851285+- `X-Tangled-Hook-ID: <webhook-id>` — The webhook ID
12861286+- `X-Tangled-Delivery: <uuid>` — Unique delivery ID
12871287+- `X-Tangled-Signature-256: sha256=<hmac>` — HMAC-SHA256 signature (if secret configured)
12881288+12891289+## Verifying webhook signatures
12901290+12911291+If you configured a secret, you should verify the webhook signature to ensure requests are authentic. For example, in Go:
12921292+12931293+```go
12941294+package main
12951295+12961296+import (
12971297+ "crypto/hmac"
12981298+ "crypto/sha256"
12991299+ "encoding/hex"
13001300+ "io"
13011301+ "net/http"
13021302+ "strings"
13031303+)
13041304+13051305+func verifySignature(payload []byte, signatureHeader, secret string) bool {
13061306+ // Remove 'sha256=' prefix from signature header
13071307+ signature := strings.TrimPrefix(signatureHeader, "sha256=")
13081308+13091309+ // Compute expected signature
13101310+ mac := hmac.New(sha256.New, []byte(secret))
13111311+ mac.Write(payload)
13121312+ expected := hex.EncodeToString(mac.Sum(nil))
13131313+13141314+ // Use constant-time comparison to prevent timing attacks
13151315+ return hmac.Equal([]byte(signature), []byte(expected))
13161316+}
13171317+13181318+func webhookHandler(w http.ResponseWriter, r *http.Request) {
13191319+ // Read the request body
13201320+ payload, err := io.ReadAll(r.Body)
13211321+ if err != nil {
13221322+ http.Error(w, "Bad request", http.StatusBadRequest)
13231323+ return
13241324+ }
13251325+13261326+ // Get signature from header
13271327+ signatureHeader := r.Header.Get("X-Tangled-Signature-256")
13281328+13291329+ // Verify signature
13301330+ if signatureHeader != "" && verifySignature(payload, signatureHeader, yourSecret) {
13311331+ // Webhook is authentic, process it
13321332+ processWebhook(payload)
13331333+ w.WriteHeader(http.StatusOK)
13341334+ } else {
13351335+ http.Error(w, "Invalid signature", http.StatusUnauthorized)
13361336+ }
13371337+}
13381338+```
13391339+13401340+## Delivery retries
13411341+13421342+Webhooks are automatically retried on failure:
13431343+13441344+- **3 total attempts** (1 initial + 2 retries)
13451345+- **Exponential backoff** starting at 1 second, max 10 seconds
13461346+- **Retried on**:
13471347+ - Network errors
13481348+ - HTTP 5xx server errors
13491349+- **Not retried on**:
13501350+ - HTTP 4xx client errors (bad request, unauthorized, etc.)
13511351+13521352+### Timeouts
13531353+13541354+Webhook requests timeout after 30 seconds. If your endpoint needs more time:
13551355+13561356+1. Respond with 200 OK immediately
13571357+2. Process the webhook asynchronously in the background
13581358+13591359+## Example integrations
13601360+13611361+### Discord notifications
13621362+13631363+```javascript
13641364+app.post("/webhook", (req, res) => {
13651365+ const payload = req.body;
13661366+13671367+ fetch("https://discord.com/api/webhooks/...", {
13681368+ method: "POST",
13691369+ headers: { "Content-Type": "application/json" },
13701370+ body: JSON.stringify({
13711371+ content: `New push to ${payload.repository.full_name}`,
13721372+ embeds: [
13731373+ {
13741374+ title: `${payload.pusher.did} pushed to ${payload.ref}`,
13751375+ url: payload.repository.html_url,
13761376+ color: 0x00ff00,
13771377+ },
13781378+ ],
13791379+ }),
13801380+ });
13811381+13821382+ res.status(200).send("OK");
13831383+});
13841384+```
13851385+12241386# Migrating knots and spindles
1225138712261388Sometimes, non-backwards compatible changes are made to the
···13561518<details>
13571519 <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
1358152013591359- In order to build Tangled's dev VM on macOS, you will
13601360- first need to set up a Linux Nix builder. The recommended
13611361- way to do so is to run a [`darwin.linux-builder`
13621362- VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
13631363- and to register it in `nix.conf` as a builder for Linux
13641364- with the same architecture as your Mac (`linux-aarch64` if
13651365- you are using Apple Silicon).
15211521+In order to build Tangled's dev VM on macOS, you will
15221522+first need to set up a Linux Nix builder. The recommended
15231523+way to do so is to run a [`darwin.linux-builder`
15241524+VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
15251525+and to register it in `nix.conf` as a builder for Linux
15261526+with the same architecture as your Mac (`linux-aarch64` if
15271527+you are using Apple Silicon).
1366152813671367- > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
13681368- > the Tangled repo so that it doesn't conflict with the other VM. For example,
13691369- > you can do
13701370- >
13711371- > ```shell
13721372- > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
13731373- > ```
13741374- >
13751375- > to store the builder VM in a temporary dir.
13761376- >
13771377- > You should read and follow [all the other intructions][darwin builder vm] to
13781378- > avoid subtle problems.
15291529+> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
15301530+> the Tangled repo so that it doesn't conflict with the other VM. For example,
15311531+> you can do
15321532+>
15331533+> ```shell
15341534+> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
15351535+> ```
15361536+>
15371537+> to store the builder VM in a temporary dir.
15381538+>
15391539+> You should read and follow [all the other intructions][darwin builder vm] to
15401540+> avoid subtle problems.
1379154113801380- Alternatively, you can use any other method to set up a
13811381- Linux machine with Nix installed that you can `sudo ssh`
13821382- into (in other words, root user on your Mac has to be able
13831383- to ssh into the Linux machine without entering a password)
13841384- and that has the same architecture as your Mac. See
13851385- [remote builder
13861386- instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
13871387- for how to register such a builder in `nix.conf`.
15421542+Alternatively, you can use any other method to set up a
15431543+Linux machine with Nix installed that you can `sudo ssh`
15441544+into (in other words, root user on your Mac has to be able
15451545+to ssh into the Linux machine without entering a password)
15461546+and that has the same architecture as your Mac. See
15471547+[remote builder
15481548+instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
15491549+for how to register such a builder in `nix.conf`.
1388155013891389- > WARNING: If you'd like to use
13901390- > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
13911391- > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
13921392- > ssh` works can be tricky. It seems to be [possible with
13931393- > Orbstack](https://github.com/orgs/orbstack/discussions/1669).
15511551+> WARNING: If you'd like to use
15521552+> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
15531553+> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
15541554+ssh` works can be tricky. It seems to be [possible with
15551555+> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
1394155613951557</details>
13961558···1463162514641626We follow a commit style similar to the Go project. Please keep commits:
1465162714661466-* **atomic**: each commit should represent one logical change
14671467-* **descriptive**: the commit message should clearly describe what the
14681468-change does and why it's needed
16281628+- **atomic**: each commit should represent one logical change
16291629+- **descriptive**: the commit message should clearly describe what the
16301630+ change does and why it's needed
1469163114701632### Message format
14711633···14911653knotserver/git/service: improve error checking in upload-pack
14921654```
1493165514941494-14951656### General notes
1496165714971658- PRs get merged "as-is" (fast-forward)—like applying a patch-series
14981498-using `git am`. At present, there is no squashing—so please author
14991499-your commits as they would appear on `master`, following the above
15001500-guidelines.
16591659+ using `git am`. At present, there is no squashing—so please author
16601660+ your commits as they would appear on `master`, following the above
16611661+ guidelines.
15011662- If there is a lot of nesting, for example "appview:
15021502-pages/templates/repo/fragments: ...", these can be truncated down to
15031503-just "appview: repo/fragments: ...". If the change affects a lot of
15041504-subdirectories, you may abbreviate to just the top-level names, e.g.
15051505-"appview: ..." or "knotserver: ...".
16631663+ pages/templates/repo/fragments: ...", these can be truncated down to
16641664+ just "appview: repo/fragments: ...". If the change affects a lot of
16651665+ subdirectories, you may abbreviate to just the top-level names, e.g.
16661666+ "appview: ..." or "knotserver: ...".
15061667- Keep commits lowercased with no trailing period.
15071668- Use the imperative mood in the summary line (e.g., "fix bug" not
15081508-"fixed bug" or "fixes bug").
16691669+ "fixed bug" or "fixes bug").
15091670- Try to keep the summary line under 72 characters, but we aren't too
15101510-fussed about this.
16711671+ fussed about this.
15111672- Follow the same formatting for PR titles if filled manually.
15121673- Don't include unrelated changes in the same commit.
15131674- Avoid noisy commit messages like "wip" or "final fix"—rewrite history
15141514-before submitting if necessary.
16751675+ before submitting if necessary.
1515167615161677## Code formatting
15171678···1601176216021763- You may need to ensure that your PDS is timesynced using
16031764 NTP:
16041604- * Enable the `ntpd` service
16051605- * Run `ntpd -qg` to synchronize your clock
17651765+ - Enable the `ntpd` service
17661766+ - Run `ntpd -qg` to synchronize your clock
16061767- You may need to increase the default request timeout:
16071768 `NODE_OPTIONS="--network-family-autoselection-attempt-timeout=500"`
16081769