Server and configuration
How to start the server, where it keeps its state, what it reads from the environment, how scheduled flows fire, and how to put a new version live without stopping anything.
Starting the server
$ nexus serve --port 9090NexusFabric server starting on 0.0.0.0:9090 db: /home/you/.nexus/registry.db ui db: /home/you/.nexus/nexus-ui.db audit db: /home/you/.nexus/nexus-audit.db log db: /home/you/.nexus/nexus-log.db queue dir: /home/you/.nexus/queues webhook signature verification: disabledOne process serves everything: the flow endpoints, the WSDL endpoint, the health check, the queue workers, the cron scheduler, and the management UI. Start the same binary again against the same state directory and you have a second engine — the two coordinate through file locks, with no orchestrator between them. See Running more than one engine.
| Flag | Default | Meaning |
|---|---|---|
--host <addr> |
0.0.0.0 |
Address to bind. |
--port <n>, -p |
9090 |
Port to bind. |
--db <path> |
~/.nexus/registry.db |
Registry database. Its parent directory becomes the state directory — every other file is placed relative to it. |
--grpc-port <n> |
off | Open the gRPC door on a separate port, h2c (plaintext). Without the flag nothing listens, and a flow deployed with inbound: leaves a WARN naming it. 0 asks the system for a port, announced as NexusFabric gRPC ingress listening on <addr>. See gRPC. |
--secret <key> |
none | Shared secret for X-Nexus-Signature verification. When set, every /run and /enqueue request must carry a valid signature. See Authentication. |
--no-auth |
off | Serve without API key authentication. Local development only — the boot line reads DISABLED (--no-auth) and a warning goes to the log on every start. |
API keys are required unless you pass --no-auth, so the boot output ends with
API key authentication: enabled and a further warning if the registry holds no keys at all. See
Authentication.
There is no TLS option. Terminate TLS in front of the process. That is a transport decision and it is independent of the gate above: a reverse proxy is not a substitute for a key, because the platform would then have no caller identity of its own.
The message-size ceiling is 1 MiB by default and is resolved per access point, not per process:
max_message_bytes in system_config, overridden by a flow’s front matter, overridden by an
http_egress step for that call. With the gate on, an unauthenticated caller announcing an
oversized body is refused with 401 before the ceiling is even consulted. See Limits.
Where state lives
Everything is derived from the parent directory of --db. With the default, that is ~/.nexus.
| Path | Holds |
|---|---|
registry.db |
Compiled flow artifacts (as content-addressed blobs), the version registry and its refs, API keys, protobuf descriptors, platform settings. |
nexus-audit.db |
The hash-chained audit trail, one chain per tenant. |
nexus-log.db |
The structured log — one row per event. |
nexus-ui.db |
Management UI users, sessions and preferences. |
logs/nexus-log-YYYY-MM-DD.jsonl |
The structured log again, as JSON Lines, one file per day. |
queues/{tenant}/{flow}/ |
Write-ahead queue files for one flow: segment.log, its checkpoint, its retry schedule, its message-id counter (segment.log.seq, which keeps counting across a drain so an id is never reissued), and dlq.log with its marks file. |
The databases are ordinary SQLite files. They are opened with synchronous=FULL and a rollback
journal rather than WAL mode, so the state directory can live on a shared filesystem. If two
server processes share one state directory, they coordinate through lock files: only one runs
the cron schedules, and only one runs the workers for any given queue.
Back up the state directory as a unit. The four databases reference each other only by tenant and flow name, but a partial restore will leave the audit chain and the log describing artifacts the registry no longer has.
Back it up with sqlite3 .backup, not with cp — two of the four databases are in WAL mode, and
a file copy of one of those takes a database that looks intact and is missing its last writes.
The script, the restore procedure and the checks that prove it worked are on
Backup and restore.
Environment variables
| Variable | Read by | Meaning |
|---|---|---|
NEXUS_SECRET_<KEY> |
The secret_read effect |
The value of the secret named <KEY>. See below. |
NEXUS_CONFIG_<KEY> |
serve, config check |
The value of the configuration key <KEY> declared by a flow’s config:. See below. |
RUST_LOG |
Every command | Filter for the operational log the process writes to stderr. Default info. |
NEXUS_UI_ADMIN_USER |
serve |
Username for the first management UI account. |
NEXUS_UI_ADMIN_PASS |
serve |
Password for that account. |
HOME (or USERPROFILE on Windows) is used only to compute the default database path.
How secrets are named
A step with the secret_read effect names a secret with key:; if it has no key:, the step
name is used with - and . replaced by _. The variable name is that key, uppercased, with
NEXUS_SECRET_ in front.
## Step: load-api-tokeneffects: [secret_read]key: downstream_tokenThat reads NEXUS_SECRET_DOWNSTREAM_TOKEN and makes the value available to later steps as
ctx.downstream_token. A missing variable fails the step. The variable name never appears in
the response or in the log — only the key does.
Separately, a literal endpoint: may contain ${VAR}, which is substituted from the process
environment when the step runs. An unset variable fails the step rather than sending a request
to a half-built URL. See Secrets and OAuth2.
How configuration is named
A flow declares the environment configuration it needs
(config:), and each name is fed by
NEXUS_CONFIG_<KEY>:
config: [BILLING_URL]
## Step: forwardeffects: [http_egress]endpoint: "{{ ctx.BILLING_URL }}/submissions"That reads NEXUS_CONFIG_BILLING_URL. Same source as secrets, different notion: an endpoint is not a
secret, so it is not masked in the log and it costs no step.
Two differences from secrets matter operationally:
- A missing value stops the server, at start-up, naming the tenant, the flow, the key and the
variable — not at the first message that needs it. Ask
nexus config checkfor the same verdict without starting anything. - A registry that cannot be listed also stops the server. The check has nothing to check
against, and starting anyway would mean starting on a gate that verified nothing. An individual
artifact that cannot be read does not stop the boot: it is reported, at
ERROR, naming the flow — the server cannot say what that flow requires, so it does not pretend it checked. - A flow published with the old
ctx.Xspelling on a credential key also stops the server. Since 2026-09-16bearer_token:and the fiveoauth2_tokenkeys are{{ ctx.X }}templates and the bare form is no longer read; an artifact that still carries it would send nothing where a credential was meant. The refusal names the tenant, the flow, the step and the key — never the value — and asks for the flow to be rewritten and republished.nexus config checkgives the same verdict. A literal published before,${TOKEN}included, keeps working. - The values are captured once, at start-up. A secret is read on every execution, so rotating one takes effect without a restart. Configuration is not: changing it requires a restart. That is the price of the start-up check being worth anything — if binding re-read the environment, the check would have verified values the platform is no longer running on.
Set-but-empty is not the same as absent. An empty prefix is a legitimate value: it starts the
server and is reported at INFO. An absent one is a refusal.
The first UI account
If nexus-ui.db has no users and both NEXUS_UI_ADMIN_USER and NEXUS_UI_ADMIN_PASS are set
at startup, that account is created with the sysadmin role. Once any user exists the two
variables are ignored, so remove them after the first boot. If they are not set and there are
no users, the UI has no way to log in and says so in the operational log.
.env
Every nexus command loads a .env file from the working directory or a parent before doing
anything else. It does not override variables already present in the environment — a real
environment variable always wins.
RUST_LOG=info,nexus_http=debugNEXUS_SECRET_DOWNSTREAM_TOKEN=s3cr3tNEXUS_CONFIG_BILLING_URL=https://billing.example.comNEXUS_UI_ADMIN_USER=adminNEXUS_UI_ADMIN_PASS=change-me-nowBecause .env is read before the log subscriber is installed, RUST_LOG set there takes
effect.
Capacity settings
These live in the system_config table of the registry database, not in the environment, because
they belong to the deployment rather than to the process that happens to be running. Most are read at startup, so changing one takes effect on the next restart; the two marked below
as read per request or per cycle are the exceptions.
| Key | Default | What it limits |
|---|---|---|
max_concurrent_flow_executions |
32 per core, capped at 256 | Flow executions running at once, across all tenants. Past it, /run answers 503 with Retry-After |
max_queue_workers |
4 per core, capped at 64 | Queue workers running at once, across all tenants |
max_queue_workers_per_tenant |
equal to max_queue_workers |
One tenant’s share of the above |
flow_max_duration_secs |
7200 |
Wall time of one execution, checked between steps |
max_message_bytes |
1048576 |
Default ceiling on one message body. Read per request, not at startup, and overridable per flow and per egress step |
log_retention_days |
30, minimum 1 |
How long log rows and the daily JSON Lines files are kept. Read at the start of each retention cycle |
$ sqlite3 ~/.nexus/registry.db \ "INSERT INTO system_config (key, value) VALUES ('max_queue_workers_per_tenant', '8') ON CONFLICT(key) DO UPDATE SET value = excluded.value;"A value that cannot be parsed falls back to the default rather than removing the limit. There is no way to express “unlimited”: the defaults exist because an unbounded server accumulates work instead of refusing it, and a caller waiting behind an invisible queue is worse off than one told to come back.
One worker per queue, and it never stops
A queue worker drains one (tenant, flow) queue and holds its thread for the life of the
process. So max_queue_workers is not a rate — it is the number of distinct queues that can drain
on this server at all. Reach it, and a queue that does not already have a worker will not get one
until you restart. Its messages stay durable and unprocessed, and /enqueue keeps answering 202.
Two things follow, and both are worth planning for rather than discovering:
- Count your queues, not your traffic. A deployment with 50 queued flows needs
max_queue_workersabove 50, however quiet they are. max_queue_workers_per_tenantis inert until you set it. It defaults to the global ceiling, so out of the box one tenant can take every worker slot and leave nothing for the others — permanently, since slots come back only on restart. The default is deliberate: a fraction would have silently cost queues to every single-tenant install on upgrade. Startup logs a line saying the quota is inert, so it is not mistaken for a protection that is switched on.
A tenant that has reached its quota is refused even when the server has room. That is the point: whether your worker starts depends only on what your tenant has deployed, not on what the neighbours happen to be doing at that moment.
Scheduled flows
A flow with a schedule: key in its front matter runs on a timer.
---flowmarkdown_version: "0.1"flow: pull-meter-readingstenant: acmeschedule: "0 */15 * * * *"effects: [http_egress]---
## Step: fetcheffects: [http_egress]endpoint: https://meters.example.com/readingsmethod: GETThe cron format
Six fields, not the usual five. Seconds come first.
sec min hour day-of-month month day-of-week 0 30 * * * *| Expression | Fires |
|---|---|
"0 30 * * * *" |
Every hour at minute 30, second 0. |
"*/10 * * * * *" |
Every ten seconds. |
"0 */15 * * * *" |
Every fifteen minutes. |
"0 0 2 * * *" |
Every day at 02:00:00. |
A five-field expression is not valid here and the flow will not be scheduled. Quote the value —
it contains spaces and *.
What a tick passes in
The flow is invoked with a single-field object:
{ "triggered_at": "2026-08-06T09:15:00.123456789+00:00" }Read it as $.triggered_at. Nothing else is supplied: there is no request, no headers, and no
correlation ID from a caller.
A scheduled run has no HTTP response, so its output is discarded. Whatever the flow is for must happen in an effect — an HTTP call, a queued submission — not in the value the last step returns.
When changes take effect
The schedule list is read once, at startup, from the latest artifact of every flow. Each tick
resolves latest again before running, so changing a flow’s steps takes effect on the next
tick, but adding, changing or removing a schedule: takes effect only after a restart.
An unparseable cron expression is skipped with a warning in the operational log; the rest of the flows are scheduled normally.
Deploying a new version
nexus deploy compiles the file, stores the artifact, registers it under (tenant, flow, version), and moves that flow’s latest pointer to it. The tenant in the file’s tenant: must
already exist — register it once with nexus tenant create, since deploy refuses an unknown or
disabled one rather than creating it.
$ nexus deploy forward-order.flow.md --version 1.3.0deployed forward-order.flow.md flow: forward-order tenant: acme version: 1.3.0 hash: 9f2c... db: /home/you/.nexus/registry.dbYou can run this while the server is running. Every request resolves latest at call time, so
the next request after the deploy uses the new artifact. Requests already in flight finish
against the artifact they started with. No restart, no connection drop, no queue drain — with
one exception: a change to schedule: needs a restart, as above.
Point --db at the same database the server was started with. If you started the server with a
non-default --db, pass the same path to deploy.
A version is claimed once. Deploying a version number that already exists for that flow fails and changes nothing:
$ nexus deploy forward-order.flow.md --version 1.3.0error: registering acme/forward-order v1.3.0: artifact already exists: tenant=acme name=forward-order version=1.3.0Compile before you deploy if you want the errors without the side effects — nexus validate
runs fewer checks than nexus deploy does. See Command line.
Rolling back
Deploy the previous source again under a new version number. That moves latest back to
equivalent bytes:
$ nexus deploy forward-order-1.2.0.flow.md --version 1.4.0How versions resolve
| What | Resolves to |
|---|---|
POST /flows/{tenant}/{flow}/run |
The latest artifact, looked up per request. |
POST /flows/{tenant}/{flow}/enqueue |
The queue stores only the message. The worker resolves latest when it picks the message up — which may be a newer version than was live at submission time. |
GET /flows/{tenant}/{flow} |
Metadata of the latest artifact: version, hash, deployment time. |
GET /flows/{tenant}/{flow}/wsdl |
Generated from the latest artifact. |
| A cron tick | The latest artifact, resolved at each tick. |
Older versions are never deleted. They stay in the registry with their artifact bytes intact,
and the Registry page of the management UI lists them. The HTTP endpoints always run latest;
there is no per-version URL.
Artifacts are addressed by the hash of their own bytes, and that hash is verified on every read. If a stored artifact no longer matches its key, the request fails with 500 rather than executing something that changed underneath you.
Log and queue housekeeping
Five background tasks run inside the server.
| Task | Interval | What it does |
|---|---|---|
| Log retention | Every 6 hours, and once at startup | Deletes flow_log rows from nexus-log.db and the daily logs/nexus-log-*.jsonl files older than the retention window. Default 30 days, minimum 1. |
| Queue compaction | Every hour, configurable | Deletes the queue files of any (tenant, flow) whose messages have all been acknowledged and which has no pending retries, no unhandled dead letters, and no running worker. The directory stays; the next submission recreates the files. A queue that does have a worker reclaims its own space instead, in place — see Queues and delivery. |
| Correlation reconciliation | Every 60 seconds, first pass immediately | Marks correlations past their deadline timed_out and, where one is declared, enqueues the compensating flow with the recorded state as its message. |
| Flow-state purge | Every 6 hours, and once at startup | Deletes flow_inbox rows past their own expires_at and closed / timed-out flow_correlations rows more than flow_state_retention_hours (default 96, minimum 1) past their deadline. See below. |
| Certificate expiry sweep | Every cert_check_interval_hours, and once at startup |
Emits cert_expiring / cert_expired for certificates inside the warning window. |
Log retention and queue compaction are configured from the Settings page of the management UI, and both read their settings at the start of each cycle — a change applies at the next cycle, without a restart. That page also has buttons to run either immediately.
The flow-state window
flow_state_retention_hours (a system_config value, default 96) is read once, at startup —
unlike the two above — so a change to it takes effect only after a restart. The boot banner prints
the value in force: flow state (dedup + correlation): enabled — retention 96h.
The two tables it purges are cut differently:
flow_inbox(deduplication): each row carries its ownexpires_at, fixed at claim time from the window then in force — the flow’sdedup_window_hoursif it set one, otherwise the installation default. Loweringflow_state_retention_hoursand restarting does not shorten receipts already written; the new value applies to later claims only.flow_correlations: a closed, timed-out, or late-closed row is deleted once its deadline is more thanflow_state_retention_hoursin the past — cut retroactively, and measured from the deadline, not from when the row closed.
A correlation key is one-shot for that whole window. correlation_open: refuses to reuse a key
that still has a row — open, closed, or timed_out alike — because a late or duplicate reply
could still arrive on it. The key frees only after its row is purged: deadline + flow_state_retention_hours,
and then at the next purge pass. For a test loop, send a fresh key each time (a distinct
X-Correlation-ID, or none, so the platform generates one). A stuck row can also be cleared by
hand with nexus state release.
What log retention covers
One setting governs both outputs of the log. Until it did, the half that was never cleaned was
the half that holds plaintext payloads at log_level: full — the rows were pruned on schedule while
the files grew without limit.
The two edges differ deliberately. Rows are compared against an instant; files against a date, because a file covers a whole day, so the one dated exactly on the boundary is kept. Files therefore live up to a day longer than the equivalent rows. That is the safe direction.
Three rules protect the files you did not ask us to touch:
- The current day’s file is never removed. The writer holds it open, and on Unix unlinking it would send every subsequent line into an invisible inode.
- A file with a name that is not ours is left alone.
- A file that is ours but carries no parsable date is reported, not deleted.
Neither half can stop the other: if row deletion fails, the files are still swept, and the UI reports the failure as a warning instead of showing you a success built from the other half’s numbers.
Health
$ curl http://localhost:9090/health{"ok":true}Unauthenticated and free of database access — it answers as long as the process is listening.