Atlas · Replication guide Field reference

Replicating this platform on free GitHub

A complete blueprint for running the entire Atlas data platform — gathering, cleansing, validation, storage, refresh and serving — on free GitHub + GitHub Actions, pulling directly from the primary sources. No intermediary API (not even Atlas's own), no server, no database, no paid tier of anything. Every number on this page is measured from the running Atlas pipeline, not estimated.

Written for sibling projects (e.g. london-buses.farhan.app) that want to reproduce the stack independently. For what each field means and how it is validated, this page links into the field-level reference — the per-dataset rules there apply unchanged; only the runner and the store differ.

Overview & architecture

The design principle: GitHub Actions is the scheduler and compute, the git repo is the database, and static hosting serves the API. Every dataset is fetched from its primary source on a schedule, cleaned and validated in the workflow run, committed as JSON/NDJSON files, and served as static assets (optionally wrapped by serverless functions for a queryable API).

 TfL Unified API ─┐
 BODS · DfT ······│    ┌────────────────────┐     ┌───────────────────┐    ┌─────────────────┐
 DVLA · DVSA VOL ─┼──▶ │ GitHub Actions     │ ──▶ │ git repo          │ ─▶ │ static hosting   │
 londonbusroutes ·│    │ scheduled workflows │     │ data/*.json       │    │ (+ functions =   │
 bustimes · OSM ──┘    │ fetch→clean→validate│     │ data/history/*    │    │  a public API)   │
                       └────────────────────┘     └───────────────────┘    └─────────────────┘
                         the compute                 the database             the serving layer

The free-tier budget — measured, not guessed

Actions minutes (private repo)
2,000 free /month
Actions minutes (public repo)
unlimited (standard runners)
This workload needs
~900–1,350 min /month
Job ceiling
6 h/job · cron ≥ 5 min
WorkloadRuntimeCadenceMinutes/month
Full nightly refresh (every dataset, TTL-gated)~20–35 mindaily~600–1,050
Diversions-only refresh (live status + sequence diffs)~2 min5×/day~300
Fleet moves via bustimes (see boundary)~2–5 mindaily~60–150

Repo growth from committing data daily, measured on the live Atlas repo and by simulation (676 routes × 90 days of realistic history rows, extrapolated):

WhatMeasured
One full nightly data commit (~630 files touched)550–820 KB permanent git growth
Diversions-only commit2–9 KB
History rows, day-partitioned files~5 MB/year per 676-route dataset
History rows, per-route appending files~25 MB/year per dataset (5× worse — git re-deltas the growing file)

GitHub is comfortable to ~1 GB and warns approaching 5 GB — day-partitioned history buys years of headroom. Static-host ceilings (Cloudflare Pages free): 20,000 files/site · 25 MiB/file · 500 builds/month. A nightly + 5×-daily push cadence is ~180 builds/month; a per-route file layout for ~5 history datasets lands ~6,000 files growing ~1,800/year — inside all three, but the build count is the limit to watch if you add more intraday refreshes.

Do not commit raw high-volume observations. Atlas's raw arrival samples run 460k–1.4M rows/day and its GPS trip logs are similar scale — 20–60× the durable rollups, 3–10 GB/year, and git cannot prune. Commit derived daily rows only; keep raw intermediates as workflow artifacts (90-day default retention) or don't keep them at all.

Workflows to set up

Two scheduled workflows cover everything. Cadence rule: match the source, don't hammer it — most reference data changes at most daily; TTLs inside the pipeline make re-runs cheap no-ops (a dataset within its TTL is skipped; conditional requests via ETag/If-Modified-Since skip unchanged downloads).

1 — refresh-data.yml (nightly, the workhorse)

name: refresh-data
on:
  schedule: [{cron: "17 3 * * *"}]      # 03:17 UTC — off-peak for TfL, before the day's users
  workflow_dispatch: {}                 # manual runs for backfills/debugging
concurrency: {group: refresh, cancel-in-progress: false}   # never two refreshes at once (TfL 429s)
permissions: {contents: write}         # lets GITHUB_TOKEN push the data commit
jobs:
  refresh:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: {node-version: 22}
      - uses: actions/cache@v4          # HTTP validator cache + one-lookup-ever registries
        with: {path: .cache, key: "http-cache-${{ github.run_id }}", restore-keys: http-cache-}
      - run: npm ci --omit=dev
      - run: node pipeline/run.js       # fetch → clean → validate per dataset
        env:
          TFL_APP_KEY: ${{ secrets.TFL_APP_KEY }}
          DVLA_API_KEY: ${{ secrets.DVLA_API_KEY }}
      - run: node pipeline/validate-atlas.js    # HARD GATE — fail here = no commit
      - name: commit refreshed data
        run: |
          git config user.name  "data-bot"
          git config user.email "bot@users.noreply.github.com"
          git add data/
          git diff --cached --quiet && exit 0   # no change → no commit → no rebuild
          git commit -m "chore(data): scheduled refresh $(date -u +%FT%TZ)"
          for i in 1 2 3 4 5; do git push && break || { git pull --rebase; sleep $((i*3)); }; done

2 — diversions.yml (intraday freshness)

on:
  schedule: [{cron: "17 7,11,15,19,23 * * *"}]   # 5×/day; diversions are the one fast-moving reference set
# identical job, but:  run: node pipeline/run.js --only=diversions
# same concurrency group as refresh-data — they must never overlap

Secrets to configure Settings → Secrets → Actions

SecretNeeded forRequired?
TFL_APP_KEYRaises the TfL rate allowance (500 req/min keyed)Optional — TfL works keyless at lower limits
DVLA_API_KEYVehicle make/year/fuel enrichmentOnly if you want fleet enrichment
BODS_API_KEYLive GPS proxy at the serving layer (not the pipeline)Only if serving live positions

No push token needed: the workflow's own GITHUB_TOKEN with permissions: contents: write commits to the repo it runs in. Note one quirk: pushes made with GITHUB_TOKEN do not trigger other Actions workflows (loop protection) — but they do trigger external webhooks, so the static-host rebuild still fires.

Scheduling honesty

Per-dataset source map — plug into these directly

Every dataset, its primary source, and the cadence to run it at. The cleansing/validation rules for each are specified field-by-field in the reference (linked per row) — they are runner-independent: apply them identically in an Actions job. Global rules: timeout every request; retry 429/5xx with backoff + jitter; treat every source as fallible; a failed fetch keeps last-good, never writes empty.

DatasetPrimary source (fetch this)KeyRun cadenceCleansing / gates (detail)
routes · stops · geometryTfL Unified API/Line/Mode/bus + /Line/{id}/Route/Sequence/{dir} (~1,350 calls, concurrency ≤10)optionaldaily≥400 routes or refuse; dedupe stops by NaPTAN id; simplify overview geometry, keep full-fidelity per-route (ref)
line-status · disruptionsTfL /Line/Mode/bus/Status, /Disruptionoptionalon refresh + live proxy30–60 s cache at the serving layer (ref)
diversionsTfL status + Route/Sequence diffed against a frozen canonical baseline; dated iBus drops for baseline recoveryoptional5×/daynever absorb a temporary sequence into the baseline; freeze flagged routes (ref)
route-meta (operator/garage/PVR)londonbusroutes.netgarages.csv + details.htmdailydecode entities; PVR 0→null; school-band routes kept out of term; TfL values never overwritten by scrape (ref)
garagessame CSV + postcodes.io geocoding + DVSA VOL bulk CSVdaily (VOL ~weekly upstream)curated postcode/route-fix overrides; licence ceiling ≠ depot capacity (ref)
fleet · vehiclesTfL /Line/{id}/Arrivals sweep + DVLA VES + bustimes vehiclesDVLAdailyone DVLA lookup per reg ever (cache via actions/cache); ~220 ms spacing, per-run cap, 429 backoff-stop; bustimes ≤250 new lookups/run (ref)
fleet moves (assignments)bustimes vehiclejourneys?vehicle=&date= → (reg, route, day)dailyvolunteer-run: identify politely in User-Agent, space requests, cap per run, and reconcile attribution before trusting (~75% exact route match vs own tracking in sampling)
tendersTfL tender results + per-award pages + annual LBSL programme PDFdaily (new awards only; cache is append-only)canonicalise operator via alias table, keep raw; recompose from cache when the index is unreachable (ref)
performance (QSI)bus.data.tfl.gov.uk — quarterly PDFs, per-route MPS PDFsdaily check; content moves ~quarterlyPDF table extraction; ≤1,000 MPS PDFs/run, 28-day per-route TTL (ref)
accidentsDfT STATS19 — collision + vehicle CSVs per year, streamedmonthly TTL; list next year early (404 skips harmlessly)bus/coach vehicle types only; London bbox; decode all codes, unknown→null never wrong label (ref)
bridgesTfL EPOWR xlsx + OSM Overpass maxheight cross-checkmonthly TTLband lower bound = guaranteed clearance; EPOWR upstream frozen since 2019 → OSM tops up; drivable-road classes only (ref)
crowdingTfL BUSTO CSV (~98 MB — stream, never buffer)monthly TTL, conditional-skip on unchanged yearreduce to per-route peak V/C + profile split (ref)
localitiesOSM Overpass — place=town|suburb, London bbox (POST, UA required, mirror fallback)monthly TTLODbL attribution (ref)
live GPS (serving only)BODS SIRI-VM datafeed, London bboxBODSon request, 10 s edge cachenever store; parse ~6.5 MB XML / ~7,700 vehicles per pull (ref)
EWT / OTD (reliability)PLACEHOLDER headway.plumby.ioto be confirmed/updatedtbctbcsee the callout below — this row is not actionable yet

EWT/OTD placeholder — read before wiring anything. headway.plumby.io is a commercial product with no published API, and its terms prohibit systematic bulk download and redistribution without written permission. It is listed here purely as a placeholder while an arrangement is explored — do not scrape it. Until a licensed source exists, the only reliability figures available without running your own continuous collector are TfL's official QSI (authoritative, quarterly, months in arrears) — see what Actions cannot do for why daily EWT/OTD can't be self-computed on this stack.

Files as the database

Current-state datasets are one JSON file each, overwritten per run — exactly the Atlas static store. Time-series (history) needs a layout decision, and the measured difference is 5×:

data/history/<dataset>/day/<YYYY-MM>/<YYYY-MM-DD>.ndjson   # write-once → git-cheapest (~5 MB/yr), answers "all routes on a day"
data/history/<dataset>/route/<id>.ndjson                   # append per run (~25 MB/yr), answers "one route over time" in one read

Serving an API from files

A database is not required to have a real API. The pattern (Atlas's own /api/v1 current group runs exactly this way):

What GitHub Actions cannot do — the honest boundary

One class of workload does not fit, at any repo visibility: continuous, stateful observation. Atlas's own daily EWT/OTD, lost-mileage and first-party fleet tracking come from a daemon polling BODS every 25 seconds, holding thousands of open bus trips in memory between polls. Actions cannot host that:

RequirementGitHub Actions
25-second poll cadencecron floor is 5 minutes — 12× too coarse
Punctual sampling (headway maths is timing-sensitive)schedules are best-effort; 10–30+ min jitter, occasional skips — jitter is sampling bias here
State held across polls (open trips)lost at every job boundary; no resident memory
Runs indefinitely6-hour job ceiling
24/7 runner minutes (~43,200/month)21× the private-repo free tier; against acceptable-use as a service on public runners

Consequences for a pure-GitHub replica: consume reliability figures rather than compute them (TfL QSI now; the placeholder above if licensed), and take fleet moves from bustimes' vehiclejourneys — the community instance of exactly this collector. Everything else on this page fits comfortably.

Getting-started checklist

Licensing reminder for anything you republish: TfL open data terms (attribution — "Powered by TfL Open Data"), OGL v3 (DfT/DVLA/DVSA), ODbL (OSM — attribution + share-alike), community sources credited. The full per-source table is in the reference appendix.