Self-Hosting Your Bluesky PDS with Podman
Table of Contents
Bluesky runs on the AT Protocol, which means your posts, follows, and identity don’t have to live on Bluesky’s servers. You can host a Personal Data Server yourself and your account lives there instead — if Bluesky the company disappears tomorrow, your data doesn’t go with it. Your handle can also be your own domain, which is a small thing that somehow makes the whole setup feel more permanent.
Most guides assume you’re running Docker. I’m running Podman, and while the differences are minor, they’re the kind of thing that wastes 30 minutes if you’re not expecting them.
Why Bother? #
You already own the domain. You’re already running a homelab. Hosting your own PDS is maybe 20 minutes of setup and then it just runs. Your posts live in a directory on your server, not in someone else’s database, and if you ever want to move to a different PDS you can — your handle and social graph come with you.
There’s also something to be said for not having your identity tied to a platform’s continued existence. Bluesky has been solid so far despite its…eccentricities, but so was Twitter until it wasn’t.
What You’ll Need #
- A VPS or server with a public IP — Bluesky needs to reach it from the outside, so a home server behind a typical consumer NAT won’t work without extra steps
- A domain name you control
- Podman and podman-compose on your server
- A free Resend account for transactional email — account verification requires working SMTP
DNS Setup #
Two records, both pointing at your server’s IP:
| Type | Name | Value |
|---|---|---|
| A | pds.yourdomain.com |
your server IP |
| A | *.pds.yourdomain.com |
your server IP |
The wildcard is not optional. Each account on your PDS gets its own subdomain handle and they all need to resolve. Skip it and account creation will fail in a way that isn’t immediately obvious.
DNS can take a few minutes to propagate. Good time to do the next step.
Install Podman and podman-compose #
On Debian/Ubuntu:
sudo apt update && sudo apt install -y podman podman-compose
Fedora and RHEL-based distros have both in the default repos. Confirm they’re working before moving on:
podman --version
podman-compose --version
The Compose File #
Create a directory for your config:
mkdir ~/pds && cd ~/pds
Create compose.yaml:
services:
pds:
container_name: pds
image: ghcr.io/bluesky-social/pds:${PDS_VERSION}
restart: unless-stopped
volumes:
- ./data:/pds:Z
env_file:
- .env
environment:
PDS_HOSTNAME: ${PDS_HOSTNAME}
PDS_JWT_SECRET: ${PDS_JWT_SECRET}
PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD}
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX}
PDS_DATA_DIRECTORY: /pds
PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks
PDS_EMAIL_SMTP_URL: smtps://resend:${RESEND_API_KEY}@smtp.resend.com:465
PDS_EMAIL_FROM_ADDRESS: ${PDS_EMAIL_FROM_ADDRESS}
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
PDS_BLOB_UPLOAD_LIMIT: 52428800
LOG_ENABLED: true
caddy:
image: caddy:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:Z
- caddy_data:/data:Z
- caddy_config:/config:Z
volumes:
caddy_data:
caddy_config:
The :Z on volume mounts is a Podman thing — it tells the container runtime to relabel the directory for SELinux. On Debian/Ubuntu you can leave it off without issue. On Fedora or anything RHEL-based, you’ll want it or you’ll get permission errors that don’t make sense at first glance.
If you already have a reverse proxy set up (Nginx Proxy Manager, Traefik, whatever), just drop the caddy service, expose port 3000 on the PDS service, and point your existing proxy at it. Personally, I use Nginx Proxy Manager but that’s more out of habit than an endorsement.
Caddyfile #
Create Caddyfile in the same ~/pds directory:
pds.yourdomain.com, *.pds.yourdomain.com {
reverse_proxy pds:3000
}
Caddy handles TLS automatically via Let’s Encrypt. Nothing else to configure.
Environment Variables #
Generate the two random secrets first:
openssl rand -hex 32 # run this twice
Create .env:
PDS_VERSION=0.4.204
PDS_HOSTNAME=pds.yourdomain.com
PDS_JWT_SECRET=<first_random_hex>
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=<second_random_hex>
PDS_ADMIN_PASSWORD=<a_strong_password>
PDS_RESEND_API_KEY=<your_resend_api_key>
PDS_EMAIL_FROM_ADDRESS=[email protected]
Add .env to .gitignore if this directory is tracked anywhere.
Email with Resend #
PDS requires email verification for account creation. Resend’s free tier handles this without any issues.
- Sign up at resend.com
- Add your PDS domain and complete the DNS verification
- Create an API key
- Drop the API key into
PDS_RESEND_API_KEY
The DNS verification for Resend can take a few minutes — do it while the containers are starting.
Start It Up #
cd ~/pds
podman-compose up -d
Check the logs to make sure everything came up cleanly:
podman-compose logs -f
Once it settles, go to https://pds.yourdomain.com in your browser. You’ll see a simple landing page — that’s all it shows, and that’s correct.
Creating Your Account #
Go to bsky.app, click Create account, and switch the hosting provider at the top from the default to Custom. Enter your PDS address.
You need an invite code to continue. Two ways to get one:
Via the web tool — go to ezsh.link/pds-invite, enter your PDS endpoint and admin password, click Generate. The request goes directly from your browser. Granted, it’s a trust me bro situation, but I’ve used it and the author says they don’t save info.
Via the terminal:
PDS_ADMIN_PASSWORD="your_admin_password"
curl --silent \
--request POST \
--header "Content-Type: application/json" \
--user "admin:${PDS_ADMIN_PASSWORD}" \
--data '{"useCount": 1}' \
"https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" \
| jq -r '.code'
Paste the code into the sign-up form, fill in your email and password, and pick a username. Your PDS domain is baked into the handle by default — you.pds.yourdomain.com. You can change it to something cleaner once the account is set up.
After sign-up, go to Settings → Account → Verify email and confirm your address. If Resend is configured correctly the email shows up quickly.
Cleaner Handle #
To use your own domain as your handle — @yourdomain.com instead of the PDS subdomain:
- Settings → Account → Handle → I have my own domain
- Bluesky gives you a TXT record to add at your DNS provider
- Add the record, wait a few minutes, click Verify
Worth doing. It makes the whole thing feel less like a dev environment.
Maintenance #
Updates — the PDS image moves fast. Pull updates regularly:
cd ~/pds
podman-compose pull && podman-compose up -d
Firewall — make sure ports 80 and 443 are actually open:
# ufw
sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
# firewalld
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload
Auto-start on reboot — Podman doesn’t have a background daemon watching containers. The quick solution is a @reboot cron entry. For anything more permanent, Podman Quadlets let you manage containers as proper systemd services, which is the right way to do it on a server you actually care about.
Backup and Restore #
The PDS uses SQLite internally, which means you can’t just rsync the data directory while the container is running and call it a day. I’ve tried this and it made me so angry I put off restoration for over a month (which is saying something because I don’t anger easily). If a write lands mid-copy you’ll end up with a corrupt database and a bad time. The cleanest solution for a personal setup is to stop the containers briefly, archive everything, and bring them back up. It’s down for a few seconds at most.
Backup Script #
Save this as ~/pds/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-$HOME/pds-backups}"
PDS_DIR="$(cd "$(dirname "$0")" && pwd)"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
ARCHIVE="$BACKUP_DIR/pds-$TIMESTAMP.tar.gz"
KEEP_DAYS="${KEEP_DAYS:-7}"
mkdir -p "$BACKUP_DIR"
echo "Stopping PDS..."
cd "$PDS_DIR"
podman-compose stop
echo "Archiving..."
tar -czf "$ARCHIVE" \
-C "$PDS_DIR" \
data/ \
.env \
compose.yaml \
Caddyfile
echo "Starting PDS..."
podman-compose start
echo "Pruning backups older than $KEEP_DAYS days..."
find "$BACKUP_DIR" -name "pds-*.tar.gz" -mtime +$KEEP_DAYS -delete
echo "Done: $ARCHIVE"
Make it executable:
chmod +x ~/pds/backup.sh
Run it manually once to make sure it works before handing it off to cron:
~/pds/backup.sh
To run it daily at 3am, add a cron entry:
crontab -e
0 3 * * * /home/youruser/pds/backup.sh >> /home/youruser/pds/backup.log 2>&1
The .env file is included deliberately — it holds your JWT secret and PLC rotation key. If your server dies and you’re restoring to a new machine, you need those values or the account data is unreadable. I learned this the hard way so that you don’t have to. Back up the archive somewhere off the server: a remote machine, S3, whatever you use.
Restore #
To restore on the same machine or a fresh one:
# Stop everything if it's running
cd ~/pds && podman-compose down
# Clear out the existing data directory
rm -rf ~/pds/data
# Extract the backup
tar -xzf /path/to/pds-20260317-030001.tar.gz -C ~/pds
# Start it back up
cd ~/pds && podman-compose up -d
If you’re restoring to a new server, make sure DNS is pointed at the new IP and Podman is installed before running the last line. Everything else — the TLS certs, account data, blobs — comes out of the archive.