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
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
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.
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.
Related
- Serving Raster Tiles with TiTiler — the parent topic on tiling, mosaics and caching.
- Caching and CDN Strategies for Raster Tiles — the layer in front of this service.
- Building a Slim GDAL Docker Image — keeping the image small and reproducible.
- Caching PROJ Data and GDAL Config in Containers — the configuration approach this deployment follows.