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.


Four tile servers on the dimensions that decide the choiceMartin, tileserver-gl, pg_tileserv and mbtileserver compared on tile source, raster rendering, PostGIS support and operational weight.SERVER COMPARISONMartintileserver-glpg_tileservServes filesMBTiles, PMTilesMBTilesNoServes from PostGISYes, ST_AsMVTNoYes, onlyRenders rasterNoYesNoMemory footprintLowHigh (renderer)LowConfig surfaceOne TOML fileJSON + style filesEnvironment only
There is no best row here. The choice is settled by where the tiles come from — the first row — and everything else is a consequence.

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

Where a tile request stops, warm and coldA cold request passes the CDN to the tile server and into PostGIS; every later request for the same tile is answered at the edge.BrowserCDN edgeMartinPostGISGET /roads/14/8192/5461cold missST_AsMVT over the tile bboxone row of MVT bytes200 + Cache-Controlthe header that decides everything after this200, and every later reader stops here
The database appears exactly once per tile per TTL. If it appears more often than that, the Cache-Control the server emits is the thing to look at.

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.

The Question That Actually Decides It

Comparison matrices make this look like a feature-by-feature choice, and it rarely is. Two questions settle it in almost every case.

Do the tiles come from a file or a database? A file-backed tileset can be served by anything, including no server at all. A database-backed one needs a server that speaks ST_AsMVT, which narrows the field to Martin or pg_tileserv immediately.

Is raster output a requirement? Static image endpoints for reports, print exports or link previews need a renderer, and a renderer means tileserver-gl and the memory footprint that comes with it. If raster is not required, carrying a rendering stack is a cost with no matching benefit.

Everything else — configuration format, metrics endpoints, TileJSON conventions — is a preference between broadly equivalent options, and choosing on those grounds first tends to produce a decision that has to be revisited when one of the two questions above finally gets asked.

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

Four reasons the edge is not caching tilesA decision tree covering missing cache headers, query strings on the tile URL, Vary headers and Set-Cookie on tile responses.DIAGNOSEOrigin CPU tracks reader traffic — where is thecache going wrong?No Cache-Control ontile responsesThe edge applies its ownshort defaultQuery string in thetile URLEach unique string is aseparate cache objectVary on arequest-specificheaderOne cache entry pervariant — effectively noneSet-Cookie on thetile responseMost CDNs refuse to cacheit at all

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.

FAQ

What decides the choice between servers?

Two questions: do the tiles come from a file or a database, and is raster output required? Everything else is preference between broadly equivalent options.

Is tileserver-gl heavier than Martin?

Considerably, when rendering. It carries a full rendering stack in memory — hundreds of megabytes idle, over a gigabyte rendering — against Martin’s tens of megabytes.

Does a tile server remove the need for a CDN?

No, it increases it. Every request that reaches the server is a request the edge could have answered, and a tile workload is overwhelmingly cacheable even at short TTLs.

What should a health check do?

Return without touching the database. A health check that queries will fail the whole fleet during a brief database blip, turning a degradation into an outage.

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 Next reading Serving Vector Tiles from PostGIS with ST_AsMVT