Automating Tippecanoe Builds with GitHub Actions

A single .github/workflows/tiles.yml file is enough to install tippecanoe, build a versioned PMTiles archive, gate it on tile size, and publish it to Cloudflare R2 or S3 — triggered automatically whenever the source data changes or on a schedule. This page gives you that workflow in full and explains the anatomy that keeps it deterministic.

When to use this

Reach for a GitHub Actions tile build when the source data (or a pointer to it) lives in the same repository, or when you want scheduled rebuilds against data in object storage on a fixed cadence. It is the lightest path to the gated, versioned pipeline described in the parent CI/CD tile build automation guide — no separate orchestrator, just a runner, a container, and repository secrets. The two triggers cover the two realistic change sources: a push on the data paths rebuilds the moment an engineer commits new source, while the weekly schedule cron catches upstream data that lives in object storage and changes without a commit. If your dataset is multi-gigabyte and rebuilds take longer than a runner’s timeout, move the heavy compile to a self-hosted runner or a dedicated build box and keep only the gate and publish in Actions — the workflow structure is identical, only the runs-on target changes.

Specification detail: workflow anatomy

Element Value Why
Triggers push on data paths + schedule cron Rebuild on data change and on a regular cadence
Job container pinned tippecanoe image Avoids the outdated apt package; reproducible output
Cache source download keyed by hash Skips re-fetching unchanged input
Secrets R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (or AWS equivalents) Scoped deploy creds, never in the repo
Size gate shell step, exit 1 over 500 KB Blocks oversized tilesets before publish
Concurrency group + cancel-in-progress Prevents two publishes racing the pointer

The build runs inside a container: so tippecanoe, tile-join, and pmtiles are all present at pinned versions; the runner’s own OS never touches the tile toolchain.

Production workflow

The full .github/workflows/tiles.yml. It fetches the source, builds with production flags, runs a size gate that exits non-zero over 500 KB, and publishes to a versioned R2 prefix (an S3 variant follows in the comments).

yaml
name: Build vector tiles

on:
  push:
    paths:
      - 'data/**/*.geoparquet'
      - 'data/**/*.geojson'
  schedule:
    - cron: '0 4 * * 1'   # Weekly Monday 04:00 UTC

concurrency:
  group: tiles-publish
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/felt/tippecanoe:2.53.0   # pinned, never :latest
    env:
      LAYER: features
      MAXZOOM: '14'
      MAX_BYTES: '500000'
    steps:
      - uses: actions/checkout@v4

      - name: Install pmtiles CLI
        run: |
          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

      - name: Cache source download
        id: srccache
        uses: actions/cache@v4
        with:
          path: build/source.geoparquet
          key: src-${{ hashFiles('data/source.ref') }}

      - name: Fetch source
        if: steps.srccache.outputs.cache-hit != 'true'
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
        run: |
          mkdir -p build
          aws s3 cp s3://tiles-src/latest/source.geoparquet build/source.geoparquet \
            --endpoint-url "${{ secrets.R2_ENDPOINT }}"

      - name: Compute version
        id: ver
        run: echo "hash=$(sha256sum build/source.geoparquet | cut -c1-12)" >> "$GITHUB_OUTPUT"

      - name: Build tiles
        run: |
          tippecanoe --output=build/tiles.pmtiles --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 \
            build/source.geoparquet

      - name: Size gate
        run: |
          pmtiles verify build/tiles.pmtiles
          MAX_TILE=$(pmtiles show build/tiles.pmtiles \
            | grep -oP 'max.?tile.?size[:\s]+\K[0-9]+')
          echo "Largest tile: ${MAX_TILE} bytes"
          if [ "$MAX_TILE" -gt "$MAX_BYTES" ]; then
            echo "::error::tile ${MAX_TILE} B exceeds ${MAX_BYTES} B budget"
            exit 1
          fi

      - name: Publish to versioned prefix
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          VERSION: ${{ steps.ver.outputs.hash }}
        run: |
          KEY="tiles/${LAYER}/${VERSION}/${LAYER}.pmtiles"
          # Cloudflare R2 (S3-compatible endpoint)
          aws s3 cp build/tiles.pmtiles "s3://prod-tiles/${KEY}" \
            --endpoint-url "${{ secrets.R2_ENDPOINT }}" \
            --cache-control "public, max-age=31536000, immutable" \
            --content-type "application/octet-stream"
          # AWS S3 variant: drop --endpoint-url and use AWS_* secrets
          # wrangler variant:
          #   wrangler r2 object put "prod-tiles/${KEY}" \
          #     --file=build/tiles.pmtiles \
          #     --cache-control="public, max-age=31536000, immutable"
          echo "Published ${KEY}"

The gate step sits between build and publish deliberately: exit 1 there stops the job before the publish step ever runs, so a failed build leaves the previous version live on the CDN.

Interaction effects

The version hash produced by the Compute version step is what makes versioned tile URL rotation without cache purges work: each new source hash publishes to a fresh, immutable key, so the year-long Cache-Control is safe and a rotation never needs a purge. Pair the size gate here with a style-layer check — the same source-layer contract enforced by style validation workflows — running both gates in the workflow catches a layer rename from either the tile or the style side before it ships. The MAX_BYTES budget is the same 500 KB ceiling the flag set in essential tippecanoe flags for production builds is tuned to hold; if the gate trips, adjust the flags, not the budget.

Performance impact

Runner build time is dominated by the tippecanoe compile, which is roughly linear in feature count and holds the full input in RAM during its sort phase — a city-scale z14 build finishes in a few minutes on a standard ubuntu-latest runner (7 GB RAM, 2 vCPU), while a national road network can exceed that RAM ceiling and needs a larger self-hosted runner. The size gate step adds only seconds: pmtiles verify and pmtiles show read the archive’s directory index, not every tile. Caching the source download by content hash removes repeated egress and shaves the fetch entirely when the input is unchanged, which for a large GeoParquet source is often the single biggest wall-clock saving in the whole workflow. For multi-layer tilesets, a strategy.matrix with one job per layer runs the compiles in parallel across runners and cuts wall-clock time proportionally, at the cost of a follow-up tile-join step and more runner minutes; keep it a single job while the whole build fits comfortably inside one runner’s timeout.

Common mistakes

1. Installing tippecanoe from apt. apt-get install tippecanoe on Ubuntu pulls a release that is often years old and produces different simplification and dropping output than current tippecanoe — builds silently diverge from local.

yaml
# Wrong: unpinned, outdated
- run: apt-get update && apt-get install -y tippecanoe
# Right: pinned container image, as in the workflow above
container:
  image: ghcr.io/felt/tippecanoe:2.53.0

Build from a tagged source ref or use a pinned image; never rely on the distro package for a production tile build.

2. Missing or mis-scoped secrets. The publish step fails with Unable to locate credentials or AccessDenied. The secret names in the workflow must match the repository or environment secrets exactly, and the R2/S3 token must be scoped to write the tiles bucket. Confirm with a dry aws s3 ls s3://prod-tiles --endpoint-url "$R2_ENDPOINT" step before the first real publish.

3. Publishing before the gate. Ordering the aws s3 cp step ahead of the size check means an oversized tileset reaches the CDN and only then does the job fail — the damage is already live. Always place the size gate before the publish step so exit 1 aborts the job first.

4. No concurrency guard. Without the concurrency: block, a scheduled run and a push-triggered run can publish at the same time and the older build can win the pointer flip. The group plus cancel-in-progress: true serializes publishes and cancels superseded runs.


Parent: CI/CD Tile Build Automation