Skip to content

Quickstart

This guide takes you from nothing to a running process instance in about five minutes. You watch it run in TinyConductor Console.

The whole install is one container and one volume. The container holds the engine and PostgreSQL. The volume holds PostgreSQL’s data and the secrets the engine generates on its first start.

Every number and durability statement below names its mode. A Bundled number never describes Cluster mode, and the reverse is also true. Before you put real data in, read Beta data handling.

All you need is Docker. Bundled mode is published as the container image registry.tinyfactory.ai/tinyblox/tinyconductor-bundled, for linux/amd64 and linux/arm64 (which covers Apple silicon). The images are published to TinyFactory’s registry; the exact address is given with your access.

image/README.md describes the image itself: its authentication default, its bind address and how to publish its port. The same file ships inside the image at /usr/share/tinyconductor/README.md.

Third-party notices for everything bundled in the image (PostgreSQL’s permissive license first among them) ship inside the image at /usr/share/tinyconductor/THIRD_PARTY_NOTICES.md and in image/THIRD_PARTY_NOTICES.md. No connector runtime is bundled.

Terminal window
docker run -d --name tinyconductor -p 127.0.0.1:8080:8080 \
-v tinyconductor-pgdata:/var/lib/postgresql/data \
registry.tinyfactory.ai/tinyblox/tinyconductor-bundled:0.1.0

Docker downloads the image on first use. If you prefer Docker Compose, save this as compose.yaml and run docker compose up -d; it is the same deployment:

services:
tinyconductor:
image: registry.tinyfactory.ai/tinyblox/tinyconductor-bundled:0.1.0
container_name: tinyconductor
ports:
- "127.0.0.1:8080:8080"
volumes:
- tinyconductor-pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
tinyconductor-pgdata:

The rest of this guide uses the container name tinyconductor, which both forms set.

-p 127.0.0.1:8080:8080 publishes the engine to this host only. That is deliberate and it is the only form this guide uses; see “Reaching it from another machine” below before you widen it.

That one line is the deployment. On first boot the container sets up PostgreSQL’s data directory and creates the engine’s database and login. It then starts PostgreSQL, waits until it accepts connections, and only then starts the engine with --mode bundled.

The engine talks to PostgreSQL over a Unix domain socket. PostgreSQL runs with listen_addresses='', so it has no TCP listener at all, and publishing a port cannot expose the database. The engine keeps no log file and no second volume: its processes and their history are in PostgreSQL, and its own directory on the same volume holds only the secrets it generated on its first start.

Check the mode and the partitions. The health endpoint is public in every mode, so this needs no credential:

Terminal window
curl -s localhost:8080/healthz | jq '{mode, partitions}'

partitions shows how many partitions the engine runs, and that it owns and has loaded all of them. How the engine runs your processes explains what a partition is.

The engine is authenticated by default. On its first boot it generated a random token and stored only its SHA-256 digest in PostgreSQL. The token itself is never written to the log, where every log collector would keep a copy. It is written once to a file on the data volume that only the engine’s user can read. The log says where:

Terminal window
docker exec tinyconductor cat /var/lib/postgresql/data/tinyconductor/first-boot-secrets
# Secrets TinyConductor generated on its first start. …
CONDUCTOR_FIRST_BOOT_TOKEN bpm_1f4c…
CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD 3f0c…

Keep the token: every command below sends it.

Terminal window
export TOKEN=bpm_1f4c… # paste the value from the file

The token is for scripts and job workers. For you, the same first boot created a bootstrap administrator (the first admin account), whose password is the CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD line. Only its argon2id hash is stored. You sign in to the console with it in step 7.

Once you have stored both in your password manager, delete the file. The engine does not need it and never writes it again:

Terminal window
docker exec tinyconductor rm /var/lib/postgresql/data/tinyconductor/first-boot-secrets

If you lose the token, create a new one: delete the stored digest and restart. The next boot writes a fresh token to the file.

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

To give colleagues their own accounts, sign them in through your identity provider or add local users with CONDUCTOR_LOCAL_USERS_JSON (see Identity and access).

The first-boot token is enough for this quickstart. For real job workers and scripts, create an API client for each in the console (Access → Clients) or with POST /v2/clients, and let it fetch short-lived tokens from the engine (see Tokens for SDKs and connectors). Fixed static tokens (CONDUCTOR_AUTH_CREDENTIALS_JSON) are meant for automated tests and bootstrap; they are described in Bundled mode.

