Serving Raster Tiles from FastAPI with TiTiler

A single Cloud Optimized GeoTIFF sitting in object storage can back a slippy map without any pre-rendering step, provided something translates z/x/y into a byte range and a PNG — and TiTiler is that something, shipped as FastAPI routers you mount rather than a server you adopt. This guide is for Python developers who already have a COG and now need it on a web map; it sits under Geospatial Dashboards & App Deployment in Web Mapping & Interactive Visualization, and assumes you are comfortable with the windowed reads from a Cloud Optimized GeoTIFF that make the whole thing viable.

Why This Approach / What Goes Wrong

The alternative to dynamic tiling is baking a tile pyramid ahead of time — correct for a static basemap, wrong for a raster you rescale, recolour, or replace weekly. Dynamic tiling trades a one-off render cost for a per-request one, and the entire engineering question becomes how small you can make that per-request cost. TiTiler answers it by never reading the whole file: for each tile it computes the tile's bounds in the requested tile matrix set, asks rasterio for a 256×256 decimated read of that window, and lets GDAL's virtual filesystem satisfy that read with a handful of HTTP range requests against the overview level whose resolution is closest to what the zoom needs.

Three things break this in practice, and all three are configuration rather than code. The first is a source raster that is not actually a valid COG — no internal tiling, no overviews — in which case GDAL cannot do a partial read and quietly downloads hundreds of megabytes per tile. The second is GDAL's default network behaviour: without tuning, every open() issues a directory listing against the bucket prefix and several small sequential reads before the first pixel is touched, which on S3-class latency dominates the response. The third is missing cache headers, which turns every pan and zoom into a fresh compute even though the tile is deterministic. Get those right and a modest container serves hundreds of tiles a second; get them wrong and the same code takes two seconds per tile.

The path of one tile request from browser to ranged GET on a COG A left-to-right chain of four stages. The browser issues GET /cog/tiles for zoom 12, x 1205, y 1539. The TiTiler router converts that to tile bounds using the tile matrix set. The rio-tiler Reader requests a 256 by 256 decimated read, which selects an overview level. Object storage answers with HTTP Range requests against the COG rather than a full download. Two panels below show the mapping from zoom band to overview level — zoom 0 to 8 reads overview 3 at one eighth resolution, 9 to 11 reads overview 2, 12 to 13 reads overview 1, and zoom 14 and above reads the full-resolution image — and the bytes actually moved, which is one 32 kilobyte header read plus one to four small range requests, leaving the rest of the file untouched. A footer notes the response is a PNG tile carrying a public max-age cache header. One tile request becomes one ranged read of the right overview Browser GET /cog/tiles/... z 12 · x 1205 · y 1539 TiTiler router TilerFactory tile bounds from the TMS rio-tiler Reader out_shape 256x256 picks the overview level Object storage Range: bytes=... COG · no full download Zoom band decides the overview read z 0-8 overview 3 — 1/8 resolution z 9-11 overview 2 — 1/4 resolution z 12-13 overview 1 — 1/2 resolution z >= 14 full-resolution image Bytes moved for one 256 px tile 1 GET · 32 KB of header and tile offsets 1-4 GETs · only the overlapping blocks 0 GETs · the other 4 GB stay put GDAL_INGESTED_BYTES_AT_OPEN sizes that first read Response: a PNG tile carrying Cache-Control: public, max-age=3600
Every zoom level maps to the overview whose resolution is closest to 256 pixels of tile, so the bytes read stay roughly constant as you zoom out.

Prerequisites

python -m pip install "titiler.core==2.2.*" "uvicorn[standard]>=0.34" \
  "rio-cogeo>=5.3" "httpx>=0.27"

Install into a clean virtual environment rather than an existing GeoPandas one where possible: titiler.core pins rio-tiler and rasterio tightly, and mixing those pins with a conda-installed GDAL stack is the usual source of import-time symbol errors.

Step-by-Step Implementation

1. Prove the source raster is a real COG before writing any server code.

Dynamic tiling is only cheap because the file is internally tiled with overviews. rio cogeo validate tells you in one line, and it is the first thing to check when tiles are slow.

rio cogeo validate s3://elevation-cogs/lidar/dtm_2024.tif
# s3://elevation-cogs/lidar/dtm_2024.tif is a valid cloud optimized GeoTIFF
# The following warnings were found:
# - The file is greater than 512xH or 512xW, it is recommended to include internal overviews

