Home -> Docs

Documentation

Run it in five minutes.

TerraServe is one small binary in one small container. Point it at your data, give it a style, tell it which port, and it serves WMS, tiles and more. This page covers the Docker quick start, the arguments that matter, the config file, and the pitfalls worth knowing before you go live.

Quick start (Docker)

Pull the published image and serve a single dataset. Data is mounted at /data; nothing is baked into the image.

# the published image, linux/amd64 + linux/arm64, no login needed
docker pull ghcr.io/terraops-org/terraserve:0.2.0

# serve a Cloud-Optimized GeoTIFF as WMS + tiles
docker run -p 8080:8080 -v "$PWD/data:/data:ro" ghcr.io/terraops-org/terraserve:0.2.0 \
  serve --cog /data/ortho.cog.tif --style /data/rgb.json \
        --host 0.0.0.0 --port 8080

Then point QGIS at http://localhost:8080/wms, or open the built-in viewer at http://localhost:8080/viewer. To serve a vector dataset instead of a raster, swap --cog/--style for --vector/--vec-style (see the live examples for real commands).

Where the image lives. GitHub files container images somewhere almost nobody finds on the first try, so here is the direct link: github.com/terraops-org/TerraServe/pkgs/container/terraserve. Tags are 0.2.0 (pinned), latest, and edge (built from main). Prefer to build it yourself? docker build -t terraserve . from the repo root does the same thing.

docker-compose

