CI/CD Tile Build Automation

A tile build that lives in one engineer’s shell history is a liability: the flags drift, the tippecanoe version floats, and nobody can reproduce last quarter’s tileset when a rendering bug appears. Promoting the build into CI/CD turns tile generation into a versioned, gated artifact pipeline — the same source hash always yields the same PMTiles archive, every publish is auditable, and an oversized tile fails the build instead of shipping to production. This guide covers the build DAG, a source-hashed Makefile, a containerized tippecanoe image, the verification gate, and the versioned publish step that hands off to CDN delivery.


CI tile build pipeline DAG Five sequential boxes — fetch, validate, build, verify, publish — connected by arrows. The verify box has a downward fail branch to a rejected-build box. ① Fetch source data ② Validate schema + CRS ③ Build tippecanoe ④ Verify size gate ⑤ Publish versioned URL fail build fails nothing shipped

Prerequisites

Requirement Detail Notes
Container image tippecanoe + tile-join + pmtiles CLI at pinned versions One image tag per tool set; never latest in production
Source data GeoParquet/GeoJSON in object storage or DVC Content-addressable so the build can hash inputs
CI runner 4 GB RAM (city z14), 16 GB+ (national) Memory ceiling drives the runner tier you pay for
Deploy credentials R2 or S3 token scoped to the tiles bucket Stored as CI secrets, never in the repo
Make GNU Make 4.0+ Drives the target DAG and hash short-circuiting
pmtiles CLI 1.19+ pmtiles verify and pmtiles show for the gate

The pipeline assumes upstream data has already been cleaned by the GeoParquet input processing stage — valid geometries, EPSG:4326, pruned attributes. CI/CD tile automation is the stage that takes that deterministic input and produces a signed, versioned tileset; it does not repair data.

Core Concept: The Build DAG and Idempotency

A reproducible tile build is a directed acyclic graph where every node is a file whose existence and freshness are decided by the content hash of its inputs, not a wall-clock timestamp. Three properties make it deterministic:

  1. Pinned tool versions. tippecanoe’s simplification and feature-dropping output can change between releases. Pin the image digest so the same source always compiles to byte-identical tiles.
  2. Content-hashed inputs. Compute a SHA-256 of each source file (or read the object storage ETag). The hash becomes part of the artifact name and the Make target’s prerequisite, so a build reruns only when the bytes change.
  3. Gated promotion. Nothing reaches the CDN until the verification step passes. A failed gate leaves the previous version live.

The canonical target chain, expressed as a Makefile DAG:

Target Depends on Command Output
fetch source hash aws s3 cp / wrangler r2 object get build/source.geoparquet
validate fetch schema + CRS check script build/.validated
tiles validate tippecanoe production flags build/tiles.pmtiles
join tiles tile-join merge/filter build/merged.pmtiles
convert join already PMTiles, or pmtiles convert from .mbtiles build/final.pmtiles
verify convert pmtiles verify + size gate build/.verified
publish verify upload to versioned prefix + flip pointer remote artifact

Because each target’s output is a file and each edge is a prerequisite, make publish rebuilds only the subgraph downstream of whatever changed. A source that is byte-identical to the last run short-circuits to a no-op.

Step-by-Step Implementation

Step 1 — A pinned Docker image with tippecanoe

Build tippecanoe from a tagged source commit rather than installing the distro package, which is almost always years out of date. Pin the base image by digest and record the tippecanoe version in a label so the artifact is traceable.

dockerfile
# Dockerfile.tiles
FROM debian:12.6-slim@sha256:5f7d5664eae4a192c2d2d6cb67fc3f3c7891a8722cc2b0b2f5e3d... AS build
ARG TIPPECANOE_REF=2.53.0
RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential git zlib1g-dev libsqlite3-dev curl ca-certificates \
    && git clone --depth 1 --branch ${TIPPECANOE_REF} \
       https://github.com/felt/tippecanoe.git /src \
    && make -C /src -j"$(nproc)" && make -C /src install \
    && curl -sSL -o /usr/local/bin/pmtiles \
       https://github.com/protomaps/go-pmtiles/releases/download/v1.22.1/pmtiles-linux-amd64 \
    && chmod +x /usr/local/bin/pmtiles

LABEL org.opencontainers.image.title="tile-builder" \
      tippecanoe.version="${TIPPECANOE_REF}"
ENTRYPOINT ["/bin/bash"]

Verify: docker run --rm tile-builder tippecanoe --version prints the pinned ref, and tile-join --version and pmtiles version both resolve. Publish this image to your registry once and reference it by digest from CI.

Step 2 — A Makefile with source-hashed targets

The Makefile turns the DAG into short-circuiting targets. The source hash is a stamp file whose contents change only when the input bytes change, so downstream targets rebuild exactly when they must.

makefile
# Makefile
SHELL       := /bin/bash
BUILD       := build
SRC         := $(BUILD)/source.geoparquet
HASH        := $(BUILD)/source.sha256
LAYER       := features
MAXZOOM     := 14
VERSION     := $(shell cat $(HASH) 2>/dev/null | cut -c1-12)
FINAL       := $(BUILD)/tiles-$(VERSION).pmtiles