If it fails, rewrite it with the resampling and overview settings covered in resampling and overviews when writing COGs before going any further.

2. Mount the tiler router inside your own FastAPI app.

TilerFactory builds a router you include like any other. The one argument people forget is router_prefix: the factory generates absolute tile URLs for the TileJSON document with url_for, and without the prefix those URLs come back missing /cog.

# app.py
from fastapi import FastAPI
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from titiler.core.factory import TilerFactory
from titiler.core.middleware import CacheControlMiddleware

app = FastAPI(title="Elevation tiles", openapi_url="/api")

# router_prefix must match the include_router prefix, or tilejson URLs are wrong
cog = TilerFactory(router_prefix="/cog")
app.include_router(cog.router, prefix="/cog", tags=["Cloud Optimized GeoTIFF"])

# Maps rio-tiler exceptions to HTTP codes: TileOutsideBounds -> 404, etc.
add_exception_handlers(app, DEFAULT_STATUS_CODES)

# One hour on tiles; the health endpoint must stay uncached for load balancers
app.add_middleware(
    CacheControlMiddleware,
    cachecontrol="public, max-age=3600",
    exclude_path={r"/healthz"},
)


@app.get("/healthz", tags=["Health"])
def ping():
    return {"ping": "pong!"}

That is the whole server. The factory registers /cog/info, /cog/statistics, /cog/point/{lon},{lat}, /cog/preview, /cog/tiles/{tileMatrixSetId}/{z}/{x}/{y}, /cog/{tileMatrixSetId}/tilejson.json and /cog/WMTSCapabilities.xml, each taking the dataset as a url query parameter.

3. Set the GDAL environment before the workers start.

GDAL reads its configuration from the process environment when the first dataset is opened, so these belong in your launch command, Dockerfile ENV block, or task definition — not in a @app.on_event hook that may run after a worker has already touched a file.

export GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR
export GDAL_INGESTED_BYTES_AT_OPEN=32768
export GDAL_HTTP_VERSION=2
export GDAL_HTTP_MULTIPLEX=YES
export GDAL_CACHEMAX=200
export VSI_CACHE=TRUE
export VSI_CACHE_SIZE=5000000
export CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif,.TIF,.tiff"

uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4
GDAL environment settings and the network work each one removes A three-column table of GDAL configuration options, their recommended values, and the cost each avoids. GDAL_DISABLE_READDIR_ON_OPEN set to EMPTY_DIR removes a LIST request against the bucket prefix. GDAL_INGESTED_BYTES_AT_OPEN of 32768 fetches the header and tile offsets in one read. GDAL_HTTP_VERSION 2 reuses one connection. GDAL_HTTP_MULTIPLEX YES overlaps range requests on that connection. GDAL_CACHEMAX of 200 keeps 200 megabytes of decoded blocks per worker. VSI_CACHE TRUE with VSI_CACHE_SIZE of five million bytes keeps header re-reads in memory per file handle. CPL_VSIL_CURL_ALLOWED_EXTENSIONS limited to tif extensions stops probing for sidecar files. A footer notes these must be set in the process environment before uvicorn starts. What each GDAL setting removes from the hot path Setting Value What it saves GDAL_DISABLE_READDIR_ON_OPEN EMPTY_DIR a LIST request on the bucket prefix per open GDAL_INGESTED_BYTES_AT_OPEN 32768 two extra round trips for header and offsets GDAL_HTTP_VERSION 2 a TLS handshake per request GDAL_HTTP_MULTIPLEX YES serialised waits — ranges overlap on one link GDAL_CACHEMAX 200 re-decoding blocks neighbouring tiles share VSI_CACHE TRUE re-fetching header bytes already seen VSI_CACHE_SIZE 5000000 sizes that cache at 5 MB per file handle CPL_VSIL_CURL_ALLOWED_EXTENSIONS .tif,.tiff probes for .ovr and .msk sidecars Set these in the process environment before uvicorn starts — GDAL reads them once, per worker.
Most of a slow tile is network round trips, not pixels; each row above deletes one class of round trip.

4. Read the dataset's own metadata through /cog/info.

Before styling anything, ask the tiler what it sees. The response comes from rio-tiler, and its bounds are expressed in the dataset's CRS — not longitude and latitude — with a companion crs field naming it.

