Inspired by Aaron Parecki who has been tracking his location since 2008 with an iPhone app and a server-side tracking API, I decided to build a similar system, but entirely with tools I control.
My goal: continuously track my location using my Android phone, store the data in a PostgreSQL database, and visualize all historical locations on a web map. Over time the stack evolved significantly. The original setup relied on OwnTracks, a Node.js webhook, GeoServer, MapProxy and OpenLayers. Today I use:
- Colota: my self-developed Android tracking app
- PostgreSQL + PostGIS: for geospatial data storage
- Martin: a lightweight vector tile server backed by PostGIS
- MapLibre GL JS: for rendering the interactive web map
Set up PostgreSQL
To install PostgreSQL with PostGIS support, first add the repository and install the packages:
bashsudo apt update sudo apt install gnupg2 wget vim sudo sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg sudo apt update sudo apt-get -y install postgresql postgresql-contrib postgis
Start and enable the service:
bashsudo systemctl start postgresql sudo systemctl enable postgresql
Connect and create a database and user:
bashsudo su postgres psql
sqlCREATE DATABASE locations; CREATE USER <username> WITH ENCRYPTED PASSWORD '<password>'; GRANT ALL PRIVILEGES ON DATABASE locations TO <username>;
Create the locations table:
sqlCREATE TABLE public.locations ( id bigserial NOT NULL, created_at timestamptz NULL DEFAULT CURRENT_TIMESTAMP, lat float8 NULL, lon float8 NULL, acc int4 NULL, alt int4 NULL, batt int4 NULL, bs int4 NULL, cog numeric(10, 2) NULL, rad int4 NULL, t varchar(255) NULL, tid varchar(255) NULL, tst int4 NULL, vac int4 NULL, vel int4 NULL, p numeric(10, 2) NULL, conn varchar(255) NULL, topic varchar(255) NULL, inregions jsonb NULL, ssid varchar(255) NULL, bssid varchar(255) NULL );
The key columns are lat, lon and alt. The others (velocity, battery level, connection type) are used on my /now page to show what I am currently up to.
Creating a PostGIS geometry view
Enable the PostGIS extension and create a view that exposes a proper geometry column:
sql\c locations CREATE EXTENSION postgis;
sqlCREATE OR REPLACE VIEW public.locations_geom AS SELECT id, lat, lon, alt, vel, ST_SetSRID(ST_MakePoint(lon, lat, alt::double precision), 4326) AS geom FROM locations;
This view is what Martin will query to generate vector tiles.
Setting up Colota
Colota is the Android app I built to replace OwnTracks. It is written in React Native (TypeScript + Kotlin) and sends location payloads in the OwnTracks HTTP format, which makes it compatible with the webhook described below. It supports tracking profiles, geofencing and multiple backends including custom endpoints.
Setting up the locations webhook
To receive location payloads from Colota and write them to PostgreSQL, I run a small Node.js HTTP server. Colota sends a JSON POST request for each location update in the OwnTracks format. The server parses the body and inserts the relevant fields into the locations table.
javascriptconst http = require("http"); const { Pool } = require("pg"); const pool = new Pool({ user: "username", database: "locations", password: "password", port: 5432, host: "localhost", }); async function insertData(body) { try { await pool.query( "INSERT INTO locations (lat, lon, acc, alt, batt, bs, tst, vac, vel, conn, topic, inregions, ssid, bssid) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", [body.lat, body.lon, body.acc, body.alt, body.batt, body.bs, body.tst, body.vac, body.vel, body.conn, body.topic, body.inregions, body.ssid, body.bssid] ); } catch (error) { console.error(error); } } const server = http.createServer((request, response) => { let body = []; if (request.method === "POST") { request.on("data", (chunk) => body.push(chunk)).on("end", () => { insertData(JSON.parse(Buffer.concat(body).toString())); }); } response.end(); }); server.listen(9001);
The server listens on port 9001. Point Colota at http://yourserverip:9001 and location data will start flowing into the database. In production, add an API key check in the request handler to restrict access to authorized clients only.
Serving vector tiles with Martin
Martin is a Rust-based tile server that reads directly from PostGIS and serves vector tiles (MVT) with no heavy backend required.
Run Martin with Docker Compose:
yamlservices: martin: image: ghcr.io/maplibre/martin:latest container_name: martin restart: always ports: - "3000:3000" environment: DATABASE_URL: postgresql://<username>:<password>@<host>/locations command: - --listen-addresses=0.0.0.0:3000
Martin will automatically detect all tables and views with a geometry column and expose them as tile endpoints. The locations_geom view created earlier becomes available at:
https://your-server/martin/locations_geom/{z}/{x}/{y}
You can verify it is working by opening the tilejson endpoint:
https://your-server/martin/locations_geom
Rendering the map with MapLibre
MapLibre GL JS is an open-source fork of Mapbox GL JS that renders vector tiles using WebGL. I use OpenFreeMap as the basemap, which provides free hosted vector tiles based on OpenStreetMap.
The map component for my website reads the current theme (data-theme attribute on <html>) and switches between light and dark basemap styles accordingly:
tsximport { useEffect, useRef } from "react"; import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; const STYLE_LIGHT = "https://tiles.openfreemap.org/styles/bright"; const STYLE_DARK = "https://tiles.openfreemap.org/styles/dark"; function getStyle(): string { const attr = document.documentElement.getAttribute("data-theme"); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; return attr === "dark" || (!attr && prefersDark) ? STYLE_DARK : STYLE_LIGHT; } const LiveMap = ({ coords }: { coords?: { lat: number; lon: number } }) => { const mapElement = useRef<HTMLDivElement>(null); useEffect(() => { if (!mapElement.current) return; const getPrimaryColor = () => getComputedStyle(document.documentElement) .getPropertyValue("--primary-color") .trim() || "#39b5e0"; const addLocationsLayer = (map: maplibregl.Map) => { map.addSource("locations", { type: "vector", tiles: ["https://your-martin-server/locations_geom/{z}/{x}/{y}"], minzoom: 0, maxzoom: 16, }); map.addLayer({ id: "locations", type: "circle", source: "locations", "source-layer": "locations_geom", paint: { "circle-radius": 3, "circle-color": getPrimaryColor(), "circle-opacity": 0.7, }, }); }; const map = new maplibregl.Map({ container: mapElement.current, style: getStyle(), center: [coords?.lon ?? -15.439457, coords?.lat ?? 28.128124], zoom: 10, }); map.on("load", () => addLocationsLayer(map)); // Switch style when theme changes const observer = new MutationObserver(() => { map.setStyle(getStyle()); map.once("styledata", () => { if (!map.getSource("locations")) addLocationsLayer(map); map.setPaintProperty("locations", "circle-color", getPrimaryColor()); }); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); return () => { observer.disconnect(); map.remove(); }; }, []); return <div style={{ height: "100%", width: "100%" }} ref={mapElement} />; }; export default LiveMap;
The MutationObserver watches for data-theme changes and swaps the basemap style on the fly, then re-adds the locations layer once the new style has loaded.
The result is the interactive map on /map. It is limited to Gran Canaria for privacy reasons.