Troubleshooting
If something’s broken, find your symptom below. Most issues have a two-line fix. Anything that needs more than that points to a file you’ll already know how to read.
Container won’t start
Section titled “Container won’t start””Database connection timeout after 30 attempts”
Section titled “”Database connection timeout after 30 attempts””The app waits 60 seconds for Postgres before giving up. Most likely causes:
- Postgres container itself crashed —
docker compose logs dbwill show why. Most common: wrongDB_PASSWORD(it changed since the volume was created — Postgres won’t accept the new password against existing data). DATABASE_URLis wrong — pointing at a host that doesn’t exist on the Docker network. The bundled compose usesdbas the hostname.- DNS / network isolation — Docker bridge network not created, or the app container is on a different network than
db.
Try first: docker compose -f docker-compose.prod.yml ps — if db
isn’t (healthy), fix that before fixing the app.
”Migration failed with exit code 1”
Section titled “”Migration failed with exit code 1””The entrypoint will retry on the next boot, but you’ll see the underlying error in the logs. Common cases:
- Schema drift — someone edited the database manually and Prisma sees columns it didn’t add. Resolve by rolling back the marker row:
docker exec travstats-app npx prisma migrate resolve --rolled-back <migration_name>then restart. - Disk full —
df -hon the host. Postgres needs free space for transaction logs even on a read-only failed migration. - Missing PostGIS extension — only happens on external-Postgres setups. Run
CREATE EXTENSION postgis;against the database, then restart.
”JWT_SECRET is invalid”
Section titled “”JWT_SECRET is invalid””The persisted secret got truncated, partially overwritten, or its file lost permissions. Delete it and let the entrypoint regenerate (this signs out everyone — they re-log-in with the same password):
docker exec travstats-app rm /app/data/secrets/jwt.secretdocker compose -f docker-compose.prod.yml restart appSign-in problems
Section titled “Sign-in problems”Login loop / immediate logout
Section titled “Login loop / immediate logout”Almost always one of two things behind a reverse proxy:
-
X-Forwarded-Protois missing. TravStats reads it to decide whether to set theSecureflag on the JWT cookie. If your proxy doesn’t forward it and the browser is on HTTPS, the cookie is dropped and you’re back at login.nginx fix:
proxy_set_header X-Forwarded-Proto $scheme; -
COOKIE_SECURE=trueon plain HTTP. Inverse problem — you’re onhttp://travstats.lan:3010and the cookie is setSecure-only, so the browser drops it. Fix: leaveCOOKIE_SECUREunset (auto-detect) or set it explicitly tofalsefor LAN-only HTTP.
DevTools → Application → Cookies should show travstats_session set
on the response from /api/v1/auth/login. If it’s set there but
missing on subsequent requests, you’re hitting one of the above.
”Setup wizard keeps appearing”
Section titled “”Setup wizard keeps appearing””The frontend redirects to setup whenever /api/v1/setup/status
returns requiresSetup: true. That endpoint counts admin users — if
none exist, you get the wizard. Cause: someone deleted every admin.
docker exec -it travstats-db psql -U flights flights -c "SELECT id, username, is_admin FROM users WHERE is_admin = true;"If empty, promote a user manually or run the createAdmin.js
fallback from the first-run page.
”Forgot password” email never arrives
Section titled “”Forgot password” email never arrives”SMTP isn’t configured (or the configured server is rejecting). Admin → SMTP has a Send test mail button — start there. If the test fails, check your provider’s auth requirements (most need an app-specific password, not your account password).
If you’re locked out and can’t get to the admin panel: see the recovery steps.
Locked out by two-factor authentication
Section titled “Locked out by two-factor authentication”You lost the phone with the authenticator, and the recovery codes with it. The password alone will not get you in — that is the point.
- You still have a recovery code. Enter it where the six-digit code is asked for. Each code works once; set two-factor up again afterwards and store the new codes somewhere else.
- You have a passkey on another device. A passkey replaces the password and satisfies two-factor by itself. Sign in with it and reset two-factor under Settings → Security.
- Neither. Ask an administrator: Admin → Users → Reset two-factor clears the secret, and you sign in with the password alone. If you are the only administrator, another admin has to be promoted through the database first — the first-run page has the steps.
Details on Security.
The passkey button is missing, or always fails
Section titled “The passkey button is missing, or always fails”A passkey is bound to one relying-party id, so the rpId is an explicit admin setting, never guessed from the host header. The button is hidden and the security section explains why when: no rpId is configured; the page is not a secure context (plain http anywhere but localhost — a LAN address over http will not do); or the rpId is a bare IP address, which is not a valid rpId at all. Fix the setting or front the instance with HTTPS; see Reverse proxy.
Parsing problems
Section titled “Parsing problems”Email parser returned no flight at all
Section titled “Email parser returned no flight at all”First ask whether it should have. A marketing mail — a promotion, a holiday greeting, “ab 380 EUR” — yields nothing by design: a booking must carry a flight number or both ends of a route, and a date is not evidence. That is the parser working.
For a real confirmation, most likely: the email is from an airline TravStats doesn’t have a template for, and Ollama isn’t reachable / not configured.
Check:
docker exec travstats-app curl -fsS http://ollama:11434/api/tagsShould list installed models. If the call 503s, the Ollama container isn’t running. If it lists no models, pull one:
docker exec travstats-ollama ollama pull gemma3:12bBuilt-in templates exist for: LH (Lufthansa, both old and new formats), LX (Swiss), OS (Austrian), SN (Brussels), FR (Ryanair), U2 (easyJet), EW (Eurowings), W6 (Wizz Air), and Booking.com for hotels. For other carriers you can record a user template.
The document came back as the wrong kind
Section titled “The document came back as the wrong kind”You dropped a flight confirmation and got a hotel form, or the other way round. The server scores the document to decide its domain; the result carries the runners-up, and the dialog offers a switch. Use it rather than re-dropping the file. A document that scores weakly on every domain still returns what it parsed.
A hotel confirmation cannot be read
Section titled “A hotel confirmation cannot be read”Three causes, each now named in the result rather than hidden:
- “No parser produced a result” is not “no parser ran”. The lodging parser needs a name and both dates; the result says who looked and what was dropped.
- A photographed bill goes through
POST /parse-imageand the OCR worker. It reads German and English; on an air-gapped instance it degrades to English rather than failing. - A mail that is two thirds tracking links is cleaned before the model sees it, for the model only — a template may key on a link.
Email parser extracted wrong dates
Section titled “Email parser extracted wrong dates”The most-common case is multi-leg bookings where the parser picked up the wrong leg. The review screen lets you delete the unwanted suggestions before saving — nothing’s persisted until you click Save all.
If the date is consistently off by hours (not days), it’s a timezone issue — make sure your Admin → Settings → Timezone is set correctly. The container itself runs UTC; the UI converts to your configured display timezone.
Boarding pass scan returned blank fields
Section titled “Boarding pass scan returned blank fields”Vision parsers cascade in this order: Ollama vision model → OpenAI / Claude (if API key set) → Tesseract OCR → manual entry pre-fill.
If everything blanks out:
- Check
OLLAMA_URLpoints to a container with a vision-capable model installed (e.g.llama3.2-vision) - Try uploading a clearer photo — Tesseract is picky about glare and angle
- Check rate limits:
boardingPassParseLimiterallows N requests/min per user; busy users hit it during bulk re-imports
Numbers that moved
Section titled “Numbers that moved”The country count changed after the upgrade
Section titled “The country count changed after the upgrade”It is supposed to. Since 2.6.0 a country counts by the evidence that proves you were there — a completed stay, a recorded place, a port call, a day on the ground — not by where a flight touched down. The headline rises for accounts with cruises, places or hotel stays, and falls for accounts whose countries rested on airport connections. A connection country is greyed out and kept, never hidden, and the passport says once what the old rule and the new one make of your own rows. Nothing was deleted.
If the number still looks wrong, open the passport: every counted country names its evidence and links to the records behind it. A country that should be there and is not usually means the stay that proves it has no check-out yet, the booking is still ahead, or the house is marked cancelled. How strict the headline is — whether a connection counts — is a setting, an instance default with a per-user override. See Countries & passport.
A data-quality flag appeared in the inbox
Section titled “A data-quality flag appeared in the inbox”Two sources inside one of your records disagree: an address whose country differs from the stored one, coordinates that fall outside the claimed country, a country resting solely on records with no date, a check-out before its check-in. Nothing was corrected and no number was changed — a third-party geocoder does not get a veto over your own data, so the record was written and the question waits.
Answer it in the Inbox. “I corrected it” lets a surviving contradiction come back; “This is right” never asks again — the escape hatch for a district that shares a country’s name. A pin in the sea, or in a territory the offline outlines do not attribute, is not a disagreement and raises no flag.
An amount says “no rate” and the total leaves it out
Section titled “An amount says “no rate” and the total leaves it out”The stay or flight is priced in a currency for which neither the European Central Bank nor the fallback dataset has a rate for that day — or the administrator switched the fallback off. The amount is kept in its own currency, marked, and left out of every total, which says how many it left out. You may enter a rate yourself under the amount; it is then shown as yours, never as an official one. See Money & currencies.
Passport or places answers 404, or bounces home
Section titled “Passport or places answers 404, or bounces home”Both sit behind the instance’s beta switch, which is off by
default. With the switch off, /passport and /places go home like
any closed gate, the Places dashboard tab and statistics tab are not
offered, and the Devices section in settings is not shown. An
administrator turns the switch on under Admin → Instance; it applies
without a reload. Data recorded while the switch was on is untouched
by turning it off. See Beta features.
Flight time or hours in the air went up or down
Section titled “Flight time or hours in the air went up or down”Two rules changed in 2.6.0. A flight saved with a date and no times carries “12:00 → 13:00” that nobody measured; those placeholder clocks are no longer subtracted anywhere, and such a flight is estimated from its coordinates and labelled as an estimate. And a year with no priced flight shows a dash and says how many flights had no price, never “0 €”. Both move the figures for accounts with such rows; both are correct.
Map / globe doesn’t render
Section titled “Map / globe doesn’t render”Almost always WebGL or content-security-policy related.
- DevTools → Console. Look for errors mentioning
WebGL,WEBGL_lose_context, orCSP. - No errors but blank canvas? Try a hard reload (
Ctrl + Shift + R) — three.js can lose its WebGL context when the tab was backgrounded for a long time. - CSP blocking something? TravStats ships its own
Content-Security-Policyon the page since 2.6.0 —script-src 'self'with nounsafe-eval,connect-srcdeliberately broad because a self-hosted instance talks to the Immich, Dawarich or OSRM the operator names, plusdata:andblob:because deck.gl fetches its icon atlases from inline SVGs (a candidate without them lost every arrow on the map). If your reverse proxy adds a stricter policy on top, the browser applies both; check the console for the blocked scheme. Verify a CSP in a browser, not with curl — the first attempt silently blocked the fonts and Leaflet’s stylesheet, which no reading of the header would have shown. - Map tiles don’t load? Check the network tab — TravStats fetches from OpenStreetMap-compatible tile servers by default. If your firewall blocks them, point to a self-hosted tileserver under Admin → Settings → Map → Tile Server.
TravStats writes structured JSON via Pino to four files inside
/app/data/logs/:
| File | Contains | Default level |
|---|---|---|
app.log | Everything the application emits | info |
error.log | Errors and warnings only (subset of app.log) | error |
http.log | One line per HTTP request (method, path, status, latency, user) | info |
parser*.log | Parser pipeline traces (template detection, Ollama prompts, fallback decisions) | debug |
Read them
Section titled “Read them”From the host:
docker exec travstats-app tail -f /app/data/logs/app.log | jq .Or just docker compose logs -f app — Pino writes to stdout too,
so docker logs and the file see the same stream.
Raise verbosity for one debugging session
Section titled “Raise verbosity for one debugging session”In the UI: Admin → Settings → Logging → Level → debug, save.
Persists across restarts. Drop it back to info when done — debug
adds a lot of volume to app.log.
For one-off Prisma query inspection:
docker exec -e PRISMA_LOG_LEVEL=info travstats-app …Disk usage runaway
Section titled “Disk usage runaway”docker system dfdocker exec travstats-app du -sh /app/data/*Common culprits:
/app/data/logs/— unbounded growth if backups aren’t pruning.Admin → Settings → Logging → Retention./app/data/backups/— every nightlypg_dumpaccumulates. Lower the Admin → Settings → Backups → Retention setting.- Old image tags —
docker image prune -fafter upgrades.
Per-flight diagnostic JSON (v1.5+)
Section titled “Per-flight diagnostic JSON (v1.5+)”When a bug touches specific flights or trips — wrong duration, trip
detection missing a leg, an importer flagging the wrong row — the
maintainer needs to see that data (redacted), not just server logs.
The GET /api/v1/diagnostics endpoint returns a user-scoped JSON
snapshot of the flights and trips you select.
Filters (combine freely):
flightIds=<csv>— by row IDtripIds=<csv>— by trip IDairline=<iata>— every flight for that airlinesince=<ISO8601>— only flights withdep_utc >= since
PII is stripped server-side: passenger name, booking reference, notes, gate, terminal — anything personally identifiable that isn’t required to reproduce a flight-data bug.
Get the JSON with a Personal Access
Token (read scope is enough):
curl -H "Authorization: Bearer ts_pat_…" \ "https://travstats.example.com/api/v1/diagnostics?flightIds=abc,def"Paste into the GitHub issue. The maintainer can replay the exact rows against the test suite without having to ask follow-up questions.
Distinct from the Diagnostic export bundle below: that one captures server logs + system info (“is this an infrastructure problem”), this one captures redacted user data (“why does my data look wrong”).
When to file an issue
Section titled “When to file an issue”Before opening one, capture:
- TravStats version — bottom of any page in the UI, or
cat /app/backend/VERSIONinside the container. - The container logs around the failure —
docker compose logs --tail 200 app db. Redact any API keys. - A repro — what you clicked or which API call you made. Curl + headers if you can.
File at github.com/Abrechen2/TravStats/issues. The bug-report template will ask for exactly the things above.
The in-app Report Bug button (Settings → footer) builds an anonymised diagnostic bundle and copies it to your clipboard — paste it into the GitHub issue and you’ve covered most of what maintainers will ask.
Diagnostic export bundle
Section titled “Diagnostic export bundle”The bundle is a single JSON document — small enough to paste into an issue, structured so maintainers can grep through it. Same content as Admin → Settings → System info → Diagnostic export.
What’s in the bundle
Section titled “What’s in the bundle”- Recent log entries from
app.loganderror.log(latest ~500 lines per file) - System info — TravStats version, build date, Node version, Postgres version, database size, backup count, current health status
- Settings sketch — instance name, registration mode, configured-or-not flags for each external service (no values), log level, retention policy, backup schedule
- Log statistics — how many
error/warn/infolines in the rolling window
What’s redacted
Section titled “What’s redacted”The bundle goes through a defensive scrubber before download. Stripped entirely if they appear as object keys:
ip, ipAddress, userAgent, email, notificationEmail,
password, passwordHash, token, auth_token, authorization,
cookie, cookies, resetToken, changeToken, apiKey,
api_key, openaiApiKey, claudeApiKey, globalOpenaiApiKey,
globalClaudeApiKey, airlabsApiKey, aviationstackApiKey,
clientSecret, accessToken, refreshToken.
Pattern-replaced when they appear in any string value:
| Pattern | Replaced with |
|---|---|
JWT-shaped tokens (eyJ…) | <redacted:jwt> |
Email addresses ([email protected]) | <redacted:email> |
| IPv4 addresses | <redacted:ip> |
| UUIDs (user IDs, flight IDs, …) | <redacted:uuid> |
What’s NOT in the bundle
Section titled “What’s NOT in the bundle”- Flight data (no routes, no airlines, no dates)
- User account data (no usernames, no emails, no passwords)
- API key values for any external service
- Encryption key, JWT secret, or any other secret from
/app/data/secrets/ - Anything from the database tables — only logs + system info
When to use it
Section titled “When to use it”When you’re filing a GitHub issue. The bundle answers most of the “can you reproduce / what version / what config / what’s the error” questions in one paste. Without it, the maintainer has to ask follow-up questions for two days; with it, you usually get a fix on first reply.
If you’re nervous about residual PII in the redacted bundle, open the JSON in a text editor before pasting and grep for anything that looks identifying — the scrubber is conservative, but the “check before paste” step is yours to make.