Block the World: Country-Level Traffic Blocking with nftables
Runbooks

Block the World: Country-Level Traffic Blocking with nftables

This post is part of a four-part series.

Auto-updates close known vulnerabilities. Fail2ban blocks repeat offenders. SSH alerts and intrusion detection tell you what already happened. None of them stop the flood of connection attempts before they arrive. This post adds a layer in front of all of it: dropping traffic by country before it ever reaches your SSH port, and blocking a handful of high-risk countries from your public web ports too.

This is the other half of what caught my attention early on: not just how many attempts were hitting the server, but where a lot of them kept coming from. Fail2ban handles the volume. This layer handles the origin, blocking the locations known for this kind of behavior before they get the chance to try.

This one has more moving parts than the earlier posts, and I hit two real errors getting it working. Both are documented below with the actual fixes, not the polished version that pretends it worked the first time.

The Approach

Two different postures for two different kinds of traffic:

SSH (port 22): default-deny by country. Only my own country plus a static admin IP can reach it. Everyone else gets dropped before Fail2ban even sees them.

Web ports (80/443): default-allow with a blocklist. A blog needs visitors from anywhere. So web traffic stays open globally, but a short list of high-risk countries gets dropped.

Both allow lists are built from current CIDR data, not guesswork. A friend sent me a script that hardcoded entire continents as blocks of /8 address ranges, but those ranges are from early internet registry history and have been resold and subdivided for decades. They no longer map cleanly to geography, and blocking them would catch a lot of legitimate traffic while missing plenty of illegitimate traffic. This setup pulls current per-country IP ranges instead.

Coexisting with UFW

If you already have UFW managing your ports (allowing 22, 80, 443, and whatever else your server runs), you don't need to disable it. This script creates its own isolated nftables table, separate from whatever UFW manages, and both are evaluated independently. Traffic has to clear both to get through: UFW decides which ports are open, this table decides which countries can reach them. Neither one can delete or override the other's rules.

If you're running Fail2ban from the earlier posts in this series, same story: it lives in its own space and keeps working exactly as before.

Before You Start: Set Your Admin IP

This is the same caution as the Fail2ban whitelist, except the stakes are higher. Fail2ban locks you out after three bad passwords. This locks out your entire country if the allowlist is wrong.

Set at least one static admin IP before running the script. You can list more than one, space-separated, if you SSH in from multiple locations (home, work, a phone hotspot). Use your public IP, not a local network address:

curl ifconfig.me

The script also arms a 5-minute automatic revert the first time it runs. If you get locked out, wait 5 minutes and access restores itself. If it works, cancel the revert so it doesn't undo your protection:

sudo systemctl stop geoblock-failsafe.timer

The Setup Script

This script installs nftables, downloads current CIDR ranges from ipdeny.com, builds the ruleset, and sets up a weekly refresh so the IP data doesn't go stale. Save it as /root/scripts/nftables-geoblock-setup.sh, the same location used for every setup script in this series. Edit the configuration block at the top before running.

#!/bin/bash
# =============================================================================
# nftables GeoIP Blocking Setup Script - Ubuntu 22.04 LTS
# SSH: default-deny by country (allowlist). Web ports: blocklist high-risk countries.
# Run as non-root user with sudo privileges.
# =============================================================================

set -u

# --- CONFIGURATION ---
# Your static admin IP(s). This is your safety net if GeoIP data is wrong,
# stale, or your connection isn't registered to the country you expect.
# Space-separated: add one per location you SSH in from (home, work, etc).
# Use your public IP, not a local/LAN address (check with: curl ifconfig.me).
# ALWAYS set at least one real IP before running.
ADMIN_IPS="YOUR.ADMIN.IP.HERE/32"

# Country allowed to reach SSH (ISO 3166-1 alpha-2, lowercase)
ALLOW_COUNTRY="us"

# Countries blocked from web ports (space-separated ISO codes). Edit freely.
BLOCK_COUNTRIES="ru cn kp ir"

