Automatically Deploying a Validated 3D Tileset to a CDN or Object Store
This guide deploys a validated 3D Tiles set from CI to an object store fronted by a CDN — Amazon S3 or Cloudflare R2 — using boto3 and aws s3 sync, setting the correct Content-Type and Content-Encoding for gzipped tileset.json and .b3dm payloads, applying long-lived Cache-Control, invalidating the edge, and swapping an atomic versioned prefix so clients never see a half-uploaded tileset.
You need this once the tileset is more than a folder you drag into a bucket: browsers refuse to decode a gzipped .b3dm served without Content-Encoding: gzip, a stale tileset.json pins clients to deleted tiles, and a mid-sync deploy shows users a broken tree. This is the publish stage of the CI/CD automation for spatial pipelines workflow, and it runs only after the schema validation gate is green.
Prerequisites
- Python 3.11 with
boto3>=1.34, and the AWS CLI v2 (aws s3 sync) orrcloneavailable on the deploy runner. Cloudflare R2 speaks the S3 API, so the same tools target it via a customendpoint_url. - A validated tileset in
dist/from the validation gate, containingtileset.jsonand.b3dm/.pntspayloads. Tiles are geocentric EPSG:4978 for Cesium; the deploy never reprojects — it publishes bytes. - A bucket with a CDN in front (CloudFront over S3, or Cloudflare’s cache over R2) and credentials in the CI secret store, injected as environment variables and never written to the workflow file.
- A decision on gzip: either pre-gzip
tileset.jsonand setContent-Encoding: gzipyourself, or leave files raw and let the CDN compress. Do exactly one — double compression is a classic breakage covered below.
Step-by-Step
1. Map each extension to its content type and encoding
3D Tiles payloads have specific MIME types, and a gzipped file must advertise Content-Encoding: gzip or the browser hands raw DEFLATE bytes to the glTF loader. Define the mapping once so every object is tagged consistently.
CONTENT_TYPES = {
".json": "application/json",
".b3dm": "application/octet-stream",
".pnts": "application/octet-stream",
".glb": "model/gltf-binary",
}
def headers_for(path: str, gzipped: bool) -> dict:
ext = path[path.rfind("."):]
meta = {"ContentType": CONTENT_TYPES.get(ext, "application/octet-stream")}
if gzipped:
meta["ContentEncoding"] = "gzip"
# Immutable versioned objects: cache hard. The pointer alias is cached short.
meta["CacheControl"] = "public, max-age=31536000, immutable"
return meta
2. Upload the tree to an immutable versioned prefix with boto3
Write the whole tileset under releases/<commit-sha>/ so each deploy is immutable and independently addressable. boto3 sets the per-object metadata from step 1; R2 is reached by pointing endpoint_url at the account’s R2 domain.
import os
from pathlib import Path
import boto3
def upload_release(dist: Path, bucket: str, sha: str) -> str:
s3 = boto3.client(
"s3",
endpoint_url=os.environ.get("S3_ENDPOINT_URL"), # set for Cloudflare R2
region_name=os.environ.get("AWS_REGION", "auto"))
prefix = f"releases/{sha}"
for path in dist.rglob("*"):
if path.is_file():
gz = path.suffix == ".json" # we pre-gzip tileset.json
key = f"{prefix}/{path.relative_to(dist).as_posix()}"
s3.upload_file(str(path), bucket, key, ExtraArgs=headers_for(path.name, gz))
return prefix
3. Or sync the directory with the CLI
For large tilesets aws s3 sync (or rclone) is faster than a Python loop because it parallelises and skips unchanged objects. Set per-pattern metadata with --exclude/--include passes so .b3dm and gzipped JSON get the right headers.
SHA="${GITHUB_SHA}"
DEST="s3://twin-tiles/releases/${SHA}"
# Binary tiles: octet-stream, long-lived immutable cache.
aws s3 sync dist/ "${DEST}" \
--exclude "*.json" \
--content-type "application/octet-stream" \
--cache-control "public, max-age=31536000, immutable"
# Pre-gzipped tileset.json: json + gzip encoding.
aws s3 sync dist/ "${DEST}" \
--exclude "*" --include "*.json" \
--content-type "application/json" \
--content-encoding "gzip" \
--cache-control "public, max-age=31536000, immutable"
4. Swap the live alias atomically
Clients hit a stable URL — live/tileset.json — so the deploy goes live by repointing that alias to the new release in one operation, after every tile is uploaded. Copying the versioned tileset.json to the short-cached alias key is the atomic swap: until this line runs, the old tileset serves unchanged; after it, the new tree is live as a whole.
def swap_pointer(s3, bucket: str, sha: str) -> None:
# The alias tileset.json is cached briefly and its child URIs point at the
# immutable release prefix, so repointing it flips the whole tree at once.
s3.copy_object(
Bucket=bucket,
CopySource={"Bucket": bucket, "Key": f"releases/{sha}/tileset.json"},
Key="live/tileset.json",
ContentType="application/json", ContentEncoding="gzip",
CacheControl="public, max-age=60", MetadataDirective="REPLACE")
5. Invalidate the CDN edge
The alias is short-cached, but force an invalidation so the swap is visible immediately rather than after TTL expiry. On CloudFront, invalidate the alias path; on Cloudflare, purge the URL.
def invalidate_cloudfront(distribution_id: str, sha: str) -> None:
cf = boto3.client("cloudfront")
cf.create_invalidation(
DistributionId=distribution_id,
InvalidationBatch={
"Paths": {"Quantity": 1, "Items": ["/live/tileset.json"]},
"CallerReference": sha,
})
6. Run the deploy job only from the default branch
Guard the whole deploy on a push to main after the gate, and inject credentials from secrets. Fork pull requests never reach this job, so they cannot touch the bucket.
deploy:
needs: [gate]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with: { name: validated-tileset, path: dist/ }
- name: Publish to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET }}
S3_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT }}
run: python3 scripts/deploy.py dist/ --bucket twin-tiles --sha "${{ github.sha }}"
Expected Output & Verification
After a deploy, confirm the served headers with curl -I against the CDN URL — the single most useful post-deploy check. The gzipped tileset.json must report both content-type: application/json and content-encoding: gzip, and a .b3dm must be application/octet-stream.
$ curl -sI https://tiles.example.com/live/tileset.json
HTTP/2 200
content-type: application/json
content-encoding: gzip
cache-control: public, max-age=60
$ curl -sI https://tiles.example.com/releases/9f3c2a1/tiles/tile_18_3312.b3dm
HTTP/2 200
content-type: application/octet-stream
cache-control: public, max-age=31536000, immutable
Then load https://tiles.example.com/live/tileset.json with Cesium.Cesium3DTileset.fromUrl and confirm the tiles render at the survey location. Because the release prefix is immutable and the alias is atomic, rolling back is repointing live/tileset.json at a previous release — no re-upload. For a fully managed alternative that skips bucket, headers, and invalidation entirely, publish to Cesium ion instead; see Cesium ion upload automation and automating ion tileset uploads with the REST API.
Common Errors
Tiles download but Cesium throws Unexpected token or fails to parse the glTF. The object was gzipped but served without Content-Encoding: gzip, so the browser passed compressed bytes straight to the loader. Fix: set ContentEncoding: gzip on every pre-gzipped object (steps 1 and 3), and verify with curl -I that the header is present — a missing encoding header on a gzipped body is the most common 3D Tiles deploy failure.
Every tile is served double-compressed and errors intermittently. You gzipped the files and the CDN re-compressed them, producing a double-DEFLATE body some clients reject. Fix: choose one layer — either pre-gzip and mark Content-Encoding: gzip while disabling CDN compression for those paths, or upload raw and let the CDN compress. Never both.
Clients keep loading old tiles after a deploy. The tileset.json alias was cached with a long max-age and no invalidation, so the edge kept serving the previous tree while its referenced tiles were deleted, yielding 404s. Fix: cache the alias with a short max-age (60 s) and issue an explicit invalidation on the alias path (step 5), while keeping the immutable release prefix long-lived.
Related Guides
- Schema Validation Gates for Spatial Data — the gate that must pass before this deploy runs
- GitHub Actions GDAL/PDAL Pipeline Jobs — the process stage producing the artifact deployed here
- Cesium ion Upload Automation — the managed-hosting alternative to a self-hosted CDN
- Automated Tile Generation for 3D Geospatial — how the tileset being deployed is built