Everything needs a credential except /livez, /healthz, /readyz and the console (/console). A call without one answers 401 with urn:bpm:error:unauthorized, so nothing is silently open. The console is public because a page has to load before you can sign in. It holds no engine data of its own.

Open http://localhost:8080/console, sign in as admin with the bootstrap password from step 2, and open Modeler. The engine serves the console itself: there is nothing to install, no second container and no endpoint to configure.

  1. Draw a process, or open a deployed one to make its next version.
  2. The Modeler checks the diagram against the engine’s rules as you draw.
  3. Deploy it, and start a test run beside the canvas to see it work.

Decisions and forms are edited in the Modeler too, each in a tab of its own beside the process, and a task opens the form or decision it names. See Modeler.

The rest of this guide uses curl, because commands are easier to follow on a page than clicks. Both reach the same engine.

The sample order process is in the examples download of the release, tinyconductor-0.1.0-examples.tar.gz (where to download it is given with your access). Unpack it:

Terminal window
tar -xzf tinyconductor-0.1.0-examples.tar.gz
cd tinyconductor-0.1.0-examples

order.bpmn is the shared sample process:

start ─► charge-card ─► shipping-tier ─► review-period ─► send-invoice ─► confirm-shipment ─► end
(service task) (decision table) (timer PT24H) (service task) (user task)

The process-testing examples deploy exactly this file in every language. order.form is the form that the confirm-shipment user task shows. order-decisions.dmn holds the decision table that the shipping-tier business rule task calls.

Terminal window
curl -s -X POST localhost:8080/v1/deployments \
-H "authorization: Bearer $TOKEN" \
-F 'resource=@order.form;type=application/json'
curl -s -X POST localhost:8080/v1/deployments \
-H "authorization: Bearer $TOKEN" \
-F 'resource=@order-decisions.dmn;type=application/xml'
curl -s -X POST localhost:8080/v1/deployments \
-H "authorization: Bearer $TOKEN" \
-F 'resource=@order.bpmn;type=application/xml'

Deploy the form and the decision first. When the BPMN is deployed, the user task’s formId is looked up among the deployed forms. The business rule task calls the decision by id as soon as an instance reaches it.

You can also deploy the three files from the console: open Deployments, choose Deploy… and drop them on the dialog. See Deploying files.

Terminal window
curl -s -X POST localhost:8080/v1/process-instances \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"processDefinitionId":"order-process",
"variables":{"orderId":4711,"amount":99.90}}'

The response carries processInstanceKey. The engine answers only after the new instance is committed in PostgreSQL. Starting an instance costs exactly one database commit.

The instance is waiting on the charge-card service task. A service task creates a job, and a job worker (your code) picks it up and completes it. Act as that worker:

Terminal window
curl -s -X POST localhost:8080/v1/jobs/activation \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"type":"charge-card","timeout":30000,"maxJobsToActivate":1}'

Take jobKey and leaseEpoch from the response and complete it:

Terminal window
curl -s -X POST localhost:8080/v1/jobs/<jobKey>/completion \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"leaseEpoch":<leaseEpoch>,"variables":{"charged":true}}'

The instance now waits on review-period, a 24-hour timer. That is the point of the sample: a real process waits. Bundled mode uses real time, so this timer fires twenty-four hours later, even if the engine restarted in between. Then send-invoice creates the next job and confirm-shipment creates a user task. You don’t have to trigger anything.

To see the whole process without waiting a day, run the same BPMN in Embedded mode. There, your test controls a virtual clock: see Testing processes.

Open http://localhost:8080/console and sign in as admin with the bootstrap password from step 2. The API token from step 2 works too.

The engine keeps your session and gives the browser an HttpOnly cookie, so nothing secret is stored in the browser. The cookie is marked Secure. Chrome and Firefox accept that on http://localhost; Safari does not. For Safari on this local-only setup, restart the container with -e CONDUCTOR_SESSION_INSECURE_COOKIE=1.

The engine serves the console on the same address as the API, so there is no endpoint to configure and no CORS setup.

The instance list shows the running order instance. Open it to see the element timeline, the current variables and any incidents. The lists read PostgreSQL tables that the engine fills just after each change, so a brand-new instance can take a moment to appear; the console refreshes until it does.

Bundled mode serves every API operation that Cluster mode serves. Only the Embedded engine’s virtual-clock and record routes are missing. They answer RFC 7807 urn:bpm:error:mode-not-supported with mode: "bundled", never a silent 404.

