# Skill: Vector Tile Serving from PostGIS ## When to Use When building a web map that serves vector tiles from PostgreSQL/PostGIS data. Applies to any project using MapLibre GL JS, Mapbox GL JS, or OpenLayers with MVT tiles from a spatial database. --- ## Core Architecture Decision **Always use a dedicated tile server over GeoJSON for datasets >20K features.** GeoJSON limits: - 20K polygons: visible jank on `setData()`, 200-400ms freezes - 50K polygons: multi-second freezes, 500MB+ browser memory - 100K+ polygons: crashes mobile browsers, 1-2GB memory on desktop - `JSON.stringify` runs on main thread — blocks UI proportional to data size Vector tiles (MVT) solve this: - Only visible tiles loaded (~50-200KB per viewport) - Incremental pan/zoom (no re-fetch) - ~100-200MB client memory regardless of total dataset size - Works on mobile --- ## Tile Server Rankings (Rechsteiner Benchmark, April 2025) | Rank | Server | Language | Speed | Notes | |---|---|---|---|---| | 1 | **Martin** | Rust | 1x | Clear winner, 95-122ms range | | 2 | Tegola | Go | 2-3x slower | Only supports SRID 3857/4326 | | 3 | BBOX | Rust | ~same as Tegola | Unified raster+vector | | 4 | pg_tileserv | Go | ~4x slower | Zero-config but limited control | | 5 | TiPg | Python | Slower | Not for production scale | | 6 | ldproxy | Java | 4-70x slower | Enterprise/OGC compliance | Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark) --- ## Martin: Best Practices ### Always use explicit config (not auto-discovery) Auto-discovery can drop properties, misdetect SRIDs, and behave unpredictably with nested views. ```yaml postgres: connection_string: ${DATABASE_URL} default_srid: 3844 # your source SRID auto_publish: false # explicit sources only tables: my_layer: schema: public table: my_view_name geometry_column: geom srid: 3844 bounds: [20.2, 43.5, 30.0, 48.3] # approximate extent minzoom: 10 maxzoom: 18 properties: object_id: text # explicit column name: pg_type name: text area: float8 ``` ### Docker image tags Martin changed tag format at v1.0: - Pre-1.0: `ghcr.io/maplibre/martin:v0.15.0` (with `v` prefix) - Post-1.0: `ghcr.io/maplibre/martin:1.4.0` (no `v` prefix) ### Docker deployment **If your orchestrator has access to the full repo** (docker-compose CLI, Docker Swarm with repo checkout): ```yaml martin: image: ghcr.io/maplibre/martin:1.4.0 command: ["--config", "/config/martin.yaml"] environment: - DATABASE_URL=postgresql://user:pass@host:5432/db volumes: - ./martin.yaml:/config/martin.yaml:ro ports: - "3010:3000" ``` **If using Portainer CE or any system that only sees docker-compose.yml** (not full repo): Volume mounts for repo files fail silently — Docker creates an empty directory instead. Bake config into a custom image: ```dockerfile # martin.Dockerfile FROM ghcr.io/maplibre/martin:1.4.0 COPY martin.yaml /config/martin.yaml ``` ```yaml martin: build: context: . dockerfile: martin.Dockerfile command: ["--config", "/config/martin.yaml"] environment: - DATABASE_URL=postgresql://user:pass@host:5432/db ports: - "3010:3000" ``` ### Custom SRID handling Martin handles non-4326/3857 SRIDs natively. Set `default_srid` globally or `srid` per source. Martin reprojects to Web Mercator (3857) internally for tile envelope calculations. Your PostGIS spatial indexes on the source SRID are used correctly. ### Zoom-dependent simplification Create separate views per zoom range with `ST_SimplifyPreserveTopology`: ```sql -- z0-5: heavy simplification (2000m tolerance) CREATE VIEW my_layer_z0 AS SELECT id, name, ST_SimplifyPreserveTopology(geom, 2000) AS geom FROM my_table; -- z8-12: moderate (50m) CREATE VIEW my_layer_z8 AS SELECT id, name, ST_SimplifyPreserveTopology(geom, 50) AS geom FROM my_table; -- z12+: full precision CREATE VIEW my_layer_z12 AS SELECT * FROM my_table; ``` ### Performance at 1M+ features - Set `minzoom` per source to avoid pathological low-zoom tiles - Buildings: minzoom 14 (skip at overview levels) - Use zoom-dependent simplified views for boundaries - Add HTTP cache (nginx proxy_cache) in front of Martin - Consider PMTiles for static overview layers --- ## PMTiles: Pre-generated Tile Archives Best for: static/rarely-changing layers, overview zoom levels, eliminating DB load. ### Pipeline ```bash # 1. Export from PostGIS, reproject to WGS84 ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \ layer.fgb "PG:dbname=mydb" \ -sql "SELECT id, name, geom FROM my_table" # 2. Generate PMTiles tippecanoe -o output.pmtiles \ --layer="my_layer" layer.fgb \ --minimum-zoom=0 --maximum-zoom=14 \ --drop-densest-as-needed \ --detect-shared-borders \ --hilbert --force # 3. Serve from any HTTP server with Range request support (MinIO, nginx, CDN) ``` ### MapLibre integration ```typescript import { Protocol } from 'pmtiles'; maplibregl.addProtocol('pmtiles', new Protocol().tile); // Add source map.addSource('overview', { type: 'vector', url: 'pmtiles://https://my-server/tiles/overview.pmtiles', }); ``` ### Hybrid approach (recommended for large datasets) - PMTiles for overview (z0-z14): pre-generated, ~5ms serving, zero DB load - Martin for detail (z14+): live from PostGIS, always-current data - Rebuild PMTiles on schedule (nightly) or after data sync --- ## MLT (MapLibre Tiles) — Next-Gen Format (2026) - 6x better compression than MVT (column-oriented layout) - 3.7-4.4x faster client decode (SIMD-friendly) - Martin v1.3+ supports serving MLT - MapLibre GL JS 5.x supports decoding MLT - Spec: [github.com/maplibre/maplibre-tile-spec](https://github.com/maplibre/maplibre-tile-spec) --- ## Common Pitfalls 1. **Martin auto-discovery drops properties** — always use explicit config with `auto_publish: false` 2. **Martin Docker tag format changed at v1.0** — `v0.15.0` (with v) but `1.4.0` (without v). Check actual tags at ghcr.io. 3. **Portainer CE volume mounts fail silently** — Docker creates empty directory instead of file. Bake configs into images via Dockerfile COPY. 4. **Martin logs `UNKNOWN GEOMETRY TYPE` for views** — normal for nested views, does not affect tile generation 5. **Nested views lose SRID metadata** — cast geometry: `geom::geometry(Geometry, 3844)` 6. **GisUat.geometry is huge** — always `select` to exclude in list queries 7. **Low-zoom tiles scan entire dataset** — use zoom-dependent simplified views 8. **No tile cache by default** — add nginx/Varnish in front of any tile server 9. **tippecanoe requires WGS84** — reproject from custom SRID before generating PMTiles 10. **PMTiles not incrementally updatable** — full rebuild required on data change 11. **Tegola doesn't support custom SRIDs** — only 3857/4326, requires ST_Transform everywhere 12. **pg_tileserv `ST_Estimated_Extent` fails on views** — use materialized views or function layers 13. **Martin caches source schema at startup** — restart after view DDL changes