Platform
Running Arcus
Self-hosting the gateway — architecture, environment variables, deployment topology, migrations, health checks and operational practice.
Should you self-host?#
Use hosted Arcus if you want the gateway working this afternoon. Self-host when your payloads must not transit infrastructure you do not control, when policy requires the audit chain to live in your own database, or when you want the gateway inside the same network as the targets it delivers to.
The stack is deliberately unexotic: Node, PostgreSQL, Redis. Nothing here needs a Kubernetes cluster.
Architecture#
┌──────────────┐ POST /v1/dispatch ┌───────────────────────┐
│ Your agent │ ───────────────────────► │ Arcus API │
└──────────────┘ ark_live_ key │ (Express, port 4000) │
│ │
┌──────────────┐ Clerk session │ 5 enforcement gates │
│ Dashboard │ ───────────────────────► │ audit chain writer │
│ (Next, 3001) │ └───────┬───────┬───────┘
└──────────────┘ │ │
▼ ▼
┌─────────────┐ ┌───────┐
│ PostgreSQL │ │ Redis │
│ tenants │ │ rate │
│ keys │ │ limit │
│ policies │ │ queue │
│ guardrails │ └───┬───┘
│ logs │ │
│ audit chain │ │ jobs
└─────────────┘ ▼
┌────────────────┐
│ Delivery worker│
└───────┬────────┘
│ HTTPS
▼
┌────────────────┐
│ Your target │
└────────────────┘Three processes at most, and by default only two.
| Component | Role |
|---|---|
| API | Enforcement, recording, and the control plane. The only process agents talk to |
| Delivery worker | Drains the queue and performs outbound HTTPS with retries |
| Dashboard | Next.js UI. Optional — everything it does is available over the API |
The worker starts in-process inside the API by default, after a Redis reachability probe. For a single-instance deployment that is one process to run. It can also run standalone (pnpm --filter @arcus/api worker) when you want delivery to scale or fail independently of the enforcement path.
If Redis is unreachable at boot, the API logs a warning and serves anyway: dispatches are evaluated and recorded, but not delivered. That is a deliberate choice — an evaluated, recorded, undelivered request is far better than a gateway that will not start.
Requirements#
| Version | Notes | |
|---|---|---|
| Node.js | 20.12+ | 24.x recommended. process.loadEnvFile is used, so there is no dotenv dependency |
| pnpm | 11.x | The repository is a pnpm workspace |
| PostgreSQL | 14+ | Anything Prisma 5 supports. percentile_cont is used for p95 latency |
| Redis | 6+ | Rate limiting and the delivery queue |
| Clerk | — | Authentication for the control plane. A free project is enough to start |
| Payment provider | — | Optional. Without it every account is treated as free tier |
Resource footprint is modest: the API is a single Node process, and the expensive queries are memoised. Start at 512 MB and scale on evidence from the health endpoint rather than on guesswork.
Environment variables#
All of these are read by the API. Place them in apps/api/.env for local development — it is loaded regardless of the working directory the process starts from — or set them in your platform's environment for a deployment.
Required#
| Variable | Notes |
|---|---|
DATABASE_URL | PostgreSQL connection string. The process refuses to boot without it |
CLERK_SECRET_KEY | Clerk backend key. Required for the control plane and for operator checks |
Optional, with defaults#
| Variable | Default | Notes |
|---|---|---|
REDIS_URL | redis://localhost:6379 | Rate limiting and the delivery queue |
PORT | 4000 | API listen port |
NODE_ENV | development | Set to production in a deployment |
DASHBOARD_ORIGIN | http://localhost:3001 | The only origin permitted by CORS. Never * |
SUPER_ADMIN_EMAILS | (empty) | Comma-separated operator allow-list. Unset means no account has platform access |
Billing — every field optional#
| Variable | Notes |
|---|---|
POLAR_ACCESS_TOKEN | Provider API token |
POLAR_SERVER | sandbox (default) or production |
POLAR_WEBHOOK_SECRET | Verifies webhook signatures over the raw request bytes |
POLAR_PRO_PRODUCT_ID | Product id for the Pro plan |
POLAR_MAX_PRODUCT_ID | Product id for the Max plan — independent of Pro |
Billing is optional by design. An instance with no provider credentials boots normally and serves dispatch, keys, policies, guardrails, logs, audit and everything else. Checkout and webhook routes return a clear error rather than taking the process down at startup, and every account is treated as free tier. For a single-tenant internal deployment, leave all five unset and use the tier-override endpoint to provision your own account.
Selling only Pro is a valid configuration: with POLAR_MAX_PRODUCT_ID unset, a Max checkout is refused rather than silently billing the Pro product.
Dashboard variables#
| Variable | Notes |
|---|---|
NEXT_PUBLIC_API_URL | Where the browser reaches the API |
CLERK_SECRET_KEY | Clerk backend key |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Clerk frontend key |
SUPER_ADMIN_EMAILS | Same list as the API's. Checked independently |
NEXT_PUBLIC_POLAR_PRO_PRICE | Display only, e.g. $10 |
NEXT_PUBLIC_POLAR_MAX_PRICE | Display only, e.g. $30 |
Why the allow-list is checked twice
The dashboard decides whether to render the control plane; the API decides whether to answer it. Neither trusts the other. A dashboard bug that rendered the operator UI to the wrong person would still get 403 from every endpoint behind it.
Set `DASHBOARD_ORIGIN` before you expose the API
It defaults to http://localhost:3001. In production it must be your real dashboard origin. Leaving the default means the browser cannot reach the API from your dashboard; setting it to * would mean any page on the internet could.
Local setup#
git clone <your-arcus-repo> arcus
cd arcus
pnpm installCreate apps/api/.env:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/arcus"
REDIS_URL="redis://localhost:6379"
CLERK_SECRET_KEY="sk_test_xxxxxxxx"
DASHBOARD_ORIGIN="http://localhost:3001"
SUPER_ADMIN_EMAILS="you@yourcompany.com"Create the schema and generate the client:
cd apps/api
npx prisma migrate deploy
npx prisma generateStart everything from the repository root:
pnpm devThat runs every workspace app in parallel — the API on :4000 (with the delivery worker in-process) and the dashboard on :3001.
Confirm the API is alive:
curl -sS http://localhost:4000/health{ "status": "ok", "service": "arcus-api" }Then sign in at http://localhost:3001, mint a key, and run the quickstart against your own instance.
Run a local target while you are learning
You need something to deliver to. Any HTTP server that returns 200 and prints the body works — a 20-line Express or Flask app is enough, and it makes the whole pipeline visible end to end.
Migrations#
Arcus uses Prisma migrations. In any environment with data you care about, exactly one command:
npx prisma migrate deployForward-only. No shadow database, no drift check, no reset.
Never run `prisma migrate dev` against real data
It resets the database. It is a development-loop command, and on a deployment it destroys tenants, keys, logs and audit chains. migrate deploy is the only correct command outside local development.
To review the SQL before it runs — advisable, and the right habit for anything touching the audit chain:
npx prisma migrate diff \
--from-schema-datasource prisma/schema.prisma \
--to-schema-datamodel prisma/schema.prisma \
--scriptRead the output. Then apply it.
Do not hand-edit the _prisma_migrations table. If a deployment has drifted, resolve it explicitly with prisma migrate resolve and record what you did — rewriting migration history makes the next person's problem unfixable.
Production deployment#
Any platform that runs a Node process works. The reference deployment is a managed platform with PostgreSQL and Redis add-ons.
Build#
pnpm install --frozen-lockfile
pnpm --filter @arcus/api exec prisma generate
pnpm --filter @arcus/api build # tsc -p tsconfig.json
pnpm --filter @arcus/dashboard build # next buildRun#
| Process | Command |
|---|---|
| API (worker in-process) | node apps/api/dist/index.js |
| Delivery worker (optional, standalone) | pnpm --filter @arcus/api worker |
| Dashboard | pnpm --filter @arcus/dashboard start |
Run prisma migrate deploy as a release step, before the new process starts serving.
Topology choices#
One API instance, worker in-process. The default, and correct for most deployments. Simplest thing that works.
Multiple API instances, worker in-process on each. Horizontal scaling for enforcement. BullMQ coordinates through Redis, so several workers drain the same queue safely at concurrency 5 each. Note that the health endpoint's process metrics are per instance — uptime and RSS describe the instance that answered.
Separate worker process. Choose this when a slow target must not compete with enforcement for CPU, or when you want to scale delivery independently. Set REDIS_URL and DATABASE_URL identically in both.
Checklist#
NODE_ENV=productionDASHBOARD_ORIGINset to the real dashboard origin- TLS terminated at the platform edge — never serve the API over plain HTTP
SUPER_ADMIN_EMAILSset to the smallest possible set, on accounts with MFA/healthwired as the platform's liveness probe- Database backups enabled and restore tested, because the audit chain lives there
- Redis persistence considered: losing it loses in-flight queue jobs and rate-limit counters
Redis is not optional in practice
The API boots without it, but nothing gets delivered — dispatches are recorded FAILED with Queue unavailable. If your platform's Redis restarts, in-flight jobs are lost and those dispatches need resending.
Health and monitoring#
GET /health — unauthenticated, no dependency checks. This is what a load balancer should poll. It answers "is the process serving requests".
GET /v1/admin/platform/health — operator-only, and the one to build alerting on. Live PostgreSQL and Redis probes, delivery-queue depth, process metrics, real 24-hour throughput and a true p95 latency. Both datastore probes are wrapped so an outage is reported rather than returned as a 500. Full detail: Platform endpoints.
Poll it at 10 seconds. The two trend arrays are memoised for 60 seconds server-side, so a 10-second poll costs two round trips rather than a 24-hour scan.
What to alert on:
| Signal | Meaning |
|---|---|
| Redis probe failing | Rate limiting has failed open; nothing is being delivered |
| PostgreSQL latency climbing | Every gate is on this path; enforcement is slowing |
waiting jobs climbing steadily | The worker is down or cannot keep up |
failed jobs climbing | Targets are rejecting deliveries |
| p95 latency climbing | Usually a slow target, not Arcus |
And on your own schedule, from a job rather than a person: run /v1/admin/audit/verify and alert if valid is ever false. That is the single most important check in the system, and it is the one nobody remembers to automate.
Backups#
The audit chain is the reason to take this seriously. It is the artefact you would produce to an auditor, and it exists in exactly one place: your PostgreSQL database.
- Enable managed backups and, once, actually restore one. An untested backup is a hypothesis.
- Export the chain periodically to storage outside the database: /v1/admin/compliance/export with
format=jsonpreserveshashandprevHash, so an archive can be re-verified independently. - Archive where the database credentials do not reach. This is what turns a tamper-evident chain into evidence against wholesale replacement. The reasoning is in Security model, and there is a ready-made script in Compliance reporting.
Redis holds rate-limit counters and queue jobs. Counters are disposable — losing them means agents get a fresh 60-per-minute budget. Queue jobs are not: losing them loses undelivered dispatches, which remain visible as non-terminal rows in your transfer logs.
Operating a single-tenant instance#
If Arcus is internal infrastructure rather than a product you resell:
- Leave all five billing variables unset. Every account is free tier.
- Set
SUPER_ADMIN_EMAILSto your own address. - Provision your own account at the tier you want with /v1/admin/tenants/:id/tier — an administrative override, independent of any provider, with a
reasonthat lands in your audit chain. - Ignore the abuse queue. It exists to triage tenants, and you are the only one.
That gives you unlimited guardrails, full audit history and chain verification with no payment provider configured at all.
Tests#
pnpm --filter @arcus/api test # guardrails + control plane
pnpm --filter @arcus/api test:e2e # full pipeline against a running instance
pnpm --filter @arcus/api test:guardrails
pnpm --filter @arcus/api test:superadminThe end-to-end suite needs the API running, Redis up, a target to deliver to, and CLERK_SECRET_KEY set — it mints a real session to exercise the control plane. Suites that cannot authenticate skip with a stated reason rather than passing silently.
Run these after a migration and before a release. They exercise the enforcement order itself, which is the part where a regression is most expensive.
Upgrading#
- Read the diff for schema changes.
pnpm install --frozen-lockfilenpx prisma migrate deploy, thennpx prisma generate- Build both apps
- Run the test suites
- Deploy, then verify the audit chain once — the fastest confirmation that nothing touched history
Verify after every deploy
/v1/admin/audit/verify takes seconds and is the cheapest possible check that a migration did not disturb the chain. Put it in your release script.