Every console area works in this mode:

Console areaBundled
Workflows: instances and incidents, instance pages with the diagram, element timeline and variablesavailable
Instance actions: set variables, cancel, modify, migrate, retry an incidentavailable
Deployments, healthavailable — Deployments lists every installed definition
Tasks area (open tasks, claim, deployed form, complete)available — answered from the engine’s own user-task rows, so the whole list → claim → deployed form → complete moment works here
Audit and Accessavailable
Reporting lag (Cell health)available — it shows how far the tenant’s searches and reports are behind the saved changes
Analytics areas and analytics.* reportsavailable, eventually consistent — each area shows how far behind it is
Tenants and Credentialsavailable

Honored reads are POST /api/v1/history/process-instances/search, GET /api/v1/history/process-instances/{key} (which carries the element timeline and current variables inline), POST /api/v1/history/incidents/search (filterable by state and processInstanceKey, as in Cluster), GET /api/v1/console/deployments, and the records through GET /v1/audit-log.

The token from step 2 is the operator user with every permission, so the Tasks area shows it every task. Give a person their own credential with the permissions task:read, task:claim and task:complete (the task-worker role). They then see only:

  • tasks assigned to them;
  • tasks that name them, or one of their groups, as candidates;
  • tasks with no assignee and no candidates.

Claiming for someone else, and completing or unclaiming someone else’s task, needs task:assign. task:admin shows every task. Somebody else’s task answers 404. Rules and examples: Task authorization.

To see who claimed or completed what, call GET /v1/audit-log?valueType=USER_TASK&intent=ASSIGNED with a token that holds audit:read (see Audit log API).

The engine runs an exporter next to itself. It reads what the engine has already saved and writes query tables into the same database. The four analytics.* views read those tables. The views are a stable interface and identical in Bundled and Cluster mode: same view names, same columns, same meaning. A query or dashboard written for one mode works unchanged in the other.

Terminal window
docker exec -it tinyconductor psql -U postgres -d tinyconductor -c \
"BEGIN READ ONLY;
SET LOCAL ROLE analytics_reader;
SELECT set_config('bpm.tenant_id', 'default', true);
SELECT bpmn_process_id, started_count, completed_count, duration_p95_ms
FROM analytics.instance_volume_daily ORDER BY started_on DESC;
ROLLBACK;"

Point Metabase, Superset, or anything that speaks PostgreSQL at the same database with a login granted analytics_reader, exactly as Analytics describes. The tenant id in a Bundled deployment is default, plus any tenants you add in the console (see Tenants).

Reports lag slightly behind, on purpose. The exporter runs after the commit, outside the request, so it never slows a request down. A request is answered when its change is durable, not when it has been reported on. A report therefore shows what has been processed so far. In a quiet system that is a fraction of a second behind; under sustained load it can be more. The console shows this delay in every reporting area, and GET /metrics on the metrics port (9090) publishes it per tenant as tinyconductor_exporter_lag_seconds.

Sign in with your identity provider (OIDC)

Section titled “Sign in with your identity provider (OIDC)”

People normally sign in with the identity provider your organisation already runs — Keycloak, Microsoft Entra ID, Okta and so on. The variables, mapping rules, roles and a worked local Keycloak setup are in Identity and access.

This guide publishes -p 127.0.0.1:8080:8080, so the engine is reachable from this host and from nowhere else. Opening it up is a separate, deliberate step, and it has one requirement.

Keep the credential. Every route that reads or changes engine state needs a credential, whether it is reachable from one host or from a network. If you turn authentication off (CONDUCTOR_ALLOW_ANONYMOUS) and bind beyond loopback, the engine refuses to start, and the error says why.

Prefer a tunnel or a proxy over a wider bind. The console is plain HTTP on the published port, so:

  • SSH tunnel — nothing changes on the server:
    Terminal window
    ssh -N -L 8080:127.0.0.1:8080 you@that-host
    then open http://localhost:8080/console on your own machine.
  • Reverse proxy — terminate TLS at nginx/Caddy/Traefik and forward to 127.0.0.1:8080. Authentication stays the engine’s, so the proxy is not the thing keeping people out.

If you really need the container on a routable interface, change only the publish address (-p 0.0.0.0:8080:8080), never the engine’s CONDUCTOR_SERVER_LISTEN_HOST. Inside a container the engine must bind 0.0.0.0. Docker’s published port targets the container’s own interface, so a loopback bind there would make the port unreachable. You control exposure with the publish address, not with the engine’s bind. Put TLS in front of it first. The metrics port (9090) needs no credential, and this guide does not publish it. Publish it only to your metrics scraper, never to the public.

Running without authentication is only for a throwaway local engine. The image can’t do it: it must bind 0.0.0.0 inside its container, and an engine without authentication refuses a non-loopback bind. Run the Embedded engine from a release download on loopback instead (the downloads are listed in Embedded mode):

Terminal window
CONDUCTOR_ALLOW_ANONYMOUS=1 ./tinyconductor-0.1.0-linux-x86_64/tinyconductor \
--mode embedded --bind 127.0.0.1:8080

It prints a warning at every startup, and the console at /console shows a permanent banner saying that the engine is unauthenticated.

An engine without authentication answers local callers only. It serves requests that name localhost or a loopback address and refuses everything else, including any request a web page makes to its port. So you can’t put it behind a reverse proxy that forwards the original Host. Configure credentials for that, which you want in front of a proxy anyway.

The console is served by the engine on the same address, so it needs no CORS setup in any exposure. A browser tool served from a different address (for example a development server on http://127.0.0.1:5173) is accepted only from loopback addresses; a page from the internet is not.

Everything durable is on the one volume: PostgreSQL’s data, and the engine’s own directory with the secrets it generated on its first start (tinyconductor/ on the volume). Every standard PostgreSQL tool works unchanged; a volume snapshot covers both, and with PostgreSQL’s own tools keep a copy of the engine’s directory as well, because the key that protects the engine’s token signing keys is there when the token endpoint is on.

Committed is durable in PostgreSQL; machine-loss recovery is standard PostgreSQL operations.

Concretely, from inside or against the container:

  • Logical backup. docker exec tinyconductor pg_dump -U postgres tinyconductor > tinyconductor.sql gives a consistent copy of everything the engine has saved in PostgreSQL.
  • Physical base backup + PITR. pg_basebackup plus WAL archiving via archive_command, restored with a recovery_target_time. Nothing about TinyConductor changes this recipe.
  • Volume snapshot. Snapshot tinyconductor-pgdata with your storage layer’s own tooling. PostgreSQL commits atomically, so a crash-consistent snapshot recovers the way an unclean shutdown does.
  • Streaming replication. Point a standby at the cluster in the usual way. A synchronous replica yields machine-loss RPO 0. TinyConductor adds no replication of its own, so PostgreSQL’s replication lag is the only one to consider. Because the image ships with listen_addresses='', attaching a standby is a deliberate act: set CONDUCTOR_POSTGRES_LISTEN_ADDRESSES (and any extra server settings via CONDUCTOR_POSTGRES_OPTIONS) and add the matching pg_hba.conf rules in the volume. Nothing is exposed until you do.

A restart needs no special steps. Stop and start the container, and deployments, running and completed instances, current variables, element timelines and incidents all come back from PostgreSQL. Every release of the image is checked for this before it is published.

If PostgreSQL becomes unreachable while the engine is writing, the affected requests answer 503 and the engine keeps nothing that is not saved. When PostgreSQL answers again, the engine reloads what was saved and carries on by itself.

Bundled mode is the demo, dev, and single-team production path. Switch to Cluster mode when you need what it is for:

BundledCluster
Tenancystarts with one tenant; add more in the consolemany tenants, row-level security, per-tenant quotas, and database connections reserved for API requests
Deployment unitone container, one volumeone or more engine nodes sharing a PostgreSQL database you operate (together called a cell, which serves many tenants)
Durability pathone database commit per engine step, over a Unix domain socketthe full PostgreSQL schema, over the network
Readssearches from PostgreSQL query tables; one live instance, job or task by key from the engine’s memorythe same, from the node that runs the tenant
Analyticsanalytics.* views, projected in-process after commit, eventually consistentanalytics.* views, exporter, BI tools

Process behaviour does not change. Modes differ only in durability, latency, capacity and tenancy. How a process runs, the records it writes, its incidents and its FEEL results are the same in every mode, and one shared test suite checks this for each mode. A process that behaves one way here behaves the same way there.

Cluster mode pays a network round trip to PostgreSQL and, in a cell of several nodes, a hop between nodes on every write, in return for serving many tenants and scaling out. For measured throughput, latency and resource use of each mode, and the environment they were measured in, see Performance and scaling and Hardware requirements.