---
title: "Host your Tangled repos on your own knot"
description: "Run a Tangled knot on an exe.dev VM: your git repositories on your own server, with issues, pull requests and stars on tangled.org under your atproto handle."
url: https://exe.dev/deploy/tangled
category: "Developer tools"
updated: 2026-11-04
---

# Host your Tangled repos on your own knot

Your git repos on your own VM, with tangled.org on top.

- **Software:** Tangled knot (https://tangled.org)
- **Source:** https://tangled.org/tangled.org/core
- **License:** MIT
- **Time:** 30 minutes, most of it compiling
- **Example VM name:** `my-knot` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/tangled/manifest.json

## What you get

- Tangled's knot 1.20 at `https://my-knot.exe.xyz/`, owned by your atproto DID, restarted on failure and after a reboot.
- `/xrpc/_health` and `/.well-known/did.json` answering on the knot's address, ready to add on tangled.org.
- `knot-member add <handle>` on the VM, to let other people create repositories on the knot.
- The knot's SSH server on port 2222, for pushes through exe.dev's SSH.
- Repositories, keys and settings in `/var/lib/knot` and `/etc/knot`, backed up nightly to `/var/backups/knot`, with a restore checked.

Running on the VM:

- **Tangled knot 1.20**: `knot-server`, the Rust knot, built from the v1.20.0 tag, as a systemd service, port 8000, data in `/var/lib/knot`
- **Knot SSH**: the knot's own SSH server, for git push, reached through exe.dev's SSH, port 2222
- **Nightly backup**: a systemd timer; keeps two weeks, data in `/var/backups/knot`

How it is reached:

- https://my-knot.exe.xyz/ (public): the knot, for tangled.org and anyone cloning; pushes need your atproto identity
- ssh -J my-knot.exe.xyz -p 2222 git@localhost (private): git push over SSH, for you, through exe.dev's SSH

## Deploy with Shelley

Open https://exe.dev/new?prompt=TANGLED_HANDLE%3D%3Cfill-this-in%3E%0AKNOT_CONTACT_EMAIL%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Tangled%20knot%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Ftangled.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20TANGLED_HANDLE%3A%20the%20handle%20you%20sign%20in%20to%20tangled.org%20with%3A%20%60alice.tngl.sh%60%20if%20you%20signed%20up%20there%20with%20an%20email%2C%20or%20your%20Bluesky%20handle%3B%20it%20becomes%20the%20knot%27s%20owner%0A-%20KNOT_CONTACT_EMAIL%3A%20an%20email%20address%20the%20knot%20publishes%20for%20abuse%20reports%20and%20questions%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-knot.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-knot.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20Tangled%27s%20knot%201.20%20at%20%60https%3A%2F%2Fmy-knot.exe.xyz%2F%60%2C%20owned%20by%20your%20atproto%20DID%2C%20restarted%20on%20failure%20and%20after%20a%20reboot.%0A-%20%60%2Fxrpc%2F_health%60%20and%20%60%2F.well-known%2Fdid.json%60%20answering%20on%20the%20knot%27s%20address%2C%20ready%20to%20add%20on%20tangled.org.%0A-%20%60knot-member%20add%20%3Chandle%3E%60%20on%20the%20VM%2C%20to%20let%20other%20people%20create%20repositories%20on%20the%20knot.%0A-%20The%20knot%27s%20SSH%20server%20on%20port%202222%2C%20for%20pushes%20through%20exe.dev%27s%20SSH.%0A-%20Repositories%2C%20keys%20and%20settings%20in%20%60%2Fvar%2Flib%2Fknot%60%20and%20%60%2Fetc%2Fknot%60%2C%20backed%20up%20nightly%20to%20%60%2Fvar%2Fbackups%2Fknot%60%2C%20with%20a%20restore%20checked.%0A%0ARead%20the%20whole%20recipe%2C%20closed%20details%20included%2C%20and%20follow%20%22Set%20up%20by%0Ahand%22%20in%20order%2C%20skipping%20VM%20creation%3B%20the%20walkthrough%20above%20it%20is%20for%0Athe%20owner.%20Resolve%20TANGLED_HANDLE%20to%20a%20DID%20as%20written%20and%20stop%20if%20it%0Adoesn%27t%20resolve.%20The%20build%20takes%2015-25%20minutes%3A%20run%20it%20in%20the%0Abackground.%20On%20an%20existing%20VM%2C%20ask%20before%20replacing%20anything%20on%20port%0A8000.%20Don%27t%20make%20the%20VM%20public%20yourself%3A%20the%20owner%20runs%20%60ssh%20exe.dev%0Ashare%20set-public%60%20on%20their%20computer.%20Show%20the%20owner%20the%20master%20key%27s%0Aline%20from%20%2Fetc%2Fknot%2Fknot.env%20once%2C%20and%20tell%20them%20to%20store%20it%20off%20the%0AVM.%20Finish%20with%20the%20%60set-public%60%20command%2C%20then%20%22Add%20the%20knot%20on%0Atangled.org%22%20and%20%22Push%20over%20SSH%22%20for%20the%20owner%20to%20follow.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open%20and%20these%20commands%2C%20which%20only%20I%20can%20run%2C%20from%20my%20own%20computer%20%28with%20this%20VM%27s%20name%20in%20them%29%3A%0A%20%20ssh%20exe.dev%20share%20set-public%20my-knot%0A%20%20ssh%20my-knot.exe.xyz%20sudo%20systemctl%20restart%20knot.service%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe.dev/deploy/tangled/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Then, on your own computer:

```sh
ssh exe.dev share set-public my-knot
ssh my-knot.exe.xyz sudo systemctl restart knot.service
```

Or follow the steps below by hand.

[Tangled](https://tangled.org) is a place to host and work on code, like
GitHub, built on the [AT Protocol](https://atproto.com), the network
behind Bluesky. You sign in with an atproto account; stars, issues and
pull requests are records in it. The git
repositories live on **knots**, small servers that anyone can run, and
tangled.org shows them all in one place.

This page runs your own knot on an exe.dev VM. exe.dev's
[HTTPS proxy](/docs/proxy) gives it a stable address with TLS,
`https://my-knot.exe.xyz/`, which becomes its identity on the network.
You push over SSH through exe.dev; people and tangled.org read it over
HTTPS. The VM is always on and keeps your repositories on its disk.

You need a Tangled account. [Sign up](https://tangled.org/signup) with
an email to get a handle like `alice.tngl.sh`, or sign in with your
Bluesky handle: either is an atproto account. Use that handle below;
the knot's owner must be the account you use on tangled.org.

## Make the knot public

On your computer:

```bash
ssh exe.dev share set-public my-knot
ssh my-knot.exe.xyz sudo systemctl restart knot.service
```

tangled.org has to reach the knot without an exe.dev login, and so does
anyone cloning. This opens port 8000, the knot, to the internet. SSH to
the VM stays behind exe.dev. The restart makes the knot announce itself
to the atproto relays again, now that they can reach it. Check it from
anywhere:

```bash
curl https://my-knot.exe.xyz/xrpc/_health
```

Expect `{"version":"knot 1.20.0"}`.

## Add the knot on tangled.org

On [tangled.org](https://tangled.org), open **Settings → Knots**
([tangled.org/settings/knots](https://tangled.org/settings/knots)), press
**Add a knot**, enter `my-knot.exe.xyz` as the **Knot address** and press
**Add**. That puts it in the list you pick from when you create a
repository. The knot itself decides who may create repositories on it:
you, as its owner, and the members you add.

Then open **Settings → Keys**
([tangled.org/settings/keys](https://tangled.org/settings/keys)) and add
the SSH public key you use for exe.dev (`cat ~/.ssh/id_ed25519.pub`).
The knot lets you push with the keys you list there.

## Create a repository

On tangled.org, press **New** → **New repository**, name it, and pick
`my-knot.exe.xyz` under **Select a knot**. The repository's **Code** menu shows
`https://tangled.org/alice.tngl.sh/hello`, which sends git on to your
knot, and `git@my-knot.exe.xyz:alice.tngl.sh/hello` for SSH.

Anyone can clone over HTTPS, with no login:

```bash
git clone https://tangled.org/alice.tngl.sh/hello
```

## Push over SSH

The SSH URL that tangled.org shows uses port 22, which on
`my-knot.exe.xyz` is exe.dev's SSH. The knot's SSH listens on the VM's
port 2222, and exe.dev's SSH can carry you there. On your computer, add
an alias:

```bash
cat >> ~/.ssh/config <<'EOF'

Host my-knot-git
  HostName localhost
  Port 2222
  User git
  ProxyJump my-knot.exe.xyz
  HostKeyAlias my-knot-git
EOF
```

Then let git rewrite the knot's SSH URLs to it, so you can copy them
from tangled.org as they are:

```bash
git config --global url."my-knot-git:".insteadOf "git@my-knot.exe.xyz:"
```

Clone with the SSH URL and push something:

```bash
git clone git@my-knot.exe.xyz:alice.tngl.sh/hello
cd hello
echo "# hello" > README.md
git add README.md && git commit -m "First commit"
git push origin main
```

The commit shows on the repository's page on tangled.org. In a clone
made over HTTPS, push over SSH with
`git remote set-url --push origin git@my-knot.exe.xyz:alice.tngl.sh/hello`.
To move an existing repository, create an empty one on the knot, then
`git remote add tangled git@my-knot.exe.xyz:alice.tngl.sh/hello` and
`git push tangled --all`.

> [!NOTE]
> The alias needs SSH access to the VM, so it's for you (and your
> [team](/docs/teams)). Collaborators without it push over HTTPS, below.

## Push over HTTPS

Knot 2 accepts pushes over HTTPS too, authenticated with a short-lived
token from your atproto account. The community helper
[git-credential-tangled](https://tangled.org/pds.dad/git-credential-tangled)
fetches one, using an app password for your atproto account (in the
Bluesky app: **Settings → Privacy and security → App passwords**). With
Go installed:

```bash
go install tangled.org/pds.dad/git-credential-tangled@latest
git config --global credential.https://my-knot.exe.xyz.helper ""
git config --global --add credential.https://my-knot.exe.xyz.helper "tangled --handle alice.tngl.sh"
```

Push to the knot's own address, not tangled.org's:

```bash
git remote set-url --push origin https://my-knot.exe.xyz/alice.tngl.sh/hello
git push
```

It asks for the app password on the first push.

## Let other people in

On a new knot, only you can create repositories. To let someone else
create them, make them a member, on the VM:

```bash
sudo knot-member add friend.tngl.sh
```

(`sudo knot-member remove friend.tngl.sh` undoes it.) They add
`my-knot.exe.xyz` under **Settings → Knots** too. To let someone push to one repository, add them under
**Collaborators** in the repository's **Settings → Access**. Who can open issues and
pull requests is each repository's contribution policy; new ones start
with `anyone`.

## Add CI

Tangled's CI runners are called spindles, and workflows live in
`.tangled/workflows/` in the repository. The quickest start is
Tangled's own: pick `spindle.tangled.network` under **Select a spindle**
when you create the repository, or later under **Spindle** in its
**Settings → Pipelines**. To run CI on your own machine, set up a spindle on a
second VM, in [Run your own spindle](#spindle) below.

## The nuts and bolts

Happy with what Shelley set up? Stop here. Below are the steps it
followed, and maintenance for later.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Set up by hand</summary>

### Create the VM

On your computer (pick another name if `my-knot` is taken; the name ends
up in every clone URL):

```bash
ssh exe.dev new --name=my-knot
ssh my-knot.exe.xyz
```

Everything else runs **on the VM**.

### Build the knot

Tangled publishes the knot as source and as a container image that
needs a registry login, so build it. Install Rust and the build tools:

```bash
sudo apt-get update
sudo apt-get install -y build-essential cmake perl pkg-config git
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none
. ~/.cargo/env
```

Build the v1.20.0 release. The repository pins its Rust version, which
`cargo` downloads first; the build takes 15-25 minutes on a 2-CPU VM.
`CARGO_NET_GIT_FETCH_WITH_CLI` lets git fetch one dependency that
cargo's built-in fetcher stalls on:

```bash
git clone --depth 1 --branch v1.20.0 https://tangled.org/tangled.org/core ~/tangled-core
cd ~/tangled-core
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release --locked -p knot-server
sudo install -m 755 target/release/knot-server /usr/local/bin/knot-server
knot-server --help | head -1
```

### Configure it

The knot runs as a `git` system user and keeps everything in
`/var/lib/knot`:

```bash
sudo useradd --system --home-dir /var/lib/knot --shell /usr/sbin/nologin git
sudo install -d -o git -g git -m 700 /var/lib/knot /var/lib/knot/repos /var/lib/knot/lfs
```

The owner is your DID. Look it up from your handle, and set the contact
address the knot publishes:

```bash
HANDLE=alice.tngl.sh        # TANGLED_HANDLE
EMAIL=you@example.com           # KNOT_CONTACT_EMAIL
OWNER_DID=$(curl -fsS "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=$HANDLE" | python3 -c 'import json,sys; print(json.load(sys.stdin)["did"])')
echo "$OWNER_DID"
```

Expect `did:plc:…` (or `did:web:…`). The master key seals the knot's
signing keys. Generate it into an env file only root can read:

```bash
sudo install -d -m 755 /etc/knot
sudo tee /etc/knot/knot.env >/dev/null <<EOF
KNOT_MASTER_KEY=$(openssl rand -base64 32)
KNOT_ADMIN_SECRET=$(openssl rand -hex 24)
EOF
sudo chmod 600 /etc/knot/knot.env
```

> [!NOTE]
> Copy that key off the VM now (`sudo cat /etc/knot/knot.env`) and keep
> it with your passwords. Without it, a backup of the knot can't prove
> who owns its repositories. The admin secret lets `knot-member`, below,
> add members.

Write the config. The hostname is the knot's identity
(`did:web:my-knot.exe.xyz`), so keep it. The knot serves plain HTTP on port
8000, where exe.dev's proxy, which handles TLS, sends HTTPS traffic. The
proxy reaches it from `127.0.0.1`, so the knot trusts `X-Forwarded-For`
from there to rate-limit each client by its own address. `crawlers` adds
Tangled's indexer, which 1.20.0 leaves out of its default list, to the
services the knot announces itself to; without it, new repositories
don't show up on tangled.org:

```bash
sudo -u git tee /var/lib/knot/config.toml >/dev/null <<EOF
[server]
hostname = "$(hostname).exe.xyz"
admins = ["$OWNER_DID"]
listen_addr = "[::]:8000"
ssh_listen_addr = "[::]:2222"
ssh_host_key_file = "/var/lib/knot/ssh/host_key"
appview_endpoint = "https://tangled.org"

[repo]
scan_path = "/var/lib/knot/repos"

[git]
object_format = "sha1"

[secrets]
sealed_key_file = "/var/lib/knot/secrets/sealed.bin"
master_key_env = "KNOT_MASTER_KEY"

[atproto]
plc_directory = "https://plc.directory"
crawlers = ["https://index.tangled.network", "https://relay.fire.hose.cam", "https://relay3.fr.hose.cam", "https://bsky.network", "https://northamerica.firehose.network", "https://europe.firehose.network", "https://asia.firehose.network", "https://atproto.africa", "https://relay.upcloud.world"]

[acl]
legacy_admin_secret_env = "KNOT_ADMIN_SECRET"

[xrpc]
trusted_proxy_header = "x-forwarded-for"
trusted_proxies = ["127.0.0.1", "::1"]

[tls]
http3 = false

[lfs]
store_path = "/var/lib/knot/lfs"

[contact]
email = "$EMAIL"
EOF
```

`object_format = "sha1"` keeps new repositories
compatible with GitHub and existing clones; see the FAQ. Every other
setting is in the
[knot configuration reference](https://tangled.org/docs/reference/knot-config/).
Check it:

```bash
sudo -u git env $(sudo cat /etc/knot/knot.env) knot-server --config /var/lib/knot/config.toml validate
```

### Run it

Use Tangled's systemd unit, which locks the knot into `/var/lib/knot`:

```bash
curl -fsSL https://tangled.org/tangled.org/core/raw/v1.20.0/systemd/knot.service | sudo tee /etc/systemd/system/knot.service >/dev/null
sudo systemctl daemon-reload
sudo systemctl enable --now knot.service
```

On its first start the knot creates its SSH host key and sealed key
store.

### Check it

```bash
systemctl is-active knot.service
curl -fsS localhost:8000/xrpc/_health; echo
curl -fsS localhost:8000/.well-known/did.json; echo
ssh-keyscan -p 2222 localhost 2>/dev/null | head -1
```

Expect `active`, the knot's version, a DID document for
`did:web:my-knot.exe.xyz`, and an `ssh-ed25519` host key. The VM can't
reach its own `https://my-knot.exe.xyz/`; once the owner has made it
public, check that from any other computer.

### Add members

Members are the accounts that may create repositories on the knot.
Tangled's `knot-server admin member add` sends its request to the knot's
public HTTPS address, which the VM can't reach, so this small script asks
the knot directly on port 8000, with the admin secret:

```bash
sudo tee /usr/local/bin/knot-member >/dev/null <<'EOF'
#!/bin/bash
# knot-member add|remove HANDLE: change who can create repositories on this knot.
set -euo pipefail
case "${1:-}" in
  add) method=createRecord; record=',"record":{"$type":"org.tangled.knot.member"}' ;;
  remove) method=deleteRecord; record="" ;;
  *) echo "usage: knot-member add|remove HANDLE" >&2; exit 2 ;;
esac
did=$2
if [[ $did != did:* ]]; then
  did=$(curl -fsS "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=$did" |
    python3 -c 'import json,sys; print(json.load(sys.stdin)["did"])')
fi
. /etc/knot/knot.env
knot=did:web:$(sed -n 's/^hostname = "\(.*\)"/\1/p' /var/lib/knot/config.toml)
curl -fsS -o /dev/null -u "admin:$KNOT_ADMIN_SECRET" -H 'content-type: application/json' \
  -d "{\"repo\":\"$knot\",\"collection\":\"org.tangled.knot.member\",\"rkey\":\"$did\"$record}" \
  "http://localhost:8000/xrpc/com.atproto.repo.$method"
echo "$1: $did"
EOF
sudo chmod 755 /usr/local/bin/knot-member
```

Check it with any handle, then take it out again:

```bash
sudo knot-member add tangled.org
curl -fsS "localhost:8000/xrpc/com.atproto.repo.listRecords?repo=did:web:$(hostname).exe.xyz&collection=org.tangled.knot.member"; echo
sudo knot-member remove tangled.org
```

Expect `add: did:plc:…`, a record named after that DID, and `remove: did:plc:…`.

### Back up nightly

A backup is the whole of `/var/lib/knot` (repositories, LFS objects,
sealed keys, SSH host key, config) and `/etc/knot`. This script stops
the knot for the few seconds a copy takes, so no push lands half-way,
and keeps the 14 newest:

```bash
sudo tee /usr/local/bin/knot-backup >/dev/null <<'EOF'
#!/bin/bash
# Back up the knot to /var/backups/knot/knot-<time>.tar.gz. Keeps the 14 newest.
set -euo pipefail
umask 077
mkdir -p /var/backups/knot
out=/var/backups/knot/knot-$(date -u +%Y%m%d-%H%M%S).tar.gz
systemctl stop knot.service
trap 'systemctl start knot.service' EXIT
tar -czf "$out.part" -C / var/lib/knot etc/knot
systemctl start knot.service
trap - EXIT
mv "$out.part" "$out"
ls -1t /var/backups/knot/knot-*.tar.gz | tail -n +15 | xargs -r rm --
echo "$out"
EOF
sudo chmod 755 /usr/local/bin/knot-backup
```

Run it nightly, around 03:30 UTC:

```bash
sudo tee /etc/systemd/system/knot-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up the Tangled knot

[Service]
Type=oneshot
ExecStart=/usr/local/bin/knot-backup
EOF
sudo tee /etc/systemd/system/knot-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up the Tangled knot nightly

[Timer]
OnCalendar=*-*-* 03:30:00 UTC
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now knot-backup.timer
```

Make one now and check that it unpacks:

```bash
backup=$(sudo /usr/local/bin/knot-backup)
sudo tar -tzf "$backup" | grep -E 'config.toml|host_key|sealed|knot.env'
systemctl is-active knot.service
```

Expect the four files and `active`. The backup holds the master key, so
keep copies as private as the VM. Copy `/var/backups/knot` off the VM
now and then; otherwise the VM's disk holds the only copy.

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

To restore on a fresh VM with the same name (or on this one), build the
knot and create the `git` user as in "Set up by hand", copy a backup to
the VM, then:

```bash
sudo systemctl stop knot.service 2>/dev/null || true
sudo rm -rf /var/lib/knot /etc/knot
sudo tar -xzf knot-YYYYMMDD-HHMMSS.tar.gz -C /
curl -fsSL https://tangled.org/tangled.org/core/raw/v1.20.0/systemd/knot.service | sudo tee /etc/systemd/system/knot.service >/dev/null
sudo systemctl daemon-reload
sudo systemctl enable --now knot.service
curl -fsS localhost:8000/xrpc/_health; echo
```

The knot keeps its hostname, keys and repositories; tangled.org doesn't
need to know. A knot on a different hostname is a different knot: its
repositories would have to be recreated there.

</details>

<details id="update" class="dp-details">
<summary>Update</summary>

Check [the release tags](https://tangled.org/tangled.org/core/tags) and
the [upgrade notes](https://tangled.org/docs/upgrading/knots-and-spindles/),
then build the new tag (here `v1.21.0`) and swap the binary:

```bash
. ~/.cargo/env
cd ~/tangled-core
git fetch --depth 1 origin tag v1.21.0 && git checkout v1.21.0
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release --locked -p knot-server
sudo /usr/local/bin/knot-backup
sudo install -m 755 target/release/knot-server /usr/local/bin/knot-server
sudo systemctl restart knot.service
curl -fsS localhost:8000/xrpc/_health; echo
```

The knot finishes running pushes (up to 40 seconds) before it stops.

</details>

<details id="spindle" class="dp-details">
<summary>Run your own spindle</summary>

A spindle runs your repositories' CI. It needs its own public HTTPS
address, so it gets its own VM, which also keeps CI jobs away from your
repositories. It runs each step in a Docker container (Tangled's
`nixery` engine), so workflows on it say `engine: nixery`.

On your computer:

```bash
ssh exe.dev new --name=my-spindle
ssh my-spindle.exe.xyz
```

On the VM, start Docker and install [Nix](https://nixos.org/download/),
which is how Tangled builds the spindle (there's no container image).
The build takes about 20 minutes. The spindle needs a newer git than
Ubuntu's, so the same Nix profile holds git:

```bash
sudo systemctl enable --now docker.service
sh <(curl --proto '=https' --tlsv1.2 -sSfL https://nixos.org/nix/install) --daemon --yes
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
sudo env "PATH=$PATH" nix --extra-experimental-features 'nix-command flakes' \
  profile add --profile /nix/var/nix/profiles/spindle \
  'git+https://tangled.org/tangled.org/core?ref=refs/tags/v1.20.0#spindle' nixpkgs#git
sudo ln -sf /nix/var/nix/profiles/spindle/bin/spindle /usr/local/bin/spindle
```

Make its signing key (back it up: `sudo cat /var/lib/spindle/key.pem`)
and its settings. It serves on port 8000, lets tangled.org's pages read
logs from it, and runs one job at a time on this 2-CPU VM:

```bash
HANDLE=alice.tngl.sh        # TANGLED_HANDLE
EMAIL=you@example.com           # KNOT_CONTACT_EMAIL
OWNER_DID=$(curl -fsS "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=$HANDLE" | python3 -c 'import json,sys; print(json.load(sys.stdin)["did"])')
sudo install -d -m 700 /var/lib/spindle
sudo openssl ecparam -name prime256v1 -genkey -noout -out /var/lib/spindle/key.pem
sudo chmod 600 /var/lib/spindle/key.pem
sudo install -d -m 755 /etc/spindle
sudo tee /etc/spindle/spindle.env >/dev/null <<EOF
SPINDLE_SERVER_HOSTNAME=$(hostname).exe.xyz
SPINDLE_SERVER_OWNER=$OWNER_DID
SPINDLE_CONTACT_EMAIL=$EMAIL
SPINDLE_SERVER_KEY_PATH=/var/lib/spindle/key.pem
SPINDLE_SERVER_LISTEN_ADDR=0.0.0.0:8000
SPINDLE_SERVER_DB_PATH=/var/lib/spindle/spindle.db
SPINDLE_SERVER_REPO_DIR=/var/lib/spindle/repos
SPINDLE_SERVER_HYDRANT_DIR=/var/lib/spindle/hydrant
SPINDLE_ARTIFACT_STORES_DISK_DIR=/var/lib/spindle/artifacts
SPINDLE_SERVER_CORS_ORIGINS=https://tangled.org
SPINDLE_SERVER_MAX_JOB_COUNT=1
SPINDLE_NIXERY_PIPELINES_MAX_JOB_MEMORY_MB=4096
SPINDLE_NIXERY_PIPELINES_MAX_CONCURRENT_WORKFLOWS=2
EOF
sudo chmod 600 /etc/spindle/spindle.env
```

Run it as a service. It runs as root, because it drives Docker:

```bash
sudo tee /etc/systemd/system/spindle.service >/dev/null <<'EOF'
[Unit]
Description=Tangled spindle
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service

[Service]
EnvironmentFile=/etc/spindle/spindle.env
Environment=PATH=/nix/var/nix/profiles/spindle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
ExecStart=/nix/var/nix/profiles/spindle/bin/spindle run
WorkingDirectory=/var/lib/spindle
StateDirectory=spindle
StateDirectoryMode=0700
LogsDirectory=spindle
Restart=always
RestartSec=5
TimeoutStopSec=600

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now spindle.service
sleep 5
curl -fsS localhost:8000/_health; echo
```

Expect `{"status":"ok"}`. On your computer, make it public and check:

```bash
ssh exe.dev share set-public my-spindle
curl https://my-spindle.exe.xyz/_health
```

On tangled.org, open **Settings → Spindles**
([tangled.org/settings/spindles](https://tangled.org/settings/spindles)),
press **Add a spindle**, enter `my-spindle.exe.xyz` and press **Add**.
Pick it under **Select a spindle** when you create a repository, or
under **Spindle** in an existing one's **Settings → Pipelines**. Add a workflow,
`.tangled/workflows/test.yml`:

```yaml
when:
  - event: ["push", "manual"]
    branch: main

engine: nixery

dependencies:
  nixpkgs:
    - go

steps:
  - name: Test
    command: go test ./...
```

Push it; the run shows under the repository's **Pipelines** tab. At
first the spindle runs CI only for you; to let someone else use it:

```bash
sudo spindle allowlist add --db /var/lib/spindle/spindle.db friend.tngl.sh
```

It doesn't need a restart. The
[workflow guide](https://tangled.org/docs/spindles/writing-workflows/)
has the rest.

To update, swap the tag (here `v1.21.0`) and restart:

```bash
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
sudo env "PATH=$PATH" nix --extra-experimental-features 'nix-command flakes' profile remove --profile /nix/var/nix/profiles/spindle spindle
sudo env "PATH=$PATH" nix --extra-experimental-features 'nix-command flakes' profile add --profile /nix/var/nix/profiles/spindle 'git+https://tangled.org/tangled.org/core?ref=refs/tags/v1.21.0#spindle'
sudo systemctl restart spindle.service
```

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

- **Creating a repository fails with `Request failed with status code
  404`.** The address under **Select a knot** isn't a knot: check it's
  `my-knot.exe.xyz` (not the spindle's), and that
  `curl https://my-knot.exe.xyz/xrpc/_health` works from your computer
  (if it asks you to log in, the VM isn't public yet: run
  `ssh exe.dev share set-public my-knot`).
- **Creating a repository is refused.** Only the knot's owner and
  members may: the first DID in `admins` in `/var/lib/knot/config.toml`
  must be the account you're signed in to tangled.org with
  (`curl https://my-knot.exe.xyz/.well-known/did.json` shows the knot's
  side). An account made on tangled.org (`alice.tngl.sh`) is a different
  DID from your Bluesky handle.
- **A new repository doesn't show on tangled.org.** Tangled's indexer
  hasn't crawled the knot: check `crawlers` in the config, then
  `sudo systemctl restart knot.service`, which announces the knot again.
- **`Permission denied (publickey)` on push.** The knot checks your key
  against the SSH keys in **Settings → Keys** on tangled.org and caches them
  for about an hour. Add the key, wait, and try again. `ssh -v
  my-knot-git` shows which key you offer.
- **The push hangs or `ssh` reaches the VM's shell.** Your URL isn't
  rewritten: check `git remote -v` and `git config --global --get-regexp
  '^url\.'`.
- **The knot won't start.** `journalctl -u knot.service -n 50` names the
  setting it doesn't like; `knot-server --config
  /var/lib/knot/config.toml validate` (with the env file, as above)
  checks the config.

</details>

## Frequently asked questions

### What is Tangled?

A code collaboration site, like GitHub, built on the AT Protocol (atproto), the network behind Bluesky. You sign in with an account made on tangled.org, or your Bluesky or other atproto account; stars, issues, pull requests and comments are records in your own atproto repository. The git data lives on knots, small servers anyone can run. tangled.org (the appview) shows them all in one place.

### Do I need my own knot to use Tangled?

No. Tangled runs knots anyone can create repositories on. Run your own to keep your code on a server you control, to set your own limits, or to host a team's or community's repositories.

### Why does the VM have to be public?

tangled.org, other knots, spindles and people cloning your repositories all talk to the knot over HTTPS, without an exe.dev login. Making the VM public opens port 8000, the knot, to the internet; SSH stays behind exe.dev. The knot decides who may push and create repositories, by atproto identity.

### Why not push to git@my-knot.exe.xyz like tangled.org says?

Port 22 on `my-knot.exe.xyz` is exe.dev's SSH, which logs you in to the VM, not the knot. The `my-knot-git` alias jumps through exe.dev's SSH to the knot's own SSH server on port 2222, and git's `insteadOf` rewrites tangled.org's URLs to it, so you can copy them unchanged. People without SSH access to the VM push over HTTPS instead.

### Can I use my own domain?

Yes, but decide before the first start: its hostname is its identity (`did:web:<hostname>`). Point a CNAME at `my-knot.exe.xyz`, run `ssh exe.dev domain add my-knot git.example.com` (see [custom domains](/docs/cnames)), then set `hostname` in `/var/lib/knot/config.toml` before the first start.

### Why SHA-1 repositories?

Knot 2 creates SHA-256 repositories by default, which can't push to or fetch from SHA-1 ones, and GitHub and most tools are SHA-1. This page sets `object_format = "sha1"` so you can push existing repositories and mirror to GitHub. Change it before creating repositories if you want SHA-256; it only affects new ones.

### What about CI?

Tangled's CI runners are called spindles. Pick Tangled's spindle when you create a repository, or run your own spindle on a second VM (below): one VM serves one public port, and a spindle is a separate service with its own hostname.

### What does it cost?

Tangled and the knot are free (MIT). The knot uses little memory when idle; you pay for your [VM plan](https://exe.dev/pricing), and disk for your repositories.


## Related

- [Forgejo](https://exe.dev/deploy/forgejo.md): Self-hosted Git forge with exe.dev SSO
- [Gitea](https://exe.dev/deploy/gitea.md): Self-hosted Git forge with exe.dev SSO
- [Run GitHub Actions jobs on exe.dev, a fresh VM per job](https://exe.dev/deploy/github-actions.md): Every job on a fresh copy of a warm VM, billed by the second.
- [VS Code in your browser, with code-server](https://exe.dev/deploy/code-server.md): Your editor in a tab. Your projects right where you left them.
- [A Linux desktop in your browser](https://exe.dev/deploy/linux-desktop.md): Xfce, Chrome, and coding agents on a VM you open in a browser tab.
- [Use exe.dev as a remote Docker host](https://exe.dev/deploy/docker-host.md): Keep the docker CLI. Send the builds, the containers, and the fan noise to a VM.
