Deploying TiTiler for a COG Archive

A production tile service is a small application with a very specific environment. The code is short; the configuration is where the performance lives:

FROM ghcr.io/lambgeo/lambda-gdal:3.8 AS base

ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
    GDAL_HTTP_MULTIPLEX=YES \
    GDAL_HTTP_VERSION=2 \
    VSI_CACHE=TRUE \
    VSI_CACHE_SIZE=536870912 \
    GDAL_CACHEMAX=512

RUN pip install --no-cache-dir "titiler.application==0.18.*" "uvicorn[standard]==0.29.*"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

Those environment variables are worth more than any amount of application tuning. This page belongs to serving raster tiles with TiTiler in Visualization, Tiling & Web Delivery.


What Each GDAL Setting Does

Four settings, four specific failures Disabling directory listing on open stops every file open from listing a bucket prefix. HTTP multiplexing lets several range requests share one connection. The VSI cache keeps recently read byte ranges so panning does not re-fetch headers. GDAL's block cache holds decoded blocks between tiles of the same area. Why each variable is set setting the failure it prevents GDAL_DISABLE_READDIR _ON_OPEN=EMPTY_DIR every open listing a prefix with a million objects GDAL_HTTP_MULTIPLEX=YES serialising four range requests that could overlap VSI_CACHE_SIZE=512 MB re-fetching the same header for every tile while panning GDAL_CACHEMAX=512 decoding the same internal block for adjacent tiles

The directory listing setting is the one that turns a working prototype into a broken production service. Against a bucket holding a handful of files nobody notices it; against a bucket with a million objects under one prefix, every single tile request pays a listing, and the service appears to hang.


Environment & Setup

Package Version pin Used for
titiler.application ==0.18.* Tile endpoints and the OpenAPI surface
uvicorn[standard] ==0.29.* ASGI server with efficient event loop
GDAL 3.8 Byte-range reads, warping, decoding
rio-cogeo >=5.0 Validating sources before they are served
pip install "titiler.application==0.18.*" "uvicorn[standard]==0.29.*" "rio-cogeo>=5.0"

Version pinning matters more here than in an analysis script, for the reasons set out in pinning GDAL and PROJ versions reproducibly: a GDAL upgrade can change resampling behaviour, and a tile service that renders subtly differently after a deploy is very hard to diagnose.


Complete Working Example

# app.py — a production-shaped tile service
import logging
import os

from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import Response
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from titiler.core.factory import TilerFactory, MultiBaseTilerFactory
from titiler.mosaic.factory import MosaicTilerFactory

logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
log = logging.getLogger("tiles")

app = FastAPI(title="Raster tile service", version="1.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=os.environ.get("ALLOWED_ORIGINS", "").split(",") or ["*"],
    allow_methods=["GET"],
)

cog = TilerFactory(router_prefix="/cog")
app.include_router(cog.router, prefix="/cog", tags=["cog"])

mosaic = MosaicTilerFactory(router_prefix="/mosaic")
app.include_router(mosaic.router, prefix="/mosaic", tags=["mosaic"])

add_exception_handlers(app, DEFAULT_STATUS_CODES)


@app.middleware("http")
async def cache_and_timing(request: Request, call_next) -> Response:
    import time
    start = time.perf_counter()
    response = await call_next(request)
    elapsed_ms = (time.perf_counter() - start) * 1000
    response.headers["Server-Timing"] = f"render;dur={elapsed_ms:.0f}"
    if request.url.path.startswith(("/cog/tiles", "/mosaic/tiles")):
        response.headers["Cache-Control"] = "public, max-age=86400, s-maxage=604800"
    if elapsed_ms > 500:
        log.warning("slow tile %s %.0f ms", request.url.path, elapsed_ms)
    return response


@app.get("/healthz", include_in_schema=False)
def healthz() -> dict[str, str]:
    return {"status": "ok"}

The Server-Timing header is a small addition that pays for itself the first time somebody reports slowness: browser dev tools display it per request, so the render time is visible next to the network time and the argument about which is at fault ends immediately.


Sizing and Credentials

Public tiles, private data Viewers request tiles from a CDN, which forwards misses to the tile service. The tile service holds an instance role granting read access to a private bucket and reads byte ranges from it. The bucket itself is never public, so only rendered tiles leave the system. The bucket stays private; only tiles are public viewers request PNG tiles CDN serves 95% from cache tile service holds the instance role private bucket no public access Without authentication in front of the tile service, a public tiler on a private bucket is a public bucket. Anyone who can guess a key can request tiles of it.

