IP Geo blocking
NGINX
Qool geographic IPrestricts access restriction
Purpose
Restrict an NGINX website so that only client IP addresses geolocated to the Netherlands (NL)using andNGINX explicitly configured exception addresses can access it. All other clients receive 403 Forbidden.GeoIP2.
This
1. procedure targets Ubuntu or Debian and uses the MaxMind GeoLite2 Country database withInstall 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 orsudoaccess 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 isNL, then access is allowed.When the client IP matches a configured exception, then access is allowed.When neither condition matches, then NGINX returns403 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 packagesmodule
sudo apt update
sudo apt install libnginx-mod-http-geoip2 geoipupdate mmdb-bin
CheckUbuntu whethershould theautomatically module is loaded:create:
sudo nginx -T 2>&1 | grep geoip2
On Ubuntu, installing libnginx-mod-http-geoip2 normally creates a module loader under /etc/nginx/modules-enabled/50-mod-http-geoip2.conf.
If it does not, add
With the following directive at the top level of /etc/nginx/nginx.conf, outside the http {} block:contents:
load_module modules/ngx_http_geoip2_module.so;
Do
2. 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 downloadDownload the country database
geoipupdateEditDB-IP /etc/GeoIP.conf:publishes its free country database monthly in MMDB format. Select the current release from the official DB-IP download page. The free database requires attribution under CC BY 4.0.
For the November 2026 release:
AccountID YOUR_MAXMIND_ACCOUNT_ID
LicenseKey YOUR_MAXMIND_LICENSE_KEY
EditionIDs GeoLite2-Country
DatabaseDirectorycd /usr/share/GeoIPtmp
curl Protect-fL the\
credentialshttps://download.db-ip.com/free/dbip-country-lite-2026-11.mmdb.gz and\
download-o thedbip-country-lite.mmdb.gz
database:
sudo-dc chowndbip-country-lite.mmdb.gz root:root\
/etc/GeoIP.conf> dbip-country-lite.mmdb
sudo chmod 600 /etc/GeoIP.conf
sudo mkdirinstall -pd -o root -g root -m 0755 /usr/share/GeoIP
sudo geoipupdate
Confirm that the database exists:
sudo lsinstall -lho root -g root -m 0644 \
dbip-country-lite.mmdb \
/usr/share/GeoIP/GeoLite2-Country.dbip-country-lite.mmdb
TestDB-IP adocuments lookup:that Lite databases are updated monthly, so the YYYY-MM value must periodically be updated. DB-IP documentation
mmdblookup3. \Define --file /usr/share/GeoIP/GeoLite2-Country.mmdb \
--ip 8.8.8.8 \the country iso_code
The example address should return US.
5. Configure the reusable NGINX rulespolicy
Create /etc/nginx/conf.d/nl-country-:access.map.conf
# Determine the client's country from its IP address.
geoip2 /usr/share/GeoIP/GeoLite2-Country.dbip-country-lite.mmdb {
auto_reload 1h;
$geoip2_country_codevisitor_country_code default=ZZ country iso_code;
}
# Explicit IP exceptions. Individual addresses and CIDR ranges are supported.
geomap $manual_ip_exceptionremote_addr $trusted_ip {
default 0;
# Replace this reserved example address with theyour realown exception.trusted public IP.
203.0.113.42/32 1;
# Optional IPv6 exception:
# 2001:db8:1234::1/12810 1;
}
# Allow Dutch addresses.
map "$geoip2_country_codevisitor_country_code:$trusted_ip" $country_allowed {
default 0;
NL# Allow clients classified as Dutch.
~^NL: 1;
# Allow explicitly trusted source addresses.
~:1$ 1;
}
4. Create the enforcement snippet
Create /etc/nginx/snippets/nl-only.conf:
# Reusable Netherlands-only access policy.
# AccessRequires is$nl_country_allowed allowed whenin the countryHTTP orcontext.
theif explicit($country_allowed exception= matches.
map "$country_allowed:$manual_ip_exception" $geographic_access_allowed0) {
defaultreturn 0;
"1:0" 1;
"1:1" 1;
"0:1" 1;403;
}
UseUsing /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 with 192.0.2.0/24ifthea real proxy network. Never trustsimple X-Forwarded-Forreturnfromis everyappropriate internet address because clients could forge it.here.
For
5. Cloudflare,Enable configureit allfor currenta Cloudflare proxy ranges as trusted and use:
real_ip_header CF-Connecting-IP;
website
real_ip_header CF-Connecting-IP;
VerifyInside the resulting client address before enabling the restriction.
7. Apply the restriction to the virtual host
Add the access check inside the relevant HTTPSapplicable server block: {}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com;
ifinclude ($geographic_access_allowed = 0) {
return 403;
}snippets/nl-only.conf;
# ExistingRemaining website configuration follows.
location / {
proxy_pass http://127.0.0.1:8080;configuration...
}
}
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.6. Test thebefore complete NGINX configuration:
sudo nginx -t
Only afterwhen athat successful test, reload NGINX:succeeds:
sudo systemctl reload nginx
9. Test all access paths
Test from three different network locations:from:
From aA Dutch connection,expectwhich should receive the normalwebsite response.website.From the explicit exception address, expect the normal website response.From aA non-DutchconnectionVPNorendpoint,VPN,whichexpectshould receive HTTP403.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 andcan inspect theNGINX’s responseeffective headers.configuration 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:with:
sudo crontabnginx -e
T
Add
Important a weekly update:
17 4 * * 3 /usr/bin/geoipupdate
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
considerations
GeoIPIfdataNGINX isnotbehindperfect.Cloudflare,CorporateanotherVPNs,reversemobileproxy,networks,orforeignaexitloadnodes,balancer,and$remote_addrprivacymayrelays can cause Dutch users to appear outsidebe theNetherlands.proxy address. Configure NGINX’s real-IP module correctly before applying this restriction. Do not blindly trust client-supplied forwarding headers.- A
proxyserver-levelor CDN configuration that does not restore the real client addressrestriction canclassifyalsoeveryblockvisitor using the proxy address and country. Let'Let’s Encrypt HTTP-01validationvalidation.canIforiginateCertbotoutsideusestheHTTP-01,Netherlandsprovideandabeseparateblocked. Prefer DNS-01 validation, or deliberately exempt only theunrestricted ACME challengelocation.virtual host/location or use DNS-01 validation.ExternalGeoIPmonitoringis not an absolute security boundary. VPNs, proxies, database inaccuracies, andhealthstalechecksrecordsoriginatingcanoutside the Netherlands need an explicit exceptionbypass oraincorrectlydeliberatelytriggerunprotected endpoint.it.- The
exceptionqooladdressservercancurrentlychangehaswhennoitdetected automatic database updater. A monthly replacement of the MMDB file should therefore be scheduled if you want current classifications. - SSH access is
assignedunaffected;dynamicallythisbyconfigurationanonlyISP.restrictsConfirmwebsiteswhetherserved through theexceptionalapplicableconnection 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 theif ($geographic_access_allowed = 0)block from the affectedNGINX virtual host.Runsudo--
nginx-t.Runsudo systemctl reload nginxonly when the test succeeds.Investigate the real client address, GeoIP lookup, exception range, and proxy configuration before re-enabling the restriction.