Skip to content

feat(web): fill view locations from a GeoIP database when self-hosted - #21

Merged
hjescalona merged 2 commits into
smgfrom
feat/self-hosted-geoip
Nov 13, 2026
Merged

hjescalona merged 2 commits into
smgfrom
feat/self-hosted-geoip

Conversation

@hjescalona

Copy link
Copy Markdown

Why

The analytics map ("Country / City") is always empty on our instance. View locations only come from Vercel's x-vercel-ip-country, x-vercel-ip-country-region and x-vercel-ip-city headers, and Railway's edge sends none of them (only X-Real-IP / X-Forwarded-For).

What

  • New optional env var CAP_GEOIP_DATABASE_PATH: path to a GeoIP2/GeoLite2 City .mmdb file (MaxMind GeoLite2 or DB-IP City Lite).
  • apps/web/lib/visitor-location.ts opens the file once (lazily, on the first view) with mmdb-lib and maps a record to { country, region, city }. Region uses the subdivision code, or its name when the database has no codes (DB-IP Lite).
  • /api/analytics/track and the mobile view event use it only when the Vercel country header is missing, with the client address from clientAddress (last X-Forwarded-For hop off Vercel, so the client can't pick it).
  • Without the variable nothing changes. A missing or unreadable file logs one warning and views keep no location.
  • mmdb-lib@3.0.3 pinned exactly: MIT, no dependencies, no install scripts.

Past views stay without a location: the view event never stored the IP.

The database itself ships with our image through smg/web.Dockerfile in a separate internal/* PR. This branch is upstream-able as is.

Verified

  • visitor-location.test.ts: GeoIP2 and DB-IP record shapes, missing/private/invalid IPs, missing database file (warns once).
  • Against the real DB-IP City Lite 2026-11 file: 8.8.8.8 → US / California / Mountain View, 190.202.0.1 → VE / Zulia / San Carlos del Zulia, 2800:3f0:4001::1 → BR / São Paulo, 127.0.0.1 → null.
  • bun run typecheck and Biome clean; visitor-country and mobile-events tests still pass.

Views only get a country and city from Vercel's x-vercel-ip-* headers, so
self-hosted instances record every view with no location and the analytics
map stays empty.

When those headers are missing and CAP_GEOIP_DATABASE_PATH points to a
GeoIP2/GeoLite2 City .mmdb file (MaxMind, DB-IP Lite), look up the client
address instead. Covers the share page tracker and the mobile view event.
Without the variable nothing changes; a missing or unreadable file logs
one warning and views keep no location.
The route test mocks @/lib/rate-limit as a whole, so it now provides
clientAddress, and mocks the GeoIP lookup to check that Vercel's headers
still win and the database fills the location without them.
@hjescalona
hjescalona merged commit 78115e9 into smg Nov 13, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant