Skip to content

Generating MVT Tiles with Tippecanoe in Cloud Run

Ship tippecanoe in a two-stage Docker image, deploy it to Cloud Run with --cpu 4 --memory 8Gi --timeout 3600, and drive it from a request handler that downloads the GCS source into /tmp, runs tippecanoe -zg --drop-densest-as-needed --extend-zooms-if-still-dropping -l <layer> -o /tmp/out.mbtiles, and uploads the unpacked z/x/y.pbf pyramid with Content-Encoding: gzip. Those flags let tippecanoe pick a maxzoom from feature density and keep every tile under the ~500 KB soft ceiling, so a few-million-feature dataset tiles in single-digit minutes well inside the 60-minute request budget.

Context

The vector tile pipeline with Cloud Run and Pub/Sub recipe wires the buckets, Pub/Sub topic, and push subscription that deliver an upload event to Cloud Run. This page fills in the part that actually makes tiles: the container image, the tippecanoe command line, and the upload loop the parent recipe calls generate_tiles().

tippecanoe earns its place because it does the hard cartographic work most tile generators skip — per-zoom feature dropping, geometry simplification with topology preserved, and layer coalescing — in a single streaming pass over the input. That pass reads features once and writes the whole pyramid, so runtime tracks feature count, not zoom depth. The reason it belongs on Cloud Run rather than a source-deployed function is covered in the parent recipe: it is a compiled binary that needs a container you build, exactly the situation the multi-stage Dockerfile for GDAL on Cloud Run pattern was written for.

Prerequisites

  • Runtime: Cloud Run service, Python 3.12 base for the handler, tippecanoe 2.x compiled from source
  • Container tools: a builder stage with git, build-essential, libsqlite3-dev, and zlib1g-dev; a runtime stage with only libsqlite3-0 and zlib1g
  • Service settings: --cpu 4 --memory 8Gi --timeout 3600 --concurrency 1 so one build owns an instance with real parallelism and the full timeout
  • IAM: the Cloud Run runtime service account needs roles/storage.objectViewer on the source bucket and roles/storage.objectAdmin on the tiles bucket; the Pub/Sub invoker needs roles/run.invoker
  • Environment variables set on the service:
    code
    TILES_BUCKET=geo-vt-tiles
    TMP_DIR=/tmp
    DEFAULT_MAXZOOM=14
    
  • Dependency versions: google-cloud-storage>=2.16, flask>=3.0, and the mb-util or tile-join utility for unpacking MBTiles

Applying roles/storage.objectAdmin only to the tiles bucket, not project-wide, keeps the tiler inside the least-privilege boundary described in IAM security boundaries for cloud GIS.

Implementation

The Dockerfile compiles tippecanoe in a throwaway builder stage and copies just the binaries into a slim runtime — the same builder/runtime split used across the Docker container optimization for GIS patterns, which keeps the final image small and fast to cold-start.

