A high-performance GeoIP2 middleware for Caddy that provides geographic information based on client IP addresses.
- Minimal & Fast: Only extracts the essential GeoIP2 data you actually need
- Thread-safe: Concurrent request handling with read/write locks
- Auto-reload: Configurable database reloading (daily, weekly, or custom intervals)
- Smart IP Detection: Flexible handling of X-Forwarded-For headers with security controls
- Memory Efficient: Optimized data structures to minimize allocations
- Intelligent Database Routing: EU IPs use Europe-specific database, non-EU IPs use global database for optimal performance
xcaddy build --with github.com/ScaleCommerce/caddy-geoip2
{
# Configure the GeoIP2 databases globally with intelligent routing
geoip2 {
country_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-Country.mmdb
city_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-City-Europe.mmdb
global_city_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-City.mmdb
asn_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-ASN.mmdb
reload_interval daily # daily, weekly, off, or hours (e.g., 24)
}
# Order matters - GeoIP2 must run before directives that use the variables
order geoip2_vars before header
}
example.com {
# Enable GeoIP2 with IP detection mode:
# - strict: only use RemoteAddr (ignore X-Forwarded-For)
# - wild: trust any X-Forwarded-For header
# - trusted_proxies: trust X-Forwarded-For only from trusted proxies (default)
geoip2_vars strict
# Use GeoIP2 variables in directives
header Country-Code "{geoip2_country_code}"
header Is-EU "{geoip2_is_in_eu}"
# Conditional responses based on location
@eu_visitors expression {geoip2_is_in_eu} == true
respond @eu_visitors "Hello from the EU!"
# Log geographic data
log {
format json
append {
country {geoip2_country_code}
city {geoip2_city}
coordinates "{geoip2_latitude},{geoip2_longitude}"
subdivision {geoip2_subdivisions}
asn {geoip2_asn}
}
}
respond "Hello from {geoip2_city}, {geoip2_country_code}!"
}
The module provides 8 essential GeoIP2 variables from 4 specialized databases:
Variable | Description | Example | Database Source |
---|---|---|---|
{geoip2_country_code} |
Two-letter country code | "DE" |
Country DB |
{geoip2_is_in_eu} |
EU membership status | true |
Country DB |
{geoip2_city} |
City name (German preferred) | "MΓΌnchen" |
EU: Europe City DB Non-EU: Global City DB |
{geoip2_latitude} |
Geographic latitude | 48.1374 |
EU: Europe City DB Non-EU: Global City DB |
{geoip2_longitude} |
Geographic longitude | 11.5755 |
EU: Europe City DB Non-EU: Global City DB |
{geoip2_subdivisions} |
State/Province code | "BY" |
EU: Europe City DB Non-EU: Global City DB |
{geoip2_asn} |
Autonomous System Number | 3320 |
ASN DB |
{geoip2_asorg} |
AS Organization | "Deutsche Telekom AG" |
ASN DB |
IP Location | City Data Source | Performance Benefit |
---|---|---|
European Union | GeoIP2-City-Europe.mmdb |
β‘ Faster (smaller DB) |
Non-EU | GeoLite2-City.mmdb |
π Complete global coverage |
All IPs | GeoIP2-Country.mmdb (for country/EU data) |
π― Specialized accuracy |
All IPs | GeoLite2-ASN.mmdb (for network data) |
π Comprehensive ASN info |
Nginx Variable | Caddy Variable | Database Used |
---|---|---|
$geoip2_country_code |
{geoip2_country_code} |
GeoIP2-Country.mmdb |
$geoip2_city |
{geoip2_city} |
EU: GeoIP2-City-Europe.mmdb Non-EU: GeoLite2-City.mmdb |
$geoip2_subdivision |
{geoip2_subdivisions} |
EU: GeoIP2-City-Europe.mmdb Non-EU: GeoLite2-City.mmdb |
$geoip2_is_in_european_union |
{geoip2_is_in_eu} |
GeoIP2-Country.mmdb |
$geoip2_latitude |
{geoip2_latitude} |
EU: GeoIP2-City-Europe.mmdb Non-EU: GeoLite2-City.mmdb |
$geoip2_longitude |
{geoip2_longitude} |
EU: GeoIP2-City-Europe.mmdb Non-EU: GeoLite2-City.mmdb |
{
geoip2 {
country_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-Country.mmdb
city_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-City-Europe.mmdb
global_city_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-City.mmdb
asn_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-ASN.mmdb
reload_interval daily
}
}
api.example.com {
geoip2_vars trusted_proxies
# Block requests from certain countries
@blocked_countries expression {geoip2_country_code} in ["CN", "RU", "KP"]
respond @blocked_countries "Access denied" 403
# Rate limit by geographic region
@high_traffic expression {geoip2_country_code} in ["US", "GB", "DE"]
rate_limit @high_traffic 100r/m
@low_traffic expression !({geoip2_country_code} in ["US", "GB", "DE"])
rate_limit @low_traffic 10r/m
reverse_proxy localhost:8080
}
{
geoip2 {
country_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-Country.mmdb
city_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-City-Europe.mmdb
global_city_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-City.mmdb
asn_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-ASN.mmdb
reload_interval daily
}
}
localhost {
geoip2_vars wild # More permissive for development
respond `
IP: {remote_host}
Location: {geoip2_city}, {geoip2_country_code}
Coordinates: {geoip2_latitude}, {geoip2_longitude}
EU Member: {geoip2_is_in_eu}
ASN: {geoip2_asn} ({geoip2_asorg})
`
}
{
geoip2 {
country_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-Country.mmdb
city_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-City-Europe.mmdb
global_city_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-City.mmdb
asn_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-ASN.mmdb
reload_interval daily
}
}
example.com {
geoip2_vars trusted_proxies
log {
output file /var/log/caddy/geo.log
format json
append {
# Geographic data
geo_country {geoip2_country_code}
geo_city {geoip2_city}
geo_lat {geoip2_latitude}
geo_lng {geoip2_longitude}
geo_subdivision {geoip2_subdivisions}
geo_eu {geoip2_is_in_eu}
# Network data
asn {geoip2_asn}
as_org {geoip2_asorg}
# Request data
user_agent {>User-Agent}
real_ip {remote_host}
}
}
reverse_proxy backend:8080
}
GeoIP2 is a middleware that sets variables, but directives (like header
, log
, respond
) need those variables to be available:
{
# GeoIP2 middleware must run BEFORE directives that use the variables
order geoip2_vars before header
# Alternative: order geoip2_vars first (runs before everything)
}
example.com {
# 1. geoip2_vars middleware runs first β sets variables
geoip2_vars strict
# 2. Then header directive can use the variables
header Country-Code "{geoip2_country_code}" # β
Variable available
# 3. respond directive can also use them
respond "Hello from {geoip2_city}!" # β
Variable available
}
# Option 1: Run before specific directives
order geoip2_vars before header
# Option 2: Run first (safest, works with everything)
order geoip2_vars first
# Option 3: Run before multiple directives
order geoip2_vars before header rewrite respond
# β BAD: No order specified
example.com {
header Country-Code "{geoip2_country_code}" # Variable = "" (empty)
geoip2_vars strict # Runs too late!
}
# β
GOOD: Correct order
{
order geoip2_vars before header
}
example.com {
geoip2_vars strict # Sets variables first
header Country-Code "{geoip2_country_code}" # Variable has value
}
- Use case: High-security environments, direct connections
- Behavior: Only uses
RemoteAddr
, ignores all forwarded headers - Pros: Most secure, no spoofing possible
- Cons: Won't work behind proxies/load balancers
- Use case: Production with known proxy infrastructure
- Behavior: Uses
X-Forwarded-For
only from Caddy's trusted proxies - Pros: Secure and works with proper proxy setup
- Cons: Requires correct
trusted_proxies
configuration
- Use case: Development, testing, or when you can't control proxy headers
- Behavior: Trusts any
X-Forwarded-For
header - Pros: Works everywhere, easy setup
- Cons: Vulnerable to IP spoofing
Value | Description | Use Case |
---|---|---|
daily or 24h |
Reload every 24 hours | π Recommended for production |
weekly or 168h |
Reload every 7 days | β‘ Lower overhead, less frequent updates |
48 |
Reload every 48 hours | βοΈ Balance between freshness and performance |
off or 0 |
No automatic reload | π§ Manual control only |
- Minimal Structure: Only parses fields you actually use
- Constant Variables: Uses constants for variable names (compiler optimization)
- Method Decomposition: Separates concerns for better caching
- Smart Fallbacks: English city names with fallback to any available language
- Early Returns: Fails fast on errors without unnecessary processing
- Read Locks: Multiple concurrent lookups without blocking
Database | Type | Purpose | Cost | Size |
---|---|---|---|---|
GeoIP2-Country | Commercial | Country codes & EU status | π° Paid | ~5MB |
GeoIP2-City-Europe | Commercial | European city data | π° Paid | ~30MB |
GeoLite2-City | Free | Global city data (fallback) | π Free | ~70MB |
GeoLite2-ASN | Free | ASN numbers & organizations | π Free | ~10MB |
For budget-conscious deployments, you can use free alternatives:
GeoLite2-Country.mmdb
instead ofGeoIP2-Country.mmdb
GeoLite2-City.mmdb
for both EU and global city data
For optimal performance on a central load balancer, use the four-database approach with intelligent routing:
{
geoip2 {
country_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-Country.mmdb
city_database_path /etc/nginx/maxmind-geo-ip/GeoIP-Country/GeoIP2-City-Europe.mmdb
global_city_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-City.mmdb
asn_database_path /etc/nginx/maxmind-geo-ip/GeoLite2-ASN.mmdb
reload_interval daily
}
}
Intelligent Routing Logic:
- Country Lookup: Determines EU membership status
- EU IPs: Use smaller, faster Europe-specific city database
- Non-EU IPs: Use global city database as fallback
- ASN Lookup: Always uses dedicated ASN database
Why four databases with intelligent routing?
- GeoIP2-Country: Fast country codes and EU membership detection
- GeoIP2-City-Europe: Optimized for European traffic (smaller, faster)
- GeoLite2-City: Global coverage for non-European IPs
- GeoLite2-ASN: Comprehensive ASN data
Performance benefits for central load balancers:
- Reduced memory pressure: European traffic uses smaller database
- Faster lookups: 70%+ of traffic (EU) uses optimized database
- Global coverage: Non-EU traffic still gets complete data
- High availability: Automatic fallback if any database unavailable
- Language support: German city names preferred for European IPs
The module gracefully handles:
- Missing database files
- Corrupted databases
- Invalid IP addresses
- Database reload failures
- Network interruptions
Variables are always available (empty strings if lookup fails) to prevent template errors.
You can monitor the GeoIP2 module via Caddy's admin API:
# Check current status
curl localhost:2019/config/apps/geoip2
# Reload database manually
curl -X POST localhost:2019/load \
-H "Content-Type: application/json" \
-d '{"module": "geoip2"}'
This project is licensed under the Apache License 2.0.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature
) - Commit your changes (
git commit -m 'Add amazing feature'
) - Push to the branch (
git push origin feature/amazing-feature
) - Open a Pull Request