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:
TinyGPSPlusandPubSubClient
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:
- No NMEA in the serial console — wiring. TX to RX, and 3V3 not 5V.
- 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. - Fix but no connection — TLS. Try
setInsecure()once to confirm that is the layer, then put the root back. - 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
- HTTP or MQTT for GPS tracking? — the trade-off in full, if you are still choosing
- Send custom telemetry alongside GPS — voltage, rpm, temperature, on the same messages