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?

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:

  1. 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.
  2. 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


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, dev and source are all taken before you even start, and createAccount just rejects you. forge, ops, vcs, scm, tangled, knot and hub were free — I went with forge.suranyami.com because 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

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.

Discuss...