Self-hosting
Pitchbox is designed to run on a single VPS or a beefy laptop. The architecture is just three processes (web, daemon, Postgres) plus a Chrome extension on your workstation.
Topology
┌────────────┐ ┌─────────────────────────┐ ┌─────────────┐
│ Chrome │ ──> │ web (SvelteKit, :5180) │ ──> │ Postgres │
│ extension │ │ ├ /api/run (campaigns)│ │ (port 5434) │
└────────────┘ │ ├ /api/extension/* │ └─────────────┘
│ └ AgentRunner (claude)│
└────────┬────────────────┘
│ POST /api/run
┌────────┴────────┐
│ daemon (Node) │
│ scheduler tick │
│ reply poller │
└─────────────────┘The AgentRunner box is pluggable. With a local runner (Claude Code, Codex, …) the web spawns an ACP agent CLI in-process, as drawn. With the default cloud runner it instead drives the model loop in-process against the AI Gateway (no separate runner process, no relay), so no agent CLI runs on this host and your data stays local. See Agent runners and Cloud runner. The daemon loops (scheduler, reply poller, retention, keyword-watcher, webhook-sender, insights) can also run embedded in the web process via PITCHBOX_EMBED_DAEMON=1 instead of as a separate process.
Authentication and accounts
Off by default (PITCHBOX_AUTH unset) - no login, every visitor is the sole operator with full access, the point of a single-user self-host. Turn it on with PITCHBOX_AUTH=on; see Authentication for the full session, rate-limit and registration model.
With it on, an account can come from four places: the first-run bootstrap (whoever submits the first POST /api/auth/login claims the owner account), pitchbox seed:owner run right after migrations so the owner is claimed before the URL is reachable, self-service registration at /register, or an invite an admin sends from /settings/organization. Self-registration is gated by a three-state policy - open / invite / off - stored in app_config.registration_policy and set from /settings/admin (instance admin only). The default, and what a fresh deployment gets with nothing configured, is invite - a self-host that wants nobody to register at all sets it to off and creates accounts only through seed:owner or pitchbox user:create (see CLI reference).
Billing
Self-host is free and unlimited. PITCHBOX_BILLING unset (or anything other than on) is the self-host posture: no Stripe client is ever built, no Stripe key is needed, and shared/src/plans.ts's resolveEntitlements returns the unlimited shape for every org on this deployment without counting anything - there is no plan, no metered ceiling, and no read-only state to fall into. The hosted plans, what each meters, and what happens at a limit or a failed payment are all a cloud-edition concern: see Billing.
Outbound email
Invites and password reset send mail through one pluggable transport (@pitchbox/shared/mail), selected by environment variable and read fresh at send time - never persisted to the database:
# MAIL_PROVIDER selects a real transport explicitly ('resend' or 'smtp').
# Unset, unrecognized, or missing the provider's required credential(s)
# below all fall back to the null transport, not a failed send.
# MAIL_PROVIDER=resend
# Sender address for every outbound email, "Name <addr>" or a bare address.
# Optional: defaults to "Pitchbox <no-reply@pitchbox.app>".
# MAIL_FROM=
# --- resend (MAIL_PROVIDER=resend) ------------------------------------------
# RESEND_API_KEY=
# --- smtp (MAIL_PROVIDER=smtp) - self-host escape hatch ---------------------
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=
# SMTP_PASS=The null transport is the default, and it is a supported state, not a missing feature. With nothing set above, a self-host keeps working: an invite or reset request never throws, it just logs what it would have sent ([mail] null transport - would send "..." to ...) and drops it. Nobody actually receives the email, so an invite's copyable link (/settings/organization) stays the only way to deliver it, and a lost password recovers through pitchbox user:reset-password (see CLI reference) rather than a reset email that will never arrive.
Every outbound link is built from the request's own origin (event.url.origin), which adapter-node resolves from the ORIGIN env var behind a reverse proxy, so a deployment on any domain gets working links with no code change. The instance at https://app.pitchbox.app is one example: its apex, pitchbox.app, is still answered by the same deployment while the two are transitionally both trusted origins, so a caller still on the apex during that migration gets a link back to the apex rather than to app.pitchbox.app.
Backups
pg_dump pitchbox is enough. Everything that matters lives in Postgres:
- Campaigns, runs, drafts, contact history, blocklist, messages.
- Encrypted account credentials (
accounts.cookie_session), for the platforms that have one. LinkedIn accounts carry no credential at all: see LinkedIn. - App config (
app_config) - quota defaults, runner configs, default runner, retention policy, notification webhooks, registration policy. Extension auth uses per-device tokens inextension_devices, not a singleton here. - Built-in and user playbooks.
ENCRYPTION_KEY is not in Postgres - keep it in .env or a secret store, and snapshot it alongside backups or you'll lose access to encrypted columns.
The daemon prunes ageing event logs and terminal drafts on a configurable schedule - see retention. Contact history is never pruned automatically.
Upgrades
git pull && pnpm install && pnpm run migrate && pnpm -F @pitchbox/shared seed:core. The seed step refreshes built-in playbooks but leaves user-created rows alone.
Disk usage: build cache
scripts/deploy.sh's blue-green cutover prunes docker's build cache after every successful deploy, capped by DEPLOY_CACHE_MAX_GB (default 40; 0 disables the prune). The cap targets the shared buildkit cache store on whichever docker daemon builds this app's image - on a host running other containers off that same daemon, it bounds every app's cache together, not just this one's. Lower it if disk is tight, but not so low that a same-day incremental build stops hitting cache. DEPLOY_KEEP_N (default 5) is the separate knob for how many old immutable image tags and pre-migrate restore points the script keeps; see the script's own header comment for the full env-knob list.
Dependency pinning policy
A few dependencies are pinned to exact versions (no caret) in package.json because semver-range upgrades have historically broken self-hosted installs:
- Any
*-beta*release (e.g.@crxjs/vite-pluginbetas) - beta tags do not follow semver guarantees. - Packages that ship native bindings (e.g.
sharp) - minor bumps frequently change the prebuilt-binary matrix and break Docker images on uncommon architectures. - Build tooling whose output is shipped to users (
vite,vitest,@crxjs/vite-plugin) - patch releases here can change the bundle layout or extension manifest in subtle ways.
When adding or upgrading any of the above, write the exact version (no ^, no ~) and regenerate the lockfile with pnpm install --lockfile-only. Other dependencies may continue to use ^ ranges.
Logs
Stream logs land in daemon/logs/run-*.log (one per run). The runs.stdout_log_path column points at the matching file.
Performance: index audit
Migration 0030_index_audit adds composite indexes covering the hottest read paths: dm-sync (account_handle, target_user) lookups on contact_history, Inbox filters on drafts(state, run_id, created_at DESC), and audit-feed scans on draft_events(event, created_at) / run_events(kind, created_at). To benchmark against a realistic volume, run tsx scripts/perf-seed.ts against a throwaway DB - it inserts 100k rows into both draft_events and run_events and ANALYZEs the tables.