A lightweight Caddy v2 HTTP handler module for geoblocking or geowhitelisting requests based on client IP country codes, using the free GeoJS API.
Supports both allowlisting (default allow, block others) and blocklisting (default allow, block specific).
Includes per-instance caching, concurrency-safe stats, and an optional debug endpoint for monitoring.
- GeoIP Lookup: Fetches 2-letter country code (e.g.,
DE) from GeoJS. - Caching: In-memory LRU-like cache (default 50k entries, 15m TTL) to minimize API calls.
- Concurrency: Optional
singleflightdeduplication for simultaneous lookups. - Stats: Atomic counters for allowed/blocked requests with JSON export.
- Log Vars: Sets
geojs_countryandgeojs_decisionfor access logs. - Debug Endpoint: Optional
/debug/geojsendpoint for stats snapshot/reset (token-protected).
Best way is to use xcaddy:
xcaddy build --with github.com/itstomsh/caddy-geojs-blockeror:
# Clone Caddy
git clone https://github.com/caddyserver/caddy.git && cd caddy
# Add the module import
# In cmd/caddy/main.go:
import _ "github.com/itstomsh/caddy-geojs-blocker"
go mod tidy
# Build Caddy
go build \
-tags 'nobadger,nomysql,nopgx' \
-trimpath \
-ldflags '-w -s' \
-o ./caddy ./cmd/caddyThen run:
./caddy run --config CaddyfilePlace the directive in a route block.
- Use
geojs_allowfor allowlisting (only specific countries allowed). - Use
geojs_blockfor blocklisting (specific countries blocked).
Inline arguments are ISO2 country codes; options are set within the block.
:80 {
route {
geojs_allow DE US RU CN {
cache_ttl 30m
debug_path /debug/geojs
}
respond "Hello from {http.vars.geojs_country}!"
}
}→ Allows Germany (DE), United States (US), Russia (RU), and China (CN); blocks all others with 403.
:80 {
route {
geojs_block DE US RU CN
respond "Access granted from {http.vars.geojs_country}!"
}
}→ Blocks Germany (DE), United States (US), Russia (RU), and China (CN); allows all others.
:80 {
log {
format json
}
route {
geojs_allow DE US RU CN {
cache_ttl 10m
cache_size 10000
singleflight off
allow_undetected off
prune_interval 2m
debug_path /debug/geojs
debug_token mysecret
stats_file /var/lib/caddy/geojs_stats.json
stats_flush_interval 1m
}
respond "Welcome from {http.vars.geojs_country} ({http.vars.geojs_decision})"
}
}geojs_allow and geojs_block are HTTP handlers. Use them inside a route block, or configure global ordering if you prefer global (site-wide) geoblocking.
Preferred:
:80 {
route {
geojs_allow DE US RU CN
respond "Hello from {http.vars.geojs_country}!"
}
}Alternative with global order:
{
order geojs_allow before respond
order geojs_block before respond
}
:80 {
geojs_allow DE US RU CN
respond "Hello from {http.vars.geojs_country}!"
}This module only makes decisions by country. For plain IP/CIDR allow- or block-listing — e.g. "only my VPS may reach this site, regardless of country" — use Caddy's built-in remote_ip matcher instead of extending this module. It's evaluated before any GeoJS lookup happens, so no API call or cache entry is wasted on IPs you already have a fixed decision for.
Only allow one specific IP (e.g. your VPS), block everyone else:
:80 {
@vps remote_ip 203.0.113.10
route @vps {
respond "Hello from the VPS!"
}
respond "Forbidden" 403
}Combine it with geojs_allow/geojs_block, reaching the same handler either way — e.g. allow requests from Germany or from one trusted IP (your VPS), and serve identical content to both:
:80 {
@trusted remote_ip 203.0.113.10 198.51.100.0/24
@untrusted not remote_ip 203.0.113.10 198.51.100.0/24
route @trusted {
# trusted IP - skip the geo check entirely, fall through below
}
route @untrusted {
geojs_allow DE
}
respond "Welcome!"
}route @trusted { } has an empty body, so matching requests just fall through to respond untouched. Non-matching requests hit route @untrusted, which runs geojs_allow DE — if it blocks, the request stops there with a 403 and never reaches respond; if it allows, it falls through to the same respond the trusted IP gets. Requests handled by the @trusted branch never reach geojs_allow, so they won't appear in this module's stats or set geojs_country/geojs_decision — those log vars are only set for requests that actually went through the GeoJS check.
| Option | Type | Description | Example | Default |
|---|---|---|---|---|
| Country Codes (inline) | []string |
ISO2 codes for allow/block list | geojs_allow DE US RU CN |
— |
| cache_ttl | Duration | Cache TTL for IP lookups | cache_ttl 30m |
15m |
| cache_size | int | Max cache entries | cache_size 10000 |
50000 |
| singleflight | string | Deduplicate concurrent lookups (on/off) |
singleflight off |
on |
| allow_undetected | string | Allow (on) or block (off) undetected IPs (lookup failures, etc.) |
allow_undetected off |
on |
| prune_interval | Duration | Prune expired cache entries | prune_interval 1h |
5m |
| debug_path | string | Path for stats JSON endpoint | debug_path /debug/geojs |
(disabled) |
| debug_token | string | Token for debug auth (header X-Debug-Token) |
debug_token mysecret |
(none) |
| stats_file | string | File path to persist stats counters across restarts/reloads | stats_file /var/lib/caddy/geojs_stats.json |
(disabled) |
| stats_flush_interval | Duration | How often stats_file is written to disk |
stats_flush_interval 1m |
same as prune_interval |
Different environments benefit from different cache and pruning settings.
Here are suggested values you can tune to balance performance and accuracy.
| Scenario | Description | cache_ttl | cache_size | prune_interval | singleflight | Notes |
|---|---|---|---|---|---|---|
| 🏠 Home Server / Personal Use | Few visitors, low load | 30m |
1000 |
10m |
on |
Keeps memory use minimal and still avoids repeat lookups. |
| 🚀 Low-Traffic Site | Small business, blog, small API | 1h |
10000 |
10m |
on |
Reduces API requests while maintaining fresh lookups. |
| 🌍 High-Traffic / Production | Many visitors or global access | 6h |
50000–100000 |
15m |
on |
Minimizes API hits, faster response, more RAM usage. |
| 🧪 Debug / Testing | Development, frequent restarts | 5m |
1000 |
1m |
off |
More frequent lookups help see live GeoJS behavior. |
💡 Tip:
For very high traffic, keep singleflight on — it prevents multiple concurrent lookups for the same IP and saves both time and API requests.
If debug_path is set (e.g., /debug/geojs):
GET → Returns JSON stats:
{
"total_allowed": 4,
"total_blocked": 0,
"allowed_by_cc": { "DE": 2, "US": 1, "RU": 1, "CN": 0 },
"blocked_by_cc": {},
"cache_hits": 12,
"api_calls": 4,
"api_errors": 0
}total_allowed/total_blocked(and their per-country breakdowns) only count fresh GeoJS lookups — requests served from the IP cache don't add to these totals.cache_hitscounts requests resolved straight from the in-memory cache, with no GeoJS call.api_callscounts actual physical calls made to the GeoJS API. Withsingleflight on(the default), concurrent requests for the same uncached IP are collapsed into a single call, so this reflects real upstream traffic, not request volume.api_errorsis the subset ofapi_callsthat failed (network error, non-200 status, or an unparseable response) — useful for spotting GeoJS outages or rate-limiting.
POST ?reset=1 → Resets counters.
Example:
curl http://localhost:80/debug/geojs
curl -X POST http://localhost:80/debug/geojs?reset=1Requires header X-Debug-Token: {token} if debug_token is set.
Example with token:
curl -H "X-Debug-Token: mysecret" http://localhost:80/debug/geojs
curl -X POST -H "X-Debug-Token: mysecret" http://localhost:80/debug/geojs?reset=1By default, stats live only in memory and reset to zero on every Caddy restart and on config reloads (since a reload re-provisions the module).
Set stats_file to a writable path to persist counters across both:
geojs_allow DE US RU CN {
debug_path /debug/geojs
stats_file /var/lib/caddy/geojs_stats.json
stats_flush_interval 1m
}How it works:
- On startup/reload, the module loads the last saved snapshot from
stats_file(if present) instead of starting from zero. - The snapshot is written back to disk on a
stats_flush_intervaltick (defaults to the same value asprune_intervalif not set), after aPOST ?reset=1, and once more on shutdown/reload (Cleanup). - Writes are atomic (temp file + rename) and serialized internally, so the periodic flush, a debug reset, and shutdown can never race on the same file even if they land at the same instant.
- Parent directories for
stats_fileare created automatically if they don't exist. - This is a lightweight, single-file mechanism — not a durable store. A hard
SIGKILLbetween two flush ticks can still lose the most recent counts.
- API Usage: GeoJS currently has no fixed rate limits, but may restrict users who make excessive requests. The in-memory cache and optional singleflight mode help reduce unnecessary lookups.
- IPv6 Support: Fully supported.
- Proxy Headers: Prefers
X-Forwarded-For, thenX-Real-IP, thenRemoteAddr. - Cache Eviction: Uses simple random eviction when full (not full LRU).
- Validation: You cannot mix both blocklist and allowlist in one directive.
# Test with known IP
curl -H "X-Forwarded-For: 91.64.46.1" http://localhost:80
# → DE IP → allowed
# View stats
curl http://localhost:80/debug/geojs
# View stats with token
curl -H "X-Debug-Token: mysecret" http://localhost:80/debug/geojs
# Reset counters (token protected)
curl -X POST -H "X-Debug-Token: mysecret" http://localhost:80/debug/geojs?reset=1If you want GeoJS Blocker’s decision data (geojs_country, geojs_decision) to appear in your main access logs, you can append them manually to log entries using the following Caddyfile block:
log {
format json
}
handle_errors {
log_append geojs_country {http.vars.geojs_country}
log_append geojs_decision {http.vars.geojs_decision}
respond "{http.error.status_code} {http.error.status_text}"
}
route {
geojs_allow DE US RU CN
log_append geojs_country {http.vars.geojs_country}
log_append geojs_decision {http.vars.geojs_decision}
}This ensures both normal requests and blocked responses include the GeoJS decision context in your JSON access log output.
GeoJS returns ISO 3166-1 alpha-2 country codes.
You can use any of these two-letter codes (case-insensitive) in geojs_allow or geojs_block directives.
The module automatically converts them to uppercase and ignores invalid entries.
Licensed under the MIT License.
Contributions welcome!
Built with ❤️ for Caddy.