Deploying Generated Map Bundles with GitHub Actions CI/CD

Part of the Static vs Dynamic Export Methods guide.

Operative rule: build the map artifacts inside CI from pinned source, never by hand — so any commit reproduces byte-for-byte the bundle it deploys, and a rollback is just re-running an earlier commit.

How the Pipeline Works

A generated map dashboard has two producers that must run in order: a Python step that emits the map artifacts (a PyDeck spec, a Folium HTML export, GeoJSON, or a tile set) and the 11ty step that templates those artifacts into a static site. Running both by hand invites drift — the deployed bundle stops matching the source that supposedly produced it, and nobody can say which data snapshot is live. GitHub Actions solves this by making the pipeline the only path to production: a workflow checks out a specific commit, installs pinned dependencies, runs the Python producer, runs the 11ty build, and deploys the result.

The workflow is split into jobs because concerns are separable and jobs can be gated independently. A build-maps job owns the Python toolchain and produces the artifacts; a build-site job owns the Node toolchain, pulls those artifacts in, and runs 11ty; a deploy job publishes the bundle and applies cache headers at the host or CDN. Jobs run on isolated runners with no shared disk, so the generated artifacts move between them through upload-artifact / download-artifact. Secrets — a map provider token, a deploy credential — arrive through the workflow’s environment rather than being baked into source. This is the automated counterpart to running the PyDeck output through 11ty data pipelines by hand, and it shares its scheduling machinery with automating nightly GeoJSON rebuilds with GitHub Actions.

Three-job map bundle deployment pipeline The build-maps job runs Python and uploads a map-artifacts bundle. The build-site job downloads it, runs the 11ty build, and uploads a site bundle. The deploy job downloads the site bundle and publishes it to the host with cache-control headers. Artifacts move between isolated jobs. job: build-maps run Python producer emit map artifacts upload-artifact artifact job: build-site download-artifact run 11ty build upload site bundle bundle job: deploy publish to host / CDN apply cache headers env secrets isolated runners · artifacts carry state between jobs · same commit = same bundle

Production-Ready Implementation

The workflow below runs on every push to the default branch and on a manual trigger. The build-maps job pins the Python version, installs from a lockfile, runs the producer with a provider token pulled from secrets, and uploads the build/maps directory. The build-site job downloads that directory into the 11ty source tree, runs the build, and uploads the finished _site. The deploy job downloads _site and publishes it, applying cache headers. Each needs clause enforces the ordering that makes the deploy reproducible.

name: build-and-deploy-maps

on:
  push:
    branches: [main]
  workflow_dispatch:        # manual re-run button for rollbacks

permissions:
  contents: read

jobs:
  build-maps:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - name: Install pinned dependencies
        run: pip install -r requirements.lock

      - name: Generate map artifacts
        env:
          MAP_PROVIDER_TOKEN: ${{ secrets.MAP_PROVIDER_TOKEN }}
        run: python scripts/build_maps.py --out build/maps

      - name: Upload map artifacts
        uses: actions/upload-artifact@v4
        with:
          name: map-artifacts
          path: build/maps
          retention-days: 7

  build-site:
    needs: build-maps       # must not start until artifacts exist
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: npm

      - name: Download map artifacts into the source tree
        uses: actions/download-artifact@v4
        with:
          name: map-artifacts
          path: build/maps

      - name: Install and build the static site
        run: |
          npm ci
          npx @11ty/eleventy --input=. --output=_site

      - name: Upload site bundle
        uses: actions/upload-artifact@v4
        with:
          name: site-bundle
          path: _site

  deploy:
    needs: build-site
    runs-on: ubuntu-latest
    environment: production   # gate + scope the deploy secrets
    steps:
      - name: Download site bundle
        uses: actions/download-artifact@v4
        with:
          name: site-bundle
          path: _site

      - name: Publish to host with cache headers
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          # Fingerprinted assets: cache forever; HTML: revalidate quickly.
          python scripts/deploy.py \
            --dir _site \
            --immutable "assets/**,maps/**" \
            --immutable-max-age 31536000 \
            --html-max-age 300

Alternative Variants

Combine the Python and Node stages into one job

For a small site where artifact hand-off overhead outweighs the benefit of separation, run both toolchains in a single job. This keeps everything on one filesystem, so no artifact upload is needed between the producer and the 11ty build — at the cost of coupling the two runtimes into one runner.

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12", cache: pip }
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: npm }
      - run: pip install -r requirements.lock
      - run: python scripts/build_maps.py --out build/maps
      - run: npm ci && npx @11ty/eleventy --output=_site

Cache-header policy reference

Asset class Example path Cache-Control
Fingerprinted bundle assets/app.4f2a.js public, max-age=31536000, immutable
Generated map tiles maps/tiles/{z}/{x}/{y}.png public, max-age=31536000, immutable
Map HTML entry point maps/dashboard.html public, max-age=300
Site HTML pages index.html public, max-age=300, must-revalidate
Data JSON snapshot data/deck.json public, max-age=600

Fingerprinted names carry a content hash, so an immutable long max-age is safe — a change produces a new URL. HTML entry points keep a short max-age so a fresh deploy is visible without waiting out a long cache. When a push should also refresh downstream tile caches, hand off to the pattern in rebuilding tiles on GitHub push with repository_dispatch.

Verification Steps

Confirm the pipeline is sound before pointing production at it:

  • Ordering holds — check the Actions run graph; build-site must show it waited on build-maps, and deploy on build-site. A job that starts early will not see the artifacts.
  • Artifacts round-trip — download the map-artifacts artifact from the run summary and confirm it contains the expected generated files, proving the producer ran.
  • Secrets are not printed — scan the logs; a provider token or deploy credential must appear masked, never in plaintext, confirming it came from the environment.
  • Reproducibility — re-run the same commit and diff the two site-bundle artifacts; a reproducible pipeline yields identical output from identical input.
  • Cache headers land — after deploy, inspect a fingerprinted asset’s response headers for a long immutable max-age, and an HTML page’s for a short one.

Common Errors & Fixes

build-site cannot find the map artifacts

The jobs run on separate runners with no shared disk, so a build/maps directory written in build-maps does not exist in build-site. Upload it with actions/upload-artifact and download it with actions/download-artifact into the same path the 11ty build expects. Confirm the artifact name matches on both ends.

The deploy job runs before the build finishes

A missing or wrong needs clause lets jobs run in parallel, so deploy may publish a stale or empty bundle. Declare needs: build-site on deploy and needs: build-maps on build-site so the runner serializes them. The Actions graph should render as a straight chain, not a fan.

A secret is empty at runtime

The secret is referenced but not defined in the repository or environment scope, so ${{ secrets.NAME }} expands to an empty string and the step fails or silently skips work. Define the secret at the correct scope — repository or the production environment used by the deploy job — and reference it through env: so it is masked in logs.

New deploy is not visible to users

The HTML entry point was served with a long max-age, so browsers and the CDN keep the previous version. Give HTML a short max-age (a few minutes) while reserving immutable long caching for fingerprinted assets, and purge or version the entry point on deploy so the new bundle propagates promptly.