ADR 0013: Deployment model
Stateless planes, one image, forward-only migrations, and enforcement mode stored as data rather than configuration.
#Context
Veefore has to be deployable by a team that is not the team that wrote it, into infrastructure they already run, with a rollout that does not require a maintenance window. And it must be hard to deploy in a way that quietly weakens the security model.
Four decisions follow from that.
#Decision
#Stateless request-serving processes
Session state and the job queue live in PostgreSQL. Both planes can be scaled horizontally with no coordination, no sticky sessions and no shared cache.
#One image, three entry points
bashnode dist/control-plane/server.js
node dist/gateway/server.js
node dist/control-plane/worker.js
The processes share almost all their code. One image means one artefact to scan, sign and promote.
#Forward-only migrations
No down-migrations. Each migration is written to be safe against the previous application version, which is what makes a rolling restart possible:
text1. migrations (job, completes first)
2. control plane (rolling)
3. gateway (rolling)
4. worker (any time)
5. console assets (last)
Gateway after the control plane, so the control plane is already on the new schema when the gateway starts depending on it.
#Enforcement mode is data, not configuration
It lives in the database on the environment row, and changes only through
POST /v1/environments/{id}/gateway-mode or veefore deploy — with a reason,
step-up authentication, and a CRITICAL audit event if it reduces enforcement.
#Configuration is environment-only
No runtime configuration file, no settings table. Validated at startup; an invalid value stops the process rather than degrading it.
#Honest readiness
/health/ready genuinely checks the database on both planes.
#Alternatives
Enforcement mode as an environment variable. Would make it deployable with the rest of the configuration, and it would mean a misconfigured pipeline can reduce enforcement. It would also lose the reason, the step-up and the audit event.
The whole asymmetry — tightening is routine, loosening leaves a mark — depends on the mode being data that changes through an audited operation. Rejected.
Reversible migrations. The conventional expectation. A down-migration that drops a column drops the data in it, and for an audit trail that is deletion rather than rollback.
The route to undoing a schema change is a new forward migration, which leaves a record. Application rollback is separate and works, because migrations are safe against the previous version.
Separate images per process. Smaller images, and three artefacts to scan, sign, promote and keep in step. The shared packages are the bulk of the code anyway, so the size saving is modest and the drift risk is real.
Stateful sessions in process memory. Faster session lookup. Requires sticky sessions, breaks on a rolling restart, and makes horizontal scaling a coordination problem.
A runtime configuration file or settings table. Lets an operator change behaviour without a restart. It also means the running configuration can disagree with the process that booted, and "what is this deployment actually configured as" becomes a question with two answers.
Liveness-only health checks. Simpler. A process that is live with a broken database connection stays in the load balancer's rotation and serves errors, which is worse than being pulled out.
#Consequences
PostgreSQL is on the critical path for session lookup. Acceptable: it is already on the critical path for every authorization decision, and fail-closed means a database outage refuses protected actions regardless.
DATABASE_POOL_MAX is per process instance, and scaling out multiplies
connections. Three control plane, two gateway and one worker at 20 each is 120.
Size max_connections for the total, and consider PgBouncer past a handful of
instances. A recurring operational surprise, so it is stated in
Deployment and
Database.
Migrations must be written to be backwards-safe, which constrains what a
migration may do. Adding a nullable column is fine; renaming one in a single
migration is not. npm run migrate:check applies every migration to a throwaway
database and verifies it comes out clean.
Editing an applied migration is a silent divergence. The edit applies only
to new environments, and nothing indicates the drift. The rule is to add a new
one, and migrate:check is the gate.
Separate ingresses are a deployment responsibility the manifests cannot
enforce. Exposing /v1/* where agents can reach it undoes
ADR 0002, and no Kubernetes
manifest can prevent it. Stated prominently and checked in review.
Each process gets only the secrets it needs. The gateway does not issue
human sessions, so it does not need VEEFORE_SESSION_SECRET. A table of what
each process requires is in Run the stack.
A database backup alone is not restorable. Credential material is sealed
with VEEFORE_DATA_ENCRYPTION_KEY and the chain is keyed with
VEEFORE_AUDIT_CHAIN_SECRET, neither of which is in the database. Both must be
backed up alongside it, and the restore must be tested with both.
Console configuration is baked in at build time. VITE_* values are
compiled into the bundle, so changing the API URL requires a rebuild and
redeploy — restarting a server does nothing. A real source of confusion, so it
is called out in Configuration and
Troubleshooting.
At least one worker must run, and nothing visibly breaks without it.
Webhooks stop delivering and usage stops rolling up, silently. Monitor
veefore_job_queue_depth — a stalled queue is the failure mode that does not
page anyone.