- TILE-SERVER-EVALUATION.md: updated to reflect current architecture (PMTiles z0-z18) - MODULE-MAP.md: added PMTiles + tile-cache to Geoportal section - Monitor: timeout increased to 90 min for z18 builds, description updated - Added PROMPT-GEOPORTAL-IMPROVE.md with mega prompt for future sessions (includes MLT check, mvt-rs evaluation prompt, operational commands) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 KiB
Tile Server Evaluation — ArchiTools Geoportal (March 2026)
Context
ArchiTools Geoportal serves vector tiles (MVT) from PostgreSQL 16 + PostGIS 3 via Martin. Data: ~330K GIS features (parcels, buildings, admin boundaries) in EPSG:3844 (Stereo70), growing to 1M+. Frontend: MapLibre GL JS 5.21, Next.js 16, Docker self-hosted via Portainer CE.
Problem Statement
- Martin v0.15.0 was running in auto-discovery mode — the existing
martin.yamlconfig was never mounted - Building labels (
cadastral_ref) missing from MVT tiles despite the view exposing them - Performance concerns at scale (330K -> 1M+ features)
Solutions Evaluated (7 options + emerging tech)
1. Martin (Fix + Upgrade) — WINNER
| Aspect | Detail |
|---|---|
| Root cause | martin.yaml not mounted in docker-compose — Martin ran in auto-discovery mode |
| Fix | Bake config into custom image via Dockerfile + upgrade v0.15 -> v1.4.0 |
| Performance | Fastest tile server benchmarked (2-3x faster than #2 Tegola) |
| EPSG:3844 | Native support via default_srid: 3844 |
| New in v1.4 | ZSTD compression, MLT format, materialized views, better logging |
Status: IMPLEMENTED AND VERIFIED IN PRODUCTION (2026-03-27)
2. pg_tileserv (CrunchyData)
| Aspect | Detail |
|---|---|
| Architecture | Go binary, zero-config, delegates ST_AsMVT to PostGIS |
| Property control | Auto from schema + URL ?properties= parameter |
| Performance | 2-3x slower than Martin (Rechsteiner benchmark) |
| EPSG:3844 | Supported (auto-reprojects via ST_Transform) |
| Killer feature | Function-based sources (full SQL tile functions) |
| Dealbreaker | View extent estimation bug (#156) affects all our views, development stagnant (last release Feb 2025) |
Verdict: NO — slower, buggy with views, stagnant development.
3. Tegola (Go-based)
| Aspect | Detail |
|---|---|
| Architecture | Go, TOML config, explicit per-layer SQL |
| Performance | 2nd in benchmarks, but 2-3x slower than Martin |
| Built-in cache | File, S3/MinIO, Redis — with seed/purge CLI |
| EPSG:3844 | NOT SUPPORTED (only 3857/4326) — requires ST_Transform in every query |
| Killer feature | Built-in tile seeding and cache purging |
Verdict: NO — EPSG:3844 not supported, dealbreaker for our data.
4. t-rex (Rust-based)
| Aspect | Detail |
|---|---|
| Status | Abandoned/unmaintained — no releases since 2023 |
Verdict: NO — dead project.
5. GeoJSON Direct from Next.js API
| Aspect | Detail |
|---|---|
| 330K features | ~270 MB uncompressed, 800 MB-1.4 GB browser memory |
| Browser impact | 10-30s main thread freeze, mobile crash |
| Pan/zoom | Full re-fetch on every viewport change, flickering |
| Viable range | Only at zoom 16+ with <500 features in viewport |
Verdict: NO — does not scale beyond ~20K features.
6. PMTiles (Pre-generated)
| Aspect | Detail |
|---|---|
| Architecture | Single-file tile archive, HTTP Range Requests, no server needed |
| Performance | ~5ms per tile (vs 200-2000ms for Martin on low-zoom) |
| Property control | tippecanoe gives explicit include/exclude per property |
| Update strategy | Full rebuild required (~3-7 min for 330K features) |
| EPSG:3844 | Requires reprojection to 4326 via ogr2ogr before tippecanoe |
| MinIO serving | Yes — direct HTTP Range Requests with CORS |
Verdict: YES as hybrid complement — excellent for static UAT overview layers (z0-z12), Martin for live detail.
7. Emerging Solutions
| Solution | Status | Relevance |
|---|---|---|
| mvt-rs (Rust) | v0.16.2, active | Admin UI, auth per layer, cache — good for multi-tenant |
| MLT format | Stable Jan 2026 | 6x compression, 4x faster decode — Martin v1.3+ supports it |
| BBOX | Maturing | Similar to Tegola performance, unified raster+vector |
| DuckDB tiles | Early | Not PostGIS replacement, interesting for GeoParquet |
| FlatGeobuf | Stable | Good for <100K features, not a tile replacement |
Benchmark Reference (Rechsteiner, April 2025)
| Rank | Server | Language | Relative Speed |
|---|---|---|---|
| 1 | Martin | Rust | 1x (fastest) |
| 2 | Tegola | Go | 2-3x slower |
| 3 | BBOX | Rust | ~same as Tegola |
| 4 | pg_tileserv | Go | ~4x slower |
| 5 | TiPg | Python | Slower |
| 6 | ldproxy | Java | 4-70x slower |
Source: github.com/FabianRechsteiner/vector-tiles-benchmark
Implementation Roadmap
Phase 1: Martin Fix — DONE (2026-03-27)
Changes applied:
martin.Dockerfile: custom image that COPY-smartin.yamlinto/config/docker-compose.yml: Martin v0.15 -> v1.4.0, build from Dockerfile,--configflagmartin.yaml: comment updated to reflect v1.4map-viewer.tsx: building labels layer activated (cladiriLabelat minzoom 16)
Deployment Lessons Learned
-
Docker image tag format changed at v1.0: old tags use
vprefix (v0.15.0), new tags do not (1.4.0). The tagghcr.io/maplibre/martin:v1.4.0does NOT exist — correct isghcr.io/maplibre/martin:1.4.0. -
Portainer CE volume mount pitfall: volume
./martin.yaml:/config/martin.yaml:rofails because Portainer deploys only the docker-compose.yml content, not the full git repo. Docker silently creates an empty directory instead of failing. Solution: bake config into a custom image with a 2-line Dockerfile:FROM ghcr.io/maplibre/martin:1.4.0 COPY martin.yaml /config/martin.yaml -
Martin config format is stable: YAML format unchanged from v0.15 to v1.4 —
postgres.tables,connection_string,auto_publish,propertiesmap all work identically. No migration needed. -
PostGIS view geometry type: Martin logs
UNKNOWN GEOMETRY TYPEfor all views — this is normal for nested views (SELECT * FROM parent_view). Views don't register specific geometry types ingeometry_columns. Does not affect tile generation or property inclusion.
Phase 2A: nginx Tile Cache — DONE (2026-03-27)
Impact: 10-100x faster on repeat requests, zero PostGIS load for cached tiles.
Changes applied:
nginx/tile-cache.conf: proxy_cache config with 2GB cache zone, 7-day TTL, stale servingtile-cache.Dockerfile: bakes nginx config into custom image (Portainer CE pattern)docker-compose.yml:tile-cachecontainer, Martin no longer exposed on host- Gzip passthrough (Martin already compresses), browser caching via Cache-Control headers
- CORS headers for cross-origin tile requests
Phase 2B: PMTiles — DONE (2026-03-27)
Impact: Sub-10ms overview tiles, zero PostGIS load for z0-z18.
Changes applied:
scripts/rebuild-overview-tiles.sh: ogr2ogr export (3844->4326) + tippecanoe generation- PMTiles archive: z0-z18, ~1-2 GB, includes all terenuri, cladiri, UATs, and administrativ layers
map-viewer.tsx: pmtiles:// protocol registered on MapLibre, hybrid source switching- MinIO bucket
tileswith public read + CORS for Range Requests - N8N webhook trigger for rebuild (via monitor page)
- Monitor page (
/monitor): rebuild + warm-cache actions with live status polling
Phase 2C: MLT Format — DEFERRED
Martin v1.4 advertises MLT support, but it cannot generate MLT from PostGIS live queries. MLT generation requires pre-built tile archives (tippecanoe does not output MLT either). No actionable path until Martin or tippecanoe adds MLT output from PostGIS sources.
Phase 2D: mvt-rs Evaluation — FUTURE (Multi-Tenant)
Impact: Built-in auth, admin UI, per-layer access control. Effort: 1-2 days for evaluation + migration.
Reserved for when external client access to the geoportal is needed. mvt-rs (v0.16.2+, Rust, Salvo framework) provides per-layer auth and admin UI.
Phase 3: Current Architecture (as of 2026-03-27)
Full tile-serving pipeline in production:
PostGIS (EPSG:3844)
|
+--> Martin v1.4.0 (live MVT from 9 PostGIS views)
| |
| +--> tile-cache (nginx reverse proxy, 2GB disk, 7d TTL)
| |
| +--> Traefik (tools.beletage.ro/tiles)
|
+--> ogr2ogr (3844->4326) + tippecanoe (z0-z18)
|
+--> PMTiles archive (~1-2 GB)
|
+--> MinIO bucket "tiles" (HTTP Range Requests)
|
+--> MapLibre (pmtiles:// protocol)
Hybrid strategy:
- PMTiles serves pre-generated overview tiles (all zoom levels, all layers)
- Martin serves live detail tiles (real-time PostGIS data)
- nginx tile-cache sits in front of Martin to absorb repeat requests
- Rebuild triggered via N8N webhook from the
/monitorpage
Operational Commands
Rebuild PMTiles
Trigger from the Monitor page (/monitor -> "Rebuild PMTiles" button), which sends a webhook to N8N.
N8N runs scripts/rebuild-overview-tiles.sh on the server.
Manual rebuild (SSH to 10.10.10.166):
cd /path/to/architools
bash scripts/rebuild-overview-tiles.sh
Warm nginx Cache
Trigger from the Monitor page (/monitor -> "Warm Cache" button).
Pre-loads frequently accessed tiles into the nginx disk cache.
Purge nginx Tile Cache
docker exec tile-cache rm -rf /var/cache/nginx/tiles/*
docker exec tile-cache nginx -s reload
Restart Martin (after PostGIS view changes)
docker restart martin
Martin caches source schema at startup — must restart after DDL changes to pick up new columns.
Check PMTiles Status
# Check file size and last modified in MinIO
docker exec minio mc stat local/tiles/overview.pmtiles
Key Technical Details
Martin v1.4.0 Deployment Architecture
Gitea repo (martin.yaml + martin.Dockerfile)
-> Portainer CE builds custom image: FROM martin:1.4.0, COPY martin.yaml
-> Container starts with --config /config/martin.yaml
-> Reads DATABASE_URL from environment
-> Serves 9 PostGIS view sources on port 3000
-> Host maps 3010:3000
-> Traefik proxies tools.beletage.ro/tiles -> host:3010
Critical: Do NOT use volume mounts for config files in Portainer CE stacks. Always bake configs into custom images via Dockerfile COPY.
Martin Config (validated compatible v0.15 through v1.4)
The martin.yaml at project root defines 9 sources with explicit properties.
Config format unchanged from v0.15 to v1.4 — no migration needed.
Key config features used:
auto_publish: false— only explicitly listed sources are serveddefault_srid: 3844— all sources use Stereo70properties:map per source — explicit column name + PostgreSQL typeminzoom/maxzoomper source — controls tile generation rangebounds: [20.2, 43.5, 30.0, 48.3]— approximate Romania extent
Docker Image Tag Convention
Martin changed tag format at v1.0:
- Pre-1.0:
ghcr.io/maplibre/martin:v0.15.0(withvprefix) - Post-1.0:
ghcr.io/maplibre/martin:1.4.0(novprefix) - Also available:
latest,nightly
PostGIS View Chain
GisFeature table (Prisma) -> gis_features view -> gis_terenuri / gis_cladiri / gis_administrativ
-> gis_terenuri_status / gis_cladiri_status (with JOINs)
GisUat table -> gis_uats_z0/z5/z8/z12 (with ST_SimplifyPreserveTopology)
MapLibre Layer Architecture
Sources (Martin): gis_uats_z0, z5, z8, z12, administrativ, terenuri, cladiri
Layers per source: fill + line + label (where applicable)
Selection: Separate highlight layers on terenuri source
Drawing: GeoJSON source for freehand/rect polygon
Building labels: cladiriLabel layer, cadastral_ref at minzoom 16
Deployment Pitfalls (Discovered During Implementation)
-
Portainer CE does not expose repo files to containers at runtime. Volume mounts like
./file.conf:/etc/file.conf:rofail silently — Docker creates an empty directory. Always bake config files into custom images via Dockerfile COPY. -
Martin Docker tag format change at v1.0.
v1.4.0does not exist,1.4.0does. Always check ghcr.io/maplibre/martin for actual tags. -
Martin logs
UNKNOWN GEOMETRY TYPEfor PostGIS views. This is normal — nested views don't register geometry types ingeometry_columns. Does not affect functionality. -
Martin auto-discovery mode is unreliable for property inclusion. Always use explicit config with
auto_publish: falseand per-sourceproperties:definitions. -
Martin caches source schema at startup. After PostGIS view DDL changes (e.g., adding columns to gis_features), Martin must be restarted to pick up new columns.