7.1 geo and map: Building Routing Tables
Policy begins with classification: "who is this client?" turned into a variable you can route, gate, and log on.
Intermediate
What you will learn
geo— the IP→value lookup engine: CIDR keys, longest-match, and the loopback trick that makes it testable.geovsmap— same shape, different fuel (address vs any variable).geoipdatabases and thegeodirective's big-data cousin.The classification patterns: country gates, partner tiers, and the allowlist composition.
Feeding everything into logs — classification you can't observe doesn't exist.
1.
geo — IP to value, longest match wins
The classification engine, verbatim:
geo $country {
default XX; # unknown/unlisted addresses
127.0.0.1 US; # exact match
127.0.0.2 CN;
127.0.0.3 RU;
127.0.0.0/24 EU; # CIDR catch-all for the rest of loopback
}And its live output, one line per source address:
$ curl -s -H 'Host: edge.example.test' http://127.0.0.1:8700/whereami
country=US
$ curl -s --interface 127.0.0.2 -H 'Host: edge.example.test' http://127.0.0.1:8700/whereami
country=CN
$ curl -s --interface 127.0.0.7 -H 'Host: edge.example.test' http://127.0.0.1:8700/whereami
country=EU # no exact match → the /24 CIDR caught itThe mechanics worth memorizing:
Input is
$remote_addr— the TCP peer. Behind a proxy chain, that's the middlebox: put Lesson 4.2'sreal_ipconfig first or your geography is a map of your CDN. (real_iprewrites$remote_addr;geoevaluates against whatever it holds at request time.)Longest-prefix match —
127.0.0.1/32beats127.0.0.0/24beats0.0.0.0/0. Write specific rules first, catch-alls last, and the lookup respects intent regardless of file order.IPv6 works natively (
2001:db8::/32keys) with a subtlety: IPv4-mapped connections (::ffff:a.b.c.don dual-stack listeners) are converted to IPv4 automatically, so they match plain IPv4 keys — only genuinely IPv6-native clients need IPv6 entries, and without them they land indefault.The loopback trick: the whole 127/8 range is local on Linux, so
--interface 127.0.0.2manufactures "another country" on demand — the same trick that made Module 5'sip_hashhonest. Every geo demonstration in this module rides on it.
2.
geo vs
map — the same idea twice, on different fuel
You already know
map (Lesson 2.4): any variable → any value, with literal/regex/
hostnames matching.
geo is
map specialized for one input — the connection's remote address — with CIDR matching the generic engine lacks:
|
| |
|---|---|---|
Input | any variable (per evaluation) |
|
Keys | literals, regex, hostnames | IPs and CIDRs |
Lookup | hash / regex walk | radix trie |
Typical use | headers, args, URIs → policy | address → policy |
Can use others' output? | yes (
| n/a |
And they
compose — that's the pattern. Classify with
geo, refine with
map:
geo $country { … }
map "$country:$ssl_client_verify" $access_tier {
default guest;
"US:SUCCESS" domestic-partner;
"~^(CN|RU):NONE" restricted; # from those countries,
"~:SUCCESS" verified-partner; # certs redeem anyone
}3. The database cousin: GeoIP
Static CIDR lists answer questions you can enumerate. "Which country is 203.0.113.7 in?" needs a database. The family tree:
geoip2module (MaxMind GeoLite2/GeoIP2,.mmdbfiles) —geoip2 /etc/nginx/GeoLite2-Country.mmdb { auto_reload 5m; $geoip2_country_code country iso_code; }— the modern form, compiled as a dynamic module in official packages (Module 15 loads it).ngx_http_geo_module'sgeowithrangesand huge includes — the homegrown alternative: maintain ageofile per published block-list (cloud provider ranges, TOR exits, known-bad CIDRs) andincludeit. Same trie, your data.
Operational honesty: MaxMind's free GeoLite2 is
country-shaped approximation — fine for routing and coarse compliance, not for fraud decisions. And all of it inherits the
real_ip caveat: behind proxies, classification is only as truthful as your header hygiene.
4. The classification patterns
Pattern A — the country gate (the example's
/us-only/):
location /us-only/ {
if ($country != US) { return 403; }
…
}Verified both ways (US admitted, CN refused). The softer variants:
return 301 /intl/ instead of 403 (route, don't reject), or feed a
map that picks the
pool (Module 5) per region — data-residency routing without a gateway rearchitecture.
Pattern B — address allowlists at scale:
geo $is_office {
default 0;
192.0.2.0/24 1; # HQ
198.51.100.7 1; # the VPN gateway
203.0.113.0/25 1; # branch office
}
# … then: if ($is_office = 0) { return 403; }
# or gate a location: satisfy any + allow/deny is overkill — geo scalesThis composes with Lesson 6.5:
allow/
deny for small explicit lists,
geo when the list is data,
map when the
input isn't an address at all.
Pattern C — tiered trust:
geo $partner_net {
default unknown;
10.1.0.0/16 partner-a;
10.2.0.0/16 partner-b;
}
map $partner_net $rate_limit_key {
partner-a "pa"; # dedicated rate buckets per partner
partner-b "pb";
unknown $binary_remote_addr; # everyone else: by IP
}
# limit_req_zone $rate_limit_key zone=partners:10m rate=10r/s; (Lesson 7.2)Note the trick in the last line:
map can
pass a variable through (
unknown → $binary_remote_addr) — one zone, heterogeneous keys, no if-statements anywhere.
5. Classification you can observe
The log format carries the verdicts on every line:
log_format policy '… bucket=$limit_req_status conn=$limit_conn_status '
'geo=$country ua_blocked=$ua_blocked';127.0.0.1 [22/Sep/2026:21:23:05 +0000] "GET /api/?user=alice HTTP/1.1" 429
bucket=REJECTED conn=- geo=US ua_blocked=0That single line has the full policy story: who (geo), what decided (bucket), and the verdict. Module 12 turns this into dashboards; today's lesson is just the discipline — every classification you compute should appear in a log field you can grep, or you can't defend the policy when it's questioned.
Try it yourself
Tour §1's three outputs, then invent your own geography: add
127.0.0.4/30 AS;(a CIDR covering .4–.7 — wait, /30 covers four addresses; check which of .4/.5/.6/.7 it claims and whether it beats the /24), reload, probe each address.The longest-match proof. With both
127.0.0.1 US;and127.0.0.0/24 EU;present,.1is US and.7is EU — now add127.0.0.0/8 XX;and confirm it changes nothing (it's the shortest match). Order in the file is irrelevant; prefix length is everything.Compose a tier. Build Pattern C's
$rate_limit_keymap and use it in alimit_req_zone(next lesson gives you the syntax) — partner networks get dedicated buckets, the world gets per-IP.Real_ip sanity. Send
X-Forwarded-For: 8.8.8.8with your request — does$countrychange? (No —georeads the peer. Now you know the difference, not just believe it.)
Q&A
Q1. Why does
geo beat
allow/
deny for a 50 000-CIDR blocklist?
Answer
Both are ordered by specificity, but
geo compiles to a radix trie with O(address-bits) lookup regardless of entry count, produces a
variable you can map/log/branch on (rather than just a terminate decision), and scales to includes of arbitrary size. allow/deny walks its rule list linearly per request.
Q2. All your IPv6 clients classify as
XX (default) despite correct entries. What's the classic cause?
Answer
IPv4-mapped addressing: connections arriving as
::ffff:a.b.c.d are converted to IPv4 before the lookup — plain IPv4 keys already match them. Only native IPv6 sources need IPv6 entries; without them they land in
default.
Q3. Behind your CDN, every visitor classifies as one country — the CDN's. Fix in two lines.
Answer
set_real_ip_from <CDN ranges>; real_ip_header X-Forwarded-For; — restore
$remote_addr to the client before
geo evaluates (the ordering: real_ip runs in its module's early phase, then geo reads the rewritten address).
Q4. Design: "partners get 100 r/s each, anonymous users get 10 r/s per IP" — one zone, no
if. Sketch it.
Answer
geo $partner_net classifies addresses;
map $partner_net $rl_key maps each partner to a fixed key ("pa", "pb") and
unknown to
$binary_remote_addr;
limit_req_zone $rl_key zone=mixed:10m rate=… — wait, rate is per-zone, not per-key! So the honest shape is
two zones (
limit_req accepts several directives) or one zone with the rate you can defend for both. The map still buys you separate
buckets; per-class
rates need per-class zones — a subtlety this footnote just taught you.
Takeaways
geo= IP/CIDR → variable via radix trie; longest prefix wins; the loopback range makes it fully testable without leaving your machine.geofor addresses,mapfor everything else — and composition(mapongeo's output, pass-through keys) is where policy gets elegant.geoip2/database classification is the scalable cousin — with thereal_ipdependency front and center.Every classification belongs in a log field; policy you can't grep is policy you can't defend.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.