Self-Hosting an ATProto PDS
I've been meaning to self-host an ATProto Personal Data Server (PDS) for years but never made time to set it up. I finally took some time to do it last weekend and I'm going to walk through how I set it up.
Why ATProto?
Bluesky is the newest kid on the block for decentralized social media. What's fascinating about Bluesky is not its social media interface but the technology that powers the product. Bluesky calls this framework the AT Protocol (ATProto). In ATProto, user data is not necessarily controlled by a single authority. Instead, the user interface (the AppView) reaches out to individual user servers to display feeds and posts. As long as other apps communicate with the AT Protocol, they can also interop with the same user data. This makes a decentralized platform exit strategy actually feasible: if you don't like Bluesky, you can migrate to another app and keep your followers, likes, and posts. It means you can avoid another Twitter situation if the techno-fascist ferengi assumes control over Bluesky one day... all you need to do is host your own personal data server.
Unfortunately, it turns out that hosting your own PDS is pretty complicated for everyday users. There's still not very much guidance on how to host it. Even Bluesky's official documentation and READMEs are sparse. That's why I want to document my process so others don't run into the same roadblocks I did.
Overview
Here is how I set up my PDS:
- My ATProto handle is
@sam.bossley.com(usingdid:webwhich I will explain in detail below) - My PDS uses the official Bluesky PDS implementation hosted at
pds.bossley.comon Fly.io - I use a
bot@bossley.comemail account to send automated emails on my behalf through SMTP
If you plan to follow this guide, please substitute your own values accordingly.
You'll also need the following command-line tools:
curlfor making API requestsjqfor extracting response data (optional)- goat for generating keys and auth. I couldn't find a better way to generate these keys since ATProto uses specific cryptographic functions.
Deploying the PDS
Start by launching the personal data server. Run fly launch --no-deploy with the following fly.toml configuration:
app = "pds-bossley-com"
primary_region = "iad"
[build]
image = "ghcr.io/bluesky-social/pds:0.4"
[env]
PDS_HOSTNAME = "pds.bossley.com"
# PDS_JWT_SECRET = $(openssl rand --hex 16)
# PDS_ADMIN_PASSWORD = $(openssl rand --hex 16)
# PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX = $(openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32)
PDS_DATA_DIRECTORY = "/pds"
PDS_BLOBSTORE_DISK_LOCATION = "/pds/blocks"
PDS_BLOB_UPLOAD_LIMIT = "52428800" # 50MB
PDS_SQLITE_DISABLE_WAL_AUTO_CHECKPOINT = "true"
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"
LOG_LEVEL = "info"
# PDS_EMAIL_SMTP_URL = "smtps://bot%40bossley.com:<api key>@my.smtp.domain.com:465/"
PDS_EMAIL_FROM_ADDRESS = "bot@bossley.com"
PDS_CONTACT_EMAIL_ADDRESS = "sam@bossley.com"
PDS_RATE_LIMITS_ENABLED = "true"
PDS_INVITE_REQUIRED = "true"
PDS_SERVICE_NAME = "Bossley PDS"
[http_service]
processes = ["app"]
internal_port = 3000
force_https = true
auto_stop_machines = "off"
auto_start_machines = true
min_machines_running = 0
[mounts]
source = "pds_data"
destination = "/pds"
initial_size = 1
snapshot_retention = 2
auto_extend_size_threshold = 90
auto_extend_size_increment = "1GB"
auto_extend_size_limit = "3GB"
[[vm]]
memory = 512
cpus = 1
cpu_kind = "shared"
Note that all environment variables specified in the configuration above are required even if you don't use them. The PDS readme says otherwise but is actually incorrect and will cause issues later if not all environment variables are set properly. Don't ask me how I know.
Generate and save the values for the environment variables commented out above in a password manager, then upload them as secrets. The values are taken directly from the Bluesky PDS installer script. The admin password will be used later.
Then deploy the PDS:
fly deploy
Don't forget to assign DNS records to point the PDS domain IPs to the deployed machine:
Type | Host | Answer
A | pds.bossley.com | my.machine.ipv4.address
AAAA | pds.bossley.com | my:machine:ipv6:address
One DNS is set up, you should run healthchecks to make sure our server running properly:
curl -sS https://pds.bossley.com/xrpc/_health
curl -sS https://pds.bossley.com/xrpc/com.atproto.server.describeServer
You now have a ATProto personal data server with all personal data stored at /pds on a Fly.io volume. With the environment variables set above, the PDS will automatically restrict signups to be invite-only and rate-limit bad actors.
Creating an Account
A PDS is useless without any accounts so let's create our user account. To allow a new user to sign up, generate an invite code using the admin password from above:
curl -sS -X POST -H "content-type:application/json" -u "admin:YOUR_PDS_ADMIN_PASSWORD" -d '{"useCount":1}' https://pds.bossley.com/xrpc/com.atproto.server.createInviteCode
Before proceeding with account creation, you must decide what kind of DID the account should use. Every ATProto account is represented by a unique cryptographically signed user ID called a DID (Decentralized IDentifier) that tells other users and services that you are who you really say you are. There are currently two different kinds of DIDs in use, each with their own pros and cons:
- A
did:plcis a DID recorded and externally validated by a Public Ledger of Credentials (PLC). This is currently controlled by a single centralized authority, although there are attempts to make this more decentralized. If you don't care about decentalization and want your account up and running, adid:plcis by far the easiest DID to set up. - A
did:webis a DID intertwined to a specific domain which verifies your ownership. This provides true decentralization at the cost of irrevokably tying your account to that domain.
did:plc
If you choose to create a did:plc, the PDS will handle DID creation for you! Navigate to https://pds.bossley.com/account walks through creating an account with the invite code generated above. The account is now ready to use!
did:web
If you choose to create a did:web, you must set up the following TXT DNS record. It's good idea to do this first as this change can take hours to propagate.
Type | Host | Answer
TXT | _atproto.sam.bossley.com | did=did:web:sam.bossley.com
Next, a signing keypair needs to be generated for the handle and DID to claim each other. ATProto requires a multibase key format as a verification method. Generate a public and private key, saving the private key in a password manager:
goat key generate
Next you'll need to update our domain to point to our handle. Host a https://sam.bossley.com/.well-known/did.json file with the following contents:
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:web:sam.bossley.com",
"alsoKnownAs": [
"at://sam.bossley.com"
],
"verificationMethod": [
{
"id": "did:web:sam.bossley.com#atproto",
"type": "Multikey",
"controller": "did:web:sam.bossley.com",
"publicKeyMultibase": "MULTIBASE_PUBLIC_KEY"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.bossley.com"
}
]
}
You should also host an https://sam.bossley.com/.well-known/atproto-did file containing your DID. This isn't strictly necessary but can help reduce the amount of issues you run into as some ATProto applications look for this file:
# file must not contain a newline character
echo -n "did:web:sam.bossley.com" > .well-known/atproto-did
To confirm that both the DID and handle resolve to each other:
# from the DID, resolve the handle
curl -sS https://sam.bossley.com/.well-known/did.json | jq ".alsoKnownAs.[]"
# from handle, resolve the DID
curl -sS "https://pds.bossley.com/xrpc/com.atproto.identity.resolveHandle?handle=sam.bossley.com" | jq .did
To create the account you need a crypographically signed auth token. Generate a signed token:
goat account service-auth-offline \
--duration-sec 300 \
--lxm com.atproto.server.createAccount \
--iss did:web:sam.bossley.com \
--audience did:web:pds.bossley.com \
--atproto-signing-key MULTIBASE_PRIVATE_KEY
Then create an account with the signed token from the previous command:
goat account create \
--service-auth SIGNED_TOKEN \
--pds-host https://pds.bossley.com \
--existing-did did:web:sam.bossley.com \
--handle sam.bossley.com \
--invite-code INVITE_CODE \
--email EMAIL \
--password PASSWORD
The account is now created but in a deactivated state. To activate it, we need to login and update our verification methods:
goat account login \
--pds-host https://pds.bossley.com \
-u sam.bossley.com \
-p PASSWORD
goat account plc recommended | jq .verificationMethods.atproto
This returns the did:key ATProto is looking for. We need to update https://sam.bossley.com/.well-known/did.json once again with the new key:
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:web:sam.bossley.com",
"alsoKnownAs": [
"at://sam.bossley.com"
],
"verificationMethod": [
{
"id": "did:web:sam.bossley.com#atproto",
"type": "Multikey",
"controller": "did:web:sam.bossley.com",
"publicKeyMultibase": "NEW_MULTIBASE_DID_KEY"
},
{
"id": "did:web:sam.bossley.com#signing",
"type": "Multikey",
"controller": "did:web:sam.bossley.com",
"publicKeyMultibase": "MULTIBASE_PUBLIC_KEY"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.bossley.com"
}
]
}
It's now time to activate the account:
goat account activate
The final step is to verify the status of the account through a public relay. It took a few hours before I could effectively use my account because the relays were outdated.
goat relay account status did:web:sam.bossley.com
# force request popular relays to crawl the PDS:
curl -X POST -d '{"hostname":"pds.bossley.com"}' https://bsky.network/xrpc/com.atproto.sync.requestCrawl -H "content-type:application/json"
curl -X POST -d '{"hostname":"pds.bossley.com"}' https://relay.fire.hose.cam/xrpc/com.atproto.sync.requestCrawl -H "content-type:application/json"
curl -X POST -d '{"hostname":"pds.bossley.com"}' https://relay3.fr.hose.cam/xrpc/com.atproto.sync.requestCrawl -H "content-type:application/json"
curl -X POST -d '{"hostname":"pds.bossley.com"}' https://relay.upcloud.world/xrpc/com.atproto.sync.requestCrawl -H "content-type:application/json"
curl -X POST -d '{"hostname":"pds.bossley.com"}' https://atproto.africa/xrpc/com.atproto.sync.requestCrawl -H "content-type:application/json"
The ATProto account is now ready to use! All account management can be done through https://pds.bossley.com/account.
Conclusion
Setting up my PDS was a lot harder than I thought it would be. To give credit where credit is due, here are a few of the resources I used to set up my account:
- Self-hosting a Bluesky PDS and using your domain as your handle
- atproto pds self-hosting: some issues and antipatterns
- Embracing ATProto, part 1: Setting up a PDS
- pds-fly.io-template
- pds-on-fly
- Creating a did:web atproto account using goat
- Creating a did:web identity in ATProto
- Decentralized identity and atproto
- Creating a did-method-web Identity for ATProtocol
- bluesky-jp
- The AT Protocol SDK
- I Was Right About ATProto Key Management
I'm really excited for ATProto and the capabilities it could unlock on the internet but I wish the process was easier to follow. I will be using my ATProto account more often in the future so be sure to follow me!