No description
  • JavaScript 80.6%
  • HTML 18.2%
  • Dockerfile 0.6%
  • CSS 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Guillaume Quillery 0beb0ebad9
All checks were successful
/ image (push) Successful in 6s
Remove the Proxmox LXC deployment
Ridelog now ships as a container image with Compose files for SQLite and
PostgreSQL, so pushes to main only publish the image. The existing LXC
is left running; nothing deploys to it any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:23:30 +02:00
.forgejo/workflows Remove the Proxmox LXC deployment 2026-10-04 14:23:30 +02:00
deploy Remove the Proxmox LXC deployment 2026-10-04 14:23:30 +02:00
public Import a data export back from the Profile page 2026-10-04 13:54:55 +02:00
server Import a data export back from the Profile page 2026-10-04 13:54:55 +02:00
.dockerignore Add a Docker Compose deployment 2026-10-04 14:05:25 +02:00
.gitignore Add a Docker Compose deployment 2026-10-04 14:05:25 +02:00
Dockerfile Package Ridelog as a container image 2026-10-04 13:58:10 +02:00
package-lock.json Close reset-link poisoning and stop uploads from taking the server down 2026-09-30 21:44:28 +02:00
package.json Close reset-link poisoning and stop uploads from taking the server down 2026-09-30 21:44:28 +02:00
README.md Remove the Proxmox LXC deployment 2026-10-04 14:23:30 +02:00

ridelog

A personal ride tracker. Upload real .gpx or .fit files from your bike computer, Garmin, Strava export, or phone tracking app — Ridelog parses the actual distance, elevation, speed, and heart rate and stores your rides in a database (SQLite by default, or PostgreSQL/MySQL — see Database).

Run it

npm install
npm start

Open http://localhost:3000. On first run you'll be sent to a one-time setup page to create the admin account. After that, log in and either click Add a ride and pick one or more .gpx/.fit files, or drag and drop them anywhere on the page.

Data lives in data/ridelog.db (SQLite, created on first run, gitignored) by default — see Database to use PostgreSQL or MySQL/MariaDB instead.

Docker

Every push to main publishes an image to src.zerolatitude.dev/gquillery/ridelog (:latest and :sha-<commit>). Everything Ridelog writes is under /app/data, so mount a volume there:

docker run -d --name ridelog -p 3000:3000 \
  -v ridelog-data:/app/data \
  -e SESSION_SECRET=<long random string> -e APP_URL=https://ridelog.example.com \
  src.zerolatitude.dev/gquillery/ridelog:latest

Or with Compose, which keeps every setting in one env file:

cp deploy/compose.env.example deploy/.env    # fill in SESSION_SECRET and APP_URL
docker compose -f deploy/compose.yaml up -d

For PostgreSQL instead of SQLite, also set POSTGRES_PASSWORD in that file and use deploy/compose.postgres.yaml. It runs Postgres 18 next to Ridelog on a network of its own:

docker compose -f deploy/compose.postgres.yaml up -d

Ridelog creates its tables on first start. To move an existing SQLite install over, export from Profile → Data and import into the new one.

The image runs as a non-root user (uid 65532) on distroless Node 24 and has a built-in healthcheck. All the environment variables below apply as-is. Build it yourself with docker build -t ridelog ..

Accounts

