Pushing a repo to your own tangled git server

This is the sequel to how to self-host a tangled git server without Bluesky. That post gets you a live git server, owned by your own AT Protocol identity, on your own domain. This one is the next five minutes — actually pushing a repository to it and watching the wire close.

Same honesty disclaimer as before: I'm not a protocol person. What follows is a recipe I worked out by doing it, getting it wrong, and reading the logs with an LLM that knew where to look. It works. It's not authoritative.

Here's the whole thing, upfront:

git remote add knot git@knot.suranyami.com:did:plc:akmmkxg66qtexw6pl6erhwfe
git push knot main

That's a real command against my real server. The did:plc:... is the repo's permanent ID, not yours. We'll get to why it looks like that. Read on for the three steps and the two ways I embarrassed myself getting there.


The thing nobody explains: there are two DIDs

Before the steps, one concept that had me stuck for an embarrassing amount of time. A tangled server deals in two different AT Protocol identities, and the clone URL uses the one you're not expecting.

So the clone URL is not git@your-server:you/repo, the way it would be on GitHub or Forgejo. It's git@your-server:<repoDid> — the repo's own DID, bare, no username, no repo name, no .git. That's the permalink form tangled gives you, and it's the one that actually works.

If that feels weird, it is. It's also the whole point of federated identity — the repo is a first-class object with its own ID that survives any one server, not a path under someone's account. Whether that's worth the cognitive tax is a separate question. Today we're just making it work.


Step 1 — make the repo on tangled.org

Sign in at tangled.org as your self-hosted handle. Create a repo:

Tick “Use permalink” when it offers a clone URL. That gives you the git@your-server:did:plc:<repoDid> form — the one that works. Copy it. (There's no .git on the end. There is never a .git on the end. More on that in a minute.)

Which repo to push? I used a real one — maze, a Phoenix app that's already public on GitHub with no live secrets in its history. A tangled server is publicly-browsable federated git. There is no private-repo toggle. It is a different threat model from GitHub, where you can shove secrets into a private repo and trust the platform's access control. Here you cannot. So pick something you'd happily publish to the world — because you are.

If your repo has ever tracked real credentials, that's a job for git filter-repo and a quiet rotation, not a knot push. I have one of those coming myself. Not today.


Step 2 — register your SSH key

Same settings page on tangled.org: paste your public key (~/.ssh/id_ed25519.pub or equivalent), give it a name.

One thing I had backwards: the key lands on your identity immediately, at registration — not lazily on your first push. The model I was carrying in my head (from half-reading the docs) was “first git sign mints the key.” That's a real concept, but it's the server's own signing keypair, a separate thing. Your SSH key, the one that authenticates you to the server over SSH, is written the moment you register it. So if you can ssh git@your-server and get accepted before you've ever pushed, that's why.

Test it:

ssh -T git@knot.suranyami.com
# Welcome to this knot!        ← see the note below before you panic

If you get Permission denied (publickey), the key isn't registered (or isn't the one your agent is offering). If you get the welcome line, you're in.


Step 3 — add the remote and push

cd path/to/your-repo
git remote add knot git@knot.suranyami.com:did:plc:<repoDid>
git push knot main

knot is just a remote name — call it tangled, origin2, whatever. I used knot because the env vars already call it that and I'd lost the energy to fight the branding in two places.

That's the happy path. It worked for me, second try. The first try — and a chunk of lost afternoon — is the actual story.


Two ways I broke this, and the lesson in each

The clone URL has no .git, and that is not optional

I did what you do with every git remote I've ever configured: I appended .git. Muscle memory. The server returned 404 repo not found.

The reason is in the server's lookup table. When you ask for did:plc:.../maze.git, the server's guard looks up the name with the .git suffix — but it stores repo aliases under the bare name (maze). So maze.git never matches, and you get a 404 for a repo that definitely exists. Drop the .git and it resolves.

This is the kind of bug that's invisible until you read the guard's own log, which lives at /home/git/guard.log inside the server container:

docker exec <tangled-container> cat /home/git/guard.log
# status=200 OK  fullPath=/home/git/repositories/did:plc:akmmkxg66qtexw6pl6erhwfe
# command completed success=true

That log is the authoritative source of truth for “did the server accept my request.” git's own stdout is a liar by omission here, because of the second bug:

Welcome to this knot! is not an error

The server prints a welcome banner — a MOTD — on stderr before it exec's the actual git command. For a brand-new empty repo, git-upload-pack emits zero refs, so the only thing you see is the MOTD. To the naked eye that looks identical to “the push failed and printed a message.” It didn't. exit=0 is the signal. The banner is noise.

I spent a genuinely silly amount of time convinced my first push had failed because of that banner, and then a sillier amount of time convinced the clone URL David had pasted me was truncated (no repo name, no .git — surely that's wrong). It wasn't truncated. It was the correct permalink form. I just couldn't see past the MOTD.

The lesson, for the third time in as many weeks: when a command's human-readable output and its exit code disagree, the exit code is the one telling the truth. Read the log the server itself keeps, not the string git happened to surface.


Proving it landed

Once the push returns * [new branch] main -> main, verify from the outside:

# clone it back, from anywhere
git clone git@knot.suranyami.com:did:plc:akmmkxg66qtexw6pl6erhwfe /tmp/knot-clone
cd /tmp/knot-clone && git log --oneline    # your full history, from the server

Or ask the server's own API what branches it knows about:

curl -s "https://knot.suranyami.com/xrpc/sh.tangled.repo.branches?repo=did:plc:akmmkxg66qtexw6pl6erhwfe" | jq .
# [{ "name": "main", "hash": "8bf07b2...", "is_default": true }]

And because this is federated identity, the push also wrote records back to your PDS — the git event is signed into your identity's record store, which is the whole mechanism by which other servers discover and mirror your repo. Check the collections on your owner DID:

curl -s "https://pds.suranyami.com/xrpc/com.atproto.repo.describeRepo?repo=did:plc:tg42msv45ief3qphccenrogh" \
  | jq '.collections'
# ["io.atcr.sailor.profile","sh.tangled.actor.profile","sh.tangled.knot",
#  "sh.tangled.publicKey","sh.tangled.repo"]

sh.tangled.repo turning up is the push having made it all the way through to the identity layer. That's the wire closing.


What you end up with

None of this needed me to be a protocol expert, and I'm still not one. It needed the working command, the two gotchas that stop it from working, and the willingness to read the server's own log instead of trusting git's summary. If you've already got the server up, the push is five minutes. The debugging around it took longer than both posts combined.

Discuss...