SSH_PORT="22"
WEB_PORTS="80, 443"

# Rate limit on SSH, applied only to traffic that already passed the country
# allowlist. Slows down brute force attempts from your own allowed country
# and protects against connection floods, independent of Fail2ban.
SSH_RATE_LIMIT="4/minute"

CONFIG_DIR="/etc/nftables-geoip"
NFT_DIR="/etc/nftables.d"
NFT_FILE="$NFT_DIR/geoblock.nft"
REFRESH_SCRIPT="/usr/local/bin/geoblock-refresh.sh"
LOG_FILE="$CONFIG_DIR/refresh.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}

separator() {
    echo "=============================================="
}

# --- PREFLIGHT CHECK ---
separator
log "NFTABLES GEOBLOCK SETUP STARTED"
separator

if command -v ufw >/dev/null 2>&1 && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
    echo ""
    echo "  NOTE: UFW is active."
    echo "  This script's rules live in a separate table (inet geoblock) from UFW's."
    echo "  Both are evaluated independently; traffic must pass both to get through."
    echo "  This does not disable, replace, or modify any existing UFW rule."
    echo ""
    read -p "  Continue with UFW active? (yes/no): " CONFIRM
    if [[ "$CONFIRM" != "yes" ]]; then
        log "Aborted by user. Re-run when ready."
        exit 0
    fi
fi

if [[ "$ADMIN_IPS" == *"YOUR.ADMIN.IP.HERE"* ]]; then
    echo ""
    echo "  WARNING: You have not set ADMIN_IPS."
    echo "  If the country allowlist is wrong, incomplete, or stale, you will be locked"
    echo "  out of SSH with no static fallback IP."
    echo ""
    read -p "  Continue without a static admin IP? (yes/no): " CONFIRM
    if [[ "$CONFIRM" != "yes" ]]; then
        log "Aborted. Set ADMIN_IPS and re-run."
        exit 0
    fi
fi

# --- INSTALL ---
log "Updating package list..."
sudo apt-get update -q

log "Installing nftables and curl..."
sudo apt-get install -y -q nftables curl

sudo mkdir -p "$CONFIG_DIR" "$NFT_DIR"

# --- WRITE CONFIG ---
log "Writing $CONFIG_DIR/geoblock.conf..."
sudo tee "$CONFIG_DIR/geoblock.conf" > /dev/null <<EOF
ADMIN_IPS="$ADMIN_IPS"
ALLOW_COUNTRY="$ALLOW_COUNTRY"
BLOCK_COUNTRIES="$BLOCK_COUNTRIES"
SSH_PORT="$SSH_PORT"
WEB_PORTS="$WEB_PORTS"
SSH_RATE_LIMIT="$SSH_RATE_LIMIT"
NFT_FILE="$NFT_FILE"
LOG_FILE="$LOG_FILE"
EOF

# --- WRITE REFRESH SCRIPT ---
# This is what actually builds and (re)applies the ruleset. It validates
# before touching live rules, and never touches any table but "inet geoblock",
# so it can't interfere with Fail2ban's rules.
log "Writing $REFRESH_SCRIPT..."
sudo tee "$REFRESH_SCRIPT" > /dev/null <<'EOF'
#!/bin/bash
# geoblock-refresh.sh - downloads current GeoIP CIDR ranges and reloads the
# nftables geoblock table. Safe to run repeatedly: validates before applying,
# and on any failure it leaves existing rules untouched.

set -u

source /etc/nftables-geoip/geoblock.conf

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}

fail() {
    log "[ERROR] $1 - keeping existing rules, not applying changes"
    exit 1
}

TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT

# --- download zone files ---
curl -fsSL "https://www.ipdeny.com/ipblocks/data/aggregated/${ALLOW_COUNTRY}-aggregated.zone" \
    -o "$TMP_DIR/allow.zone" || fail "failed to download zone for $ALLOW_COUNTRY"