$(BUILD):
	mkdir -p $(BUILD)

$(SRC): | $(BUILD)
	aws s3 cp s3://tiles-src/latest/source.geoparquet $@

$(HASH): $(SRC)
	sha256sum $(SRC) | cut -d' ' -f1 > $@

$(FINAL): $(SRC) $(HASH)
	tippecanoe --output=$@ --force \
	  --layer=$(LAYER) \
	  --minimum-zoom=0 --maximum-zoom=$(MAXZOOM) \
	  --drop-densest-as-needed \
	  --coalesce-smallest-as-needed \
	  --extend-zooms-if-still-dropping \
	  --simplification=4 \
	  --read-parallel \
	  $(SRC)

.PHONY: tiles verify publish
tiles: $(FINAL)

Verify: running make tiles twice in a row with an unchanged source performs the tippecanoe compile once; the second invocation reports Nothing to be done for 'tiles' because $(FINAL) is newer than its prerequisites.

Step 3 — Tile generation with production flags

The $(FINAL) recipe already carries the production flag set, but the choices matter. --drop-densest-as-needed and --coalesce-smallest-as-needed keep every tile under the 500 KB budget; --extend-zooms-if-still-dropping raises the max zoom automatically when dropping alone cannot fit the data; --read-parallel exploits the spatial ordering the ingestion stage produced. The full rationale for each flag lives in essential tippecanoe flags for production builds, which the CI recipe should mirror exactly so local and CI builds never diverge.

When a tileset merges several layers built separately, compile each layer into its own PMTiles archive and stitch them with tile-join:

bash
tile-join --output=build/merged.pmtiles --force \
  --no-tile-size-limit \
  build/roads.pmtiles build/buildings.pmtiles build/landuse.pmtiles

tile-join deduplicates the shared metadata and produces a single archive the delivery stage can serve. Keep --no-tile-size-limit off the generation step — you want the size gate in step 4 to catch overflow — and only relax it on the join when you have already verified the constituent tiles individually.

Step 4 — A verification gate that fails the build

The gate is the reason this is CI/CD and not a cron job. It runs after convert and exits non-zero on any violation, which stops the pipeline before publish. Two checks matter most: maximum tile size and layer schema.

bash
#!/usr/bin/env bash
# ci/verify_tiles.sh
set -euo pipefail
ARCHIVE="$1"
MAX_BYTES=500000
EXPECT_LAYER="features"

pmtiles verify "$ARCHIVE"

# Size gate — largest tile across all zooms must fit the budget
MAX_TILE=$(pmtiles show "$ARCHIVE" | grep -oP 'max.?tile.?size[:\s]+\K[0-9]+')
if [ "$MAX_TILE" -gt "$MAX_BYTES" ]; then
  echo "FAIL: max tile ${MAX_TILE} B exceeds ${MAX_BYTES} B budget" >&2
  exit 1
fi

# Schema gate — the layer name must match what the style spec expects
if ! pmtiles show "$ARCHIVE" | grep -q "\"${EXPECT_LAYER}\""; then
  echo "FAIL: layer '${EXPECT_LAYER}' missing from tileset" >&2
  exit 1
fi

echo "PASS: max tile ${MAX_TILE} B, layer '${EXPECT_LAYER}' present"

Verify: feed the script a deliberately oversized tileset (build with --no-tile-size-limit and a high zoom on dense data) and confirm it exits 1. The schema gate is the automated equivalent of the source-layer check enforced by style validation workflows; running both in the same pipeline prevents a tileset from shipping with a layer name the style cannot bind to.

Step 5 — Publish to a versioned prefix and flip the style pointer

Publishing is two moves: upload the immutable artifact to a version-stamped path, then atomically repoint the style so clients start requesting the new build. Never overwrite an existing tile URL in place — that forces a cache purge and risks serving a half-updated mix.

bash
#!/usr/bin/env bash
# ci/publish.sh
set -euo pipefail
ARCHIVE="$1"                       # build/tiles-<hash>.pmtiles
VERSION=$(basename "$ARCHIVE" .pmtiles | sed 's/tiles-//')
KEY="tiles/features/${VERSION}/features.pmtiles"

# 1. Upload to the immutable, versioned prefix
aws s3 cp "$ARCHIVE" "s3://prod-tiles/${KEY}" \
  --cache-control "public, max-age=31536000, immutable" \
  --content-type "application/octet-stream"

# 2. Flip the style pointer to the new version (small, short-TTL object)
jq --arg u "https://cdn.example.com/${KEY}" \
  '.sources.features.url = "pmtiles://" + $u' \
  style/base.json > style/base.new.json
aws s3 cp style/base.new.json "s3://prod-tiles/style/base.json" \
  --cache-control "public, max-age=60"
echo "Published ${VERSION}; style now points at ${KEY}"

