Install & deploy
for operators
psd is one static binary, one keys file and one SQLite database. This page takes you from nothing to a Person Server that agents can find and people can log in to, then covers day-two operations. Every configuration field is in the configuration reference; every command in the CLI reference.
Before you start: three decisions that are hard to undo
The issuer is permanent. issuer (for example https://ps.example.com)
goes into every pairwise sub psd derives and into iss of every token it
signs. Change it and every service sees a stranger and every agent must be
approved again. It must be an https:// origin — lowercase, no port, no
path — and this exact origin must serve /.well-known/aauth-person.json.
It must be a hostname, not an address. People log in with passkeys, and
browsers refuse to create passkeys on IP-address origins. In development
that means http://localhost:8430, never http://127.0.0.1:8430.
The keys file is the identity, and the pairwise secret inside it must
never change. psd keygen writes the Ed25519 signing keys and the
pairwise_secret used to derive per-service identifiers to one file. Losing
the file means every service sees new strangers; leaking it means someone
else can sign as your server. Back it up like a private key, because it is
one.
Get the binary
Pick one.
From source (Rust 1.85+; SQLite is compiled in, so a C compiler is the only system dependency):
git clone https://github.com/PersonServer/source-code psd && cd psd
cargo build --release
./target/release/psd version
Container image, multi-arch (amd64, arm64), distroless, non-root:
docker pull ghcr.io/personserver/psd:latest # or :edge for the latest main
Helm chart (OCI), single replica with a persistent volume:
helm install psd oci://ghcr.io/personserver/charts/psd \
--set issuer=https://ps.example.com --set keys.existingSecret=psd-keys
The chart’s README covers its values; the rest of this page applies to it as much as to a bare binary.
1 · Create the keys
psd keygen --keys /var/lib/psd/psd-keys.json
# created /var/lib/psd/psd-keys.json with new active key 'ps-…' and a pairwise secret
The file is written 0600. Its shape is a JSON object with the active
kid, the private keys, and the pairwise secret; there is nothing in it you
ever paste anywhere else. Rotation is covered below.
2 · Write the config
psd example-config > /etc/psd/psd.json
Edit at least these:
{
"issuer": "https://ps.example.com",
"listen": "127.0.0.1:8430",
"keys_file": "/var/lib/psd/psd-keys.json",
"storage": { "backend": "sqlite", "path": "/var/lib/psd/psd.db" },
"metadata": {
"name": "Example Person Server",
"description": "Manage which agents act for you and review what they do.",
"documentation_uri": "https://personserver.dev/docs/"
}
}
Unknown fields are hard errors and every value is validated at load, with a
message that says what to change — a typo will not become a silently
ignored setting. metadata.name is what people see on every screen; set it.
Everything else has a sensible default; see the
reference when you want to change one.
Secrets and hostnames can be injected by the environment instead of edited
into the file: PSD_ISSUER, PSD_LISTEN, PSD_KEYS_FILE, PSD_DB_PATH
win over the file.
3 · Put TLS in front
psd speaks plain HTTP on listen; terminate TLS in a reverse proxy or
ingress. Three rules, each with a reason:
- Preserve the
Hostheader. Agents sign@authorityon every request and psd checks it against the issuer host before doing anything else. A proxy that rewritesHostto127.0.0.1:8430breaks every signed request. If you truly cannot preserve it, setexpected_authorityto what the proxy sends — but preserving it is the fix. - No path rewriting.
/.well-known/aauth-person.json,/person,/tokenand the rest must be reachable at exactly those paths on the issuer origin. Serve psd at the root of its own hostname. - Pass bodies through unchanged. Signed requests cover a
Content-Digestof the body; a proxy that re-encodes JSON will break the signature. Normal proxies do not. - Let long polls finish — at every hop. Agents poll pending requests
with
Prefer: wait=N; psd holds the connection up to 50 seconds. Any proxy in front of psd with a request/read timeout below that turns every long poll into a504from the proxy — the agent never sees psd’s answer, and the approval it was waiting for is delivered to nobody. Set the read/request timeout to at least 60 seconds on each hop: nginxproxy_read_timeout 60s;; ingress-nginxnginx.ingress.kubernetes.io/proxy-read-timeout: "60"(the chart’s default annotation); Gateway APIHTTPRoute.spec.rules[].timeouts.requestand.backendRequest: 75s— Envoy-based gateways default to 15 s, and that is the one people actually hit; APISIX upstreamread/sendtimeouts likewise. Found live on a sandbox behind APISIX → Envoy Gateway → psd, where both walls had to move.
A minimal nginx site:
server {
listen 443 ssl http2;
server_name ps.example.com;
# ssl_certificate …; ssl_certificate_key …;
location / {
proxy_pass http://127.0.0.1:8430;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_read_timeout 60s; # Prefer: wait long polls (psd holds up to 50 s)
}
}
Caddy does the right thing with reverse_proxy 127.0.0.1:8430. On
Kubernetes, the chart’s Ingress preserves Host with every mainstream
controller’s defaults.
Kubernetes DNS and egress admission. psd refuses to fetch from private
addresses (see Security model).
A pod’s DNS search domains combined with a wildcard record in your zone
can make an external hostname such as sandbox.agentprovider.dev resolve
to a private address inside the cluster (sandbox.agentprovider.dev.lab.example
matches *.lab.example), and every Agent Provider fetch is then refused —
which the released 0.1.0 reported as unknown_key, and current builds
report as 503 temporarily_unavailable with a discovery_unavailable
audit event. The fix is on the pod, not in psd: dnsConfig: { options: [{
name: ndots, value: "1" }] } (the chart’s dnsConfig value), so
fully-qualified external names are resolved as such.
4 · Serve
psd serve --config /etc/psd/psd.json
Startup prints what it is doing — issuer, listen address, storage, whether passkeys are available (they are not on an IP-address issuer), whether templates are overridden, which drafts it tracks — and then serves. Check from outside:
curl -s https://ps.example.com/healthz
curl -s https://ps.example.com/.well-known/aauth-person.json
curl -s https://ps.example.com/.well-known/jwks.json
The metadata document is what agents (and Agent Providers, and services) discover; if it does not load at the issuer origin, nothing else will work.
A systemd unit that matches the layout above:
[Unit]
Description=psd — AAuth Person Server
After=network-online.target
Wants=network-online.target
[Service]
User=psd
Group=psd
ExecStart=/usr/local/bin/psd serve --config /etc/psd/psd.json
WorkingDirectory=/var/lib/psd
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/psd
PrivateTmp=true
[Install]
WantedBy=multi-user.target
CLI commands take --config; relative paths in the config resolve against
the current directory, so run them from WorkingDirectory (or use absolute
paths, as the examples here do).
5 · Add the first person
psd person add --name "Alice" --config /etc/psd/psd.json
# https://ps.example.com/enrol/…
# created person … (Alice); open the link above within 900s to register a passkey (single use)
The link goes to stdout and the explanation to stderr, so it scripts
cleanly. --ttl changes the 15-minute default.
Send the link to Alice (or open it yourself). It shows “Welcome, Alice”
and a Create passkey button; after that she is enrolled and on her
dashboard. psd invite --person ID mints another link later — for a person
who lost every passkey, for instance. psd person list shows who exists.
That is a working Person Server. Point an agent at it — the quickest real
one is an agent enrolled at
agentprovider.dev’s sandbox;
apd’s tools/aauthcheck --target https://ps.example.com --poll walks the
whole deferred flow — approve the request on Alice’s dashboard, and the
agent receives its person token.
Day two
Headless approvals
When nobody is watching a browser, decisions can be made from the shell:
psd pending list --config /etc/psd/psd.json
psd pending approve <id> --config /etc/psd/psd.json # --person ID if unbound
psd pending deny <id> --config /etc/psd/psd.json
They are recorded in the audit log with "via": "cli", so the record shows
the operator decided, not the person’s browser. psd agents list and
psd agents revoke ISS SUB do the same for bindings; a CLI revocation
revokes the agent’s consents and sweeps its auth tokens at their services
just as the dashboard button does.
Notifications
By default a pending request simply waits on the dashboard. Add
"webhook" to notify.channels and set an https:// notify.webhook_url
to receive a JSON POST when a decision is pending — enough to page someone
or forward to chat. The webhook never carries the interaction code.
The record
Every issuance, denial, revocation, binding and mission event is one JSON
line on stderr, and the same event as a row the person sees under
Activity. Set audit_log_file to also append the lines to a file. Two
events deserve an alert if you have one: resource_token_mismatch (a
service presented a resource token that names a person token psd issued —
but for a different agent, person or mission; the service is confused or
hostile) and repeated person_token_denied with reason: too_many_resources
(an agent probing many services). A third, discovery_unavailable, means
psd could not fetch an issuer’s metadata or JWKS and answered the agent
503: usually your egress (DNS, firewall, a proxy that rewrites responses),
sometimes their outage — either way the agent is not at fault, and the
event names the issuer and the reason.
Backups
Two files: the keys file and the SQLite database (psd.db, plus its -wal
and -shm while running). The database holds person records, passkey
public keys, bindings, consent history and the directed identifiers — it is
personal data; treat backups accordingly. psd runs SQLite in WAL mode, so
copying the file while serving is safe as long as you copy psd.db and
psd.db-wal together (or use sqlite3 psd.db ".backup out.db").
Key rotation
psd keygen --keys /var/lib/psd/psd-keys.json --rotate # new active key
psd keygen --keys /var/lib/psd/psd-keys.json --prune-days 30 # drop old ones
Rotation adds a new active key; old public keys stay in the JWKS until pruned, so tokens signed before rotation (they live at most an hour) keep verifying. psd re-reads the file at start — restart after rotating. The pairwise secret is untouched by rotation and must stay so.
Upgrades
Stop, replace the binary (or image tag), start. The schema is created and extended at startup. Take a database backup before upgrading; AAuth is a draft, so pin a version, read the release notes, and expect wire changes between draft revisions.
Development mode
For local work set "insecure_dev_mode": true and an http://host:port
issuer such as http://localhost:8430. That relaxes the identifier rules,
admits loopback and plain-HTTP egress so a mock Agent Provider on
127.0.0.1 works, and permits a non-Secure session cookie. serve prints
a warning while it is on. Never enable it where anyone else can reach the
server.
Single sign-on (OpenID Connect)
An organisation can put its identity provider — Okta, Entra ID, Google Workspace, Keycloak — in front of psd’s login. Passkeys stay: SSO is additive, per person, so a break-glass passkey works when the provider does not.
- Register psd at the provider as a web application using the
authorization-code flow with PKCE, redirect URI
https://ps.example.com/login/oidc/callback(psd prints the exact value at startup). Note the client id and secret. -
Put the secret in a file readable only by psd, and configure:
"person_auth": { "method": "oidc", "oidc": { "issuer": "https://acme.okta.com", "client_id": "0oa…", "client_secret_file": "/etc/psd/oidc-secret", "required_claims": { "groups": "psd-users" }, "tenant_claim": "org_id" } }required_claimsis mandatory — it is who may sign in.tenant_claimis what lands in tokens astenant, so resources can apply org policy. Google Workspace needs"jwks_cross_origin_hosts": ["www.googleapis.com"]as well. Every field is in the reference. - Restart. Startup runs discovery and fetches the provider’s keys; a wrong issuer, secret path or unreachable provider fails there. The login page now offers Sign in with acme.okta.com above the passkey button.
People who pass required_claims get a person on first sign-in
(provision: true); people who already have a passkey account connect
their provider identity from Sign-in methods so both routes lead to
the same person. Identities are keyed on the provider’s (issuer, sub),
never on email.
Offboarding is two steps, not one. Deactivating the person at the
provider stops their logins. It does not stop the agents already acting for
them; run psd person deactivate ID to revoke every binding, end their
missions, drop their sessions and refuse further sign-ins (see the
CLI). Put it in the runbook.
There is no SCIM in this release. Provider-specific settings and the
failures each one makes likely — Okta’s groups claim, Entra’s group
overflow, Google’s cross-origin keys — are in
Identity providers.
Optional surfaces
missions.enabled advertises mission_endpoint and turns on the mission
flow; federation.enabled turns on four-party federation to an Access
Server and call chaining. Both default to off. Federation is exercised
against a mock Access Server only — no live one exists yet — so enable it
knowingly.
What psd expects from you
- A stable hostname with TLS in front,
Hostpreserved. - The keys file kept secret and backed up; the database backed up.
- Someone who reads the audit line about
resource_token_mismatch. - A version pin and a glance at release notes: the protocol is a draft.