IP Geo blocking
NGINX geographic IP access restriction
Purpose
Restrict an NGINX website so that only client IP addresses geolocated to the Netherlands (NL) and explicitly configured exception addresses can access it. All other clients receive 403 Forbidden.
This procedure targets Ubuntu or Debian and uses the MaxMind GeoLite2 Country database with the NGINX GeoIP2 module.
Location
The relevant server paths are:
/etc/GeoIP.conf: MaxMind download credentials and database settings./usr/share/GeoIP/GeoLite2-Country.mmdb: downloaded country database./etc/nginx/conf.d/country-access.conf: reusable geographic access rules./etc/nginx/sites-available/<site>: the protected virtual host.
Replace example domains, proxy networks, and IP addresses before applying the configuration.
Inputs and outputs
Required inputs:
- Root or
sudoaccess to the NGINX server. - A free MaxMind account, Account ID, and license key.
- The public IPv4 and, when applicable, IPv6 address that must be allowed as an exception.
- The trusted proxy networks when NGINX is behind a CDN, load balancer, or reverse proxy.
The result is an NGINX access decision:
- When the client country is
NL, then access is allowed. - When the client IP matches a configured exception, then access is allowed.
- When neither condition matches, then NGINX returns
403 Forbidden. - When the GeoIP database has no match, then access is denied by default.
Flow and behavior
1. Record the exception address
Run the following command from the exceptional connection:
curl -4 https://ifconfig.me
The examples use the reserved documentation address 203.0.113.42. Replace it with the real public address. If the connection uses IPv6, record and configure that address separately.
2. Install the required packages
sudo apt update
sudo apt install libnginx-mod-http-geoip2 geoipupdate mmdb-bin
Check whether the module is loaded:
sudo nginx -T 2>&1 | grep geoip2
On Ubuntu, installing libnginx-mod-http-geoip2 normally creates a module loader under /etc/nginx/modules-enabled/. If it does not, add the following directive at the top level of /etc/nginx/nginx.conf, outside the http {} block:
load_module modules/ngx_http_geoip2_module.so;
Do not add the directive if the module is already loaded.
3. Create the MaxMind credentials
- Create a free MaxMind GeoLite2 account.
- Accept the GeoLite2 terms.
- Find the MaxMind Account ID.
- Generate a license key for
geoipupdate. - Store the license key securely; treat it as a password.
4. Configure and download the country database
Edit /etc/GeoIP.conf:
AccountID YOUR_MAXMIND_ACCOUNT_ID
LicenseKey YOUR_MAXMIND_LICENSE_KEY
EditionIDs GeoLite2-Country
DatabaseDirectory /usr/share/GeoIP
Protect the credentials and download the database:
sudo chown root:root /etc/GeoIP.conf
sudo chmod 600 /etc/GeoIP.conf
sudo mkdir -p /usr/share/GeoIP
sudo geoipupdate
Confirm that the database exists:
sudo ls -lh /usr/share/GeoIP/GeoLite2-Country.mmdb
Test a lookup:
mmdblookup \
--file /usr/share/GeoIP/GeoLite2-Country.mmdb \
--ip 8.8.8.8 \
country iso_code
The example address should return US.
5. Configure the reusable NGINX rules
Create /etc/nginx/conf.d/country-access.conf:
# Determine the client's country from its IP address.
geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
auto_reload 1h;
$geoip2_country_code country iso_code;
}
# Explicit IP exceptions. Individual addresses and CIDR ranges are supported.
geo $manual_ip_exception {
default 0;
# Replace this reserved example address with the real exception.
203.0.113.42/32 1;
# Optional IPv6 exception:
# 2001:db8:1234::1/128 1;
}
# Allow Dutch addresses.
map $geoip2_country_code $country_allowed {
default 0;
NL 1;
}
# Access is allowed when the country or the explicit exception matches.
map "$country_allowed:$manual_ip_exception" $geographic_access_allowed {
default 0;
"1:0" 1;
"1:1" 1;
"0:1" 1;
}
Use /32 for one IPv4 address and /128 for one IPv6 address. Only use a broader CIDR range when the entire range is intentionally trusted.
6. Configure the real client IP when using a proxy
Skip this step when clients connect directly to NGINX.
When NGINX is behind a reverse proxy, $remote_addr can contain the proxy address instead of the visitor address. Configure the real-IP module with only the actual trusted proxy networks. For example:
set_real_ip_from 192.0.2.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Replace 192.0.2.0/24 with the real proxy network. Never trust X-Forwarded-For from every internet address because clients could forge it.
For Cloudflare, configure all current Cloudflare proxy ranges as trusted and use:
real_ip_header CF-Connecting-IP;
Verify the resulting client address before enabling the restriction.
7. Apply the restriction to the virtual host
Add the access check inside the relevant HTTPS server {} block:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com;
if ($geographic_access_allowed = 0) {
return 403;
}
# Existing website configuration follows.
location / {
proxy_pass http://127.0.0.1:8080;
}
}
Using if only to return an HTTP status is appropriate here. Add the same check to the port 80 virtual host when HTTP must also be protected:
server {
listen 80;
listen [::]:80;
server_name example.com;
if ($geographic_access_allowed = 0) {
return 403;
}
return 301 https://$host$request_uri;
}
8. Validate and reload NGINX
Keep the existing SSH session open so the change can be reversed if necessary. Test the complete NGINX configuration:
sudo nginx -t
Only after a successful test, reload NGINX:
sudo systemctl reload nginx
9. Test all access paths
Test from three different network locations:
- From a Dutch connection, expect the normal website response.
- From the explicit exception address, expect the normal website response.
- From a non-Dutch connection or VPN, expect
403 Forbidden.
Use:
curl -I https://example.com
For temporary diagnostics, add these headers inside the virtual host:
add_header X-Debug-Country $geoip2_country_code always;
add_header X-Debug-IP-Exception $manual_ip_exception always;
Reload NGINX and inspect the response headers. Remove the diagnostic headers after testing because they expose internal access-decision information.
10. Schedule database updates
Check whether the package installed a timer:
systemctl list-timers --all | grep geoip
When no timer is present, edit the root crontab:
sudo crontab -e
Add a weekly update:
17 4 * * 3 /usr/bin/geoipupdate
The auto_reload 1h setting makes the NGINX module check for a replaced database without requiring a manual NGINX reload.
Edge cases and failure modes
- GeoIP data is not perfect. Corporate VPNs, mobile networks, foreign exit nodes, and privacy relays can cause Dutch users to appear outside the Netherlands.
- A proxy or CDN configuration that does not restore the real client address can classify every visitor using the proxy address and country.
- Let's Encrypt HTTP-01 validation can originate outside the Netherlands and be blocked. Prefer DNS-01 validation, or deliberately exempt only the ACME challenge location.
- External monitoring and health checks originating outside the Netherlands need an explicit exception or a deliberately unprotected endpoint.
- The exception address can change when it is assigned dynamically by an ISP. Confirm whether the exceptional connection has a static IP.
- IPv4 and IPv6 are evaluated independently. Add both exception addresses when the exceptional client can use either protocol.
- An unavailable or unmatched GeoIP record is denied by default. This is fail-closed behavior.
- This restriction operates at the HTTP layer. A firewall or upstream WAF can provide another layer, but correct real-client-IP handling remains necessary.
Rollback
If valid users are unexpectedly blocked:
- Comment out or remove the
if ($geographic_access_allowed = 0)block from the affected virtual host. - Run
sudo nginx -t. - Run
sudo systemctl reload nginxonly when the test succeeds. - Investigate the real client address, GeoIP lookup, exception range, and proxy configuration before re-enabling the restriction.