GeoIP Blocking with nftables and MaxMind GeoLite2 on Linux: Filter Traffic by Country

Networking tutorial - IT technology blog
Networking tutorial - IT technology blog

The Problem: My Server Logs Were Full of Garbage

A while back, I was managing a production server for a client whose entire user base was in Southeast Asia and Japan. One morning I pulled up the access logs and saw thousands of failed SSH attempts, port scans, and random exploit probes — all from a handful of countries the client had zero business relationship with. fail2ban was already running. Rate limiting was in place. The noise kept coming anyway, eating up log space and triggering alerts.

That’s when I implemented GeoIP blocking at the firewall level — dropping traffic from entire country IP ranges before it even reaches the application stack. If you run any public-facing Linux server, this belongs in your toolkit. Here’s the exact setup I use: nftables for the firewall rules and MaxMind GeoLite2 for the country-to-IP mapping.

Core Concepts: What You’re Actually Doing

nftables and Its Set Feature

nftables is the modern replacement for iptables. It’s been in the Linux kernel since version 3.13 and is now the default on Ubuntu 22.04+, Debian 11+, and most current distros. The reason it’s perfect for GeoIP filtering is its native sets with interval support — you load tens of thousands of CIDR ranges into a named set and match against all of them with a single rule. No ipset juggling, no extra daemons. Just clean nftables syntax.

MaxMind GeoLite2

MaxMind maintains databases that map IP ranges to countries. GeoLite2 is the free tier — create an account, generate a license key, and you can download it. The Country database refreshes twice a month. It won’t catch every VPN exit node, but for cutting out bulk scanning traffic from specific regions, it makes a measurable difference.

How the Pieces Connect

  1. Download the GeoLite2 Country MMDB database from MaxMind
  2. Extract IP ranges for your target countries using a Python script
  3. Load those ranges into an nftables set
  4. Write a rule that drops inbound traffic matching that set
  5. Automate database refresh every two weeks via cron

Hands-On Setup

Step 1: Download the GeoLite2 Database

Register at maxmind.com and grab a free license key. Then pull the MMDB file:

sudo apt install mmdb-bin wget -y

MAXMIND_KEY="YOUR_LICENSE_KEY_HERE"

wget -q "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_KEY}&suffix=tar.gz" \
  -O /tmp/GeoLite2-Country.tar.gz

tar -xzf /tmp/GeoLite2-Country.tar.gz -C /tmp/
sudo mkdir -p /etc/maxmind
sudo cp /tmp/GeoLite2-Country_*/GeoLite2-Country.mmdb /etc/maxmind/
rm -rf /tmp/GeoLite2-Country*

Step 2: Extract Country IP Ranges

The MMDB file is binary. You’ll need a small Python script to pull out CIDR ranges for specific countries. Install the library first:

pip3 install maxminddb
#!/usr/bin/env python3
# /usr/local/bin/extract_country_ips.py

import maxminddb
import sys

MMDB_PATH = "/etc/maxmind/GeoLite2-Country.mmdb"
TARGET_COUNTRIES = set(sys.argv[1:])  # e.g., CN RU KP

ranges = []
with maxminddb.open_database(MMDB_PATH) as db:
    for record, network in db:
        if record and "country" in record:
            iso = record["country"].get("iso_code", "")
            if iso in TARGET_COUNTRIES:
                ranges.append(str(network))

for r in sorted(set(ranges)):
    print(r)
sudo chmod +x /usr/local/bin/extract_country_ips.py

# Test it — CN + RU together typically yields 10,000–13,000 lines
python3 /usr/local/bin/extract_country_ips.py CN RU | wc -l

Step 3: Create the nftables Configuration

Set up the base table and sets in a dedicated file — keeps things clean and reloadable:

sudo nano /etc/nftables-geoip.conf
#!/usr/sbin/nft -f
# GeoIP blocking table

table inet geoip_filter {
    set blocked_countries {
        type ipv4_addr
        flags interval
        auto-merge
    }

    set blocked_countries_v6 {
        type ipv6_addr
        flags interval
        auto-merge
    }

    chain input {
        type filter hook input priority 0; policy accept;

        # Optional: log before drop during testing
        # ip saddr @blocked_countries log prefix "geoip-block: " drop
        ip saddr @blocked_countries counter drop
        ip6 saddr @blocked_countries_v6 counter drop
    }
}

Two directives worth understanding: flags interval tells nftables the set holds CIDR ranges rather than individual IPs. auto-merge consolidates overlapping ranges automatically. Both are essential when dealing with country-level datasets that can have thousands of overlapping prefixes.

Step 4: Write the Update Script

This script downloads a fresh database when needed, extracts the ranges, and reloads the nftables sets without a full firewall restart:

sudo nano /usr/local/bin/update-geoip-block.sh
#!/bin/bash
set -e

MMDB_PATH="/etc/maxmind/GeoLite2-Country.mmdb"
MAXMIND_KEY="${MAXMIND_LICENSE_KEY}"
BLOCKED_COUNTRIES="CN RU KP IR"  # Adjust to your needs

