OpexFlow runs in two places at once: an edge node in the plant (it polls the machines and
keeps working when the WAN is down) and a hosted node in the cloud (it receives records
from every plant, runs the business flows, and lets people work in the UI from anywhere). Both
are the same binary. This page is the supported recipe for the hosted node and for the link
between the two. The files are in deploy/hosted/ of the repository.
plant cloud (one VPS / VM)
┌─────────────────────┐ HTTPS, out ┌──────────────────────────────────────────────┐
│ edge OpexFlow │ signed POST │ Cloudflare: Access (SSO) in front │
│ adapters + flow ├────────────────►│ /api/hooks/edge Bypass (HMAC is the auth) │
│ push_queue.db │ │ cloudflared ──► opex-flow :3000 ──► /data │
└─────────────────────┘ └──────────────────────────────────────────────┘
no inbound port no published port, no inbound firewall ruleWhy a VPS and not Workers or Containers
OpexFlow is a native binary whose state is SQLite files (data.db, push_queue.db, flow.json,
versions.db, …).
- Cloudflare Workers cannot run it: no native process, no local filesystem.
- Cloudflare Containers have an ephemeral disk: every restart or redeploy would start from an
empty
/dataand lose the history, the flow versions and the queue.
So the hosted target is a VPS or VM with a persistent volume (any provider; x86_64 or arm64). Cloudflare is still the front door: a Tunnel and Access.
Start the hosted node
On the VPS (Docker Engine with the compose plugin; it only needs outbound HTTPS):
cd opex-flow/deploy/hosted
cp .env.example .env && chmod 600 .env # OPEX_API_TOKEN, OPEX_MASTER_KEY, TUNNEL_TOKEN, EDGE_HMAC_SECRET
docker compose up -d --build
docker compose ps # opex-flow (healthy), cloudflared (running)The image is built from the published static release, so there is no compiler and no private
token on the VPS. Pin the release and verify its digest for production:
OPEX_FLOW_VERSION=2.1.0 OPEX_FLOW_SHA256=<sha256 of the .tar.gz> (the build prints the digest it
downloaded and fails on a mismatch). To use an image built from source, see the header of the
repository Dockerfile and set OPEX_FLOW_IMAGE.
What the container is: non-root user app (uid 10001), state in the /data volume, read-only root
filesystem, no capabilities, HEALTHCHECK on GET /api/ready, SIGTERM drains the queues for up to
30 s (stop_grace_period: 45s). Environment overrides: OPEX_API_TOKEN, OPEX_MASTER_KEY (encrypts
data at rest and the local secret store; keep a copy elsewhere), OPEX_DATA_DIR, OPEX_BIND_ADDR, and
every ${VAR} of the config file.
A node fed only by webhooks or the ingest API has no adapters. It is ready as soon as its pipeline
engine is up, and it needs no push_endpoint: leave it unset and the push sink is skipped
(hosted.yaml does exactly that). Anything that pushes upstream (backfill, batch) still needs one.
The intake flow
Deploy flow.hosted.json once (it is kept in the volume):
jq -n --slurpfile f flow.hosted.json '{flow: $f[0], message: "edge intake"}' |
curl -fsS -X PUT https://flow.example.com/api/flow \
-H "Authorization: Bearer $OPEX_API_TOKEN" -H 'Content-Type: application/json' --data @-It is an ordinary flow, editable on the canvas:
| Node | What it does |
|---|---|
webhook:edge source |
Verifies X-Opex-Signature = sha256= HMAC-SHA256 over {timestamp}.{body} with ${EDGE_HMAC_SECRET}, timestamp within 300 s (the pusher’s own signing scheme). A bad signature is a 401 and never routed. |
dedupe |
Key = edge key id + machine + timestamp, 2 days: a record sent twice is stored once. |
branch |
status.state == "alarm" takes the alarm port. |
transform (DSL) |
Tags metadata.hosted_route = alarm / normal. |
sink |
storage (history in data.db); alarms also go to the live feed (hub). |
Swap the sinks for http nodes to deliver to an ERP or an iPaaS (see Connectors).
Cloudflare Tunnel and Access
- Tunnel. Zero Trust, Networks, Tunnels: create a tunnel, copy its token into
TUNNEL_TOKEN, add the public hostnameflow.example.compointing atHTTPopex-flow:3000(the compose service name). - Access for people. A self-hosted application on the hostname with an Allow policy for your identity provider group: SSO and MFA happen at Cloudflare before a request reaches the VPS.
- Bypass for the edge push. A second self-hosted application on
flow.example.com/api/hooks/edgewith action Bypass (Cloudflare applies the most specific matching application). The hook has no bearer token and is authenticated by its HMAC signature alone, so nothing else is exposed. If you prefer Access on that path too, use Service Auth with a service token and setconnector.push_headerson the edge (CF-Access-Client-Id,CF-Access-Client-Secret). - No inbound ports. The compose file publishes nothing and
cloudflareddials out, so the origin is reachable only through the tunnel and Access cannot be bypassed by hitting the VPS address.
Identity in the app. Cloudflare authenticates the browser; OpexFlow still authorises it with a bearer token or
its own OIDC login, with roles (viewer to admin), a session cookie and an audit log. Configure auth.oidc against
your IdP to avoid a second prompt (see Security); OPEX_API_TOKEN
stays the break-glass admin credential.
The edge-to-hosted link
The cleanest outbound-only link uses what OpexFlow already has: the edge’s push sink and the hosted node’s signed
webhook source. No extra component, nothing listens at the plant.
# edge.yaml (plant)
connector:
name: "plant-sg-01"
push_endpoint: "${HOSTED_URL}/api/hooks/edge"
hmac_secret: "${EDGE_HMAC_SECRET}" # same value as on the hosted node
hmac_key_id: "plant-sg-01"
# push_headers: # only with Access Service Auth on that path
# CF-Access-Client-Id: "${CF_ACCESS_CLIENT_ID}"
# CF-Access-Client-Secret: "${CF_ACCESS_CLIENT_SECRET}"
push_queue: { enabled: true, max_quota_mb: 2000, on_full: evict_oldest }flow.edge.json sends every record of the plant adapters to local history and to the push sink.
What each end guarantees:
| Situation | Behaviour |
|---|---|
| Normal | The record is written to push_queue.db first, then sent as one signed POST; the hosted flow answers 200 after it ran (respond: on_done). |
| Hosted side or WAN down | Records queue on the edge disk, in order, up to max_quota_mb; sending retries with exponential backoff, Retry-After honoured. The edge restarting keeps the queue. |
| Link back | The queue drains by itself, oldest first. |
| Response lost after the hosted side stored the record | The record is sent again (at-least-once); dedupe makes the repeat harmless. |
Wrong secret / 401, 403 |
Nothing is lost: the records stay queued and GET /api/push/status shows last_error. Fix the secret and the queue drains. |
Other 4xx (the hosted flow refused the record) |
The row moves to the push dead letters (GET /api/push/dead, replay with POST /api/push/dead/replay). |
| Queue at quota | evict_oldest drops the oldest records and counts the gap (alert at 80 %); block refuses new records instead (for business flows). |
| Captured request replayed later | Refused: the signature covers a timestamp, the hook rejects more than 300 s. Each retry is re-signed, so a record that waited a day is still accepted. |
This was run end to end with two local instances (edge and hosted, own data directories). Records injected on the
edge (POST /api/ingest/edge/{machine}) arrived and were routed (alarm records tagged alarm, others normal). With the hosted
instance stopped, five more records were injected and queued on the edge (queued=4, then 5 after the edge itself was restarted, with
last_error: transport error), and once the hosted instance was started again the queue drained to 0 and every
record was in the hosted history, none dead-lettered. deploy/hosted/check-link.sh repeats the first half against your
own pair, and the test suite (api/hosted_link.rs) pins the outage, the redelivery, a wrong secret and a replay.
Operating it
Sizing. 1 vCPU, 1 GB RAM and 20 GB disk is a starting point, not a measured limit. The published 2.1.0 binary idles at
about 15 MiB RSS in a container. Disk grows with records: 2,008 records with one axis, a spindle and two metadata values
took about 0.43 KB each in data.db and 0.17 KB in dedupe.db, so plan records per day × 0.45 KB × storage.retention_days and cap it with storage.max_size_mb. Load-test your own rate: the 2,000-record smoke run (a debug build driven by a
curl loop, 24 s, no backlog) is not a capacity figure.
Backups. Never copy a live data.db. deploy/hosted/backup.sh snapshots every database with sqlite3 .backup
(online, consistent), copies the small state files, and keeps the newest 14 timestamped directories
(KEEP). The systemd timer in deploy/hosted/systemd/ does the same without Docker. Ship the directory off the host.
OPEX_MASTER_KEY is not in the backup and must be stored separately, or restored data is unreadable. To restore, stop the
stack, copy the backup into the volume, start (deploy/hosted/README.md). A restore of such a backup came up with the
same flow version and all of its records.
Upgrades. Take a backup, change OPEX_FLOW_VERSION (or OPEX_FLOW_IMAGE) and run
docker compose build opex-flow && docker compose up -d opex-flow. The old container drains on SIGTERM and the new one
opens the same volume. Run one version per volume; to roll back, re-pin the old version, and restore the backup if the
newer one had already changed a database.
Without Docker. deploy/hosted/systemd/ has a drop-in for the shipped deploy/opex-flow.service
(opex-flow-hosted.conf: master key as a systemd credential, loopback bind, no device access), a hardened
cloudflared.service, and the backup service and timer. Point the tunnel’s public hostname at HTTP localhost:3000.
Monitoring. GET /api/ready for the container, GET /api/push/status on the edge (queued, consecutive_failures,
oldest_age_secs), GET /api/status → engine.webhooks[] on the hosted node (received, bad_signature), plus
/metrics and the SLA dashboard as on any instance (Monitoring and reports).
OpexFlow next to an iPaaS
An iPaaS (Boomi, Workato, MuleSoft, n8n Cloud, Make, …) and OpexFlow solve different parts of the same problem. The iPaaS is strong where the work is between SaaS applications, with a catalogue of prebuilt connectors and tooling for people who are not developers. OpexFlow is strong at the plant boundary (Modbus, OPC-UA, EtherNet/IP, FOCAS, printers, local store-and-forward) and as a self-hosted business flow engine with delivery guarantees. OpexFlow does not ship a catalogue of hundreds of SaaS connectors: if the business side needs one, keep the iPaaS for it. Three ways they sit together:
1. OpexFlow pushes into the iPaaS (signed). Point push_endpoint at the iPaaS’s inbound webhook trigger. Every
request is signed (X-Signature over the body, X-Opex-Signature over {timestamp}.{body}, X-Opex-Key-Id), so the
iPaaS can verify origin and freshness (the contract and sample verifiers are on Data contract);
put an API key the platform wants in connector.push_headers. Delivery is at-least-once through the durable queue, so make the
iPaaS flow idempotent (the batch envelope carries X-Batch-Id) and answer 5xx/429 to be retried, another 4xx to dead-letter.
connector:
push_endpoint: "https://hooks.ipaas.example.com/catch/abc123"
hmac_secret: "${IPAAS_SIGNING_SECRET}"
push_headers: { X-Api-Key: "${IPAAS_API_KEY}" }
push_queue: { enabled: true }
batch: { enabled: true, max_records: 100, flush_interval_secs: 5 }2. The iPaaS calls OpexFlow. A flow source webhook:{name} is a public, signature-checked endpoint
(hmac_sha256 with a timestamp, or doku; see Signed webhooks); POST /api/ingest/{source}/{id}
with a named API token (role operator) is the authenticated alternative. Both answer 200 done, 422 failed, 503 retry (with Retry-After) or
202 pending, so the iPaaS can retry correctly.
3. Hosted OpexFlow as the hub. The plants push to the hosted node (above); its flows call the ERP and the iPaaS with http
nodes (OAuth2, JWT bearer, basic, bearer, NetSuite TBA, DOKU signing; retries and a dead-letter queue with replay), and
people approve exceptions in the app. The iPaaS keeps doing what it is good at, fed by a record stream that already has
the plant’s identity, quality flags and ordering.
Hosted OpexFlow replaces an iPaaS where the integration is plant to ERP/MES with delivery guarantees and local autonomy. It does not replace one where the integration is SaaS to SaaS across many vendors.