Run the stack
Three processes, what each is responsible for, how to check they are healthy, and how to scale them independently.
#The three processes
| Process | Entry point | Serves | Reached by |
|---|---|---|---|
| Control plane | apps/control-plane/src/server.ts |
/v1/* |
People, console, CLI, your CI |
| Gateway | gateway/src/server.ts |
/gateway/v1/* |
Agents only |
| Console | apps/console |
The browser app | People |
Plus a background worker, apps/control-plane/src/worker.ts, which runs the job
queues: webhook fan-out and delivery, usage rollups, risk baseline recomputation,
notifications, and reclaiming abandoned jobs.
The split is a security boundary, not a packaging choice
The gateway is the only process agents can reach. It has a smaller HTTP surface, it accepts no human credentials, and it can be scaled, network-isolated and hardened independently of the management API.
Do not merge them. Do not expose /v1/* on the same ingress agents reach.
#Health
bashcurl -s http://localhost:4000/health/live
curl -s http://localhost:4000/health/ready
curl -s http://localhost:4010/health/live
curl -s http://localhost:4010/health/ready
| Endpoint | Means | Use as |
|---|---|---|
/health/live |
The process is up and the event loop is responsive | Liveness probe |
/health/ready |
Dependencies are reachable — the database, in particular | Readiness probe |
/health/ready genuinely checks the database. If the database is gone,
readiness goes red rather than reporting healthy and then failing every request.
This was a real bug worth knowing about
The gateway's readiness check once stayed green with no database, because the embedded driver silently booted a fresh empty database on the next query after being closed. A "healthy" gateway with an empty database is worse than a red one, because your load balancer keeps sending it traffic. The driver now treats close as terminal, and readiness reflects reality.
#Scaling
Both planes are stateless. Session state and the job queue live in the database. Run as many instances as you need behind a load balancer.
Scale them independently. They have completely different load profiles: the gateway handles agent traffic, which is high volume and latency sensitive; the control plane handles human and CI traffic, which is low volume and bursty. Typical ratio is 3:1 gateway to control plane.
Run at least one worker. Job claiming is tenant-safe and guarded against overlapping poll ticks, so several workers can run concurrently without duplicating work. With no worker, webhooks never deliver and usage never rolls up — and nothing visibly breaks, which is the annoying part.
Another real bug worth knowing about
Every job claim once hit the repository's tenant-scope guard. The error was swallowed by the poll loop, and 67 jobs wedged every queue permanently with no alert. Claiming now carries an explicit cross-tenant justification, abandoned jobs are reclaimed and dead-lettered after repeated failure, and overlapping ticks are prevented.
Monitor queue depth. A silently stalled queue is the failure mode that does not page anyone.
Redis is optional. Leave REDIS_URL empty for the in-process store, which
is correct for a single-node run. Set it for multi-node, so rate limiting and
nonce tracking are shared — otherwise each instance has its own view and the
effective rate limit is multiplied by your instance count.
#Starting it
Development:
bashnpm run dev # all three, from the root .env
npm run dev:control-plane
npm run dev:gateway
npm run dev:console
Production:
bashnpm run build
node dist/control-plane/server.js
node dist/gateway/server.js
node dist/control-plane/worker.js
The console builds to static assets. Serve them from any static host or CDN; it
talks to the control plane from the browser, so CORS_ALLOWED_ORIGINS must
include wherever you serve it from.
#Ordering during a deploy
- Migrations.
npm run migrate. Migrations are forward-only and designed to be safe against the previous version, so this runs before any process restarts. - Control plane. Rolling.
- Gateway. Rolling.
- Worker. It can restart at any point; in-flight jobs are reclaimed.
- Console. Static assets; publish last.
Gateway last among the request-serving processes, so the control plane is already on the new schema when the gateway starts depending on it.
#Logs
Structured JSON through pino. LOG_LEVEL controls verbosity;
info is the production default.
Every line carries requestId and traceId, so a request_id from an error
response finds every log line that request produced.
Redaction is applied on the way in, not on the way out. There is no code path where a secret is written to a log and later filtered.
Do not raise the log level to debug in production and leave it
debug logs request and response shapes. Redaction still applies to known
secret shapes, but the volume alone will cost you, and a log retained for 90
days is a much larger surface than one retained for 7.
#Metrics
METRICS_ENABLED=true exposes Prometheus metrics at /metrics. See
Observability for what to alert on.
#What each process needs
| Control plane | Gateway | Worker | |
|---|---|---|---|
| Database | yes | yes | yes |
VEEFORE_DATA_ENCRYPTION_KEY |
yes | yes | yes |
VEEFORE_SIGNING_PRIVATE_KEY |
yes | yes | — |
VEEFORE_SESSION_SECRET |
yes | — | — |
VEEFORE_AUDIT_CHAIN_SECRET |
yes | yes | yes |
| Outbound egress | for webhooks | for tool execution | for webhooks |
| Inbound from agents | no | yes | no |
| Inbound from people | yes | no | no |
The gateway does not need VEEFORE_SESSION_SECRET, because it never issues a
human session. Keep it out of the gateway's environment — a secret a process
does not need is a secret it should not have.