That last point is the one that catches people. A tile service takes the source file as a URL parameter, so by default it will render any file its credentials can read. Restrict it: either allow only a prefix, or accept an opaque identifier that the service maps to a path internally.

import os

from fastapi import HTTPException

ALLOWED_PREFIX = os.environ["ALLOWED_PREFIX"]     # e.g. s3://example-bucket/public/


def validate_url(url: str) -> str:
    if not url.startswith(ALLOWED_PREFIX):
        raise HTTPException(status_code=403, detail="source not permitted")
    return url

Worker sizing follows from the I/O profile. A tile render spends most of its time waiting on range requests, so four to eight workers per core is the productive range; beyond that each worker’s GDAL cache adds memory without adding throughput. Budget roughly GDAL_CACHEMAX + VSI_CACHE_SIZE per worker when choosing an instance size.


Verification

import httpx

BASE = "https://tiles.example.org"


def smoke_test(sample_url: str) -> None:
    with httpx.Client(timeout=30) as c:
        assert c.get(f"{BASE}/healthz").json()["status"] == "ok"

        info = c.get(f"{BASE}/cog/info", params={"url": sample_url}).json()
        assert info["overviews"], "source has no overviews"

        r = c.get(f"{BASE}/cog/tiles/WebMercatorQuad/10/604/512.png",
                  params={"url": sample_url, "rescale": "0,3000"})
        r.raise_for_status()
        assert r.headers["content-type"] == "image/png"
        assert 2_000 < len(r.content) < 200_000, len(r.content)
        assert "max-age" in r.headers.get("cache-control", "")
        print("render time:", r.headers.get("Server-Timing"))

Running this after every deploy catches the three failures that actually happen: the credentials did not reach the container, the cache headers were lost in a configuration change, and the source archive contains files without overviews.

Cold and warm request timelines A cold request spends most of its time fetching the file header and the overview directory before any pixels are read. A warm request skips both because they are already in the VSI cache, so only the pixel reads and the render remain and the tile returns several times faster. The header fetch only happens once per file cold header + IFD pixels ~340 ms warm pixels ~60 ms byte-range reads warp, colour, encode Warming after a deploy moves the orange bar off the first real user.

Warm the service afterwards by requesting a handful of tiles across the archive’s most-viewed areas. The first request against any file pays for reading its header and overview directory, and doing that once at deploy time rather than in front of a user removes a visible stall.


Common Errors

Every request takes seconds against a large bucket

GDAL_DISABLE_READDIR_ON_OPEN is unset, so each open lists the prefix. Set it to EMPTY_DIR in the image, not in the application.

Tiles render but the CORS check fails in a browser

The allowed origins list does not include the site making the request. Set it explicitly rather than leaving the wildcard in production.

403 from the bucket despite correct credentials

The role is attached to the platform service but not passed into the container, or the region is wrong. Check by running rio info against the same URL inside the container.

Memory grows until the container is killed

Too many workers, each with its own GDAL cache. Reduce the worker count or lower GDAL_CACHEMAX; the total is roughly per-worker cache times workers.

The service renders any file it is asked for

The source URL is a free parameter and the credentials are broad. Add the allowed-prefix check above, or map opaque identifiers to paths server-side so no caller ever names a path directly.


Frequently Asked Questions

Q: How many workers should a tile server run? Four to eight per CPU core, because a tile render is dominated by waiting on byte-range reads rather than by computation. More than that multiplies GDAL’s per-worker caches without improving throughput, and can exhaust memory on a small instance.

Q: Does the service need credentials for a private bucket? Yes, and they should come from an instance role rather than environment variables wherever the platform supports it. The tile service then holds the only read access, and the bucket stays private while rendered tiles are public.

Q: Why is the first request after a deploy so slow? The GDAL file cache is empty, so headers and overview directories must be fetched before any tile can be rendered. Warming the service with a handful of requests to representative files after each deploy removes the effect for real users.

Q: Can the same service handle several archives? Yes, provided the allowed-prefix check covers each of them and the credentials grant access to all. Keeping one service for several archives is usually simpler than running several, since the caches and the warm instance are shared.