Self-hosting your PDS | jola.dev
Home
About
Blog
Newsletter
Projects
Talks
I take a lot of pleasure in running my own services. Self-hosting is a lot of fun and I keep accumulating more and more things on my server, from a feed reader to uptime monitoring (as backup to larm.dev), not to mention all of my toy projects.
I’ve been writing a lot about atproto recently and it’s frankly a very fertile area for self-hosting. Not only are there lots of open source components you can run, but the nature of atproto decentralization means that you can run your own services while seamlessly participating in shared systems. For example, you can run your own PDS (Personal Data Server) and create an account on it, and then log into Bluesky and post and participate with everyone else. Your account will work across the ATmosphere.
So, let’s talk about what it takes to self-host a PDS. Follow along and by the end of this you're posting on Bluesky from your own self-hosted account.
tranquil-pds
There are multiple PDS implementations out there. The original and probably most common one is the Bluesky reference PDS, but when I was researching options I came across one that matches the reference feature set but adds useful features on top: tranquil-pds. It’s implemented in Rust, comes with a nice admin panel, invite code management, passkeys, granular OAuth scopes, and a lot more.
There are plenty of instructions in the repo, but they’re a bit spread out, and some parts can get a bit tricky around wildcard certs, so I thought I’d write a bit of a getting started guide. This will be very similar to the setup I have for cove.town, with some simplifications.
There are a few things going on under the hood here that are worth highlighting:
The PDS holds the account repo, or in other words, all of your on-protocol data. This can be things like blog posts, Bluesky posts, standard reader reading history, book reviews, and much more. It also holds the blobs you upload, like images and videos.
Handles need a verification method, either through DNS or a web server that responds on .well-known/atproto-did. The PDS is built to handle this, but it requires getting the right DNS records and TLS certificates in place.
As long as you make backups and set up a rotation key, self-hosting your atproto account is fairly safe, you can recover from things like the PDS going down or accidentally deleting the database. So make backups.
Getting started
Before we get to the code, we need a few things.
A domain that you can add records to. You’re going to need to set up an A or CNAME wildcard record pointing at your server. For example, if your domain is example.com, the record would be *.example.com. This is because the accounts will get handles like alice.example.com. Alternatively, if you’re happy to add each subdomain manually, you can skip the wildcard record and just create pds.example.com.
A server with Docker or something similar installed. We’re going to use it to set up a compose. You can use alternatives like podman, but this guide will focus on Docker.
An account on atcr.io, the atproto container registry. Log in using docker login atcr.io or docker login atcr.io -u --password-stdin, using an app password.
Armed with those things, we’re ready to go.
TLS termination
A PDS needs to serve HTTPS traffic, and it needs to be able to serve that traffic on subdomains. That means the subdomains need TLS certificates issued. Caddy (unlike Traefik) comes with the ability to provision TLS certificates for domains on demand, so we’ll start by setting this up.
If you’ve opted not to use a DNS wildcard, and instead manually create each record for each account, you can skip the on_demand_tls and hardcode the subdomains instead.
Create a file called Caddyfile where you want the project.
on_demand_tls {
interval 2m
burst 5
*.example.com {
reverse_proxy pds:3000
tls {
on_demand
The PDS itself
This is mostly an adapted version of the example compose from the Tangled repo, with the configuration moved into the environment section. Create compose.yml with this content.
services:
pds:
image: atcr.io/tranquil.farm/tranquil-pds:latest
restart: always
environment:
SERVER_HOST: "[::]"
PDS_HOSTNAME: pds.example.com
PDS_USER_HANDLE_DOMAINS: example.com
INVITE_CODE_REQUIRED: "true"
DATABASE_URL: postgres://tranquil_pds:${POSTGRES_PASSWORD}@db:5432/pds
JWT_SECRET: ${JWT_SECRET}
MASTER_KEY: ${MASTER_KEY}
DPOP_SECRET: ${DPOP_SECRET}
DISABLE_ACCOUNT_VERIFICATION_GATE: "true"
volumes:
- blob_data:/var/lib/tranquil-pds/blobs
depends_on: [db]
db:
image: postgres:18-alpine
restart: always
environment:
POSTGRES_USER: tranquil_pds
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: pds
volumes:
- pg_data:/var/lib/postgresql
caddy:
image: caddy:2
restart: always
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
volumes:
blob_data:
pg_data:
caddy_data:
You’ll want to...