GPSTrack

Stream GPS from an ESP32 over MQTT

5 min read2 of 5 in Telematics

An ESP32 with a GPS module is the cheapest real tracker you can build — about the price of a takeaway, and it fits in a matchbox. What it usually lacks is somewhere to send the fixes.

This is the whole path: module to board, board to broker, broker to map. No backend to write, no account to create.

Why MQTT and not HTTP here

One HTTP request per point is the right answer from a laptop and the wrong one from a battery-powered microcontroller. Each HTTPS request means a fresh TCP handshake and a fresh TLS handshake — several kilobytes of negotiation to deliver about forty bytes of payload, every time.

MQTT pays that cost once and holds the connection open. Afterwards a fix costs roughly its own size on the wire. On a device counting milliamp-hours that difference is the entire battery budget.

What you need

  • An ESP32 dev board (an ESP8266 works, with the WiFi include changed)
  • A NEO-6M or NEO-8M GPS module — any UART module will do
  • Two libraries: TinyGPSPlus and PubSubClient

Wiring

The GPS module talks serial. The ESP32 has three hardware UARTs, so use the second and leave UART0 free for the USB console — otherwise the debug output and the NMEA stream fight over the same pins and you will spend an hour deciding the module is broken.

NEO-6M ESP32
VCC 3V3
GND GND
TX GPIO16 (RX2)
RX GPIO17 (TX2)

The module's TX goes to the board's RX. Getting that backwards is the single most common reason a NEO-6M appears dead.

Get a session

The broker authenticates with a session token, so create one first:

curl -s -X POST https://gpstrack.dev/api/session

Keep both values. The token is the MQTT password. The sessionId becomes part of the topic, and a client may only publish under its own session's namespace — gpstrack/<sessionId>/#. Publishing anywhere else is refused.

The sketch

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <TinyGPSPlus.h>

const char* WIFI_SSID = "your-network";
const char* WIFI_PASS = "your-password";

const char* MQTT_HOST = "gpstrack.dev";
const int   MQTT_PORT = 8883;              // MQTT over TLS
const char* SESSION   = "s_7f3a91c4";      // sessionId
const char* TOKEN     = "tk_9d41b0e6a7c2f5"; // token, used as the password

// Let's Encrypt's root. gpstrack.dev presents a chain that ends here.
const char* ISRG_ROOT_X1 = R"EOF(
-----BEGIN CERTIFICATE-----
...paste ISRG Root X1 here...
-----END CERTIFICATE-----
)EOF";

TinyGPSPlus gps;
WiFiClientSecure net;
PubSubClient mqtt(net);

char topic[64];
unsigned long lastPublish = 0;

void connectMqtt() {
  while (!mqtt.connected()) {
    // The client id must be unique on the broker; the username is ignored.
    String id = "esp32-" + String((uint32_t)ESP.getEfuseMac(), HEX);
    if (mqtt.connect(id.c_str(), "device", TOKEN)) break;
    delay(2000);
  }
}

void setup() {
  Serial.begin(115200);
  Serial2.begin(9600, SERIAL_8N1, 16, 17);  // GPS on UART2

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  net.setCACert(ISRG_ROOT_X1);
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  snprintf(topic, sizeof(topic), "gpstrack/%s/gps", SESSION);
}

void loop() {
  while (Serial2.available()) gps.encode(Serial2.read());
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();

  // The relay accepts one point per second per session. Publishing faster does
  // not get you more resolution; it gets the extra messages dropped.
  if (millis() - lastPublish < 2000) return;
  if (!gps.location.isValid() || !gps.location.isUpdated()) return;
  lastPublish = millis();

  char payload[192];
  snprintf(payload, sizeof(payload),
    "{\"lat\":%.6f,\"lng\":%.6f,\"speed\":%.1f,\"heading\":%.1f,"
    "\"altitude\":%.1f,\"sats\":%d,\"label\":\"esp32\"}",
    gps.location.lat(), gps.location.lng(),
    gps.speed.kmph(), gps.course.deg(),
    gps.altitude.meters(), gps.satellites.value());

  mqtt.publish(topic, payload);
}

Open https://gpstrack.dev/app?view=live and the board appears as soon as it has a fix.

Six decimal places, and why not more

%.6f is about 11 cm at the equator. A consumer GPS module is doing well to know where it is within 2.5 m, so anything past the sixth decimal is transmitting noise at the cost of bytes. %.4f — roughly 11 m — is visibly coarse on a map when the vehicle is slow, so six is the sweet spot rather than a default.

TLS, and the shortcut you will be tempted by

net.setInsecure() skips certificate validation and the sketch will work immediately. It also means anything that can answer for gpstrack.dev on your network gets your session token, which is the credential that lets it publish as your device.

For a board on your own bench, deciding that trade-off is yours to make. For anything that leaves the building, paste the real root in. It is a copy-and-paste from letsencrypt.org/certificates and it costs nothing at runtime.

Certificates expire. ISRG Root X1 is good until 2035, but a device that hard-codes a root and never gets a firmware update is a device with an expiry date. Worth knowing before you seal the enclosure.

Nested JSON is flattened, once

Device payloads are often already structured. The relay flattens one level, so:

{ "lat": 6.9271, "lng": 79.8612, "engine": { "rpm": 900, "temp": 88 } }

arrives as the channels engine.rpm and engine.temp, ready to bind to a widget. One level only — deeply nested payloads are a denial-of-service vector, not a feature. See custom telemetry for what to do with them.

What the broker will not let you do

  • Subscribe. All subscriptions are refused. This is an ingest-only broker, and denying subscription is what stops one client reading another's stream. The dashboard reads over HTTP.
  • Publish outside your namespace. The topic must start with gpstrack/<sessionId>/.
  • Send anything but a JSON object. Non-JSON payloads are dropped silently.
  • Exceed one message per second. MQTT publish has no response channel, so over-rate messages are dropped rather than rejected with an error. If points are missing and everything else looks right, this is the first thing to check.

Debugging a board that shows nothing

Work down the chain in order, because each step assumes the one before it:

  1. No NMEA in the serial console — wiring. TX to RX, and 3V3 not 5V.
  2. NMEA but location.isValid() false — no fix yet. A cold NEO-6M takes 30 seconds to several minutes, and needs sky. Indoors it may never get one.
  3. Fix but no connection — TLS. Try setInsecure() once to confirm that is the layer, then put the root back.
  4. Connected but nothing on the map — check the topic starts with gpstrack/<sessionId>/, and that the token belongs to that same session.

Where to go next

Open the dashboard → Push points from any device over HTTP or MQTT and watch them move on a live map.
More on Telematics dashboard All 5 →
  1. Telematics

    Send custom telemetry alongside GPS

    Push rpm, fuel, voltage or water temperature on the same request as the position, and bind each one to a gauge, sparkline or compass. No schema to register.

    23 June 2026 · 4 min read

  2. Telematics

    HTTP or MQTT for GPS tracking? Pick by power budget

    Both deliver the same point. The difference is what a connection costs your device. A decision table, the handshake arithmetic, and where each one falls apart.

    25 June 2026 · 4 min read