0% прочитано

GeoIP Analytics Without Storing IP Addresses: Nginx + MaxMind + a Trusted Header

I needed country analytics in ICanUp without storing visitor IP addresses or calling an external GeoIP API on every page view. I moved country resolution to the Nginx and MaxMind infrastructure boundary, while Laravel receives only a normalized two-letter country code through a trusted internal header.

11 вересня 2026 р. 8 хв читанняNginx

When I added a country breakdown to ICanUp Analytics, I had an important design question: where should the visitor country come from if I deliberately do not want to store raw IP addresses in analytics history?

I also did not want Laravel to send the visitor IP to an external GeoIP API for every page view.

I moved country resolution to the infrastructure boundary: Nginx and MaxMind resolve the country, while the application receives only a normalized two-letter code.

Not storing IP does not mean the server never sees an IP address. Nginx receives the network connection and can use the address for a GeoIP lookup. The important boundary is that the IP is not persisted in Analytics history.
Nginx and MaxMind resolve a visitor country before Laravel Analytics stores only the country code
Nginx and MaxMind turn the network address into a country code before Analytics receives the request. Laravel persists only country_code.

I Needed the Country, Not the IP Address

For Analytics, I want to understand which countries generate traffic.

That does not require storing the address of an individual visitor.

A country code gives me the aggregation level I need without creating a history of raw IP addresses.

What I need

What I do not need

Country code UA

Raw IP address

Aggregated statistics

Precise visitor location

Country breakdown

City, coordinates, or visitor profile

Stable infrastructure source

External HTTP request for every view

The Main Decision Was to Move GeoIP Outside Laravel

Laravel already has enough work to do during a public request: routing, rendering, analytics tracking, and other application logic.

I did not want every eligible view to add another HTTP request to an external GeoIP service.

Country resolution fits more naturally where the network request already crosses the infrastructure boundary.

Country resolution flow
Visitor request      |      vNginx      |      | visitor IP is used for local GeoIP lookup      vMaxMind GeoLite2 Country      |      | result: UA      vtrusted internal country header      |      vLaravel      |      vcountry_code = UA      |      vAnalytics aggregate IP is not persisted in Analytics history

MaxMind Runs Locally Next to Nginx

A local MaxMind GeoLite2 Country database combined with GeoIP2 support in Nginx fits this model well.

The lookup does not require sending the visitor address to a third-party service on every request.

The lookup happens inside my infrastructure, and the application receives only the country code.

Nginx - Simplified GeoLite2 Country configuration
geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb {    auto_reload 1h;     $geoip2_country_code        country iso_code;}
This is a simplified configuration example, not a copy of the complete production nginx.conf. The .mmdb path, GeoIP2 module setup, and real IP configuration depend on the infrastructure.

Laravel Must Not Trust a Header Sent by the Browser

Passing the country code through an HTTP header is convenient, but it can easily create a security bug.

A client can send a header such as X-Icanup-Country-Code: US itself.

The application therefore must not trust a value merely because it arrived under the expected header name.