Builder stage against runtime stage in the tippecanoe imageTwo panels comparing the Docker builder stage on debian bookworm-slim, which installs git, build-essential, libsqlite3-dev and zlib1g-dev to compile tippecanoe from the pinned 2.53.0 tag, with the runtime stage on python 3.12-slim, which installs only libsqlite3-0 and zlib1g and copies the compiled binaries out of the builder.What crosses the boundary between the two stagesBuilder — debian:bookworm-slimgit, build-essential, libsqlite3-dev, zlib1g-devclones tippecanoe at the pinned 2.53.0 tagmake -j$(nproc), installs into /outdiscarded entirely once the build finishesRuntime — python:3.12-slimlibsqlite3-0 and zlib1g onlytippecanoe, tile-join and tippecanoe-decode copied from/outthe Flask handler and its pinned requirementsgunicorn with a matching 3,600 s timeoutPinning the tag rather than tracking a branch is what makes the builder stage reproducible; without it a rebuild months latersilently changes tile output.
The compiler toolchain is the largest thing in the build and none of it ships. What crosses the boundary is three binaries and two shared libraries — which is the whole reason the runtime image cold-starts quickly.
dockerfile
# ---- builder stage: compile tippecanoe ----
FROM debian:bookworm-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
      git build-essential libsqlite3-dev zlib1g-dev ca-certificates \
    && rm -rf /var/lib/apt/lists/*
# Pin a release tag so builds are reproducible
RUN git clone --depth 1 --branch 2.53.0 \
      https://github.com/felt/tippecanoe.git /src
RUN make -C /src -j"$(nproc)" && make -C /src install PREFIX=/out

# ---- runtime stage: handler + tippecanoe binaries only ----
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      libsqlite3-0 zlib1g \
    && rm -rf /var/lib/apt/lists/*
# Copy the compiled binaries (tippecanoe, tile-join, tippecanoe-decode)
COPY --from=builder /out/bin/ /usr/local/bin/
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
# Cloud Run sets $PORT; gunicorn serves the Flask handler
CMD exec gunicorn --bind :$PORT --workers 1 --threads 1 --timeout 3600 app:app

The handler function the parent recipe references. It builds the tippecanoe command from feature-aware flags, streams stdout/stderr to Cloud Run logs, and uploads the unpacked pyramid.

Steps the tiler performs between the push envelope and the acknowledgementFour numbered steps inside a single Cloud Run request: download the GCS object into the tmpfs, run tippecanoe to build the mbtiles pyramid, unpack it into a z-x-y directory with tile-join, and upload each tile gzipped with an explicit content encoding and cache header.The four steps generate_tiles() runs inside one request1Download the source objectthe GCS blob lands in /tmp, which on Cloud Run is memory rather than diskgs:// to /tmp2Run tippecanoe-zg picks the maxzoom from feature density, capped at 14; --force overwrites a stale file left by aretried run-o /tmp/out.mbtiles3Unpack the pyramidtile-join explodes the single .mbtiles into a z/x/y.pbf directory for static servingtile-join -e4Upload every tilegzipped in the handler so Content-Encoding is set explicitly rather than inferredmax-age=86400
Every step writes into the same tmpfs, and nothing is streamed out until the last one — which is why the memory budget, not the CPU count, is what usually decides how large an input this service can take.
python
import os
import subprocess
from google.cloud import storage

storage_client = storage.Client()
TILES_BUCKET = os.environ["TILES_BUCKET"]
TMP = os.environ.get("TMP_DIR", "/tmp")
DEFAULT_MAXZOOM = os.environ.get("DEFAULT_MAXZOOM", "14")

def generate_tiles(src_path: str, layer: str) -> None:
    """Run tippecanoe on src_path and upload the pyramid under <layer>/."""
    mbtiles = os.path.join(TMP, f"{layer}.mbtiles")
    tile_dir = os.path.join(TMP, "tiles", layer)

    cmd = [
        "tippecanoe",
        "-o", mbtiles,
        "-l", layer,                        # stable source-layer name for the map style
        "-zg",                              # auto-select maxzoom from feature density
        "-Z", "0",                          # minzoom 0 so the whole world is addressable
        "--maximum-zoom", DEFAULT_MAXZOOM,  # hard cap; -zg will not exceed this
        "--drop-densest-as-needed",         # keep every tile under the ~500 KB soft limit
        "--extend-zooms-if-still-dropping", # add zooms rather than silently lose features
        "--coalesce-densest-as-needed",     # merge tiny adjacent features at low zoom
        "--no-tile-compression",            # we gzip ourselves at upload for header control
        "--force",                          # overwrite a stale mbtiles from a retried run
        src_path,
    ]

    # check=True raises on non-zero exit so the handler returns 500 and Pub/Sub retries
    subprocess.run(cmd, check=True)

    # Unpack the single mbtiles into a z/x/y.pbf directory for static CDN serving
    subprocess.run(
        ["tile-join", "--no-tile-compression", "-e", tile_dir, mbtiles],
        check=True,
    )

    _upload_pyramid(tile_dir, layer)

def _upload_pyramid(tile_dir: str, layer: str) -> None:
    """Upload every .pbf with gzip encoding and long-lived cache headers."""
    import gzip, io
    bucket = storage_client.bucket(TILES_BUCKET)
    for root, _dirs, files in os.walk(tile_dir):
        for name in files:
            if not name.endswith(".pbf"):
                continue
            local = os.path.join(root, name)
            # Object key: <layer>/z/x/y.pbf, preserving tippecanoe's directory layout
            rel = os.path.relpath(local, tile_dir)
            key = f"{layer}/{rel}"
            with open(local, "rb") as fh:
                raw = fh.read()
            # gzip the tile ourselves so we can set Content-Encoding explicitly
            buf = io.BytesIO()
            with gzip.GzipFile(fileobj=buf, mode="wb") as gz:
                gz.write(raw)
            blob = bucket.blob(key)
            blob.content_encoding = "gzip"
            blob.cache_control = "public,max-age=86400"
            blob.upload_from_string(buf.getvalue(), content_type="application/x-protobuf")

--no-tile-compression on both tippecanoe and tile-join hands us uncompressed .pbf bytes so the upload step controls gzip and the Content-Encoding header itself — the alternative, letting tippecanoe gzip and then setting the header manually, works but is easy to get out of sync on a retried run.

Verification

Run the container locally against a sample file before deploying, then confirm a tile decodes to real geometry.

bash
# Build and run one invocation locally with the source mounted
docker build -t vt-tiler:test .
docker run --rm -v "$PWD/sample:/tmp/in" vt-tiler:test \
  tippecanoe -o /tmp/parcels.mbtiles -l parcels -zg \
  --drop-densest-as-needed --force /tmp/in/parcels.geojson

# Inspect the pyramid metadata tippecanoe recorded
docker run --rm -v "$PWD:/data" vt-tiler:test \
  tippecanoe-decode -f /data/parcels.mbtiles 10 301 389 | head -5

Expected metadata output confirming the chosen zoom range and layer name:

code
{ "type": "FeatureCollection", "properties": { "zoom": 10, "x": 301, "y": 389 },
  "features": [
    { "type": "Feature", "id": 1, "properties": { "parcel_id": "0413-002" },
      "geometry": { "type": "Polygon", "coordinates": [ ... ] } } ] }

A non-empty features array at a mid zoom confirms tippecanoe kept geometry there rather than dropping the whole tile. If the array is empty at every zoom, the input CRS was not WGS84 — see the gotchas.

Gotchas and Edge Cases

  • Input must be EPSG:4326. tippecanoe assumes lon/lat GeoJSON and does not reproject. Feed it a projected shapefile and every tile comes out empty because the coordinates fall outside the web-mercator tile grid. Reproject to WGS84 with ogr2ogr -t_srs EPSG:4326 before tiling, or convert GeoParquet through GDAL first; the native library compilation guide covers getting a working GDAL into the same image.
  • tmpfs is memory on Cloud Run. The .mbtiles file, the unpacked directory, and the source object all live in /tmp, which is RAM, so a 3 GB intermediate on an 8 GB instance can trigger an OOM kill. Size the instance to roughly 2–3x the largest intermediate, or stream-delete the source file before unpacking. The ephemeral storage comparison across serverless platforms explains why this differs sharply from Lambda’s disk-backed /tmp.
  • --drop-densest-as-needed silently thins dense areas. It is the right default for keeping tiles small, but on point-heavy data it can drop enough features that a city looks sparse at low zoom. Pair it with --extend-zooms-if-still-dropping so dense clusters gain an extra zoom level instead of losing points, and spot-check the densest tile with tippecanoe-decode.
  • A retried Pub/Sub delivery reuses the same /tmp. If a previous run left a partial .mbtiles, tippecanoe refuses to overwrite it without --force. The flag is in the command above; without it, every retry after a crash fails on “file exists.”
Tile-build scratch measured against the Cloud Run instanceThree meters for the tiler instance: 8 gibibytes of instance memory against the 32 gibibyte Cloud Run ceiling, a 3 gibibyte intermediate mbtiles file held in the tmpfs of that 8 gibibyte instance, and the two-to-three times headroom rule of thumb, which asks for 6 to 9 gibibytes and therefore exceeds the instance as configured.Everything in /tmp is charged to instance memoryCloud Run instance--memory 8Gi of a 32 GiB max8 GiBIntermediate .mbtilesheld in tmpfs, so in RAM3 GiBHeadroom rule of thumb2–3x the largest intermediate6–9 GiBThe source object, the .mbtiles and the unpacked z/x/y directory all occupy the same tmpfs at once, so deleting the source before unpackingbuys back real headroom rather than tidiness.
The third meter is over its own limit, and that is the point: at a 3 GiB intermediate the rule of thumb already asks for more than the 8 GiB instance provides, which is what an exit code 137 looks like before it happens.

Frequently Asked Questions

What tippecanoe flags produce sensible web-map tiles?

Start with -zg to let tippecanoe pick a maxzoom from feature density, --drop-densest-as-needed to hold tiles under the ~500 KB soft limit, --extend-zooms-if-still-dropping so dense areas gain zoom levels instead of losing features, and -l to force a stable layer name your map style can reference. Add --coalesce-densest-as-needed for polygon-heavy data. Only reach for --detail 12 when you need sub-pixel precision, since higher detail inflates every tile.

How does tippecanoe fit inside the Cloud Run 60-minute timeout?

tippecanoe reads features once and writes the pyramid in a streaming pass, so wall-clock time scales with feature count rather than the number of zoom levels. A few million features tile in single-digit minutes on 4 vCPU. Anything projected to exceed 60 minutes should be pre-split by region and the resulting .mbtiles merged with tile-join, the same partition-and-merge logic as chunking raster jobs to fit the 15-minute Lambda ceiling.

Should tippecanoe write MBTiles or a directory of PBF tiles?

Write .mbtiles with -o for a single portable artifact that is easy to move and version, then unpack it to a z/x/y.pbf directory with tile-join -e for static CDN serving. Serving directly from .mbtiles needs a dynamic tile server, which defeats the CDN-fronted static design of this pipeline.


Back to Vector Tile Pipeline with Cloud Run and Pub/Sub