Self-hosted web app that tells you whether it pays to drive a bit further, to another town or across the border, to refuel. It finds stations around you (or along your route), routes to each one and compares total cost = fuel bought + cost of the extra kilometres. It then recommends a station, shows the saving and the number of litres from which the trip pays off.
- Start from browser GPS, a typed address or coordinates, or a pin on the map. You can add an optional destination.
- Fuels: Petrol 95 (E10), Petrol 98 (E5), Diesel (B7), LPG, mapped to each country's local names.
- Enter consumption in L/100 km or km/L, litres to buy and optional running cost per km.
- Round trip (start → station → start) or detour along a route (start → station → destination).
- Results: net saving, extra km/minutes, break-even litres, recommendation with confidence, and a map with station tooltips.
- Navigation links: Google Maps, OSM,
geo:on Android, Apple Maps on iOS. - Every price shows its kind, source and date or age. Estimates are always labelled as such.
- Dutch UI by default, with an NL/EN toggle.
- Tap an information icon to open its explanation on mobile; Enter/Space opens it with the keyboard. Tap again, tap outside, or press Escape to close.
- Expandable sections and manual vehicle fields open/close with a short height transition. Reduced-motion preferences disable the animation; keyboard operation and editable values are unchanged.
- Scroll over the map to continue through results; Ctrl/⌘ + wheel zooms. Click or focus the map to temporarily enable normal wheel zoom (until leaving it). On touchscreens, use two fingers to move/zoom the map; one finger scrolls the page. The floating arrow returns the page and desktop scroll panels to the top, respecting reduced-motion preferences.
- No npm dependencies: runs on the Node 22 standard library, with vanilla JS and Leaflet in the browser.
cp .env.example .env # optional; set CONTACT_EMAIL at least
docker compose up -d --build
# open http://localhost:8080- Out of the box the app uses OpenStreetMap stations with labelled CBS/FOD country estimates.
- Per-station price providers are off by default; see Price sources.
HOST_PORT=9000 docker compose up -dchanges the host port.env_file … required: falseneeds Docker Compose ≥ 2.24.
.github/workflows/docker.yml runs the tests and publishes a multi-arch image (linux/amd64, linux/arm64) to
ghcr.io/<github-user>/tankwijzer:
- on every push to
main, taggedlatestandsha-…; - on version tags
vX.Y.Z, taggedX.Y.ZandX.Y.
To run it:
cp .env.example .env
echo "TANKWIJZER_IMAGE=ghcr.io/<github-user>/tankwijzer:latest" >> .env
docker compose pull tankwijzer && docker compose up -d --no-buildor without Compose:
docker run -d --name tankwijzer -p 8080:8080 --env-file .env \
-v tankwijzer-anwb:/app/data/anwb -v tankwijzer-carbu:/app/data/carbu \
ghcr.io/<github-user>/tankwijzer:latestThe image contains only code. It has no .env and no cached price data.
Requires Node.js ≥ 20.6.
npm start # http://localhost:8080
node --env-file=.env src/server.js # same, reading settings from .env
npm test # unit + API tests (no network needed)For every station S with a price P(S):
detourKm(S) = km(start → S) + km(S → end) − km(base trip) (never below 0)
round trip : end = start, base trip = 0
route : end = destination, base trip = start → destination
cash(S) = litres × P(S) + detourKm(S) × (consumption/100 × P(S) + otherCostPerKm)
cost(S) = cash(S) + detourMinutes(S)/10 × requiredSavingPer10Min (the "detour rule")
cashSaving(S) = cash(baseline) − cash(S) (real money, shown as the € amount)
saving(S) = cost(baseline) − cost(S) (what it is worth to you; used for ranking)
extraKm(S) = detourKm(S) − detourKm(baseline)
break-even = litres at which saving(S) = 0
- Baseline: by default, the station with the smallest detour, the one you'd use without comparing. Alternatively, a price you pay anyway, with 0 extra km.
- Round trip without a destination assumes you would otherwise not drive at all, so all km to the station and back count. If you're driving somewhere anyway, add a destination: then only the extra km compared with the direct route count.
- Detour fuel is valued at the price of the station you refuel at.
- A saving smaller than the minimum saving (default €1) results in "refuel at the nearest station".
- Not included: tolls, parking, loyalty discounts, card fees, queueing.
- Stations are filtered by straight-line distance to the start or route, then routed by road. By default all eligible stations returned by the sources within the radius are included. OSRM routes are requested sequentially in batches of at most 40 stations, with each batch cached. Source coverage, fuel availability and provider request budgets can still limit results; partial coverage is warned about. An optional per-country cap is configurable and produces a warning when stations are omitted.
Choosing a location (GPS, a selected search result, Enter to confirm a search, or a map pin) loads stations and available prices automatically. Typing an address only searches for suggestions; it does not load stations. The fuel selector stays visible, even without a car profile.
On first use, the form starts with clearly labelled example values: 6.5 L/100 km, a 50 L tank with 20% remaining, and 40 L to buy. These activate advice after selecting a location, with an example-values notice beside the car and in the recommendation. Adjust the fields or look up your licence plate to replace them. Saved inputs and remembered cars take precedence; deliberately cleared fields stay empty.
Without usable consumption and litres to buy, stations are ordered by detour distance, with no savings, break-even amounts or recommendation. Missing prices remain visible as unavailable. Fill in the car manually or look up a plate to activate advice. A usable remembered profile also works; old unlabelled default values are not treated as supplied car details. Plug-in hybrids need a manually confirmed real fuel consumption.
Changes update automatically after about 400 ms, including tank level, realism uplift and detour preferences. Invalid vehicle input returns to prices-only mode; invalid search/cost settings show a correction message. New changes cancel or supersede older responses, without moving focus, scrolling to results or resetting the map viewport. Errors have an explicit retry button. Arithmetic changes reuse stations, prices and routes for five minutes; provider-level caches and request budgets remain in force, including on retry.
| Setting | Meaning |
|---|---|
| Is a detour worth it to you? | Any saving counts: money only. Only if the extra driving is worth it: €2 per 10 extra min, minimum €2. Only for a clear win: €5 per 10 extra min, minimum €5. Editing the two fields below switches to My own rule. |
| Every 10 extra min must save (€) | A further station must beat the nearest one by at least this much per 10 extra minutes (OSRM travel times). Used for ranking; the € saving shown stays real money. |
| Ignore savings below (€) | Below this, recommend the nearest station. |
| Search radius (km) | Radius around the start (or along the route) in which stations are considered. |
| Other running cost (€/km) | Wear, tyres and maintenance per detour km (e.g. €0.05–0.15). |
| Compare against | Nearest station or a price I pay anyway. |
You can type a Dutch licence plate (kenteken), or skip it and fill everything in yourself. The manual flow works as before. Belgian and German plates are not supported.
The server looks the plate up in RDW Open Data (CC0) and combines two datasets:
m9d7-ebf2: make, model, body type, first admission and kerb weight;8ys7-d773: fuel rows, hybrid class, power, consumption, CO₂ and emission class (uitlaatemissieniveau).
Extra vehicle info and warnings are fetched in parallel from these datasets (also CC0) and are best-effort. If one of them fails, the lookup still succeeds with a partial profile, and the card names what is missing:
- APK from
m9d7-ebf2vervaldatum_apk(YYYYMMDD), relative to today in Europe/Amsterdam: expired is red, ≤30 days orange, ≤60 days a subtle hint. This shows as a compact badge in the collapsed summary and in detail on the card. - Recalls:
t49b-isb7gives the status per plate. CodeOis an open recall;Pmeans the manufacturer has reported the repair.- For open recalls, the details come from
j9yg-7rg9(defect, repair, more-info link and publication date), looked up byreferentiecode_rdw. - The possible danger comes from
9ihi-jgpf(mogelijk_gevaar). - Resolved recalls are only shown as a collapsible count.
- Body type from
vezc-m2t6(carrosserietype,type_carrosserie_europese_omschrijving). The number of doors comes fromm9d7-ebf2aantal_deuren. - Drive from
3huj-srit(as_nummer,aangedreven_as=J/N):- more than one driven axle → 4x4/AWD
- only axle 1 → front-wheel
- only axle 2 → rear-wheel
- otherwise unknown and not shown. The field is often empty in RDW.
- Extras: pk (kW × 1.36), colour (
eerste_kleur) and emission class.
The extras use the same 24 h cache. If a profile is remembered in the browser, its recall data can be out of date until the next lookup; the APK status is always recomputed against today.
The app shows the vehicle for you to confirm. It then fills in the fields below; each one stays editable, and a label shows where its value came from:
| Field | Source, in order | Label |
|---|---|---|
| Fuel | Benzine → Euro95 E10 (you can switch to E5/98), Diesel → B7, LPG → LPG (preferred when the car runs on both petrol and LPG) | RDW |
| Consumption | WLTP combined → NEDC combined → estimated from CO₂ (petrol CO₂/23.7, diesel CO₂/26.5, LPG CO₂/16.1) | RDW WLTP / RDW NEDC / geschat uit CO₂ |
| Tank capacity | not in RDW; estimated as target range × lab consumption (before the realism uplift), see below. Falls back to kerb weight (<1050 kg 35 L, 1050–1250 kg 42 L, 1250–1500 kg 52 L, >1500 kg 60 L) when consumption is missing, and for plug-in hybrids | geschat (±920 km × 5,5 L/100) / geschat uit gewicht |
Tank model: target range by kerb weight is <1100 kg → 750 km, <1600 kg → 920 km, <2000 kg → 980 km, otherwise
1050 km. For diesel the target is ×1.2, because diesels have similar tanks but use less fuel. The result
(range × L/100 km ÷ 100) is snapped to the nearest of 35, 40, 42, 45, 50, 52, 55, 60, 65, 70, 75 or 80 L and
clamped to 30–80 L. The vehicle card also shows the estimated range: tank ÷ the consumption you are actually using.
Any value you change is labelled door jou aangepast.
- Lab values. WLTP and NEDC are lab figures; real-world consumption is usually higher. A visible +15% realism
option is on by default (
VEHICLE_REALISM_UPLIFT_PCT). You can switch it off or change the percentage. The raw RDW value is always shown next to it. - Plug-in hybrids (OVC-HEV) get a warning: their lab consumption assumes battery driving and is unrealistic for fuel-only use.
- Electric-only cars are not prefilled, because this app is for liquid fuel.
- Litres to buy = tank capacity × (1 − tank level). Set the level with the slider. The litres field remains a direct override.
Privacy and robustness:
- The lookup runs server-side (
/api/kenteken). - Results are cached in memory for 24 h; a "not found" is cached for 1 h.
- The endpoint has its own rate limit of 20 per minute.
- Logs contain only a masked plate (
AB**3D). - Looked-up cars and deliberately entered manual car details are remembered automatically in this browser on this device, including edits and the open/closed state of the car details. Untouched example values are not stored as a real car. Existing saved profiles are retained.
- Vergeet auto / Forget car, beside the car name in the summary, clears the local plate, profile, manual car values and vehicle-related preferences, including legacy preference copies. Location, language and non-car comparison preferences stay unchanged. Stations remain visible in prices-only mode until you enter new car details or look up another car. Reload does not restore the forgotten car or refill example values.
- The small info control beside the forget button explains local storage. Storage failures are shown visibly; calculations still work for the current visit. If deletion fails, clear this site's browser data before reloading. Forgetting a car does not delete the server's separate 24 h RDW cache.
- The detailed vehicle fields are collapsed behind a one-line summary (Aanpassen / Handmatig invullen). They open automatically, with the reason shown, for an EV, a plug-in hybrid, a failed lookup or a missing/invalid consumption or litres value.
- Errors are explicit: invalid format, not found, RDW unavailable or rate limited. In each case manual entry keeps working.
A possible future tank-capacity source is the CarQuery API. It is not integrated because its availability and terms for this use could not be confirmed.
The UI is Dutch by default; the NL / EN toggle in the header switches it, and the choice is remembered in the
browser. The client sends lang (nl/en) with each comparison, so recommendation, warnings and assumptions come
back in that language.
The API defaults to English. Some texts are English only:
- input validation errors;
- geocoder place labels;
- DirectLease price notes.
Price priority per station:
- CARBU.COM (Belgium);
- ANWB Onderweg;
- DirectLease, if enabled;
- country estimate.
Estimates are never presented as station prices, and the recommendation's confidence is lowered when they are used.
| Purpose | Source | Notes |
|---|---|---|
| Stations + prices (NL, BE, DE, …) | ANWB Onderweg POI API | Off by default (ANWB_PRIVATE_USE_ACK). No price date, so the retrieval time is shown. Cached 3 h per area tile. |
| Station prices (BE) | CARBU.COM station lists | Off by default (CARBU_PRIVATE_USE_ACK). Includes each station's price date. Cached 3 h. |
| Station prices (NL, BE) | DirectLease via the optional pyfuelprices sidecar |
Off by default (DIRECTLEASE_PRIVATE_USE_ACK). Cached 24 h per station. |
| NL estimate | CBS StatLine 80416ned | Open data (CC BY 4.0). Daily national average, published about a week late. No 98 octane. |
| BE estimate | FOD Economie maximum prices | Legal maximum price. Stations often sell lower, so BE savings are conservative. |
| Stations (fallback) | OpenStreetMap via Overpass | ODbL. Used when ANWB is off or unavailable. NL/BE only. |
| Routing | OSRM public demo | Falls back to straight line × ROAD_FACTOR, labelled as such. |
| Geocoding | Photon (or Nominatim) | Search-as-you-type, cached. The public Photon takes about 3–5 s per query. |
| Instant place suggestions | GeoNames (data/places.tsv) |
CC BY 4.0. Offline index of towns and NL/BE/LU 4-digit postcodes, answers in milliseconds while Photon loads streets/addresses. Rebuild with python scripts/build_places.py; turn off with LOCAL_PLACES=false. |
| Fast streets/addresses NL | PDOK Locatieserver (Kadaster, BAG/NWB) | Open data (CC0), no key. ~0.1-0.3 s per query, biased towards your location. |
| Fast streets/addresses Flanders + Brussels | Digitaal Vlaanderen geolocation | Open service, no key. Wallonia, Germany and the rest still come from Photon (slow public server; self-host Photon via PHOTON_URL to fix). Turn off with OFFICIAL_GEOCODERS=. |
| Map tiles | OpenStreetMap | Tile usage policy |
ANWB Onderweg, CARBU.COM and DirectLease data is not open data. Only enable these providers if you have the provider's own permission, for a private installation. Each provider:
- uses normal requests with an honest User-Agent;
- caches results and enforces request budgets;
- on HTTP 401/403, writes
blocked.jsonin its data directory and stops all requests to that provider.
Remove blocked.json only after the provider has restored access.
Stations in countries without a country estimate (e.g. DE, FR, LU) are compared only when they have a station price, or when you enter a price per country under Advanced.
All settings are environment variables (see .env.example). Nothing secret is sent to the browser.
| Variable | Default | Purpose |
|---|---|---|
PORT / HOST |
8080 / 0.0.0.0 |
Listen address |
CONTACT_EMAIL |
- | Added to outgoing User-Agents (recommended by OSM policies) |
HTTP_USER_AGENT |
tankwijzer/0.2 (self-hosted; <email>) |
Outgoing User-Agent for OSM services |
HTTP_TIMEOUT_MS |
15000 |
Outgoing request timeout |
STATION_PROVIDER |
anwb |
anwb (falls back to Overpass) or overpass; if both fail, the app shows an error |
OVERPASS_URLS |
public mirrors | Comma-separated, tried in order |
STATION_CACHE_TTL_S |
86400 |
Station cache |
SEARCH_RADIUS_KM |
20 |
Default radius (UI allows 1–50) |
MAX_STATIONS_PER_COUNTRY |
0 |
0 = all eligible stations within the radius; positive = optional cap per country, with a visible warning when reached |
PRICE_PROVIDERS |
carbu,anwb,directlease,cbs-nl,fod-be |
Order = priority; providers without their ACK stay inactive |
ANWB_PRIVATE_USE_ACK / ANWB_PAUSED |
false / false |
Enable ANWB Onderweg / make no ANWB requests |
ANWB_DATA_DIR |
data/anwb |
Tile cache, request log, blocked.json |
ANWB_CACHE_TTL_H |
3 |
Re-fetch an area tile at most once per TTL |
ANWB_STALE_AFTER_H / ANWB_MAX_AGE_H |
12 / 24 |
Mark older quotes / never use older quotes |
ANWB_MIN_INTERVAL_MS |
2000 |
Gap between requests |
ANWB_HOURLY_BUDGET / ANWB_DAILY_BUDGET |
30 / 150 |
Request caps |
ANWB_MAX_TILES_PER_SEARCH |
6 |
Area tiles fetched per search |
ANWB_ERROR_BACKOFF_MIN / ANWB_TIMEOUT_MS |
30 / 20000 |
Pause after errors / request timeout |
CARBU_PRIVATE_USE_ACK / CARBU_PAUSED |
false / false |
Enable CARBU.COM / make no CARBU.COM requests |
CARBU_DATA_DIR |
data/carbu |
List/location cache, request log, blocked.json |
CARBU_CACHE_TTL_H |
3 |
Re-fetch a station list at most once per TTL |
CARBU_STALE_AFTER_H / CARBU_MAX_AGE_H |
12 / 24 |
Mark older lists / never use older lists |
CARBU_PRICE_STALE_DAYS / CARBU_PRICE_MAX_DAYS |
7 / 30 |
Mark / drop quotes by price date |
CARBU_MAX_LOCATIONS_PER_SEARCH |
2 |
Station lists fetched per search |
CARBU_MATCH_RADIUS_M |
200 |
Max distance for matching a station |
CARBU_MIN_INTERVAL_MS |
1500 |
Gap between requests |
CARBU_HOURLY_BUDGET / CARBU_DAILY_BUDGET |
20 / 100 |
Request caps |
DIRECTLEASE_URL |
http://127.0.0.1:8090 |
Sidecar URL (Compose profile directlease) |
DIRECTLEASE_PRIVATE_USE_ACK / DIRECTLEASE_PAUSED |
false / false |
Enable DirectLease / make no DirectLease requests (app and sidecar) |
DIRECTLEASE_CACHE_TTL_H / DIRECTLEASE_MAX_AGE_H |
24 / 36 |
Sidecar cache / never use older quotes |
PRICE_CACHE_TTL_S |
10800 |
CBS/FOD cache |
CBS_URL, FOD_PDF_URL |
official URLs | Override if they move |
ROUTING_PROVIDER |
osrm |
osrm or estimate (no network) |
OSRM_URL |
public demo | Point to your own OSRM for heavier use |
ROAD_FACTOR |
1.3 |
Straight-line → road multiplier for the fallback |
GEOCODER |
photon |
photon, nominatim (whole words only) or none |
PHOTON_URL, NOMINATIM_URL, GEOCODE_COUNTRIES |
public instances, European countries | Geocoder. A self-hosted Photon (PHOTON_URL) makes street/address search fast too. |
LOCAL_PLACES |
true |
Instant offline town/postcode suggestions (GeoNames) |
OFFICIAL_GEOCODERS |
pdok,vlaanderen |
Fast official address registers (NL, Flanders/Brussels); empty disables; only used for countries in GEOCODE_COUNTRIES |
PDOK_URL / VLAANDEREN_GEO_URL |
public endpoints | Override the register base URLs |
MAP_TILE_URL, MAP_TILE_ATTRIBUTION |
OSM tiles | Map tiles |
MIN_WORTHWHILE_SAVING_EUR |
1.0 |
Below this, recommend the nearest station |
RATE_LIMIT_PER_MIN |
30 |
Per-IP limit for geocode/compare/prices |
docker compose --profile directlease up -d
# or locally (Python 3.11+):
python -m venv .venv-dl && .venv-dl/bin/pip install -r sidecar/directlease/requirements.txt
DIRECTLEASE_DATA_DIR=./directlease-data .venv-dl/bin/python sidecar/directlease/app.py # :8090
python -m unittest discover -s sidecar/directleaseTo use it, set DIRECTLEASE_PRIVATE_USE_ACK=true in .env (read by both the app and the sidecar).
In src/providers/prices.js, a price provider is an object with one of:
stationPrice(station, fuelId, ctx), with an optional asyncprefetch(stations);reference(fuelId), per country.
Either one returns:
{ price, kind: 'station'|'national-average'|'legal-maximum', quality: 'live-quote'|'stale-quote'|'country-estimate',
estimate, source, sourceUrl, license, asOf, fetchedAt, live, note }Register it in priceFactories in src/server.js and add its id to PRICE_PROVIDERS.
The public OSRM, Photon, Nominatim and OSM tile servers are community resources meant for light use. For heavier use, self-host them or use a commercial provider.
Put the app behind a TLS reverse proxy: browsers only allow GPS on HTTPS or localhost.
GET /api/health: livenessGET /api/config: fuels, defaults, providersGET /api/geocode?q=: address search (orlat,lon)GET /api/prices: status of price providersGET /api/kenteken?k=: RDW vehicle lookup (Dutch plates; 400 invalid, 404 not found, 429 rate limited, 501 disabled, 502 RDW unavailable)POST /api/compare:{ start:{lat,lon}, destination?, fuel, advice?, litres?, consumption?, perKmCost?, timeValuePerHour?, minSaving?, radiusKm?, baseline?:{mode:'nearest'|'custom', price?}, lang?, refresh? }advicedefaults totrue: requires consumption above 0 and at most 50 L/100 km, and 1–200 litres.advice:falsediscovers stations/prices without vehicle values. Returnsinput.advice:false, null consumption/litres, null baseline/recommendation, emptybestByCountry, and no economic fields on results. Results are ordered by detour distance; price or route can be null. Source/fallback warnings and navigation remain available.- Discovery and advice share an in-memory preparation cache keyed by coordinates, fuel, radius and language
(five minutes, at most 100 areas). Concurrent requests share preparation. Arithmetic and ranking changes do not
call external providers again.
refresh:trueinvalidates this preparation, not provider caches or block markers. Failed preparation is not cached, so an explicit retry can recover.
src/economics.js pure cost model
src/compare.js orchestration, validation, price priority, recommendation
src/i18n.js NL/EN helpers for server texts
src/providers/ ANWB, CARBU.COM, stations (Overpass), prices (CBS, FOD, DirectLease), routing, geocoding
sidecar/directlease/ optional Python pyfuelprices sidecar
src/server.js HTTP server, static files, security headers, rate limiting
public/ UI (vanilla JS + Leaflet)
data/ offline place index (places.tsv), runtime caches
scripts/ build_places.py: rebuilds data/places.tsv from GeoNames (CC BY 4.0)
test/ node:test suites
.github/workflows/ CI: tests + multi-arch image to ghcr.io
- ANWB gives no price date, so freshness means the retrieval time. Always check the pump.
- Many Belgian CARBU.COM prices equal the legal maximum.
- There is no NL 98-octane estimate: enter a price under Advanced.
- Without Leaflet (loaded from unpkg.com), the app works without the map.