A GPS tracker with MQTT is three small pieces: a device that reads a GPS fix and publishes it as JSON, a broker that routes it, and a map that subscribes and moves a marker. Because MQTT keeps a single connection open and adds only a few bytes per message, it suits trackers that report often over Wi-Fi or cellular links.
This tutorial builds all three: ESP32 firmware with a serial GPS module, a Python simulator so you can test without hardware, and a browser map with Leaflet and MQTT.js. The payload follows the OwnTracks location format, so phones running OwnTracks can share the same map.
Architecture
GPS module ──NMEA──▶ ESP32 ──MQTT/TLS 8883──▶ broker ──WSS 8084──▶ browser map
│
└──▶ recorder / database (optional)| Piece | Role | What to choose |
|---|---|---|
| GPS module | Outputs NMEA sentences over UART | u-blox NEO-6M/M8N or similar, 9600 baud |
| Device | Parses NMEA, decides when to publish | ESP32 (Wi-Fi) or ESP32 + LTE modem |
| Broker | Routes, retains last position, enforces ACLs | Any MQTT 3.1.1/5 broker with TLS |
| Map | Subscribes over WebSockets, draws markers | Leaflet + MQTT.js in a static page |
| Storage (optional) | Keeps history | OwnTracks Recorder, InfluxDB, PostgreSQL |
Topics and payload
Use one topic per tracker and keep a separate status topic for availability:
testmqtt/gps/<user>/<device> location JSON, retained
testmqtt/gps/<user>/<device>/status "online" / "offline", retained (Last Will)On your own broker you would typically use owntracks/<user>/<device>, which is what the OwnTracks apps and Recorder expect. Here we use a testmqtt/ prefix so the examples are safe to run on a shared test endpoint. You can check which subscriptions match which tracker with the topic matcher.
The payload is a subset of the OwnTracks location message:
{
"_type": "location",
"tid": "t1",
"lat": 52.520008,
"lon": 13.404954,
"alt": 38,
"vel": 12,
"cog": 270,
"acc": 6,
"tst": 1792236000
}lat/lon are decimal degrees, tst is the UTC Unix timestamp of the fix (not of the publish), vel is km/h, cog is the course in degrees and acc an accuracy estimate in metres. tid is a two-character label shown on maps. Always send the fix time: QoS 1 retries, offline buffers and the retained message mean a subscriber can receive a position long after it was measured.
Publish the location retained so a map that opens later immediately shows the last known position. See retained messages for how retention works and how to clear it.
The ESP32 firmware
Wire the module's TX to ESP32 GPIO16 and RX to GPIO17, power it from 3.3 V (check your board), and install TinyGPSPlus and PubSubClient from the Arduino Library Manager. If you have not set up an ESP32 MQTT project before, start with ESP32 MQTT with Arduino.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <TinyGPSPlus.h>
#include <time.h>
const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASS = "your-wifi-password";
const char* MQTT_HOST = "broker.connectmq.com";
const int MQTT_PORT = 8883;
const char* MQTT_USER = "username";
const char* MQTT_PASS = "password";
const char* LOC_TOPIC = "testmqtt/gps/alice/tracker1";
const char* STAT_TOPIC = "testmqtt/gps/alice/tracker1/status";
TinyGPSPlus gps;
HardwareSerial gpsSerial(2);
WiFiClientSecure net;
PubSubClient mqtt(net);
double lastLat = 0, lastLon = 0;
unsigned long lastSend = 0;
time_t fixEpoch() {
struct tm t = {};
t.tm_year = gps.date.year() - 1900;
t.tm_mon = gps.date.month() - 1;
t.tm_mday = gps.date.day();
t.tm_hour = gps.time.hour();
t.tm_min = gps.time.minute();
t.tm_sec = gps.time.second();
return mktime(&t); // TZ is set to UTC in setup()
}
void connectMqtt() {
while (!mqtt.connected()) {
String id = "gps-" + String((uint32_t)ESP.getEfuseMac(), HEX);
// Last Will: the broker publishes "offline" if the tracker vanishes
if (mqtt.connect(id.c_str(), MQTT_USER, MQTT_PASS, STAT_TOPIC, 1, true, "offline")) {
mqtt.publish(STAT_TOPIC, "online", true);
} else {
Serial.printf("MQTT connect failed, state=%d\n", mqtt.state());
delay(5000);
}
}
}
void publishFix() {
char payload[256];
snprintf(payload, sizeof(payload),
"{\"_type\":\"location\",\"tid\":\"t1\",\"lat\":%.6f,\"lon\":%.6f,"
"\"alt\":%d,\"vel\":%d,\"cog\":%d,\"acc\":%d,\"tst\":%lu}",
gps.location.lat(), gps.location.lng(),
(int)gps.altitude.meters(), (int)gps.speed.kmph(), (int)gps.course.deg(),
(int)(gps.hdop.hdop() * 5), // rough heuristic, not a real accuracy
(unsigned long)fixEpoch());
mqtt.publish(LOC_TOPIC, payload, true); // retained
}
void setup() {
Serial.begin(115200);
gpsSerial.begin(9600, SERIAL_8N1, 16, 17);
setenv("TZ", "UTC0", 1);
tzset();
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(500);
net.setInsecure(); // first test only, use net.setCACert(rootCA) in production
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(512);
mqtt.setKeepAlive(30);
}
void loop() {
while (gpsSerial.available()) gps.encode(gpsSerial.read());
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
if (!gps.location.isValid() || !gps.date.isValid() || gps.location.age() > 2000) return;
double moved = TinyGPSPlus::distanceBetween(
gps.location.lat(), gps.location.lng(), lastLat, lastLon);
unsigned long now = millis();
// publish on movement (> 25 m) or every 60 s as a heartbeat
if (moved > 25 || now - lastSend > 60000) {
publishFix();
lastLat = gps.location.lat();
lastLon = gps.location.lng();
lastSend = now;
}
}A few design choices worth keeping:
- Distance-based reporting. Publishing every second wastes data while parked; publishing every minute makes the trail jagged while moving. Distance plus heartbeat gives both.
- Last Will on the status topic. If power or coverage disappears, the broker publishes
offlineafter about 1.5 × keep alive. The map can grey the marker out. More in Last Will and Testament. - TLS from the start.
setInsecure()is fine on your desk; for real devices load the broker's root CA withsetCACert(). Location data is personal data. - The connect loop blocks while offline, so NMEA bytes pile up in the UART buffer. For a production tracker, reconnect without blocking and buffer fixes so the trail has no gaps.
Test without hardware: a Python simulator
Before you solder anything, simulate a tracker. This script (paho-mqtt 2.x) walks a route through Berlin and publishes the same payload and topics as the firmware:
import json, os, time
import paho.mqtt.client as mqtt
HOST, PORT = "broker.connectmq.com", 8883
TOPIC = "testmqtt/gps/alice/sim1"
STATUS = TOPIC + "/status"
ROUTE = [(52.5200, 13.4050), (52.5163, 13.3777), (52.5096, 13.3759), (52.5074, 13.3904)]
def steps(a, b, n=10):
for i in range(n):
f = i / n
yield a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="gps-sim-1")
if os.getenv("MQTT_USER"):
client.username_pw_set(os.environ["MQTT_USER"], os.getenv("MQTT_PASS"))
client.tls_set()
client.will_set(STATUS, "offline", qos=1, retain=True)
client.connect(HOST, PORT, keepalive=30)
client.loop_start()
client.publish(STATUS, "online", qos=1, retain=True)
for a, b in zip(ROUTE, ROUTE[1:]):
for lat, lon in steps(a, b):
fix = {"_type": "location", "tid": "s1", "lat": round(lat, 6),
"lon": round(lon, 6), "acc": 8, "vel": 30, "tst": int(time.time())}
client.publish(TOPIC, json.dumps(fix), qos=1, retain=True)
print("sent", fix["lat"], fix["lon"])
time.sleep(2)
client.publish(STATUS, "offline", qos=1, retain=True).wait_for_publish()
client.disconnect()
client.loop_stop()Watch the messages arrive from a terminal:
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
-u "$MQTT_USER" -P "$MQTT_PASS" -t 'testmqtt/gps/#' -vOr open the online MQTT client and subscribe to testmqtt/gps/#. Because the location is retained, a subscriber that connects after the simulator stops still receives the last fix.
A live map in the browser
Browsers speak MQTT over WebSockets. This static page uses Leaflet with OpenStreetMap tiles and MQTT.js 5 on port 8084 (wss, path /mqtt):
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>MQTT GPS map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>html, body, #map { height: 100%; margin: 0; }</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
<script>
const map = L.map('map').setView([52.52, 13.40], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
const markers = {}, trails = {};
const client = mqtt.connect('wss://broker.connectmq.com:8084/mqtt', {
username: 'username', // a read-only map user
password: 'password',
});
client.on('connect', () => client.subscribe('testmqtt/gps/+/+', { qos: 1 }));
client.on('message', (topic, payload) => {
let p;
try { p = JSON.parse(payload.toString()); } catch { return; }
if (p._type !== 'location') return;
const pos = [p.lat, p.lon];
if (!markers[topic]) {
markers[topic] = L.marker(pos).addTo(map).bindTooltip(p.tid || topic);
trails[topic] = L.polyline([], { weight: 3 }).addTo(map);
}
markers[topic].setLatLng(pos);
trails[topic].addLatLng(pos);
map.panTo(pos);
});
</script>
</body>
</html>testmqtt/gps/+/+ matches exactly two levels below the prefix, so it receives locations but not the /status topics one level deeper. Subscribe to those separately if you want to grey out offline trackers. For a fuller dashboard with history and charts, see building a live MQTT dashboard.
Security and privacy
A location stream shows where a person sleeps, works and travels. Treat it accordingly:
- One user per device with an ACL that allows publishing only to its own
.../<user>/<device>topics. - A read-only user for maps and dashboards, scoped to the users it may display.
- TLS everywhere: port 8883 for devices, 8084 (
wss) for browsers. - No wildcard-for-everyone. A subscriber with
#sees every tracker on the broker. - Retention policy for stored history. See MQTT and GDPR.
Testing checklist
- Run the simulator and confirm the marker moves on the map.
- Restart the map page: the last position should appear instantly (retained message).
- Kill the simulator with Ctrl+C: after roughly 1.5 × keep alive the status topic switches to
offline(Last Will). - Log in to the map with the device user and try to publish elsewhere: the broker should refuse it.
- Take the real device outdoors and compare
tstwith the receive time to see your end-to-end delay.
Watch tracker messages in the browser
A tracker built this way is small enough to understand completely: a few dozen lines of firmware, a retained topic per device, a Last Will for availability and a static map page. Once the simulator, map and ACLs behave as expected, swap in the real hardware and point everything at your own broker.
Frequently asked questions
Why use MQTT instead of HTTP for a GPS tracker?
A tracker sends small messages often over unreliable links. MQTT keeps one connection open, adds only a few bytes of overhead per message, can retain the last position for new subscribers and reports a dead tracker through a Last Will message. HTTP needs a new request with full headers for every fix.
Do I have to use the OwnTracks JSON format?
No, any JSON works. Using the OwnTracks location format (_type, lat, lon, tst, acc…) is a convenient choice because the OwnTracks apps, the OwnTracks Recorder and several home-automation integrations already understand it, so your DIY tracker and phones can share one map.
How often should a GPS tracker publish?
Publish on movement rather than on a fixed clock: for example when the device has moved more than 25–50 metres, plus a heartbeat every minute or two while stationary. That keeps the map smooth while moving and saves data and battery when parked.

