This repository has no description
site content posts decentralized-social-git.md
11 kB
Markdown
at main

+++ title = "Self-Hosting a Tangled Knot: Decentralized Social Git" date = "2026-06-11" slug = "decentralized-social-git" description = "Running my own git server on the AT Protocol with Tangled, on a NixOS homelab."

[taxonomies] tags = ["nix", "git", "atproto"] +++

I like running my own software. You learn how things actually work, you own your data, and the box in my apartment doesn't have a quarterly roadmap. That alone is reason enough to want my git off someone else's servers.

But there's a sharper reason too. GitHub has been visibly creaking lately under the weight of AI. A massive influx of accounts, pushing billions of commits and spawning CI runners etc. This has put a real strain on the GitHub servers, which they have openly stated. GitHub stopped publishing aggregate uptime numbers years ago, but an unofficial status mirror reconstructs them from the archived feed, and as I write this it's reporting 87.38% uptime. That's not a number you advertise.

GitHub Platform uptime over the last 90 days: 87.38%, with 75 incidents, from the unofficial GitHub status mirror

And that abuse costs money to fight. I pay GitHub a small monthly fee today, but fending off this load is expensive, and those costs land on paying users eventually. I'd rather leave before my $5 invoice quietly turns into $50 bundled with AI features I never asked for, while it's still my choice to make.

The one thing I didn't want to give up, though, was the social side. Plain self-hosted git gets you your data on your own box, but it's an island: no shared identity, no easy way for someone else to open a pull request, none of the network effect that makes GitHub a place to share your projects or stumble onto someone else's.

A bit of research later, I landed on Tangled, which threads exactly that needle. It's git hosting built on the AT Protocol, the same protocol behind Bluesky. Your identity is a DID that isn't tied to any one server. Repos live on a "knot" (the server), while collaboration, identity, and the social graph live on the open network. You can run as much or as little of it yourself as you like, all the way up to your own CI runners. But because identity and the social layer aren't tied to any one host, self-hosting a piece doesn't cut you off from everyone else.

