Publishing Tilesets to R2 from a CI Job

Upload the archive under a new version prefix, verify it answers a range request at its public URL, and only then rewrite the style to point at it. That ordering is the whole publish step, and getting it wrong is what produces a map that is blank for the two minutes an upload takes.

When to Use This

Any pipeline where a CI job produces the tileset that readers see. R2 is the common target because egress to the CDN is free, which matters for a workload that is almost entirely egress, but the shape below applies unchanged to S3 or any S3-compatible store.

Specification Detail

Setting Value Why
Object key tiles/v{hash}/basemap.pmtiles The version prefix is what makes rotation possible
Content-Type application/octet-stream Anything compressible invites a body transform that breaks range reads
Cache-Control public, max-age=31536000, immutable The object at this key never changes
Credentials R2 API token, scoped to one bucket A CI token that can delete other buckets is a liability
Upload mode Multipart for archives over ~100 MB Single-part uploads of several GB time out

The version hash should cover everything that changes the tiles — the source snapshot, the flag set, the Tippecanoe version and the layer schema — which is the same key an incremental build already computes.

Upload, verify, then flipThe CI job uploads the archive to a new prefix, issues a range request against the public URL to confirm it is readable, and only then rewrites the style document.CI jobR2 bucketPublic URLPUT tiles/v43/basemap.pmtiles200, ETagGET with Range: bytes=0-99206 + Content-Rangeproves the CDN path works, not just the bucketPUT style.json naming v43200 - readers migrate as their style TTL expires
The verification step is what makes the flip safe. Without it the style can point at an object that is still uploading, and every reader sees a blank map.

Production Command

yaml
# .github/workflows/tiles.yml (publish job)
publish:
  needs: build
  if: github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4

    - uses: actions/download-artifact@v4
      with: { name: tileset, path: dist }

    - name: Compute the version prefix
      id: version
      run: |
        VERSION=$(sha256sum dist/basemap.pmtiles | cut -c1-12)
        echo "prefix=v$VERSION" >> "$GITHUB_OUTPUT"

    - name: Upload the archive
      env:
        AWS_ACCESS_KEY_ID: $
        AWS_SECRET_ACCESS_KEY: $
        AWS_DEFAULT_REGION: auto
      run: |
        aws s3 cp dist/basemap.pmtiles \
          "s3://tiles/$/basemap.pmtiles" \
          --endpoint-url "https://$.r2.cloudflarestorage.com" \
          --content-type "application/octet-stream" \
          --cache-control "public, max-age=31536000, immutable"

    - name: Verify it is readable over the public URL
      run: |
        URL="https://tiles.example.com/$/basemap.pmtiles"
        STATUS=$(curl -s -o /dev/null -w '%{http_code}' -r 0-99 "$URL")
        [ "$STATUS" = "206" ] || { echo "expected 206, got $STATUS"; exit 1; }
        LOCAL=$(stat -c%s dist/basemap.pmtiles)
        REMOTE=$(curl -sI -r 0-0 "$URL" | grep -i content-range | sed 's|.*/||' | tr -d '\r')
        [ "$LOCAL" = "$REMOTE" ] || { echo "size mismatch: $LOCAL vs $REMOTE"; exit 1; }

    - name: Flip the style pointer
      env:
        AWS_ACCESS_KEY_ID: $
        AWS_SECRET_ACCESS_KEY: $
        AWS_DEFAULT_REGION: auto
      run: |
        jq --arg p "$" \
          '.sources.basemap.url = "https://tiles.example.com/\($p)/basemap.json"' \
          style/base.json > style/style.json
        aws s3 cp style/style.json s3://tiles/style.json \
          --endpoint-url "https://$.r2.cloudflarestorage.com" \
          --content-type "application/json" \
          --cache-control "public, max-age=60"

Two details carry most of the safety. The verification uses the public URL, not the bucket endpoint, so it exercises the CDN path a reader will take — a bucket that is fine and a CDN that recompresses would otherwise pass. And the size comparison against Content-Range catches a truncated multipart upload, which is the failure that produces 416s on the last tiles only.

