// ai summary · architecture·14 May 2026·4 min read

Map tiles without a tile server.

How Terraforge serves 440,000 geotechnical investigation points across the Netherlands from a single file on Cloudflare R2 — no Postgres, no Martin, no Tegola, nothing running.

AI summary —written by an AI agent from a brief I gave it. The architecture is real and shipping at Terraforge; the prose isn't hand-written.
PMTilesMapLibre GLCloudflare R2

// the shape of the problem

Web maps work in tiles. The world is sliced into a pyramid: zoom level z, column x, row y. When the user pans or zooms, the browser asks for the dozen or so tiles in view and stitches them together. At zoom 0 the entire planet is one tile; at zoom 14 the planet is 268 million tiles.

The traditional way to serve those tiles is a tile server — something like Martin, Tegola, or Mapnik — that talks to a PostGIS database, runs a spatial query for each requestedz/x/y, encodes a vector tile, and ships it back. Works great, takes a real server, costs real money, and is one more moving part you can break.

// the insight

Tiles are just files. Files don't need a server.

Terraforge's data is read-only — the BRO open-data registry of 440k points doesn't change between page loads. So why re-compute a tile every time someone pans the map? Generate every tile once, save the bytes, and let the CDN do what it's good at.

The naive version of this is a folder of millions of small files, one per z/x/y, sitting in a bucket. That works, but millions of small files are slow to upload, expensive to list, and painful to update. The version that actually works is PMTiles: one file, with an internal directory index, that the client reads using HTTP byte-range requests.

// what's in a .pmtiles file

A .pmtiles archive is laid out like this:

┌──────────────────────────┐
│ header (127 bytes)       │  ← magic bytes + offsets to the bits below
├──────────────────────────┤
│ root directory           │  ← which byte ranges hold which (z, x, y) tiles
├──────────────────────────┤
│ metadata JSON            │  ← layer names, attribution, bounds
├──────────────────────────┤
│ leaf directories         │  ← (only for very large archives — paged index)
├──────────────────────────┤
│ tile data — gzipped MVT  │  ← all the actual map tiles, concatenated
└──────────────────────────┘

When MapLibre wants tile z=10, x=520, y=339 it asks the PMTiles client, which has already cached the header + root directory (~50 KB). The client looks up the byte range for that tile — say bytes 47,123,401-47,128,907 — and issues a single HTTP request to R2 with a Range: header. R2 returns ~5 KB of gzipped MVT, MapLibre parses it, the tile shows up. No server-side code runs. R2 is a dumb file store. Cloudflare's CDN caches the byte range so the next user within a hundred-kilometre radius doesn't even hit R2.

// the whole architecture

Three boxes. No tile server.

┌──────────────┐    1× upload         ┌─────────────────────┐
│  build step  │ ─────────────────▶   │  Cloudflare R2       │
│  (one-shot)  │   bro.pmtiles        │  bro.pmtiles         │
│  Rust + GDAL │     (20 MB)          │  + CDN in front      │
└──────────────┘                      └──────────┬──────────┘
                                                 │
                                                 │  HTTP Range:
                                                 │  bytes=47123401-47128907
                                                 ▼
                                      ┌─────────────────────┐
                                      │  browser            │
                                      │  ─ MapLibre GL      │
                                      │  ─ pmtiles client   │
                                      │  ─ deck.gl glow     │
                                      └─────────────────────┘
  1. Build step: a one-shot Rust pipeline reads the BRO open-data dumps, builds a vector tile pyramid using tippecanoe, and writes a single ~20 MB bro.pmtiles file. Done once, re-run only when the upstream registry releases a new batch.
  2. Cloudflare R2 holds the file. R2 is S3-compatible object storage, so the same idea works one-for-one with AWS S3 (or any HTTP host that honours Range). The Cloudflare CDN sits in front and serves cached byte-ranges globally. No D1, no Worker on the hot path for tiles — just bytes.
  3. Browser: MapLibre GL is configured with a pmtiles://https://…/bro.pmtiles source URL. The PMTiles client (a few hundred lines of TypeScript) translates every requested tile into a byte-range HTTP request. Deck.gl renders the additive-blend glow on top.

// tradeoffs

What you give up.

  • Real-time updates. If your data changes minute-to-minute, you don't want pre-rendered tiles. Run a tile server. For BRO, the data refresh is monthly at best — pre-rendered is correct.
  • Per-user filtering. Tile server can run a query per request and filter to the logged-in user. Static PMTiles can't. Workaround: ship every visible point and filter on the client; cheap up to a few million features.
  • Rebuild cost. Generating a tile pyramid from a fresh dataset takes minutes of CPU. Acceptable for monthly batches; painful for hourly ones.

// what you save

  • No server. No Martin/Tegola binary, no Postgres + PostGIS, no Docker, no RDS bill, no SREs.
  • No cold start. First tile is a CDN hit, ~30 ms global. A warm tile server is faster than that; a cold one isn't.
  • No scaling story. A million users hitting the same tile is a million CDN cache hits. R2 bandwidth is free.
  • One artifact. Deploying a new dataset is aws s3 cp bro.pmtiles s3://…. Rollback is aws s3 cp with the old name. No migrations.
  • Cost. Storing 20 MB on R2 costs about $0.0003 per month. Egress is free on Cloudflare's network. Compare to running a small Postgres + a tile server: $20–50/mo minimum.

// when this pattern fits

Anywhere you have geographic data that doesn't change often and isn't user-specific: open-data registries, historical layers, base maps for niche regions, archaeological sites, environmental monitoring, anything you'd previously have run a tile server for. PMTiles + object storage + a CDN is the calm, cheap, boring answer. It scales further than most projects ever need.

The same shape works for AWS S3 + CloudFront, Cloudflare R2 + CDN, or any HTTP server that honours byte-range requests (which is every HTTP server made after 1997). The format is open, the tooling is open, the bill is small. The dumbest part of the stack is the part doing the most work.

See it live at terraforge.davidbroza.dev, or read the project write-up at /projects/terraforge →

Note: this article was written by an AI. The architecture is real and shipping in production at Terraforge — the prose isn't hand-written.