Cold chain monitoring is about proving that chilled food, vaccines or biologics never left their temperature range between the depot and the customer, and reacting fast when they do. In this article you will design a monitoring system for refrigerated trucks and containers that sends temperature data over cellular MQTT, survives dead zones without losing readings, and raises excursion alerts based on how long a limit was exceeded rather than every single spike.
What Cold Chain Monitoring Needs to Prove
Temperature logging in transport has two audiences. Operations staff need live information so a driver can fix a door left ajar or a failing reefer unit before the load is lost. Quality staff need a complete record after the trip, often to satisfy customers and good distribution practice requirements, showing every reading with its time and location.
Those two needs drive the whole design:
| Requirement | Why it matters | Design consequence |
|---|---|---|
| Live visibility | Intervene before product is damaged | Cellular connectivity, MQTT telemetry every few minutes |
| No gaps in the record | A missing hour can invalidate a shipment | Store-and-forward buffering on the device |
| Trustworthy timestamps | Order and duration of excursions must be provable | Timestamp at measurement time, not arrival time |
| Context | Was the door open? Where was the truck? | Door sensors and GPS alongside temperature |
| Meaningful alerts | Defrost cycles and door openings cause brief spikes | Duration-based excursion rules |
Hardware in the Vehicle
A typical setup has three parts:
- Temperature probes in the cargo area, usually two or more: one near the evaporator and one at the back near the doors, where warm air enters. Wireless BLE loggers are common for pallet-level tracking; wired probes suit fixed reefer installations.
- A telematics gateway with an LTE-M, NB-IoT or LTE modem and GPS. This can be a commercial unit or a small Linux board with a cellular HAT. It reads the probes, buffers data and publishes MQTT.
- Door contact sensors so every temperature rise can be correlated with an opening.
The gateway is the only device that talks to the broker. Even a long trailer full of BLE pallet loggers becomes one MQTT connection.
Topic Design for Cold Chain Monitoring
Organise by fleet asset rather than by trip; trips are metadata in the payload:
coldchain/<fleet>/<asset-id>/telemetry {"ts":1760600000,"t_front":3.8,"t_rear":4.6,"door":"closed","lat":41.01,"lon":28.97,"trip":"TR-8812"}
coldchain/<fleet>/<asset-id>/status retained {"online":true,"fw":"1.4.2","battery_v":12.6}
coldchain/<fleet>/<asset-id>/alarm {"type":"excursion","probe":"t_rear","max":9.4,"duration_s":1260,"trip":"TR-8812"}
coldchain/<fleet>/<asset-id>/config retained {"min":2.0,"max":8.0,"grace_s":900}
Retaining the config topic is a neat trick. The gateway subscribes to it on connect, so changing a load from chilled to frozen is a single retained publish from the dispatch system, and a gateway that reboots gets its limits back immediately.
Gateway Code with Store-and-Forward
Cellular coverage on motorways, in tunnels and at ports is patchy. The gateway must keep sampling while offline and send everything when the link returns. The Python sketch below uses paho-mqtt 2.x, MQTT 5, and a SQLite queue on the gateway. It targets the public test broker so you can try it on any computer; replace read_probes() with your actual sensor code.
import json
import random
import sqlite3
import time
import paho.mqtt.client as mqtt
PREFIX = "connectmq/demo/f2q7b5" # your own random prefix
ASSET = "TRK-104"
BASE = f"{PREFIX}/coldchain/fleet1/{ASSET}"
SAMPLE_EVERY = 60 # seconds
db = sqlite3.connect("buffer.db")
db.execute("CREATE TABLE IF NOT EXISTS q (id INTEGER PRIMARY KEY, payload TEXT)")
connected = False
limits = {"min": 2.0, "max": 8.0, "grace_s": 900}
def read_probes():
# replace with real sensors; values here simulate a chilled load
return {"t_front": round(random.uniform(3, 5), 1), "t_rear": round(random.uniform(3.5, 6), 1),
"door": "closed"}
def on_connect(client, userdata, flags, reason_code, properties):
global connected
connected = not reason_code.is_failure
if connected:
client.subscribe(f"{BASE}/config", qos=1)
client.publish(f"{BASE}/status", json.dumps({"online": True}), qos=1, retain=True)
def on_disconnect(client, userdata, flags, reason_code, properties):
global connected
connected = False
def on_message(client, userdata, msg):
limits.update(json.loads(msg.payload))
def flush(client, batch=50):
rows = db.execute("SELECT id, payload FROM q ORDER BY id LIMIT ?", (batch,)).fetchall()
for row_id, payload in rows:
info = client.publish(f"{BASE}/telemetry", payload, qos=1)
info.wait_for_publish(timeout=10)
if not info.is_published():
return # link dropped; keep remaining rows
db.execute("DELETE FROM q WHERE id = ?", (row_id,))
db.commit()
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"gw-{ASSET}-f2q7b5",
protocol=mqtt.MQTTv5)
client.will_set(f"{BASE}/status", json.dumps({"online": False}), qos=1, retain=True)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.reconnect_delay_set(min_delay=2, max_delay=120)
client.connect_async("broker.connectmq.com", 1883, keepalive=120)
client.loop_start()
while True:
reading = {"ts": int(time.time()), "trip": "TR-8812", **read_probes()}
db.execute("INSERT INTO q (payload) VALUES (?)", (json.dumps(reading),))
db.commit()
if connected:
flush(client)
time.sleep(SAMPLE_EVERY)
Every reading goes into the queue first and is only deleted once the broker has acknowledged it at QoS 1. That makes the buffer the source of truth: power loss, reboots and long dead zones cannot create gaps, and the ts field records when the measurement was taken rather than when it arrived. For more on delivery guarantees, see MQTT QoS levels explained.
Excursion Alerts That Ignore Noise
A naive "alert if temperature is above 8 °C" rule fires every time the doors open at a delivery stop or the evaporator defrosts. Real cold chain alerting looks at duration: alarm only when a probe has been out of range longer than a grace period.
import json
import paho.mqtt.client as mqtt
PREFIX = "connectmq/demo/f2q7b5"
LIMITS = {"min": 2.0, "max": 8.0, "grace_s": 900}
state = {} # (asset, probe) -> first out-of-range timestamp
def check(client, asset, probe, value, ts):
key = (asset, probe)
out = value < LIMITS["min"] or value > LIMITS["max"]
if not out:
state.pop(key, None)
return
start = state.setdefault(key, ts)
if start is not None and ts - start >= LIMITS["grace_s"]:
alarm = {"type": "excursion", "probe": probe, "value": value, "since": start,
"duration_s": ts - start}
client.publish(f"{PREFIX}/coldchain/fleet1/{asset}/alarm", json.dumps(alarm), qos=1)
state[key] = None # alarm once per excursion; reset when back in range
def on_connect(client, userdata, flags, reason_code, properties):
client.subscribe(f"{PREFIX}/coldchain/fleet1/+/telemetry", qos=1)
def on_message(client, userdata, msg):
asset = msg.topic.split("/")[-2]
r = json.loads(msg.payload)
for probe in ("t_front", "t_rear"):
if probe in r:
check(client, asset, probe, r[probe], r["ts"])
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="excursion-f2q7b5")
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=60)
client.loop_forever()
Because it uses the measurement timestamp, the detector handles buffered data correctly: a twenty-minute excursion that happened in a tunnel is still recognised as twenty minutes when the backlog arrives. For simple fixed limits on a live value, the threshold alert rules in the portal are a quick start; duration logic like this belongs in your own subscriber or Node-RED.
Watching the Fleet
Subscribe to everything while testing:
mosquitto_sub -h broker.connectmq.com -p 1883 -i ops-f2q7b5 -t "connectmq/demo/f2q7b5/coldchain/#" -v
The public MQTT test broker is fine for this prototype, but shipment data and customer locations must not travel over a publicly readable area. Production gateways should use TLS on port 8883 and credentials generated in the portal.
Cellular Connectivity Tips
- Keep payloads small. Short keys and one-minute samples keep data plans modest; batch several readings per message if your plan charges per message.
- Set a generous keep-alive. Values of two to five minutes reduce radio wake-ups without delaying disconnect detection too much.
- Use MQTT 5 session expiry so subscriptions survive short drops; see MQTT 5 features.
- Back off on reconnect. Exponential delays avoid hammering the network after a tower handover.
- Watch the modem. Publish signal strength and network type in
statusto diagnose dead zones.
Common Pitfalls
Probe next to the evaporator only. That probe shows the coldest air in the box and can hide a warm zone at the doors. Measure where the product is most at risk.
Server-side timestamps. If you stamp readings when they arrive, a buffered backlog collapses into a single minute and excursion durations become meaningless. Always stamp at measurement time.
Alarm storms after reconnect. A gateway flushing an hour of data can trigger many alerts at once. Alarm once per excursion, as the detector above does, and mark late alarms as "historical" so dispatchers know the event is already over.
Shared credentials across the fleet. One leaked login then exposes every truck. Give each gateway its own credentials and client ID.
Rollout Checklist
- Probes placed at the warmest and coldest points of the cargo space, and calibrated.
- Door sensors installed and reported in every telemetry message.
- Gateway buffers to non-volatile storage and only deletes after acknowledgement.
- Timestamps from a synchronised clock (GPS or NTP).
- Limits delivered as retained configuration per asset.
- Duration-based excursion rules per product category.
- A durable archive subscriber that writes every reading to a database for trip reports.
- TLS and unique credentials per gateway.
Related Patterns
Fleet monitoring shares a lot with other distributed IoT systems. The gateway-per-asset idea mirrors the field gateways in a smart city IoT architecture, and aggregating many assets into live status views is the same problem solved in smart parking IoT. For building a durable retained-state model, our water leak detection project shows retained alarms in a smaller setting, and the IoT automation guide has more examples.
Run Your Cold Chain Telemetry on ConnectMQ
Fleet telemetry needs a broker that is reachable from every cellular network, secured with TLS and maintained around the clock. ConnectMQ provides a managed MQTT broker with MQTT 5, TLS, generated credentials and a live topic explorer to watch every truck report in. Start with a free Serverless broker at app.connectmq.com, no credit card required, and scale to a paid or dedicated plan as the fleet grows. For hosting background, read about the cloud MQTT broker approach.
Frequently Asked Questions
How often should a refrigerated truck report temperature?
Sampling every one to five minutes is typical. Sample locally at that rate even when offline, and send the buffered readings when coverage returns.
What happens if the truck has no signal?
The gateway stores readings with their measurement timestamps in local storage and publishes them in order once connected, deleting each only after the broker acknowledges it.
Why alert on duration instead of a single high reading?
Door openings and defrost cycles cause brief spikes that do not harm the product. A grace period filters these out while still catching real excursions.
Does ConnectMQ store the temperature history?
No. ConnectMQ delivers messages in real time. Run a subscriber that writes every reading to your own database for trip reports and audits.