What the publish job must confirm before the flipFive checks: range support, object length, content type, cache headers and the style document's own TTL.PUBLISH GATESThe public URL answers a range request with 206not the bucket endpoint — the CDN path is what readers useRemote object length equals the local filecatches a truncated multipart uploadContent-Type is application/octet-streama compressible type invites a body transformThe archive is immutable, the style is notthe asymmetry the whole scheme rests onThe previous prefix is still readableit is the rollback, and it must outlive the style cache
Each of these has produced an outage somewhere. All five together are about fifteen lines of shell.
The three prefixes that exist during and after a publishThe previous prefix stays warm and readable, the new prefix is uploaded and verified, and the style pointer moves between them — with rollback being a move back.PUBLISH STATESv42previous, warmv43uploaded, verifiedStylepoints at oneflip forward afterverificationflip back — that is the whole rollback
Rollback is the same operation as the deploy, in reverse. Nothing is rebuilt and nothing is purged, because the old prefix never went away.

Credentials and Scope

An R2 API token for CI should be scoped to one bucket with object read and write, and nothing else. Two habits are worth adopting beyond that.

Do not grant delete. Retention of old prefixes is a scheduled lifecycle rule on the bucket, not something a build job should be able to do — which also means a compromised CI token cannot remove the tileset currently being served.

Do not read the credentials into a shell variable that gets echoed. The AWS CLI reads them from the environment; passing them as arguments or logging them into a debug line is how a token ends up in a public build log.

Interaction Effects

With versioned rotation. This job is the publishing half of versioned URL rotation. Nothing is ever purged; readers migrate as their style expires.

With CORS. The bucket’s CORS policy must allow the map’s origin and expose Content-Range, or the archive is readable by curl and not by a browser — see the range request configuration.

With TileJSON. If the deployment publishes a TileJSON document, it goes under the same prefix with the same immutable headers, and the style points at that rather than at the archive.

With runner limits. Hosted runners have modest disk. A multi-gigabyte archive downloaded as an artefact and then uploaded can exhaust it — stream the upload from the build job instead of round-tripping through an artefact when the archive is large.

Performance Impact

Upload dominates the publish job, and it scales with archive size and the runner’s network. Measured from a GitHub-hosted runner to R2:

Archive Upload Verify Flip
400 MB 38 s 1 s 1 s
1.4 GB 2 m 10 s 1 s 1 s
6 GB (multipart) 8 m 40 s 1 s 1 s

Verification and the flip are constant and negligible, which is the argument for doing both: they add two seconds to a job that already takes minutes, and they convert a class of silent failures into a failed build.

Common Mistakes

Flipping the style before verifying the upload. The window is short and it is a total outage while it lasts.

Uploading to the same prefix as last time. Immutable caching then serves the old bytes for a year, and no purge reaches every edge reliably.

Setting a compressible content type. Invites the CDN to transform the body, which invalidates every offset in the archive’s directory.

Deleting the previous prefix in the same job. Readers holding a cached style still ask for it. Retire old prefixes on a schedule, well past the style TTL.

FAQ

Why R2 rather than S3?

Egress from R2 to Cloudflare’s CDN is free, and tile delivery is nearly all egress. The upload path and headers are identical; the API is S3-compatible, so the same tooling works either way.

Should the style live in the same bucket?

It is convenient and it works, provided the style gets a short TTL and the tiles get an immutable one. The one thing that must never happen is the style inheriting the archive’s cache policy.

How long should old prefixes be kept?

Longer than the longest cached style, plus a rollback margin. A 60-second style TTL and 30 days of retention is comfortable and cheap, since old archives are cold storage.

Can the verify step use HEAD instead of a range GET?

A HEAD confirms existence and length but not that ranges work through the CDN. The range GET is the check that matters, and it costs 100 bytes.