Configuration
All configuration is via environment variables. No config files are required. The engine reads .env if present (via dotenv).
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 |
LOG_LEVEL |
info |
error, warn, info, debug. Defaults to debug when DEBUG=true |
LOG_FORMAT |
pretty |
json (production) or pretty (development). Auto-selects json in production |
PORT |
3030 |
HTTP server port |
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. Two routes take bulk payloads from workers and are raised to 50 MB:
POST /blackboard/job/result– job results, which can carry bulk entity updatesPOST /blackboard/job/log– job execution logs
| Variable | Default | Description |
|---|---|---|
MAX_REQUEST_BODY_SIZE |
(unset) | Replaces the 50 MB limit on the two routes above, e.g. 100mb 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 routes that are already raised. Every other route,
including workflow publishing, keeps the 100 kB 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).
| 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 |
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.
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) |
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 |
Example .env
# Production configuration
NODE_ENV=production
LOG_LEVEL=info
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>

