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.
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-sitemust show it waited onbuild-maps, anddeployonbuild-site. A job that starts early will not see the artifacts. - Artifacts round-trip — download the
map-artifactsartifact 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-bundleartifacts; 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.
Related
- Static vs Dynamic Export Methods — parent guide on when a pre-built static bundle beats a live service
- Serving PyDeck Output Through 11ty Data Pipelines — the build-time data handoff this workflow automates end to end
- Rebuilding Tiles on GitHub Push with repository_dispatch — triggering downstream tile rebuilds from the same push event
- Automating Nightly GeoJSON Rebuilds with GitHub Actions — the scheduled variant of this pipeline for time-based data refreshes