This guide covers running a Bluesky PDS (Personal Data Server) on macOS using Docker Desktop and Cloudflare Tunnel for public access.
The standard PDS distribution targets Linux VPS deployments. This setup adapts it for macOS with:
- Docker Desktop instead of native Docker
- Cloudflare Tunnel instead of Caddy (handles TLS, no port forwarding needed)
~/pdsdata directory instead of/pds- launchd instead of systemd for service management
Internet → Cloudflare Edge → cloudflared tunnel → localhost:3000 → PDS container
- macOS (tested on macOS 15.3.1 / Apple Silicon)
- Docker Desktop
- Homebrew
- A domain managed by Cloudflare
# Install cloudflared
brew install cloudflared
# Run the setup script
./setup.macos.shThen configure Cloudflare Tunnel (see below).
| File | Purpose |
|---|---|
compose.macos.yaml |
Docker Compose for macOS (PDS + Watchtower, no Caddy) |
setup.macos.sh |
Interactive setup script |
pdsadmin.macos.sh |
macOS-compatible admin CLI |
mkdir -p ~/pds/blocks
chmod 700 ~/pdscat <<EOF > ~/pds/pds.env
PDS_HOSTNAME=pds.yourdomain.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 2>/dev/null | tail -c +8 | head -c 32 | xxd -p -c 32)
PDS_DATA_DIRECTORY=/pds
PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks
PDS_BLOB_UPLOAD_LIMIT=104857600
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
EOFSave the PDS_ADMIN_PASSWORD value - you'll need it for admin operations.
PDS_DATA_DIR=~/pds docker compose -f compose.macos.yaml up -d# Login to Cloudflare
cloudflared tunnel login
# Create tunnel
cloudflared tunnel create pds
# Note the tunnel ID (e.g., e435494d-e8f0-4ce4-b107-628f2201ac1f)Create ~/.cloudflared/config.yml:
tunnel: <YOUR_TUNNEL_ID>
credentials-file: /Users/<username>/.cloudflared/<YOUR_TUNNEL_ID>.json
ingress:
- hostname: pds.yourdomain.com
service: http://localhost:3000
- hostname: "*.pds.yourdomain.com"
service: http://localhost:3000
- service: http_status:404Add DNS routes:
cloudflared tunnel route dns pds pds.yourdomain.com
cloudflared tunnel route dns pds "*.pds.yourdomain.com"Create ~/Library/LaunchAgents/com.cloudflare.cloudflared-pds.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cloudflare.cloudflared-pds</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/cloudflared</string>
<string>tunnel</string>
<string>--config</string>
<string>/Users/<username>/.cloudflared/config.yml</string>
<string>run</string>
<string>pds</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/<username>/Library/Logs/cloudflared-pds.log</string>
<key>StandardErrorPath</key>
<string>/Users/<username>/Library/Logs/cloudflared-pds.log</string>
</dict>
</plist>Load the service:
launchctl load ~/Library/LaunchAgents/com.cloudflare.cloudflared-pds.plistcurl https://pds.yourdomain.com/xrpc/_health
# Should return: {"version":"0.4.xxx"}./pdsadmin.macos.sh account create [email protected] yourhandle.pds.yourdomain.comSave the generated password - you'll use it to log in at bsky.app.
To allow handles like username.yourdomain.com instead of username.pds.yourdomain.com:
-
Add to
~/.cloudflared/config.yml:ingress: - hostname: pds.yourdomain.com service: http://localhost:3000 - hostname: "*.pds.yourdomain.com" service: http://localhost:3000 - hostname: "*.yourdomain.com" service: http://localhost:3000 - service: http_status:404
-
Add DNS route:
cloudflared tunnel route dns pds "*.yourdomain.com" -
Add to
~/pds/pds.env:PDS_SERVICE_HANDLE_DOMAINS=.yourdomain.com,.pds.yourdomain.com -
Restart services:
PDS_DATA_DIR=~/pds docker compose -f compose.macos.yaml down PDS_DATA_DIR=~/pds docker compose -f compose.macos.yaml up -d launchctl unload ~/Library/LaunchAgents/com.cloudflare.cloudflared-pds.plist launchctl load ~/Library/LaunchAgents/com.cloudflare.cloudflared-pds.plist
Note: Wildcard subdomains (*.yourdomain.com) require either:
- Cloudflare's free Universal SSL (covers one level:
*.yourdomain.com) - Cloudflare Advanced Certificate (covers two levels:
*.pds.yourdomain.com)
Cloudflare's free Universal SSL covers:
yourdomain.com*.yourdomain.com(one level of subdomain)
For two-level subdomains like *.pds.yourdomain.com, you need an Advanced Certificate:
- Cloudflare Dashboard → SSL/TLS → Edge Certificates
- Order Advanced Certificate
- Add hostnames:
pds.yourdomain.comand*.pds.yourdomain.com
# List accounts
./pdsadmin.macos.sh account list
# Create account
./pdsadmin.macos.sh account create [email protected] handle.yourdomain.com
# Create invite code (for others to sign up)
./pdsadmin.macos.sh create-invite-code
# Reset password
./pdsadmin.macos.sh account reset-password <did>
# Delete account
./pdsadmin.macos.sh account delete <did>
# Request crawl from relay
./pdsadmin.macos.sh request-crawl# Start
PDS_DATA_DIR=~/pds docker compose -f compose.macos.yaml up -d
# Stop
PDS_DATA_DIR=~/pds docker compose -f compose.macos.yaml down
# View logs
docker logs -f pds
# Restart PDS only
PDS_DATA_DIR=~/pds docker compose -f compose.macos.yaml restart pds# Start service
launchctl load ~/Library/LaunchAgents/com.cloudflare.cloudflared-pds.plist
# Stop service
launchctl unload ~/Library/LaunchAgents/com.cloudflare.cloudflared-pds.plist
# View logs
tail -f ~/Library/Logs/cloudflared-pds.log
# Run manually (for debugging)
cloudflared tunnel run pdsPeriodically backup your data directory:
cp -r ~/pds ~/pds-backup-$(date +%Y%m%d)Watchtower automatically updates the PDS container nightly. To manually update:
PDS_DATA_DIR=~/pds docker compose -f compose.macos.yaml pull
PDS_DATA_DIR=~/pds docker compose -f compose.macos.yaml up -dTo pin a specific version, edit compose.macos.yaml:
image: ghcr.io/bluesky-social/pds:0.4.136 # instead of :0.4# Health check
curl -s https://pds.yourdomain.com/xrpc/_health
# Container status
docker ps
# Disk usage
du -sh ~/pdsSystem Settings → Energy → Prevent automatic sleeping when the display is off
Docker Desktop → Settings → General → Start Docker Desktop when you sign in
- Your profile/posts become temporarily unavailable
- You can't post or interact
- When back online, everything resumes automatically
- No data is lost (stored locally in
~/pds)
To enable email verification and password reset emails, add to ~/pds/pds.env:
PDS_EMAIL_SMTP_URL=smtps://resend:<api-key>@smtp.resend.com:465/
[email protected]
Works with Resend, SendGrid, Mailgun, or any SMTP server. Restart PDS after changes.
# Check container is running
docker ps | grep pds
# Check tunnel is connected
tail ~/Library/Logs/cloudflared-pds.log
# Test local connection
curl http://localhost:3000/xrpc/_health- Ensure DNS CNAME records point to tunnel
- Check SSL certificate covers the subdomain
- Verify tunnel config includes the hostname
- Add domain to
PDS_SERVICE_HANDLE_DOMAINSin~/pds/pds.env - Restart PDS with
docker compose down && up -d
- Keep
~/pds/pds.envprivate (contains admin password and secrets) - Invite codes are required to create accounts (not open registration)
- Cloudflare provides DDoS protection
- All traffic is encrypted via Cloudflare's TLS