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,
tippecanoe2.x compiled from source - Container tools: a builder stage with
git,build-essential,libsqlite3-dev, andzlib1g-dev; a runtime stage with onlylibsqlite3-0andzlib1g - Service settings:
--cpu 4 --memory 8Gi --timeout 3600 --concurrency 1so one build owns an instance with real parallelism and the full timeout - IAM: the Cloud Run runtime service account needs
roles/storage.objectVieweron the source bucket androles/storage.objectAdminon the tiles bucket; the Pub/Sub invoker needsroles/run.invoker - Environment variables set on the service:
TILES_BUCKET=geo-vt-tiles TMP_DIR=/tmp DEFAULT_MAXZOOM=14 - Dependency versions:
google-cloud-storage>=2.16,flask>=3.0, and themb-utilortile-joinutility 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: 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.
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.
# 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:
{ "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.
tippecanoeassumes 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 withogr2ogr -t_srs EPSG:4326before 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
.mbtilesfile, 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-neededsilently 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-droppingso dense clusters gain an extra zoom level instead of losing points, and spot-check the densest tile withtippecanoe-decode.- A retried Pub/Sub delivery reuses the same
/tmp. If a previous run left a partial.mbtiles,tippecanoerefuses to overwrite it without--force. The flag is in the command above; without it, every retry after a crash fails on “file exists.”
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.
Related
- Vector Tile Pipeline with Cloud Run and Pub/Sub — the buckets, Pub/Sub wiring, and push-subscription contract this handler plugs into
- Multi-Stage Dockerfile for GDAL on Cloud Run — the builder/runtime split behind the tippecanoe image
- Native Library Compilation for Serverless — compiling tippecanoe and a matching GDAL for reprojection against the right ABI
- Ephemeral Storage Comparison Across Serverless Platforms — why Cloud Run tmpfs counts against memory during large tile builds
- Docker Container Optimization for GIS — trimming the runtime image so cold starts stay fast