Self hosting
psh.now is MIT licensed and runs as one Bun process in front of Postgres. Two containers, three environment variables, and a domain that resolves to the host. Everything below has been run on a clean machine, in this order.
Quick start
git clone https://github.com/passy-ai/psh-now && cd psh-now
cp .env.example .env
# the two that compose refuses to start without
PUBLIC_BASE_URL=https://psh.example.com
PSH_SECRET=$(openssl rand -base64 48)
# sign-in needs one of these
MAIL_FROM="psh.now <no-reply@example.com>"
SMTP_URL=smtps://user:password@smtp.example.com:465
docker compose up -d --build
Migrations run inside the container on start, with retries, so there is no
separate migrate step to forget. The app comes up on
http://localhost:3000; put TLS in front of it (next section) before
you pair anything, because pairing links embed PUBLIC_BASE_URL.
Environment
Three variables are required. The rest have defaults that work.
PUBLIC_BASE_URL required the URL people and agents will use, no trailing slash
PSH_SECRET required encrypts stored agent keys; 32+ random bytes
DATABASE_URL required compose builds it from POSTGRES_* for you
MAIL_FROM mail the From line, e.g. psh.now <no-reply@example.com>
SMTP_URL mail smtps://user:pass@smtp.host:465
RESEND_API_KEY mail alternative to SMTP_URL
PORT 3000 app port inside the container
DEFAULT_TIMEOUT_MS 90000 how long an ask waits for a human
PAIRING_TTL_MS 600000 how long a pairing link lives
PSH_FREE_REQUESTS 20 requests granted before a confirmed account
PSH_CLAIMED_REQUESTS 100 requests granted to a confirmed account
Rotating PSH_SECRET is not supported in place. It
encrypts recoverable agent keys at rest, so changing it makes every stored key
undecryptable. Back it up with the database, not separately.
Email is how a person signs in. Set MAIL_FROM plus one transport:
-
SMTP (
SMTP_URL) works with your own Postfix, SES, Postmark, Fastmail, or a Gmail app password. Usesmtps://for implicit TLS on 465,smtp://on 587 with STARTTLS. -
Resend (
RESEND_API_KEY) if you would rather not run an SMTP relationship. It needs a verified sending domain.
With neither set, the server still runs and says so at boot:
no mail transport configured. In development the code prints to the
console and comes back in the API response, so local sign-in needs no mail at
all. In production, sign-in returns an error rather than pretending an email went
out.
TLS
Two supported shapes. Caddy is the one-liner:
# Caddyfile
psh.example.com {
reverse_proxy app:3000
}
Or hand certificates to Bun directly with TLS_CERT_FILE and
TLS_KEY_FILE and skip the proxy. If you use nginx or Cloudflare in
front, read Limits and proxies before you test approving.
First account
- Open
https://psh.example.com/app. - Enter your email, then the six digit code from that mailbox.
-
Open
/devicesand pair a phone or the Notch app. The pairing link is built fromPUBLIC_BASE_URL, so a wrong value here is the most common first-run failure.
There is no seeded admin and no default password. The first person to claim a workspace owns it, which is why a self-hosted instance should not be exposed before you claim it.
Connecting agents
Agents do not need a plugin. In any project, install the skill and run setup:
npx skills add passy-ai/psh-now-skill -g -y
curl -fsSL https://psh.example.com/install.sh | sh
Or point an agent at the instance without installing anything: give it the
channel key from /channels and the prompt at
/docs#sandbox. The CLI is plain shell plus
curl, so it works anywhere an agent can run a command.
Upgrades and backups
git pull
docker compose up -d --build # migrations run on start
# backup: database and the secret together, or neither is useful
docker compose exec db pg_dump -U psh.now psh.now > psh-$(date +%F).sql
printf '%s\n' "$PSH_SECRET" > psh-secret.txt
Restoring is the same two files: a fresh volume, psql < dump, and
the original PSH_SECRET. A dump without the secret restores rows
whose encrypted values cannot be read.
Limits and proxies
An ask blocks until a human answers, so the request is only as long
as your proxy allows. Keep DEFAULT_TIMEOUT_MS below it:
- Cloudflare: 100s, and a longer wait becomes a 524.
- nginx
proxy_read_timeout: 60s by default. - Caddy: no limit.
The default of 90s sits under all three. If agents report timeouts at exactly the same second every time, it is the proxy, not psh.now.
What is not included
- Push notifications. They need an Apple push key and a signed app, so a self-hosted instance has the web dashboard and the Notch over the network, not push to a locked phone.
-
A signed Notch build. The app builds from
tools/psh-notchwithswiftc; you sign it yourself. -
Billing. Dodo Payments is optional. Unset, checkout reports
offand the packs are not offered. - Managed anything. No uptime monitoring, no off-site backup, no support rotation. It is your instance.
Troubleshooting
-
Compose exits immediately. It refuses to start without
PUBLIC_BASE_URLandPSH_SECRET; the error names the missing one. -
Sign-in says the code could not be sent. No mail transport is
configured. Set
SMTP_URLorRESEND_API_KEY, plusMAIL_FROM, and restart. -
Pairing link points at localhost.
PUBLIC_BASE_URLis still the default. Fix it and restart. -
403 on sign-in from another origin. The app trusts
PUBLIC_BASE_URLplus localhost in development. Open the host you configured, not an IP. - Every query is slow. Postgres over a tunnel or a distant managed database adds a round trip per query. Run the database next to the app.
The long version, including the release checklist and the security notes, is in
the repository at docs/self-hosting.md.