How to Serve Raster Tiles from a COG with TiTiler

Problem statement

A GeoTIFF needs to appear on a web map. The traditional route is to cut a tile pyramid โ€” hours of processing, gigabytes of small files, and a rerun every time the raster changes.

A Cloud Optimised GeoTIFF plus a dynamic tile server removes both. The COG is internally tiled and carries overviews, so a tile server can read exactly the bytes one tile needs over a range request and render it on demand. TiTiler is that server: a FastAPI application built on rasterio and rio-tiler that turns any COG URL into an XYZ tile endpoint.

The parts that go wrong are predictable: the source is not actually a valid COG, the rescaling is wrong so every tile is black or white, nothing is cached, and the server reads from slow storage on every request.

Quick answer

pip install "titiler.application"
uvicorn titiler.application.main:app --port 8000
http://localhost:8000/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png
    ?url=https://example.org/scene.tif
    &rescale=0,3000
    &colormap_name=viridis

and in MapLibre:

map.addSource("dem", {
  type: "raster",
  tiles: ["http://localhost:8000/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png" +
          "?url=" + encodeURIComponent(COG_URL) + "&rescale=0,3000"],
  tileSize: 256,
});

The two parameters that decide whether anything appears are url and rescale. Everything else is styling.

Two panels contrasting a striped GeoTIFF with an internally tiled COG.
Validate the source before blaming the server.

Step-by-step solution

1. Make sure the source is a real COG

"Cloud optimised" is a specific internal layout: the image is tiled rather than striped, overviews are present, and the metadata sits at the front of the file. A plain GeoTIFF works and is slow, because reading one tile means reading whole strips.

from rio_cogeo.cogeo import cog_validate

valid, errors, warnings = cog_validate("scene.tif")
print("valid COG:", valid)
for message in errors + warnings:
    print("  ", message)

Converting is one command:

from rio_cogeo.cogeo import cog_translate
from rio_cogeo.profiles import cog_profiles

cog_translate("scene.tif", "scene_cog.tif", cog_profiles.get("deflate"),
              overview_level=6, web_optimized=True)

web_optimized=True aligns the internal tiling with the Web Mercator grid, so a map tile maps onto whole internal blocks rather than straddling them.

2. Inspect the raster before styling it

Half the "all my tiles are black" reports are a rescale problem, and the fix starts with knowing the data's actual range:

GET /cog/info?url=โ€ฆ          bands, dtype, nodata, overviews, bounds
GET /cog/statistics?url=โ€ฆ    min, max, percentiles per band

Use the 2nd and 98th percentiles rather than the true minimum and maximum: a single hot pixel otherwise compresses the whole visible range.

3. Set rescale explicitly

TiTiler maps the source values onto 0โ€“255 for display. For anything that is not already 8-bit โ€” a DEM in metres, Sentinel-2 reflectance in scaled integers, a float index โ€” the default is almost certainly wrong.

&rescale=0,3000            one range for all bands
&rescale=0,3000&rescale=0,3000&rescale=0,3000    per band, in order

Everything black usually means the range is far too wide; everything white means it is far too narrow.

4. Choose bands and a colormap

&bidx=4&bidx=3&bidx=2                     false colour from a multiband scene
&expression=(b8-b4)/(b8%2Bb4)             NDVI, computed per tile
&colormap_name=viridis&rescale=-1,1

expression is evaluated per tile on the fly, which is what makes a dynamic tiler genuinely different from a pyramid: an index can be recomputed with a different formula without regenerating anything. Note that + must be URL-encoded as %2B.

5. Cache, because tile generation is not free

Every tile is a range read plus a decode plus a resample plus an encode. It is fast โ€” tens of milliseconds from a local COG โ€” and it is not free, and identical tiles are requested repeatedly.

The measured argument, from an HTTP service: a conditional request answered with 304 ran at 1,191 requests per second and 6.2 ms, against 100 requests per second and 94.6 ms for producing the body. Put a CDN or a caching proxy in front, and set Cache-Control on the responses.

6. Mind where the COG actually lives

A tile served from a COG on the same machine is a local read. The same COG on object storage in another region is a range request per tile, with that latency on every uncached tile.

Both work. The second needs a cache much more urgently, and benefits from GDAL's block cache being sized sensibly:

export GDAL_CACHEMAX=512                 # MB
export VSI_CACHE=TRUE
export VSI_CACHE_SIZE=536870912
export GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR

That last one prevents GDAL listing the whole bucket prefix every time it opens a file, which on object storage is the difference between fast and unusable.

Bar chart of source value ranges against the 8-bit display range.
Everything black means the range is far too wide; everything white, too narrow.

Code examples

Example 1 โ€” a TiTiler application with your own defaults and caching

from fastapi import FastAPI, Request, Response
from titiler.core.factory import TilerFactory
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers

app = FastAPI(title="Raster tiles")
cog = TilerFactory()
app.include_router(cog.router, prefix="/cog", tags=["Cloud Optimised GeoTIFF"])
add_exception_handlers(app, DEFAULT_STATUS_CODES)


@app.middleware("http")
async def cache_tiles(request: Request, call_next):
    response = await call_next(request)
    if "/tiles/" in request.url.path and response.status_code == 200:
        response.headers["Cache-Control"] = "public, max-age=86400"
    return response


@app.get("/health")
def health():
    return {"status": "ok"}

Wrapping the factory rather than running titiler.application directly is what lets you add auth, restrict which URLs may be tiled, and set caching policy โ€” all of which a public deployment needs.

