Skip to content

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

Terminal window
$ nexus serve --port 9090
NexusFabric 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: disabled

One 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-token
effects: [secret_read]
key: downstream_token

That 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: forward
effects: [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 check for 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.X spelling on a credential key also stops the server. Since 2026-09-16 bearer_token: and the five oauth2_token keys 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 check gives 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=debug
NEXUS_SECRET_DOWNSTREAM_TOKEN=s3cr3t
NEXUS_CONFIG_BILLING_URL=https://billing.example.com
NEXUS_UI_ADMIN_USER=admin
NEXUS_UI_ADMIN_PASS=change-me-now

Because .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
Terminal window
$ 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_workers above 50, however quiet they are.
  • max_queue_workers_per_tenant is 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-readings
tenant: acme
schedule: "0 */15 * * * *"
effects: [http_egress]
---
## Step: fetch
effects: [http_egress]
endpoint: https://meters.example.com/readings
method: GET

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

Terminal window
$ nexus deploy forward-order.flow.md --version 1.3.0
deployed forward-order.flow.md
flow: forward-order
tenant: acme
version: 1.3.0
hash: 9f2c...
db: /home/you/.nexus/registry.db

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

Terminal window
$ nexus deploy forward-order.flow.md --version 1.3.0
error: registering acme/forward-order v1.3.0: artifact already exists: tenant=acme name=forward-order version=1.3.0

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

Terminal window
$ nexus deploy forward-order-1.2.0.flow.md --version 1.4.0

How 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 own expires_at, fixed at claim time from the window then in force — the flow’s dedup_window_hours if it set one, otherwise the installation default. Lowering flow_state_retention_hours and 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 than flow_state_retention_hours in 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

Terminal window
$ curl http://localhost:9090/health
{"ok":true}

Unauthenticated and free of database access — it answers as long as the process is listening.