GeoIP2 in Nginx: IP geolocation on Ubuntu, Rocky Linux, and Oracle Linux
Approximating a visitor's location from their IP address can be useful for selecting a language, customizing content, generating statistics, applying regional rules, or forwarding location data to an application.
A common solution is to query an external API. However, this adds latency, third-party dependencies, request limits, and potentially extra costs. With the GeoIP2 module, Nginx can query MaxMind databases locally and expose the results as Nginx variables.
In this guide, we will configure:
- country and ISO code;
- state or region;
- city;
- postal code;
- latitude and longitude;
- forwarding location data to PHP, Node.js, and Next.js applications;
- installation on Ubuntu, Rocky Linux, and Oracle Linux;
- automatic GeoLite2 database updates;
- correct client IP handling when Cloudflare or another reverse proxy is in front of Nginx.
IP-based location is approximate. It is not equivalent to the device's GPS location and should not be treated as proof of a person's exact location.
How the solution works
The request flow is:
- A visitor connects to Nginx.
- Nginx obtains the remote IP address.
- The
ngx_http_geoip2_modulemodule queries a MaxMind.mmdbdatabase. - The results are stored in Nginx variables.
- Those variables can be used in logs, rules, HTTP headers, FastCGI, or
proxy_pass.
The lookup happens locally and does not require an external API request for every visit.
Legacy GeoIP and GeoIP2 are not the same
Do not confuse these modules:
ngx_http_geoip_module: the legacy module based on the old GeoIP databases;ngx_http_geoip2_module: the current module compatible with MaxMind DB files in the.mmdbformat.
This article uses GeoIP2 exclusively.
1. Check whether GeoIP2 is already available
Run:
nginx -V 2>&1 | grep -i geoipAlso search for installed dynamic modules:
find /usr/lib64/nginx /usr/lib/nginx /etc/nginx/modules \
-type f -iname '*geoip2*.so' 2>/dev/nullIf you find something similar to:
ngx_http_geoip2_module.sothe module is already installed. Check whether it is loaded near the beginning of /etc/nginx/nginx.conf:
load_module modules/ngx_http_geoip2_module.so;Depending on the package and distribution, the path may be absolute:
load_module /usr/lib64/nginx/modules/ngx_http_geoip2_module.so;Test the configuration:
sudo nginx -t
nginx -Vdoes not always list modules loaded dynamically. That is why you should also search for the.sofile and inspect theload_moduledirectives.
2. Install the MaxMind DB library
The Nginx module depends on libmaxminddb.
Ubuntu and Debian
sudo apt update
sudo apt install -y libmaxminddb0 libmaxminddb-dev mmdb-bin geoipupdateVerify the command-line utility:
mmdblookup --versionRocky Linux 8, 9, or 10
Install EPEL and the required tools:
sudo dnf install -y epel-release
sudo dnf install -y libmaxminddb libmaxminddb-devel geoipupdate git gcc make wget tarIf a package cannot be found, inspect the enabled repositories:
sudo dnf repolist
sudo dnf search maxmindOn Rocky Linux 9, you may also need to enable CRB:
sudo dnf config-manager --set-enabled crbOn Rocky Linux 8, the equivalent repository is usually named PowerTools:
sudo dnf config-manager --set-enabled powertoolsThen retry the installation:
sudo dnf install -y libmaxminddb libmaxminddb-devel geoipupdateOracle Linux 8, 9, or 10
Enable the development repository for your Oracle Linux version.
On Oracle Linux 9:
sudo dnf config-manager --enable ol9_codeready_builderOn Oracle Linux 8:
sudo dnf config-manager --enable ol8_codeready_builderInstall the Oracle Linux EPEL release package when available:
sudo dnf install -y oracle-epel-release-el9On Oracle Linux 8, use:
sudo dnf install -y oracle-epel-release-el8Then install the dependencies:
sudo dnf install -y libmaxminddb libmaxminddb-devel geoipupdate git gcc make wget tarOn Oracle Linux 10, repository names may differ depending on the image and enabled repositories. List the available options:
sudo dnf repolist all | grep -Ei 'code|developer|epel'
sudo dnf search libmaxminddbIf libmaxminddb-devel is unavailable, enable the matching development repository shown by the previous command.
3. Install the Nginx GeoIP2 module
The installation method depends on where your Nginx package came from. A dynamic module must be binary-compatible with the installed Nginx version and its build options.
Option A: use a distribution package
First, check whether your distribution provides a ready-made package:
sudo dnf search nginx | grep -i geoipOr on Ubuntu:
apt search nginx | grep -i geoipIf a package explicitly named GeoIP2 is available, prefer it. Do not assume a package named only geoip is equivalent; it is usually the legacy module.
After installation, locate the module file:
find /usr/lib64/nginx /usr/lib/nginx /etc/nginx/modules \
-type f -iname '*geoip2*.so' 2>/dev/nullOption B: compile a compatible dynamic module
This is the most universal option for Rocky Linux and Oracle Linux when no suitable RPM package is available.
3.1 Find the Nginx version and build arguments
nginx -v
nginx -V 2>&1Save the reported version. Example:
nginx version: nginx/1.28.0The source code used to compile the module must match the installed Nginx version.
Store the version in a variable:
NGINX_VERSION="$(nginx -v 2>&1 | sed -E 's#nginx version: nginx/##')"
echo "$NGINX_VERSION"3.2 Install build dependencies
On Rocky Linux and Oracle Linux:
sudo dnf groupinstall -y "Development Tools"
sudo dnf install -y \
git wget tar gcc make \
pcre2-devel zlib-devel openssl-devel \
libmaxminddb-develOn some versions, the zlib compatibility package may be required:
sudo dnf install -y zlib-ng-compat-devel3.3 Download the source code
cd /usr/local/src
sudo wget "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz"
sudo tar -xzf "nginx-${NGINX_VERSION}.tar.gz"
sudo git clone --depth 1 \
https://github.com/leev/ngx_http_geoip2_module.git3.4 Compile only the module
Enter the Nginx source directory:
cd "/usr/local/src/nginx-${NGINX_VERSION}"Copy the arguments shown by nginx -V, preserving the original options and adding:
--with-compat --add-dynamic-module=/usr/local/src/ngx_http_geoip2_moduleSimplified example:
sudo ./configure \
--with-compat \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_realip_module \
--add-dynamic-module=/usr/local/src/ngx_http_geoip2_module
sudo make modulesThe compiled module will be created at:
objs/ngx_http_geoip2_module.soTo avoid the
module is not binary compatibleerror, use the same Nginx version and preserve the relevant arguments shown bynginx -V. Do not blindly replace your installation's build options with the simplified example above.
3.5 Install the module
Find the configured Nginx module directory:
nginx -V 2>&1 | grep -oE -- '--modules-path=[^ ]+'On RPM-based systems, it is commonly:
/usr/lib64/nginx/modulesCreate the directory and copy the module:
sudo mkdir -p /usr/lib64/nginx/modules
sudo cp objs/ngx_http_geoip2_module.so /usr/lib64/nginx/modules/
sudo chmod 755 /usr/lib64/nginx/modules/ngx_http_geoip2_module.soAt the beginning of /etc/nginx/nginx.conf, before the events block, add:
load_module /usr/lib64/nginx/modules/ngx_http_geoip2_module.so;Test the configuration:
sudo nginx -tBe careful after Nginx updates
When Nginx is upgraded to a different version, a manually compiled module may no longer be compatible. After an upgrade, always run:
sudo nginx -tIf you see module is not binary compatible, rebuild the module against the exact new Nginx version.
4. Create a free MaxMind account
GeoLite2 databases are free, but they require a MaxMind account and a license key.
In the MaxMind account portal:
- create or sign in to your account;
- generate a new license key;
- copy your
AccountIDand license key; - do not publish the key in Git, Docker images, or documentation.
We will use:
GeoLite2-Country;GeoLite2-City.
The City database already includes country information, but keeping Country separately can still be useful when a smaller database is preferred for specific lookups.
5. Configure geoipupdate
Edit the configuration file:
sudo nano /etc/GeoIP.confUse this template:
AccountID YOUR_ACCOUNT_ID
LicenseKey YOUR_LICENSE_KEY
EditionIDs GeoLite2-Country GeoLite2-City
DatabaseDirectory /var/lib/GeoIPProtect the file:
sudo chmod 600 /etc/GeoIP.conf
sudo chown root:root /etc/GeoIP.confCreate the database directory:
sudo mkdir -p /var/lib/GeoIPDownload or update the databases:
sudo geoipupdateCheck the files:
ls -lh /var/lib/GeoIP/You should see files such as:
GeoLite2-City.mmdb
GeoLite2-Country.mmdb6. Test the database from the command line
Choose a public IP address for testing:
mmdblookup \
--file /var/lib/GeoIP/GeoLite2-City.mmdb \
--ip 8.8.8.8To query only the country code:
mmdblookup \
--file /var/lib/GeoIP/GeoLite2-City.mmdb \
--ip 8.8.8.8 \
country iso_codeTo query the city name in English when available:
mmdblookup \
--file /var/lib/GeoIP/GeoLite2-City.mmdb \
--ip 8.8.8.8 \
city names enNot every record includes a city, postal code, or coordinates. Always define fallback values in your Nginx configuration.
7. Permissions and SELinux on Rocky Linux and Oracle Linux
The Nginx worker process must be able to read the .mmdb files.
Apply safe permissions:
sudo chown -R root:root /var/lib/GeoIP
sudo find /var/lib/GeoIP -type d -exec chmod 755 {} \;
sudo find /var/lib/GeoIP -type f -name '*.mmdb' -exec chmod 644 {} \;Check the SELinux mode:
getenforceIf SELinux is Enforcing, assign a context readable by Nginx:
sudo semanage fcontext -a -t httpd_sys_content_t "/var/lib/GeoIP(/.*)?"
sudo restorecon -Rv /var/lib/GeoIPIf semanage is unavailable:
sudo dnf install -y policycoreutils-python-utilsInspect the resulting context:
ls -lZ /var/lib/GeoIP/Do not disable SELinux to work around a file permission problem. Fix the file context instead.
8. Configure GeoIP2 variables in Nginx
Create a dedicated configuration file:
sudo nano /etc/nginx/conf.d/geoip2.confAdd:
geoip2 /var/lib/GeoIP/GeoLite2-City.mmdb auto_reload=5m {
$geoip2_country_code default=-- source=$remote_addr country iso_code;
$geoip2_country_name default=Unknown source=$remote_addr country names en;
$geoip2_region_code default=-- source=$remote_addr subdivisions 0 iso_code;
$geoip2_region_name default=Unknown source=$remote_addr subdivisions 0 names en;
$geoip2_city_name default=Unknown source=$remote_addr city names en;
$geoip2_postal_code default=-- source=$remote_addr postal code;
$geoip2_latitude default=0 source=$remote_addr location latitude;
$geoip2_longitude default=0 source=$remote_addr location longitude;
$geoip2_time_zone default=Unknown source=$remote_addr location time_zone;
$geoip2_accuracy_radius default=0 source=$remote_addr location accuracy_radius;
}The option:
auto_reload=5mmakes the module periodically check whether the database file has been replaced, avoiding the need to restart Nginx after each update.
Fallback between localized names
You can define multiple variables and use map to select a fallback language:
geoip2 /var/lib/GeoIP/GeoLite2-City.mmdb auto_reload=5m {
$geoip2_city_local source=$remote_addr city names en;
$geoip2_city_fallback source=$remote_addr city names es;
}
map $geoip2_city_local $geoip2_city_name {
"" $geoip2_city_fallback;
default $geoip2_city_local;
}The
geoip2andmapdirectives belong to thehttpcontext. In common installations, files in/etc/nginx/conf.d/*.confare included inside that block. Do not place these directives insideserverorlocation.
Test and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx9. Expose the data through a temporary test route
Inside a server block, temporarily add:
location = /geoip-debug {
default_type application/json;
return 200 '{
"ip": "$remote_addr",
"countryCode": "$geoip2_country_code",
"country": "$geoip2_country_name",
"regionCode": "$geoip2_region_code",
"region": "$geoip2_region_name",
"city": "$geoip2_city_name",
"postalCode": "$geoip2_postal_code",
"latitude": "$geoip2_latitude",
"longitude": "$geoip2_longitude",
"timeZone": "$geoip2_time_zone",
"accuracyRadiusKm": "$geoip2_accuracy_radius"
}';
}Test it:
curl https://your-domain.com/geoip-debugRemove this route after testing. Publicly exposing debug data without a clear need is not recommended.
10. Forward the data to an application with proxy_pass
For Node.js, Next.js, Bun, NestJS, or any application behind a reverse proxy:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-GeoIP-Country-Code $geoip2_country_code;
proxy_set_header X-GeoIP-Country-Name $geoip2_country_name;
proxy_set_header X-GeoIP-Region-Code $geoip2_region_code;
proxy_set_header X-GeoIP-Region-Name $geoip2_region_name;
proxy_set_header X-GeoIP-City $geoip2_city_name;
proxy_set_header X-GeoIP-Postal-Code $geoip2_postal_code;
proxy_set_header X-GeoIP-Latitude $geoip2_latitude;
proxy_set_header X-GeoIP-Longitude $geoip2_longitude;
proxy_set_header X-GeoIP-Time-Zone $geoip2_time_zone;
proxy_set_header X-GeoIP-Accuracy-Radius $geoip2_accuracy_radius;
}Reading the headers in Next.js
In a Server Component, Route Handler, or Server Action:
import { headers } from "next/headers";
type GeoLocation = {
countryCode: string | null;
countryName: string | null;
regionCode: string | null;
regionName: string | null;
city: string | null;
postalCode: string | null;
latitude: number | null;
longitude: number | null;
timeZone: string | null;
accuracyRadiusKm: number | null;
};
function parseNumber(value: string | null): number | null {
if (value === null || value.trim() === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
export async function getGeoLocation(): Promise<GeoLocation> {
const requestHeaders = await headers();
return {
countryCode: requestHeaders.get("x-geoip-country-code"),
countryName: requestHeaders.get("x-geoip-country-name"),
regionCode: requestHeaders.get("x-geoip-region-code"),
regionName: requestHeaders.get("x-geoip-region-name"),
city: requestHeaders.get("x-geoip-city"),
postalCode: requestHeaders.get("x-geoip-postal-code"),
latitude: parseNumber(requestHeaders.get("x-geoip-latitude")),
longitude: parseNumber(requestHeaders.get("x-geoip-longitude")),
timeZone: requestHeaders.get("x-geoip-time-zone"),
accuracyRadiusKm: parseNumber(
requestHeaders.get("x-geoip-accuracy-radius"),
),
};
}These headers should only be considered trustworthy when the application is not exposed directly to the internet and receives traffic exclusively through Nginx.
11. Forward the data to PHP with FastCGI
In a PHP-FPM configuration:
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm/www.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param GEOIP_COUNTRY_CODE $geoip2_country_code;
fastcgi_param GEOIP_COUNTRY_NAME $geoip2_country_name;
fastcgi_param GEOIP_REGION_NAME $geoip2_region_name;
fastcgi_param GEOIP_CITY $geoip2_city_name;
fastcgi_param GEOIP_LATITUDE $geoip2_latitude;
fastcgi_param GEOIP_LONGITUDE $geoip2_longitude;
}In PHP:
<?php
$countryCode = $_SERVER['GEOIP_COUNTRY_CODE'] ?? null;
$countryName = $_SERVER['GEOIP_COUNTRY_NAME'] ?? null;
$city = $_SERVER['GEOIP_CITY'] ?? null;12. Include the data in access logs
Create a custom log format in the http context:
log_format geoip_combined
'$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'country=$geoip2_country_code '
'region="$geoip2_region_name" '
'city="$geoip2_city_name"';In the virtual server:
access_log /var/log/nginx/access_geoip.log geoip_combined;Then run:
sudo nginx -t
sudo systemctl reload nginxAvoid logging latitude, longitude, or other unnecessary information unless there is a legitimate purpose. Logs are also subject to privacy and retention requirements.
13. Cloudflare, load balancers, and proxies: restore the real IP first
When Nginx is behind Cloudflare, a load balancer, or another proxy, $remote_addr may contain the proxy's address. In that case, GeoIP will return the data center's location instead of the visitor's location.
With Cloudflare, configure Nginx's Real IP module and trust only the official Cloudflare networks.
Structural example:
# Add all current official Cloudflare IPv4 and IPv6 ranges here.
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ...remaining official ranges...
real_ip_header CF-Connecting-IP;
real_ip_recursive on;After Real IP is configured correctly, $remote_addr represents the visitor and can be used as the GeoIP2 source.
Do not configure:
set_real_ip_from 0.0.0.0/0;That would allow any client to spoof the IP address through HTTP headers.
If your provider sends the client IP in a different header, adjust real_ip_header and restrict set_real_ip_from to the trusted proxy addresses or networks.
14. Automatically update the databases
GeoLite2 databases should be kept up to date. You can run geoipupdate periodically with systemd.
Create a service:
sudo nano /etc/systemd/system/geoipupdate.service[Unit]
Description=Update MaxMind GeoLite2 databases
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/geoipupdateConfirm the program path:
command -v geoipupdateIf the path differs, update ExecStart accordingly.
Create the timer:
sudo nano /etc/systemd/system/geoipupdate.timer[Unit]
Description=Weekly GeoLite2 database update
[Timer]
OnCalendar=Sun *-*-* 04:15:00
Persistent=true
RandomizedDelaySec=30m
[Install]
WantedBy=timers.targetEnable it:
sudo systemctl daemon-reload
sudo systemctl enable --now geoipupdate.timerVerify it:
systemctl list-timers geoipupdate.timer
sudo systemctl start geoipupdate.service
sudo journalctl -u geoipupdate.service --no-pagerBecause auto_reload=5m is enabled, Nginx will detect the updated database files automatically.
15. Troubleshooting common problems
unknown directive "geoip2"
The module was not loaded.
Check:
sudo nginx -T 2>&1 | grep -i load_module
find /usr/lib64/nginx /usr/lib/nginx -iname '*geoip2*.so' 2>/dev/nullMake sure the load_module directive appears before the events block.
module is not binary compatible
The module was compiled for a different Nginx version or with incompatible build options.
Rebuild it using the exact version and arguments shown by:
nginx -V 2>&1MMDB_open ... Permission denied
Check standard permissions and SELinux:
namei -l /var/lib/GeoIP/GeoLite2-City.mmdb
ls -lZ /var/lib/GeoIP/
sudo ausearch -m AVC -ts recentReapply the context:
sudo restorecon -Rv /var/lib/GeoIPThe city is shown as Unknown
This may be normal. Not every IP has city-level resolution, and mobile networks, VPNs, CGNAT, and regional providers can reduce accuracy.
Test the address directly:
mmdblookup \
--file /var/lib/GeoIP/GeoLite2-City.mmdb \
--ip VISITOR_PUBLIC_IP \
city names enThe result shows Cloudflare's location
Nginx is still using the proxy IP. Configure real_ip_header, set_real_ip_from, and real_ip_recursive before the GeoIP2 lookup.
It works in the terminal but not in Nginx
The Nginx user or SELinux domain may not have access to the database. Identify the worker user:
ps -eo user,group,comm | grep nginxThen inspect every permission in the path:
namei -l /var/lib/GeoIP/GeoLite2-City.mmdb16. Security and privacy
Important recommendations:
- do not treat GeoIP as an exact location;
- do not make critical decisions based only on the detected country or city;
- do not trust GeoIP headers sent directly by a client;
- keep the application accessible only through the reverse proxy;
- do not expose your MaxMind license key;
- do not log more data than necessary;
- disclose approximate geolocation use in your privacy policy when applicable;
- provide a manual language or region fallback;
- remember that VPNs, Tor, mobile networks, and CGNAT can change or reduce accuracy.
Final configuration summary
Your /etc/nginx/conf.d/geoip2.conf file can look like this:
geoip2 /var/lib/GeoIP/GeoLite2-City.mmdb auto_reload=5m {
$geoip2_country_code default=-- source=$remote_addr country iso_code;
$geoip2_country_name default=Unknown source=$remote_addr country names en;
$geoip2_region_code default=-- source=$remote_addr subdivisions 0 iso_code;
$geoip2_region_name default=Unknown source=$remote_addr subdivisions 0 names en;
$geoip2_city_name default=Unknown source=$remote_addr city names en;
$geoip2_postal_code default=-- source=$remote_addr postal code;
$geoip2_latitude default=0 source=$remote_addr location latitude;
$geoip2_longitude default=0 source=$remote_addr location longitude;
$geoip2_time_zone default=Unknown source=$remote_addr location time_zone;
$geoip2_accuracy_radius default=0 source=$remote_addr location accuracy_radius;
}And the reverse proxy for a Next.js application:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-GeoIP-Country-Code $geoip2_country_code;
proxy_set_header X-GeoIP-Country-Name $geoip2_country_name;
proxy_set_header X-GeoIP-Region-Code $geoip2_region_code;
proxy_set_header X-GeoIP-Region-Name $geoip2_region_name;
proxy_set_header X-GeoIP-City $geoip2_city_name;
proxy_set_header X-GeoIP-Postal-Code $geoip2_postal_code;
proxy_set_header X-GeoIP-Latitude $geoip2_latitude;
proxy_set_header X-GeoIP-Longitude $geoip2_longitude;
proxy_set_header X-GeoIP-Time-Zone $geoip2_time_zone;
proxy_set_header X-GeoIP-Accuracy-Radius $geoip2_accuracy_radius;
}Always finish with:
sudo nginx -t
sudo systemctl reload nginxConclusion
GeoIP2 allows Nginx to identify approximate location information without querying an external API for every request. The solution is fast, local, and especially useful when Nginx already acts as a reverse proxy for PHP, Node.js, or Next.js.
The most important points are to use GeoIP2 instead of the legacy GeoIP module, keep the databases updated, preserve binary compatibility when compiling the module, and correctly restore the visitor's IP when Cloudflare or another proxy is in front of Nginx.
References
- Article that inspired this guide: https://dev.to/gbhorwood/nginx-doing-ip-geolocation-right-in-nginx-442h
- GeoIP2 module for Nginx: https://github.com/leev/ngx_http_geoip2_module
- MaxMind GeoLite2 documentation: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data/
- Updating MaxMind databases: https://dev.maxmind.com/geoip/updating-databases/
- Nginx on Rocky Linux: https://docs.rockylinux.org/guides/web/nginx-mainline/
- Nginx on Oracle Linux: https://docs.oracle.com/en/learn/ol-nginx/