curl -s "http://localhost:8000/cog/info?url=s3://elevation-cogs/lidar/dtm_2024.tif" | jq
# {
#   "bounds": [472680.0, 4374300.0, 508920.0, 4410540.0],
#   "crs": "http://www.opengis.net/def/crs/EPSG/0/32613",
#   "dtype": "float32",
#   "nodata_type": "Nodata",
#   "band_descriptions": [["b1", "elevation_m"]]
# }

Those numbers are UTM zone 13N metres. If you need them as lon/lat — to set a map's initial view, say — transform them explicitly rather than assuming, and pass always_xy=True so PyProj yields (x, y) instead of the authority-defined latitude-first order for EPSG:4326. The full model behind that flag is covered in Coordinate Systems with PyProj.

from pyproj import Transformer

# always_xy=True -> (lon, lat) out, matching what web maps expect
to_wgs84 = Transformer.from_crs("EPSG:32613", "EPSG:4326", always_xy=True)
west, south = to_wgs84.transform(472680.0, 4374300.0)
east, north = to_wgs84.transform(508920.0, 4410540.0)
print(round(west, 4), round(south, 4), round(east, 4), round(north, 4))
# -105.6178 39.5041 -105.1942 39.8287

Note that the tiles themselves are always delivered in the WebMercatorQuad tile matrix set — EPSG:3857 — because that is what slippy maps consume. That is a display grid only. Any distance, area or slope computation on this elevation model belongs upstream in the UTM zone above, never in Web Mercator, whose scale error grows with latitude.

5. Choose rescale from the data, not from guesswork.

A float32 elevation band means nothing to a PNG encoder until you tell it which value range maps to 0–255. /cog/statistics gives you defensible bounds, including percentiles that clip outliers a plain min/max would let dominate the ramp.

curl -s "http://localhost:8000/cog/statistics?url=s3://elevation-cogs/lidar/dtm_2024.tif" \
  | jq '.b1 | {min, max, percentile_2, percentile_98}'
# {
#   "min": 1483.2,
#   "max": 4302.7,
#   "percentile_2": 1596.4,
#   "percentile_98": 3874.1
# }

Feed those into the tile URL as rescale=min,max, and pick a colormap by name from rio-tiler's registry. rescale is repeatable — supply it once per band when rendering a three-band composite, and once total for a single-band product like this one.

curl -s -o tile.png -D - \
  "http://localhost:8000/cog/tiles/WebMercatorQuad/12/1205/1539.png?\
url=s3://elevation-cogs/lidar/dtm_2024.tif&rescale=1596,3875&colormap_name=terrain"
# HTTP/1.1 200 OK
# content-type: image/png
# cache-control: public, max-age=3600

6. Hand MapLibre the TileJSON, not a hand-built tile template.

The tilejson.json endpoint echoes back every rendering parameter you passed it, embedded in the tiles template, plus bounds, minzoom and maxzoom derived from the raster. Query it once and MapLibre gets a correct raster source for free.

curl -s "http://localhost:8000/cog/WebMercatorQuad/tilejson.json?\
url=s3://elevation-cogs/lidar/dtm_2024.tif&rescale=1596,3875&colormap_name=terrain" | jq
# {
#   "tilejson": "2.2.0",
#   "tiles": ["http://localhost:8000/cog/tiles/WebMercatorQuad/{z}/{x}/{y}@1x?url=s3%3A%2F%2F..."],
#   "minzoom": 8,
#   "maxzoom": 16,
#   "bounds": [-105.6178, 39.5041, -105.1942, 39.8287],
#   "center": [-105.406, 39.6664, 8]
# }

A MapLibre raster source accepts that document by URL directly. The one value TileJSON cannot carry is tile size, and MapLibre defaults raster sources to 512 px while TiTiler serves 256 px — so state it explicitly or every tile lands at half the resolution it should. Vector sources in the same map are covered in MapLibre GL Vector Web Maps.

const tilejson =
  "http://localhost:8000/cog/WebMercatorQuad/tilejson.json" +
  "?url=s3%3A%2F%2Felevation-cogs%2Flidar%2Fdtm_2024.tif" +
  "&rescale=1596%2C3875&colormap_name=terrain";

map.on("load", () => {
  map.addSource("dtm", {
    type: "raster",
    url: tilejson,   // MapLibre fetches bounds, minzoom, maxzoom from here
    tileSize: 256,   // TiTiler serves 256 px; MapLibre would assume 512
  });
  map.addLayer({ id: "dtm", type: "raster", source: "dtm", paint: { "raster-opacity": 0.85 } });
});

