perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
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```