Pitchbar is built for Laravel Cloud as the primary target — the stack is FrankenPHP + Postgres + Redis, all of which Laravel Cloud provisions natively. Self-hosting is supported but the operator owns more pieces.
infra/cloud.yaml in the repo describes the environments
and processes. The high-level shape:
us-east by default. Choose for proximity to your customers.crawl, index, and default queues.| Env | Purpose |
|---|---|
| preview | Per-PR ephemeral environments. Auto-spun on PR open, torn down on merge / close. |
| staging | Long-lived. Mirrors production config. Used for QA and pre-release verification. |
| production | The customer-facing environment. Releases gated on green CI + manual deploy. |
Starting point. Adjust based on traffic.
| Component | Size | Why |
|---|---|---|
| App (Octane) | 2 instances × 2 vCPU / 2 GB | Hot path is mostly I/O-bound on LLM streaming. Two instances for HA. |
| Worker (Horizon) | 2 instances × 2 vCPU / 2 GB | Indexing throughput. Scale on queue depth. |
| Reverb | 1 instance × 1 vCPU / 1 GB | WebSocket, sticky. |
| Postgres | 2 vCPU / 4 GB / 50 GB SSD | Comfortable until ~10M messages. |
| Redis | 1 GB | Sessions, queue, hot caches. |
You typically need:
app.pitchbar.com for the customer / admin app.cdn.pitchbar.com serving /widget/widget.js. The bundle has a content-hash query param, so aggressive caching is safe.realtime.pitchbar.com if you split the WebSocket process onto its own host.
GitHub Actions workflows under .github/workflows/:
tests.yml — PHP setup + Composer + Pest suite (with fakes, no network).lint.yml — Pint, ESLint, TypeScript tsc --noEmit.widget.yml — widget bundle build + size budget check.Deploys are gated on green CI; the actual deploy step is configured on Laravel Cloud (or your hosting equivalent), not in the workflow files.
Laravel Cloud runs php artisan migrate --force on every
deploy. Migrations should be backwards-compatible — a deploy that adds
a NOT NULL column to a populated table needs a two-step:
Same goes for renames and drops — never destructive in a single deploy.
chunks table by re-dispatching IndexDocumentJob for every document. php artisan pitchbar:audit-vectors reports drift between the chunks table and the live vector store; if you need to repair, dispatch the job per row.APP_KEY separately (it's the master for app_settings encryption).Laravel Cloud keeps the previous release for instant rollback. For schema-incompatible rollbacks (rare), restore from the latest snapshot.
The same Docker setup that powers docker-compose.yml works
for production with a few additions:
The composer run dev shortcut starts everything locally
(Octane, queue worker, Reverb, vite) for development.
When in-cluster scheduling isn't available (cPanel shared hosting,
DIY VPS without systemd, Laravel Cloud's preview environments), an
external Cloudflare Worker can drive the queue every 60 seconds by
POSTing /api/v1/internal/queue-tick with the
INTERNAL_QUEUE_TOKEN bearer secret. The endpoint
invokes php artisan queue:tick which spawns one
queue:work --once --stop-when-empty pass with these
defaults:
--max-time=55 — the loop exits before the 60-second tick boundary so consecutive ticks don't pile up.--job-timeout=120 — individual jobs (mostly CrawlPageJob / IndexDocumentJob) get a 2-minute ceiling.crawl,index,default.
Build + deploy the Worker via
php artisan pitchbar:deploy-cron-worker. The Worker
body is templated from WorkerDeployer and ships with
the tick parameters baked in. Rotate
INTERNAL_QUEUE_TOKEN after deploy.
CrawlPageJob retries up to 3 times
with backoff [30, 90, 180] seconds. The retry path
branches on failure class:
release(60) without burning a retry slot. Every fan-out page tends to hit the same 429 wave; the shared wait is productive.$this->fail() immediately. Without this, every dead URL burned the full 3-retry budget and produced a generic MaxAttemptsExceededException in the logs.
Per-job timeout is 90 seconds; failOnTimeout=true so
a SIGTERM on timeout still runs the failed() callback
and flips the Source row to failed with a
customer-readable error.