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.
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:
- 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.
- 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.
- 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.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
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:
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.
#!/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.
#!/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.
# 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.
/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.
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
Related
- GeoParquet Input Processing — the ingestion stage that produces the deterministic, EPSG:4326 input this pipeline’s
fetchandvalidatetargets 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.