Infrastructure
How the site is built and served. The model itself is in /methodology; the library and API are in /developers. This page is the plumbing: what runs where, how a request moves through it, and the parts I'd still change.
The short version: a physics model that reads elevation, road-surface and weather for any course in the world, served from one small managed-Kubernetes cluster in Paris, defined end to end in Terraform. A prediction is CPU-heavy and slow the first time (a cold mountain route does about a minute of data prep), so the API never computes inline. It drops a job on a queue, a pool of workers drains it, and three cache layers make sure the second person to ask the same question waits under 50 ms.
The amber path is what one prediction request travels. The worker does the heavy work off the request, then streams the result back over SSE.
Five decisions that earned their keep
The API answers in milliseconds and hands the real work to a worker pool over a Valkey stream. A Reddit spike queues; it doesn't drop requests or blow up a request thread that's busy downloading a mountain's worth of elevation tiles.
A CPU autoscaler reacts a step late: CPU only climbs once the queue is already deep, then it adds a pod and waits ~30 s for it to boot. KEDA watches the Valkey stream directly and scales within ~10 s of the backlog growing: one warm worker as a floor, up to twenty on a dedicated pool that sits at zero nodes at rest. A readiness probe keeps a fresh pod out of rotation until it's actually draining jobs, not just running.
The map builds need a 32 vCPU / 128 GB machine for an hour a month. That pool sits at zero nodes the rest of the time. The autoscaler spins the node up when a build job is pending and tears it down when it's done.
Reading a route's elevation over S3 is thousands of range-GET round-trips: 5 to 17 seconds. The same tile as a local file on a shared NFS volume is a disk seek: about 0.9 seconds warm. Same data, a self-hosted cache in front of it.
The cluster, the buckets, the deployments, the DNS, the dashboards: one repo, six numbered layers, applied by CI on every push. Secrets are committed encrypted with SOPS + age. There's no console-clicking that isn't also in code.
The stack
Everything runs on Scaleway Kapsule (managed Kubernetes, 1.33, Cilium networking) in a private network in the Paris region. Three pools: a small always-on pool for the app; a tainted worker pool that's zero nodes until a burst of jobs spills onto it; and a tainted batch pool that holds nothing until a map build needs it.
| Component | What it is | Scaling |
|---|---|---|
| API | FastAPI. Enqueues jobs, streams results over SSE, proxies map and elevation tiles. | 2→8 pods on CPU |
| Worker | Drains the job stream, runs plan_race, caches the result. One stays warm on the infra pool; bursts spill to a dedicated worker pool. | 1→20 pods on queue depth |
| SPA | React build served by nginx, which also proxies /api. | static |
| Valkey (jobs) | Job queue + results + prepared-route cache + pinned demo plans. RDB + AOF so a restart doesn't cold-start the demos. | 1 replica, 50 GB disk |
| Valkey (cache) | Read-through cache for OpenStreetMap surface and weather lookups, 30-day TTL. | 1 replica, 20 GB disk |
| Overpass proxy | Caches OpenStreetMap Overpass queries so the upstream gets hit once per area. | 1 replica |
| DEM NFS | Self-hosted NFS over a 200 GB block volume: the local elevation-tile cache the pods read as files. | 1 replica |
| Infra pool | DEV1-M nodes (3 vCPU / 4 GB): API, SPA, Valkey, DEM NFS, plus the one warm worker. Always on; autoscales, self-heals. | 3→12 nodes |
| Worker pool | DEV1-M, tainted for plan_race workers only. Zero nodes at rest; the autoscaler spins them up for a burst and drains them back to zero. | 0→5 nodes |
| Batch pool | PRO2-L (32 vCPU / 128 GB) for Planetiler map builds. Tainted so nothing else lands on it. | 0→6 nodes |
What a prediction request does
You pick a route, set your numbers, and hit plan. Here's what happens between that click and the finish time coming back.
- Edge
Traefik terminates TLS, checks the rate limit and the in-flight cap, and routes the call to the API. A flood of a thousand different IPs hits the concurrency cap and gets a fast 429 instead of piling onto the pods.
- Enqueue
The API validates the request and drops a job on the Valkey stream. It returns a job id right away: no computing on the request thread.
- Compute
A worker picks up the job and runs the model: pull the elevation for the corridor, read the road surface per segment, fetch the archived weather for the race window, then integrate power to speed over every split.
- Cache
The prepared route (about 99.5% of the cold cost is rider-independent road prep) and the result land in Valkey. The same route with different rider numbers skips straight to the physics next time.
- Stream back
The browser has been polling the job id; the API streams the finish time, the per-split targets and the elevation profile back over SSE, and the FIT download is built on demand.
Three cache layers
A cold prediction on a long mountain route is 55 to 127 seconds of downloading and preparing terrain. Almost none of that depends on the rider, so almost none of it should happen twice. Each layer catches a different kind of repeat.
Maps and elevation
The model needs two things about any road in the world: how steep it is and what it's paved with. Elevation comes from 33 regional LiDAR datasets on S3, with a global fallback for everywhere else. Road surface comes from OpenStreetMap, built into map tiles the model can query by location.
A single planet-wide map build thrashes: the 92 GB extract needs more working memory than a node has, so it ran I/O-bound with a 20-hour estimate and got killed. The fix was to split it by continent. Six builds run in parallel on the batch pool, each one small enough to fit in RAM and finish in under an hour, then a merge step stitches them into one worldwide file. Total wall-clock is about an hour, once a month.
Every bucket is private. The API proxies tiles through, so nothing is served straight from storage, not even the map tiles that a map library would normally range-request directly. A CI check fails any change that would flip a bucket public.
Edge, delivery and ops
The whole thing is six Terraform layers, applied in order by GitHub Actions on every push to main. Each layer depends on the one before it.
Ingress is Traefik, with TLS from Let's Encrypt via DNS-01 and DNS records created automatically by external-dns from the service annotations. Rate limits sit at the edge: 100 requests per second per IP, 5 magic-link requests per minute, and a hard cap on concurrent in-flight API work. Secrets are SOPS + age, so they're committed to the repo encrypted and there's one key to rotate. Weather is a self-hosted Open-Meteo on my homelab, which skips the public endpoint's rate limit. Observability is Scaleway Cockpit for the cluster and PostHog for the product, both wired up in Terraform.
Sign-in is a passwordless magic link, and the account is a one-way hash: there's no password to leak. Custom-course uploads and shared plans are end-to-end encrypted with the key in the URL fragment, which browsers don't send to the server, so the server stores ciphertext it can't read. The privacy page has the full data-handling breakdown.
What I'd still change
None of this is free of sharp edges. The honest list:
- Valkey runs one replica. A restart replays from disk in seconds, but there's no failover. It's a single instance with good persistence, not a highly-available cluster. Fine for the traffic, not what I'd run for something people pay for.
- The NFS DEM cache is a single point of failure. If it dies, elevation reads fall back to S3 and everything gets slow rather than breaking, which is the right failure mode, but it's still one pod holding the fast path.
- Rate-limit counters are per-Traefik-replica. With two replicas the effective limit is roughly double the nominal number. It's deliberately loose so real users doing tile bursts don't get throttled; a genuine flood still hits the concurrency cap.
- The worker scales on CPU, not queue depth. A proper queue-length autoscaler (KEDA) is the right tool. I didn't want a new operator in the stack the week of launch, so CPU utilisation is the proxy for now.