Local-first hosting for prototypes. Drop a project in a folder, run one command, and get a real HTTPS URL with TLS, single sign-on, and secrets injected — without ever touching DNS configs, cert warnings, OAuth client registrations, or .env sprawl.
Status: working. The spine, secrets, auth, all three reaches (private/tailnet/public), scaffolding, an admin dashboard, and an interactive CLI are built — see the roadmap at the bottom. This README is the canonical design;
AGENTS.mdis the working contract for anyone (human or agent) building it.
Spinning up a web prototype carries a recurring tax: pick a port, wire a reverse proxy, fight a self-signed cert, register a new OAuth app for "log in with Google," stash API keys in an untracked .env, and remember which thing is running where. Every new idea pays it again. perch pays it once and makes every new site a single word of config.
cd ~/perch/whatever
perch up # → https://whatever.perch.test (TLS, behind your SSO, secrets injected)
perch new dashboard --template next # scaffold, already wired to the platform
perch publish demo # → public at https://demo.perch.dev, only you can log in
perch share demo --with amy@gmail.com # → public, allowlisted to you + Amy
perch ls # every app: status, URL, reach, access
perch logs whatever -f
perch start | stop | restart | status [app]Subdomain defaults to the directory name. Stacks are autodetected (Node, Python, static, Docker). Zero config gets you a private HTTPS URL; one word changes who can reach it.
Same control plane, two front doors — use whichever fits the moment.
- The CLI. Every capability is a
perchsubcommand. New to it? Runperch ifor an interactive menu: it lists your apps and lets you open, restart, publish, share, view logs, manage tokens, run the doctor, and edit secrets — no flags to memorise. Bareperchin a terminal drops straight into that menu.perch newprompts for a template when you don't name one. - The dashboard. perch dogfoods itself: a small admin home page is served at
https://home.perch.test(owner-only, behind your own SSO). It's the same control plane the CLI talks to, with five tabs:- Overview — daemon health, how many apps are running, rolled-up traffic, and a setup-readiness summary (which reaches are good to go).
- Apps — every app with live status and traffic; open/start/stop/restart, edit policy (reach, access, allowlist, reauth), tail logs, issue/revoke service tokens, or bring a new folder online.
- Setup — the environment doctor, with a copy-paste fix command for anything not yet wired (perch never changes your system for you).
- Secrets — manage the encrypted keystore (names only on read; values are write-only and never shown back or logged).
- Guide — the
reach × accessmodel, a quickstart, and a CLI cheatsheet.
There aren't three hosting stacks — there's one control plane, and every app carries a policy that selects the substrate. Reach is how far it's exposed; access is who gets through.
Reach — where it's announced and how traffic arrives:
| reach | name | DNS | cert | exposure |
|---|---|---|---|---|
private |
name.perch.test |
dnsmasq → 127.0.0.1 | mkcert | Caddy loopback listener |
tailnet |
name.perch.ts.net |
Tailscale MagicDNS | tailscale cert |
Caddy on the tailnet |
public |
name.perch.dev |
Cloudflare | TLS at CF edge | cloudflared tunnel → Caddy |
Access — who gets through, enforced at the edge:
| access | mechanism |
|---|---|
me |
Google OAuth, email == yours |
allowlist |
Google OAuth, email ∈ allow[] |
tailnet |
Tailscale device/user identity — no login prompt |
service-token |
signed bearer token, for machines/CLIs |
open |
no auth (must be explicit, never a default) |
Safe by default: access is derived from reach unless you override it — private→me, tailnet→tailnet, public→me. So reach = "public" alone gives you a public URL that only you can log into. Opening or sharing it is always a separate, explicit change.
Drop this in any project root. Everything is optional; the CLI verbs (publish, share, private) edit it for you.
name = "demo" # subdomain; default = directory name
run = "npm run dev" # start command; default = autodetected
port = 0 # 0 = perchd assigns a free loopback port via $PORT
reach = "private" # private | tailnet | public (default: private)
bindings = ["private","tailnet"] # optional: serve on several reaches at once (multi-binding)
access = "me" # me | allowlist | tailnet | service-token | open
allow = ["amy@gmail.com"] # emails, when access = allowlist
reauth = "" # e.g. "5m" — force fresh Google re-auth (step-up for sensitive apps)
open_paths = ["/webhooks/*"] # paths exempt from auth — you verify the provider signature yourself
secrets = ["openai", "stripe"] # secret scopes injected as env at start private tailnet public
*.perch.test *.perch.ts.net *.perch.dev
(dnsmasq + (MagicDNS + (Cloudflare DNS,
mkcert) Tailscale cert) TLS at CF edge)
│ │ │
│ │ cloudflared tunnel
▼ ▼ ▼
┌────────────────────────────────────────────────────────┐
│ Caddy (embedded in perchd) │
│ • host-based routing • per-app TLS │
│ • forward-auth ─► oauth2-proxy ─► Google OIDC │
│ • Tailscale whois for tailnet identity │
│ • strips inbound X-Perch-*, injects the verified one │
└───────────────────────────┬────────────────────────────┘
authed request + X-Perch-Email + secrets (env)
▼
127.0.0.1:PORT (supervised app)
▲
┌────────────────────────────┴───────────────────────────┐
│ perchd control plane │
│ registry (sqlite) · policy compiler · supervisor │
│ (process/Docker) · secrets (SOPS+age) · programs │
│ Caddy routes in-process │
└─────────────────────────────────────────────────────────┘
Two components:
perch— the single CLI. Lifecycle (start/stop/restart/status) plusnew,up,down,restart <app>,rm,publish,share,private,bind,secrets,token,ls,logs,open,doctor,backup, andi(interactive menu).perchd— the daemon. Embeds Caddy as a library, so it is the proxy and the control plane in one binary: app registry, policy→mechanism compiler, process/Docker supervisor, secrets resolver/injector, Caddy route programmer, traffic metrics, and the in-process dashboard served athome.perch.test.
You never check a password yourself. You delegate to an identity provider that already did, then verify its signed proof — and the app behind the edge trusts a header it can't be lied to about. Full detail and the non-negotiable invariants live in AGENTS.md; the short version:
- Humans (web): Google OIDC via oauth2-proxy. Verify the signed ID token (signature against Google's JWKS, plus
iss/aud/exp/nonce, requireemail_verified), set a signed+encrypted cookie scoped to the parent domain → SSO across every subdomain. - Your devices (tailnet): Tailscale/WireGuard authenticates every packet at the network layer; Caddy maps the source to a user via
whois. No prompt. - To the app: Caddy injects
X-Perch-Email. The app trusts it only because (a) apps bind127.0.0.1so nothing but Caddy can reach them, and (b) Caddy strips any client-suppliedX-Perch-*first. This is the #1 thing to get right. - Machines: signed
service-token, or exempt a webhook path viaopen_pathsand verify the provider's own signature.
The painful infra is a one-time perch init / perch doctor:
- dnsmasq +
/etc/resolver/perch.test(wildcard*.perch.test→ 127.0.0.1) mkcert -install+ a*.perch.testwildcard certtailscale up(for tailnet reach)- a domain (
perch.dev) + Cloudflare account +cloudflared login+ named tunnel (for public reach) - one Google OAuth client, brokered to all subdomains via oauth2-proxy
- an
agekeypair for SOPS
After that, a new site is one perch up and at most one word of reach/access.
All four pains land in v0, sequenced on a working spine. Through M10 this is built:
- ✅ Spine —
perch↔perchd, registry, autodetect, supervisor, embedded Caddy → private reach end-to-end (cd && perch up → https://name.perch.test). - ✅ Secrets — SOPS+age resolution and env injection in the supervisor.
- ✅ Auth — oauth2-proxy + Google OIDC forward-auth + the access model (
me/allowlist/service-token/reauth). - ✅ Reach —
tailnet(Tailscale) andpublic(cloudflared), plus multi-binding (perch bind). - ✅ Scaffold —
perch newtemplates, pre-wired to the platform. - ✅ Dashboard & observability — access-log metrics + the admin home page at
home.perch.test. - ✅ Polish — lazy start, idle-out, reauth, backup/restore.
- ✅ Ease of use — the multi-tab admin dashboard (Overview/Apps/Setup/Secrets/Guide) and the interactive CLI (
perch i), so you can drive everything without memorising flags.
The proxy, certs, and auth are solved problems. perch is the thin glue + conventions that wire them into one command: Caddy (proxy + TLS, embedded), oauth2-proxy + Google OIDC (web identity), Tailscale (device identity + cross-machine DNS), cloudflared (public exposure), mkcert + dnsmasq (local TLS + DNS), SOPS + age (secrets). The custom code is the control plane, the policy model, and the DX.
Go, with Caddy embedded as a library. SQLite for the registry. See AGENTS.md for layout, conventions, and build commands.