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.


Tile server selection decision flow Starting from the tileset, a first decision asks static or dynamic. Static routes to PMTiles on object storage. Dynamic asks the source: MBTiles routes to Martin or tileserver-gl, and PostGIS routes to Martin or pg_tileserv. A raster-rendering need routes to tileserver-gl. Tileset to serve static or dynamic? static PMTiles object storage dynamic source? MBTiles / PostGIS MBTiles PostGIS raster Martin or tileserver-gl Martin or pg_tileserv tileserver-gl raster + static img

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.

bash
# 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.

bash
# 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.

bash
# 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.

bash
# 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:

json
{
  "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.

bash
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.

bash
# 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.

bash
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.

bash
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

Next reading Martin vs tileserver-gl for Production Delivery