Wiki
GeoIP & Geographic Access Control
How QuoteNode uses a local GeoIP database for country-level access control and offer tracking.
GeoIP & Geographic Access Control
QuoteNode includes optional geographic access control and country-level tracking using a local GeoIP country database (keyless DB-IP Country Lite by default, or MaxMind GeoLite2).
Two Uses of GeoIP
1. Application access control
When enabled, the GeoIP filter can restrict application access to specific countries. Requests from disallowed countries receive HTTP 403 (Forbidden).
This is useful for organizations that:
- Operate only within specific jurisdictions
- Want to reduce exposure to automated attacks from certain regions
- Must comply with data residency or access control regulations
2. Offer interaction tracking
When a client opens a public offer link, the system records the client’s country code (ISO 3166-1 alpha-2) alongside the interaction event. This provides geographic context for offer analytics:
- “Your offer was opened from Germany” — confirms international reach
- “Multiple opens from different countries” — may indicate the offer was forwarded
How It Works
Database
GeoIP resolution uses a local country database in MaxMind DB (.mmdb) format — not an external API. All lookups happen in memory with no network calls.
By default the database is the keyless DB-IP Country Lite edition (CC-BY-4.0, no account required). The container fetches it automatically on startup (and on a daily schedule) if it is missing or stale — independently of GEOIP_ENABLED, governed by GEOIP_AUTO_UPDATE (default true). The database can therefore be ready while country enforcement is off. There is no manual download or file-mounting step. To use MaxMind GeoLite2 instead, set MAXMIND_ACCOUNT_ID / MAXMIND_LICENSE_KEY (a free MaxMind account); it is the same .mmdb format. You can also populate the file manually with scripts/update-geoip.sh.
IP Detection
The client IP is resolved entirely by the application — it is not taken blindly from request headers. A forged X-Forwarded-For, X-Real-IP or CF-Connecting-IP from a direct client can never spoof the country check.
- The application takes the transport peer (the address that actually opened the connection).
- If that peer is not internal, all forwarding headers are ignored and the peer is used directly. A peer is internal when it is an explicitly trusted proxy or any non-globally-routable address (private/RFC 1918, loopback, link-local, CGNAT, IPv6 ULA) — which is always container-internal behind Docker/Coolify.
- If the peer is internal, the client is derived from its forwarding metadata in order:
CF-Connecting-IP(so a CDN such as Cloudflare that rewritesX-Forwarded-Forstill yields the real client) →X-Forwarded-For(walked right-to-left, skipping internal hops) →X-Real-IP. Only literal IPs are accepted (no DNS).
Because private peers and CDNs are handled automatically, most deployments need no configuration — the bundled Caddy, a Coolify edge, or a CDN in front all work out of the box. The optional SECURITY_TRUSTED_PROXIES variable (comma-separated literal IP/CIDR and/or service hostnames) only adds trust for an unusual proxy whose transport peer is a public address.
The client IP is a coarse, best-effort filter — not authentication. Anyone who can reach the origin directly (bypassing the CDN/proxy) could present a chosen
CF-Connecting-IP; the recommended hardening is to firewall the origin so only your CDN/proxy can reach it.
Non-globally-routable client addresses (loopback, private/RFC 1918, link-local, CGNAT, IPv6 ULA) are never geo-blocked — they cannot be geolocated — so localhost health checks and LAN/same-host deployments keep working while GeoIP is active. The IP whitelist and authentication still apply to them.
Exempt paths
The following are always exempt from geographic restrictions so they stay reachable worldwide:
- public offer links (
/offer/public/*) and public branding (/api/v1/public/branding/*); - email-link actions: unsubscribe (
/api/v1/notifications/unsubscribe-token/*) and public preferences (/api/v1/notifications/preferences-public/*); - infrastructure/health endpoints (
/health*,/actuator/health,/actuator/info).
Admin authentication (/api/v1/auth/login, 2FA) and authenticated assets stay geo-enforced.
Configuration
| Variable | Default | Description |
|---|---|---|
GEOIP_ENABLED | false | Country-enforcement switch only — also the break-glass switch. It does not affect database loading: the DB still loads and auto-updates (see GEOIP_AUTO_UPDATE), so it can be ready while enforcement is off. false disables all country enforcement reliably (see recovery below). |
GEOIP_DB_PATH | /app/data/geoip/GeoLite2-Country.mmdb | Path to the country .mmdb file (auto-populated when GeoIP is enabled). |
GEOIP_AUTO_UPDATE | true | Auto-fetch the database on startup (keyless DB-IP by default; MaxMind when MAXMIND_* are set) when missing or older than GEOIP_AUTO_UPDATE_MAX_AGE_DAYS (default 25). Set false to manage the file yourself. |
SECURITY_GEOIP_ALLOWED_COUNTRIES | (empty) | Comma-separated ISO 3166-1 alpha-2 country codes (e.g., PL,DE,FR). Codes are normalized to uppercase and validated; invalid codes are rejected. Empty = no restriction. When set, this operator value is authoritative over the in-app tenant setting. |
When no country list is configured, the GeoIP filter is passive — it resolves country codes for tracking purposes but does not block any requests. Enabling country filtering in the admin UI uses a validated country picker (ISO 3166-1 alpha-2) and requires at least one country and a loaded database.
Refreshing the database: the startup auto-fetch keeps it reasonably fresh, but after replacing the
.mmdbfile manually you can reload it without a restart from Admin → Security → effective state → Reload database (a guarded reload — a corrupt or missing candidate keeps the currently loaded database). A backend restart also picks up the new file. The effective-state panel shows whether the database is loaded and its build/loaded timestamps.
Recovery from a GeoIP lockout
If a country allow-list accidentally excludes your own country, you receive HTTP 403 with a GEO_BLOCKED recovery hint. To recover:
- Set
GEOIP_ENABLED=falsein the backend environment. - Restart the backend (this disables GeoIP enforcement before any policy is evaluated).
- Fix or clear the country allow-list, then set
GEOIP_ENABLED=trueand restart again.
This is independent of the IP-whitelist recovery switch — recovering one never silently disables the other.
Privacy Considerations
- GeoIP resolution is stateless — no IP-to-country mapping is stored permanently.
- For offer tracking, only the country code is recorded (e.g., “PL”), not the IP address itself.
- IP addresses recorded in offer web events are subject to the configurable IP anonymization job, which hashes them after a configurable number of days for GDPR compliance.