Configuration
All configuration is via environment variables. No config files are required. The engine reads .env if present (via dotenv).
A numeric variable takes a plain integer: digits only, no unit, no exponent, no separators. A byte
size takes a number with an optional binary unit: 5242880, 100kb, 50mb, 5MiB and 5 MiB
all work, and mb means MiB. Leading and trailing spaces are ignored. Any other value, such as
60000ms, 1.5e6 or 5 242 880, stops the engine at boot with an error that names the variable
and the value, so a typo cannot start the engine with a limit you did not set.
Runtime & Logging
| Variable | Default | Description |
|---|---|---|
NODE_ENV |
development |
development, production, or test. The published image sets production in its runner stage. The development escapes read the declared value, so an unset NODE_ENV does not open them |
DEBUG |
false |
Enable verbose validation errors. Also switches deferred retry delays from seconds to milliseconds – intended for tests, never for production |
NEOPS_LOG_LEVEL |
info |
error, warn, info, debug. Defaults to debug when DEBUG=true. Replaces LOG_LEVEL |
NEOPS_LOG_FORMAT |
pretty |
json (production) or pretty (development); text is read as pretty. Auto-selects json in production. Replaces LOG_FORMAT |
PORT |
3030 |
HTTP server port |
Log store
The engine writes its own log lines to a log store (VictoriaLogs or Loki) next to stdout. Lines go
through an in-memory queue to the store; while the store is unreachable or refuses the engine’s
settings, and when the queue overflows, they wait in the log_outbox table of the engine’s own
database and are replayed once the store accepts a probe. Request and response body dumps and the
job result dump stay on stdout only.
| Variable | Default | Description |
|---|---|---|
NEOPS_LOG_BACKEND |
(unset) | victorialogs or loki. Unset: stdout only, nothing queued or stored, logs are not queryable. db is core’s value; the engine treats it as unset and says so at boot. Any other value aborts startup |
NEOPS_LOG_URL |
(unset) | Base URL of the store, e.g. http://victorialogs:9428 or http://loki:3100. Required with a backend; startup is refused without it |
NEOPS_LOG_USERNAME / NEOPS_LOG_PASSWORD |
(unset) | Basic auth for the store |
NEOPS_LOG_TENANT |
(unset) | Loki: a tenant id without whitespace, sent as X-Scope-OrgID. VictoriaLogs: <uint32>[:<uint32>], sent as AccountID and ProjectID (0 when no project is given). A malformed value aborts startup |
NEOPS_LOG_ENV |
(unset) | Deployment label written as env on every record |
NEOPS_LOG_QUEUE_MAX_RECORDS |
10000 |
In-memory queue; when full, its oldest lines move to the outbox |
NEOPS_LOG_BATCH_MAX_RECORDS |
500 |
Records per push; a full batch is pushed before the next tick |
NEOPS_LOG_BATCH_MAX_BYTES |
2097152 |
JSON-encoded bytes per push |
NEOPS_LOG_FLUSH_INTERVAL_MS |
200 |
Flusher period |
NEOPS_LOG_OUTBOX_MAX_ROWS |
500000 |
Outbox row cap; beyond it the oldest rows are deleted |
NEOPS_LOG_OUTBOX_MAX_BYTES |
1073741824 |
Outbox byte cap (1 GiB); beyond it the oldest rows are deleted |
NEOPS_LOG_OUTBOX_MAX_ATTEMPTS |
10 |
Replay failures after which a row is deleted; only failures in a tick where the store accepted another push count |
NEOPS_LOG_SETTLE_MS |
3000 |
Live-read horizon of the log read routes; values below 1000 are raised to 1000 |
A 401, 403, 404 or 405 from the store (a wrong URL, wrong credentials, or an
auth-enabled Loki without NEOPS_LOG_TENANT) logs one error and keeps every line in the outbox,
probing every 30 s. A network error, a timeout, a 5xx or a 429 keeps the lines in the outbox
and probes every 5 s (or after Retry-After). A store that refuses a line itself (400, 413,
422) loses that line only. Postgres jsonb cannot hold a NUL character or an unpaired UTF-16
surrogate, so a line that waits in the outbox has each replaced by U+FFFD; a line pushed straight
from the queue keeps them.
The outbox is a second copy of every line during an outage
While the store is away the engine’s Postgres holds up to NEOPS_LOG_OUTBOX_MAX_BYTES of
log rows, and a backup taken then carries them. The table is empty in steady state.
HTTP
| Variable | Default | Description |
|---|---|---|
NEOPS_CORS_ORIGINS |
(unset) | Comma-separated exact browser origins allowed to call the API cross-origin, e.g. https://neops.example,https://monitor.example. Unset with NODE_ENV=development reflects any origin; unset otherwise sends no CORS headers at all |
Most routes accept a request body of up to 100 kB. One route takes bulk payloads from workers and is raised to 50 MB:
POST /blackboard/job/result– job results, which can carry bulk entity updates
The two log push routes, POST /blackboard/job/logs and POST /workers/:uuid/logs, accept
1 MiB. That limit is part of their contract (x-neops-limits.bytesPerRequest in
schema/LogRecord.schema.json, which workers chunk by), so MAX_REQUEST_BODY_SIZE does not change it.
| Variable | Default | Description |
|---|---|---|
MAX_REQUEST_BODY_SIZE |
(unset) | Replaces the 50 MB limit of POST /blackboard/job/result, e.g. 100mb, 100MiB or a plain byte count. Leave unset to keep the built-in limit |
Set this when workers push results larger than 50 MB and get 413 Payload Too Large.
It does not change the 100 kB default
MAX_REQUEST_BODY_SIZE only adjusts the result route. Every other route, including workflow
publishing and the log push routes, keeps its limit and is unaffected by this variable.
A malformed value aborts startup
The value is checked when the engine starts, and anything that is not a byte size stops the boot with an error. This is deliberate: an unparseable limit would otherwise be read as unlimited, so a typo would silently remove the cap instead of setting it.
License
The license is compiled into the image and cannot be changed at runtime – there is no
environment variable that grants or upgrades one. To see which license is active and how much of
each quota is in use, call GET /license/; see
API Reference.
The build-time variables behind it are documented under Docker Deployment.
Database
The engine requires PostgreSQL 15+ and is tested against 17 (the version docker-compose.yml runs).
Its database user needs the CREATE privilege on its database (the database owner has it): a migration
installs the pg_trgm extension, which the run list’s text search uses.
| Variable | Default | Description |
|---|---|---|
POSTGRES_HOST |
localhost |
Database hostname |
POSTGRES_PORT |
5434 |
Database port |
POSTGRES_USER |
postgres |
Database user |
POSTGRES_PASSWORD |
unsafe |
Database password (override in production) |
Change the default password
The default password unsafe is for local development only. Always set a strong
password in production deployments.
Database Setup
Run migrations to create the schema:
npx mikro-orm migration:fresh # development (drops and recreates)
npx mikro-orm migration:up # production (applies pending migrations)
CMS Connection
The engine communicates with the neops CMS via GraphQL for entity acquisition and locking.
| Variable | Default | Description |
|---|---|---|
NEOPS_CMS_URL |
http://localhost:8000/graphql |
CMS GraphQL endpoint |
NEOPS_CMS_TOKEN |
unsafe |
Authentication token for CMS API. unsafe is the built-in placeholder; the engine refuses to start carrying it unless NODE_ENV=development |
CMS dependency
The CMS is required for entity acquisition and locking. Without a CMS connection,
workflows that use acquire clauses or seedEntity other than global will fail
during the resource discovery phase.
Authentication & Authorization
The engine verifies Neops-issued RS256 access tokens and authorizes a route from the permissions claim they carry. Permissions are granted in the neops CMS as <element>:<flag> strings.
| Variable | Default | Description |
|---|---|---|
NEOPS_AUTHZ_MODE |
enforce |
enforce, permissive, or disabled – see below |
NEOPS_JWT_PUBLIC_KEY |
(unset) | The CMS’s public key as an SPKI PEM, inline |
NEOPS_JWT_PUBLIC_KEY_PATH |
(unset) | A file holding that PEM, e.g. a mounted ConfigMap |
NEOPS_JWKS_URL |
(unset) | The CMS’s JWKS endpoint. https unless NODE_ENV=development AND NEOPS_ALLOW_INSECURE_JWKS=true |
NEOPS_ALLOW_INSECURE_JWKS |
false |
Accepts a plaintext-HTTP NEOPS_JWKS_URL, alongside a development NODE_ENV |
NEOPS_ALLOW_DISABLED_AUTHZ |
false |
Runs NEOPS_AUTHZ_MODE=disabled where NODE_ENV is not development |
NEOPS_JWT_ISSUER |
neops |
The iss a token must carry; matches the CMS’s own setting |
NEOPS_JWT_AUDIENCE |
neops-services |
The aud a token must carry; matches the CMS’s own setting |
Which routes these apply to
Every route handler carries exactly one authorization marker. The public set – the container
probes, GET /version, POST /version/compatibility and the worker channel – is served
anonymously in every mode. Every other route needs a valid token, and enforce needs the
route’s permission as well. test/auth/route-table.e2e-spec.ts holds the full table.
Providing the verification key
Pin the key. It is a public key, so it belongs in a ConfigMap – mount the same PEM the CMS loads from its own NEOPS_JWT_PUBLIC_KEY_PATH, or pass it inline. NEOPS_JWKS_URL is the fallback: it puts the trust anchor on the network, and the CMS serves exactly one key with no rotation window.
The engine refuses to start when none of the three is set, naming them in the failure.
A plaintext-HTTP JWKS is a total bypass
Whoever can answer for that host – a pod on the network, a compromised sidecar, DNS or ARP
control – serves their own key set, and can then mint tokens with any subject and any
permissions. Accepting one requires both NODE_ENV=development and
NEOPS_ALLOW_INSECURE_JWKS=true.
Modes
| Mode | Authentication | Authorization |
|---|---|---|
enforce (default) |
Valid Neops JWT required | The route’s permission must be in the token |
permissive |
Valid Neops JWT required | A token reporting authz_enforced: false is allowed through, with a warning |
disabled |
None – anonymous | None. Refuses to start unless NODE_ENV=development or NEOPS_ALLOW_DISABLED_AUTHZ=true |
permissive is for a CMS running without its permissions plugin: a CMS whose NEOPS_PLUGINS omits neops_permissions_simple issues tokens whose permissions claim is always empty and whose authz_enforced claim is false, so an enforce engine denies every gated route to every user. An enforce engine logs one WARN naming that pairing when the first such token arrives. Authentication stays required in permissive, so every request still carries an identity.
disabled is for a workstation with no CMS at all. It verifies nothing and serves every endpoint anonymously, including the ones that start executions against real devices.
NEOPS_ALLOW_DISABLED_AUTHZ=true runs it where NODE_ENV is anything else, and the engine logs an ERROR naming the mode at every boot for as long as it is set. That pairing is the recourse for an engine that can verify no token at all – a wrong key, issuer or audience answers 401 to every request in permissive too, since that mode still requires a valid token.
The token’s authz_enforced claim only ever tightens: a token minted by an enforcing CMS is enforced whatever the engine’s mode says. Operator configuration is the one input that loosens anything.
Worker Management
These thresholds control how the engine detects and handles unresponsive workers. The defaults cascade: unreachable (2 min) -> offline (3x = 6 min) -> stuck job (2x offline = 12 min).
| Variable | Default | Description |
|---|---|---|
WORKER_UNREACHABLE_THRESHOLD_MS |
120000 (2 min) |
Time since last heartbeat before worker is marked unreachable |
WORKER_OFFLINE_THRESHOLD_MS |
360000 (6 min) |
Time since last heartbeat before worker is marked offline and its jobs are failed. An offline worker no longer counts against the license worker quota |
WORKER_OFFLINE_DELETE_THRESHOLD_MS |
86400000 (24 h) |
Time before an offline worker is soft-deleted |
WORKER_CLEANUP_ENABLED |
true |
Enable automatic deletion of stale workers |
WORKER_CLEANUP_INTERVAL_MS |
3600000 (1 h) |
How often to check for stale workers |
Workflow Engine
The engine is a single-writer design: one instance drives the epoch machine for a given database. On startup it takes a Postgres session-level advisory lock and waits for any outgoing instance to release it, so a rolling deploy hands over without operator action. If the lock is still held when the timeout expires, the process refuses to start rather than running a second engine against the same database.
| Variable | Default | Description |
|---|---|---|
ENGINE_INSTANCE_LOCK_TIMEOUT_MS |
60000 (60 s) |
How long startup waits for another instance to release the single-instance lock before failing |
EPOCH_MAX_PARALLEL_RUNS |
unset (no cap) | Soft cap on how many workflows an epoch locks before it stops accepting new locks |
EPOCH_RETENTION_DAYS |
30 |
Age after which completed epochs are pruned; 0 or negative disables pruning |
EPOCH_CLEANUP_INTERVAL_MS |
3600000 (1 h) |
How often the epoch retention sweep runs |
Never run two engines against one database
Scale the engine vertically, not horizontally. A second instance is refused by the lock; if you deliberately need two engines, give each its own database.
EPOCH_MAX_PARALLEL_RUNS is a soft cap
It bounds sustained concurrency, not each individual epoch. The coordinator checks the cap between events against a count that may already be stale, while the packing itself happens inside concurrent handler transactions that do not consult it — so a burst of workflows reaching lock acquisition together can overshoot (three workflows packed with the cap set to one has been observed). Leave it unset unless you need to throttle sustained load; do not rely on it as a hard “at most N locked at once” guarantee.
PARKED_EXECUTION_PROBE_INTERVAL_MS (default 300000, 5 minutes; 0 disables) sets how
often the engine probes the CMS while executions are parked after repeated unlock failures;
once the CMS answers, the parked executions resume and release their locks without a
restart.
Completed epochs are pruned in batches on the EPOCH_CLEANUP_INTERVAL_MS schedule.
The latest epoch is never deleted, and neither is any epoch a non-terminal workflow
execution is still parked in — so pruning can never disturb work in flight, however
aggressive the retention setting.
Blackboard (Job Management)
| Variable | Default | Description |
|---|---|---|
BLACKBOARD_JOB_CHECK_INTERVAL |
60000 (1 min) |
How often the engine runs its job cleanup cycle (offline workers, stuck jobs, never-polled jobs). For one WORKER_OFFLINE_THRESHOLD_MS after start, the offline and stuck checks are skipped so workers can reconnect first |
BLACKBOARD_STUCK_JOB_TIMEOUT |
720000 (12 min) |
Jobs in POLLED state longer than this are auto-failed (default: 2× WORKER_OFFLINE_THRESHOLD_MS) |
BLACKBOARD_PENDING_JOB_TIMEOUT |
3600000 (60 min) |
Jobs in PENDING state that no worker ever polled within this deadline are auto-failed, so a workflow — and the epoch holding it — cannot wait forever for a worker that never comes (default: 5× BLACKBOARD_STUCK_JOB_TIMEOUT). Applies to acquire, execute and rollback jobs alike; a job claimed by a worker while the cleanup runs is left to that worker |
Rollback Data
Rollback data is the snapshot a reversible function block sends with its execute result for its own rollback().
| Variable | Default | Description |
|---|---|---|
ROLLBACK_DATA_MAX_BYTES |
5242880 (5 MiB) |
A byte size such as 5242880, 10mb or 10MiB. Largest snapshot the engine stores per attempt, measured as the UTF-8 byte length of its compact JSON. A larger one is dropped as too_large; the result itself is still stored. The Worker SDK applies its own 5 MiB cap, measured the same way |
ROLLBACK_DATA_RETENTION_DAYS |
30 |
How long a run that rests in FAILED_UNSAFE_ACK, with no rollback job active, keeps its snapshots. Runs that ended COMPLETED_ACK, FAILED_SAFE or FAILED_SAFE_ACK lose them at the next sweep |
ROLLBACK_DATA_SWEEP_INTERVAL_MS |
3600000 (1 h) |
How often the sweep that removes snapshots runs |
Unset or 0 keeps the default for these three. A negative value stops the engine at boot, as does
any value that is not an integer, or for ROLLBACK_DATA_MAX_BYTES not a byte size.
Example .env
# Production configuration
NODE_ENV=production
NEOPS_LOG_LEVEL=info
NEOPS_LOG_FORMAT=json
# Database
POSTGRES_HOST=postgres.internal
POSTGRES_PORT=5432
POSTGRES_USER=neops_engine
POSTGRES_PASSWORD=<secure-password>
# CMS
NEOPS_CMS_URL=http://neops-cms.internal:8000/graphql
NEOPS_CMS_TOKEN=<cms-auth-token>

