Why On-Premise?
Most IoT tutorials start with AWS IoT Core, Azure IoT Hub, or some SaaS broker. Those are good options — today I’ll show an architecture for companies such as carriers, or enterprises that prefer to run their own private clouds. Or on any cloud, in containers.
This stack was built on the following premises:
- Total control over the data
- Mandatory mTLS — no device connects without a valid certificate
- Streaming pipeline
- Near-zero operational cost.
- To be fair on this item: the cost here is your own servers and your IT analysts — analysts you would need anyway to operate cloud solutions.
An architecture that makes sense in an industrial plant, a private network, or somewhere isolated from the internet.
Architecture Overview

Each layer has a single responsibility.
- EMQX doesn’t know Kafka directly; it authenticates and manages the devices’ mTLS keys, validates who can publish to which topic, and fires a data transport rule. Here it plays the middleware/second-layer role described in this post
- Kafka doesn’t know the device; it only receives messages with a topic and a payload.
Layer 1: The Device — M5StickC Plus2 with MicroPython
The client is an M5StickC Plus2 running MicroPython. It collects sensor data and publishes to the sensors/device-001 topic at a configurable interval.
The connection uses mTLS: the device carries three files in its internal filesystem — ca.pem, cert.pem, and key.pem. Without these certificates, the connection is refused at the TLS layer before it even reaches MQTT.
MQTT_HOST = "mqtt.mfs.eng.br"
MQTT_PORT = 8883
MQTT_TOPIC = "sensors/device-001"
DIR_CERTS = "certificate"
FILE_CA = "ca.pem"
FILE_CERT = "cert.pem"
FILE_KEY = "key.pem"
The payload published by the device is a JSON with system, network, environment, and hardware identity fields:
{
"clientid": "device-001",
"host": "189.37.74.112:18983",
"mqtt_topic": "sensors/device-001",
"ts": 1774877350907,
"pub_rec_date": 1774877350907,
"payload": {
"timestamp": "2026-03-30T13:29:10",
"system": {
"send_interval_seconds": 60,
"send_count": 647,
"is_charging": true,
"client_id": "device-001",
"battery_v": 4.246,
"battery_pct": 100
},
"network": {
"ssid": "MinhaRede",
"rssi": -39,
"mac": "00:4B:12:C4:B8:0C",
"ip": "192.168.68.107"
},
"env": {
"temperature": 26.2,
"pressure": 911.22,
"humidity": 58.18,
"altitude_est": 886.4
},
"device": {
"unique_id": "004B12C4B80C",
"micropython_version": "1.25.0",
"machine": "M5STACK StickC PLUS2 with ESP32(SPIRAM)",
"cpu_freq_mhz": 240,
"chip": "esp32"
}
}
}
I’ll write a follow-up post about certificates, mTLS, and how to generate them on both the device and the server side.
Layer 2: EMQX 5.0
Why EMQX?
EMQX 5 has a native feature I really like: per-listener isolated authentication. That means you can have, in the same instance:
- Port 8883: accepts only client certificates (mTLS), no username/password
- Port 1883: accepts only username/password, no TLS
- Port 8083: WebSocket with username/password
Each listener has its own authentication chain. A device without a certificate cannot connect on 8883, even if it presents valid credentials.
mTLS Listener Configuration
# mTLS — dispositivos IoT
listeners.ssl.default {
bind = "0.0.0.0:8883"
enable_authn = false
ssl_options {
cacertfile = "etc/certs/rootCA.crt"
certfile = "etc/certs/server.crt"
keyfile = "etc/certs/server.key"
verify = verify_peer
fail_if_no_peer_cert = true
}
}
mqtt.peer_cert_as_clientid = cn
mqtt.peer_cert_as_username = cn
Using the Certificate CN as Identity
An important design decision: EMQX can extract the Common Name from the client certificate and use it automatically as the clientid and username.
mqtt.peer_cert_as_clientid = cn
mqtt.peer_cert_as_username = cn
This means the device’s identity is the certificate itself — not a password that can leak. The CN device-001 automatically becomes the clientid in the MQTT session.
Layer 3: Kafka + ksqlDB
Kafka in KRaft Mode
Kafka runs without Zookeeper using KRaft — Kafka’s native consensus mode since version 3. One less process to manage, one less source of failure.
The SQL rule in EMQX selects the payload fields and forwards them to the Kafka action:
SELECT
timestamp as ts,
clientid,
peername as host,
topic as mqtt_topic,
publish_received_at as pub_rec_date,
json_decode(payload) as p
FROM
"sensors/+"
ksqlDB — Input Stream (TELEMETRY_RAW)
The input stream maps the iot.telemetry.raw topic with the payload’s full schema. An important lesson learned in practice: inside STRUCT<>, ksqlDB doesn’t accept backticks or INTEGER — use INT. The word timestamp works normally inside the STRUCT, contrary to what you’d expect.
CREATE STREAM TELEMETRY_RAW (
clientid VARCHAR,
host VARCHAR,
mqtt_topic VARCHAR,
ts BIGINT,
pub_rec_date BIGINT,
payload STRUCT<
timestamp VARCHAR,
system STRUCT<
send_interval_seconds INT,
send_count INT,
is_charging BOOLEAN,
client_id VARCHAR,
battery_v DOUBLE,
battery_pct INT
>,
network STRUCT<
ssid VARCHAR,
rssi INT,
mac VARCHAR,
ip VARCHAR
>,
env STRUCT<
temperature DOUBLE,
pressure DOUBLE,
humidity DOUBLE,
altitude_est DOUBLE
>,
device STRUCT<
unique_id VARCHAR,
ram_free_b BIGINT,
ram_alloc_b BIGINT,
micropython_version VARCHAR,
machine VARCHAR,
flash_size_kb INT,
firmware VARCHAR,
cpu_freq_mhz INT,
chip VARCHAR
>
>
) WITH (
KAFKA_TOPIC = 'iot.telemetry.raw',
VALUE_FORMAT = 'JSON',
TIMESTAMP = 'ts'
);
ksqlDB — Flat Derived Stream (SENSOR_RAW)
From the raw stream, we create a flat stream with only the environment variables, RSSI, and device identity. The clientid becomes device, and everything sits at the same level, with no nesting.
CREATE STREAM SENSOR_RAW
WITH (
KAFKA_TOPIC = 'iot.telemetry.sensor',
VALUE_FORMAT = 'JSON',
PARTITIONS = 6
) AS
SELECT
clientid AS device,
ts,
payload->network->rssi AS rssi,
payload->env->temperature AS temperature,
payload->env->pressure AS pressure,
payload->env->humidity AS humidity,
payload->env->altitude_est AS altitude_est
FROM TELEMETRY_RAW
EMIT CHANGES;
The result on the iot.telemetry.sensor topic is a flat JSON, easy for any sink to consume:
{
"DEVICE": "device-001",
"TS": 1774877350907,
"RSSI": -39,
"TEMPERATURE": 26.2,
"PRESSURE": 911.22,
"HUMIDITY": 58.18,
"ALTITUDE_EST": 886.4
}
ksqlDB — Daily Statistics Table (SENSOR_DAILY_STATS)
A table with a 1-day tumbling window aggregates the minimum, maximum, and average of every variable per device. The table key is composed of device + window, so each device has exactly one record per day.
CREATE TABLE SENSOR_DAILY_STATS
WITH (
KAFKA_TOPIC = 'iot.telemetry.sensor.daily',
VALUE_FORMAT = 'JSON',
PARTITIONS = 6
) AS
SELECT
device,
WINDOWSTART AS window_start,
WINDOWEND AS window_end,
COUNT(*) AS total_records,
MIN(temperature) AS temperature_min,
MAX(temperature) AS temperature_max,
ROUND(AVG(temperature), 2) AS temperature_avg,
MIN(pressure) AS pressure_min,
MAX(pressure) AS pressure_max,
ROUND(AVG(pressure), 2) AS pressure_avg,
MIN(humidity) AS humidity_min,
MAX(humidity) AS humidity_max,
ROUND(AVG(humidity), 2) AS humidity_avg,
MIN(altitude_est) AS altitude_min,
MAX(altitude_est) AS altitude_max,
ROUND(AVG(altitude_est), 2) AS altitude_avg,
MIN(rssi) AS rssi_min,
MAX(rssi) AS rssi_max,
ROUND(AVG(rssi), 2) AS rssi_avg
FROM SENSOR_RAW
WINDOW TUMBLING (SIZE 1 DAY)
GROUP BY device
EMIT CHANGES;
To query a specific device’s statistics for the day:
SELECT
device,
TIMESTAMPTOSTRING(window_start, 'yyyy-MM-dd') AS dia,
temperature_min, temperature_max, temperature_avg,
humidity_min, humidity_max, humidity_avg,
pressure_min, pressure_max, pressure_avg,
rssi_min, rssi_max, rssi_avg
FROM SENSOR_DAILY_STATS
WHERE device = 'device-001'
EMIT CHANGES;
The result is a reactive pipeline: when the M5Stick publishes, the data crosses EMQX → Kafka → ksqlDB in under a second, and the daily statistics update in real time.
Design Decisions and Trade-offs
mTLS instead of username/password on devices: Certificates can’t be brute-forced. A compromised device can have its certificate revoked at the CA without affecting the others.
Kafka instead of writing straight to the database: The database is a sink, not the source of truth. With Kafka in the middle, you can add new consumers without touching the producer.
ksqlDB instead of custom consumers: For simple aggregations and transformations, SQL is enough — and more readable than Python or Java code.
Layered streams: TELEMETRY_RAW preserves the device’s full schema. SENSOR_RAW is the working stream — flat and lightweight. The SENSOR_DAILY_STATS table delivers ready-made aggregations with no additional queries.
Conclusion
This stack isn’t the simplest to set up. But it’s the most honest one to run on-premise.
The M5Stick could be any IoT device with mTLS support.
EMQX scales to millions of simultaneous connections while keeping the same configuration.
Kafka absorbs peaks without loss and decouples the architecture between writes and reads.
ksqlDB processes in real time with no additional infrastructure.