: > "$TMP_DIR/block.zone"
for cc in $BLOCK_COUNTRIES; do
    curl -fsSL "https://www.ipdeny.com/ipblocks/data/aggregated/${cc}-aggregated.zone" \
        >> "$TMP_DIR/block.zone" || fail "failed to download zone for $cc"
done

# --- sanity check: refuse to apply an empty allowlist ---
ALLOW_COUNT=$(grep -c '/' "$TMP_DIR/allow.zone" || true)
if [ "$ALLOW_COUNT" -lt 1 ]; then
    fail "downloaded allowlist for $ALLOW_COUNTRY is empty"
fi

BLOCK_COUNT=$(sort -u "$TMP_DIR/block.zone" | grep -c '/' || true)

# --- build element lists ---
ALLOW_ELEMENTS=$(paste -sd, "$TMP_DIR/allow.zone")
BLOCK_ELEMENTS=$(sort -u "$TMP_DIR/block.zone" | paste -sd,)

ADMIN_ELEMENT=""
for ip in $ADMIN_IPS; do
    if [[ "$ip" != *"YOUR.ADMIN.IP.HERE"* ]]; then
        ADMIN_ELEMENT="${ADMIN_ELEMENT}${ip},"
    fi
done

# --- generate ruleset ---
cat > "$TMP_DIR/geoblock.nft" <<NFT
#!/usr/sbin/nft -f

table inet geoblock {}
delete table inet geoblock

table inet geoblock {
    set ssh_allow_v4 {
        type ipv4_addr
        flags interval
        auto-merge
        elements = { ${ADMIN_ELEMENT}${ALLOW_ELEMENTS} }
    }

    set web_block_v4 {
        type ipv4_addr
        flags interval
        auto-merge
        elements = { ${BLOCK_ELEMENTS} }
    }

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

        iif "lo" accept
        ct state established,related accept

        tcp dport $SSH_PORT ip saddr @ssh_allow_v4 limit rate $SSH_RATE_LIMIT accept
        tcp dport $SSH_PORT drop

        tcp dport { $WEB_PORTS } ip saddr @web_block_v4 drop
    }
}
NFT

# --- validate syntax before touching live rules ---
if ! sudo nft -c -f "$TMP_DIR/geoblock.nft" 2>>"$LOG_FILE"; then
    fail "generated ruleset failed validation"
fi

sudo cp "$TMP_DIR/geoblock.nft" "$NFT_FILE"
sudo nft -f "$NFT_FILE" || fail "nft -f failed to apply $NFT_FILE"

log "[OK] geoblock rules refreshed - ssh_allow: $ALLOW_COUNT ranges, web_block: $BLOCK_COUNT ranges"
EOF

sudo chmod 700 "$REFRESH_SCRIPT"

# --- SYSTEMD UNITS: reapply on boot, refresh weekly ---
log "Writing systemd units..."

sudo tee /etc/systemd/system/geoblock.service > /dev/null <<EOF
[Unit]
Description=Refresh nftables GeoIP block/allow rules
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=$REFRESH_SCRIPT
EOF

sudo tee /etc/systemd/system/geoblock.timer > /dev/null <<EOF
[Unit]
Description=Weekly refresh of nftables GeoIP rules

[Timer]
OnCalendar=weekly
Persistent=true

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now geoblock.timer

# --- FIRST APPLY ---
separator
log "Applying rules for the first time..."
separator

sudo "$REFRESH_SCRIPT"

if ! sudo nft list table inet geoblock > /dev/null 2>&1; then
    log "[ERROR] geoblock table did not apply. Check $LOG_FILE."
    exit 1
fi

log "Rules applied."

# --- FAILSAFE REVERT ---
# A 5-minute dead-man's switch. If the allowlist is wrong and you're locked
# out, this automatically removes the geoblock table and restores access.
separator
log "Arming 5-minute failsafe revert."
separator
sudo systemd-run --unit=geoblock-failsafe --on-active=5min \
    /usr/sbin/nft delete table inet geoblock

