A Ktor server that serves UK petrol-station and fuel-price data to the Fueller Android app.
The backend can populate its in-memory cache in one of two ways. The /api/search contract served to mobile clients is identical in both.
The backend periodically calls the UK government Fuel Finder API directly, using OAuth2 client credentials. Used in development and once the deployed backend's egress IP is whitelisted by the Fuel Finder team.
Required configuration:
FUEL_FINDER_MODE=pull(or unset — pull is the default)FUEL_FINDER_CLIENT_IDFUEL_FINDER_CLIENT_SECRET
Optional:
fuelfinder.baseUrl(default:https://stg.fuel-finder.ics.gov.uk)fuelfinder.priceRefreshMinutes(default: 30)fuelfinder.stationRefreshHours(default: 24)fuelfinder.staleAfterMinutes(default: 90)
A separate ingester (typically a script on a whitelisted laptop or a CI job) fetches data from Fuel Finder and POSTs it to the backend via POST /admin/ingest. The backend stores no Fuel Finder credentials in this mode.
Required configuration:
FUEL_FINDER_MODE=pushINGEST_TOKEN(32+ bytes random; shared with the ingester)
Optional:
ingest.maxBodyBytes(default: 33,554,432 — 32 MiB)fuelfinder.staleAfterMinutes(default: 90)
The first /api/search request after startup returns 503 until the first ingest lands. If no ingest arrives for longer than the stale threshold, /api/search returns 503 again until the cache is refreshed.
Public endpoint consumed by the Android app. Identical contract in both modes.
Returns 503 if the cache is empty (dataLoaded = false) or stale (isStale = true).
{
"status": "ok",
"mode": "push",
"dataLoaded": true,
"isStale": false,
"lastPriceRefresh": "2026-04-29T20:00:00Z",
"nextPriceRefresh": "2026-04-29T20:30:00Z",
"stationCount": 8421,
"priceCount": 8237,
"ingestSource": "laptop-push",
"ingesterVersion": "1.0.0"
}External monitoring should poll this endpoint and alert when isStale: true or non-200.
POST /admin/ingest
Content-Type: application/json
X-Ingest-Token: <secret>
{
"fetched_at": "2026-04-29T20:00:00Z",
"ingester_version": "1.0.0",
"stations": [ <Fuel Finder station objects, passthrough> ],
"prices": [ <Fuel Finder price-record objects, passthrough> ]
}
| Status | Meaning |
|---|---|
| 200 | Accepted, cache swapped atomically |
| 400 | Malformed JSON, missing fields, empty arrays, or unparseable fetched_at |
| 401 | Missing or wrong X-Ingest-Token |
| 409 | fetched_at is older than the currently cached snapshot (out-of-order) |
| 413 | Payload exceeds ingest.maxBodyBytes |
| 503 | Server is in pull mode |
cd backend
./gradlew build # compile + tests
./gradlew shadowJar # produces backend/build/libs/fueller-backend.jar
./gradlew ebBundle # produces backend/build/eb/fueller-backend-eb.zip (for EB deploys)
The backend runs on a single-instance Elastic Beanstalk environment in eu-west-1
(fueller-backend-env). TLS for api.fueller.app is terminated by nginx on the
EC2 instance using a Let's Encrypt cert.
Deploy with the EB bundle, not a bare JAR. Uploading a bare JAR works for
the Java runtime, but it bypasses the .platform/nginx/conf.d/ config in this
repo — which means EB regenerates /etc/nginx/ from defaults on each deploy
and silently strips the HTTPS server block. Always use the ZIP produced by
ebBundle:
cd backend
./gradlew ebBundle
# Upload backend/build/eb/fueller-backend-eb.zip via:
# - AWS Console: EB → environment → Upload and deploy
# - or: eb deploy (if EB CLI is configured for this project)
The bundle contains:
| File | Purpose |
|---|---|
fueller-backend.jar |
The Ktor server (shadowJar output) |
Procfile |
Tells EB how to run the JAR (web: java -jar fueller-backend.jar) |
.platform/nginx/conf.d/00-https.conf |
nginx HTTPS server block + HTTP→HTTPS redirect for api.fueller.app |
.platform/hooks/predeploy/01-tls-precheck.sh |
Gates the deploy on the TLS cert existing at /etc/letsencrypt/live/api.fueller.app/; fails the deploy with a clear recovery message if the cert is missing (e.g. fresh EC2 instance) |
The cert itself lives in /etc/letsencrypt/ on the instance, which EB does
not touch — so the cert survives deploys, and only the nginx wiring to use it
needs to be regenerated, which is exactly what .platform/nginx/conf.d/ does.
Cert auto-renewal runs on certbot's systemd timer independently of EB.
If EB ever replaces the underlying EC2 instance (platform updates, manual
rebuild env, autoscaling event), /etc/letsencrypt/ will be empty on the new
host and the predeploy precheck will fail with the recovery procedure. Briefly:
- Add an SSH (port 22) inbound rule to the env's security group, source: your laptop's IP
- EC2 Instance Connect into the new instance (AWS Console: EC2 → Instances → Connect)
sudo dnf install -y certbot python3-certbot-nginxsudo certbot --nginx -d api.fueller.app --non-interactive --agree-tos --email gav_h_thomas@yahoo.co.uk --redirect- Lock port 22 back down
- Re-trigger the deploy — the
.platform/config now has its cert and the precheck passes
Pull mode (development):
export FUEL_FINDER_MODE=pull
export FUEL_FINDER_CLIENT_ID=...
export FUEL_FINDER_CLIENT_SECRET=...
java -jar backend/build/libs/fueller-backend.jar
Push mode:
export FUEL_FINDER_MODE=push
export INGEST_TOKEN=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-')
java -jar backend/build/libs/fueller-backend.jar
See Fueller_LaptopIngest_Design.md in the workspace folder for the full design rationale and the laptop-side ingester spec.