I started with just the knot: my repos on my hardware, everything else riding on the shared network. The CI runner (a "spindle", in Tangled's terms) can come later.

What a knot actually is #

A knot is the thing that stores your bare git repositories and serves them over HTTP and SSH. But it's not a closed forge. It announces itself on the AT Protocol network, and the public appview (the web UI, the social layer) talks to it on your behalf. When someone views your repo, browses a diff, or opens a pull request, that flows through the appview and the protocol.

A fully public knot has to be reachable from the internet so the appview and peer knots can fetch from it. I'm not there yet. For now I run mine behind Tailscale, reachable only from my own devices. The web side still works because nginx serves it, but pushes and clones go over the tailnet. I might change this later, but this is safer and doesn't require any special firewall rules.

The NixOS side is a flake input. The Tangled core repo ships a knot module, so my own module is small:

{ inputs, ... }:
{
  flake.modules.nixos.tangled-knot =
    { config, lib, ... }:
    let
      domain = "knot.ankarhem.dev";
      internalPort = "5444";
      publicPort = "5555";
    in
    {
      imports = [ inputs.tangled.nixosModules.knot ];

      services.tangled.knot = {
        enable = true;
        package = inputs.tangled.packages.${config.nixpkgs.hostPlatform.system}.knot;
        gitUser = "git";
        stateDir = "/var/lib/tangled-knot";
        server = {
          listenAddr = "127.0.0.1:${publicPort}";
          internalListenAddr = "127.0.0.1:${internalPort}";
          hostname = domain;
          owner = "did:plc:freiumm55hanqgziwfajvhn6";
        };
        openFirewall = false;
      };

      services.nginx.virtualHosts."${domain}" = {
        forceSSL = true;
        useACMEHost = "ankarhem.dev";
        locations."/".proxyPass = "http://127.0.0.1:${publicPort}";
        locations."/events" = {
          proxyPass = "http://127.0.0.1:${publicPort}";
          proxyWebsockets = true;
          extraConfig = ''
            proxy_read_timeout 86400s;
          '';
        };
      };
    };
}

The /events endpoint gets its own block because it's a long-lived WebSocket stream that pushes live repo events. Without proxyWebsockets and a long read timeout, nginx kills the connection after the default 60 seconds and the event feed stops updating. The configuration does require the recommendedProxySettings option to be enabled, which I have enabled globally in my NixOS config.

The owner is my DID, a protocol-level identity rather than a per-service account the knot mints for me. Mine is my Bluesky account's DID.

Pushing over the tailnet #

The appview hands out SCP-style remotes (git@knot.example.com:owner/repo), which always go over SSH on port 22. Since I'm running this privately for now, I'd rather those pushes never touch the public hostname and stay on the tailnet instead, so I just point that host at the homelab's LAN IP in my SSH config:

# This is the ssh client config.
# So that when my mbp pushes to knot.ankarhem.dev,
# it uses the homelab's LAN IP instead of the public hostname
# This works both locally and over the tailnet.
"knot.ankarhem.dev" = {
  HostName = "192.168.1.221";
  User = "git";
  Port = 22;
  IdentitiesOnly = true;
  IdentityFile = homelabKeys;
};

What makes this work everywhere is that my homelab advertises its LAN subnet as a Tailscale route (--advertise-routes=192.168.1.0/24), and my laptop accepts routes. So 192.168.1.221 reaches the box whether I'm home on the LAN or anywhere else over the tailnet.

There's more Tailscale could say for itself here, but that's a post of its own, which I might get to another time.

Mirroring tangled repos #

I don't want to abandon GitHub overnight; I want the knot to mirror what's already there. The Tangled docs cover this with dual push: keep origin pointed at GitHub, add the knot as a second push URL, and one git push lands on both.

git remote set-url --add --push origin git@knot.ankarhem.dev:ankarhem.dev/nix-config
git remote set-url --add --push origin git@github.com:ankarhem/nix-config

That's two commands to type out exactly right, every time I clone the repo on a new machine, with the URLs varying per repo. Fine once, annoying as a habit. So I checked the remote config into the repo itself, as a .gitconfig at the root:

# git remote remove origin
# git config --local include.path ../.gitconfig
[remote "origin"]
    url = git@knot.ankarhem.dev:ankarhem.dev/nix-config
    pushurl = git@knot.ankarhem.dev:ankarhem.dev/nix-config
    pushurl = git@github.com:ankarhem/nix-config

The fetch url points at the knot, and the two pushurl lines send every push to both the knot and GitHub. The two commented lines at the top are the activation. Git won't read a tracked file as config on its own, and that's deliberate: if a checked-in file could silently rewrite your remotes, cloning a hostile repo could redirect your pushes anywhere. So you opt in once per clone with git config --local include.path ../.gitconfig, after dropping the auto-created origin. One pasted line instead of two hand-edited URLs, and the actual remote definition lives in version control where I can fix it in one place.

Git repos on NFS #

The homelab is a Proxmox LXC. Bulk storage is a Synology NAS mounted over NFS, and my first instinct was to point the knot's repo storage there. I figured repos can grow, the NAS has the space. So I set:

repo.scanPath = "/mnt/DISKETTEN_drive/tangled/repos";

Upon applying the config, the service crash-looped immediately:

knot-pre-start[...]: chown: changing ownership of
'/mnt/DISKETTEN_drive/tangled/repos': Operation not permitted

The module's preStart runs chown -R git:git on the repo path, as root. That should always work, since root can chown anything. Except this is NFS:

$ findmnt /mnt/DISKETTEN_drive
SOURCE: 192.168.1.163:/volume1/drive   FSTYPE: nfs   (NFSv3)

$ stat -c '%U:%G %a' /mnt/DISKETTEN_drive/tangled/repos
UNKNOWN:users 777

Synology's NFS export uses root_squash, the default, and a good one. It maps the client's root to an unprivileged user on the server side. So "root" inside my container has no authority to change ownership on the export. chown returns EPERM, preStart fails, the service never starts. The directory was already mode 777 and perfectly writable; the chown was both failing and unnecessary.

I went down the path of overriding preStart to skip the chown, since the knot binary never inspects ownership (thus trying to change ownership). While that might have worked, I realized the problem I was trying to solve was not a problem at all. I already clone my entire GitHub collection of repos on my machine using a script, so they aren't really that big and could just be stored locally on the homelab SSD.

stateDir = "/var/lib/tangled-knot";
repo.scanPath = "${cfg.stateDir}/repos";

A terminal trying to be helpful #

With pushes working, every connection to the knot started with a pause and a warning:

$ ssh -T git@knot.ankarhem.dev
Setting up xterm-ghostty terminfo on 192.168.1.221...
Warning: Failed to install terminfo.

This one took a minute to place, because it isn't Tangled's fault at all. It's my terminal. Ghostty has an SSH shell integration that, on every connection, tries to install its xterm-ghostty terminfo on the remote by piping it through tic, so colors and keys render correctly in an interactive session.

But the knot's git user can't run tic. It uses a forced command, every key is locked to knot guard ... no-pty. There's no shell to run tic so the install fails. And Ghostty only caches successful installs, never failed ones, so it retries and hangs on every single connection.

The fix is to tell Ghostty this host is already handled, which makes it skip the attempt:

ghostty +ssh-cache --add=git@knot.ankarhem.dev

Wrap up #

That's the knot running. Repos on local disk, behind Tailscale, pushing to both GitHub and my own box. The spindle (CI) comes next, and opening the knot to the public internet is a port-forward if I ever decided that I want to expose it to the world.

If you want to run one, start with the knot self-hosting guide; the NixOS module does most of the work, and my config lives in ankarhem.dev/nix-config.