Arcus Docs

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#

text
  ┌──────────────┐    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.

ComponentRole
APIEnforcement, recording, and the control plane. The only process agents talk to
Delivery workerDrains the queue and performs outbound HTTPS with retries
DashboardNext.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#

VersionNotes
Node.js20.12+24.x recommended. process.loadEnvFile is used, so there is no dotenv dependency
pnpm11.xThe repository is a pnpm workspace
PostgreSQL14+Anything Prisma 5 supports. percentile_cont is used for p95 latency
Redis6+Rate limiting and the delivery queue
ClerkAuthentication for the control plane. A free project is enough to start
Payment providerOptional. 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#

VariableNotes
DATABASE_URLPostgreSQL connection string. The process refuses to boot without it
CLERK_SECRET_KEYClerk backend key. Required for the control plane and for operator checks

Optional, with defaults#

VariableDefaultNotes
REDIS_URLredis://localhost:6379Rate limiting and the delivery queue
PORT4000API listen port
NODE_ENVdevelopmentSet to production in a deployment
DASHBOARD_ORIGINhttp://localhost:3001The 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#

VariableNotes
POLAR_ACCESS_TOKENProvider API token
POLAR_SERVERsandbox (default) or production
POLAR_WEBHOOK_SECRETVerifies webhook signatures over the raw request bytes
POLAR_PRO_PRODUCT_IDProduct id for the Pro plan
POLAR_MAX_PRODUCT_IDProduct 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#

VariableNotes
NEXT_PUBLIC_API_URLWhere the browser reaches the API
CLERK_SECRET_KEYClerk backend key
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYClerk frontend key
SUPER_ADMIN_EMAILSSame list as the API's. Checked independently
NEXT_PUBLIC_POLAR_PRO_PRICEDisplay only, e.g. $10
NEXT_PUBLIC_POLAR_MAX_PRICEDisplay 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#

bash
git clone <your-arcus-repo> arcus
cd arcus
pnpm install

Create apps/api/.env:

bash
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:

bash
cd apps/api
npx prisma migrate deploy
npx prisma generate

Start everything from the repository root:

bash
pnpm dev

That 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:

bash
curl -sS http://localhost:4000/health
json
{ "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:

bash
npx prisma migrate deploy

Forward-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:

bash
npx prisma migrate diff \
  --from-schema-datasource prisma/schema.prisma \
  --to-schema-datamodel prisma/schema.prisma \
  --script

Read 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#

bash
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 build

Run#

ProcessCommand
API (worker in-process)node apps/api/dist/index.js
Delivery worker (optional, standalone)pnpm --filter @arcus/api worker
Dashboardpnpm --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=production
  • DASHBOARD_ORIGIN set to the real dashboard origin
  • TLS terminated at the platform edge — never serve the API over plain HTTP
  • SUPER_ADMIN_EMAILS set to the smallest possible set, on accounts with MFA
  • /health wired 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:

SignalMeaning
Redis probe failingRate limiting has failed open; nothing is being delivered
PostgreSQL latency climbingEvery gate is on this path; enforcement is slowing
waiting jobs climbing steadilyThe worker is down or cannot keep up
failed jobs climbingTargets are rejecting deliveries
p95 latency climbingUsually 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=json preserves hash and prevHash, 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:

  1. Leave all five billing variables unset. Every account is free tier.
  2. Set SUPER_ADMIN_EMAILS to your own address.
  3. Provision your own account at the tier you want with /v1/admin/tenants/:id/tier — an administrative override, independent of any provider, with a reason that lands in your audit chain.
  4. 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#

bash
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:superadmin

The 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#

  1. Read the diff for schema changes.
  2. pnpm install --frozen-lockfile
  3. npx prisma migrate deploy, then npx prisma generate
  4. Build both apps
  5. Run the test suites
  6. 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.