High-Performance Geo-Blocking with Nginx, OpenResty, and API Caching
Build fast, correct country rules with GeoIP2, OpenResty Lua, and cache keys that prevent cross-country data leaks.
Direct answer: Use GeoIP2 at the NGINX edge to derive a normalized country code, apply a native map for stable allow or deny rules, and include every response-varying dimension—including country or policy segment—in your API cache key. Use OpenResty access_by_lua_block only when decisions need external state, signed policies, or exceptions. Bypass caching for personalized, authenticated, unsafe, or otherwise non-shareable responses.
This design keeps the common path in native NGINX directives, limits Lua execution, and prevents one country from receiving another country’s cached representation. IP geolocation is an estimate: VPNs, mobile carriers, proxies, and corporate egress can produce the wrong country, so measure denial rates and exceptions in your own traffic.
1. The request path
- Accept the client connection at the edge.
- GeoIP2 looks up the client IP in a country or city MMDB database and exposes variables such as an ISO country code. See the NGINX GeoIP2 access-control guide.
- A
mapconverts that value into a small policy segment such asallow,deny,eu, orna. - Stable rules return a clear status (usually
403or, where legally appropriate,451) or select a regional upstream. - For APIs, build the cache key from host, normalized URI, representation-changing query parameters, method where relevant, geo or policy segment, language, device, authorization state, and experiments.
- Requests that are personalized, authenticated, unsafe, or otherwise non-shareable bypass lookup and are never stored.
2. Install and load GeoIP2
Download and maintain a MaxMind country or city MMDB database according to its license. NGINX Plus documents the GeoIP2 dynamic-module package and loading process; package names and paths vary by distribution. The official sequence is to load the module, define the database path, test with nginx -t, and reload with nginx -s reload (GeoIP2 module documentation).
load_module modules/ngx_http_geoip2_module.so;
http {
geoip2 /usr/local/share/GeoIP/GeoLite2-Country.mmdb {
$geo_country country iso_code;
$geo_continent continent code;
}
# ... server blocks ...
}
Verify the database independently when diagnosing a lookup:
mmdblookup \
--file /usr/local/share/GeoIP/GeoLite2-Country.mmdb \
--ip 8.8.8.8 country iso_code
3. Fast country allow and deny rules with map
Keep policy values small and explicit. The default branch is your failure policy; choose it deliberately when a lookup is missing.
http {
geoip2 /usr/local/share/GeoIP/GeoLite2-Country.mmdb {
$geo_country country iso_code;
}
map $geo_country $country_policy {
default deny;
US allow;
CA allow;
GB allow;
DE allow;
FR allow;
}
server {
listen 443 ssl;
server_name api.example.test;
location / {
if ($country_policy = deny) { return 403; }
proxy_pass http://api_origin;
}
}
}
For a legal restriction that requires a distinct response, map to a status variable instead of embedding many if statements:
map $geo_country $geo_status {
default 451;
US 0;
CA 0;
}
server {
location / {
error_page 451 = @geo_blocked;
if ($geo_status) { return $geo_status; }
proxy_pass http://api_origin;
}
location @geo_blocked {
default_type application/json;
return 451 '{"error":"unavailable_in_your_region"}';
}
}
Use return for a simple decision. Avoid complex rewrite logic in if; a location split or an upstream map is easier to reason about.
4. Route to a regional upstream
Country routing can select a nearer regional server group and may reduce latency in principle, but there is no universal percentage improvement. Measure your own round-trip time, origin capacity, and failure rate.
upstream origin_na { server 10.0.10.10:8080; }
upstream origin_eu { server 10.0.20.10:8080; }
upstream origin_default { server 10.0.30.10:8080; }
map $geo_country $regional_upstream {
default origin_default;
US origin_na;
CA origin_na;
GB origin_eu;
DE origin_eu;
FR origin_eu;
}
server {
location / {
proxy_pass http://$regional_upstream;
}
}
Keep the fallback upstream healthy. A geolocation database outage or an unknown address should not silently route traffic to an overloaded region.
5. Use OpenResty Lua for dynamic policy
Use OpenResty when policy depends on external state, signed rules, account attributes, time windows, or multiple conditions that are awkward in map. The access_by_lua_block directive runs in the access phase. Lua modules loaded with require are cached, and production code caching should remain enabled; the OpenResty reference warns that disabling it has a significant performance cost (OpenResty directives reference).
lua_shared_dict geo_policy 10m;
server {
location / {
access_by_lua_block {
local country = ngx.var.geo_country or "ZZ"
local policy = ngx.shared.geo_policy
local decision = policy:get(country)
if decision == "deny" then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
if decision == nil then
-- Fail closed for protected endpoints. For public content,
-- choose an explicit fail-open policy instead.
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
}
proxy_pass http://api_origin;
}
}
Do not perform unbounded blocking network calls in the access phase. Refresh policy asynchronously, store the current snapshot in a worker-safe shared dictionary, set a bounded refresh interval, and define what happens when the snapshot is stale. Reload NGINX after editing Lua files when code caching is enabled.
6. Build a cache key that cannot cross countries
NGINX caches GET and HEAD responses by default when proxy caching is enabled. The NGINX content-caching guide documents proxy_cache_key, bypass, and no-cache controls.
Start with the smallest normalized key that fully describes the representation:
proxy_cache_path /var/cache/nginx/api
keys_zone=api_cache:100m
max_size=10g
inactive=10m
use_temp_path=off;
map $request_method $cacheable_method {
default 0;
GET 1;
HEAD 1;
}
map $http_authorization $has_authorization {
default 1;
"" 0;
}
map $http_cookie $has_session_cookie {
default 1;
"" 0;
}
map $geo_country $geo_segment {
default other;
US us;
CA ca;
GB gb;
DE de;
}
server {
location /v1/catalog {
proxy_cache api_cache;
proxy_cache_methods GET HEAD;
proxy_cache_key "$scheme|$host|$request_uri|country=$geo_segment|lang=$http_accept_language";
proxy_cache_bypass $has_authorization $has_session_cookie $cacheable_method;
proxy_no_cache $has_authorization $has_session_cookie $cacheable_method;
# Honor upstream Cache-Control by default. Override only with a
# documented reason and a bounded TTL.
proxy_cache_valid 200 1m;
proxy_cache_valid 404 10s;
proxy_pass http://api_origin;
}
}
The example uses $request_uri, which includes the query string. If only some parameters affect the representation, construct a normalized key from those parameters and reject or ignore unknown parameters consistently. Add language, device class, authorization state, tenant, experiment, or content encoding whenever it changes the body or important headers.
Cache-safety checklist
- Cache only methods whose semantics are safely shareable.
- Bypass and do not store requests with
Authorization, session cookies, or tenant-specific identity. - Include country or policy segment when content differs by geography.
- Include
Accept-Language, device, experiment, and format dimensions when they affect output. - Do not cache responses that contain private data, one-time tokens, or user-specific
Set-Cookieheaders. - Use cache locking to reduce stampedes when many requests miss together.
- Provide a purge or versioning strategy for policy changes and origin updates.
7. Complete combined configuration
load_module modules/ngx_http_geoip2_module.so;
http {
geoip2 /usr/local/share/GeoIP/GeoLite2-Country.mmdb {
$geo_country country iso_code;
}
lua_shared_dict geo_policy 10m;
map $geo_country $geo_segment {
default other;
US us;
CA ca;
GB gb;
DE de;
FR fr;
}
map $request_method $unsafe_method {
default 1;
GET 0;
HEAD 0;
}
map $http_authorization $private_request {
default 1;
"" 0;
}
proxy_cache_path /var/cache/nginx/api keys_zone=api_cache:100m
max_size=10g inactive=10m use_temp_path=off;
upstream api_origin { server 127.0.0.1:8080; }
server {
listen 443 ssl;
server_name api.example.test;
location / {
access_by_lua_block {
local country = ngx.var.geo_country or "ZZ"
local decision = ngx.shared.geo_policy:get(country)
if decision == "deny" then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
}
proxy_cache api_cache;
proxy_cache_key "$scheme|$host|$request_uri|geo=$geo_segment";
proxy_cache_bypass $unsafe_method $private_request;
proxy_no_cache $unsafe_method $private_request;
proxy_cache_lock on;
proxy_cache_valid 200 60s;
proxy_pass http://api_origin;
}
}
}
Before applying a change, run nginx -t; then reload with nginx -s reload. Confirm the effective configuration and module availability in the same environment that serves traffic.
8. Edge cases you must decide explicitly
| Case | Risk | Recommended policy |
|---|---|---|
| VPN, proxy, mobile carrier | Country may be wrong | Offer an appeal or account-based exception; monitor false denials. |
| Missing or corrupt MMDB | Every request becomes unknown | Choose fail-open or fail-closed per endpoint and alert immediately. |
| IPv6 clients | Different lookup coverage | Keep IPv4 and IPv6 databases current and test both paths. |
| CDN in front of NGINX | Origin sees CDN egress IP | Restore the verified client IP before GeoIP lookup; never trust an arbitrary header. |
| Policy update | Old decisions remain cached | Version keys or purge affected segments; do not rely only on database refresh. |
| Country-varying errors | Error object leaks across regions | Include the segment in the key or bypass error caching. |
| HEAD versus GET | Headers differ from body response | Confirm the origin’s HEAD behavior and vary the key or method if needed. |
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
unknown directive geoip2 |
Module is missing or not loaded | Install the matching module, add load_module at main context, and run nginx -t. |
All users map to ZZ or empty |
Wrong MMDB path, variable declaration, or client IP | Check file permissions and path; test with mmdblookup; verify real client IP handling. |
| Allowed country receives 403 | Map has case or whitespace mismatch | GeoIP ISO codes are normally uppercase; inspect the variable in a temporary response header and normalize policy entries. |
| One country sees another country’s data | Country or another varying dimension is absent from the key | Add every representation-changing dimension and purge the old cache. |
| Private response is cached | Bypass and no-cache conditions differ or test request lacks auth headers | Use both proxy_cache_bypass and proxy_no_cache; inspect Authorization, cookies, and upstream cache headers. |
| Lua changes are ignored | Lua code cache is enabled | Reload NGINX after source changes; keep code caching enabled in production. |
| Latency spikes during policy checks | Blocking external lookup or expensive Lua work | Refresh policy asynchronously, cache it in shared memory, and bound lookup time. |
| Cache stampede on expiry | Many requests refill simultaneously | Enable proxy_cache_lock, choose a sensible TTL, and watch lock wait time. |
10. Performance, reliability, and cost
- Lookup cost: Native GeoIP2 variable access and
maprules keep common decisions simple. Lua adds execution and dependency overhead. - Cache cardinality: Adding country, language, device, or experiments improves isolation but creates more objects and can lower hit rate.
- Locks: Cache locking reduces origin bursts but can increase wait time during an outage or cold start.
- Freshness: MMDB updates, policy updates, and cache invalidation are separate lifecycles. Automate and monitor each one.
- Observability: Log country, policy decision, cache status, upstream status, and a policy or database version. Track denial rate, unknown-country rate, hit rate, bypass rate, origin errors, and purge latency.
- Failure mode: Decide whether each endpoint fails open or closed when geolocation, policy storage, or the origin is unavailable.
- Licensing: NGINX Plus provides documented GeoIP2 packaging and API or key-value capabilities; open-source NGINX and OpenResty are suitable for many static country-policy deployments. Include licensing in your total operating-cost decision.
11. Validation checklist
- Test known IPv4 and IPv6 addresses for every allow, deny, and fallback branch.
- Test direct origin access and traffic through every CDN or proxy hop.
- Request the same URI from two country segments and inspect cache status and response body.
- Repeat with authorization headers, session cookies, language, device, and experiment headers.
- Change a policy rule, reload, and verify both new requests and existing cached objects.
- Stop or delay the policy source and confirm the documented fail-open or fail-closed behavior.
- Run
nginx -tin CI before every reload.
12. Or skip the browser setup
If your goal is to capture country-specific pages for QA, documentation, or regression checks, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture; bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. An MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month without a card, and paid plans start at $5 for 3,000 shots.
See the ScreenshotNeo API documentation for all options, including custom headers, cookies, user agents, geolocation, timezone, waits, blocking rules, caching, signed links, async jobs, and bulk capture.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Create a free ScreenshotNeo account with 1,000 screenshots a month and no card.
FAQ
Should I use GeoIP2 or Lua?
Use GeoIP2 variables and map for stable rules. Add Lua when policy needs external state, signatures, exceptions, or multi-factor decisions.
Does adding country to a cache key always improve performance?
It improves isolation when responses differ, but increases cache cardinality. Measure hit rate and memory use after adding it.
Can geolocation identify a person’s true country?
No. It estimates from an IP address and can be wrong for VPNs, proxies, mobile networks, and corporate gateways.
When should an API response never be cached?
When it is personalized, authenticated, unsafe, one-time, tenant-specific, or otherwise not safely shareable.


