Self-hosting Bluesky PDS Using Docker

I'm not usually a big user of social networks, but with my new blog, I wanted a way to share updates on social media. I went with Bluesky for its decentralized nature, which aligns with my interest in the growing trend of decentralized social platforms.

In this write-up, I will document the steps I took to install Bluesky’s PDS (Personal Data Server) on my setup, an Ubuntu server running Docker.

Thanks to this write-up, which was a huge help to do mine. While most of it was very useful, some parts differed since my Docker setup is different. Also, I encountered difficulties when trying to change my handle to the root domain, so I used a different method to achieve this. As a result, I hope this guide will provide additional value and be helpful to others.

Installing the PDS Using Docker

The starting point is the official bluesky compose.yaml. However, my environment already include other services, including an NGINX proxy with ACME Companion to automatically generate and renew TLS certificates for my http services (see Basic usage).

Therefore, I modify this compose.yaml to reuse my proxy setup to provide a reverse proxy for the PDS container, eliminating the need for the reverse proxy (Caddy) provided in the initial file. I also remove Watchtower, as I use my own scripts to handle image updates.

Finally, my compose.yaml is pretty simple file looks like this:

version: '3.9'
services:
  pds:
    container_name: pds
    image: ghcr.io/bluesky-social/pds:0.4
    network_mode: host
    restart: unless-stopped
    volumes:
      - type: bind
        source: <SOME PATH IN HOST>/pds # Change this for
        target: /pds
    env_file:
      - pds.env

Most of the setup is to adapt with environment variables in the environment file pds.env:

# PDS_HOSTNAME: The domain name of your PDS service (e.g., domain.com)
PDS_HOSTNAME=<YOUR DOMAIN>
# PDS_SERVICE_HANDLE_DOMAINS: A suffix for the domain to be used with the service (e.g., .domain.com)
PDS_SERVICE_HANDLE_DOMAINS=.<YOUR DOMAIN>
# PDS_JWT_SECRET: A secret key for signing JWT tokens, needed for secure authentication
PDS_JWT_SECRET=<SECRET HERE>
# PDS_ADMIN_PASSWORD: The admin password for accessing the PDS service (use a strong password generated by a password manager)
PDS_ADMIN_PASSWORD=<ADMIN PASSWORD>
# PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: A private key for cryptographic operations in hex format
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=<KEY HEX HERE>
# PDS_DATA_DIRECTORY: Directory where data will be stored (e.g., /pds)
PDS_DATA_DIRECTORY=/pds
# PDS_BLOBSTORE_DISK_LOCATION: Location for storing blob data (e.g., /pds/blocks)
PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks
# PDS_BLOB_UPLOAD_LIMIT: The maximum upload size for blobs in bytes (e.g., 50 MB = 52428800 bytes)
PDS_BLOB_UPLOAD_LIMIT=52428800
# PDS_EMAIL_SMTP_URL: The URL for the SMTP server used to send emails (e.g., for email verification)
PDS_EMAIL_SMTP_URL="smtp://<USERNAME>:<PASSWORD>@<SMTP HOSTNAME>"
# PDS_EMAIL_FROM_ADDRESS: The email address used to send emails (e.g., noreply@domain.com)
PDS_EMAIL_FROM_ADDRESS=<EMAIL FROM_ADDRESS>
# LOG_ENABLED: Set to 'true' to enable logging
LOG_ENABLED=true
# PDS_DID_PLC_URL: URL for the DID (Decentralized Identifier) PLC service
PDS_DID_PLC_URL=https://plc.directory
# PDS_BSKY_APP_VIEW_URL: URL for the BlueSky app view service
PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
# PDS_BSKY_APP_VIEW_DID: DID for the BlueSky app view service
PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
# PDS_REPORT_SERVICE_URL: URL for the report service
PDS_REPORT_SERVICE_URL=https://mod.bsky.app
# PDS_REPORT_SERVICE_DID: DID for the report service
PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
# PDS_CRAWLERS: URL for the crawlers to scrape data from (e.g., bsky.network)
PDS_CRAWLERS=https://bsky.network
# VIRTUAL_HOST: The virtual host for your PDS service (e.g., pds.domain.com)
VIRTUAL_HOST=<PDS HOSTNAME>
# VIRTUAL_PORT: The port number for the virtual host (e.g., 3000)
VIRTUAL_PORT=3000
# LETSENCRYPT_HOST: The host for generating a Let's Encrypt SSL certificate (e.g., pds.domain.com)
LETSENCRYPT_HOST=<PDS HOSTNAME>
# LETSENCRYPT_EMAIL: The email used for Let's Encrypt certificate registration (e.g., admin@domain.com)
LETSENCRYPT_EMAIL=<LETSENCRYPT EMAIL>

Most variable comments are self-explanatory, but here are some additional hints for a few of them.

To generate the PDS_JWT_SECRET , you can use the following command:

$ openssl rand --hex 16

For PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX, you can use

$ openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32

Since I run my own mail server on my domain, I use my SMTP server by specifying the information in the format smtp://<USERNAME>:<PASSWORD>@<SMTP HOSTNAME>. If you don't have your own mail server, as mentioned in the official documentation, you can use services like resend as an alternative.

