README
¶
caddy-geo-ops
A Caddy plugin for IP geolocation. It loads MaxMind GeoIP2 /
GeoLite2, DB-IP, and IP2Location .mmdb databases and gives you:
- an HTTP handler that exposes every field of every loaded database as a request
placeholder (
{geo.<db>.<field>}), - an HTTP matcher for routing/allow/deny by geo data,
- automatic, scheduled database updates (MaxMind, DB-IP, and IP2Location) plus hot-reload when a database file changes on disk.
All three share one set of in-memory databases, owned by a Caddy app.
Contributing? See CONTRIBUTING.md for the workflow, and CLAUDE.md for architecture, design decisions, conventions, and the test layout.
Contents
- Install
- Quick start
- Getting databases
- Configuration
- Placeholders
- The handler
- The matcher
- Examples
- CEL expression examples
- A complete Caddyfile
- JSON configuration
- Logging
- Privacy / personal data
- Troubleshooting
- License
Install
Caddy plugins are compiled into the Caddy binary with
xcaddy:
# Latest tagged release:
xcaddy build --with github.com/ubiuser/caddy-geo-ops
# Or pin a specific version (recommended for reproducible builds):
xcaddy build --with github.com/ubiuser/caddy-geo-ops@v1.0.0
Versions are the project's git tags (e.g. v1.0.0), served by the Go module proxy — pin one
to keep builds reproducible and to control upgrades. xcaddy composes this plugin with any
others you need in the same build command (--with ...@version per plugin).
This produces a caddy binary that includes the geo_ops app, handler, and matcher.
Verify they're present:
./caddy list-modules | grep geo_ops
# geo_ops
# http.handlers.geo_ops
# http.matchers.geo_ops
Quick start
- Put a database in a directory, e.g.
/var/lib/geoip/geoip2-city.mmdb(see Getting databases). - Write a
Caddyfile:
{
order geo_ops first
geo_ops {
db_path /var/lib/geoip
}
}
:8080 {
geo_ops
respond "You appear to be in {geo.geoip2-city.country.iso_code} ({geo.geoip2-city.city.names.en})"
}
- Run it and test:
./caddy run --config Caddyfile
curl -H "X-Forwarded-For: 89.105.110.225" localhost:8080
# You appear to be in GB (Tower Hamlets)
order geo_ops firstis required. The handler must run before any directive or matcher that reads its placeholders. See The handler.
Getting databases
The plugin never invents databases — it loads the .mmdb files you place in db_path, and
(optionally) keeps the ones already there up to date. So the first step is always to obtain
the editions you want and drop them in the folder under the expected
filename.
MaxMind (GeoIP2 / GeoLite2)
GeoLite2 is free; GeoIP2 is the commercial, higher-accuracy version. Both use identical record formats and the same plugin filenames.
-
Create a free account at https://www.maxmind.com/en/geolite2/signup (or a paid GeoIP2 subscription).
-
In your account portal, generate a license key (Account → Manage License Keys) and note your Account ID.
-
Get the database files one of two ways:
a. Download manually (Account → Download Databases). Download the GeoIP2/GeoLite2 binary (.mmdb) archive, extract it, and copy the
.mmdbintodb_path. MaxMind's filenames (e.g.GeoLite2-City.mmdb) already match the plugin's taxonomy case-insensitively, so no rename is needed — just drop the file in.b. Let the plugin auto-update them — see Automatic updates. Note that auto-update only refreshes files that already exist, so you still seed the folder once with an initial copy (option a), then the plugin keeps it fresh.
DB-IP
DB-IP "Lite" databases are free and need no account.
-
Download the MMDB format of the editions you want from https://db-ip.com/db/lite.php (IP to City Lite, IP to Country Lite, IP to ASN Lite).
-
The download is gzipped and dated, e.g.
dbip-city-lite-2024-06.mmdb.gz. Decompress it and rename it to the un-dated filename the plugin expects:gunzip dbip-city-lite-2024-06.mmdb.gz mv dbip-city-lite-2024-06.mmdb /var/lib/geoip/dbip-city-lite.mmdbThe rename matters: one fixed name per edition means a new copy overwrites the old one instead of leaving stale dated files piling up. (Auto-update writes this canonical name for you; DB-IP needs no credentials.)
IP2Location
IP2Location LITE databases are free but require a personal download token (register at https://lite.ip2location.com/ip2location-lite, then find your token in the account download area).
-
Download the MMDB format of the editions you want — Country (DB1), City (DB11), or ASN — either manually from the LITE site, or automate it:
curl -L "https://www.ip2location.com/download?token=$TOKEN&file=DB11LITEMMDB" -o db11.zip -
The download is a zip archive containing a license file, a README, and the
.mmdbitself (e.g.IP2LOCATION-LITE-DB11.MMDB). Extract it and rename to the plugin's canonical filename:unzip -j db11.zip '*.MMDB' -d /var/lib/geoip mv /var/lib/geoip/IP2LOCATION-LITE-DB11.MMDB /var/lib/geoip/ip2location-city.mmdb(Auto-update writes this canonical name for you — see Automatic updates.)
IP2Location LITE uses the same field schema as MaxMind's GeoIP2/GeoLite2 for these three editions —
{geo.ip2location-city.*}resolves the same field paths as{geo.geoip2-city.*}(see Field reference by edition); only the underlying geolocation data differs between vendors.
IP2Proxy PX10
IP2Proxy PX10 uses the same IP2Location account and ip2location_token as the LITE editions
above — no separate credential. Two editions exist:
PX10MMDB— paid, full dataset (VPNs, hosting providers, public/residential proxies, Tor exit nodes).PX10LITEMMDB— free, public-proxies-only subset. Register at https://lite.ip2location.com/ip2location-lite for a token if you don't already have one.
curl -L "https://www.ip2location.com/download?token=$TOKEN&file=PX10MMDB" -o px10.zip
unzip -j px10.zip '*.MMDB' -d /var/lib/geoip
mv /var/lib/geoip/IP2PROXY-PX10*.MMDB /var/lib/geoip/ip2proxy-px10.mmdb
(Use file=PX10LITEMMDB and rename to ip2proxy-px10-lite.mmdb for the free edition instead.
Auto-update writes the canonical name for you either way.)
PX10's field schema is identical to MaxMind's GeoIP2 Anonymous IP —
{geo.ip2proxy-px10.is_anonymous}resolves the same field paths as{geo.geoip2-anonymous-ip.is_anonymous}(see Field reference by edition).
PX10 is a large download (~549MB uncompressed). The default update_timeout (30s) will not
be enough to complete it — set something like update_timeout 5m (tuned to your connection)
before enabling auto-update for PX10. See download_memory_threshold for how
large downloads are kept from spiking memory use. It also needs roughly 1.2GB of free space in
db_path during the update (the download's temp zip plus the atomic-write temp, alongside the
existing file).
Supported editions & required filenames
Place files under exactly these names (matching is case-insensitive). Anything else is ignored.
| Edition | Filename | Source | Auto-update |
|---|---|---|---|
| GeoIP2 City | geoip2-city.mmdb |
MaxMind (paid) | needs credentials |
| GeoIP2 Country | geoip2-country.mmdb |
MaxMind (paid) | needs credentials |
| GeoIP2 ISP | geoip2-isp.mmdb |
MaxMind (paid) | needs credentials |
| GeoIP2 Domain | geoip2-domain.mmdb |
MaxMind (paid) | needs credentials |
| GeoIP2 Connection Type | geoip2-connection-type.mmdb |
MaxMind (paid) | needs credentials |
| GeoIP2 Anonymous IP | geoip2-anonymous-ip.mmdb |
MaxMind (paid) | needs credentials |
| GeoIP2 Enterprise | geoip2-enterprise.mmdb |
MaxMind (paid) | needs credentials |
| GeoLite2 City | geolite2-city.mmdb |
MaxMind (free) | needs credentials |
| GeoLite2 Country | geolite2-country.mmdb |
MaxMind (free) | needs credentials |
| GeoLite2 ASN | geolite2-asn.mmdb |
MaxMind (free) | needs credentials |
| DB-IP City Lite | dbip-city-lite.mmdb |
DB-IP (free) | no credentials |
| DB-IP Country Lite | dbip-country-lite.mmdb |
DB-IP (free) | no credentials |
| DB-IP ASN Lite | dbip-asn-lite.mmdb |
DB-IP (free) | no credentials |
| IP2Location Country (DB1) | ip2location-country.mmdb |
IP2Location LITE (free) | needs token |
| IP2Location City (DB11) | ip2location-city.mmdb |
IP2Location LITE (free) | needs token |
| IP2Location ASN | ip2location-asn.mmdb |
IP2Location LITE (free) | needs token |
| IP2Proxy PX10 | ip2proxy-px10.mmdb |
IP2Proxy (paid) | needs token |
| IP2Proxy PX10 LITE | ip2proxy-px10-lite.mmdb |
IP2Proxy LITE (free) | needs token |
You can load several at once (e.g. a Country db plus an ASN db plus an Anonymous-IP db); each contributes its own placeholders.
Configuration
The geo_ops app (global options)
Configure the shared app in the Caddyfile global options block:
{
order geo_ops first
geo_ops {
db_path /var/lib/geoip # required: directory holding the *.mmdb files
auto_update # optional: enable scheduled downloads
account_id 123456 # MaxMind Account ID (only with auto_update)
license_key {env.MAXMIND_KEY} # MaxMind license key (only with auto_update)
ip2location_token {env.IP2LOCATION_TOKEN} # IP2Location download token (only with auto_update)
download_memory_threshold 104857600 # bytes; below this, zip downloads are read in memory (default 100 MiB)
update_frequency 24h # how often to check (default 24h)
update_timeout 1m # per-download timeout (default 30s)
}
}
| Option | Meaning | Default |
|---|---|---|
db_path |
Directory containing the .mmdb files. Required. |
— |
auto_update |
Enable periodic remote updates of databases already present. | off |
account_id |
MaxMind Account ID (integer). Required to update MaxMind editions. | — |
license_key |
MaxMind license key. Required to update MaxMind editions. | — |
ip2location_token |
IP2Location download token (also used for IP2Proxy PX10). Required to update IP2Location/IP2Proxy editions. | — |
download_memory_threshold |
Below this size (bytes), a downloaded zip is read fully into memory before extraction; at or above it, extraction streams to a temp file instead. | 104857600 (100 MiB) |
update_frequency |
Interval between update checks. | 24h |
update_timeout |
Timeout for a single download. | 30s |
Use
{env.VAR}to keep the license key and IP2Location token out of the Caddyfile.
Automatic updates
When auto_update is set, the plugin keeps the databases already present in db_path
fresh — it never downloads editions you haven't seeded.
- MaxMind editions are refreshed via MaxMind's update protocol and require
account_id+license_key. Without credentials, MaxMind files are left untouched. - DB-IP editions are refreshed from DB-IP's public monthly URLs and need no credentials.
- IP2Location LITE editions, and IP2Proxy PX10 (paid or LITE), are refreshed from
IP2Location's download endpoint and require
ip2location_token. Without a token, these files are left untouched. - On startup, a database whose file is already older than
update_frequencyis refreshed immediately; fresh files are left alone (so reloading Caddy doesn't re-hit the vendors). - Updates are written atomically and hot-reloaded with no downtime. Manually replacing
a file in
db_pathtriggers the same hot-reload.
If you don't set auto_update, you can instead manage files yourself (cron + MaxMind's
geoipupdate, a scripted DB-IP download, etc.) — the plugin hot-reloads on any change.
Client IP behind a proxy
The plugin uses the client IP that Caddy resolves, which honors Caddy's standard trusted-proxy configuration. If Caddy sits behind a load balancer or CDN, tell it which upstreams to trust and which header carries the real client IP:
{
servers {
trusted_proxies static private_ranges # or specific CIDRs
client_ip_headers X-Forwarded-For X-Real-IP
}
}
Without this, lookups use the direct connection's IP. Never trust forwarding headers
without configuring trusted_proxies — they're spoofable.
Placeholders
The handler exposes every field of every loaded database as:
{geo.<db>.<dotted field path>}
<db>is the database filename without.mmdb— e.g.geoip2-city,geolite2-country,dbip-asn-lite. This keeps multiple databases from colliding.- The field path mirrors the database record structure; nested objects use dots, and lists
(like
subdivisions) are indexed numerically (.0,.1). - A missing field resolves to an empty string, never an error — so referencing a field that a given database doesn't have is safe.
Examples (against geoip2-city.mmdb):
| Placeholder | Example value |
|---|---|
{geo.geoip2-city.country.iso_code} |
GB |
{geo.geoip2-city.country.names.en} |
United Kingdom |
{geo.geoip2-city.city.names.en} |
London |
{geo.geoip2-city.location.latitude} |
51.5142 |
{geo.geoip2-city.location.longitude} |
-0.0931 |
{geo.geoip2-city.location.time_zone} |
Europe/London |
{geo.geoip2-city.postal.code} |
OX1 |
{geo.geoip2-city.subdivisions.0.iso_code} |
ENG |
{geo.geoip2-city.continent.code} |
EU |
Field reference by edition
These are the common fields per edition. Prefix each with geo.<db>. (and names.<lang>
supports en, de, es, fr, ja, pt-BR, ru, zh-CN). Booleans render as the
strings "true" / "false".
City (geoip2-city, geolite2-city, dbip-city-lite, ip2location-city)
continent.code continent.names.en
country.iso_code country.names.en country.is_in_european_union
registered_country.iso_code
city.names.en
subdivisions.0.iso_code subdivisions.0.names.en
location.latitude location.longitude location.time_zone
location.accuracy_radius location.metro_code
postal.code
Country (geoip2-country, geolite2-country, dbip-country-lite, ip2location-country)
continent.code continent.names.en
country.iso_code country.names.en country.is_in_european_union
registered_country.iso_code represented_country.iso_code
ASN (geolite2-asn, dbip-asn-lite, ip2location-asn)
autonomous_system_number autonomous_system_organization
ISP (geoip2-isp)
isp organization autonomous_system_number autonomous_system_organization
mobile_country_code mobile_network_code
Anonymous IP (geoip2-anonymous-ip, ip2proxy-px10, ip2proxy-px10-lite) — all booleans
is_anonymous is_anonymous_vpn is_hosting_provider
is_public_proxy is_residential_proxy is_tor_exit_node
Connection Type (geoip2-connection-type)
connection_type # e.g. "Cable/DSL", "Cellular", "Corporate"
Domain (geoip2-domain)
domain # second-level domain, e.g. "comcast.net"
Enterprise (geoip2-enterprise) — City fields above, plus confidence scores and:
traits.user_type traits.connection_type traits.isp
traits.organization traits.is_anonymous_proxy traits.is_satellite_provider
country.confidence city.confidence location.accuracy_radius
Not sure what a database exposes? Point a route at
respond "{geo.geoip2-city.country.iso_code} / {geo.geoip2-city.city.names.en}"and experiment, or check the MaxMind / DB-IP schema docs.
The handler
The geo_ops directive is the handler. It takes no arguments; it looks up the client IP
and registers the {geo.*} placeholders for the rest of the request.
example.com {
geo_ops
# ... everything after here can use {geo.*} placeholders
}
It must run before anything that reads its placeholders — so always set
order geo_ops first in global options (or route it explicitly first). You only need the
handler if you want placeholders; the matcher looks up geo data on its
own and works without it.
The matcher
The geo_ops matcher matches a request against one or more field = allowed-values
conditions. The field is a placeholder key without the geo. prefix.
Semantics: AND across fields, OR within a field's values — every listed field must equal one of its listed values. A field with no data for the client IP doesn't match.
Block form (multiple conditions):
@northAmerica geo_ops {
geoip2-country.country.iso_code US CA MX
}
@londonEnglish geo_ops {
geoip2-city.city.names.en London
geoip2-city.country.iso_code GB
}
Inline form (single condition):
@us geo_ops geoip2-country.country.iso_code US
At least one condition is required.
Examples
All examples assume order geo_ops first and a geo_ops { db_path ... } global block, with
the relevant database present. Add the geo_ops handler in any site that uses {geo.*}
placeholders.
Add a country header to every response
example.com {
geo_ops
header +X-Country "{geo.geoip2-country.country.iso_code}"
reverse_proxy localhost:9000
}
Pass geo data to a backend
example.com {
geo_ops
reverse_proxy localhost:9000 {
header_up X-Geo-Country "{geo.geoip2-city.country.iso_code}"
header_up X-Geo-City "{geo.geoip2-city.city.names.en}"
header_up X-Geo-ASN "{geo.geolite2-asn.autonomous_system_number}"
}
}
Block a set of countries (return 403)
example.com {
@blocked geo_ops geoip2-country.country.iso_code RU KP IR
respond @blocked "Not available in your region" 403
reverse_proxy localhost:9000
}
Allow-list: only serve specific countries, deny the rest
example.com {
@allowed geo_ops geoip2-country.country.iso_code US CA GB
handle @allowed {
reverse_proxy localhost:9000
}
handle {
respond "Access restricted" 403
}
}
Route different countries to different backends
example.com {
@eu geo_ops geoip2-country.country.iso_code DE FR NL IE
@us geo_ops geoip2-country.country.iso_code US
handle @eu { reverse_proxy eu-backend.internal:9000 }
handle @us { reverse_proxy us-backend.internal:9000 }
handle { reverse_proxy global-backend.internal:9000 }
}
Redirect by country (e.g. localized site)
example.com {
@de geo_ops geoip2-country.country.iso_code DE AT CH
redir @de https://de.example.com{uri}
}
Block anonymizers (VPN / proxy / Tor) — needs geoip2-anonymous-ip.mmdb
example.com {
@anon geo_ops {
geoip2-anonymous-ip.is_anonymous true
}
respond @anon "Anonymous networks are not allowed" 403
reverse_proxy localhost:9000
}
Block only Tor exit nodes
example.com {
@tor geo_ops geoip2-anonymous-ip.is_tor_exit_node true
respond @tor "Tor is blocked" 403
reverse_proxy localhost:9000
}
Match a specific ASN (network operator) — needs an ASN database
example.com {
# Block a hosting/cloud ASN (example: AS14618 Amazon)
@aws geo_ops geolite2-asn.autonomous_system_number 14618
respond @aws "Datacenter traffic blocked" 403
reverse_proxy localhost:9000
}
Match a connection type — needs geoip2-connection-type.mmdb
example.com {
@cellular geo_ops geoip2-connection-type.connection_type Cellular
header @cellular +X-Lite-Mode "1" # e.g. serve a lighter page to mobile networks
reverse_proxy localhost:9000
}
A geo debug endpoint
example.com {
geo_ops
handle /whereami {
respond `country={geo.geoip2-city.country.iso_code}
city={geo.geoip2-city.city.names.en}
coords={geo.geoip2-city.location.latitude},{geo.geoip2-city.location.longitude}
asn={geo.geolite2-asn.autonomous_system_number} ({geo.geolite2-asn.autonomous_system_organization})`
}
reverse_proxy localhost:9000
}
Log geo data with each request — place geo fields into the access log via headers, or use a structured field through a header the logger captures:
example.com {
geo_ops
header +X-Country "{geo.geoip2-country.country.iso_code}"
log {
output file /var/log/caddy/access.log
}
reverse_proxy localhost:9000
}
CEL expression examples
Caddy's built-in expression
matcher evaluates a CEL expression and can read the
{geo.*} placeholders. Two things to remember:
- The
geo_opshandler must run first (it's what sets the placeholders), so keeporder geo_ops firstand includegeo_opsin the site. - Placeholders resolve to strings (booleans are
"true"/"false", numbers are their text form). Compare as strings, or convert with CEL'sdouble()/int().
Country in a set
example.com {
geo_ops
@us_or_ca expression `{geo.geoip2-country.country.iso_code} in ["US", "CA"]`
respond @us_or_ca "Hello, North America"
reverse_proxy localhost:9000
}
Block an entire continent
example.com {
geo_ops
@blockAsia expression `{geo.geoip2-country.continent.code} == "AS"`
respond @blockAsia "Unavailable" 403
reverse_proxy localhost:9000
}
EU vs. non-EU (GDPR banner, etc.)
example.com {
geo_ops
@eu expression `{geo.geoip2-country.country.is_in_european_union} == "true"`
header @eu +X-Show-Cookie-Banner "1"
reverse_proxy localhost:9000
}
Combine geo with the request path — protect /admin to one country
example.com {
geo_ops
@foreignAdmin expression `path('/admin/*') && {geo.geoip2-country.country.iso_code} != "CH"`
respond @foreignAdmin "Admin is region-locked" 403
reverse_proxy localhost:9000
}
Anonymous OR public proxy (boolean fields as strings)
example.com {
geo_ops
@suspicious expression <<CEL
{geo.geoip2-anonymous-ip.is_anonymous} == "true" ||
{geo.geoip2-anonymous-ip.is_public_proxy} == "true"
CEL
respond @suspicious "Blocked" 403
reverse_proxy localhost:9000
}
Block a list of ASNs
example.com {
geo_ops
@badAsn expression `{geo.geolite2-asn.autonomous_system_number} in ["14618", "16509", "15169"]`
respond @badAsn "Datacenter traffic blocked" 403
reverse_proxy localhost:9000
}
Numeric comparison (latitude) — convert with double(); guard against an empty value
example.com {
geo_ops
@northern expression `{geo.geoip2-city.location.latitude} != "" && double({geo.geoip2-city.location.latitude}) > 60.0`
header @northern +X-Region "nordic"
reverse_proxy localhost:9000
}
When a country/city placeholder may be empty (IP not in the database), the equality and
informs simply don't match — which is usually the safe default for allow/deny rules.
A complete Caddyfile
{
order geo_ops first
servers {
trusted_proxies static private_ranges
client_ip_headers X-Forwarded-For
}
geo_ops {
db_path /var/lib/geoip
auto_update
account_id 123456
license_key {env.MAXMIND_LICENSE_KEY}
ip2location_token {env.IP2LOCATION_TOKEN}
download_memory_threshold {env.DOWNLOAD_MEMORY_THRESHOLD}
update_frequency 24h
}
}
example.com {
geo_ops
# Block anonymizers outright.
@anon geo_ops geoip2-anonymous-ip.is_anonymous true
respond @anon "Anonymous networks are not allowed" 403
# Region-lock the admin area.
@foreignAdmin expression `path('/admin/*') && {geo.geoip2-country.country.iso_code} != "US"`
respond @foreignAdmin "Region-locked" 403
# Tell the backend where the visitor is.
reverse_proxy localhost:9000 {
header_up X-Geo-Country "{geo.geoip2-city.country.iso_code}"
header_up X-Geo-City "{geo.geoip2-city.city.names.en}"
}
}
JSON configuration
The Caddyfile adapts to Caddy's native JSON. The app is configured under
apps.geo_ops; the handler and matcher use their module IDs
(http.handlers.geo_ops, http.matchers.geo_ops). To see the JSON for any Caddyfile:
./caddy adapt --config Caddyfile --pretty
Sketch of the app block:
{
"apps": {
"geo_ops": {
"db_path": "/var/lib/geoip",
"auto_update": true,
"account_id": 123456,
"license_key": "…",
"ip2location_token": "…",
"download_memory_threshold": 104857600,
"update_frequency": "24h"
}
}
}
Logging
geo_ops logs through Caddy's structured (zap) logger, so its entries appear in Caddy's
normal log output and honour your log directive's level and format.
Levels — what to expect, and what's worth alerting on:
- error — an operation failed with no automatic recovery; likely needs your attention.
- warn — degraded but still serving. A background database update or hot-reload failed
(the previously loaded database keeps serving and the next cycle retries);
auto_updateis enabled with MaxMind databases present but no credentials configured (so they will never update); or a per-request lookup against one database errored (the request still succeeds using the others). These are the events worth surfacing without paging. - info — normal state changes: a database was loaded, updated, or removed; the periodic updater started.
- debug — per-request and per-decision detail: no client IP resolved, a matcher condition not satisfied (with the field, its allowed values, and the looked-up value), a database skipped because it is still fresh, a file change detected and routed. Turn this on to answer "why didn't this match / update / reload?".
Standard fields — entries carry a small, stable set of structured keys you can filter and alert on:
| Key | Meaning |
|---|---|
database |
database edition / filename |
file |
a filesystem path (database or temp file) |
md5 |
the loaded database's MD5 sum |
ip |
the client IP being looked up |
field, allowed, got, found |
matcher condition detail: which field, its allowed values, and the value found |
frequency |
the updater's refresh interval |
maxmind_enabled |
whether MaxMind credentials are configured |
action |
resolved file-change action (update or delete) |
To see debug detail, raise the log level (globally or per-logger) in your Caddyfile:
{
log {
level DEBUG
}
}
Privacy / personal data
geo_ops processes client IP addresses and the geolocation derived from them — both
are personal data under regulations such as the GDPR and CCPA. What the module does with it:
- It does not persist or cache it. No per-request IP or lookup is written to disk or held in memory beyond the request; the only files written are the geo databases themselves.
- It does not transmit it. The auto-updater only downloads databases from MaxMind / DB-IP / IP2Location; client IPs and lookups never leave the process.
- Logs. The client
ipcan appear inwarn-level logs on a lookup error, and a geo value (got) indebuglogs on a matcher non-match. Both are emitted as structured fields, so you can drop or redact theip(and geo) keys in your log pipeline if required — see Logging. - Placeholders.
{geo.*}placeholders carry personal data wherever you route them — response headers, upstream headers, and especially access logs. Treat any sink you send them to as holding personal data.
You remain the data controller for how IPs and geolocation are used, logged, and retained in your deployment.
Troubleshooting
Placeholders come out empty.
- Make sure
order geo_ops firstis set and thegeo_opshandler is in the site — placeholders only exist after the handler runs. - Check the field path and
<db>segment match a loaded database (see the field reference). A wrong/absent field is empty by design. - Confirm the client IP is actually in the database (private/localhost IPs usually aren't —
test with a public IP via
X-Forwarded-Forandtrusted_proxiesconfigured).
A database isn't being loaded.
- The filename must match the taxonomy exactly (case-insensitive). DB-IP files in particular must be renamed to drop the date suffix; IP2Location files must be extracted from their download zip and renamed to the canonical name.
- Check Caddy's logs at startup for
database loadedentries.
Wrong IP is being geolocated (always the proxy's IP).
- Configure
servers { trusted_proxies … ; client_ip_headers … }so Caddy resolves the real client IP from the forwarding header.
Auto-update isn't fetching MaxMind.
- MaxMind needs both
account_idandlicense_key; with only one set, the configuration is rejected at startup. DB-IP needs no credentials. IP2Location needs a singleip2location_token. - Auto-update only refreshes databases already present — seed the folder first.
Matcher never matches.
- The condition field is the placeholder key without
geo.(e.g.geoip2-country.country.iso_code, notgeo.geoip2-country.country.iso_code). - Values are matched by exact string equality; check casing (ISO codes are uppercase).
License
MIT © 2026 Gabor Szabad
Documentation
¶
Overview ¶
Package geoops registers the geo_ops Caddy modules: the shared app, the HTTP handler, and the HTTP matcher. Importing this package (e.g. via `xcaddy build --with github.com/ubiuser/caddy-geo-ops`) is enough to make all three available in Caddy.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package app implements the shared geo_ops Caddy app.
|
Package app implements the shared geo_ops Caddy app. |
|
Package handler implements the http.handlers.geo_ops middleware.
|
Package handler implements the http.handlers.geo_ops middleware. |
|
internal
|
|
|
clientip
Package clientip extracts the client IP from a request using Caddy's pre-computed value, which already honours the operator's global trusted_proxies and client_ip_headers configuration (X-Forwarded-For, X-Real-IP, etc.).
|
Package clientip extracts the client IP from a request using Caddy's pre-computed value, which already honours the operator's global trusted_proxies and client_ip_headers configuration (X-Forwarded-For, X-Real-IP, etc.). |
|
db
Package db defines the canonical filename taxonomy for the mmdb databases this module recognises and maps those filenames to their MaxMind/DB-IP type.
|
Package db defines the canonical filename taxonomy for the mmdb databases this module recognises and maps those filenames to their MaxMind/DB-IP type. |
|
dirmonitor
Package dirmonitor watches the database directory with fsnotify and, after a short debounce, invokes update/delete callbacks for changed *.mmdb files.
|
Package dirmonitor watches the database directory with fsnotify and, after a short debounce, invokes update/delete callbacks for changed *.mmdb files. |
|
logfields
Package logfields centralises the structured-log field keys used across the geo_ops module.
|
Package logfields centralises the structured-log field keys used across the geo_ops module. |
|
ops
Package ops is the shared core owned by the geo_ops Caddy app: a registry of database providers that the handler and matcher consume.
|
Package ops is the shared core owned by the geo_ops Caddy app: a registry of database providers that the handler and matcher consume. |
|
replacers
Package replacers turns a decoded mmdb record (arbitrary nested maps, slices and scalars) into a flat set of dotted-path placeholder keys.
|
Package replacers turns a decoded mmdb record (arbitrary nested maps, slices and scalars) into a flat set of dotted-path placeholder keys. |
|
update
Package update periodically refreshes database files that already exist in the db folder.
|
Package update periodically refreshes database files that already exist in the db folder. |
|
Package matcher implements the http.matchers.geo_ops request matcher.
|
Package matcher implements the http.matchers.geo_ops request matcher. |