Because the rendering parameters live in the query string, the tile URL is a complete cache key: two different rescale values are two different resources, and every cache in front of the app treats them as such.

The four caches between a map pan and a ranged GET Four nested layers. The outermost is the browser HTTP cache, which answers in roughly zero milliseconds and honours the max-age directive. Inside it is the CDN edge at about fifteen milliseconds, which keys on the full tile URL including its query string. Inside that is the TiTiler worker at about ninety milliseconds, holding GDAL's VSI cache and decoded block cache. The innermost layer is the COG on object storage at about three hundred milliseconds, the only layer that costs egress. A footer states that each layer which answers removes one ranged GET, and that Cache-Control is what allows the outer two layers to answer at all. Four caches sit between a map pan and a ranged GET Browser HTTP cache ~0 ms · obeys max-age CDN edge ~15 ms · keys on the whole query string TiTiler worker ~90 ms · VSI cache + block cache COG on object storage ranged GET · the only layer that costs egress ~300 ms cold Each layer that answers deletes one ranged GET — Cache-Control is what lets the outer two answer at all
The header set by CacheControlMiddleware is what converts the two cheapest layers from decoration into actual capacity.

Verification

Drive the mounted routers through FastAPI's TestClient, which exercises the real dependency chain without a running server. The assertions cover the three things that break silently: a wrong router_prefix, a missing cache header, and out-of-bounds tiles returning 500 instead of 404.

# test_tiles.py
from fastapi.testclient import TestClient

from app import app

COG = "s3://elevation-cogs/lidar/dtm_2024.tif"
client = TestClient(app)

info = client.get("/cog/info", params={"url": COG})
assert info.status_code == 200
assert info.json()["dtype"] == "float32"

params = {"url": COG, "rescale": "1596,3875", "colormap_name": "terrain"}
tj = client.get("/cog/WebMercatorQuad/tilejson.json", params=params).json()
# router_prefix is correct only if the generated template carries /cog/tiles
assert "/cog/tiles/WebMercatorQuad/{z}/{x}/{y}" in tj["tiles"][0]
assert tj["minzoom"] < tj["maxzoom"]

tile = client.get("/cog/tiles/WebMercatorQuad/12/1205/1539.png", params=params)
assert tile.status_code == 200
assert tile.headers["content-type"] == "image/png"
assert tile.headers["cache-control"] == "public, max-age=3600"

# Tile 0/0 at z12 is in the Arctic — outside a Colorado DTM
miss = client.get("/cog/tiles/WebMercatorQuad/12/0/0.png", params={"url": COG})
assert miss.status_code == 404, "add_exception_handlers is not wired up"

print(f"OK: {len(tile.content)} byte tile, zooms {tj['minzoom']}-{tj['maxzoom']}")
# OK: 41902 byte tile, zooms 8-16

Edge Cases & Debugging

Frequently Asked Questions

Do I need the full titiler.application package? No — and for a production service you usually should not. titiler.core gives you the factories; titiler.application is a reference deployment that also mounts STAC, MosaicJSON and Zarr endpoints plus a viewer, all of which become surface area you have to secure. Mounting TilerFactory yourself, as in step 2, keeps the app to the routes you actually serve and lets you attach your own auth dependencies to include_router.

How do I stop people pointing the url parameter at arbitrary files? The url query parameter is an open proxy by default. Override the factory's path_dependency with your own callable that maps an opaque layer id to a bucket path, so callers pass ?layer=dtm_2024 and never a raw URL. That single change also makes tile URLs stable and cacheable, which the query-string-keyed CDN layer rewards.

Can TiTiler render hillshade or slope without a preprocessing step? Yes. The tile endpoints accept an algorithm parameter — algorithm=hillshade is built in, with parameters passed as JSON in algorithm_params. It runs per tile on the decimated read, so results vary slightly with zoom; when you need a fixed, reproducible hillshade, bake it into a second COG instead.

Where does this fit next to a Streamlit or Dash dashboard? TiTiler is the raster backend, not the application. A dashboard built as described in building a Streamlit map dashboard with Folium points its raster layer at this service's tile template while keeping its own vector and widget logic; the two deploy as separate containers so the tiler can scale on its own.