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

Every extra zoom level quadruples the pyramid For an area the size of a small country, a pyramid to zoom 10 holds about four thousand tiles, to zoom 12 about sixty-five thousand, to zoom 14 about a million and to zoom 16 about sixteen million. Storage in bytes grows the same way, but it is the object count that drives upload time, listing time and per-request pricing. Tiles in the pyramid by maximum zoom to z10 ~4 thousand to z12 ~65 thousand to z14 ~1 million to z16 ~16 million Bars are not to scale for the smallest levels — the point is the factor of four per level. Stop at the zoom that matches the source resolution; beyond it the tiles hold no new detail.

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

Most tiles around an irregular footprint are empty An irregular data footprint, such as a coastline or a country border, sits over a grid of tiles. Tiles entirely outside the footprint contain only nodata and are not uploaded. For a typical irregular area this removes a third or more of the pyramid, and map clients render the missing tiles as transparent. Only tiles touching real data are published skip skip published tiles A missing tile renders as transparent, which is exactly what an empty one would show.

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

What to check after publishing Sample tiles at each zoom must return an image with the PNG content type and a long cache header. The written plus empty plus outside counts must equal the enumerated total so nothing was silently dropped. And a tile inside the footprint at the maximum zoom must render with visible structure. Three checks after upload headers image/png, immutable counts reconcile written + empty + outside = enumerated total a real tile renders inside the footprint at max zoom A browser opening a tile as a download rather than an image means the content type is wrong.
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.