There are plenty of legitimate reasons to restrict access to your site or application based on where a visitor is connecting from — licensing restrictions that only permit content in certain countries, compliance requirements, cutting down on abuse traffic that overwhelmingly originates from a handful of regions, or simply reducing noise from bots hammering an admin panel from countries none of your actual users live in. Nginx can do this natively using GeoIP databases, without needing an external service or a third-party firewall product sitting in front of your server.
This guide covers setting up GeoIP-based blocking (and allow-listing) in Nginx using MaxMind’s GeoLite2 database and the modern ngx_http_geoip2_module, since the legacy geoip module and its associated database format have been deprecated by MaxMind and shouldn’t be used for new setups.
How GeoIP Blocking Works in Nginx
Nginx doesn’t have geographic awareness built in — it relies on an external IP-to-country (or IP-to-city/region) database, most commonly MaxMind’s GeoLite2. The ngx_http_geoip2_module reads that database and exposes variables like $geoip2_data_country_code that you can then use in your configuration to allow or deny requests, redirect visitors, or serve different content entirely.
The general flow:
- Download and maintain a MaxMind GeoLite2 database (free with a registered account, or a paid GeoIP2 database for more accuracy).
- Install the
ngx_http_geoip2_module(a dynamic module in most modern Nginx packages). - Configure Nginx to load the database and expose country/region variables.
- Use those variables in
mapblocks orifconditions to allow or deny requests.
Requirements
- Nginx 1.9.x or later (dynamic module loading,
load_module, requires 1.9.11+). - A free MaxMind account to download GeoLite2 databases (MaxMind requires registration since a 2019 policy change).
- The
libmaxminddblibrary andngx_http_geoip2_module, available as packages on most distros or compiled from source. - Root/sudo access to install packages and edit Nginx config.
Step 1: Sign Up for a MaxMind Account and Get a License Key
Go to MaxMind’s GeoLite2 sign-up page, create a free account, and generate a license key from the account portal. You’ll need this key to download the database via their API — direct download links without an account no longer work.
Step 2: Install the GeoIP2 Module and Database Tools
On Ubuntu/Debian, the module is often available as a package:
sudo apt update
sudo apt install nginx-module-geoip2 libmaxminddb0 libmaxminddb-dev mmdb-bin -y
If your Nginx package doesn’t provide nginx-module-geoip2, you may need to install geoipupdate (MaxMind’s official updater tool) instead:
sudo apt install geoipupdate -y
On CentOS/RHEL:
sudo dnf install epel-release -y
sudo dnf install libmaxminddb libmaxminddb-devel geoipupdate -y
Step 3: Configure geoipupdate and Download the Database
Edit /etc/GeoIP.conf (created by the geoipupdate package):
AccountID YOUR_ACCOUNT_ID
LicenseKey YOUR_LICENSE_KEY
EditionIDs GeoLite2-Country GeoLite2-City
Then run the updater:
sudo geoipupdate
This downloads the .mmdb database files, typically to /usr/share/GeoIP/ or /var/lib/GeoIP/ depending on distro defaults. Confirm they’re there:
ls -lh /usr/share/GeoIP/
You should see GeoLite2-Country.mmdb (and GeoLite2-City.mmdb if you requested it).
Step 4: Load the GeoIP2 Module in Nginx
If installed as a dynamic module, add this near the top of /etc/nginx/nginx.conf, before the events block:
load_module modules/ngx_http_geoip2_module.so;
Then inside the http block, point Nginx at your database and define which fields to expose as variables:
http {
geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
$geoip2_country_code country iso_code;
$geoip2_country_name country names en;
}
...
}
If you downloaded the City database and want more granularity (region, city):
geoip2 /usr/share/GeoIP/GeoLite2-City.mmdb {
$geoip2_city_name city names en;
$geoip2_region_code subdivisions 0 iso_code;
}
Step 5: Block or Allow by Country
The cleanest way to do this is with a map block, which avoids the performance and readability problems of stacking if statements. Define a map that flags whether a country is blocked:
http {
map $geoip2_country_code $blocked_country {
default 0;
RU 1;
CN 1;
KP 1;
}
server {
listen 443 ssl http2;
server_name example.com;
if ($blocked_country) {
return 403;
}
location / {
root /var/www/example.com;
index index.html;
}
}
}
This blocks visitors from Russia, China, and North Korea (as an example — adjust country codes to whatever list your actual policy requires) with a 403 Forbidden, while everyone else passes through normally.
Allow-Listing Instead of Blocking
If your use case is the opposite — only permit specific countries — invert the map default:
map $geoip2_country_code $allowed_country {
default 0;
US 1;
CA 1;
GB 1;
}
server {
if ($allowed_country = 0) {
return 403;
}
...
}
Redirecting Instead of Blocking Outright
Sometimes a redirect to a localized page or a “not available in your region” notice is friendlier than a bare 403:
if ($blocked_country) {
return 302 https://example.com/not-available;
}
Step 6: Test and Reload
sudo nginx -t
sudo systemctl reload nginx
Step 7: Verify It’s Actually Working
Testing GeoIP blocking from your own machine is tricky since you’re presumably not physically located in the country you’re testing. A few practical approaches:
Use a VPN endpoint in a blocked country and confirm you get a 403.
Query the database directly to confirm a known IP resolves to the expected country:
mmdblookup --file /usr/share/GeoIP/GeoLite2-Country.mmdb --ip 1.2.3.4 country iso_code
(Swap 1.2.3.4 for an IP you know belongs to a specific country — many public IP-to-country lookup tools online can give you a sample IP for testing.)
Add a debug header temporarily to confirm the variable is populating correctly in production:
add_header X-Debug-Country $geoip2_country_code always;
Then check with curl:
curl -I https://example.com
Remove the debug header once confirmed working — you don’t want to leak this information to real visitors indefinitely.
Troubleshooting Common Issues
$geoip2_country_code is always empty. Usually means the geoip2 directive isn’t loaded correctly, or the module failed to load — check sudo nginx -t output carefully and confirm load_module is present and points to the correct .so path (varies by distro: /usr/lib/nginx/modules/ is common on Debian-based systems).
Everyone gets blocked, including allowed regions. Double check your map default value — a default 1; with map‘s logic inverted from what you intended is an easy mistake.
Database seems outdated or wrong for known IPs. GeoLite2 databases update periodically; MaxMind recommends updating at least monthly. Set up a cron job:
echo "0 3 * * 3 /usr/bin/geoipupdate" | sudo tee -a /etc/crontab
Nginx won’t start after adding load_module. Confirm the module .so file actually exists at the path specified — package names and install paths vary between Debian, Ubuntu, and RHEL-based systems.
Security Considerations
- GeoIP blocking is a coarse tool. Determined attackers or bots trivially route around it using VPNs or proxies from allowed regions — treat this as noise reduction, not a robust security control.
- IP-to-country mapping isn’t perfectly accurate, especially for mobile carriers, satellite ISPs, and cloud provider IP ranges that may be registered in a different country than where traffic actually originates. Expect some false positives/negatives.
- Don’t rely on GeoIP blocking alone for regulatory compliance (e.g., export control, licensing) without understanding its accuracy limitations — consult your legal/compliance team on whether it satisfies actual requirements.
- Log blocked requests separately so you can audit false positives affecting legitimate users:
if ($blocked_country) {
return 403;
}
access_log /var/log/nginx/geo_blocked.log combined if=$blocked_country;
Performance Tips
- The
.mmdblookup is fast (binary tree lookup, not a network call), so GeoIP blocking adds negligible latency per request — this is one of the reasons doing it at the Nginx layer beats an application-level geo lookup on every request. - Use
mapblocks instead of multipleifstatements —mapis evaluated once per request efficiently, while chainedifblocks in Nginx are notoriously easy to misuse and can cause unexpected behavior with other directives. - Keep the database file on local disk (not network storage) so lookups stay fast under load.
- If you’re blocking at scale across many countries or need city-level granularity across huge traffic volumes, consider caching decisions at a CDN/edge layer in front of Nginx rather than doing GeoIP lookups on every single origin request.
Real-World Use Cases
- A media licensing platform restricts video playback to countries where they hold distribution rights, returning a friendly “not available in your region” page rather than a bare error.
- A company running an internal admin panel blocks all countries except the ones where their offices and remote staff are located, cutting down credential-stuffing attempts from unrelated regions significantly.
- An e-commerce store blocks known high-fraud-rate regions from checkout flows specifically (while still allowing browsing) to reduce fraudulent transaction attempts without fully locking out legitimate browsing traffic.
Best Practices
- Prefer
mapoveriffor country-based logic — cleaner, faster, and avoids Nginx’s well-documentedifquirks. - Always test with a debug header before relying on GeoIP logic in production, and remove the header afterward.
- Automate database updates via cron — a stale GeoIP database silently degrades accuracy over time without any obvious error.
- Combine GeoIP blocking with rate limiting and fail2ban-style tools for a layered defense rather than relying on geography alone.
- Log blocked traffic distinctly so you can review false positives and adjust your country list based on real data, not assumptions.
Wrapping Up
GeoIP blocking in Nginx is quick to set up and genuinely useful for cutting down unwanted traffic patterns or meeting basic regional content restrictions — but it’s worth going in with realistic expectations about its limits. It’s a blunt instrument, easily bypassed by anyone using a VPN, and dependent on a database that’s only ever approximately accurate. Used as one layer among several (rate limiting, WAF rules, proper authentication) rather than a standalone security measure, it does exactly what it’s good at: quietly filtering out the geography-based noise you don’t want reaching your application.