Documentation Reference
Run your own deployment
For whoever hosts Opportunora itself. The three processes, first install, the environment variables that matter, workers, Docker, backups, key rotation and the admin tools.
Most people use a hosted workspace and never read this page. It is for whoever runs the app itself: the web server, the worker, the database, and the environment they read. Everything in the rest of the documentation assumes this part is done.
What runs
| Piece | Command | Does |
|---|---|---|
| Postgres 16 | npm run db:up, or any Postgres |
Holds everything, including every generated site's HTML. |
| The web app | npm run build and npm start |
Serves the screens, the API and the pay pages. |
| At least one worker | npm run worker |
Runs discovery, generation, publishing and the payments sweep. |
The web server never does the slow work. A click writes a queued job and returns; a worker claims it. Without a worker, nothing fails — it just never happens. Discovery runs sit queued, generations sit at Starting — handing the job to a worker, publishes never produce an address, and invoices never turn overdue.
First install
npm install
cp .env.example .env.local # then fill it in, see below
npm run db:up # Postgres in Docker
npm run db:migrate
npm run dev # http://localhost:3000
npm run worker # a second terminal
npm run db:seed loads a demo workspace and prints its sign-in. It is
development data: it refuses to run against a non-local database unless you
force it, and a real deployment should never carry it.
The environment
.env.example documents every variable. The ones that decide whether the
product works at all:
| Variable | Needed for | Without it |
|---|---|---|
DATABASE_URL |
Everything | Nothing starts. |
APP_URL |
Sign-in redirects, pay links, OAuth callbacks, PayPal webhooks | Production refuses to boot. No trailing slash; it must match what you registered at Google, Microsoft and Stripe exactly. |
AUTH_SECRET |
Google and Microsoft sign-in | The provider buttons fail with Sign-in is not configured on this site. |
CREDENTIAL_ENCRYPTION_KEY |
Connecting Cloudflare, PayPal | Settings refuses to store either: Set an encryption key first. |
| An AI provider key | Real discovery and generation | Discovery returns seeded demo leads and generation uses a fixed template, silently. |
Generate the two secrets with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Keep CREDENTIAL_ENCRYPTION_KEY out of the database and out of database
backups. The split is what stops a leaked dump from being enough to publish to,
and bill, someone else's Cloudflare account.
AI providers
Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY or OPENROUTER_API_KEY. The
vendor and model are infrastructure choices, set in the environment rather than
in the app: DISCOVERY_PROVIDER and DISCOVERY_MODEL for research,
GENERATION_PROVIDER and GENERATION_MODEL for building and editing sites,
which fall back to the discovery ones.
Discovery must be able to search the web. Anthropic and OpenRouter search on
their side; an OpenAI-compatible provider needs SEARXNG_URL,
BRAVE_SEARCH_API_KEY or SERPER_API_KEY. A provider that cannot search counts
as unconfigured, and runs fall back to the demo leads.
Sign-in providers
Google needs GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET; Microsoft needs
MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET and optionally
MICROSOFT_TENANT_ID. Leave either pair incomplete and that button does not
render. Register the callback as <APP_URL>/api/auth/callback/google or
…/microsoft.
With MICROSOFT_TENANT_ID left at common, any Microsoft account can create a
workspace. Set it to your directory's id to keep sign-in inside one company.
Payments
PAYMENTS_LIVEMODE=true for real money; anything else is sandbox. Stripe needs
the platform setup described in Connect
Stripe.
PayPal needs no variable of its own, only the encryption key and an HTTPS
APP_URL for its webhook.
Workers
Each worker process drains four queues by default — discovery, generation,
publish and payments — and holds no state between jobs, so replicas are
interchangeable.
- More throughput: start more workers.
DISCOVERY_CONCURRENCY,GENERATION_CONCURRENCYandPUBLISH_CONCURRENCYset how many jobs each runs at once. - Split the workloads:
QUEUES=discoveryon one machine,QUEUES=generation,publishon another. - Autoscale on running plus runnable jobs per queue from
GET /api/queues/depth, enabled byQUEUE_METRICS_TOKEN. Runnable excludes jobs waiting for a workspace plan slot. Keep at least one worker for payment reconciliation and housekeeping.
Plans cap running discovery, generation and publishing jobs together: Trial/Solo 1, Studio 3, Agency 10 per workspace. Waiting jobs do not use a running slot. One generation per website and one publish per workspace prevent conflicting changes.
For Railway, the app repository includes railway/worker.json, railway/autoscaler.json, and docs/RAILWAY_SCALING.md with the setup instructions. The controller grows the worker service from queue demand and returns to its minimum after an idle period.
Run a worker from the repository root; it loads .env.local from where it
starts.
Docker
The Dockerfile builds one image for both roles: npm start for the web app,
npm run worker for workers. docker-compose.yml runs Postgres and, behind a
profile, workers:
docker compose --profile worker up -d --build --scale worker=3
Compose does not start the web app; run the image with npm start behind your
own proxy. For multiple replicas, set the same UPLOADS_S3_* bucket variables on web and workers. The private bucket stores logos, attachments and generated images for all instances. On a single host, put UPLOADS_DIR (default .uploads) on shared persistent storage. Railway volumes do not support replicas; use a Railway Bucket there.
Backups
There is no export in the app. Back up three things, separately:
- The database — every workspace, every invoice, and every generated site's HTML.
- The uploads directory — logos, attachments, and the pictures copied from clients' old sites. They are not in the database.
CREDENTIAL_ENCRYPTION_KEY— somewhere other than the database backup. Without it no stored Cloudflare token or PayPal secret can be opened.
Rotating the encryption key
- Generate a new key.
- Move the old one to
CREDENTIAL_ENCRYPTION_KEY_PREVIOUS, put the new one inCREDENTIAL_ENCRYPTION_KEY, and restart every web and worker process. npm run secrets:reseal -- --checklists anything neither key opens.npm run secrets:resealrewrites every stored credential under the new key.- Remove
CREDENTIAL_ENCRYPTION_KEY_PREVIOUSand restart.
A value neither key opens stays as it is; that workspace reconnects the account.
Admin tools
There is no admin screen. Plans, credits and announcements are command-line tools:
npm run credits -- <workspace> show
npm run credits -- <workspace> grant 500 --kind topup --note "Goodwill after the outage"
npm run credits -- <workspace> plan studio
npm run credits -- <workspace> ledger --limit 20
<workspace> is the workspace id or its slug. --note is required on a grant.
To post a message to everyone's notification bell:
node scripts/announce.js maintenance-0912 "Scheduled maintenance tonight at 22:00" --preview
node scripts/announce.js maintenance-0912 "Scheduled maintenance tonight at 22:00"
The first argument is a key you choose. Sending the same key twice only reaches
people who do not have it yet. --to limits it to one workspace, --href sets
where the bell links, and --preview reports who would get it without sending.
Run it with node, not npm run: npm swallows the flags, and a rehearsal with
--preview turns into a send.
Run logs and cost
Every finished discovery run writes a JSON file to logs/discovery-runs/ and a
line to index.ndjson beside it: what it found, what it cost in tokens and
searches, and what that cost in money at the prices configured.
npm run discovery:cost summarises them. It is the data you price the product
from.