Bluesky (and the at protocol) are a (relatively) newly way of decentralized communication, where users are free to choose who holds/provides their data to the network, in adition to custom data-munchers often called "feeds". But in it's default way of usage still relies on a centralized service, plc.directory which provides identities for the network. This guide not only describes how you can host your own data storage (called a Personal Data Server in bluesky / atproto), but also how to completly decentralize your identity as well!
A place to call home
Installation of a PDS
The first thing you need is a place to store your data, which is provided by a PDS or Personal Data Server. Bluesky itself provides a default implementation of this services as a opensource MIT or Apache2.0 licensed github repository . In their README they provide an simple install script , while it does uses docker, it currently (06-2025) lacks some configuration abilities for the storage path in your host system (it just places everything in `/pds`). It also comes with an caddy, which wasn't usable in my case as my host already runs nginx as resverse proxy. So lets install it manually then!
Before we start we ofc need to have docker installed, or atleast a docker-compose compatible software.
Next we can create a compose.yaml
for our stack; You can choose to use the current example from the bluesky pds as a base and remove what you dont need, but here I simply write a new one. One thing to note is that I use /opt/bluesky_pds
as the basepath for the pds:
services:
pds:
container_name: bluesky_pds
# You can also use ghcr.io/bluesky-social/pds:0.4 if you want to always use the latest 0.4 version.
image: ghcr.io/bluesky-social/pds:0.4.138
restart: unless-stopped
ports:
# Change 8080 to any value you want the pds to be reachable on the host system
- 127.0.0.1:8080:3000/tcp
volumes:
- type: bind
source: /opt/bluesky_pds/pds
target: /pds
env_file:
- /opt/bluesky_pds/pds/pds.env
Before we can configure the environment variables in the pds.env
file, we need to create some keys (im executing these in the basepath mentioned above):
$ openssl rand --hex 16 > jwt_secret.key
$ openssl rand --hex 16 > admin_password.key
$ openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32 > plc_rotation.key
Now we can configure the pds.env
file:
PDS_HOSTNAME=<your pds domain>
PDS_JWT_SECRET=<content of jwt_secret.key>
PDS_ADMIN_PASSWORD=<content of admin_password.key>
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=<content of plc_rotation.key>
PDS_DATA_DIRECTORY=/pds
PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks
PDS_BLOB_UPLOAD_LIMIT=52428800
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_REPORT_SERVICE_URL=https://mod.bsky.app
PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
PDS_CRAWLERS=https://bsky.network
LOG_ENABLED=true
You might notice that we still configure it to also generate plc id's (the central identity service I mentioned), which is needed if you want to migrate accounts that uses plc identities to the server; we dont actually do that in the rest of the article, but I honestly didn't investigatet if you can leave this empty or somehow can disable the feature.
And thats (mostly) it; You can now start your pds with docker compose up -d
.
For public access we still need a reverse proxy, and you can create a nginx vhost with following content in the server
block:
location / {
# Change 8080 here to the port you also gave the pds in the compose.yaml
proxy_pass http://localhost:8080;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
client_max_body_size 50M;
proxy_http_version 1.1;
}
To test if it is working, you can run the following two commands:
$ curl https://<your pds domain>/xrpc/_health
{"version":"0.4.138"}
$ wscat --connect 'wss://<your pds domain>/xrpc/com.atproto.sync.subscribeRepos?cursor=0'
Connected (press CTRL+C to quit)
>
If both give similar output as listed above, congratulations: you have an own pds now!
The pdsadmin utility
The bluesky pds also comes with a seperate admin utility script called pdsadmin, but be aware that it downloads the commands it supports on-the-fly into your /tmp
directory and executes it there. This is a bit to much of a security risk for my taste (and also may not work if you mount your tmp as noexec
). So I created my own scripts:
- The
update_pdsamin.sh
script downloads the current version to a local folder:Bashupdate_pdsamin.sh#!/bin/bash VERSION="main" # You can also download a specifiy version by using the tag instead: # VERSION="refs/tag/v0.4.138" BASE_URL="https://raw.githubusercontent.com/bluesky-social/pds/${VERSION}/pdsadmin" BASE_PATH="/opt/bluesky_pds/.pdsadmin" COMMANDS="account create-invite-code help request-crawl update" for cmd in $COMMANDS; do echo "==> Update command $cmd" if ! curl --fail --silent --show-error --location --output "$BASE_PATH/$cmd.sh" "$BASE_URL/$cmd.sh"; then echo "Failure: could not download command $cmd!" exit 1 fi done echo "Done :3"
- And a custom
pdsamin.sh
Bashpdsamin.sh#!/bin/bash set -o errexit set -o nounset set -o pipefail BASE_PATH="/opt/dockering/bluesky_pds" CMD_BASE="$BASE_PATH/.pdsadmin" COMMAND="${1:-help}" shift || true if [[ "${EUID}" -ne 0 ]]; then echo "ERROR: This script must be run as root" exit 1 fi SCRIPT_FILE="$CMD_BASE/${COMMAND}.sh" if [ ! -f "${SCRIPT_FILE}" ]; then echo "ERROR: ${COMMAND} not found" exit 2 fi PDS_ENV_FILE="$BASE_PATH/pds/pds.env" "${SCRIPT_FILE}" "$@"
Now you can simply use pdsadmin.sh
without worrying about any possible security risks!
A decentral Identity
Now that we have a place where our data can be stored, it is about time to talk about another core aspect of the at protocol: decentralized identities. Because the protocol itself already supports decentralization of identities! To achieve this, atproto uses the W3C DID specification which describe a way of representing identifiers in a decentralized system.
TLDR: they are formated like this: did:<method>:<data...>
where method
can any ascii specifier of a list of valid did-methods and data...
is just character data that is intepret like the method specifies. This essentially creates scoped identitifers that specify how they are resolved! There are many methods available: some use other protocol, central services, and there are even some that uses blockchains to store them! One problem remains: each "consumer" of DIDs needs to implement these methods, so your preferred ones might not be available in every software...
Which comes to the next problem: Which can we use in bluesky / atproto? Luckily, there are two "blessed" methods , which the at protocol specifies that every implementor NEEDING to support at minimum. Those two are the previously centralized did:plc
and more importantly: did:web
.
The did:web method
The did:web is what we need in order to achieve the goal of this article: it uses the DNS system and HTTP protocol instead to resolve identities (which you could argue to be not completly decentralized, but it's still better than a single services controlled by a single coporation). They can come in two forms:
-
did:web:example.com
resolves to a HTTP request tohttps://example.com/.well-known/did.json
, while -
did:web:example.com:user:alice
resolevs tohttps://example.com/user/alice/did.json
; note that you can use ANY amount of components seperated by a colon (:
) after the domain name, each of those colons are simply translated to a slash (/
).
I decided to go with the first approach, since I controll my entire domain anyways. So I configured my webserver accordingly to serve a did-document when querried for .well-known/did.json .
But what are these did-documents anyway?
DID Documents under the microscope
The DIDs we talked about so far are just the identitifier part, I.e. how one can refer to a DID in a textual representation and resolve to it's content. A DID Document instead is the actuall content the DID refers to.
It is also very narrowly specified, but it's content can vary greatly depending on which applications read it. The at protocol has it's own rules what it expects to be present / uses. So lets look at an example:
{
// This is the "context", it describes which "features" or "extensions" are available in this
// document. Here we ofcourse specify that it's a DID, followed by extensions for multikey crypthocraphic keys
// as well as the SECP256K1 key algorithm which is one of the both allowed key algos atproto supports.
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
// This array holds alternate names the DID is known as. In the case of the at protocol it looks for the first
// entry that is formatted as a URI, with the protocol being "at://".
"alsoKnownAs": [
"at://mai.lapyst.dev"
],
// Since DIDs can resolve in a multitude of ways, this document also includes the DID that the document is ment for.
// Conumers are expected to validate it against the DID used to get to it to ensure the correct content was fetched.
"id": "did:web:mai.lapyst.dev",
// The service array contains a list of services the DID offers or is a part of.
// In the context of atproto, this means it contains an entry of type "AtprotoPersonalDataServer",
// ideally with the id "#atproto_pds", that links to the PDS that contains the identities data;
// I.e. the server we setup al the way at the start of this guide!
"service": [
{
"id": "#atproto_pds",
"serviceEndpoint": "https://<your pds server>",
"type": "AtprotoPersonalDataServer"
}
],
// This array holds all methods that can be used to verify data for this identity.
// In the atproto context this essentially lets consumers of your data validate that posts, likes and other interactions
// are indeed coming from your identitiy and are legit.
"verificationMethod": [
{
"controller": "did:web:mai.lapyst.dev",
"id": "did:web:mai.lapyst.dev#atproto",
"publicKeyMultibase": "...",
"type": "Multikey"
}
]
Creating your first DID document
After you choose which of the two ways the did:web
method you want, you can create your own document; you can use the template above, but be sure to delete all comments from it, as DID documents need to be valid JSON not JSON-with-comments!
To complete it tho, you need an initial multikey for verification of your identitiy. Thanks to this lovely blog entry (and their picopds!) for an python script to create valid keys for this! You can also use this utility written in golang to generate the id, and even the whole DID document for you!
I quickly wrote an custom little script (based on the mentioned blog entry) for this:
#!/usr/bin/python
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import multiformats
import base64
def encode_did_pubkey(pubkey: ec.EllipticCurvePublicKey):
assert(type(pubkey.curve) is ec.SECP256K1)
compressed_public_bytes = pubkey.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.CompressedPoint
)
return "" + multiformats.multibase.encode(
multiformats.multicodec.wrap("secp256k1-pub", compressed_public_bytes),
"base58btc"
)
name = input("Enter filename: ")
print(f"Using {name}...")
privkey = ec.generate_private_key(ec.SECP256K1())
pubkey = privkey.public_key()
with open(f"{name}.key", "wb") as keyfile:
keyfile.write(privkey.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
))
with open(f"{name}.pub", "wb") as pubfile:
pubfile.write(pubkey.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
))
did_pubkey = encode_did_pubkey(pubkey)
print("Encoded pubkey:", encode_did_pubkey(pubkey))
with open(f"{name}.did.pub", "w") as pubfile:
print(did_pubkey, file=pubfile)
To use it, you need to install some pip packages: pip install multiformats cryptography
. It asks after a filename to store the data generated. It creates three files: A .key
with the private part, a .pub
with the PEM-encoded public part, and an extra .did.pub
with the public part formatted to be used in a DID document. Just copy the content of the .did.pub
file (which is also printed to the output), and place it inside the value for "publicKeyMultibase"
in your did document.
Hosting the DID document
Some tips about hosting a DID:
- Make sure that the DID document and all related data is accessible from web-clients, I.e. set the
Access-Control-Allow-Origin
header like so:add_header Access-Control-Allow-Origin "*";
- Make sure to also host a
atproto-did
document (I.e. mai.lapyst.dev/.well-known/atproto-did ), its content should be simply your did, without any wrappers). - Edit your DNS to add an
_atproto
TXT
entry with the contentdid=<your did>
Creating an account on your PDS
To create an account, we first need to create an invite code for your PDS, as this validates that your request for an account is actually wanted and to prevent others form simply signing up to your PDS, which you might not want:
$ ./pdsadmin.sh create-invite-code
Next, we'll need to actually create the account; for this we need to make a signed request to the com.atproto.server.createAccount endpoint. To do that you can either use the go tool mentioned above, or by using a simple script like the following:
#!/usr/bin/env ruby
require "net/http"
require "json"
require "jwt"
require "openssl"
require 'securerandom'
require 'date'
require 'time'
require 'active_support/core_ext/numeric/time'
# Configuration; change this!
$pds_server = "<your pds server here>"
$pds_server_did = "did:web:"+$pds_server
$pds_invite_code = "<pds invite code>"
$user_email = "<your user email>"
$user_handle = "<your handle>"
$user_did = "did:web:"+$user_handle
$user_password = "<passwort for login>"
$user_keyfile = "./<user>.key"
# Spec: https://atproto.com/specs/xrpc#inter-service-authentication-jwt
def get_service_jwt(did, keyfile, service_did)
dtnow = DateTime.now()
dtexp = dtnow + 180.seconds
nonce = SecureRandom.hex
payload = {
iss: did,
aud: service_did,
exp: dtexp.to_i,
iat: dtnow.to_i,
jti: nonce,
lxm: 'com.atproto.server.createAccount',
}
key = OpenSSL::PKey::EC.new(File.read(keyfile))
return JWT.encode(payload, key, "ES256K")
end
$service_jwt = get_service_jwt($user_did, $user_keyfile, $pds_server_did)
puts "==> Use following service-auth jwt:"
pp $service_jwt
puts ""
puts "==> Decoded content:"
pp JWT.decode($service_jwt, "", false)
puts ""
# Spec: https://docs.bsky.app/docs/api/com-atproto-server-create-account
def createAccount(doc)
url = "https://"+$pds_server+"/xrpc/com.atproto.server.createAccount"
resp = Net::HTTP.post(
URI(url),
doc.to_json,
{
"Content-Type" => "application/json",
"Authorization" => "Bearer "+$service_jwt,
}
)
if resp.code.to_i != 200 then
puts "Error while creating account:"
pp JSON::parse( resp.body )
exit(1)
end
return resp.body
end
puts "==> Try acccount creation..."
$account_data = createAccount({
email: $user_email,
handle: $user_handle,
did: $user_did,
password: $user_password,
inviteCode: $pds_invite_code,
})
File.write("createAccount_"+$user_handle+".json", $account_data);
pp $account_data
It should print out (and store) a json like the following:
{
"handle":"mai.lapyst.dev",
"did":"did:web:mai.lapyst.dev",
"didDoc":{
# Copy of your did.json
}
"accessJwt": "...",
"refreshJwt": "..."
}
You'll need the jwt's in this for followup requests; use use the accessJwt in the Authorization
header as the value for Bearer authentication: Bearer <your access jwt>
.
For further interaction with the API, you can use a small script like this:
#!/usr/bin/env ruby
require 'net/http'
require 'json'
# Configuration; change this!
$pds_server = "<your pds server here>"
$account_create_data = JSON.parse(File.read('./createAccount_<your account>.json'))
$access_jwt = $account_create_data['accessJwt']
STDOUT.write("Method: ")
$method = gets.chomp
resp = Net::HTTP.get(
URI('https://'+$pds_server+'/xrpc/'+$method),
{
"Authorization" => "Bearer "+$access_jwt,
}
)
pp JSON.parse(resp)
Migrate your data (if any)
You now can migrate data you might have on an old account. But keep in mind anyone who has followed you previously will not follow your new account, because following works on on DIDs which would have changed if you for example migrate away from a plc DID. This is also true for any likes and or retposts of your posts! The only data you keep this way are your posts, settings and who you follow etc. Since one most likely have not (which was the case for me), I keep this section short and refer to the offical guide on that.
Updating the DID Document
We now need to update the DID document with new keys, as the offical Bluesky PDS dosn't allow us to specify the private & public key for an Account's DID. It instead generates it's own keypair it expects to be used.
To get the public key for this operation, use the authenticated com.atproto.identity.getRecommendedDidCredentials endpoint (accessJwt). It returns something like this:
{
"alsoKnownAs": [ "at://<your handle>" ],
"verificationMethods": {
"atproto": "did:key:xxxx",
},
"rotationKeys": [
// Another key, but it's only relevant for the did:plc method.
"did:key:yyyy",
],
"services": {
"atproto_pds": {
"type"=>"AtprotoPersonalDataServer",
"endpoint"=>"https://atproto-pds.saiyajin.space"
}
}
}
You take the key from verificationMethods
for atproto
, remove the did:key:
prefix, and replace it with the value already in the publicKeyMultibase
field of your DID document.
Finalizing your Account
You now can check your account's status with the authenticated com.atproto.server.checkAccountStatus endpoint (accessJwt):
{
"activated": false,
"validDid": true, // If this is false, your did is not correctly setup!
"repoCommit": "...",
"repoRev": "...",
"repoBlocks": 1234,
"indexedRecords": 1234,
"privateStateValues": 1234,
"expectedBlobs": 0,
"importedBlobs": 0
}
If the validDid
is NOT true
, you need to check your did:
- Check if you can access the did via a GET request:
curl -v https://<your domain>/.well-known/did.json
(replace as neccessary). - Check if both the
atproto-did
file AND the_atproto
DNS record are setup correctly to point to your DID. - Make sure your DID document contain the updated value for
publicKeyMultibase
- Also make sure that you have extra
A
, andAAAA
records if you use subdomains: I create an wildcard entry for all domains I use, but for some reason when I added_atproto.mai
it didn't resolved the ip-address entries for themai
entry anymore, even tho the wildcard entry worked before. So look out for these issues!
If all looks fine, you can activate the account with a call to com.atproto.server.activateAccount . However, this didn't worked for me; instead I needed to lockin into the bluesky webapp with my credentials (make sure to also select your PDS), which then promted me to activate the account instead, which luckily was all it took to get the account working.
Some more tips: be patient now, even if the UI is throwing errors like "identity unkown"; for me it simply took a moment until the bluesky servers correctly communicated with my PDS. (And I had that nasty DNS bug mentioned above that made my DID not accessible anymore!)
The end?
Digging through this rabbit hole was an amazing time, learning a bit about decentralized identification, key signing and so forth. And ofc I'm very happy to have an fully-decentralized account (even tho I'm still using the the offical client and AppView; but thats a task for another time). In a world that centralizes more by the second, ruled by upper-classes, billionäres and oligarchs, decentralized communication networks are becomming more and more relevant. And I'm releieved that the at protocol can be decoupled from their centralized components; if this is true for the AppViews like bluesky itself is left to be explored, but I have high hopes that this is true as well!
Ofc the fediverse is very powerfull and decentralized as well and I will never give it up; but having options is good and neccessary!