The tileset object is immutable and cached for a year; the tiny style JSON that names the current version carries a 60-second TTL so the flip propagates fast. This split — long-lived content-addressed tiles, short-lived pointer — is the mechanism behind versioned tile URL rotation without cache purges.

Optimization Knobs

Knob Conservative Aggressive Trade-off
Rebuild scope full dataset every run changed bbox/region only Incremental: minutes not hours, but needs a tile-join merge with the untouched regions
Build matrix one job, all layers serial one job per layer, fan-out Matrix cuts wall-clock time; costs more runner minutes and a join step
Source caching re-download every run cache by content hash Cached download saves egress + time; stale-cache risk if the hash key is wrong
tippecanoe parallelism single-threaded --read-parallel on sorted input Parallel read is faster only when input is spatially ordered; random input thrashes
Runner tier 16 GB always size the tier to the layer Right-sizing per matrix leg saves money; a too-small leg OOMs mid-build

Incremental region rebuilds are the highest-leverage knob for large tilesets. Split the world into stable tiles-by-region archives, rebuild only the region whose source changed, and tile-join it back over the unchanged regions. The source hash in step 2 makes this safe: if a region’s bytes are unchanged, its target short-circuits and the old archive is reused verbatim.

Integration with Adjacent Pipeline Stages

This stage sits between ingestion and delivery. It consumes the cleaned output of GeoParquet input processing — the fetch and validate targets expect a file that is already EPSG:4326, valid, and pruned. It emits a versioned PMTiles archive plus the container image the delivery stage serves from object storage.

The connective tissue is the schema hash. The content hash that names the artifact (tiles-<hash>.pmtiles) becomes the version segment in the published URL, which is what makes versioned tile URL rotation work without purges: a new hash means a new URL means no cache collision. On the styling side, the schema gate in step 4 is the CI counterpart to style validation workflows — the same layer-name contract enforced from both the tile side and the style side, so a rename on either end fails a build rather than silently rendering nothing.

Troubleshooting

1. Nondeterministic output — same source, different tiles

Two runs of the identical GeoParquet produce archives with different byte sizes or feature counts.

bash
# Confirm the tippecanoe version differs between runs
docker inspect --format '{{ index .Config.Labels "tippecanoe.version" }}' tile-builder

The cause is almost always an unpinned tool or floating base image (tippecanoe:latest). Pin the image by digest as in step 1, and pin the tippecanoe source ref. If output still varies with feature ordering, the input is not spatially sorted — restore the deterministic sort from the ingestion stage so --read-parallel sees stable input.

2. OOM kill on the runner

The build dies with Killed and no stack trace, or the CI log ends abruptly mid-compile.

bash
/usr/bin/time -v tippecanoe --output=/tmp/t.pmtiles --layer=features \
  --maximum-zoom=8 build/source.geoparquet 2>&1 | grep "Maximum resident"

tippecanoe holds the full input in RAM during the sort phase. Either move that matrix leg to a larger runner, drop --maximum-zoom by one to two levels, or switch to an incremental per-region build so no single job loads the whole dataset. Set ulimit -v in the job so exhaustion surfaces as a clear error instead of a silent kill.

3. Oversized tile fails the gate

verify_tiles.sh exits 1 with max tile NNNNNN B exceeds 500000 B budget.

bash
pmtiles show build/tiles-*.pmtiles | grep -i 'max.*tile.*size'

This is the gate working as designed — do not raise MAX_BYTES to make it pass. Add or tighten --drop-densest-as-needed, tighten the --include allowlist to drop a high-cardinality text attribute, or lean on controlling tile size with drop and coalesce flags. Rebuild and re-gate before publishing.

4. Publish race — two builds flip the pointer at once

A scheduled rebuild and a push-triggered rebuild both reach publish and the style pointer ends up naming the older of the two.

Add a concurrency guard so only one publish runs at a time and newer runs cancel older ones. In GitHub Actions that is a concurrency: group on the workflow; in a self-hosted orchestrator, a lock object in the tiles bucket taken before the pointer flip. Because the tile artifacts themselves are immutable and content-addressed, the race is only ever about which pointer wins — the guard makes that resolution deterministic rather than last-writer-wins.

Further Reading

Automating Tippecanoe Builds with GitHub Actions — a complete .github/workflows/tiles.yml that installs tippecanoe, builds a versioned PMTiles archive, gates on tile size, and publishes to Cloudflare R2 or S3 on every data change, including the container job, caching, secrets, and the size-gate step.


Parent: Automated Generation Pipelines with Tippecanoe

  • GeoParquet Input Processing — the ingestion stage that produces the deterministic, EPSG:4326 input this pipeline’s fetch and validate targets consume.
  • Tippecanoe CLI Fundamentals — the flag inventory the build recipe mirrors, so local and CI compiles never diverge on simplification or feature-dropping behaviour.
  • Tile Serving & CDN Delivery — where the versioned PMTiles artifact and its immutable URL are served, including cache-header and range-request delivery patterns.
Next reading Automating Tippecanoe Builds with GitHub Actions