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.
Prerequisites
titiler.core==2.2.*— the router factories, dependency classes and middleware; pullsrio-tiler>=9.0andrasteriotransitivelyfastapi>=0.140— resolved bytitiler.core, pinned here so you notice if a downgrade sneaks inuvicorn[standard]>=0.34— the ASGI server, withhttptoolsanduvloopfor the workerrio-cogeo>=5.3— therio cogeo validateCLI you use to prove the source is actually a COGhttpx>=0.27— required byfastapi.testclient.TestClientfor the verification step- Python 3.11 or newer, which
titiler.core2.x requires
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
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.
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
- Every tile is a grey or transparent square. The band is floating point and no
rescalewas supplied, so the encoder clamps everything to one end. Read/cog/statisticsand passrescale=p2,p98; addnodata=-9999if the source declares no internal nodata value. - First tile after each deploy takes seconds, then it is fast. GDAL is listing the bucket prefix on open. Set
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIRin the process environment, not in Python — a value set after the firstopen()is ignored for that worker. rio-tilerraisesTileOutsideBoundsas a 500.add_exception_handlers(app, DEFAULT_STATUS_CODES)was never called, so FastAPI has no mapping for it. Add the call and the same request returns a clean 404.- Tiles are blurry and MapLibre requests half the zooms you expect. The raster source is using MapLibre's default
tileSize: 512against 256 px tiles. SettileSize: 256on the source, or request@2xtiles and keep 512. - A private bucket returns 403 from GDAL but works with
aws s3 cp. GDAL uses its own credential resolution: give the container an instance role, or setAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_REGION. For S3-compatible storage such as R2 or MinIO, addAWS_S3_ENDPOINT=<host-without-scheme>andAWS_VIRTUAL_HOSTING=FALSE. - CPU sits at 100% serving PNGs. A gzip middleware is recompressing already-compressed imagery. Exclude
image/*from compression, or drop the middleware from the tile routes entirely.
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.