Skip to content

Bundled mode

Bundled mode runs the TinyConductor engine and its PostgreSQL in one container. Saving a change never crosses a network.

Bundled mode is the durable production mode for one team. It runs the same engine as the other modes. The engine keeps its working state in memory, in partitions, and saves every change to PostgreSQL before it answers the request that caused it. How the engine runs your processes explains partitions, saving, restarts and the exporter.

The engine keeps no log file, snapshot file or replication process of its own: its processes and their history are in PostgreSQL. Next to PostgreSQL’s data, the same volume holds a small directory of the engine’s own, with the secrets it generates on its first start, and the database superuser’s password (see below). The one volume is the only thing you back up: a volume snapshot covers all of it; with PostgreSQL’s own backup tools, also keep a copy of the tinyconductor and admin directories.

One container or pod holds the engine and PostgreSQL: the published image registry.tinyfactory.ai/tinyblox/tinyconductor-bundled (see the Quickstart and Container image). The engine talks to PostgreSQL over a Unix domain socket, so saving a change never depends on a network. On Kubernetes, the tinyconductor Helm chart runs this unit as one pod with one persistent volume; Bundled mode is the chart’s default (see Kubernetes).

The image prepares PostgreSQL itself. On the first start it creates the database tinyconductor and its owner, the login tinyconductor, which the engine uses. The engine applies its schema itself when it starts, as that login, so that one login owns the schema and runs the engine. It is not a PostgreSQL superuser and cannot bypass row-level security, so PostgreSQL keeps each tenant’s rows apart even if a query in the engine misses its tenant filter. The engine checks its login at every start. If you point the engine at another PostgreSQL, we recommend the same: a plain login that is not a superuser, has no BYPASSRLS and is not a member of a role that has either. Row-level security does not apply to a login that is a superuser or has BYPASSRLS; with such a login the engine writes a warning to its log that names the login and the attribute, and starts all the same. Row-level security does not stop someone who can act as that login, which owns the tables: in this image, anyone with a shell in the container. Keep access to the container and its volume to the people who run the engine. The image sets CONDUCTOR_DATABASE_URL to that database over the socket; you don’t set it.

The container never runs as root. It starts as its own user, tinyconductor (uid and gid 65532), which runs both PostgreSQL and the engine, holds no capabilities, and cannot gain privileges (every process has the kernel’s no_new_privs flag, set by the image itself). It writes only to its data volume, so it runs with a read-only root file system. It works with Docker’s --read-only, --cap-drop ALL and --security-opt no-new-privileges, and with a Kubernetes pod that sets runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false and drops all capabilities:

Terminal window
docker run -d --name tinyconductor -p 127.0.0.1:8080:8080 \
--read-only --cap-drop ALL --security-opt no-new-privileges \
-v tinyconductor-pgdata:/var/lib/postgresql/data \
registry.tinyfactory.ai/tinyblox/tinyconductor-bundled:0.1.0

Starting it as root (--user root) is refused. It also needs a volume its user can write: a Docker named volume is, and on Kubernetes the pod’s fsGroup: 65532 makes the persistent volume so.

The engine is kept away from the database superuser

Section titled “The engine is kept away from the database superuser”

PostgreSQL’s superuser, postgres, logs in only with its password. The image generates that password on the first start and keeps it on the volume, in a file only the container’s user can read. The engine’s own login, tinyconductor, logs in without a password, and only from the container’s user.

Because the engine and PostgreSQL run as one user, file permissions alone cannot keep them apart. The engine therefore puts itself in a sandbox of the Linux kernel (Landlock) as it starts, before it does anything else, and cannot leave it. From inside it, code in the engine cannot read the superuser’s password or PostgreSQL’s files, cannot change PostgreSQL’s socket, and cannot look into PostgreSQL’s processes. So even code that took over the engine stays limited to the engine’s own login, which is not a superuser and cannot bypass row-level security. The engine’s log says so when it starts:

the engine runs in a Landlock sandbox (Landlock ABI 3): it cannot reach anything beneath /var/lib/postgresql/data/pgdata, /var/lib/postgresql/data/run, /var/lib/postgresql/data/admin