# Refresh database if older than 15 days
if [ ! -f "$MMDB_PATH" ] || find "$MMDB_PATH" -mtime +15 | grep -q .; then
  echo "[+] Refreshing GeoLite2 database..."
  wget -q "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_KEY}&suffix=tar.gz" \
    -O /tmp/GeoLite2.tar.gz
  tar -xzf /tmp/GeoLite2.tar.gz -C /tmp/
  cp /tmp/GeoLite2-Country_*/GeoLite2-Country.mmdb "$MMDB_PATH"
  rm -rf /tmp/GeoLite2*
fi

echo "[+] Extracting IP ranges for: $BLOCKED_COUNTRIES"
python3 /usr/local/bin/extract_country_ips.py $BLOCKED_COUNTRIES > /tmp/blocked_ranges.txt

# Separate IPv4 and IPv6
IPV4=$(grep -v ':' /tmp/blocked_ranges.txt | paste -sd ',' -)
IPV6=$(grep ':' /tmp/blocked_ranges.txt | paste -sd ',' -)

# Load base config if table doesn't exist yet
nft list table inet geoip_filter &>/dev/null || nft -f /etc/nftables-geoip.conf

# Flush and reload sets
nft flush set inet geoip_filter blocked_countries 2>/dev/null || true
nft flush set inet geoip_filter blocked_countries_v6 2>/dev/null || true

[ -n "$IPV4" ] && nft add element inet geoip_filter blocked_countries "{ $IPV4 }"
[ -n "$IPV6" ] && nft add element inet geoip_filter blocked_countries_v6 "{ $IPV6 }"

TOTAL=$(wc -l < /tmp/blocked_ranges.txt)
echo "[+] Done. Loaded ${TOTAL} ranges."
sudo chmod +x /usr/local/bin/update-geoip-block.sh

# Store license key safely
echo "MAXMIND_LICENSE_KEY=your_key_here" | sudo tee /etc/geoip.env
sudo chmod 600 /etc/geoip.env

Step 5: Run It and Verify

# First run
sudo bash -c 'source /etc/geoip.env && /usr/local/bin/update-geoip-block.sh'

# Check that ranges loaded
sudo nft list set inet geoip_filter blocked_countries | head -5

# Check packet counters
sudo nft list ruleset | grep counter
# Expected output:
# ip saddr @blocked_countries counter packets 1842 bytes 147360 drop

Step 6: Persist Across Reboots and Automate Updates

Save the current ruleset, enable nftables on boot, then create a systemd service to reload the GeoIP sets after every restart:

# Persist base rules
sudo nft list ruleset > /etc/nftables.conf
sudo systemctl enable --now nftables
sudo nano /etc/systemd/system/geoip-reload.service
[Unit]
Description=Reload GeoIP nftables sets
After=nftables.service
Wants=nftables.service

[Service]
Type=oneshot
EnvironmentFile=/etc/geoip.env
ExecStart=/usr/local/bin/update-geoip-block.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now geoip-reload

For bi-weekly database refreshes, add a cron job:

sudo nano /etc/cron.d/geoip-update
MAXMIND_LICENSE_KEY=your_key_here
0 3 1,15 * * root /usr/local/bin/update-geoip-block.sh >> /var/log/geoip-update.log 2>&1

Lessons From Running This in Production

Five different production deployments later, the same gotchas keep appearing. Save yourself the pain:

  • Whitelist your own IP first. Before loading any blocking rules, add an explicit accept rule for your management IPs. Getting locked out of a cloud VPS because you blocked your own country is a miserable way to spend an afternoon.
  • Start with logging, not dropping. On a new server, swap drop for log prefix "geoip-block: " drop during the first 48 hours. Scan /var/log/kern.log for anything unexpected before you commit to hard blocks.
  • Watch memory on small VPS instances. Blocking China and Russia together loads roughly 10,000–12,000 IP ranges. nftables handles this efficiently, but on a 512MB VPS you’ll notice a small memory bump on the first load. Check it once with free -m before and after.
  • VPNs are a blind spot. This cuts out unsophisticated bulk scanning effectively — the stuff hammering port 22 from the same /24 over and over. A determined attacker routing through a non-blocked country bypasses it entirely. Treat GeoIP as one layer, not a complete solution. Pair it with fail2ban, SSH key auth, and application-level rate limiting.
  • Don’t over-block. CDN edge nodes, uptime monitoring services, and third-party APIs can route through regions you’re blocking. Start with the highest-noise, least-relevant countries and expand from there — not the other way around.

Results Worth Mentioning

On one client server, SSH brute-force attempts dropped by over 70% within the first week of deployment. Access logs went from genuinely unreadable noise to something a human could actually scan in five minutes. The automated update script has been running for months without a single failure.

Initial setup takes under an hour. After that, the cron job and systemd service handle everything. If your server has a regional audience and you’re getting hammered from countries with zero legitimate users, few firewall changes will buy you this much quiet for this little ongoing effort.

Share: