Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Intermediate ~30 min read

Intermediate

What you will learn

  1. geo — the IP→value lookup engine: CIDR keys, longest-match, and the loopback trick that makes it testable.

  2. geo vs map — same shape, different fuel (address vs any variable).

  3. geoip databases and the geo directive's big-data cousin.

  4. The classification patterns: country gates, partner tiers, and the allowlist composition.

  5. Feeding everything into logs — classification you can't observe doesn't exist.


1. geo — IP to value, longest match wins

The classification engine, verbatim:

nginx
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:

bash
$ 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 it

The mechanics worth memorizing:

  • Input is $remote_addr — the TCP peer. Behind a proxy chain, that's the middlebox: put Lesson 4.2's real_ip config first or your geography is a map of your CDN. ( real_ip rewrites $remote_addr; geo evaluates against whatever it holds at request time.)

  • Longest-prefix match — 127.0.0.1/32 beats 127.0.0.0/24 beats 0.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::/32 keys) with a subtlety: IPv4-mapped connections ( ::ffff:a.b.c.d on 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 in default.

  • The loopback trick: the whole 127/8 range is local on Linux, so --interface 127.0.0.2 manufactures "another country" on demand — the same trick that made Module 5's ip_hash honest. 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:

map $x $y

geo $y

Input

any variable (per evaluation)

$remote_addr at request start

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 ( map $country … composes)

n/a

And they compose — that's the pattern. Classify with geo, refine with map:

nginx
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:

  • geoip2 module (MaxMind GeoLite2/GeoIP2, .mmdb files) — 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 's geo with ranges and huge includes — the homegrown alternative: maintain a geo file per published block-list (cloud provider ranges, TOR exits, known-bad CIDRs) and include it. 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/):

nginx
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:

nginx
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 scales

This 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:

nginx
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:

nginx
log_format policy '… bucket=$limit_req_status conn=$limit_conn_status '
                  'geo=$country ua_blocked=$ua_blocked';
text
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=0

That 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

  1. 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.

  2. The longest-match proof. With both 127.0.0.1 US; and 127.0.0.0/24 EU; present, .1 is US and .7 is EU — now add 127.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.

  3. Compose a tier. Build Pattern C's $rate_limit_key map and use it in a limit_req_zone (next lesson gives you the syntax) — partner networks get dedicated buckets, the world gets per-IP.

  4. Real_ip sanity. Send X-Forwarded-For: 8.8.8.8 with your request — does $country change? (No — geo reads 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.

  • geo for addresses, map for everything else — and composition( map on geo's output, pass-through keys) is where policy gets elegant.

  • geoip2/database classification is the scalable cousin — with the real_ip dependency front and center.

  • Every classification belongs in a log field; policy you can't grep is policy you can't defend.

Was this page helpful?

Mark this learning resource complete to track your learning progress.