For anything long-lived, a compose file is cleaner: one service per dataset (their flags are often mutually exclusive, and isolating them means one busy layer can't take another down).

services:
  terraserve:
    image: terraserve:latest
    ports: ["8080:8080"]
    volumes:
      - ./data:/data:ro
    ulimits:
      nofile: { soft: 65536, hard: 65536 }   # a public tile server holds many handles
    command:
      - serve
      - --host
      - "0.0.0.0"
      - --cog
      - /data/ortho.cog.tif
      - --style
      - /data/rgb.json
      - --public-url
      - https://maps.example.org/wms        # see Pitfalls
    restart: unless-stopped

Data & styling

--cog PATH Serve one raster: a Cloud-Optimized GeoTIFF (local path or s3://…).
--vector PATH Serve one vector layer: a GeoPackage, FlatGeoBuf or GeoJSON (a FlatGeoBuf reads from a local path or s3://…). Requires --vec-style.
--vector postgis://… The same flag, pointed at a PostGIS table instead of a file: postgis://user:pass@host:5432/db/schema.table. Only the rows in view are fetched, per tile. See PostGIS.
--config PATH Serve many layers from a YAML file (below). When set, the single-layer flags are ignored.
--src-crs CRS The projection the data is in, e.g. EPSG:32629. Auto-detected for a GeoPackage.
--style PATH Colour ramp / RGB style for a --cog layer, a style.json (local path or s3://…).
--vec-style PATH Style for a --vector layer: an SLD or a marker/label JSON (local path or s3://…). Required with --vector.
--mvt-style PATH A MapLibre GL style (local path or s3://…) served at /mvt/{layer}/style.json (for the vector-tile view + the X-ray legend).
--expression EXPR Band math computed live, e.g. "(B08 - B04) / (B08 + B04)" for NDVI. Needs --bands.
--bands NAMES Names the expression uses, in file order, e.g. B02,B03,B04,B08.
--nodata VAL The "no data" value. Those pixels get drawn transparent.
--name NAME The layer name clients request (the LAYERS= value).
--font PATH TrueType font for labels on a vector layer (local path or s3://…).

For how to write these styles, RGB, colour ramps, SLD, and MapLibre styles, with examples, see the Styling guide.

Serving & network

--host HOST Bind address. Default 127.0.0.1 (localhost only). Set 0.0.0.0 in a container.
--port PORT Port to listen on. Default 8080.
--public-url URL The address advertised to clients in GetCapabilities. Set this behind a proxy (see Pitfalls).
--max-inflight N Draw at most N maps at once; the rest queue. This is your memory ceiling under a burst. 0 = auto. Set it to your real CPU-core count.

Caching & tiles

--cache-lru MiB In-memory cache of decoded raster (COG) tiles. Does nothing for vector layers (see Pitfalls). 0 or --no-cache-lru to disable.
--mvt-cache MiB In-memory cache of finished vector tiles. This is the vector equivalent of --cache-lru.
--wms-cache MiB In-memory cache of rendered WMS pictures (PNGs), keyed by request.
--pmtiles PATH Serve vector tiles from a pre-baked PMTiles archive (fast, static reads). A miss falls through to live drawing from --vector.
--pmtiles-cache Save live-drawn tiles back into the archive so the next request for them is instant. Needs a writable archive path.
--pmtiles-flush-interval S Fold those saved tiles into the archive every S seconds (0 = only on shutdown / size cap / explicit flush).

Vector tuning

--topology-simplify M Smooth every shared border once to ~M units: crisp but light, and gap-free. GeoPackage only. Builds at startup (can take a while).
--keep-fields COLS Keep only these attribute columns and drop the rest. Smaller tiles and far lower memory, often what makes a big layer fit at all.
--mvt-min-feature-px N At overview zooms, skip polygons smaller than N pixels. Thins the map with no seams. Try 1.0 for a wall-to-wall coverage.
--mvt-max-features N Cap the features per tile (default 20000). Bites only at low zoom; higher = denser but heavier tiles.
--raster-min-feature-px N The same idea for the picture paths (WMS, WMTS, TMS), and a different job: this one is a safety valve, not cartography. Keep it ~40× smaller than the MVT value, e.g. 0.05 (see Pitfalls).
--mvt-dissolve FIELD Merge same-class neighbours into clean class regions (no squares). Best banded to low zooms with --mvt-dissolve-max-zoom.
--tms-grid ID Which tile grid(s) to publish on: a built-in preset, or a path to an OGC TileMatrixSet JSON file for any grid you like. See Tile grids.

Tile grids

Most tile servers speak one grid, Web Mercator, and everything else is somebody else's problem. TerraServe treats the grid as data: hand it an OGC TileMatrixSet 2.0 JSON file and it serves tiles on that grid, for both vector tiles and pre-rendered pictures. No code, no plugin, no rebuild. A layer can publish on several at once.

WebMercatorQuad EPSG:3857. The web-map default, and what you get if you never name a grid.
WorldCRS84Quad EPSG:4326, plate carrée. Two tiles wide at zoom 0, not one.
UPSArcticWGS84Quad<br>UPSAntarcticWGS84Quad The two polar stereographic grids, for data Mercator cannot show at all.
from_cog Build the grid from the raster's own CRS and footprint: tiles in the projection the data is already in, with no reprojection at serve time.
path/to/grid.json Any grid at all, as one OGC TileMatrixSet 2.0 JSON file. No code, no plugin, no rebuild. This is how the demos serve swissLV95 (EPSG:2056) and EuropeanETRS89_LAEAQuad (EPSG:3035).
layers:
  - name: buildings
    vector: /data/buildings.fgb
    vec_style: /data/buildings.vec.json
    src_crs: EPSG:3035
    # the same layer, on two grids at once
    grids: [WebMercatorQuad, /data/grids/EuropeanETRS89_LAEAQuad.json]

Every published grid is served as its own document at /tileMatrixSets/{id}, and both viewers build their map straight from that response, so the client never guesses at tile geometry. The demos use this for the Swiss national grid (swissLV95, EPSG:2056) and for the EU's own EuropeanETRS89_LAEAQuad (EPSG:3035), the equal-area grid INSPIRE specifies for pan-European data.

Axis order: the trap in every non-Mercator grid

Plenty of national and EU projections are northing-first: EPSG:3035, 2180, 3301, the German Gauss-Krüger zones. Their official authority definition orders the axes (Y, X), so a correct WMS 1.3.0 client sends BBOX=miny,minx,maxy,maxx and a TileMatrixSet in the OGC registry writes pointOfOrigin northing-first too. Read one of those as easting-first and you land millions of metres away, which shows up not as an error but as blank tiles behind a 200 OK. TerraServe follows each CRS's real axis order on the WMS and WMTS surfaces, while /tileMatrixSets/{id} always emits plain x,y so a naive reader stays correct.

PostGIS as a source (EXPERIMENTAL)

A layer's data can be a PostGIS table instead of a file. The split of work is deliberate and worth stating plainly: PostGIS stores, TerraServe renders. Each tile becomes one bounding-box query over the table's spatial index; the geometry comes back as WKB and every reprojection, simplification, styling and encoding step runs in the engine, exactly as it would from a file. Nothing is pushed into SQL that would make the database do rendering work.

layers:
  - name: buildings
    vector: postgis://postgres:${PGPASS}@db:5432/osm/public.osm_buildings
    vec_style: /data/buildings.vec.json
    src_crs: EPSG:3035
    extent: [3155046.0, 2026265.0, 4673364.0, 3550864.0]   # required
    grids: [WebMercatorQuad, /data/grids/EuropeanETRS89_LAEAQuad.json]
vector: postgis://… postgis://user:pass@host:5432/db/schema.table. Put the password in an environment variable and write ${PGPASS}; a literal in the file is refused at startup.
extent: [minx, miny, maxx, maxy] Required, and authoritative. TerraServe never asks the database for its own extent, because ST_Extent is a full-table scan and ST_EstimatedExtent is NULL on a table that was never analysed.
src_crs: EPSG:… The SRID the geometry column is stored in. Everything else is reprojected from it.
TERRASERVE_PG_MAX_QUERY_FEATURES Rows per query, default 500,000. On a table larger than that it truncates with no ordering, so raise it past your row count. A correctness knob, not a memory one.
TERRASERVE_PG_STATEMENT_TIMEOUT_MS Per-query timeout, default 30,000. A continental query that trips it is served as an empty tile, so raise it before baking low zooms.

This is the setup behind the EU demo: 154.8 million OpenStreetMap features across five countries, served live out of a 63 GB database, with the heavy zoom levels pre-baked to tile archives so the database is never asked a continental question at request time.

The PostGIS connection has no TLS yet

Deliberate and documented, not an oversight: the client connects in the clear and ?sslmode= is refused with a message saying so, rather than silently ignored. Until TLS lands, keep the database on a private network the server can reach and nothing else can, and do not publish its port. Passwords are read from the environment; a literal one in the config is a startup error.

Multi-layer YAML

Serve many layers from one process with --config layers.yaml. Each layer sets its own data, projection and style; a raster layer uses cog:, a vector layer uses vector: + vec_style:.

layers:
  # NDVI computed live from a Sentinel-2 stack on S3
  - name: ndvi
    cog: s3://your-bucket/s2_stack.cog.tif
    src_crs: EPSG:32629
    s3_endpoint: https://s3.gra.io.cloud.ovh.net
    s3_region: gra
    nodata: -32768
    bands: { B02: 1, B03: 2, B04: 3, B08: 4 }
    expression: "(B08 - B04) / (B08 + B04)"
    style: fixtures/styles/ndvi.json

  # A plain RGB orthophoto from a local COG
  - name: cascais
    cog: ../cogs/cascais.cog.deflate.tif
    src_crs: EPSG:3763
    style: fixtures/styles/rgb.json

docker run … terraserve serve --config /data/layers.yaml. A client picks a layer with LAYERS=ndvi (WMS) or /mvt/ndvi/… (tiles). S3 credentials come from the environment (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY).

Serve from object storage (S3)

Any path can be an s3://… URL, not only the data. TerraServe reads the FlatGeoBuf, its style and the label font straight from the bucket, so a container can ship with nothing on disk. A windowed layer still reads only the byte ranges it needs, so it stays light even when the file lives in the cloud.

terraserve serve \
  --vector    s3://your-bucket/vector/roads.fgb \
  --vec-style s3://your-bucket/style/roads.style.json \
  --font      s3://your-bucket/font/DejaVuSans.ttf \
  --name roads --port 8080

The folders above are just key prefixes, so organise the bucket however you like. In a --config YAML, point vector: and vec_style: at s3://… too. Credentials come from the environment (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), the endpoint and region from AWS_ENDPOINT_URL / AWS_REGION, or the --s3-endpoint / --s3-region flags.

Python bindings

A terraserve wheel wraps the same render core as a plain Python function: render_png() takes a COG window and returns PNG bytes, byte-identical to the CLI. Preview, v0.2.0, raster only (vector/labels and NDVI aren't wired into the binding yet). Walkthrough with a real render: the Python example.

# not on PyPI yet: install straight from the GitHub release (abi3 + aarch64 wheels too)
pip install https://github.com/terraops-org/TerraServe/releases/download/v0.2.0/terraserve-0.2.0-cp39-abi3-manylinux_2_28_x86_64.whl
import terraserve

png = terraserve.render_png(
    cog_path="cascais.cog.deflate.tif", bbox=(-9.45, 38.68, -9.38, 38.72),
    crs="EPSG:4326", src_crs="EPSG:3763", width=512, height=512,
    style="rgb.json", resample="bilinear",
)
open("cascais.png", "wb").write(png)
cog_path: str The COG to read: a local path or s3://bucket/key.
bbox: tuple[float, float, float, float] (minx, miny, maxx, maxy), in crs units.
crs: str Output projection, e.g. EPSG:3857.
src_crs: str | None The COG's own projection. Falls back to a fixture constant if omitted, so always pass it for real data.
width, height: int Output size in pixels.
style: str Path to a style.json, rgb or pseudocolor mode.
resample: str | None "nearest" or "bilinear" (default).
-> bytes The PNG file, in memory. The GIL is released for the whole render.

The wheel also ships a drop-in type: map provider for pygeoapi's OGC API - Maps, referenced by its dotted path, no pygeoapi core change needed:

providers:
  - type: map
    name: terraserve.pygeoapi.TerraServeProvider
    data: /data/cascais.cog.tif
    options:
      src_crs: EPSG:3763
      style: /data/rgb.json

Endpoints

/wms WMS 1.1.1 & 1.3.0: GetMap, GetCapabilities, GetFeatureInfo, GetLegendGraphic. Point QGIS here.
/tms/1.0.0/ OSGeo TMS 1.0.0 tiles.
/mvt/{layer}/{grid}/{z}/{x}/{y}.pbf Vector tiles (MVT). Style JSON at /mvt/{layer}/style.json, TileJSON at /mvt/{layer}/{grid}.json.
/viewer A built-in raster map viewer.
/xray The vector-tile inspector (per-feature attributes, legend). /xray?layer={name}.

Rendering

Rendering runs on two separate paths, one for raster and one for vector. They only meet at the finished PNG or tile.

Raster. A Cloud-Optimized GeoTIFF is decoded, reprojected and resampled by TerraServe's own kernels: the tile is read from the COG, warped from its source CRS into the grid you asked for (nearest or bilinear), then coloured, RGB passthrough, a pseudocolor ramp, or on-the-fly band math. No third-party imaging library touches this path; it is the engine core in render.rs.

Vector. Drawing geometry is a different problem: filling polygons, stroking lines and placing markers with clean anti-aliased edges. That rasterization is done with tiny-skia (in vector/raster.rs). The vector tile formats built on top, MVT and PMTiles, are written from scratch: a hand-rolled protobuf encoder (varints, tags, zigzag-delta geometry) and a bespoke PMTiles reader and writer, no prost and no tile library.

Why tiny-skia

tiny-skia is a pure-Rust port of Skia's rasterizer, the 2D engine behind Chrome, Android and Flutter. It gives production-grade, sub-pixel anti-aliasing with no C++ graphics dependency: no AGG, no cairo, no Skia over FFI. That fits the clean-room rule, which bans off-the-shelf dataset and tile readers but not a general rasterizer. Reimplementing a Skia-quality rasterizer would be a project of its own for no real gain, so this is the one place TerraServe leans on a library instead of building its own.

Pitfalls

The things that cost someone a night. Read these before you deploy.

The feature cap is a correctness knob, not a memory knob

The env vars TERRASERVE_FGB_MAX_QUERY_FEATURES / …_GPKG_… silently drop features in spatial order once a tile exceeds them, blanking whole regions behind a 200 OK. If "the same areas are always missing", this is why. Don't set them to save memory; use windowed reads + precomputed tiles instead.

--cache-lru does nothing for vector layers

It's the cache for decoded raster (COG) tiles only. On a vector-only server it's a no-op, and the "LRU tile cache: enabled" startup line is misleading. For vector, use --mvt-cache and --wms-cache.

Set --public-url behind a proxy

Without it, GetCapabilities advertises the bind address (e.g. http://0.0.0.0:8080/wms). QGIS and the raster viewer then request that unroutable address and nothing loads. Set it to your public URL, path prefix included.

Bind --host 0.0.0.0 in a container

The default is 127.0.0.1, reachable only from inside the container. Always pass --host 0.0.0.0 in Docker (and publish the port).

--vector requires --vec-style

Even though vector tiles ignore it, the WMS picture path needs a style. Without it the server exits immediately at startup.

--topology-simplify trades startup time + memory for speed

It loads the whole layer (not windowed) and builds a shared-border topology before it starts listening. That can take a minute or two on a large coverage, and needs the memory to hold it. Pair it with --keep-fields to fit, and set your health-check's start-period generously.

PMTiles serves vector tiles, not WMS pictures

The archive is read only by the tile path. A low-zoom WMS rasterize is drawn live and can occupy every --max-inflight slot, starving the fast tile path. For big precomputed coverages, point clients at the vector tiles, and remember --pmtiles-cache needs a writable volume (not :ro).

Don't give the picture paths the vector-tile threshold

--mvt-min-feature-px and --raster-min-feature-px look like the same knob and are not. The MVT one is cartographic: it decides what a reader sees, and on a dense coverage 2 visibly thins the map (measured: a city tile fell from 791 KB to 355 KB). The raster one is a safety valve against an accidental world-wide request, and the same 2 would erase over half of a drawn tile. Roughly 0.05 keeps 99.6% of a close-up tile while still bounding a continental accident to a fraction of a GB. And never bake low-zoom pictures with it on: an overview tile of a dense layer is the texture of sub-pixel shapes, so gating it produces a blank archive that then shadows the live path forever.

Vector-tile archives and picture archives are different lists

In a config, pmtiles: feeds vector tiles and raster_pmtiles: feeds the WMTS/TMS picture path. They are separate read-through paths, so an MVT client sees nothing of a picture archive no matter how many GB of it sit on disk, and vice versa. Archives self-describe their grid, so one list can hold one archive per grid. Put an archive under the wrong key and the server refuses it at startup, naming both formats, rather than handing image bytes to a vector client.

--max-inflight ≈ your real CPU cores

Rendering is CPU-bound, so over-subscribing buys nothing and can thrash a small box. It's also your hard memory ceiling under a burst: excess requests queue rather than pile up in memory.

Other commands

serve is one of five subcommands. The others draw a single PNG, bake an offline tile pyramid, or inspect a coverage. Each has a runnable example and its flags on the CLI reference.

render Render one window of a raster to a PNG. It's the engine core, no server.
build-pmtiles Bake a vector source into an offline .pmtiles tile pyramid (what the demos precompute).
build-topology Build & report the shared-border topology from a coverage (a diagnostic; draws no tiles).

CLI reference ->See real commands on the examples ->