Publishing Static Tiles to Object Storage
Render each tile once, skip the empty ones, and upload with the right headers:
import boto3
from rio_tiler.io import Reader
s3 = boto3.client("s3")
with Reader("web_copy.tif") as cog:
img = cog.tile(x, y, z, tilesize=256)
if img.mask.any(): # skip all-nodata tiles
s3.put_object(Bucket="example-tiles", Key=f"ndvi/v3/{z}/{x}/{y}.png",
Body=img.render(img_format="PNG"), ContentType="image/png",
CacheControl="public, max-age=31536000, immutable")
A static pyramid needs no server at all — the map points straight at the bucket prefix. This page belongs to preparing rasters for the web in Visualization, Tiling & Web Delivery.
The Object Count Is the Real Cost
Storage bytes are usually cheap; per-object costs are not. Uploading a million small objects takes hours even in parallel, listing them is slow, request pricing applies to each, and deleting an old version is its own batch job. Those costs, rather than the gigabytes, are what make static pyramids a considered choice rather than a default.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rio-tiler |
>=6.4 |
Rendering individual tiles from an aligned COG |
morecantile |
>=5.0 |
Enumerating tiles over the data footprint |
boto3 |
>=1.34 |
Uploading to S3-compatible storage |
pip install "rio-tiler>=6.4" "morecantile>=5.0" "boto3>=1.34"
Complete Working Example
from concurrent.futures import ThreadPoolExecutor, as_completed
import boto3
import morecantile
from rio_tiler.errors import TileOutsideBounds
from rio_tiler.io import Reader
TMS = morecantile.tms.get("WebMercatorQuad")
def publish_pyramid(cog_path: str, bucket: str, prefix: str, *,
min_zoom: int, max_zoom: int,
rescale: tuple[float, float], colormap: dict | None = None,
workers: int = 32) -> dict[str, int]:
s3 = boto3.client("s3")
stats = {"written": 0, "empty": 0, "outside": 0}
with Reader(cog_path) as cog:
tiles = [t for z in range(min_zoom, max_zoom + 1)
for t in TMS.tiles(*cog.geographic_bounds, [z])]
def render_and_put(t) -> str:
try:
img = cog.tile(t.x, t.y, t.z, tilesize=256)
except TileOutsideBounds:
return "outside"
if not img.mask.any(): # nothing valid in this tile
return "empty"
png = img.post_process(in_range=(rescale,)).render(
img_format="PNG", colormap=colormap)
s3.put_object(
Bucket=bucket, Key=f"{prefix}/{t.z}/{t.x}/{t.y}.png", Body=png,
ContentType="image/png",
CacheControl="public, max-age=31536000, immutable",
)
return "written"
with ThreadPoolExecutor(max_workers=workers) as pool:
for fut in as_completed(pool.submit(render_and_put, t) for t in tiles):
stats[fut.result()] += 1
return stats
The immutable cache directive and a one-year lifetime are correct only because the prefix carries a version. Publishing a reprocessed product under a new prefix — ndvi/v4/ rather than overwriting ndvi/v3/ — is what makes the long lifetime safe, for the reasons set out in caching and CDN strategies for raster tiles.
Skipping Empty Tiles
img.mask.any() is the test: the tile’s mask is true wherever there is valid data, so a tile with no true values is pure nodata and can be dropped. For a coastline, a country, or any product clipped to an irregular area of interest, this commonly removes 30–60% of the pyramid, with a proportional saving in upload time and object count.
Tiles at the very edge of the footprint contain some data and some nodata. They must be uploaded, and they must carry transparency where the nodata is — which is why the PNG format and the alpha channel matter here as much as they do for thumbnails.
Pointing a Map at the Bucket
Once published, the pyramid is consumed with nothing more than a template URL. There is no server, no configuration and nothing to keep running:
import leafmap
m = leafmap.Map(center=(0.35, 34.75), zoom=10)
m.add_tile_layer("https://example-tiles.s3.amazonaws.com/ndvi/v3/{z}/{x}/{y}.png",
name="NDVI 2026", attribution="Sentinel-2", max_zoom=14)
m
Setting max_zoom to the pyramid’s top level stops the client requesting tiles that do not exist; most libraries will then over-zoom the top level instead, which is the correct behaviour for data that has no finer detail to show. The same template works in any web map library and in a desktop GIS as an XYZ connection, and it keeps working as long as the bucket exists — which makes static pyramids a good fit for long-lived published maps.
Verification
import httpx
for z, x, y in [(10, 604, 512), (12, 2419, 2049), (14, 9678, 8196)]:
r = httpx.get(f"https://example-tiles.s3.amazonaws.com/ndvi/v3/{z}/{x}/{y}.png")
if r.status_code == 404:
continue # empty tiles are legitimately missing
r.raise_for_status()
assert r.headers["content-type"] == "image/png"
assert "immutable" in r.headers.get("cache-control", "")
A 404 is expected for skipped tiles, which is why the check tolerates it; what must not happen is a 200 with the wrong content type, which makes browsers download the tile instead of drawing it.
Common Errors
Browsers download tiles instead of showing them
The objects were uploaded with the default binary content type. Set ContentType="image/png" on upload, for every object.
Updated tiles never appear
The prefix was overwritten and the cache is serving the old ones for a year. Publish under a new versioned prefix.
The upload takes days
The pyramid goes one or two zooms too deep, or empty tiles are being uploaded. Stop at the source resolution’s zoom and skip empties; each level removed divides the remaining work by four.
Edges of the footprint render black
Tiles were rendered without the alpha channel. Render as PNG with the mask applied so partial tiles are transparent outside the data.
Frequently Asked Questions
Q: How many tiles will a pyramid produce? Roughly four-thirds of the tile count at the maximum zoom, because each lower level has a quarter as many. A country-sized area at zoom 14 is on the order of a million tiles, so the object count, not the bytes, is what to plan for.
Q: Should empty tiles be uploaded? No. Skipping tiles that contain only nodata usually removes a third or more of the pyramid for an irregular footprint. Map clients treat a missing tile as transparent, which is exactly what an empty tile would have shown.
Q: When is this better than a dynamic tile server? When the content is fixed and either traffic is very high or no server can run at all — a published basemap, a map embedded in a report site, or an offline deployment. For anything that changes, dynamic tiling is simpler.
Q: Can a static pyramid be packed into one file instead? Yes, and for offline use it often should be: single-file tile archive formats hold the whole pyramid in one object that a client reads by byte range. That removes the object-count problem entirely, at the cost of needing a client or small shim that understands the format.
Related
- Preparing Rasters for the Web — static against dynamic, and when each wins.
- Generating Web Mercator Tile Pyramids — the aligned source these tiles are cut from.
- Serving Raster Tiles with TiTiler — the dynamic alternative.
- Reducing S3 Egress Costs in Raster Pipelines — the storage-side costs a busy pyramid incurs.