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.
Production Command
# .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.
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.
Related
- CI/CD Tile Build Automation — the parent topic and the build stages preceding this one.
- Automating Tippecanoe Builds with GitHub Actions — the workflow this job attaches to.
- Versioned Tile URL Rotation Without Cache Purges — why the prefix changes on every publish.
- Configuring R2 and S3 for PMTiles Range Requests — the bucket-side configuration this assumes.