Example 2 โ€” restricting which rasters may be served

from fastapi import HTTPException
from urllib.parse import urlparse

ALLOWED_HOSTS = {"data.example.org", "s3.eu-west-2.amazonaws.com"}
ALLOWED_PREFIXES = ("s3://our-bucket/", "/data/cogs/")


def validate_url(url: str) -> str:
    """A tile server that accepts any URL is an open proxy."""
    if url.startswith(ALLOWED_PREFIXES):
        return url
    parsed = urlparse(url)
    if parsed.scheme in ("http", "https") and parsed.hostname in ALLOWED_HOSTS:
        return url
    raise HTTPException(403, f"rasters from {parsed.hostname or url!r} are not served here")

This matters more than it sounds. A ?url= parameter that accepts anything lets a stranger use your server to fetch arbitrary URLs, including internal ones.

Example 3 โ€” picking the rescale range from the data

import httpx


def suggest_rescale(base_url, cog_url, percentiles=(2, 98)):
    """Ask the server for statistics and produce the rescale parameter."""
    stats = httpx.get(f"{base_url}/cog/statistics",
                      params={"url": cog_url}, timeout=60).json()

    ranges = []
    for band, values in stats.items():
        low = values.get(f"percentile_{percentiles[0]}", values["min"])
        high = values.get(f"percentile_{percentiles[1]}", values["max"])
        ranges.append((low, high))
        print(f"{band}: min {values['min']:.1f}  "
              f"p{percentiles[0]} {low:.1f}  p{percentiles[1]} {high:.1f}  "
              f"max {values['max']:.1f}")

    return "&".join(f"rescale={low:.0f},{high:.0f}" for low, high in ranges)
b1: min -32768.0  p2 12.0  p98 894.0  max 8848.0
suggested: rescale=12,894

The gap between min and p2 in that output is the whole reason to use percentiles: a nodata value of โˆ’32768 that escaped the mask would otherwise flatten the entire display range.

Explanation

Why a COG makes dynamic raster tiling possible

An ordinary GeoTIFF is stored in strips, so reading a small square means reading every strip it touches โ€” potentially the full width of the image. Internal tiling stores the file in blocks, so a reader can fetch one block.

Overviews add the second half: a zoom 6 tile is rendered from a downsampled overview, not from the full-resolution data. Without overviews, a low-zoom tile reads and downsamples the entire image, which is why a plain GeoTIFF behind a tile server is fast at high zoom and unusable at low zoom.

Why rescale is the parameter that decides whether anything appears

Display is 8-bit; source data usually is not. A DEM in metres runs from โˆ’400 to 8,848; Sentinel-2 reflectance is scaled integers to 10,000; an NDVI is โˆ’1 to 1.

TiTiler cannot know which part of that range is interesting, so it needs telling. Set it from the 2nd and 98th percentiles of the actual data, not from the theoretical range, or one extreme pixel compresses everything else into a few grey levels.

Why GDAL_DISABLE_READDIR_ON_OPEN matters so much on object storage

By default, when GDAL opens a file it lists the containing directory to find sidecar files. On a local disk that is instant. On object storage with a prefix containing thousands of objects, it is a listing request per open โ€” and a tile server opens the file constantly.

Setting it to EMPTY_DIR tells GDAL not to look. It is the single most impactful environment variable for a cloud-hosted tile server, and it turns "unusably slow" into "fine".

Why dynamic raster tiles and pre-rendered ones are not the same product

A pyramid is fixed: one styling, one band combination, one stretch, decided when it was cut. A dynamic tiler takes the styling as query parameters, so the same COG serves true colour, false colour, an NDVI and three different stretches without any regeneration.

That flexibility is the reason to accept the per-request cost. If only one fixed rendering is ever needed, cutting a pyramid once is cheaper to operate โ€” and the decision should be made on that basis rather than on which is more modern.

Checklist of four GDAL settings for a cloud-hosted raster tiler.
GDAL lists the containing directory on open unless told not to.

Edge cases or notes

  • Validate the COG. A plain GeoTIFF works and is slow, especially at low zoom.
  • web_optimized=True when converting aligns internal blocks with the tile grid.
  • Percentiles, not min/max, for the rescale range.
  • + in an expression must be %2B.
  • Restrict the url parameter, or the service is an open proxy.
  • Set GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR for object storage.
  • GDAL_CACHEMAX is per process, so it multiplies by the worker count.
  • Nodata must be declared in the file, or it renders as data.

FAQ

What is TiTiler?

A FastAPI application built on rasterio and rio-tiler that serves XYZ tiles rendered on demand from a Cloud Optimised GeoTIFF, with the band selection, stretch and colormap as query parameters.

Why are all my tiles black?

Almost always the rescale range. Ask the server for /cog/statistics and set it from the 2nd and 98th percentiles rather than the full data range.

Does the source have to be a COG?

No, but it should be. A plain GeoTIFF has no internal tiling and no overviews, so a low-zoom tile reads and downsamples the whole image.

Can I compute an index on the fly?

Yes โ€” expression=(b8-b4)/(b8%2Bb4) computes NDVI per tile. That flexibility is the main reason to serve dynamically rather than cutting a pyramid.

Is it fast enough for a public map?

With a cache in front, yes. Measured on an HTTP service, a cached conditional response ran at 1,191 requests per second against 100 for generating the body.

What should I set for object storage?

GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR first โ€” it stops GDAL listing the bucket prefix on every open โ€” then GDAL_CACHEMAX and the VSI cache settings.