Setting up a self-hosted PDS

To get started, I followed Casey's excellent post. I experienced a moment of wonder when things almost just worked. In total it took me about 30 minutes until my Personal Data Store was running on my own server. That's really a testament to the ATproto developers' dedication. Thank you!

There were a couple of things I needed to tweak further, but for completeness' sake, I'll start from the beginning.

The setup

I have a personal webpage running at https://j23n.com. These are just plain HTML files served by nginx. I want nginx to forward the relevant requests to my PDS but keep my static page as-is.

Installing the PDS

The full instructions are found here.

Installation is as easy as getting the install script and executing it:

curl https://raw.githubusercontent.com/bluesky-social/pds/main/installer.sh >installer.sh
sudo bash installer.sh

It's going to install fine but then eventually, when it asks you to create a user account, it's going to error out

curl: (22) The requested URL returned error: 404

That's because the installer creates a docker compose file like this

version: '3.9'
services:
caddy:
container_name: caddy
image: caddy:2
network_mode: host
depends_on:
- pds
restart: unless-stopped
volumes:
[...]
pds:
container_name: pds
image: ghcr.io/bluesky-social/pds:0.4
network_mode: host
restart: unless-stopped
volumes:
[...]
env_file:
- /pds/pds.env
watchtower:
container_name: watchtower
image: containrrr/watchtower:latest
network_mode: host
volumes:
[...]
restart: unless-stopped
environment:
[...]

where caddy is acting as a reverse-proxy that tries to bind to ports 80 and 443 of the host. But that's where nginx is already bound. Our PDS is unreachable and the attempt to create a user account fails. Let's fix that.

First, stop the running docker images

cd /pds && docker compose down

Docker compose file

We'll use our existing nginx to forward requests to our PDS container, so we don't need caddy. Watchtower is a neat service that will auto-update our running docker images (at midnight) so we're always up to date.

version: '3.9'
services:
pds:
container_name: pds
image: ghcr.io/bluesky-social/pds:0.4
restart: unless-stopped
volumes:
- type: bind
source: /pds
target: /pds
ports:
- '6010:3000'
env_file:
- /pds/pds.env
watchtower:
container_name: watchtower
image: containrrr/watchtower:latest
network_mode: host
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
restart: unless-stopped
environment:
WATCHTOWER_CLEANUP: true
WATCHTOWER_SCHEDULE: "@midnight"

Awesome, let's start it.

cd /pds && docker compose up -d

Double check that everything is as it should be by checking the logs

root@j23n.com:/pds# docker logs watchtower
time="2025-09-28T10:21:17Z" level=info msg="Watchtower 1.7.1"
time="2025-09-28T10:21:17Z" level=info msg="Using no notifications"
time="2025-09-28T10:21:17Z" level=info msg="Checking all containers (except explicitly disabled with label)"
time="2025-09-28T10:21:17Z" level=info msg="Scheduling first run: 2025-09-29 00:00:00 +0000 UTC"
time="2025-09-28T10:21:17Z" level=info msg="Note that the first check will be performed in 13 hours, 38 minutes, 42 seconds"
root@j23n.com:/pds# docker logs pds
{"level":30,"time":1759054870607,"pid":7,"hostname":"b9c1c219868b","name":"pds","msg":"pds has started"}

Everything seems to be working. Lovely. Next up, nginx!

Nginx configuration

Now, we need to connect nginx to our PDS container. There are a few routes that need forwarding

/.well-known/atproto-did - this is where your DID lives

/xrpc - this is the main API appviews (websites) will use to interact with you PDS

There are also a bunch of oauth routes that need forwarding if you want to e.g. sign into leaflet using your self-hosted PDS

/.well-known/oauth-protected-resource

/.well-known/oauth-authorization-server

/oauth

/@atproto

Putting all of that together, your nginx config should look something like this

server {
# You server configuration
[...]

location /xrpc {
proxy_pass http://localhost:6010;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}

location ~ ^/(\.well-known/(atproto-did|oauth-protected-resource|oauth-authorization-server)|oauth|@atproto) {
proxy_pass http://localhost:6010;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}

Let's make sure it works

nginx -c /etc/nginx/nginx.conf -t

And then restart nginx

systemctl restart nginx

Let's try creating a user account again

pdsadmin account create mail@j23n.com hello.j23n.com

Account created successfully!
-----------------------------
Handle : hello.j23n.com
DID : did:plc:enau5rzvrui4fx4dq5icgtle
Password : <password>
-----------------------------
Save this password, it will not be displayed again.

Progress!

However, when I tried to log into leaflet, I got a cryptic error about authentication (that I failed to screenshot). Digging into the it:

curl https://hello.j23n.com/oauth
curl: (60) SSL: no alternative certificate subject name matches target host name 'hello.j23n.com'

SSL certificate

I had create an SSL certificate with certbot for j23n.com and www.j23n.com but not included a wildcard. Never fear, we'll update the certificate

certbot -d *.j23n.com -d j23n.com --preferred-challenge dns --manual certonly

I had to use the DNS challenge, which meant adding a TXT entry to my DNS records, the certbot output explains that well.

Requesting a certificate for *.j23n.com and j23n.com

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please deploy a DNS TXT record under the name:

_acme-challenge.j23n.com.

with the following value:

DMYlroz8d6r_CNLG9iz9AhD-xTwfMImolbdKZkWA3Bg

Before continuing, verify the TXT record has been deployed. Depending on the DNS
provider, this may take some time, from a few seconds to multiple minutes. You can
check if it has finished deploying with aid of online tools, such as the Google
Admin Toolbox: https://toolbox.googleapps.com/apps/dig/#TXT/_acme-challenge.j23n.com.
Look for one or more bolded line(s) below the line ';ANSWER'. It should show the
value(s) you've just added.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Press Enter to Continue

My DNS settings propagate super fast (thanks Porkbun?) so no hold up there.

And with all of that, I was finally up and running and writing this very blog post, which is about to be stored in my very own PDS.