echo ""
echo "  IMPORTANT: Open a NEW SSH session now. Do not close this one."
echo ""
echo "  If the new session connects:  sudo systemctl stop geoblock-failsafe.timer"
echo "  If you're locked out:         wait 5 minutes. The geoblock table is removed"
echo "                                 automatically and normal access is restored."
echo ""

separator
log "SETUP COMPLETE"
separator

echo ""
echo "  Useful commands:"
echo ""
echo "  View active rules:                     sudo nft list table inet geoblock"
echo "  Manual refresh:                        sudo /usr/local/bin/geoblock-refresh.sh"
echo "  Check refresh timer:                   sudo systemctl list-timers geoblock.timer"
echo "  View refresh log:                      sudo tail -f /etc/nftables-geoip/refresh.log"
echo "  EMERGENCY - remove all geoblock rules: sudo nft delete table inet geoblock"
echo ""

Run It

sudo chmod +x /root/scripts/nftables-geoblock-setup.sh
sudo /root/scripts/nftables-geoblock-setup.sh

Two Real Errors I Hit

Both happened during validation, before anything touched the live firewall. That's the point of the nft -c -f check step: catch broken rulesets before they apply.

Error 1: "File exists" on the ssh_allow_v4 set

Error: Could not process rule: File exists
    set ssh_allow_v4 {

My admin IP is a US address, and ALLOW_COUNTRY="us" already covers the entire aggregated US range. That means my explicit /32 overlapped with a broader CIDR block already in the same list. nftables treats overlapping ranges in an interval set as a conflict unless told otherwise.

Fix: add auto-merge to the set, which tells nftables to collapse overlapping or adjacent ranges instead of rejecting them. It's not a flag value combined with interval, it's its own line:

set ssh_allow_v4 {
    type ipv4_addr
    flags interval
    auto-merge
    elements = { ... }
}

Error 2: "unexpected auto-merge" syntax error

My first attempt wrote it as flags interval, auto-merge on one line, treating it like another flag value. That's wrong:

Error: syntax error, unexpected auto-merge, expecting constant or interval or dynamic or timeout

auto-merge isn't a flag value, it's a separate statement in the set body. The script above has the corrected syntax already.

Verifying It Works

Confirm the table is active and holding rules:

sudo nft list table inet geoblock

You should see both sets populated with CIDR ranges and the chain with your rules.

Open a second SSH session (don't close your first) to confirm you can still connect, then cancel the failsafe:

sudo systemctl stop geoblock-failsafe.timer

If you have SSH login alerting from the last post configured, you should get a Ntfy push the moment that second session connects, confirming both layers are working together.


Useful Commands Reference

CommandWhat It Does
sudo nft list table inet geoblockView active rules and set contents
sudo /usr/local/bin/geoblock-refresh.shManually refresh CIDR ranges and reapply
sudo systemctl list-timers geoblock.timerConfirm the weekly refresh is scheduled
sudo tail -f /etc/nftables-geoip/refresh.logWatch refresh activity in real time
sudo nft delete table inet geoblockEmergency: remove all geoblock rules immediately

What's Next

That's all four layers: patched automatically, brute force banned, intrusions detected, traffic filtered by country. Each one does one job, and none of them depend on the others to function. Together, they're the same baseline I run on every server I deploy.

And thanks to AI, I was able to build all four layers without needing to be a nftables or systemd expert going in. I described what I wanted each layer to do, and Claude helped me turn that into working scripts, catch syntax errors like the auto-merge issue above before they ever touched a live firewall, and document the fixes clearly enough to write this post. That's the real point of this whole series: the tools to run this baseline yourself are more within reach than most people think, whether that's the scripts themselves or the AI that helps you build and understand them.

Wayne M. Shelton Sr. is a retired military veteran and cybersecurity practitioner. He holds the CISM, CGRC, CySA+, Security+, and CTT+ certifications and writes about cybersecurity, AI, and ministry technology at waynesheltonsr.com. Contact: [email protected]