log in
consulting hosting industries the daily tools about contact

nginx geo module: GeoIP routing and rate limiting for free

You don't need a paid WAF or middleware to block or throttle traffic by country. nginx's geo module does it before your app even wakes up.

The best middleware is the middleware that never runs your application code. I've been using nginx's built-in geo module for years to do cheap, fast, zero-dependency GeoIP-based routing and rate limiting, and I keep meeting developers who don't know it exists — or who are paying for a WAF to do something nginx handles natively in a few lines of config.

This isn't exotic. It ships with nginx. It costs nothing. And it fires before PHP-FPM, before Laravel, before any of your code touches the request.

What problem this actually solves

The scenario I hit constantly: a client's app — e-commerce, a patient portal, a real estate tool — starts getting hammered. Could be scrapers, credential stuffing, or just garbage traffic. The traffic often concentrates in regions the client doesn't serve at all. A Seattle-based medical practice doesn't have patients in Romania. A Pacific Northwest e-commerce shop doesn't ship to Southeast Asia.

The usual answers are expensive (Cloudflare Pro, AWS WAF, a dedicated rate-limiting service) or they burn CPU in your app layer (middleware that runs after the PHP process has already forked). The geo module is neither of those. It maps an IP to a variable at the nginx level — before any upstream gets involved — and you use that variable to drive limit_req_zone, return 444, or routing decisions.

How the geo module works

ngx_http_geo_module is compiled into nginx by default. It lets you define a block that maps $remote_addr (or any IP variable) to a value, using CIDR ranges or the MaxMind GeoIP2 database via a companion module. The basic geo block is pure nginx — no database needed — and works with manually maintained CIDR lists. The geoip2 module integrates MaxMind's free GeoLite2 database for country/city-level lookups.

I'll show both, because the manual CIDR list is underrated for specific use cases.

Setup: MaxMind GeoLite2 + ngx_http_geoip2_module

On Ubuntu/Debian with the nginx mainline PPA:

# Install the geoip2 module
apt install libnginx-mod-http-geoip2

# Install mmdb-bin for database updates
apt install mmdb-bin

# Sign up for a free MaxMind account, then download GeoLite2-Country
# Use geoipupdate or wget with your license key
wget -O /etc/nginx/geoip/GeoLite2-Country.mmdb \
  "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=YOUR_KEY&suffix=tar.gz"

Add a cron to refresh the database weekly — MaxMind updates it Tuesdays.

# /etc/cron.weekly/geoipupdate
#!/bin/bash
geoipupdate
nginx -s reload

The nginx config

Here's a real pattern I use. Everything in nginx.conf or a conf.d/geoip.conf include, loaded before your server blocks:

# Load the module if it's not auto-loaded
load_module modules/ngx_http_geoip2_module.so;

http {
    # Point at the MaxMind database
    geoip2 /etc/nginx/geoip/GeoLite2-Country.mmdb {
        auto_reload 1h;
        $geoip2_metadata_country_build metadata build_epoch;
        $geoip2_data_country_code default=US source=$remote_addr country iso_code;
    }

    # Map country code to a rate-limit zone key
    # Countries we serve normally get "$binary_remote_addr"
    # Everyone else gets a tighter key or a block flag
    map $geoip2_data_country_code $limit_key {
        default          $binary_remote_addr;  # unknown — still rate-limit
        US               $binary_remote_addr;
        CA               $binary_remote_addr;
        ~^(RU|CN|KP|BY)  "blocked";
    }

    map $geoip2_data_country_code $geo_blocked {
        default  0;
        RU       1;
        CN       1;
        KP       1;
        BY       1;
    }

    # Two zones: one for normal traffic, one for everything else
    limit_req_zone $binary_remote_addr zone=normal:10m rate=30r/s;
    limit_req_zone $binary_remote_addr zone=tight:10m  rate=5r/m;
}

Then in your server block:

server {
    listen 443 ssl;
    server_name example.com;

    # Hard block — close connection without response
    if ($geo_blocked) {
        return 444;
    }

    location / {
        # Apply tighter limits to countries you serve but don't trust
        limit_req zone=normal burst=50 nodelay;
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        limit_req zone=normal burst=10;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        # ... rest of fastcgi config
    }
}

return 444 is nginx-specific — it closes the TCP connection with no response at all. No HTTP status, no body. Scanners hate it. It's my default for traffic I want gone.

The manual geo block (no MaxMind required)

For tighter control — or when you just want to block known bad CIDR ranges without dealing with a database — the plain geo module is great:

