Tile Server Selection: Martin, tileserver-gl & pg_tileserv
A dynamic tile server is a running process that answers /{z}/{x}/{y} requests from an MBTiles file or a live PostGIS database — and it is a real cost you should only pay when you need it. Before comparing engines, the first decision is whether you need a server at all: a static PMTiles object on object storage serves a stable basemap with no process to run, patch, or scale. This guide is for the cases where a server earns its keep — tiles regenerated per request from PostGIS, server-side raster rendering, or a bundled style-plus-glyph host — and it compares Martin, tileserver-gl, and pg_tileserv on the axes that actually decide the choice.
Prerequisites
| Requirement | Typical value | Notes |
|---|---|---|
| Source format | .mbtiles or PostGIS |
MBTiles from a Tippecanoe build, or live tables with a geometry column |
| PostGIS | 3.1+ | ST_AsMVT and ST_AsMVTGeom for on-the-fly encoding |
| Geometry SRID | EPSG:3857 | Tiles are Web Mercator; store or reproject to 3857 for MVT output |
| Runtime host | container or VM | Martin ships a static binary; tileserver-gl and pg_tileserv ship Docker images |
| CDN in front | required for scale | The origin should serve misses only; the CDN answers the rest |
| Style consumer | MapLibre GL | sources.url points at the server’s TileJSON or the CDN alias |
A dynamic server should always sit behind a CDN. The server’s job is to generate or read a tile once; the CDN’s job is to answer the same tile thousands of times without touching the origin. If you find yourself scaling the server horizontally to handle read traffic, the cache in front is misconfigured — see CDN cache headers and versioning.
Core Concept: Server Comparison Matrix
The three mainstream open-source servers differ less in protocol — they all speak /{z}/{x}/{y} and publish TileJSON — and more in what source they read, whether they can render raster, and how much throughput they sustain per core.
| Capability | Martin | tileserver-gl | pg_tileserv | mbtileserver / tegola |
|---|---|---|---|---|
| Language / runtime | Rust | Node.js | Go | Go (both) |
| Reads MBTiles | yes | yes | no | mbtileserver: yes |
| Reads PMTiles | yes (local + HTTP) | no | no | no |
PostGIS ST_AsMVT |
yes (tables + functions) | no | yes (its whole purpose) | tegola: yes |
| Server-side raster render | no | yes (Mapbox GL native) | no | no |
Static image API (/static/...) |
no | yes | no | no |
| Sprite / glyph (font) serving | no | yes | no | no |
| TileJSON endpoint | yes (/catalog, per-source) |
yes | yes | yes |
| Config style | CLI args or config.yaml |
config.json |
env vars / flags | TOML |
| Throughput profile | very high, low memory | moderate (Node event loop) | high for DB-bound work | high |
Read the matrix by elimination. If you need server-side raster PNGs or a bundled style-plus-glyph-plus-sprite host, only tileserver-gl qualifies. If your source is live PostGIS and you want the leanest possible dynamic MVT service, pg_tileserv is purpose-built, though Martin also does PostGIS and adds MBTiles and PMTiles in the same binary. Martin is the generalist: it reads MBTiles, PMTiles, and PostGIS, sustains the highest vector throughput per core, and has the smallest memory footprint, but it will not render a raster image. The head-to-head that most teams actually agonize over — Martin vs tileserver-gl for production delivery — is broken out in its own guide.
Step-by-Step Implementation
Step 1 — Run Martin on an MBTiles file
The fastest path from a Tippecanoe build to a live endpoint. Martin auto-discovers the tileset and exposes it by filename.
# roads.mbtiles is a Tippecanoe/tile-join build output
martin roads.mbtiles --listen-addresses 0.0.0.0:3000
# The tileset is now served at:
# TileJSON: http://localhost:3000/roads
# Tiles: http://localhost:3000/roads/{z}/{x}/{y}
# Catalog: http://localhost:3000/catalog
Verify: curl -s http://localhost:3000/catalog | jq '.tiles' lists roads, and the TileJSON at /roads reports the expected minzoom/maxzoom.
Step 2 — Point Martin at PostGIS for dynamic ST_AsMVT tiles
For data that changes continuously, skip the MBTiles rebuild and let Martin encode tiles directly from tables. It introspects every table with a geometry column and publishes each as a source.
# Martin auto-publishes every PostGIS table with a geometry column
export DATABASE_URL="postgresql://tiles_ro:[email protected]:5432/gis"
martin --listen-addresses 0.0.0.0:3000 "$DATABASE_URL"
# A table public.parcels (geom in EPSG:3857) is now served at:
# http://localhost:3000/public.parcels/{z}/{x}/{y}
Under the hood each request runs an ST_AsMVT(ST_AsMVTGeom(...)) query against the tile’s bounding box. For anything beyond a plain table — joins, filters, computed attributes — register a PostGIS function source: a SQL function that takes (z, x, y) and returns a bytea MVT, which Martin serves like any other source.
Verify: curl -s "http://localhost:3000/public.parcels/12/2048/1362" -o tile.pbf && file tile.pbf returns non-empty binary data.
Step 3 — Put a CDN in front and cache at the edge
The origin server should serve a given tile at most once per version. Set Cache-Control on the CDN response, not on the origin, so you can change caching policy without redeploying the server.
# Origin (private): http://origin.internal:3000/roads/{z}/{x}/{y}
# CDN alias (public): https://tiles.example.com/v3a1b2c3/roads/{z}/{x}/{y}
#
# On the CDN, for the versioned tile path, set:
# Cache-Control: public, max-age=31536000, immutable
# and disable the CDN's automatic re-compression on .pbf/.mvt responses.
Because the path carries a version prefix (v3a1b2c3), the tiles are safe to mark immutable — a rebuild publishes under a new prefix instead of overwriting. This is the same versioned URL rotation used for static PMTiles.
Step 4 — Verify TileJSON and a tile with curl
Before wiring the map, confirm the two things a MapLibre client needs: a valid TileJSON document and a decodable tile.
# 1. TileJSON must list tiles[], minzoom, maxzoom, and bounds
curl -s https://tiles.example.com/v3a1b2c3/roads | jq '{tiles, minzoom, maxzoom, bounds}'
# 2. A tile must be protobuf, gzip-encoded, and non-trivial in size
curl -sI "https://tiles.example.com/v3a1b2c3/roads/10/301/384" | \
grep -iE 'content-type|content-encoding|cache-control'
# Expect: content-type: application/x-protobuf
# content-encoding: gzip
# cache-control: public, max-age=31536000, immutable
Verify: the tile’s first two bytes are the gzip magic 1f 8b (curl -s ... | xxd | head -1), confirming the body is compressed exactly once.
Optimization Knobs
| Knob | Conservative | Aggressive | Trade-off |
|---|---|---|---|
| PostGIS connection pool size | 10 | 100 | Larger pool absorbs bursts but can exhaust max_connections on the database |
--max-feature-count (Martin) |
unset | 10 000 per tile | Capping features bounds tile size and query time; over-cap silently drops features |
| Edge cache TTL | short + revalidate | immutable, 1 year |
Immutable needs versioned paths so the origin serves only cold misses |
| Function sources vs table sources | table auto-publish | hand-written SQL function | Functions add filtering/joins at the cost of query tuning and an EXPLAIN budget |
| Worker/process count | 1 per core | oversubscribe cores | Martin scales near-linearly on cores; Node servers plateau on the event loop |
| Prepared-statement caching | default | pin per source | Cuts PostGIS planning time on hot tiles; more backend memory per connection |
The single highest-leverage knob is the edge cache. A basemap that changes rarely should hit the CDN for well over 95% of requests, so the origin only ever generates the small residual set of cold, uncached tiles. If origin CPU tracks user traffic, the cache is not doing its job — a correctly versioned, immutable path decouples origin load from request volume almost entirely.
Integration with Adjacent Pipeline Stages
A tile server is the middle of the delivery chain, not the end. Upstream, the MBTiles it reads comes straight from a Tippecanoe build or a tile-join merge; the server does not care how the container was produced, only that its tiles are already gzip-encoded and within the 500 KB budget. Downstream, the CDN alias — not the origin host — is what the MapLibre GL style references:
{
"sources": {
"roads": {
"type": "vector",
"url": "https://tiles.example.com/v3a1b2c3/roads"
}
}
}
MapLibre fetches the TileJSON at that url, reads its tiles[] template, and requests every tile through the CDN. The origin server never appears in a browser network trace. When the container is rebuilt, only the version prefix in this one url changes — a detail covered in structuring MapLibre styles for multi-source tiles. If you choose the static route instead, the same style points sources.url at a pmtiles:// archive and no server exists at all; the comparison lives in PMTiles vs MBTiles for CDN delivery.
Troubleshooting
1. Tiles render blank — double gzip
Symptom: HTTP 200 with a plausible byte count, but MapLibre logs a protobuf parse error.
MBTiles from Tippecanoe already contains gzip-compressed tiles. If the server or CDN adds a second gzip layer, the body is 1f 8b wrapping another 1f 8b, and the decoder fails.
curl -s "https://tiles.example.com/v3a1b2c3/roads/10/301/384" | xxd | head -2
# A correct tile: one gzip header (1f 8b ...) then protobuf after inflation.
Fix: send Content-Encoding: gzip once and disable the CDN’s automatic compression on .pbf/.mvt paths. Never let the server re-compress already-gzipped MBTiles tiles.
2. PostGIS pool exhaustion under load
Symptom: intermittent 500s and Postgres logs showing FATAL: sorry, too many clients already.
The Martin pool multiplied by the number of server replicas exceeds the database max_connections.
# Compare demand vs capacity
psql "$DATABASE_URL" -c "SHOW max_connections;"
psql "$DATABASE_URL" -c "SELECT count(*) FROM pg_stat_activity WHERE datname='gis';"
Fix: cap the pool so pool_size × replicas stays well under max_connections, or front PostGIS with PgBouncer in transaction-pooling mode. Better still, lean harder on the CDN so few tile requests reach PostGIS at all.
3. Wrong tile bbox — SRID mismatch
Symptom: tiles return 200 but features land in the ocean, are offset, or are empty at every zoom.
MVT output is Web Mercator. If the geometry column is stored in EPSG:4326 (or any non-3857 SRID) and the tile query does not reproject, ST_AsMVTGeom clips against the wrong bounding box.
psql "$DATABASE_URL" -c \
"SELECT ST_SRID(geom) FROM public.parcels LIMIT 1;" # expect 3857
Fix: store geometries in EPSG:3857, or wrap the source in a function that reprojects with ST_Transform(geom, 3857) before ST_AsMVTGeom. Do not reproject per request on a large table without a functional index on the transformed geometry.
4. High origin CPU — the CDN is not caching
Symptom: origin CPU rises and falls with user traffic; the CDN dashboard shows a low hit ratio.
The CDN is treating tiles as uncacheable — usually a missing or no-store Cache-Control, a per-request query string, or a Vary header that fragments the cache key.
curl -sI "https://tiles.example.com/v3a1b2c3/roads/10/301/384" | \
grep -iE 'cache-control|cf-cache-status|age|vary'
# Want: cache-control: ...immutable, a HIT status, and a growing Age.
Fix: set Cache-Control: public, max-age=31536000, immutable on the versioned path, drop cache-busting query strings, and remove any unnecessary Vary. Details and the full header reference are in cache-control headers for immutable vector tiles.
Further Reading
Martin vs tileserver-gl for Production Delivery — a focused head-to-head of the two most common choices: source support, throughput and memory numbers, raster rendering and glyph/sprite hosting, complete Docker and config blocks for each, and the concrete conditions under which each one wins.
Parent: Tile Serving & CDN Delivery
Related
- CDN Cache Headers & Versioned Tile URLs — the
Cache-Controland versioning rules that let a server’s origin serve only cold misses while the CDN answers the rest. - PMTiles vs MBTiles for CDN Delivery — the no-server alternative to everything on this page, and how to decide between a static archive and a dynamic server.
- MBTiles Architecture & Limits — the SQLite container Martin and tileserver-gl read, including the size and locking constraints that shape serving.