Country, subdivision, and ASN request matchers for Caddy, using MaxMind GeoLite2/GeoIP2
I wanted to block a list of countries in Caddy but the popular plugin for this
requires a double negative. Its matcher means "this request passes the filter,"
so to block a deny list you have to write not in front of it. It works, but
it always just irked me when looking at my Caddyfile.
This plugin follows the following rules:
- The matcher just matches. It tests whether the IP is in the country,
subdivision, or ASN list. That's it. You decide what happens to it...
geoip_country { country RU CN }plus Caddy'sabortis a deny list, andnoton the matcher makes it an allow list. - Defer to Caddy. Blocking, responding, redirecting, negating, combining conditions, exempting the LAN, logging... Caddy already does all of these. Don't duplicate something Caddy already does well.
- Fail early, or fail closed. A bad configuration is rejected when Caddy loads, a failed lookup stops the request rather than letting it through.
- Standard identifiers only. Countries are ISO 3166-1, subdivisions are ISO 3166-2, ASNs are IANA numbers.
Two limits to rule #3:
- Unlocated addresses: an address the database can't place is unknown. Use
match_unknowncautiously if that matters to you. - Unmatched codes: a code the database doesn't use matches nothing.
country UKwill load fine but won't work, because the database spells itGB(the standard ISO 3166-1 code).
Not related to aablinov/caddy-geoip.
xcaddy build --with github.com/davidscarth/caddy-geoipRequires Go 1.25.1 or later to build.
@name geoip_country {
db <path>
country <codes...>
match_unknown
}
@name geoip_subdivision {
db <path>
country <code>
subdivision <codes...>
match_unknown
}
@name geoip_asn {
db <path>
asn <numbers...>
match_unknown
}db is required on every matcher, along with at least one country,
subdivision, or asn. geoip_subdivision also requires country, which
scopes the codes. match_unknown is optional everywhere.
- db - path to a Country, City, or Enterprise database for
geoip_country, a City or Enterprise database forgeoip_subdivision, or an ASN, ISP, or Enterprise database forgeoip_asn. The database type is checked when the config loads, so a file that lacks the field a matcher reads is rejected rather than silently matching nothing. Placeholders such as{env.GEOIP_DB}are resolved. - country - ISO 3166-1 alpha-2 codes, case-insensitive. On
geoip_subdivisionit is a single code, because subdivision codes are only unique within a country. - subdivision - ISO 3166-2 codes without the country prefix (
CA, notUS-CA), case-insensitive. Together with country they represent the full code:country USwithsubdivision CAis howUS-CAis written. - asn - autonomous system numbers as plain integers, no
ASprefix. - match_unknown - use with caution. Matches IPs the database has no entry
for (loopback, private ranges, unallocated space), so
country USwithmatch_unknownmatches a US client and your LAN. Ongeoip_subdivisiona record with a country but no subdivision counts as unknown too. Off by default. Undernotit makes an allow list fail open, so preferclient_ip private_rangesto exempt the LAN.
Drop the connection for these countries:
@blocked geoip_country {
db "C:\Caddy\GeoLite2-Country.mmdb"
country CN RU PK KP IR ID IN VN BR NG BY CU SY VE SD
}
abort @blockedAn address the database has no record for is not in any listed country, so it
passes. That covers your LAN and unallocated space. Add match_unknown to deny
those too.
Only these countries may reach the site. not is the real
negation here. An unknown IP is never in the set, so under not your own LAN
would be blocked; exempt it with Caddy's client_ip private_ranges:
@outside {
not geoip_country {
db "C:\Caddy\GeoLite2-Country.mmdb"
country US CA GB FR DE IT ES AR AU JP
}
not client_ip private_ranges
}
abort @outside(match_unknown on the matcher is the blunter alternative: it lets every
unknown IP through, not just the LAN.)
Using Caddy's own handlers (pick one):
respond @blocked "Not available in {geoip.country}" 403
respond @blocked "Access from {geoip.country} is restricted due to legal/regulatory requirements." 451
redir @outside https://example.com/blockedThese assume access logging is on for the site (Caddy's log
directive). log_append adds fields to that line rather than producing one.
handle @blocked {
log_append geo_country {geoip.country}
log_append geo_blocked true
abort
}
handle {
log_append geo_country {geoip.country}
}The lines inside handle @blocked tag denied requests with their country, the
second block tags everything that passed (useful for testing to see what you
might want to add to your blocklist). Evaluating @blocked sets the
placeholder whether or not it matches, so the second block still has a country
to log.
If you need an audit trail, use respond 403 instead of abort in the blocked
route. An aborted request is logged without a normal response. Any tool that
reads the JSON access log, such as Loki, Vector, or Elastic, can chart
geo_country as a rate per country. Database updates sometimes move whole IP
blocks between countries, which logging and monitoring can expose.
The country is required and scopes the subdivision codes, which are only unique within a country:
@restricted geoip_subdivision {
db "C:\Caddy\GeoLite2-City.mmdb"
country US
subdivision UT LA MS
}
respond @restricted "Not available in your state" 451Regardless of country:
@hosting geoip_asn {
db "C:\Caddy\GeoLite2-ASN.mmdb"
asn 16509 14618 14061 24940 16276
}
abort @hostingMatchers inside one named matcher are ANDed, so "Russia, except this network" is a single condition rather than two rules that need ordering:
@blocked {
geoip_country { db "Country.mmdb" country RU }
not geoip_asn { db "ASN.mmdb" asn 64496 }
}
abort @blockedTwo separate abort lines are both denies and combine as OR in any order.
An exception must be written inside one matcher, as above, because nothing
can un-abort a request.
@eu geoip_country {
db /usr/share/GeoIP/GeoLite2-Country.mmdb
country AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE
}
reverse_proxy @eu eu-backend:8080
reverse_proxy backend:8080(geoblock) {
@blocked geoip_country {
db "C:\Caddy\GeoLite2-Country.mmdb"
country CN RU PK KP IR ID IN VN BR NG BY CU SY VE SD
}
abort @blocked
}
example.com {
import geoblock
reverse_proxy 127.0.0.1:3000
}{geoip.country}, {geoip.subdivision}, and {geoip.asn} hold the client's
ISO codes and AS number, or are empty if unknown. Each is set by the
corresponding matcher when it runs. geoip_subdivision sets both of the first
two, since it reads both from one record.
A matcher that finds nothing writes nothing. A matcher that finds a record writes the whole record. When two matchers write the same placeholder, the last one to run wins.
- The client IP is Caddy's
client_ip: the connection's remote address, unless that address is in the server'strusted_proxies, in which case Caddy takes the real client fromX-Forwarded-For. With no trusted proxies configured, a forgedX-Forwarded-Forheader has no effect. - If you set
trusted_proxies, also settrusted_proxies_strict(orclient_ip_headersnaming a single-value header your proxy sets, such asCF-Connecting-IP). Without it Caddy takes the left-mostX-Forwarded-Forentry, which a client can forge if your proxy appends to the header rather than replacing it. - The database is memory-mapped when the config loads. After
caddy reloadthe new config opens the file fresh, so a replaced file is picked up. The old mapping is released by the garbage collector once the old config has finished draining. Have whatever downloads your database updates runcaddy reloadafterward. - Never overwrite the database file in place while Caddy is running. The
server reads it through a shared memory mapping, so writing into it can
crash Caddy or corrupt lookups. Either stop Caddy first, or write the new
file under a temporary name and rename it over the old one, which is what
geoipupdatedoes by default. - Each matcher does its own lookup when it runs, decoding only the fields it needs from the record. There is no per-request caching.
- The matchers use MaxMind's located
country, notregistered_country. An IP whose location MaxMind cannot determine is unknown and treated as such. geoip_subdivisionmatches the most specific subdivision MaxMind reports, following theirmost_specific_subdivisionconvention. Where a country has nested subdivisions - an address in Boxford reportsENGthenWBK- only the innermost (WBK) matches.- Territories with their own ISO 3166-1 code, such as Puerto Rico and Guam, are reported under that code rather than as subdivisions of the parent country.
- Use the Country database for country matching (it's roughly an eighth the
size of City). One City database could serve both
geoip_countryandgeoip_subdivisionif you'd rather keep one file.geoip_asnalways needs its own database unless you have Enterprise.
{
"geoip_country": {
"db": "/usr/share/GeoIP/GeoLite2-Country.mmdb",
"countries": ["US", "DE", "FR", "IT", "AR", "JP"]
},
"geoip_subdivision": {
"db": "/usr/share/GeoIP/GeoLite2-City.mmdb",
"country": "US",
"subdivisions": ["CA", "NY"]
},
"geoip_asn": {
"db": "/usr/share/GeoIP/GeoLite2-ASN.mmdb",
"asns": [16509]
}
}City-level matching, database auto-download, and rich placeholders (city name, coordinates, time zone) are deliberately not here.
Database updates are either manually placed or
geoipupdate plus caddy reload.
Built and tested against Caddy v2.11.4 and maxminddb-golang v2.6.0, with no other dependencies.
The code passes:
go vet ./...andgo test ./...golangci-lint runusing Caddy's own.golangci.ymlgovulncheck ./...with no reachable vulnerabilities- CodeQL via GitHub code scanning
Tested against MaxMind's GeoLite2-Country, GeoIP2-Country, GeoLite2-City,
GeoIP2-City, GeoLite2-ASN, GeoIP2-ISP, and GeoIP2-Enterprise test
databases, and running in production with the free GeoLite2 editions.
DB-IP compatibility is not supported and depends on the vendor shipping
compliant databases. A smoke test was performed with DB-IP's Lite databases
(September 2026). The Country Lite and ASN Lite MMDB files appear to work with
geoip_country and geoip_asn as drop-in replacements for GeoLite2, no code
changes needed. However City Lite does not work for geoip_subdivision,
as the free version does not appear to include ISO 3166-2 codes.
Apache-2.0