geo $bad_actor {
    default          0;
    45.155.204.0/22  1;   # Known scanner range
    185.220.101.0/24 1;   # Tor exit nodes
    193.32.127.0/24  1;
}

I maintain a small list like this per client and update it when I see patterns in access logs. You can generate it from a log analysis script and reload nginx — the whole cycle takes seconds and doesn't touch the app.

Routing by country, not just blocking

Blocking is the obvious use case, but routing is equally useful. I've used this for:

  • Sending non-US traffic to a "not available in your region" static page without touching PHP
  • Routing Canadian traffic to a different upstream that handles CAD pricing
  • Sending suspected-bot countries to a honeypot location that logs and returns 200 with fake data
# In http block
map $geoip2_data_country_code $upstream_pool {
    default  app_us;
    CA       app_ca;
    GB       app_gb;
}

upstream app_us { server 127.0.0.1:9001; }
upstream app_ca { server 127.0.0.1:9002; }
upstream app_gb { server 127.0.0.1:9003; }

# In server block
location / {
    proxy_pass http://$upstream_pool;
}

You can also just pass the country code as a header to PHP and let Laravel handle the logic — cheaper than a full upstream split if the differences are minor:

location ~ \.php$ {
    fastcgi_param HTTP_X_COUNTRY_CODE $geoip2_data_country_code;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    include fastcgi_params;
}

Then in Laravel middleware:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class GeoAwareMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $country = $request->server('HTTP_X_COUNTRY_CODE', 'US');

        // Store on the request for use in controllers/views
        $request->merge(['country_code' => $country]);

        return $next($request);
    }
}

Nginx does the lookup. PHP just reads a header. No MaxMind PHP SDK, no database calls in the app layer.

Gotchas that will bite you

IPv6 and $binary_remote_addr in rate-limit zones. IPv6 addresses are 16 bytes vs 4 for IPv4, so your zone size estimates change. A 10m zone holds roughly 160k IPv4 addresses or 80k IPv6. Size up if you're on a high-traffic site.

Cloudflare or a load balancer in front of nginx. $remote_addr will be Cloudflare's IP, not the visitor's. You need to restore the real IP first:

# Restore real IP from CF-Connecting-IP
real_ip_header CF-Connecting-IP;
set_real_ip_from 103.21.244.0/22;  # Add all CF ranges
set_real_ip_from 103.22.200.0/22;
# ... etc

Do this before the geo/geoip2 block does its lookup, or you'll be geolocating Cloudflare's datacenter in San Jose for every request.

MaxMind accuracy. GeoLite2 is free and good, but it's not perfect. I've seen US-based VPN users show up as Russian. If you're hard-blocking by country, expect occasional support tickets from legitimate users on VPNs or unusual ISPs. For sensitive applications, I use return 403 with a friendly message and a support email rather than 444.

The if directive in nginx. Nginx's if is famously surprising in location blocks — it has edge cases that cause inheritance issues. The map approach I showed above (setting a flag variable, then using if at the server level) is safer than nesting if inside location blocks. Or use return directly at the server level before locations are evaluated.

Database staleness. MaxMind updates GeoLite2 weekly. An outdated database means some IPs get misclassified. Automate the update. It takes five minutes to set up and you'll never think about it again.

When I'd reach for this

I use geo-based filtering on almost every client site I manage now. It takes 30 minutes to set up and it's the cheapest defense layer I've found. Specifically:

  • Any app that serves a specific geographic market and gets international scraper or bot traffic
  • Healthcare or financial apps where geographic compliance matters (not a substitute for real compliance, but a first filter)
  • E-commerce sites getting credential stuffing from non-market regions
  • Anything that's getting hammered and you need relief now while you figure out the real fix

I would not rely on this as my only security layer. GeoIP is a soft control — VPNs and proxies bypass it trivially. I treat it like a bouncer checking IDs: it stops casual bad actors, not determined ones. Combine it with fail2ban, proper rate limiting in your app, and a real auth system.

I also wouldn't reach for this if you have Cloudflare Pro already — their firewall rules do this with a better UI and more reliable data. But if you're running your own nginx stack (which I do for most NWOS managed hosting), this is free and it works.

Bottom line

Nginx's geo module is one of those tools that's been sitting in your stack the whole time, doing nothing, waiting for you to use it. Thirty minutes of config and you've got country-level routing and rate limiting that fires before a single line of PHP runs. I wish I'd standardized on this pattern five years earlier than I did.

Related

Need help shipping something like this? Get in touch.