The image tells the engine which directories to keep out of with CONDUCTOR_SANDBOX_DENIED_PATHS, set for the engine alone; you don’t set it yourself.

The sandbox needs Linux 5.13 or later with Landlock turned on (it is on in current distributions and in Docker Desktop), and a seccomp profile that allows it (Docker’s and Kubernetes’ default profiles do). Where the kernel does not offer it, the engine starts all the same, unconfined, and logs a warning at every start that begins “the kernel offers no Landlock”. What the sandbox does not cover:

  • The engine can still stop PostgreSQL, since both run as one user. That stops the container, as any engine failure does; it does not give the engine any data. Linux 6.12 and later block this as well.
  • Anyone with a shell in the container (docker exec, kubectl exec) is not in the sandbox and can act as the superuser. Keep access to the container and its volume to the people who run the engine.

docker exec … psql connects to the engine’s database as the superuser, with the password from the volume; the image sets the connection for you:

Terminal window
docker exec -it tinyconductor psql
docker exec tinyconductor psql -c 'SELECT count(*) FROM pg_stat_activity'

To act as the engine’s own login instead, add -U tinyconductor. On Kubernetes, use kubectl exec -it tinyconductor-0 -- psql.

The volume holds four directories, each readable only by the container’s user:

DirectoryHolds
pgdataPostgreSQL’s data
runPostgreSQL’s socket, the engine’s only way to its database
adminpgpass, the database superuser’s password
tinyconductorthe engine’s own files

Use a volume on a local file system (a Docker named volume, or a Kubernetes persistent volume backed by a block device): PostgreSQL’s socket lives on it. The image sets CONDUCTOR_DATA_DIR to /var/lib/postgresql/data/tinyconductor. The engine writes the secrets it generates on its first start there (see below) and, when the token endpoint is on, the key that encrypts its token signing keys.

VariableDefaultMeaning
CONDUCTOR_AGENT_USAGE_AUTH_SECRETunsetOptional: the key the engine checks agent usage reports with, at least 32 random bytes
CONDUCTOR_INITIAL_TENANTdefaultThe id of the one tenant the engine starts with on its very first start
CONDUCTOR_TENANT_CONFIGS_JSONunsetOptional: a few tenants to start with instead, read on the very first start only (see Starting with several tenants). Create and change tenants in the console or through the tenant API
CONDUCTOR_PARTITIONS24How many partitions the engine uses. Fixed when the database is created
CONDUCTOR_PARTITION_LEASE_MS10000How long a partition’s lease lasts without renewal, in milliseconds (1,000 to 300,000)
CONDUCTOR_COMMIT_BATCH256The most frames saved together in one commit (1 to 4,096)
CONDUCTOR_SNAPSHOT_INTERVAL5000Milliseconds between two saved copies (segments) of a tenant’s state
CONDUCTOR_EXPORTER_BATCH256How many frames the exporter reads per pass
CONDUCTOR_EXPORTER_MAX_LAG1000000Records a tenant’s searches may be behind before its new instances are refused; they are slowed from half of it
CONDUCTOR_EXPORTER_MAX_LAG_AGE_MS60000The same limit as the age of the oldest record not yet exported, in milliseconds
CONDUCTOR_DEDUP_RETENTION24hHow long an idempotency key is remembered (units ms, s, m, h, d)
CONDUCTOR_CLAIM_WINDOW10mHow long a remembered key is kept exactly in memory; older keys are kept in a compact filter and looked up in the database only when the filter matches
CONDUCTOR_CLAIM_FILTER_FALSE_POSITIVE0.01Share of new keys that cost one extra database lookup because the filter matched by chance
CONDUCTOR_CLAIM_FILTER_ROTATION1hTime span each filter covers; a filter is dropped once all its keys have expired

There is no fsync variable: every change is saved by PostgreSQL’s commit.

