perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 469 lines 16 kB view raw view rendered
1# Deployment 2 3This document describes a generic single-node `perlsky` deployment behind a reverse proxy with TLS. 4 5## Requirements 6 7- A public host name for the PDS, for example `pds.example.com` 8- DNS for that host name pointing at your server 9- Perl 5.34+ on the server 10- SQLite and filesystem storage 11- A reverse proxy that can terminate TLS and proxy to a localhost HTTP listener 12- Optional but recommended: a process supervisor such as `systemd` 13 14## Layout 15 16A simple layout that works well in production is: 17 18- app checkout: `/opt/perlsky/app` 19- local Perl dependencies: `/opt/perlsky/local` 20- launcher: `/opt/perlsky/bin/run` 21- config: `/etc/perlsky/perlsky.json` 22- mutable data: `/var/lib/perlsky` 23 24## Install 25 26Clone the repo onto the server: 27 28```sh 29git clone https://github.com/aliceisjustplaying/perlsky.git /opt/perlsky/app 30``` 31 32Install the runtime dependencies that are easiest to obtain from the OS: 33 34```sh 35apt-get update 36apt-get install -y cpanminus libcbor-xs-perl libcryptx-perl libdbd-sqlite3-perl libio-socket-ssl-perl jq 37``` 38 39Install Mojolicious into an app-local library so the deployed runtime matches the repo expectation: 40 41```sh 42cd /opt/perlsky/app 43cpanm --notest --local-lib-contained /opt/perlsky/local Mojolicious@9.42 44``` 45 46`IO::Socket::SSL` is required for `did:plc` account creation and crawler calls to `https://` endpoints. 47 48## Config 49 50Create `/etc/perlsky/perlsky.json`: 51 52```json 53{ 54 "host": "127.0.0.1", 55 "port": 7755, 56 "base_url": "https://pds.example.com", 57 "hostname": "pds.example.com", 58 "service_did_method": "did:web", 59 "service_handle_domain": "example.com", 60 "invite_code_required": false, 61 "account_did_method": "did:plc", 62 "plc_rotation_private_key_hex": "REPLACE_WITH_64_HEX_CHARS", 63 "jwt_secret": "REPLACE_WITH_A_RANDOM_SECRET", 64 "admin_password": "REPLACE_WITH_A_RANDOM_SECRET", 65 "metrics_token": "REPLACE_WITH_A_RANDOM_SECRET", 66 "sentry_dsn": "https://PUBLIC_KEY@o0.ingest.sentry.io/0", 67 "bsky_appview_url": "https://api.bsky.app", 68 "bsky_appview_did": "did:web:api.bsky.app", 69 "chat_service_url": "https://api.bsky.chat", 70 "chat_service_did": "did:web:api.bsky.chat", 71 "crawlers": ["https://bsky.network"], 72 "crawler_notify_interval": 1200, 73 "data_dir": "/var/lib/perlsky/data", 74 "db_path": "/var/lib/perlsky/perlsky.sqlite" 75} 76``` 77 78Important fields: 79 80- `base_url`: the public HTTPS origin for the PDS 81- `hostname`: the host relays should crawl 82- `service_handle_domain`: the suffix used for local handles 83- `jwt_secret`: required; the server now refuses to start if it is missing or still set to the old `perlsky-dev-secret` fallback 84- `sentry_dsn`: optional; when set, perlsky reports unhandled XRPC exceptions to Sentry with request context and Perl stack frames 85- `base_url` also drives the built-in ATProto OAuth provider metadata and endpoints, so it must be the same public origin that third-party clients will use for login 86- If you want users like `alice.pds.example.com`, set `service_handle_domain` to `pds.example.com`, not `example.com`. 87- Public handle resolution for `alice.pds.example.com` also requires wildcard DNS for `*.pds.example.com` and a reverse proxy/TLS setup that will answer those subdomains. 88- `invite_code_required`: if true, `createAccount` requires a valid invite code 89- `account_did_method`: set to `did:plc` if you want PLC-backed user DIDs 90- `plc_rotation_private_key_hex`: required for `did:plc` account creation 91- `bsky_appview_*` / `chat_service_*`: upstream AppView and chat services for unknown `app.bsky.*` and `chat.bsky.*` calls. The public Bluesky services are the normal defaults. 92- `crawlers`: relay/crawler origins to notify after repo activity 93 94## Launcher 95 96Create a small launcher script such as `/opt/perlsky/bin/run`: 97 98```sh 99#!/bin/sh 100set -eu 101 102ARCHNAME=$(/usr/bin/perl -MConfig -e 'print $Config{archname}') 103export PATH=/opt/perlsky/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 104export PERL5LIB=/opt/perlsky/local/lib/perl5:/opt/perlsky/local/lib/perl5/$ARCHNAME 105export PERLSKY_CONFIG=/etc/perlsky/perlsky.json 106 107exec /usr/bin/perl /opt/perlsky/app/script/perlsky daemon -l http://127.0.0.1:7755 108``` 109 110Mark it executable: 111 112```sh 113chmod 755 /opt/perlsky/bin/run 114``` 115 116## systemd 117 118An example unit: 119 120```ini 121[Unit] 122Description=perlsky ATProto PDS 123After=network-online.target 124Wants=network-online.target 125 126[Service] 127Type=simple 128Environment=MOJO_MODE=production 129User=perlsky 130Group=perlsky 131WorkingDirectory=/opt/perlsky/app 132ExecStart=/opt/perlsky/bin/run 133Restart=on-failure 134RestartSec=5 135NoNewPrivileges=true 136PrivateTmp=true 137ProtectSystem=full 138ProtectHome=true 139ReadWritePaths=/var/lib/perlsky 140 141[Install] 142WantedBy=multi-user.target 143``` 144 145Then: 146 147```sh 148systemctl daemon-reload 149systemctl enable --now perlsky 150``` 151 152`MOJO_MODE=production` is recommended so unexpected exceptions return ordinary HTTP 500 responses instead of Mojolicious development debug pages. 153 154## Reverse Proxy 155 156Expose `perlsky` through a TLS-capable reverse proxy to `127.0.0.1:7755`. 157 158If `service_handle_domain` is a subdomain suffix such as `pds.example.com`, your proxy must answer both: 159 160- `pds.example.com` 161- `*.pds.example.com` 162 163That is what allows external PDSes to resolve `https://alice.pds.example.com/.well-known/atproto-did`. 164 165A minimal Caddy site looks like: 166 167```caddy 168pds.example.com { 169 encode gzip 170 reverse_proxy 127.0.0.1:7755 { 171 transport http { 172 keepalive off 173 } 174 } 175} 176``` 177 178If you run `perlsky` behind Caddy using the single-process `script/perlsky daemon` 179listener shown above, disable Caddy's upstream keepalive reuse for that backend. 180The Mojolicious daemon closes idle backend sockets after a short timeout, and Caddy 181can otherwise reuse a stale upstream connection and surface intermittent `502` 182responses on requests such as `com.atproto.server.createSession`. If you use a 183different proxy, make sure its upstream keepalive behavior and idle timeouts are 184compatible with the backend, or disable upstream reuse there as well. 185 186For public user handles you also need a matching wildcard-capable site or on-demand TLS path for `*.pds.example.com`. 187 188One practical Caddy pattern is on-demand TLS restricted to domains that `perlsky` approves: 189 190```caddy 191{ 192 on_demand_tls { 193 ask http://127.0.0.1:7755/_allow-cert 194 } 195} 196 197pds.example.com { 198 encode gzip 199 reverse_proxy 127.0.0.1:7755 { 200 transport http { 201 keepalive off 202 } 203 } 204} 205 206https:// { 207 tls { 208 on_demand 209 } 210 211 @perlsky_handles host *.pds.example.com 212 handle @perlsky_handles { 213 encode gzip 214 reverse_proxy 127.0.0.1:7755 { 215 transport http { 216 keepalive off 217 } 218 } 219 } 220} 221``` 222 223`com.atproto.sync.getBlob` responses should stay uncompressed end-to-end. `perlsky` now bypasses Mojolicious dynamic gzip for blob bytes because some downstream image proxy routes will auto-decompress the body and accidentally forward a stale `Content-Encoding` header, which shows up in clients as broken image loads (`ERR_CONTENT_DECODING_FAILED`). If your reverse proxy also does response compression, exempt `/xrpc/com.atproto.sync.getBlob` from it as well. 224 225For Caddy that means putting the blob path on a plain proxy path before any `encode` handler, for example: 226 227```caddy 228@blob_download path /xrpc/com.atproto.sync.getBlob 229handle @blob_download { 230 reverse_proxy 127.0.0.1:7755 { 231 transport http { 232 keepalive off 233 } 234 } 235} 236 237handle { 238 encode gzip 239 reverse_proxy 127.0.0.1:7755 { 240 transport http { 241 keepalive off 242 } 243 } 244} 245``` 246 247This still requires wildcard DNS or per-handle DNS records so public ACME validation can reach the server. 248 249A minimal nginx site looks like: 250 251```nginx 252server { 253 server_name pds.example.com; 254 listen 443 ssl http2; 255 256 ssl_certificate /path/to/fullchain.pem; 257 ssl_certificate_key /path/to/privkey.pem; 258 259 location / { 260 proxy_pass http://127.0.0.1:7755; 261 proxy_set_header Host $host; 262 proxy_set_header X-Forwarded-Proto https; 263 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 264 } 265} 266``` 267 268## Validation 269 270Check the local service first: 271 272```sh 273curl http://127.0.0.1:7755/_health 274curl http://127.0.0.1:7755/.well-known/did.json 275``` 276 277Then validate the public host: 278 279```sh 280curl https://pds.example.com/_health 281curl https://pds.example.com/.well-known/did.json 282curl https://pds.example.com/.well-known/oauth-protected-resource 283curl https://pds.example.com/.well-known/oauth-authorization-server 284curl https://pds.example.com/oauth/jwks 285curl https://pds.example.com/xrpc/com.atproto.server.describeServer 286curl --resolve alice.pds.example.com:443:SERVER_IP https://alice.pds.example.com/.well-known/atproto-did 287``` 288 289For browser-hosted clients such as `https://bsky.app`, `perlsky` also answers CORS preflight requests on XRPC routes. A quick manual probe looks like: 290 291```sh 292curl -i -X OPTIONS https://pds.example.com/xrpc/com.atproto.server.describeServer \ 293 -H 'Origin: https://bsky.app' \ 294 -H 'Access-Control-Request-Method: GET' 295``` 296 297You should see: 298 299- a healthy `_health` response 300- a `did:web:pds.example.com` DID document 301- OAuth protected-resource metadata advertising the same host as the authorization server 302- OAuth authorization-server metadata advertising `private_key_jwt`, PAR, PKCE `S256`, DPoP-bound access tokens, and the local `/oauth/*` endpoints 303- a JWK set with at least one signing key from `/oauth/jwks` 304- `describeServer.availableUserDomains` matching `service_handle_domain` 305- a per-handle `/.well-known/atproto-did` response returning the account DID when queried on the handle host 306 307Modern third-party ATProto OAuth clients should now be able to discover and authenticate directly against your PDS. The built-in provider enforces both the transition scopes (`transition:generic`, `transition:email`, `transition:chat.bsky`), the granular ATProto permission families (`account:`, `identity:`, `repo:`, `blob:`, and `rpc:`), and `include:<nsid>` permission-set scopes. Permission-set scopes are resolved through lexicon records and compiled down to concrete repo/RPC permissions before tokens are issued, so apps requesting spec-compliant permission bundles still get least-privilege tokens. For example, a client like Tangled will start by fetching `/.well-known/oauth-protected-resource`, follow the advertised authorization-server metadata, submit a pushed authorization request, and then send the browser through `/oauth/authorize`. 308 309The local OAuth metadata only advertises the pieces perlsky actually implements today: authorization-code flow with PAR, PKCE `S256`, DPoP, `private_key_jwt` client auth, `response_mode=query`, and interactive `prompt=login` / `prompt=consent`. 310 311## First Account 312 313You can create the first account directly with XRPC: 314 315```sh 316curl -X POST https://pds.example.com/xrpc/com.atproto.server.createAccount \ 317 -H 'Content-Type: application/json' \ 318 -d '{ 319 "handle": "alice", 320 "email": "alice@example.com", 321 "password": "correct horse battery staple" 322 }' 323``` 324 325If `service_handle_domain` is `example.com`, the short handle `alice` is normalized to `alice.example.com`. 326 327The response contains: 328 329- `did` 330- `handle` 331- `accessJwt` 332- `refreshJwt` 333 334Passwords must be at least 8 characters long. 335 336If you are running without outbound email during smoke/dev work, the safer testing knobs are: 337 338- `testing_auto_confirm_email`: explicitly opt into marking new-account emails as confirmed immediately. 339- `testing_allow_unauthenticated_email_confirm`: allow `com.atproto.server.confirmEmail` without a bearer token for local testing only. 340 341Both are intended for testing environments. Leave them off in normal deployments. 342 343If you want to disable open signup, enable `invite_code_required` and mint invite codes locally on the server: 344 345```sh 346PERLSKY_CONFIG=/etc/perlsky/perlsky.json \ 347 /opt/perlsky/app/script/perlsky-admin create-invite 348``` 349 350That command prints a single invite code such as `perlsky-0123456789ab`. 351 352You can then pass that value as `inviteCode` in the `createAccount` request: 353 354```sh 355curl -X POST https://pds.example.com/xrpc/com.atproto.server.createAccount \ 356 -H 'Content-Type: application/json' \ 357 -d '{ 358 "handle": "alice", 359 "email": "alice@example.com", 360 "password": "correct horse battery staple", 361 "inviteCode": "perlsky-0123456789ab" 362 }' 363``` 364 365If `service_handle_domain` is `pds.example.com`, the short handle `alice` becomes `alice.pds.example.com`. 366 367For a fully local bootstrap flow on the server, you can save the invite code into a shell variable first: 368 369```sh 370INVITE_CODE=$( 371 PERLSKY_CONFIG=/etc/perlsky/perlsky.json \ 372 /opt/perlsky/app/script/perlsky-admin create-invite 373) 374printf 'Invite code: %s\n' "$INVITE_CODE" 375``` 376 377## Metrics 378 379If `metrics_token` is set, scrape metrics with: 380 381```sh 382curl -H 'Authorization: Bearer YOUR_METRICS_TOKEN' \ 383 https://pds.example.com/metrics 384``` 385 386Checked-in Prometheus and Grafana examples live under: 387 388- [ops/prometheus/perlsky.yml](../ops/prometheus/perlsky.yml) 389- [ops/grafana/prometheus-datasource.yml](../ops/grafana/prometheus-datasource.yml) 390- [ops/grafana/perlsky-dashboard-provider.yml](../ops/grafana/perlsky-dashboard-provider.yml) 391- [ops/grafana/perlsky-dashboard.json](../ops/grafana/perlsky-dashboard.json) 392 393See [METRICS.md](./METRICS.md) for the metric surface and dashboard notes. 394 395## Sentry 396 397If you want exception reporting in addition to Prometheus metrics, add `sentry_dsn` to `/etc/perlsky/perlsky.json`. 398 399The current integration is intentionally narrow: 400 401- it reports unhandled XRPC exceptions 402- the Sentry event includes request metadata and Perl stack frames 403- it does not report ordinary handled XRPC errors like `InvalidToken` 404- it is a no-op when `sentry_dsn` is unset 405 406## Prometheus 407 408Merge [ops/prometheus/perlsky.yml](../ops/prometheus/perlsky.yml) into your Prometheus config and replace the placeholder bearer token with `metrics_token` from `/etc/perlsky/perlsky.json`. 409 410One minimal local scrape job looks like: 411 412```yaml 413- job_name: perlsky 414 scrape_interval: 15s 415 scrape_timeout: 5s 416 metrics_path: /metrics 417 scheme: http 418 authorization: 419 credentials: REPLACE_WITH_PERLSKY_METRICS_TOKEN 420 static_configs: 421 - targets: ['127.0.0.1:7755'] 422 labels: 423 service: perlsky 424``` 425 426Validate and reload: 427 428```sh 429promtool check config /etc/prometheus/prometheus.yml 430systemctl reload prometheus || systemctl restart prometheus 431curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22perlsky%22%7D' 432``` 433 434## Grafana 435 436Provision the Prometheus data source and dashboard provider with the checked-in examples, then copy the dashboard JSON into the watched directory: 437 438```sh 439install -d /etc/grafana/provisioning/datasources 440install -d /etc/grafana/provisioning/dashboards 441install -d /var/lib/grafana/dashboards 442cp /opt/perlsky/app/ops/grafana/prometheus-datasource.yml /etc/grafana/provisioning/datasources/perlsky-prometheus.yml 443cp /opt/perlsky/app/ops/grafana/perlsky-dashboard-provider.yml /etc/grafana/provisioning/dashboards/perlsky.yml 444cp /opt/perlsky/app/ops/grafana/perlsky-dashboard.json /var/lib/grafana/dashboards/perlsky-overview.json 445systemctl restart grafana-server || systemctl restart grafana 446``` 447 448The example data source uses the stable UID `prometheus`. Keep that UID or update the dashboard file to match your local Prometheus data source UID. 449 450## Upgrades 451 452To update a deployed instance: 453 454```sh 455git -C /opt/perlsky/app fetch origin 456git -C /opt/perlsky/app reset --hard origin/main 457cd /opt/perlsky/app 458cpanm --notest --local-lib-contained /opt/perlsky/local Mojolicious@9.42 459systemctl restart perlsky 460``` 461 462## Useful Commands 463 464```sh 465systemctl status perlsky --no-pager 466journalctl -u perlsky -f 467curl http://127.0.0.1:7755/_health 468curl http://127.0.0.1:7755/xrpc/com.atproto.server.describeServer 469```