Ridelog supports multiple users, each with their own private set of rides:

  • The first account created (via the one-time setup page) is an admin.
  • Admins can invite more people from the Admin page (top nav): enter an email, and Ridelog generates a one-time invite link (valid 7 days) to hand to that person — they use it to set their own password. If SMTP is configured (see below), the invite is emailed automatically instead of needing to be copy-pasted.
  • Admins can also remove accounts from the same page (this deletes that user's rides too).
  • Everyone's rides are private to their own account; nobody can see or edit another user's rides.

Changing your password (from the Profile page or via a reset link) signs out every other session on every device, while keeping the one you're currently using. So if someone else got into your account, changing your password actually locks them out instead of leaving them signed in until the cookie expires.

Variable Purpose
SESSION_SECRET Long random string used to sign session cookies. Without it, sessions use an insecure default — always set this in production.
APP_URL The public address Ridelog is reached at, e.g. https://ridelog.example.com. Emailed links (password resets, invites) are built from this, never from the request's Host header — which the client controls, so a reset link built from it could be pointed at someone else's server. Password reset is disabled until this is set; invite links fall back to the address the admin is using.
COOKIE_SECURE Set to true when Ridelog is behind HTTPS, so session cookies are only sent over HTTPS.
TRUST_PROXY Set when Ridelog runs behind a reverse proxy, so rate limiting sees the real client IP instead of the proxy's — without it, one person's failed logins return 429 to everybody. Prefer the proxy's own IP (e.g. 192.168.10.90) over a hop count: if Ridelog's port is reachable directly, a hop count makes Express trust whoever connects, so anything on the network could forge X-Forwarded-For and slip past the limiter. Accepts a comma-separated list or any Express trust proxy value. Leave unset only when nothing sits in front.

Every sign-in (login, setup, accepting an invite, resetting a password) starts a fresh session id, so a session cookie planted in someone's browser beforehand is worthless once they log in. A login for an email with no account takes as long as a wrong password, so timing doesn't reveal which addresses have accounts, and a reset request returns before any email is sent for the same reason. The last admin can't be demoted.

Login attempts are rate limited (10 failed attempts per IP per 15 minutes; successful logins don't count), as are password-reset requests (5/hour) and invite/reset link lookups (30 per 15 minutes). Note that once an IP trips the login limit, even the correct password is refused until the window passes.

Every response carries X-Frame-Options: DENY (so the UI can't be framed for clickjacking), X-Content-Type-Options: nosniff, and Referrer-Policy: strict-origin-when-cross-origin (cross-origin requests send only the origin, so share-link tokens never leak to map-tile hosts — but a Referer is still sent, which OpenStreetMap's tile policy requires and which no-referrer would break). Strict-Transport-Security is sent only when COOKIE_SECURE=true, since it's meaningless without HTTPS. There's deliberately no Content-Security-Policy: the frontend relies on inline scripts and onclick handlers, so a workable CSP would need 'unsafe-inline' and wouldn't add much — that's a frontend refactor rather than a header.

Email (SMTP)

Configure an SMTP server from the Admin page to enable:

  • Forgotten password — a "Forgot password?" link on the login page lets anyone request a reset email; without SMTP configured, or without APP_URL set (see above), that flow is disabled with a message pointing at the admin.
  • Emailed invites — new-account invites get sent directly to the invited address instead of only generating a copy-pasteable link (the link is always still shown as a fallback).

Settings (host, port, TLS, username/password, from address) are stored in the database, editable any time from the Admin page, with a "Send test email" button to verify them. Any SMTP provider works (a self-hosted relay, or a transactional email service like Mailgun/SES/Postmark's SMTP endpoint).

Profile

The Profile page (top nav) lets you set your first and last name, change your email, and change your password. Changing your email or password requires typing your current password first.

The Body metrics card holds an optional weight, year of birth, sex and FTP, used for nothing but working out calories (see Calories). Leaving them blank costs you nothing except calorie figures on rides that have neither a power meter nor a device calorie count, and emptying the fields and saving clears them again.

From the same page, everyone can export their data (a .zip holding their profile, bikes and rides as JSON, plus every .gpx/.fit file they've uploaded — see Original files), import such an export back (see below), delete all their rides in one go (keeping the account, bikes and default-bike setting; any active share links for those rides stop working), and delete their own account (which also deletes their rides and bikes), no admin needed. Both deletions ask for the current password, since neither can be undone. See privacy.html — linked from the login, setup, and Profile pages — for what's collected and why. Invite links and password-reset links are purged automatically once they expire or are used.

Importing an export

Import data on the same page takes the export .zip back, unchanged. It works on the account it came from (say, after deleting all your rides) or on another Ridelog server. The bare JSON from /api/me/export?format=json also works.

  • A ride whose .gpx/.fit is in the bundle is reparsed from that file and archived again, so its numbers come from the full recording and download and reanalysis keep working. Name, date, tags and bike come from the JSON, so your edits survive.
  • A ride with no file in the bundle is rebuilt from the JSON's points and figures. It has no original to download.
  • Rides already on the account are skipped (same original file, or same start time and distance). Importing the same bundle twice adds nothing, and an import that stopped halfway can just be run again.
  • Bikes are matched by name and created when missing. Name, body metrics and default bike are filled in only where the profile is empty. Email, password and admin status are never touched.
  • Share links are not restored. Share again from the ride page if you want a public link.

Each file goes through the parser, so a large import takes a few minutes. The page shows progress as it goes. Uploads are capped at 1 GB; set IMPORT_MAX_MB to change that. Behind a reverse proxy, the proxy's own body-size limit has to allow it too (nginx: client_max_body_size). Leave response buffering off for the import route so progress gets through (Ridelog sends X-Accel-Buffering: no for nginx).

Sharing a ride

Every ride's page has a Share button that generates a public, unguessable link (/share.html?token=…) — anyone with the link can view that ride's map, stats, and tags without logging in, but nobody can find it by browsing. Stop sharing immediately kills the link; sharing again generates a new one. The shared view doesn't expose your account, which bike you used, your heart rate (neither the average nor the per-second trace — it's the one figure in a ride that's about your body rather than the ride, and a link is forwardable), or any calorie figure derived from your body metrics (see Calories). It does show the exact GPS trace, including start and end points, so only share rides you're comfortable with that.

Bikes

The Bikes page lets you add, rename and remove your bikes and see total distance and ride count per bike, computed live from your rides. On each ride's page, a "Bike" dropdown assigns that ride to one of your bikes (or none) — removing a bike un-links it from any rides but doesn't delete them or their stats.

One bike can be marked default from that page — newly uploaded rides are assigned to it automatically, so you don't have to set it by hand every time. Deleting your default bike clears the setting.

Calories

Ridelog reports calories from the best source a ride actually has, and says on the ride page which one it used. An estimate rendered identically to a measurement is worse than no number at all, so each tier is labelled:

Tier Shown as Needs Typical error
Device total Recorded by your device a .fit file whose head unit wrote total_calories whatever the device's own model is worth
Power meter From power meter data recorded power a few percent
Heart rate Estimated from heart rate recorded HR + weight, year of birth and sex ±15–20%
Physical model Estimated from speed and climbing timestamps + weight ±20–25%

Setting an FTP refines the lower three, and the ride page appends adjusted for your FTP when it changed the number — see FTP below.

Power is the honest one: total mechanical work is integrated over the recorded points as Σ P·Δt, not computed as average power × duration. Those two aren't interchangeable — avg_power is the mean of every sample carrying a power value, while duration_s is moving time, so their product undercounts on a device that logs while stopped and doesn't on a device that auto-pauses. The integral is right either way, and it's stored per ride in rides.work_kj. Converting to kcal uses the cycling convention that a kJ of work costs about a kcal (the 4.184 J/cal and ~24% gross efficiency nearly cancel).

Heart rate uses Keytel et al. (2005), the equation behind most HR-based counters. It was derived from steady-state exercise, so it drifts high on long rides — cardiac drift raises heart rate at unchanged effort — and on very fit riders. An FTP switches it to the paper's more accurate VO2max form, which fixes exactly that (see below).

The physical model is P = m·g·(Crr + sinθ)·v + ½·ρ·CdA·v³, integrated over the trace and clamped at zero per segment (freewheeling down a descent is not negative work; without the clamp a loop's climbing would cancel against its descending and every ride would come out flat). The usual fallback here is a MET lookup by average speed, but that discards the elevation profile — the one thing Ridelog stores at full resolution for every ride, and the difference between a flat 40 km and a 40 km with 900 m of climbing. Wind and drafting are unknowable from a GPS trace, which is where most of the ±20–25% lives. Rider mass is your weight plus a fixed 9 kg for bike and kit; Crr and CdA are constants in server/calories.js.

GPS noise is this model's real enemy and enters twice: aero power goes as v³, so jitter inflating apparent speed by 20% inflates that term by 70%, and a few metres of altitude noise across a short window reads as a double-digit gradient. On a synthetic 30 km flat ride, ±3 m of per-sample jitter turned 413 kcal into 1341. Three defences bring that back to 414 — a 30 s / 100 m smoothing window, straight-line displacement between its endpoints instead of accumulated path length (so uncorrelated noise costs two endpoints rather than every sample it passes through), and altitude averaged over ±15 samples before it's differenced.

FTP

FTP is optional, needs your weight to mean anything (every use of it here is per kilogram), and never touches the device tier — that number was measured, not modelled. Where it does apply, it does three separate jobs.

It replaces the flat efficiency assumption. Gross efficiency isn't really 24%: it runs about 20–21% in recreational cyclists and 23–24% in elites. That's the standing critique of the kJ≈kcal shortcut — it assumes everyone is at the efficient end, and so flatters the average rider. FTP per kilo is the fitness proxy already on hand, so efficiency is interpolated from 21.0% at 2 W/kg to 24.0% at 5 W/kg. This is the only thing that changes calories on rides that do have a power meter, and it's worth knowing the direction because it surprises people: a stronger rider burns fewer calories for the same work, because more of it reaches the pedals. A 2 W/kg rider's power-meter rides read about 14% higher than the flat assumption gives; at 5 W/kg nothing changes.

It gives the heart-rate model a VO2max. Keytel publishes two equations and the one taking VO2max is the more accurate, because VO2max is what tells the equation whether a given heart rate means hard work or an easy spin for this rider — precisely the fitness blindness behind the drift noted above. Nobody enters a lab VO2max, but an FTP implies one: maximal aerobic power is roughly FTP/0.75, and the ACSM cycling equation puts the oxygen cost of a watt at 10.8 ml/min/kg plus about 7 for resting and unloaded pedalling, so VO2max ≈ 14.4 × FTP / kg + 7, clamped to 20–85 because the linear form runs away past 5 W/kg. On a 140 bpm hour that moves a 75 kg 40-year-old from a fixed 806 kcal to 686 at 2 W/kg or 936 at 5 W/kg.

It puts a ceiling on the physical model. That model can't see wind or drafting, which is where most of its error lives, and both failures look identical from outside: an implied average power no rider could hold. FTP is the only thing on file that knows what "could hold" means, so a roughly Coggan power-duration curve caps it — 100% of FTP up to an hour, 90% at two, 85% at three, down to 75% at five and beyond. On a synthetic 3-hour ride at 45 km/h on the flat (a big tailwind, or a peloton), the uncapped model claims 4751 kcal at an impossible 442 W average; a 250 W FTP pulls that to 2456 kcal at 213 W. It binds when the model implies an effort at the edge of the curve, so a rider going well above the FTP they entered will see it bite on hard days — a stale FTP is the likeliest cause.

Shared rides show the unadjusted power figure, whatever your FTP. The public page already shows average power and duration, so an adjusted number would let anyone with the link divide out the efficiency and recover your FTP per kilo.

Calories are computed when a ride is served, not stored, so correcting your weight fixes every past ride with no rebuild pass. Only the two measured facts are stored — rides.calories from the device and rides.work_kj from the power integral — and work_kj is backfilled once for rides uploaded before the column existed, gated on a settings flag so the whole table isn't rescanned on every boot.

They're deliberately absent from the rides list: the lowest tier needs the full points to integrate speed and gradient, so a calories column there would mean loading points_json for every ride — exactly what the summary columns exist to avoid.

A shared ride shows calories only from the first two tiers. A number derived from heart rate, weight, age and sex would let anyone with the link solve back for body stats that the shared view otherwise doesn't expose.

Assets

Leaflet and the two web fonts are vendored under public/vendor/ and served by Ridelog itself, so loading a page contacts no CDN, no fonts.googleapis.com, and no fonts.gstatic.com — one less thing between a cold visit and first paint, and no third party learns who reads the site. Map tiles are the only remaining external requests. The vendored files are served with a 30-day cache; replacing one still propagates, since they aren't marked immutable.

Archivo is the variable font covering weights 400–800 in a single file rather than five static ones, and only the latin and latin-ext subsets are kept. To update Leaflet, replace public/vendor/leaflet/ from the matching release (the dist/images/ folder is needed too — leaflet.css references it).

Responses are gzipped. GPS data is long runs of similar numbers and compresses about 5×, which is what makes full-resolution traces cheap enough to send.

Where a ride's numbers come from

A .fit file is two things at once: a second-by-second track, and the head unit's own summary of the ride — the totals it showed you on the screen when you stopped. Ridelog used to read only the first and derive everything else from it, which is why its figures drifted from what the bike computer reported.

They drift because deriving is not the same as measuring. The device had a wheel sensor, a barometer and its own auto-pause; all that reaches the track is a position once a second. Summing the distance between those positions picks up every metre of GPS jitter, and summing every positive altitude change treats a metre of barometric noise as a metre of climbing — on a 1 Hz recording that alone can more than double the reported ascent.

So when the file states a figure, that figure wins. Ridelog reads total_distance, total_ascent, total_descent, total_timer_time, avg_speed, avg_heart_rate, avg_power and avg_temperature out of the FIT session message and uses them in place of its own. This is the rule that already applied to calories, widened to the rest of the ride. Anything the file leaves out still falls back to the computed value, so a .gpx — which has no summary to state — behaves exactly as it always did.

Two details worth knowing:

  • Duration is timer time, the device's own clock with its auto-pause applied. That's what Ridelog's duration has always meant and what a computer displays as the ride's length; elapsed time, which includes every stop at every traffic light, is only used for a file that omits the timer.
  • Average cadence excludes zeros. A head unit logs cadence 0 every second you freewheel, so averaging every sample answers "how fast did the cranks turn, including while they weren't turning" — which drags an 85 rpm ride down into the 60s and matches no computer's display. Ridelog averages only the samples with the cranks actually moving. This is the one figure not taken from the session even when it's there, because the FIT profile defines avg_cadence over the whole timer, zeros included — the very average being avoided.

The weekly figures on the home page — the "this week" headline, the 12 weekly bars beside it, and the columns of the 26-week riding calendar — bucket by calendar week, Monday to Sunday, labelled with the Monday. They used to use rolling 7-day windows ending at the moment you loaded the page, which meant a bar's contents depended on the time of day you looked — Saturday's ride sat in one bar in the morning and the next one overnight. The calendar's shades are relative to the busiest day on screen, not a fixed distance, so they mean the same thing for a commuter as for someone riding centuries.

Rides already in the database are brought onto these rules once, at the next start, by a backfill in server/db.js: cadence is recomputed from the stored points, and the device totals are read back out of the archived original (see Original files). A ride with no archived file keeps its computed figures — there's nothing better to give it — but its cadence is still fixed. You can also redo any single ride on demand with Reanalyze.

Ride detail precision

Uploads keep every point the device recorded, at 6 decimal places of latitude/longitude, about 0.11 m. That's already finer than GPS hardware resolves, so the stored trace is limited by the recording, not by Ridelog.

There used to be a 50,000-point ceiling here, which a 1 Hz recording passes after about 14 hours. It existed so a pathological file couldn't put an unbounded blob in the database, but it also silently discarded 80% of a dense 23 MB GPX — and a ride long enough to hit it is exactly the one worth keeping whole. The size is bounded by the 30 MB per-file upload limit instead, which is the honest place for that limit to live: a property of what was sent, rather than something quietly applied to it afterwards. A 250,000-point ride stores as 12.5 MB of JSON, 1.1 MB gzipped on the wire, and the boot-time backfills in server/db.js read one ride at a time so a rebuild can't pull a page of them into memory at once.

Every stored point is drawn: on the ride map, on the ride profile (one track per metric, all on a shared distance axis with a single cursor), and on a shared ride's map and elevation profile. The profiles place each point by its distance along the ride rather than by its position in the recording, since at one sample a second a stop at a light stacks dozens of points on one spot. Slope and speed are differences between points, so each is taken across the points at least 50 m either side — a fixed distance, so they read the same on a short commute and a long day — rather than between neighbouring samples a second apart, which would chart GPS noise.

Rides uploaded before this were reduced to 2,000 points at upload time and the original detail is not recoverable — re-upload the original .gpx/.fit to get full resolution for those.

The overview map draws every ride at once, so it loads in two stages. The rides list carries a simplified outline per ride (rides.trace_json, about 0.6 KB gzipped) and the map paints from those immediately; then /api/traces fetches full-resolution outlines in the background and the map redraws at full precision. Blocking on the full set instead would mean roughly 1.6 MB gzipped at 100 rides before anything appeared. If that request is slow or fails the map simply stays on the simplified outlines — it is never blank.

/api/traces sends only latitude and longitude; the map never uses elevation, heart rate or the rest, and including all eight fields per point would be about 2.5× the bytes for an identical line.

The stored outlines are simplified with Douglas-Peucker at a 5 m tolerance rather than by keeping every Nth point — sampling spends points evenly, so it wastes them on straight sections and cuts corners on switchbacks, drifting up to 76 m from the real line on a twisty route, where Douglas-Peucker holds the error under about 5 m for roughly the same number of points. Changing that algorithm means bumping TRACE_ALGO_VERSION in server/db.js, which rebuilds every stored outline once on the next start.

Original files

Ridelog keeps the .gpx/.fit file you uploaded, exactly as you uploaded it. What the app parses out of a file is still lossy — coordinates are rounded to six decimals and only the eight fields the app charts are kept, so laps, sensor metadata and everything else in the file is discarded at upload. The archived copy is the only thing that still has all of it.

That buys two things on each ride's page:

  • Download original — get the file back byte-for-byte, to feed into another tool or keep as a backup.
  • Reanalyze — re-run the parser over the stored file and recompute every derived figure (distance, elevation, speed, heart rate, cadence, power, temperature, calories, the stored points and the map outline). Worth doing after upgrading Ridelog, if a parser fix or a new metric means the file yields something the ride doesn't have yet. Your own edits — name, date, tags, bike, share link — are left alone.

Both buttons only appear on rides that have an archived file. Rides uploaded before this feature existed don't, and can't be reanalyzed — re-upload the file to get one.

Export my data (Profile page) bundles the lot into a .zip:

ridelog-export.json                    profile, bikes, every ride
README.txt                             what's in the bundle
rides/2024-06-01-morning-climb.gpx     the files, exactly as uploaded
rides/2024-06-14-evening-loop.fit

Each ride in the JSON points at its file through original.exportPath, so the two halves join up without matching on names. Rides with no archived file are still in the JSON, just with original: null — the README.txt inside says how many. GET /api/me/export?format=json still returns the bare JSON document if you have something scripted against it.

Everything about the bundle streams, because ride points are the largest thing Ridelog stores and the export is the one operation that touches all of them at once. The manifest is written ride by ride into an open ZIP entry rather than built as one string, and archived files are read one at a time. On a test account of 78 rides (136 MB of GPX, twelve of them 11 MB each), that took peak memory for the request from about 640 MB down to about 220 MB — the cost no longer grows with the number of rides, only with the largest one.

The archived files also go in without being recompressed: they're stored gzipped, and a gzip member is already the deflate stream a ZIP entry wants, with the CRC32 and uncompressed length a ZIP header needs sitting in its trailer. So those bytes move straight from disk into the archive untouched.

The ZIP writer is server/zip.js — about 200 lines, no dependency added. Entries written incrementally use a data descriptor (flag bit 3), filenames are flagged UTF-8 so accented ride names survive, and there's no ZIP64, so it refuses rather than emitting a wrapped, corrupt archive past 4 GiB.

Uploads stream to data/incoming while they arrive and are moved from there into data/uploads/<user-id>/<ride-id>.gpx.gz, gzipped: a 134 KB GPX stores as about 8 KB. Nothing is left in data/incoming — each file is removed as soon as it's been archived or has failed to parse, and anything still there at startup (an upload interrupted by a crash) is cleared on boot. They're deleted with their ride, and wiped entirely when an account is deleted or a user erases all their rides. The Admin page reports how many files the archive holds and how much disk they take.

Variable Purpose
UPLOAD_ARCHIVE Set to off to stop keeping uploaded files. Rides still parse and import normally; they just get no Download/Reanalyze. Existing archived files are left in place.
PARSE_CONCURRENCY How many files are parsed at once, across all users (default 1). Each parse runs in its own worker thread, so it never stalls other requests, but a large GPX needs roughly 20x its size in memory while it parses — raise this only if the machine has the memory for it.
PARSE_MEMORY_MB Heap cap for each parse worker (default 768). A file that needs more fails with "File is too large to process" rather than taking the server down; a 29 MB GPX needs about 520 MB.

If you're on PostgreSQL or MySQL, this is the one thing that isn't in your database — data/uploads has to be on persistent storage and included in backups, or a restore comes back with rides that can no longer be reanalyzed.

Map tiles

By default the map uses free, keyless tiles (OpenStreetMap for Classic mode, Esri for Dark mode). Admins can switch to Mapbox instead from the Admin page: pick "Mapbox" and paste an access token from your Mapbox account (free tier is generous — 50k map loads/month). This is a site-wide setting stored in the database, applying to every user's map. Once saved the token isn't displayed again — the field shows "Access token (unchanged)" and leaving it blank keeps the stored one, so you can change provider without re-pasting it. Note the token is still readable by any logged-in user, since the browser builds Mapbox tile URLs itself; restrict it by URL from your Mapbox account if that matters.

Database

Ridelog defaults to a local SQLite file and needs no configuration for that. To use PostgreSQL or MySQL/MariaDB instead, set two environment variables before starting the server:

Variable Purpose
DB_CLIENT sqlite (default), postgres, or mysql (also accepts mariadb)
DATABASE_URL Connection string, required for postgres/mysql — e.g. postgres://user:pass@host:5432/ridelog or mysql://user:pass@host:3306/ridelog

Uploaded .gpx/.fit files are the exception to "everything is in the database": they're stored on disk under data/uploads regardless of backend, so that directory needs persisting and backing up alongside your database. See Original files. data/incoming is scratch space for uploads in flight and never needs backing up.

The database (and its user/password) must already exist — Ridelog creates its own tables in it on first run, but doesn't create the database itself. (deploy/compose.postgres.yaml does that part for you.)

This is purely about where new data is stored: switching an existing SQLite deployment to Postgres/MySQL starts that backend with an empty schema, it does not copy over rides or accounts already in the SQLite file. The old data/ridelog.db is left untouched on disk, so nothing is destroyed — but the app will not see any of it, and the first thing you get on a switched-over instance is the one-time setup page asking you to create an admin account again. There is no built-in migration; moving existing rides across means exporting them or copying the tables yourself.

Memory

Ridelog handles GPS files that expand enormously in memory, so a few paths are written to stream rather than to load. Numbers below are from a test account of 78 rides (28 MB of stored points, twelve rides at 50,000 points each) — peak RSS for the request, and the smallest --max-old-space-size the process survives.

Uploads stream to disk. multer.memoryStorage() held every uploaded byte in RAM; with the configured limits (200 files × 30 MB) that's a 6 GB ceiling of live buffers before the handler runs. Now they go to data/incoming and are read back one at a time. A 20-file batch of 11 MB GPX files went from 1452 MB peak to 974 MB, and a 40-file batch from 2121 MB to 1331 MB — and this is measured against a build that also does the archiving work the old one didn't.

/api/traces streams one ride at a time. It used to load every ride's points at once, parse them all, allocate a second array of lat/lon pairs, then stringify the lot. Peak RSS is about the same either way on an unconstrained heap (V8 grows lazily rather than collecting garbage it isn't pressed for), but the memory the request actually needs changed a lot: the old version OOM-killed the process at a 96 MB heap, while the new one returns the complete 13 MB response at 32 MB. More importantly, that floor is now set by the largest single ride rather than by the size of the whole account.

What's left. The binding constraint on both paths is now the XML parser: fast-xml-parser materialises the whole document, costing about 290 MB for one 11 MB GPX — roughly 26× the file size. That's why the upload path still can't run below a ~320 MB heap no matter how the bytes get there. Fixing it means parsing GPX with a streaming/SAX reader instead of building a DOM, which is a rewrite of server/gpx.js plus a dependency.

Deploy

Pushes to main run .forgejo/workflows/image.yml, which builds the container image and publishes it to src.zerolatitude.dev/gquillery/ridelog (:latest and :sha-<commit>). It needs a REGISTRY_TOKEN repo secret: a Forgejo access token with package write access. Nothing deploys automatically. Run the image wherever you like, most simply with one of the compose files under Docker: deploy/compose.yaml for SQLite, deploy/compose.postgres.yaml for PostgreSQL.

What's real vs. not yet built

  • Rides, stats, splits, elevation/HR profiles, and the map are all computed from your uploaded GPX/FIT files — nothing is fabricated. Those files are kept, so any ride can be downloaded in its original form or reanalyzed from scratch.
  • A ride's own map has two view modes: a dark basemap and "Classic" (standard OpenStreetMap tiles), both drawing that ride's real GPS trace. The overview map on the home page is dark only — every route at low opacity over the dark canvas reads as a heatmap of where you actually ride, and the same lines over street cartography read as clutter.
  • Multi-user with real accounts: password login, admin-issued invites, and each user's rides kept private to them.