Routing by client country in HAProxy is a flat lookup: convert the client IP to a country code, then ACL on that code. The mechanism in production HAProxy is the map file — a precomputed table that turns an IP into a string. You build the map from MaxMind GeoLite2 (or a paid GeoIP provider) and refresh it on a schedule. This article covers the build, the ACL pattern, and the refresh strategy that keeps the data current without restarting HAProxy.
How to verify
For an existing GeoIP setup, the runtime API tells you whether the map is loaded and lets you test a lookup:
echo "show map" | sudo socat /run/haproxy/admin.sock -
echo "get map /etc/haproxy/maps/geoip-country.map 8.8.8.8" | sudo socat /run/haproxy/admin.sock -
ls -lh /etc/haproxy/maps/
sudo journalctl -u haproxy -n 50 --no-pager | grep -i map
get map looks up an IP in a loaded map and returns the stored value — country code in this case. If the map is loaded but lookup returns nothing, your map file may not cover the IP space you expected.
What’s happening
HAProxy’s map files are sorted text files of key value pairs that HAProxy loads into a tree at startup. The src,map(<file>) fetch returns the value for a given IP using longest-prefix match over CIDRs. The map file format for IPv4 country mapping:
1.0.0.0/24 AU
1.0.1.0/24 CN
1.0.2.0/23 CN
...
MaxMind ships GeoLite2-Country as CSV (GeoLite2-Country-Blocks-IPv4.csv and GeoLite2-Country-Locations-en.csv). You join these two files into a HAProxy map with a small script.
The alternative — the HAProxy geoip2 Lua module — does runtime lookups against the MaxMind binary database. More accurate (city-level, ISP), heavier (Lua VM per request). For country-level routing the map approach is faster and lower-overhead.
The map is loaded at HAProxy start. To refresh without restart, use the runtime API: set map for one entry, or commit map for an atomic full reload.
The procedure
-
Pull GeoLite2 from MaxMind. You need an account (free) and a license key. The download is a zipped CSV bundle:
sudo mkdir -p /etc/haproxy/maps /var/lib/haproxy-geoip cd /var/lib/haproxy-geoip curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country-CSV&license_key=YOUR_KEY&suffix=zip" -o gl2-country.zip unzip -j gl2-country.zip '*.csv' -d . -
Build the HAProxy map. A short awk script joins the two CSVs:
cat > /usr/local/bin/build-haproxy-geo.sh <<'EOF' #!/bin/bash set -euo pipefail cd /var/lib/haproxy-geoip declare -A iso while IFS=, read -r geoname_id locale_code cont_code cont_name iso_code _; do [ "$geoname_id" = "geoname_id" ] && continue iso[$geoname_id]="${iso_code//\"/}" done < GeoLite2-Country-Locations-en.csv { while IFS=, read -r net geoid reg_geoid _; do [ "$net" = "network" ] && continue id="${geoid//\"/}" [ -z "$id" ] && id="${reg_geoid//\"/}" [ -z "$id" ] && continue code="${iso[$id]:-XX}" echo "${net//\"/} $code" done < GeoLite2-Country-Blocks-IPv4.csv } > /etc/haproxy/maps/geoip-country.map.new mv /etc/haproxy/maps/geoip-country.map.new /etc/haproxy/maps/geoip-country.map EOF sudo chmod +x /usr/local/bin/build-haproxy-geo.shRun once to confirm the output:
sudo /usr/local/bin/build-haproxy-geo.sh head -5 /etc/haproxy/maps/geoip-country.map -
Reference the map in HAProxy. ACL on the lookup result:
frontend fe_http bind *:443 ssl crt /etc/haproxy/certs/example.com.pem http-request set-var(txn.geo_country) src,map(/etc/haproxy/maps/geoip-country.map,XX) http-request set-header X-Country %[var(txn.geo_country)] acl is_eu var(txn.geo_country) -i DE FR IT ES NL BE PL AT IE acl is_us var(txn.geo_country) -i US use_backend be_app_eu if is_eu use_backend be_app_us if is_us default_backend be_app_defaultThe
,XXarg is the default if the IP is not in the map (private ranges, unallocated space). -
Block by country. Same pattern, deny instead of route:
frontend fe_http bind *:443 ssl crt /etc/haproxy/certs/example.com.pem http-request set-var(txn.geo_country) src,map(/etc/haproxy/maps/geoip-country.map,XX) acl is_blocked_country var(txn.geo_country) -m str -f /etc/haproxy/blocked-countries.lst http-request deny status 451 if is_blocked_country default_backend be_app/etc/haproxy/blocked-countries.lstis a flat file of two-letter codes, one per line. -
Refresh the map on a schedule. Once a week is fine for country-level (cities and ISPs change more often):
cat > /etc/systemd/system/haproxy-geo-refresh.service <<'EOF' [Unit] Description=Refresh HAProxy GeoIP map [Service] Type=oneshot ExecStart=/usr/local/bin/build-haproxy-geo.sh ExecStartPost=/bin/sh -c 'echo "clear map /etc/haproxy/maps/geoip-country.map" | socat /run/haproxy/admin.sock - && socat -u FILE:/etc/haproxy/maps/geoip-country.map UNIX-CONNECT:/run/haproxy/admin.sock' EOFThe simpler reload approach:
systemctl reload haproxyafter the rebuild — HAProxy re-reads the map file on reload. For zero-disruption refresh, the runtime API path above is what you use. -
Verify after refresh. Spot-check with known IPs:
for ip in 8.8.8.8 1.1.1.1 213.180.193.1; do echo -n "$ip " echo "get map /etc/haproxy/maps/geoip-country.map $ip" | sudo socat /run/haproxy/admin.sock - done -
City and ISP via Lua module. If you need finer than country, install the GeoIP2 Lua module (HAProxy compiled with
USE_LUA=1plus themmdb-haproxy-geoipLua script) and call it fromhttp-request:global lua-load /etc/haproxy/lua/geoip2.lua frontend fe_http http-request lua.geoip2_city http-request set-header X-Geo-City %[var(txn.geo_city)]This adds a per-request Lua call; budget the CPU before turning it on broadly.
Common pitfalls
- GeoIP accuracy is 80-95% at country level; lower for mobile, VPN, datacenter ranges. Do not use country routing as a security control — use it as a routing optimization or compliance lever.
- A blocked country list that excludes datacenter IPs is unenforceable — most VPN egress IPs are datacenter. If the goal is to block a country, accept that a determined user with a VPN gets through.
- Privacy regulations (GDPR, Quebec Law 25) treat IP + derived country as personal data in some contexts. Log the country as
XXif you anonymize IPs in logs, and document the use. - The map file must be sorted by CIDR for fastest lookup. HAProxy sorts on load, but a malformed line (missing CIDR or invalid country code) silently skips the entry. Validate format after build.
- Refreshing the map via
socatset mapline by line is slow on a 300k-entry file. Use file replacement + reload for full refreshes; useset maponly for one-off corrections.
Stack Harbor wires GeoIP routing for clients with regional compliance requirements (data residency, country-specific pricing) and for blocking traffic from sanctioned jurisdictions. We refresh the map weekly via systemd timer, log the country in every access line, and treat IP-derived geo data with the same privacy controls as other PII. This is part of how we run Multi-region Environments where routing decisions reflect where users actually are.