# 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