As I mention before, as I have already have the NGINX proxy with ACME Companion set up, I only need to add the following lines to make the PDS container's port 3000 accessible on <PDS HOSTNAME> over HTTPS (e.g., https://pds.domain.com) through the reverse proxy:

VIRTUAL_HOST=<PDS HOSTNAME>
VIRTUAL_PORT=3000
LETSENCRYPT_HOST=<PDS HOSTNAME>
LETSENCRYPT_EMAIL=<LETSENCRYPT EMAIL>

The container is now ready to run. To start everything, use the following command to verify is everything looks find:

$ docker-compose up

Ensuring Everything the PDS Running as Expected

As directed in the documentation https://atproto.com/guides/self-hosting, to check that everything is online and working, you can visit https://<PDS HOSTNAME>>/xrpc/_health in your browser. You should see a JSON response with the version, like:

{"version":"0.2.2-beta.2"}

Additionally, you can use an online WebSocket tester (e.g. piehost.com) and entered the following URL: wss://<PDS HOSTNAME>/xrpc/com.atproto.sync.subscribeRepos?cursor=0 If everything is configured correctly, the test should indicate that the connection has been successfully established.

Get your Root Domain (e.g domain.com) as your Bluesky Handle

You cannot directly create a handle for the root domain (e.g., domain.com). Therefore, we need to create an account with a different handle (e.g., handle.domain.com) and then verify the root domain handle ownership using Decentralised Identifier (DID) . We can verify using DNS or HTTP server verification. It seems you can use either, but I did both to be sure and can't confirm if only one is enough.

Create account

Since the administrative tools are included in the Docker image, they do not need to be installed separately, as follows:

$ git clone https://github.com/bluesky-social/pds/ bluesky-pds
$ cd bluesky-pds/pds-admin

Then, create an account using a temporary handle (e.g. myhandle.)

$ PDS_ENV_FILE=/path/to/pds.env ./account.sh create <EMAIL ADDRESS> myhandle.<DOMAIN>
Account created successfully!
-----------------------------
Handle   : myhandle.<DOMAIN>
DID      : <DID>
Password : <PASSWORD>
-----------------------------

Make sure to record the handle, DID, and password for future use.

I should be able, at this step, to connect from the Bluesky client (e.g., https://bsky.app/) using your custom domain and verify your email address.

Go to https://bsky.app/ > Sign In >In Hosting Provider, select Custom > enter your <PDS HOSTNAME> (e.g., pds.domain.com) and then use your credentials (email/password) to log in.

Then, you should be connected and able to verify your email address in the account settings by receiving a code, if your SMTP setup is correct.

Now, to validate our root domain, we first need to set up a Domain Ownership Verification method to confirm that we own the domain for the handle we want (e.g., domain.com).

Domain Ownership Verification using DNS

To do the verification of the ownership of your domain using DNS add a DNS TXT record _atproto.<DOMAIN> with did=<DID>.

Domain Ownership Verification using HTTP

To do the verification of the ownership of your domain using HTTP you need make accecible a file on http server at <DOMAIN>/.well-known/atproto-did (e.g. https://domain.com/.well-known/atproto-did) that contain your <DID>.

As I already have an Nginx server running to serve your homepage, I simply added a file at the path /.well-known/atproto-did in the root directory of my site, containing my <DID> value running the following :

$ mkdir <WEB DATA PATH IN HOST>/.well-known
$ echo <DID> > <WEB DATA PATH IN HOST>/.well-known/atproto-did

For reference, here’s a sample Docker configuration for serving my homepage:

version: '2'
services:

  web:
    image: nginx
    container_name: nginx
    restart: always
    expose:
      - 80
    volumes:
      - <WEB DATA PATH IN HOST>:/usr/share/nginx/html:ro
    environment:
      - VIRTUAL_HOST=<DOMAIN>
      - LETSENCRYPT_HOST=<DOMAIN>
      - LETSENCRYPT_EMAIL=<LETSENCRYPT EMAIL>

Validation

To verify if the DNS or HTTP Domain Ownership Verification is set up correctly, you can check on https://bsky-debug.app/handle by entering your handle.

Update Handle

We can now update your handle to your root domain !

However, I encountered an issue when trying to verify my handle directly in the Bluesky app (https://bsky.app/). After logging into my account, I was unable to update my handle to the root domain due to errors. In Settings > Account > Change Handle > I have my own domain, selecting Verify DNS Record or Verify Text File would return an error.

However, I was able to use the Go AT protocol CLI tool (goat) to successfully update my handle as follow.

Installing goat (require the Go toolchain):

$ go install github.com/bluesky-social/indigo/cmd/goat@latest

Then, update your handle using the following commands:

$ goat account login -u <YOUR DID> -p <YOUR PASSWORD> --pds-host <PDS HOSTNAME>
$ goat account update-handle <YOUR DOMAIN>

Once connected to your Bluesky account, your handle should now display as your root domain.

Initiating a Crawl to Announce Your Instance

If everything is working, it's time to notify a relay about our instance by requesting a crawl using the admin tool we downloaded earlier.

$ PDS_ENV_FILE=/path/to/pds.env./request-crawl.sh
Requesting crawl from https://bsky.network
done

Finally

Once the handle update is complete, you can restart the server in detached mode with the command below.

$ docker-compose up -d

References