Nginx - Nginx creates the internal country header
location ~ \.php$ {    include fastcgi_params;     fastcgi_param HTTP_X_ICANUP_COUNTRY_CODE        $geoip2_country_code;     # Existing PHP-FPM configuration...}

The origin infrastructure supplies the value derived from the GeoIP lookup.

A client-provided value must not pass into the application as the source of truth.

The Header Name Remains Configurable

I did not want Analytics to be permanently coupled to one specific header name.

The application environment therefore defines which trusted header contains the country code.

ENV - Configure the country header
ANALYTICS_COUNTRY_HEADER=X-Icanup-Country-Code

This keeps the boundary clear: infrastructure decides how the country is resolved, while Analytics only knows the agreed application contract.

The Resolver Receives an Already Resolved Country Code

On the application side, PublicAnalyticsCountryDimensionResolver receives the country value.

It does not need to know the MaxMind database format, the .mmdb path, or Nginx internals.

The resolver has a small contract: trusted header -> normalized country_code or null.

PHP - Simplified country code normalization
$headerName = config(    'analytics.country_header'); $value = $request->header(    $headerName); if (! is_string($value)) {    return null;} $countryCode = strtoupper(    trim($value)); return preg_match(    '/^[A-Z]{2}$/',    $countryCode,) === 1    ? $countryCode    : null;
The PHP snippet demonstrates the resolver contract rather than reproducing a production class line by line. Infrastructure details should not leak into the application layer.

Only a Normalized Value Reaches Storage

A two-letter country code is enough for the Analytics use case.

The value is normalized to a stable format such as UA, US, or DE.

An arbitrary string should not automatically become an analytics dimension.

  • Accept only the expected short format.
  • Normalize the value before persistence.
  • Treat missing or unknown values as null when appropriate.
  • Do not fail the public request when country resolution is unavailable.
  • Do not add the IP address to analytics dimensions.

Why I Did Not Use an External GeoIP API for Every View

Laravel could technically take the IP address, call an external GeoIP endpoint, and receive a country.

That would add a network dependency to every eligible page view.

The visitor IP would also leave my infrastructure during the lookup.

For a simple country breakdown, a local GeoIP database creates a much cleaner boundary.

Local GeoIP lookup

External API per view

No additional HTTP request

Additional network operation

IP stays at the infrastructure boundary

IP is sent to an external provider

Predictable latency

External availability dependency

Local database must be updated

API limits and availability must be managed

The GeoLite2 Database Is Not Part of the Application Repository

The local GeoIP database is an operational dependency, not application source code.

I do not want a large binary .mmdb file committed to Git alongside Laravel code.

  • Keep the .mmdb file outside the application repository.
  • Treat the database path as server configuration.
  • Give the Nginx process the required read permissions.
  • Update GeoLite2 through a separate operational process.
  • Keep the same trusted-header contract in development and production.
  • Replacing the database should not require application-code changes.

A Missing Country Must Not Break Analytics

GeoIP does not guarantee that every address can always be mapped to a valid country.

The value may also be unavailable during local development.

The country dimension is therefore additional analytics context, not a requirement that can break the entire request.

No country code means no country dimension, while the public request continues normally.

A missing or unknown country should not cause an exception or prevent the public page from rendering.

The Complete Path Needs End-to-End Verification

Testing MaxMind alone or testing the Laravel resolver alone is not enough.

I need to know that the complete path works from a real public request to aggregated analytics.

End-to-end verification flow
1. Public request reaches Nginx 2. Nginx resolves country with MaxMind 3. Infrastructure creates:   X-Icanup-Country-Code: UA 4. Laravel reads the configured trusted header 5. Resolver normalizes:   UA 6. Analytics stores:   country_code = UA 7. Admin Analytics:   Countries -> Ukraine
  • A new eligible public view receives a country code through the trusted infrastructure path.
  • PublicAnalyticsCountryDimensionResolver accepts the value.
  • A new analytics bucket contains country_code.
  • Admin Analytics starts showing the country.
  • A missing country does not cause an exception.
  • Analytics history does not contain a raw IP.

Header Spoofing Needed Its Own Security Check

The most important security property is that a browser cannot simply choose its own country.

If a client supplies X-Icanup-Country-Code, the origin should replace or discard it and generate its own value.

A trusted header is not trusted because of its name. It is trusted because of the controlled infrastructure path that creates it.

The client value is not the source of truth
Client sends: X-Icanup-Country-Code: XX Do not trust it directly.  Infrastructure resolves: MaxMind lookup -> UA  Laravel receives trusted value: X-Icanup-Country-Code: UA

What Analytics Actually Stores

After this pipeline finishes, Analytics no longer needs the IP address.

The new aggregate contains only a normalized country_code among its additional dimensions.

That is the outcome I wanted: country statistics without a history of individual visitor addresses.

SQL - Example country_code aggregate check
SELECT    JSON_UNQUOTE(        JSON_EXTRACT(            additional_dimensions,            '$.country_code'        )    ) AS country_code,    COUNT(*) AS bucketsFROM analytics_daily_aggregatesWHERE JSON_EXTRACT(    additional_dimensions,    '$.country_code') IS NOT NULLGROUP BY country_codeORDER BY buckets DESC;

The Country Becomes Useful After Data Accumulates

Seeing UA in the database was not the final goal.

The country code exists to power the aggregated Countries breakdown in Admin Analytics.

As new eligible views accumulate, the block starts showing real country values.

I Deliberately Stopped at Country-Level GeoIP

Once GeoIP exists, it is technically easy to ask for more: region, city, coordinates, and other location data.

None of those values were required for this use case.

My rule is to collect the smallest amount of data that answers the actual analytics question.

In scopeOut of scope
Country codeCity
Country aggregateCoordinates
Normalized two-letter valueRaw IP history
Anonymous traffic dimensionVisitor profiling

What Could Go Wrong

  • Store the IP together with the country code "just in case".
  • Call an external GeoIP API for every page view.
  • Trust X-Icanup-Country-Code sent directly by a client.
  • Commit the GeoLite2 .mmdb file to the application repository.
  • Never update the MaxMind database.
  • Persist an arbitrary unvalidated string as country_code.
  • Throw an exception when country resolution has no result.
  • Expand to city-level tracking without a real requirement.
Privacy-safe analytics does not mean the infrastructure never sees a network address. What matters is which data continues through the system, what is persisted, and how much information is retained.

What I Kept for Next Time

  • Resolve GeoIP at the infrastructure boundary.
  • Do not make Laravel call an external GeoIP API for every view.
  • A two-letter country code is enough for a Countries breakdown.
  • Raw IP does not need to enter analytics history.
  • Create or forcibly overwrite the trusted header at the origin.
  • Keep the header name configurable.
  • Store the GeoLite2 database outside the Git repository.
  • Treat a missing country as a normal state.
  • Verify the complete path from Nginx to Admin Analytics.
  • Do not collect more precise geolocation without a concrete need.

For country analytics, I do not need a history of visitor IP addresses. I need a reliable way to reduce a network request to the smallest useful country code before the data reaches Analytics.

Conclusion

The most important part of this solution was not the GeoIP database itself but the responsibility boundary around it.

Nginx sees the network request, MaxMind resolves the country locally, the infrastructure creates a trusted internal header, and Laravel receives only the two-letter country_code.

In ICanUp, Analytics does not persist raw IP addresses and does not call an external GeoIP API for every view.

I get the Countries breakdown I need without turning simple country statistics into storage for more precise visitor network data.