The engine starts with one tenant, default, with no limits. Add more tenants, change their limits and how long their history is kept, and suspend or reactivate them while the engine runs: in the console’s Tenants area, or through the tenant API, exactly as in Cluster mode (see Tenants). The first-boot token below may do all of it. For example, with the token in $TOKEN:

Terminal window
curl -X POST http://127.0.0.1:8080/v2/tenants \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"tenantId": "team-b", "name": "Team B", "quotas": {"activeInstanceLimit": 5000}}'

Finished instances are kept 30 days by default and then removed: their records and their searchable history, so searches no longer find them. Running instances are never affected, and the reports keep counting the removed ones. To keep less or more, change the tenant’s history retention in the console (the tenant’s Settings page, under Tenants) or through the API, as described under How long history is kept. Plan the volume from Capacity planning.

Bundled mode is for production, so it requires authentication and has safe network defaults. They are the same as in Cluster mode: callers present a bearer token, everything not explicitly allowed is refused, and each route requires a specific permission. All three modes share this code. No mode ignores credentials you have configured.

VariableMeaning
CONDUCTOR_AUTH_CREDENTIALS_JSONStatic tokens, for bootstrap and tests; programs normally use API clients (see Credentials for programs). The credential document is the same one Cluster mode takes: [{"token":…,"subject":…,"tenantId":…,"kind":"user"|"service","actions":["*"],"expiresAt":"2027-01-01T00:00:00Z"}]. When it is set, it is enforced. actions lists the permissions of the token. expiresAt is optional (RFC 3339); after it, the token gets 401
CONDUCTOR_ALLOW_ANONYMOUSTurns authentication off. The engine warns at every startup and the console shows a permanent banner. It cannot be combined with CONDUCTOR_AUTH_CREDENTIALS_JSON, OIDC or local users. 1 and true turn authentication off; 0, false, empty and unset keep it on. Any other value stops the engine at startup, so a value such as False or off can never turn authentication off by mistake
CONDUCTOR_SERVER_LISTEN_HOST, CONDUCTOR_SERVER_HTTP_PORTThe IP address and port the API and console listen on (--bind host:port overrides both). The image sets 0.0.0.0 and port 8080, which you keep: inside a container that is the right address, and you choose who can reach it with the publish address (-p 127.0.0.1:8080:8080). The release download’s default is 127.0.0.1
CONDUCTOR_SERVER_METRICS_PORTThe port of GET /metrics, 9090 by default, on the same address as the API. It needs no credential, so publish it only to your metrics scraper (for example -p 127.0.0.1:9090:9090 with the scraper on that host). CONDUCTOR_SERVER_METRICS_ENABLED=false turns it off

Authentication is on by default. When you configure nothing, the engine creates a random token on its first start. It never writes the token to its log, where every log collector would keep a copy. It writes it once to the first-boot secrets file on the data volume, which only the engine’s user can read, and the log says where:

Terminal window
docker exec tinyconductor cat /var/lib/postgresql/data/tinyconductor/first-boot-secrets
CONDUCTOR_FIRST_BOOT_TOKEN bpm_<64 hex characters>

Store it, then delete the file (docker exec tinyconductor rm …). Later starts reuse the stored digest and write nothing.

Only the token’s SHA-256 digest is stored. A copy of the database therefore does not reveal the token, and a lost token cannot be read back. To get a new one, delete the stored digest and restart:

Terminal window
docker exec tinyconductor psql -U postgres -d tinyconductor \
-c 'DELETE FROM bpm_runtime.engine_credential'
docker restart tinyconductor

Or set CONDUCTOR_AUTH_CREDENTIALS_JSON; the stored token is then ignored.

The generated token belongs to subject operator in the starting tenant (default unless you chose another) and has every permission (*).

The first-boot token is a bootstrap credential: use it to set things up, not as the everyday credential of your job workers. Give each program (job worker, script, connector runtime) its own API client instead: create it in the console (Access → Clients) or with POST /v2/clients, give it the roles or grants it needs, and let it fetch short-lived tokens from the engine’s token endpoint (see Tokens for SDKs and connectors). Each client’s secret can be rotated or revoked on its own, and the audit log names the client.

