How to self-host your own tangled git server without Bluesky
A confession before the recipe, because I'd rather be honest than look clever: I am not an AT Protocol expert, and I'm no great sysadmin either. What follows is a recipe I stitched together from trial, error, docs read sideways, and a lot of pairing with an LLM that knew the bits I didn't. It works — I'm running it right now — but if any of it reads as authoritative, that's hard-won, not pre-loaded.
Right. What are we actually building?
- tangled — a federated git host. Think GitHub, except no single company owns the whole thing; anyone can run a piece and the pieces talk to each other.
- The piece you run is, in tangled's words, a “knot”. I think that name is daft, so I'll call it a tangled server. It's the bit that actually holds your repositories.
- To own one you need an AT Protocol identity. AT Protocol is the open plumbing under Bluesky — and, crucially, not Bluesky. Your identity there is a DID (a permanent ID that's genuinely yours, not rented from anyone) anchored to a PDS (Personal Data Server — the box that holds your identity and data).
The lazy way to get that identity is to sign up for Bluesky. I'm in Australia, where that now means handing over a face scan, a credit card, or a photo of my ID for “age verification”. Hard no. So I run my own PDS instead, and the identity never touches Bluesky.
See this post for the painful experience that was to set up.
At the end of this you'll have a working git host on your own domain, registered on the tangled network, owned by an identity you fully control.
Two moving parts:
- A PDS — the official Bluesky reference PDS. It's open source; “Bluesky the company” and “Bluesky's software” are different things, and this is the software. It mints and hosts your DID.
- A tangled server — configured to be owned by the DID from part 1.
I run both on eon, a Raspberry-Pi-class box, behind my cluster's reverse proxy (Caddy) via uncloud. The uc deploy / x-ports lines below are uncloud's way of saying “publish this service”; if you're on plain Docker Compose, swap them for however you do reverse-proxy ingress. Everything else carries over.
What you'll need
- A host that can run a container, reachable on ports 80/443 (I'm assuming a reverse proxy out front terminating TLS).
- A domain you control DNS for. I point a wildcard
*.suranyami.comat my cluster edge, sopds.suranyami.comandknot.suranyami.comboth resolve and get auto-issued certs with no per-host records. - Somewhere durable to keep data on local disk. Not NFS — these services use SQLite, and SQLite over a network share is a recipe for corruption. (I learned that one the hard way on an unrelated service. Different post.)
openssl,curl, andjqon whatever machine you run the setup commands from.
Step 1 — stand up the PDS
Make the three secrets
The PDS needs three secret values. These are the commands the upstream installer uses; I didn't invent them, I lifted them:
# JWT signing secret
openssl rand --hex 16
# admin password (HTTP basic-auth for the admin API)
openssl rand --hex 16
# PLC rotation key — a secp256k1 private key, in hex. THIS IS YOUR IDENTITY.
openssl ecparam --name secp256k1 --genkey --noout --outform DER \
| tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32
⚠️ Back up that rotation key like your identity depends on it, because it does. It's the cryptographic key that controls your DID. Lose it and the identity is gone for good — and your git server's ownership goes with it. Drop all three into a gitignored .env, and put the rotation key in your password manager on top of that:
# .env (gitignored)
PDS_JWT_SECRET=...
PDS_ADMIN_PASSWORD=...
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=...
The service definition
This is the upstream compose file with everything I didn't want stripped out — no bundled reverse proxy, no host networking, no auto-updater — because my cluster already does TLS and proxies it through to port 3000:
# services/pds.yml
services:
pds:
image: ghcr.io/bluesky-social/pds:0.4
environment:
PDS_HOSTNAME: pds.suranyami.com
PDS_PORT: "3000"
PDS_JWT_SECRET: ${PDS_JWT_SECRET}
PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD}
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX}
PDS_DATA_DIRECTORY: /pds
PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks
PDS_DID_PLC_URL: https://plc.directory
PDS_BSKY_APP_VIEW_URL: https://api.bsky.app
PDS_BSKY_APP_VIEW_DID: did:web:api.bsky.app
PDS_CRAWLERS: https://bsky.network
PDS_SERVICE_HANDLE_DOMAINS: .suranyami.com # allows handles like forge.suranyami.com
PDS_INVITE_REQUIRED: "true" # no open signups; admin creates accounts
volumes:
- /bricks/eon-1/pds:/pds # SQLite + blobstore (local disk)
x-ports:
- pds.suranyami.com:3000/https # web/API via cluster Caddy (TLS auto)
x-machines:
- eon
restart: always
Make the data dir on the host first (mkdir -p /bricks/eon-1/pds), then deploy. With uncloud the secrets get pulled from .env at deploy time:
set -a; . ./.env; set +a
uc deploy -y -f services/pds.yml
Check it's alive:
curl -s https://pds.suranyami.com/xrpc/_health
# {"version":"0.4.x"}
Step 2 — create your identity
Heads up: this particular PDS image ships without the pdsadmin helper that the docs assume you have. Took me a minute to work out you can just talk to the admin API directly instead. Signups are invite-only (that's the PDS_INVITE_REQUIRED line above), so mint yourself an invite first — the -u admin:... is the admin password doing HTTP basic auth:
set -a; . ./.env; set +a
curl -s -X POST https://pds.suranyami.com/xrpc/com.atproto.server.createInviteCode \
-u "admin:${PDS_ADMIN_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{"useCount": 1}'
# → {"code":"pds.suranyami.com-xxxxx-xxxxx"}
Then create the account with that code:
curl -s -X POST https://pds.suranyami.com/xrpc/com.atproto.server.createAccount \
-H "Content-Type: application/json" \
-d '{
"email": "admin@suranyami.com",
"handle": "forge.suranyami.com",
"password": "<a-strong-account-password>",
"inviteCode": "<code-from-above>"
}'
# → { "did": "did:plc:...", "handle": "forge.suranyami.com", ... }
Save the returned DID and the account password into .env (PDS_ACCOUNT_DID, PDS_ACCOUNT_HANDLE, PDS_ACCOUNT_PASSWORD). The DID is the thing your git server gets owned by, so keep it handy.
A handle gotcha that caught me out: the PDS quietly reserves a pile of role-ish handles.
admin,git,code,repo,devandsourceare all taken before you even start, andcreateAccountjust rejects you.forge,ops,vcs,scm,tangled,knotandhubwere free — I went withforge.suranyami.combecause it names the job (a git host), not me. If your handle gets bounced, this is why.
Step 3 — prove the handle is yours
The wildcard DNS gets the web address resolving, but the handle itself needs a separate proof so tangled (and the login flow) will trust that forge.suranyami.com really is you. That proof is a DNS TXT record — a little text note attached to a DNS name:
host: _atproto.forge.suranyami.com
value: did=did:plc:tg42msv45ief3qphccenrogh
The explicit _atproto record wins over the wildcard for that one name. Check it resolved, and that the PDS agrees the handle maps to your DID:
dig +short TXT _atproto.forge.suranyami.com
# "did=did:plc:tg42msv45ief3qphccenrogh"
curl -s "https://pds.suranyami.com/xrpc/com.atproto.repo.describeRepo?repo=forge.suranyami.com" \
| jq '{handle, did: .didDoc.id, handleIsCorrect}'
# handleIsCorrect: true
handleIsCorrect: true is the bit you're after.
Step 4 — stand up the tangled server
tangled's server image lives in tangled's own registry, atcr.io, which is private and checks AT Protocol identities at the door. So you log in to it with your self-hosted handle and an app-password — a throwaway password scoped to one tool, so you're not handing your real account password to the docker CLI.
Mint one from your PDS:
set -a; . ./.env; set +a
ACCESS=$(curl -s -X POST https://pds.suranyami.com/xrpc/com.atproto.server.createSession \
-H "Content-Type: application/json" \
-d "{\"identifier\":\"${PDS_ACCOUNT_DID}\",\"password\":\"${PDS_ACCOUNT_PASSWORD}\"}" \
| jq -r .accessJwt)
curl -s -X POST https://pds.suranyami.com/xrpc/com.atproto.server.createAppPassword \
-H "Authorization: Bearer ${ACCESS}" \
-H "Content-Type: application/json" \
-d '{"name": "atcr"}'
# → {"password":"xxxx-xxxx-xxxx-xxxx", ...} ← store as KNOT_ATCR_APPPW in .env
Log the host that'll run the server into the registry (run this on that machine):
docker login atcr.io -u forge.suranyami.com # paste the app-password
Now the service itself. The one line that ties this whole thing to you is KNOT_SERVER_OWNER — your DID from Step 2. (Yes, the env vars insist on calling it a knot. I've made my peace with the YAML if not the branding.)
# services/knot.yml
services:
knot:
image: atcr.io/tangled.org/knot:latest
environment:
KNOT_SERVER_HOSTNAME: knot.suranyami.com
KNOT_SERVER_OWNER: did:plc:tg42msv45ief3qphccenrogh # your self-hosted DID
KNOT_SERVER_DB_PATH: /app/knotserver.db
KNOT_REPO_SCAN_PATH: /home/git/repositories
KNOT_SERVER_INTERNAL_LISTEN_ADDR: localhost:5444
volumes:
- /bricks/eon-1/knot/keys:/etc/ssh/keys # stable SSH host keys
- /bricks/eon-1/knot/repositories:/home/git/repositories
- /bricks/eon-1/knot/server:/app # SQLite db + app state
x-ports:
- knot.suranyami.com:5555/https # web UI via cluster Caddy (TLS auto)
- 2222:22@host # git-over-SSH, raw TCP on the host
x-machines:
- eon
restart: always
Deploy, and confirm the web UI answers on a valid cert:
uc deploy -y -f services/knot.yml
curl -sI https://knot.suranyami.com | head -1
# HTTP/2 200
The web UI and registration work over 443 alone. Cloning and pushing over SSH from outside your own network needs one more thing: a port-forward on your router (public TCP 22 → eon:2222) and a fixed local IP for the host. That's optional and entirely separate from getting registered — skip it for now if you just want the thing live.
Step 5 — register it with tangled
Sign in at tangled.org as forge.suranyami.com, using your account password (not the app-password, not the admin password). What happens next is the nice part of running your own identity: tangled bounces you to your own PDS to approve the login, because your PDS — not Bluesky, not tangled — is the thing that vouches for you. Approve it, then:
Settings → Knots → add knot.suranyami.com.
tangled fetches your server over HTTPS, checks it's owned by your DID, and links them. That's it. Push a repo.
Two things that'll bite you
1. The login fails and the error is a lie. When I first tried to register, the login died with invalid_client_metadata and I lost hours chasing a permissions theory that turned out to be completely wrong. The real cause was that my PDS couldn't make one outbound network request — a broken IPv6 path it should never have had. If your registration login throws that error, don't trust where it's pointing you; the whole saga (and the actual fix) is its own post: the bug was IPv6. The quick tourniquet, if you hit it before sorting IPv6 properly, is one line on the PDS:
NODE_OPTIONS: "--dns-result-order=ipv4first --no-network-family-autoselection"
2. “Wrong identifier or password” when the password is right. A 32-character password with no word breaks is trivially easy to truncate when you copy it, and then you'll swear blind it's correct. If a createSession call from the command line works but the browser login keeps rejecting you, the value reaching the form has drifted — your account is fine. Reset the password without recreating the account (recreating mints a brand-new DID and orphans your git server — don't):
curl -s -X POST https://pds.suranyami.com/xrpc/com.atproto.admin.updateAccountPassword \
-u "admin:${PDS_ADMIN_PASSWORD}" \
-H "Content-Type: application/json" \
-d "{\"did\":\"${PDS_ACCOUNT_DID}\",\"password\":\"<new-password>\"}"
Then confirm it with createSession and paste the new value straight off your clipboard, not retyped.
What you end up with
- A DID you own outright, on your own PDS, with a custom-domain handle — no Bluesky account, no face scan, no age check.
- A git host owned by that DID, on a federated network you don't depend on any single company to keep running.
- One file (
.env) you absolutely must back up — the rotation key inside it is your identity.
None of this needs you to be a protocol wizard. I'm certainly not one. It needs an afternoon, a domain, and a stubborn refusal to hand your ID to a website just to host your own code.