nginz-iot: industrial protocols are just more sockets
The nginz family keeps making one bet and acting surprised when it pays off.
The bet is this: nginx is already the best thing on your network. It terminates TLS, owns the socket, reloads cleanly, and answers to one config file and one operator model. So the features you’d normally run beside it — a sidecar, a separate service, a SaaS proxy that sees your traffic — should live inside the worker instead.
nginz was that bet applied to the reverse proxy. nginz-token applied it to the AI gateway. And now nginz-iot applies it to a domain that looks nothing like HTTP: MQTT brokers, industrial device gateways, broker-owned state, and the bridges that move readings into your databases.
This post is the tour. What we kept from nginx, how fast it actually goes, and the one extension point we opened to the public. The product site is citusio.com if you’d rather skip the essay and read the tier matrix.
Industrial protocols are just more sockets
The thing we kept repeating while building this: industrial protocols look exotic, but at the transport layer they’re a short list of things nginx has been doing for two decades.
MQTT is a TCP stream. MQTT-SN and CoAP are UDP. LwM2M rides CoAP. OCPP rides a secure WebSocket. Sparkplug B rides MQTT. Modbus TCP is a TCP socket with a poll loop attached. OPC UA is a stateful TCP client. LoRaWAN arrives as HTTPS webhooks. Nobody had to teach nginx what a socket is — the job of nginz-iot is to write the dialects.
What actually changes is the part after the transport: who owns the state. nginx workers are separate processes, so broker state can’t pretend to be local. Our answer is a hard split that we didn’t soften for convenience:
- nginx workers own the bounded, stateless hot path. Parse the frame, validate it, extract the identity, run the admission check, fail closed. Work that finishes in microseconds and never waits on anything.
nginz-iotdowns the authority. Sessions, subscriptions, routing, retained messages, QoS state, durable replay, readiness, HA writer terms. Anything that has to survive a worker restart or be consistent across workers.nginz-iot-bridgeowns the outside world. Blocking sinks — databases, webhooks, MQTT-to-MQTT — because external clients are slow and failure-prone, and the workers should never stall on them.
Two daemons, no more. Store, rules, schema, observe, and admin snapshots live inside nginz-iotd unless a measured isolation requirement justifies a new process. We have not found one yet.
The catalog that falls out of this:
| Protocol | Transport nginx already owns | Where it’s owned |
|---|---|---|
| MQTT 3.1.1 / selected MQTT 5 | TCP, TLS | worker codec + nginz-iotd sessions |
| MQTT over WebSocket | HTTP Upgrade | worker gateway |
| MQTT-SN | UDP | worker gateway |
| CoAP | UDP | worker gateway + daemon translation |
| LwM2M 1.2 | CoAP / UDP | worker boundary + daemon state |
| OCPP 1.6J | secure WebSocket | worker + daemon call correlation |
| Sparkplug B 3.0 | MQTT | ingress + schema/state machine |
| HTTPS device ingress | HTTP, TLS | worker + daemon command authority |
| Modbus TCP | TCP (client) | nginz-iotd polling |
| OPC UA client | TCP (client) | nginz-iotd client authority |
| LoRaWAN | HTTPS webhooks | worker + bridge downlink |
The two client-shaped rows — Modbus and OPC UA — are the interesting ones. They’re not inbound listeners, so they don’t get a fake nginx module just to keep the table symmetrical. They’re active acquisition clients owned entirely by nginz-iotd, behind the same bounded-queue and fail-closed discipline as everything else.
How fast, measured honestly
Per-node performance is a product edge here, not a future optimization pass. EMQX and Erlang/BEAM remain strong in scale-out and supervision dimensions, so nginz-iot has to win where nginx should win: per-node connection handling, packet hot-path latency, and memory discipline.
We don’t claim to be the fastest at everything — the published measurement is honest about the places we don’t win. What we claim is narrower: on one node, with the full nginx-plus-authority stack, we move more messages, and we beat EMQX on the paths that matter, without pretending Mosquitto stopped being a good broker.
The fixture behind the published numbers runs both nginz-iot image variants and both competitors through one workload: MQTT 3.1.1, QoS 1, plain TCP, 128-byte payloads, 200 connections, memory-backed export, three physical cores on an Intel i7-3615QM. Throughput first, because that’s where the gap lives:
| Installed image | 128 B · msg/s | 1 KiB · msg/s |
|---|---|---|
| nginz-iot (Alpine) | 80,000 | 50,000 |
| nginz-iot (Debian) | 80,000 | 50,000 |
| Mosquitto 2.1.2 | 40,000 | 30,000 |
| EMQX 6.3.2 | 15,000 | 15,000 |
At 128 bytes that’s twice Mosquitto’s sampled rate and more than five times EMQX’s. A pass isn’t “the broker kept up” — it requires an exact offer and an independent sink byte count, publish-acknowledgement p99 and oldest pending ACK inside one second, a deadline backlog under 1.25 seconds of offered work, and a drain inside two seconds. EMQX’s 20,000/s attempt failed those bounds by a wide margin: a 353,311-message backlog against a 25,000 budget, and a 13.4-second drain against a two-second limit. Finishing the race is not the same as finishing it inside the rules.
Latency tells the other half of the story. At a shared 10,000 messages/s over five minutes:
| Installed image | ACK p95 | ACK p99 | CPU time (core-s) | Peak PSS |
|---|---|---|---|---|
| nginz-iot (Alpine) | 0.659 ms | 0.871 ms | 431.69 | 466.9 MiB |
| Mosquitto 2.1.2 | 0.550 ms | 0.713 ms | 190.31 | 8.3 MiB |
| EMQX 6.3.2 | 0.728 ms | 1.086 ms | 1,274.40 | 488.1 MiB |
Read that table honestly and you’ll see the same thing we do: Mosquitto is still the low-cost champion — a few megabytes of memory and the best publish-acknowledgement latency by a couple tenths of a millisecond. We sit close on latency and far ahead of EMQX, which burns 1,274 core-seconds to our 432 while living in the same memory ballpark. That trade — a bit more memory than Mosquitto, a lot less CPU than EMQX — is the nginx-native posture, and we’d rather show it than hide it.
The caveats, because a throughput number without them is marketing: this is memory-backed export, not PostgreSQL durability, and durable recovery needs its own measurement. The throughput runs are 30-second offers; the latency runs are five minutes. The host reached 103°C and its throttle counter climbed by 146, so these are thermally bound sampled operating points on one machine, not a universal broker ceiling. Payload size, fanout, connections, and hardware all move the numbers — measure the exact image before sizing production.
The extension point is public
First- and third-party bridge adapters share one boundary: the public C SDK. It’s not a plugin API for people we trust. It’s the same loader, the same lifecycle, and the same fail-closed rules whether the adapter came from our catalog or your CI.
An adapter is a small C shared library that implements a vtable — init, connect, send, flush, drain, close, and a redact_config that keeps secrets out of diagnostics. The lifecycle is serialized and boring on purpose:
negotiate → init → connect → (send | send_batch)* → flush → drain → close
Trust defaults to reject. The daemon canonicalizes both the adapter path and the configured prefix with realpath before loading anything. Relative paths, dot traversal, ABI mismatch, missing callbacks, and unknown capability bits all fail closed. Signed packages are rejected because we haven’t shipped a signature policy yet — and we’d rather say no than pretend a half-built trust story is a trust story. Secrets are env:NAME references, never inline strings.
The delightful part is how little an adapter actually is. The hello-world sample is a little over a hundred lines of C that appends one line per accepted event to a file:// endpoint and writes a drain marker on shutdown. That’s the whole contract: take an event, do something idempotent-ish with it, report success, retryable, permanent, configuration, or timeout.
And the SDK ships from the free tier upward, because the extension mechanism isn’t what we charge for — the supported provider catalog and the operating guarantees are.
The family is growing
nginz-iot sits in four lanes, all of which permit real production use:
| Lane | What it’s for |
|---|---|
| Free | Live MQTT, memory-backed state, customer-built integrations |
| Pro | Durable MQTT through PostgreSQL, single active node |
| Max | Integration and edge — the industrial gateways, rules, and provider adapters |
| Enterprise | Fenced active–standby, governed commands, durable audit |
Same architecture all the way down. The differences are durability, breadth, and who answers the phone when it breaks.
The nginz family started as one module loading into stock nginx. Now there’s a runtime, an AI gateway, a finance-grade gateway discipline we borrow from, and an IoT data plane that speaks industrial protocols the way nginx always spoke HTTP — from inside the worker, one config file, one operator model.
If you run MQTT or plant-floor devices and already run nginx, the boundary you’re looking for is citusio.com. Start with Get Start, or go straight to Build to see the SDK adapter with its own eyes.