Static tokens in CONDUCTOR_AUTH_CREDENTIALS_JSON remain available for automated tests and for installations that must start with a fixed token. The rules below apply to them.

One subject, one answer — and how to rotate a token

Section titled “One subject, one answer — and how to rotate a token”

A subject (the name a token acts as) may have more than one token only when the entries are identical except for the token: the same kind, the same tenantId and the same actions (order and repeats don’t matter). Those are two tokens of one principal, and that is how you rotate a token.

The engine refuses to start when two entries share a subject but differ in anything else, because the subject’s permissions would be ambiguous. It also refuses to start when two entries use the same token.

To rotate a token:

  1. Add the new token next to the old one.
  2. Redeploy the callers with the new token.
  3. Remove the old entry.

The subject in your audit log and metric labels never changes:

[{"token":"OLD","subject":"ops","tenantId":"default","kind":"service","actions":["instance:create"]},
{"token":"NEW","subject":"ops","tenantId":"default","kind":"service","actions":["instance:create"]}]

If the two entries need different permissions, they are two principals: give each its own subject.

Tokens are for machines. People sign in to TinyConductor Console with their organisation’s identity provider (OIDC), or as a local user. Sign-in gives people a browser session on the server; job workers and scripts keep using their API clients or tokens next to it. Local users, the first administrator, the OIDC settings, mapping rules, roles, sessions and the /auth/* endpoints are described in Identity and access.

These answer without a credential, in every mode:

  • GET /livez, GET /healthz and GET /readyz, so an orchestrator such as Kubernetes can check the engine before any credential exists;
  • the console’s own files (/console and /console/assets/…) and the /auth/* endpoints, because people must be able to load the sign-in page;
  • the API description (/openapi.yaml) and the compatibility notes (/compatibility).

Every route that reads or changes engine data needs a credential.

The engine listens on loopback by default. It refuses to start when authentication is turned off and it listens on a non-loopback address; the error message names both fixes. The rule looks at the engine’s own listen address, which is why it also works inside a container:

Listen addressCredentialsResult
127.0.0.1:8080generated or configuredstarts
127.0.0.1:8080CONDUCTOR_ALLOW_ANONYMOUSstarts, for local callers only (see below)
0.0.0.0:8080generated or configuredstarts; this is the setting inside a container
0.0.0.0:8080CONDUCTOR_ALLOW_ANONYMOUSrefused at startup

Inside a container the engine must listen on 0.0.0.0. Docker forwards the published port to the container’s own network interface, so a loopback address there would make the port unreachable. Control exposure where you publish the port instead: -p 127.0.0.1:8080:8080 makes the engine reachable from that host only. To reach it from another machine, use an SSH tunnel or a reverse proxy, not a wider address.

The loopback CORS setting is a separate matter. It decides which browser pages may read a response, never who may call the engine.

Without authentication, the engine answers local callers only. Listening on loopback blocks the network, but not the browser. A web page you visit runs in your browser on the same host, so it can send requests to 127.0.0.1:8080, and a DNS-rebinding trick would let it read the answers. So when authentication is off (and only then), every request must name a loopback host (localhost, *.localhost, 127.0.0.0/8 or ::1; the port is not checked) and must not carry a non-loopback Origin. Anything else gets 403 with urn:bpm:error:foreign-caller, on every route, including /healthz and the console.

With authentication on (the default), none of this applies: the token already blocks both attacks, and a proxy that forwards its own Host keeps working.

The engine applies each command in memory, in the partition of the command’s tenant, and saves it to PostgreSQL as one frame before it answers. Frames that are ready at the same moment share one commit, so batching only happens while commits are slower than the engine. A restart takes the partitions back at once and loads each tenant’s latest saved state; it takes a few seconds. How the engine runs your processes describes each step.

GET /healthz shows the observed saving under log (commits, frames, meanBatch, maxBatch), and the partitions under partitions. The metrics are listed in Observability.

The saved frames also keep trace context. When a traced request creates an instance or publishes a message, the frame of that command records the request’s W3C trace context next to the command (never in the records). After a restart the engine reads it back, so the jobs of instances created before the restart still carry the caller’s trace to their workers. See Observability.

Bundled mode is a production mode, so timers follow the real (wall) clock. The engine reads the time from one place, the engine’s clock, and no other part of the engine reads the system time directly.

Each command is stamped with the time once. When the engine accepts a command, it takes the current time from the engine’s clock, moves its own clock forward to exactly that time, and saves the time in the frame with the command, to the microsecond (the precision of a record timestamp). On restart the engine replays the saved times instead of reading the clock again, so a recovered engine reproduces its records exactly. The engine’s clock never moves backwards, so an NTP correction that steps the system time back cannot rewrite saved history. A frame without a time (as written by test tools) replays at a fixed time, and behaves exactly as before.

Timers fire when they are due; the engine does not poll for them. A background task sleeps until the earliest moment something can happen: a timer, or a buffered message that has not yet been matched. Every saved change wakes it, so creating, moving or cancelling a timer takes effect at once. When nothing is scheduled, it sleeps until the next change and uses no CPU. Two limits guard against unusual cases: it wakes at least every 60 seconds, so a suspended host or a clock jump is noticed within a minute, and it waits at least 20 ms after a wake-up that fired nothing.

Timers follow the same rules as requests. A firing timer goes through the same processing step as any request, with the same limits on how much work one step may do. The timer task adds no limits of its own.

Timers that came due while the engine was down fire on restart. Recovery restores the scheduled timers from PostgreSQL. The first thing the timer task does is fire everything that is already overdue, as one command: one frame and one commit.

This is tested end to end: a real job worker completes a service task, the instance waits on a short timer, and the engine is killed with SIGKILL, kept down past the timer’s due time, and restarted. The overdue timer fires within tens of milliseconds of the restart, and the instance reaches its end event.

You cannot set or move the clock in this mode. /v1/embedded/clock* and /v2/clock* answer mode-not-supported. Controlling time is a feature of Embedded mode, for tests. A production engine that could be told what time it is would be a different product.

A confirmed change is saved in PostgreSQL. Recovering from the loss of a machine is standard PostgreSQL operations: volume snapshots, backups, point-in-time recovery (PITR) and streaming replication (with a synchronous replica, no confirmed change is lost). The engine’s own directory on the volume holds only the secrets it generated on its first start; keep it with your backups (a volume snapshot includes it).

There is no replication lag to monitor in this mode, because the engine does no replication of its own.

If PostgreSQL becomes unreachable while a change is being saved, the affected partition stops serving at once: waiting requests get 503 with urn:bpm:error:partition-unavailable, and the partition drops its memory. The engine never carries on with changes that exist only in memory. When PostgreSQL is back, the partition reloads its last saved state and serves again, without a restart. A request that got this answer may or may not have been saved; retry it with the same idempotency key.

Data written by preview builds before 0.1.0 is not carried over. On such a volume the container refuses to start and says what to do: stop the container, remove its data volume (docker volume rm tinyconductor-pgdata, or whichever volume you mounted at /var/lib/postgresql/data), and start the 0.1.0 image with a fresh volume. Then deploy your process models again, and create your tenants and API clients again in the console or through the API; single sign-on and what your configuration provides apply as before.

From 0.1.0 on, the database is upgraded forward automatically: when a newer version starts on the same volume, it updates the database layout before it serves requests. Back up the volume first, stop the old container, and start the new image on the same volume. An older version cannot be started again on a database that a newer version has upgraded; restore the backup instead.

Bundled mode and Embedded mode share the same HTTP routes, request and response formats and typed errors. Bundled adds recovery from PostgreSQL, confirmation only after a change is saved, searches from PostgreSQL query tables, metrics, and TinyConductor Console at /console.

Route familyIn Bundled mode
POST /v1/deployments and /api/v1/deploymentsSupported
Process instances: create, cancel, set variablesSupported
Messages: publishSupported
Signals: broadcastSupported
Jobs: activate, update timeout, complete, fail, throw BPMN errorSupported. Activation accepts every field Cluster mode accepts, including worker and requestTimeout
Incidents: resolveSupported
Incident searchSupported; filters by state and processInstanceKey, as in Cluster mode
User tasks: assign, claim, unassign, update, completeSupported
User-task searchSupported, from the engine’s own user-task data: the same filters, ordering, paging cursor and error answers as in Cluster mode
GET /v1/audit-logSupported, from the query tables, with the same filters, order and cursor as in Cluster mode; needs the audit:read permission. See Audit log API
GET /api/v1/console/deploymentsSupported, including the deployed forms and their schemas
POST /api/v1/console/process-definitions/searchSupported, with instance and incident counts from the reports
GET /api/v1/console/modeling/element-templates and /lint-rulesSupported, as in every mode: the element templates the engine ships and the rules of its deploy check, for the Modeler; needs process:deploy
Console history reads in the Operations areaSupported, from the query tables
GET /metricsSupported on the metrics port (9090), without a credential: Prometheus text, or OpenMetrics with exemplars when the scraper asks for it. The API port answers 404 there. See Scraping /metrics
GET /console, /console/, and the console’s filesSupported, and public: the console must load before anyone can sign in to it
GET /livez, GET /healthz, GET /readyzSupported and public, as in every mode. /readyz also needs PostgreSQL to answer within 2 seconds
Any protected route without a valid bearer token401 with urn:bpm:error:unauthorized and WWW-Authenticate: Bearer, the same answer as in Cluster mode
Console Tenants and Credentials areasSupported, as in Cluster mode
Console reporting lag (Cell health)Supported: it shows how far the tenant’s searches and reports are behind the saved changes
Console Tasks areaFully supported: find tasks with filters and saved filters, claim, unclaim, show the deployed form, complete, change dates and priority, see finished tasks and history, start a process. Saved filters are kept in PostgreSQL
Console Jobs and Decisions areasSupported: jobs from the query tables with their actions (set retries, change the timeout, complete, fail, throw an error), deployed decisions with a test evaluation
Console Cell health: this node, nodes and background workSupported: the node’s status and log, the nodes of the cell, and the janitor, retention and export counters for people who may read metrics
analytics.* reports and the console’s analytics areasSupported, from the query tables, which may be a moment behind the engine. The four console areas show real reports and how far behind they are
Process modification and migrationSupported, as in every mode
History searches (including variables) and the v2 searchesSupported, from the query tables, with the same filters, tenant filter and totalItems as in Cluster mode
Identity administration and /oauth/*Supported, as in every mode
Embedded-mode record and virtual-clock routesmode-not-supported: these exist only in Embedded mode
Unknown routes404 urn:bpm:error:not-found as an RFC 7807 problem, never a bare 404

Searches, history and the console’s instance, detail and incident views read the PostgreSQL query tables that the exporter fills after each save, so they can be a moment behind a change you just made (see How the engine runs your processes). A request for one live instance, job or user task by its key is answered from the engine’s memory and is always current. Completed instances, variables, element timelines and incident states survive a restart.

The Tasks area decides access per task, exactly as in Cluster mode (it is the same code):

  • A person sees and acts on a task when they have the task:admin or * permission, are the assignee, are a candidate user, share a group with the task’s candidate groups, or the task has no assignee and no candidates.
  • Claiming a task for someone else, assigning it to someone else, and completing or unclaiming a task assigned to someone else need task:assign.
  • A task you can’t see answers 404, like a missing task. A task you can see, with an action you may not take, answers 403. Searches never return tasks you can’t see.
  • Service principals and the generated operator token (which has *) see every task. With authentication off, no task rule applies.
  • The person who acts is saved with each user-task command, so GET /v1/audit-log shows who claimed and who completed a task, also after a restart.

Details: Task authorization.

Processes, decisions and forms are drawn, checked against the engine’s rules, deployed and tested in the console’s Modeler, in every mode (see Modeler). Finished files can also be deployed from the console’s Deployments area or through the API.