Geo blocking means allowing, blocking or redirecting visitors based on the country of their IP address. You can do it at four levels: at your CDN (a Cloudflare rule, no code), in your web server (nginx with a GeoIP2 database or the CDN’s country header), in your app’s edge code (Next.js proxy) or in any backend with an IP geolocation API call. This guide shows each one with configs and code we ran, and the mistakes that block more than you meant: search engine crawlers, payment webhooks and travelers.
Choose where to block
| Where | Country from | Good for | Limits |
|---|---|---|---|
| Cloudflare custom rule | Cloudflare’s geolocation | Sites behind Cloudflare; no code, blocks before your server | The number of rules depends on your Cloudflare plan |
| nginx | CF-IPCountry header or a local GeoIP2 database | Whole sites, fast, no extra requests | Database needs regular updates; country only |
| Next.js proxy | x-vercel-ip-country or cf-ipcountry | Apps on Vercel or behind Cloudflare | Header only exists on those platforms |
| IP geolocation API | An API call, cached per IP | Any stack and host; country plus city, ISP and VPN flags | One request per new IP (cache it) |
Before you block
- Block, redirect or suggest. Block content you may not serve (licensing, sanctions). For language or currency, suggest a version and let people choose.
- Use the right status code.
451 Unavailable For Legal Reasons(RFC 7725) for legal blocks,403 Forbiddenfor the rest. Show a short page that says why and how to reach you. - Keep crawlers in. Google says Googlebot’s default addresses appear to be in the USA and asks sites to treat it like any other visitor from that country, so blocking the US also blocks Googlebot’s US-based crawling. Verify crawlers and let them through only when your content may be shown in their country.
- Keep webhooks and monitors in. Payment providers and uptime monitors call you from their own data centers, often in the US: check Stripe’s webhook IPs and UptimeRobot’s before you block a country they send from.
- Expect VPNs. A VPN user appears in the country of the VPN server. If that matters, add VPN detection (section 4).
1. Cloudflare: a custom rule
If your site runs through Cloudflare, every request carries the visitor’s country. Go to Security → Security rules → Create rule → Custom rules, choose Edit expression and enter:
(ip.src.country in {"RU" "KP" "IR"})
Set the action to Block (or Managed Challenge) and deploy. To allow only some countries and block the rest:
(not ip.src.country in {"DE" "AT" "CH"} and not cf.client.bot)
The second part, not cf.client.bot, keeps Cloudflare’s verified bots (search engine crawlers, monitoring services) out of the block. ip.src.country is the current field name; the older ip.geoip.country still works but is deprecated. Custom rules are available on every Cloudflare plan; country blocks in IP Access rules are Enterprise-only, and Cloudflare recommends custom rules instead.
Read the country in your app. With Network → IP Geolocation switched on, Cloudflare adds CF-IPCountry: DE to every request it sends to your origin (XX when the country is unknown, T1 for Tor). The Add visitor location headers managed transform adds city, region and coordinates too. In a Cloudflare Worker, the country is request.cf.country.
2. nginx
Behind Cloudflare: use its header
When Cloudflare is in front, nginx can block on CF-IPCountry without a database. Only do this if your origin accepts connections from Cloudflare alone (an allow/deny list of the Cloudflare IP ranges, which that page offers as nginx config, or a firewall): anyone who reaches your server directly can send any CF-IPCountry they like.
# http {} context
map $http_cf_ipcountry $blocked_country {
default 0;
RU 1;
KP 1;
IR 1;
}
# the visitor's address for logs and rate limits (all Cloudflare ranges, from /ip-ranges/cloudflare/)
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 2400:cb00::/32;
real_ip_header CF-Connecting-IP;
# server {} context
error_page 451 /unavailable.html;
location = /unavailable.html {
root /var/www/errors;
internal;
}
location / {
if ($blocked_country) {
return 451;
}
# your proxy_pass, root or try_files
}
Put the check inside location / rather than directly in server {}: the error page is an internal redirect, and a server-level check would block the error page itself. A return inside if is one of the few uses of if in a location that the nginx wiki’s “If is evil” page calls safe.
Without a CDN: the GeoIP2 module
Without a CDN, nginx looks the address up in a local database with the third-party ngx_http_geoip2_module. Install it from your distribution’s packages (Debian: libnginx-mod-http-geoip2) or build it as a dynamic module, and download MaxMind’s free GeoLite country database (GeoLite2-Country.mmdb, free account and license key required).
# main context (outside http {}), if the module is dynamic and not loaded by your package
load_module modules/ngx_http_geoip2_module.so;
http {
geoip2 /etc/nginx/GeoLite2-Country.mmdb {
auto_reload 24h;
$geoip2_country_code country iso_code;
}
# behind a load balancer: take the visitor's address from X-Forwarded-For first
set_real_ip_from 10.0.0.0/8; # your load balancer
real_ip_header X-Forwarded-For;
real_ip_recursive on;
map $geoip2_country_code $blocked_country {
default 1; # block everything ...
DE 0; # ... except these countries
AT 0;
CH 0;
"" 0; # no country in the database: let through
}
}
Use the same location / block as above. To hand the country to your application instead of blocking, add proxy_set_header X-Country $geoip2_country_code; next to your proxy_pass.
How we tested: nginx -t with nginx 1.31.8 (Homebrew) passed both configurations, and a running nginx returned 451 with the error page for CF-IPCountry: RU and 200 for DE. Homebrew’s nginx has no GeoIP2 module, so for the second configuration we set $geoip2_country_code from a test header and ran everything else as printed: AT got 200, US 451 and an empty country 200. The geoip2 {} block follows the module’s README, and mmdblookup confirmed that the path country iso_code returns the ISO code in MaxMind’s test database. MaxMind updates the GeoLite country and city databases twice a week (Tuesday and Friday); run geoipupdate from cron so auto_reload picks up the new file.
3. Next.js: proxy (formerly middleware)
Next.js 16 renamed middleware.ts to proxy.ts and the exported function to proxy; the API is otherwise the same. On Vercel the country is in x-vercel-ip-country, behind Cloudflare in cf-ipcountry:
// proxy.ts – Next.js 16 (in Next.js 15 and earlier: middleware.ts with a function named middleware)
import { NextResponse, type NextRequest } from "next/server";
const BLOCKED = new Set(["RU", "KP", "IR"]);
export function proxy(request: NextRequest) {
// Vercel sets x-vercel-ip-country; behind Cloudflare it is cf-ipcountry (with IP Geolocation on)
const country = request.headers.get("x-vercel-ip-country") ?? request.headers.get("cf-ipcountry");
if (country && BLOCKED.has(country)) {
return new NextResponse("This service is not available in your country.", { status: 451 });
}
// Suggest, don't force: pass the country to the app, which can show a banner
const headers = new Headers(request.headers);
headers.set("x-country", country ?? "");
return NextResponse.next({ request: { headers } });
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)"],
};
A server component reads the country with (await headers()).get("x-country") and can offer the local version. Read only the header your platform sets: on any other host, a visitor can send x-vercel-ip-country themselves. We type-checked the file with TypeScript 7 in strict mode and ran proxy() with real NextRequest objects from Next.js 16.4: RU and IR got 451, DE passed through with x-country: DE.
4. Any stack: an IP geolocation API
If no CDN adds the country, or you need more than the country, look the visitor’s address up yourself. This Express middleware asks the ipbase API for the country and the is_anonymous flag (VPN, proxy, Tor or relay), caches the answer for a day and fails open, so an API problem never takes your site down:
// geoblock.mjs – Express middleware, Node.js 18 or later
const BLOCKED = new Set(["RU", "KP", "IR"]);
const DAY_MS = 24 * 60 * 60 * 1000;
const cache = new Map(); // ip -> { at, country, anonymous }
async function lookup(ip) {
const hit = cache.get(ip);
if (hit && Date.now() - hit.at < DAY_MS) return hit;
const params = new URLSearchParams({ ip, fields: "location.country.alpha2,security.is_anonymous" });
const res = await fetch(`https://api.ipbase.com/v2/info?${params}`, {
headers: { apikey: process.env.IPBASE_KEY },
signal: AbortSignal.timeout(3000),
});
if (!res.ok) throw new Error(`ipbase ${res.status}`);
const { data } = await res.json();
const entry = {
at: Date.now(),
country: data.location?.country?.alpha2 ?? null,
anonymous: data.security?.is_anonymous ?? null, // null without security data in the plan
};
cache.set(ip, entry);
return entry;
}
export async function geoBlock(req, res, next) {
try {
const { country, anonymous } = await lookup(req.ip.replace(/^::ffff:/, ""));
req.country = country;
if (BLOCKED.has(country)) {
return res.status(451).send("This service is not available in your country.");
}
if (anonymous === true && req.path.startsWith("/checkout")) {
return res.status(403).send("Please turn off your VPN or proxy to check out.");
}
} catch (err) {
console.error("geo lookup failed:", err.message); // fail open
}
next();
}
// app.mjs
import express from "express";
import { geoBlock } from "./geoblock.mjs";
const app = express();
app.set("trust proxy", 1); // one proxy in front: req.ip is the visitor
app.use(geoBlock);
The country check works with a free key (1,000 requests a month). The VPN check needs security data, which starts with the Medium plan; with a free key is_anonymous is null and the checkout rule never fires. We ran the middleware with Express 5 against a local stub of the API: a Russian address got 451, an Austrian one 200, a VPN address 403 on /checkout with a paid key and 200 with a free key, and an invalid key let the request through with the error logged. The IP geolocation tutorial has the same lookup in Python and PHP and explains how to find the real client IP behind proxies.
Test your rules
- Send the header yourself against a staging server that is not behind the CDN:
curl -H "CF-IPCountry: RU" https://staging.example.com/should return 451. - Check what the world sees. What is my IP shows your address and its country; switch a VPN to a blocked country and load the site.
- Watch your logs for 451s from crawlers and webhook senders in the first days, and check Google Search Console’s crawl stats after a change.