Switch from containrrr/watchtower to ghcr.io/nicholas-fedor/watchtower to fix Docker API version mismatch crashloop. Update README with pds.env management, new variables, and S3/SMTP config instructions. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com>
atproto-pds-ansible
Ansible playbook for deploying a self-hosted Bluesky PDS with Caddy (TLS) and Watchtower (auto-updates).
What it deploys
- PDS — Bluesky Personal Data Server (
ghcr.io/bluesky-social/pds:0.4) - Caddy — Reverse proxy with automatic TLS via Let's Encrypt
- Watchtower — Pulls new PDS image versions hourly and recreates containers
Requirements
- A server running Ubuntu 20.04/22.04/24.04 or Debian 11/12/13
- SSH access as the
sysadminuser (with sudo privileges) - A domain pointed at the server (A record for the hostname + wildcard
*.subdomain for handles) - Ports 80 and 443 open to the internet
- Ansible installed locally
Setup
cp inventory.yml.example inventory.yml # set your server IP
cp vars/main.yml.example vars/main.yml # set your hostname and admin email
Edit inventory.yml:
all:
hosts:
pds:
ansible_host: 203.0.113.10
Edit vars/main.yml:
pds_hostname: bsky.example.com
pds_admin_email: you@example.com
Deploy
ansible-playbook playbook.yml
What it does
First run: the official PDS installer bootstraps Docker, generates secrets, and creates the initial database. Ansible then reads the generated secrets from pds.env, templates pds.env, compose.yaml, and Caddyfile, and runs docker compose up -d.
Subsequent runs: the installer is skipped (guarded by account.sqlite). Ansible reads the existing secrets from pds.env and re-templates all config files. Changes only trigger a restart if content actually changed.
Variables
| Variable | Required | Default | Description |
|---|---|---|---|
pds_hostname |
yes | — | Public DNS name for the PDS |
pds_admin_email |
yes | — | Admin contact email (used by Caddy for Let's Encrypt) |
pds_data_dir |
no | /pds |
Data directory (must be /pds — the installer enforces this) |
pds_image_tag |
no | 0.4 |
PDS Docker image tag |
pds_blob_upload_limit |
no | 104857600 |
Max blob upload size in bytes |
pds_rate_limits_enabled |
no | true |
Enable rate limiting |
pds_invite_required |
no | true |
Require invite codes to create accounts |
pds_blobstore_type |
no | disk |
Blob storage backend (disk or s3) |
pds_email_smtp_url |
no | — | SMTP URL for email verification |
pds_email_from_address |
no | — | From address for outgoing emails |
Auto-updates
Watchtower checks every hour (WATCHTOWER_POLL_INTERVAL=3600) for new 0.4.x images on ghcr.io/bluesky-social/pds:0.4. When a new image is found, it pulls it and recreates the container. Old images are cleaned up automatically.
Post-deploy
Check that the PDS is healthy:
curl https://your-hostname/xrpc/_health
Create an account:
pdsadmin account create
Notes
pds.envis Ansible-managed. Secrets (JWT, admin password, PLC rotation key) are generated by the installer on first run, then read and preserved by Ansible on subsequent runs. To change config like S3 or SMTP, updatevars/main.ymland re-run the playbook.- The installer creates a
pds.servicesystemd unit. It's left in place — it just runsdocker compose up -don boot. Docker'srestart: unless-stoppedpolicy also handles this independently.