
Intro #
HAProxy Community is a widely used open-source load balancer and reverse proxy that can route incoming traffic based on request attributes and custom rules. With HAProxy and the IP2Location.io API, you can extend these routing and access-control decisions with IP geolocation and proxy-detection information.
In this tutorial, we will configure HAProxy Community on Debian 13 to query the IP2Location.io API using Lua. The API response will be parsed with the lua-cjson library so HAProxy can make routing and filtering decisions based on information such as the visitor’s country, VPN status, and residential proxy status.
We will build a simple demonstration that:
- Routes visitors from Malaysia to a dedicated Malaysia backend.
- Routes visitors from other countries to a global backend.
- Blocks requests identified as originating from a VPN.
- Blocks requests identified as originating from a residential proxy.
- Adds IP2Location.io lookup results to HTTP headers so the detected information can be easily inspected.
- Uses simple backend servers that display the received headers and routing result.
The setup also includes a localhost testing mechanism that allows different IP addresses to be simulated without requiring connections from multiple countries or networks.
By the end of the tutorial, you will have a working HAProxy configuration that demonstrates how IP2Location.io geolocation and proxy intelligence can be incorporated directly into HAProxy Community routing and access-control rules.
The request flow will be:

HAProxy has had its native Lua HTTP client since HAProxy 2.5, and it performs outbound HTTP requests asynchronously within HAProxy’s event model. Debian 13 currently packages HAProxy 3.0.x linked against Lua 5.4.
For the exact proxy fields this example uses proxy.is_vpn and proxy.is_residential_proxy, a paid subscription to the IP2Location.io Security Plan is required.
1. Update Debian packages #
sudo apt update
sudo apt upgrade -y
2. Install HAProxy, Lua, cJSON and the demo dependencies #
sudo apt install -y \
   haproxy \
   lua5.4 \
   lua-cjson \
   ca-certificates \
   curl \
   jq \
   python3
Check HAProxy:
/usr/sbin/haproxy -vv
To specifically confirm Lua support:
/usr/sbin/haproxy -vv | grep -i lua
You should see Lua support and Lua 5.4.

3. Add /usr/sbin to your Bash PATH #
On some Debian installations, /usr/sbin is not in the PATH of a normal user.
Add it permanently:
grep -qxF 'export PATH="$PATH:/usr/sbin"' ~/.bashrc || \
echo 'export PATH="$PATH:/usr/sbin"' >> ~/.bashrc
Reload .bashrc:
source ~/.bashrc
Now:
command -v haproxy
It should return:
/usr/sbin/haproxy
Check the version normally:
haproxy -v

4. Verify that cjson works #
Run:
lua5.4 -e 'local cjson=require("cjson"); local x=cjson.decode("{\"working\":true}"); print(x.working)'
Expected:
true
That confirms Lua can load cjson.
5. Create the two demo web backends #
We’re going to create one tiny Python application that listens on:
127.0.0.1:9001 Malaysia backend
127.0.0.1:9002 Global backend
Both servers will print all received HTTP headers as JSON. This makes it very easy to see what HAProxy detected and which backend handled the request.
Create the directory:
sudo mkdir -p /opt/haproxy-demo
Create the server:
sudo tee /opt/haproxy-demo/echo_server.py > /dev/null <<'PYTHON'
#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import threading
class DemoHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.respond()
def do_POST(self):
self.respond()
def respond(self):
data = {
"backend": self.server.backend_name,
"backend_port": self.server.server_port,
"client_seen_by_backend": self.client_address[0],
"method": self.command,
"path": self.path,
"headers": dict(self.headers),
}
body = json.dumps(data, indent=2).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
print(
"[%s] %s - %s"
% (
self.server.backend_name,
self.address_string(),
format % args,
),
flush=True,
)
def start_server(name, port):
server = ThreadingHTTPServer(("127.0.0.1", port), DemoHandler)
server.backend_name = name
print(f"Starting {name} on 127.0.0.1:{port}", flush=True)
server.serve_forever()
servers = [
("malaysia-backend", 9001),
("global-backend", 9002),
]
threads = []
for name, port in servers:
thread = threading.Thread(
target=start_server,
args=(name, port),
daemon=True,
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
PYTHON
Make it executable:
sudo chmod 755 /opt/haproxy-demo/echo_server.py
6. Run the demo backends as a systemd service #
Create:
sudo tee /etc/systemd/system/haproxy-demo-backends.service > /dev/null <<'EOF'
[Unit]
Description=HAProxy IP2Location Demo Backends
After=network.target
[Service]
Type=simple
User=nobody
Group=nogroup
ExecStart=/usr/bin/python3 /opt/haproxy-demo/echo_server.py
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
Reload systemd:
sudo systemctl daemon-reload
Enable and start:
sudo systemctl enable --now haproxy-demo-backends
Check:
sudo systemctl status haproxy-demo-backends

Test Malaysia backend directly:
curl -s http://127.0.0.1:9001/test | jq
You should see something similar to:
{
"backend": "malaysia-backend",
"backend_port": 9001,
"client_seen_by_backend": "127.0.0.1",
"method": "GET",
"path": "/test",
"headers": {
"Host": "127.0.0.1:9001",
"User-Agent": "curl/8.14.1",
"Accept": "*/*"
}
}

Test the global backend:
curl -s http://127.0.0.1:9002/test | jq
The important change is:
"backend": "global-backend"

7. Store your IP2Location.io API key #
Create a separate file rather than putting the key directly inside the Lua source code:
sudo tee /etc/haproxy/ip2location.key > /dev/null <<'EOF'
YOUR_IP2LOCATION_IO_API_KEY
EOF
Replace:
YOUR_IP2LOCATION_IO_API_KEY
with your actual API key.
Protect it:
sudo chown root:root /etc/haproxy/ip2location.key
sudo chmod 600 /etc/haproxy/ip2location.key
Check permissions:
sudo ls -l /etc/haproxy/ip2location.key
You should get something resembling:
-rw------- 1 root root 33 Sep 22 02:16 /etc/haproxy/ip2location.key
IP2Location.io supports both query-string API keys and Authorization: Bearer authentication. We’ll use the bearer header so the API key isn’t embedded in the requested URL.
8. Test IP2Location.io directly first #
Before involving HAProxy, make sure your API key works.
curl -s \
 -H "Authorization: Bearer $(sudo cat /etc/haproxy/ip2location.key)" \
 "https://api.ip2location.io/?ip=8.8.8.8&format=json" | jq
You should see data containing fields such as:
{
"ip": "8.8.8.8",
"country_code": "US",
"country_name": "United States of America",
"is_proxy": false
}
With the detailed security data available, you will additionally see:
{
"proxy": {
"proxy_type": "DCH",
"is_vpn": false,
"is_residential_proxy": false
}
}
The country_code uses the two-character ISO country code, and the proxy object includes flags including is_vpn, and is_residential_proxy.
9. Create the HAProxy Lua script #
Create:
sudo tee /etc/haproxy/ip2location.lua > /dev/null <<'LUA'
local cjson = require("cjson")
local API_KEY_FILE = "/etc/haproxy/ip2location.key"
local function read_api_key()
local file, err = io.open(API_KEY_FILE, "r")
if not file then
error("Unable to open " .. API_KEY_FILE .. ": " .. tostring(err))
end
local key = file:read("*l")
file:close()
if not key or key == "" then
error("IP2Location.io API key is empty")
end
key = key:gsub("%s+$", "")
return key
end
local API_KEY = read_api_key()
local function set_defaults(txn, ip)
txn:set_var("txn.ip2_client_ip", ip)
txn:set_var("txn.ip2_lookup_status", "failed")
txn:set_var("txn.ip2_country", "ZZ")
txn:set_var("txn.ip2_country_name", "Unknown")
txn:set_var("txn.ip2_is_proxy", "false")
txn:set_var("txn.ip2_proxy_type", "-")
txn:set_var("txn.ip2_is_vpn", "false")
txn:set_var("txn.ip2_is_residential", "false")
end
local function bool_string(value)
if value == true then
return "true"
end
return "false"
end
local function ip2location_lookup(txn)
---------------------------------------------------------
-- Determine which IP we are going to query.
--
-- Normally txn.lookup_ip contains src.
-- During localhost testing we can override it with
-- X-Demo-IP from the HAProxy configuration.
---------------------------------------------------------
local lookup_ip = txn:get_var("txn.lookup_ip")
if lookup_ip == nil then
lookup_ip = txn.f:src()
end
lookup_ip = tostring(lookup_ip)
set_defaults(txn, lookup_ip)
---------------------------------------------------------
-- Call IP2Location.io
---------------------------------------------------------
local httpclient = core.httpclient()
local response = httpclient:get{
url = "https://api.ip2location.io/?ip="
.. lookup_ip
.. "&format=json",
headers = {
["authorization"] = {
"Bearer " .. API_KEY
},
["accept"] = {
"application/json"
},
["user-agent"] = {
"HAProxy-IP2Location-Demo/1.0"
}
},
timeout = 3000
}
---------------------------------------------------------
-- Check HTTP response
---------------------------------------------------------
if response == nil then
txn:set_var(
"txn.ip2_lookup_status",
"no_response"
)
return
end
if response.status ~= 200 then
txn:set_var(
"txn.ip2_lookup_status",
"http_" .. tostring(response.status)
)
return
end
---------------------------------------------------------
-- Decode JSON using lua-cjson
---------------------------------------------------------
local ok, data = pcall(
cjson.decode,
response.body or ""
)
if not ok or type(data) ~= "table" then
txn:set_var(
"txn.ip2_lookup_status",
"invalid_json"
)
return
end
---------------------------------------------------------
-- API error object
---------------------------------------------------------
if data.error ~= nil then
txn:set_var(
"txn.ip2_lookup_status",
"api_error"
)
return
end
---------------------------------------------------------
-- Geolocation
---------------------------------------------------------
txn:set_var(
"txn.ip2_country",
tostring(data.country_code or "ZZ")
)
txn:set_var(
"txn.ip2_country_name",
tostring(data.country_name or "Unknown")
)
---------------------------------------------------------
-- Basic proxy result
---------------------------------------------------------
txn:set_var(
"txn.ip2_is_proxy",
bool_string(data.is_proxy)
)
---------------------------------------------------------
-- Detailed proxy/security result
---------------------------------------------------------
local proxy = data.proxy
if type(proxy) == "table" then
txn:set_var(
"txn.ip2_proxy_type",
tostring(proxy.proxy_type or "-")
)
txn:set_var(
"txn.ip2_is_vpn",
bool_string(proxy.is_vpn)
)
txn:set_var(
"txn.ip2_is_residential",
bool_string(proxy.is_residential_proxy)
)
end
txn:set_var(
"txn.ip2_lookup_status",
"ok"
)
end
core.register_action(
"ip2location_lookup",
{ "http-req" },
ip2location_lookup
)
LUA
Set normal read permissions:
sudo chown root:root /etc/haproxy/ip2location.lua
sudo chmod 644 /etc/haproxy/ip2location.lua
HAProxy’s native core.httpclient() supports outbound HTTP requests with a URL, headers and timeout and returns the HTTP status, headers and body. HAProxy’s HTTP client also has resolver and TLS verification settings available globally.
10. Configure HAProxy #
Back up the existing configuration first:
sudo cp \
 /etc/haproxy/haproxy.cfg \
 /etc/haproxy/haproxy.cfg.backup
Now replace it with:
sudo tee /etc/haproxy/haproxy.cfg > /dev/null <<'HAPROXY'
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin
stats timeout 30s
user haproxy
group haproxy
daemon
# Load our IP2Location.io Lua integration.
#
# per-thread allows independent Lua execution on HAProxy threads.
lua-load-per-thread /etc/haproxy/ip2location.lua
# HAProxy HTTP client configuration.
httpclient.resolvers.prefer ipv4
httpclient.ssl.ca-file @system-ca
httpclient.ssl.verify required
httpclient.timeout.connect 2s
httpclient.retries 1
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
timeout http-request 10s
timeout connect 5s
timeout client 30s
timeout server 30s
frontend fe_http
bind :8080
# ------------------------------------------------------------
# Decide which IP address IP2Location.io should look up.
#
# Production traffic:
#
# lookup_ip = real HAProxy source IP
#
# Localhost demo traffic:
#
# X-Demo-IP may override it.
#
# The demo override ONLY works when the connection itself
# originated from localhost.
# ------------------------------------------------------------
acl local_demo src 127.0.0.0/8 ::1
http-request set-var(txn.lookup_ip) req.hdr(X-Demo-IP) if local_demo { req.hdr(X-Demo-IP) -m found }
http-request set-var(txn.lookup_ip) src if !{ var(txn.lookup_ip) -m found }
# ------------------------------------------------------------
# Call IP2Location.io through Lua.
# ------------------------------------------------------------
http-request lua.ip2location_lookup
# ------------------------------------------------------------
# Add the lookup results as HTTP request headers.
#
# Our demo backends will print these.
# ------------------------------------------------------------
http-request set-header X-IP2Location-IP %[var(txn.ip2_client_ip)]
http-request set-header X-IP2Location-Lookup-Status %[var(txn.ip2_lookup_status)]
http-request set-header X-IP2Location-Country-Code %[var(txn.ip2_country)]
http-request set-header X-IP2Location-Country-Name %[var(txn.ip2_country_name)]
http-request set-header X-IP2Location-Is-Proxy %[var(txn.ip2_is_proxy)]
http-request set-header X-IP2Location-Proxy-Type %[var(txn.ip2_proxy_type)]
http-request set-header X-IP2Location-Is-VPN %[var(txn.ip2_is_vpn)]
http-request set-header X-IP2Location-Is-Residential-Proxy %[var(txn.ip2_is_residential)]
# ------------------------------------------------------------
# ACLs created from the API result.
# ------------------------------------------------------------
acl detected_vpn var(txn.ip2_is_vpn) -m str true
acl detected_residential_proxy var(txn.ip2_is_residential) -m str true
acl country_malaysia var(txn.ip2_country) -m str MY
# ------------------------------------------------------------
# Block VPNs.
# ------------------------------------------------------------
http-request return status 403 content-type "text/plain; charset=utf-8" lf-string "Blocked by HAProxy\nReason: VPN detected\nIP: %[var(txn.ip2_client_ip)]\nCountry: %[var(txn.ip2_country)]\nProxy type: %[var(txn.ip2_proxy_type)]\n" if detected_vpn
# ------------------------------------------------------------
# Block residential proxies.
# ------------------------------------------------------------
http-request return status 403 content-type "text/plain; charset=utf-8" lf-string "Blocked by HAProxy\nReason: Residential proxy detected\nIP: %[var(txn.ip2_client_ip)]\nCountry: %[var(txn.ip2_country)]\nProxy type: %[var(txn.ip2_proxy_type)]\n" if detected_residential_proxy
# ------------------------------------------------------------
# Country routing.
#
# Malaysia -> Malaysia backend
# Everything else -> Global backend
# ------------------------------------------------------------
use_backend be_malaysia if country_malaysia
default_backend be_global
backend be_malaysia
mode http
# Show routing decision in request and response.
http-request set-header X-Demo-Backend malaysia
http-response set-header X-Demo-Backend malaysia
http-response set-header X-IP2Location-Country-Code %[var(txn.ip2_country)]
server malaysia01 127.0.0.1:9001 check
backend be_global
mode http
http-request set-header X-Demo-Backend global
http-response set-header X-Demo-Backend global
http-response set-header X-IP2Location-Country-Code %[var(txn.ip2_country)]
server global01 127.0.0.1:9002 check
HAPROXY
The relevant HAProxy behavior here is that Lua stores the API result in transaction-scoped variables; HAProxy can then consume those variables in ACLs, HTTP headers, and backend-routing decisions.
11. Validate the HAProxy configuration #
Always do this before restarting HAProxy:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
Make sure there are no errors.
If it reports a Lua/cJSON problem, verify:
lua5.4 -e 'require("cjson"); print("cjson OK")'
And:
haproxy -vv | grep -i lua
12. Restart HAProxy #
sudo systemctl restart haproxy
Check:
sudo systemctl status haproxy

Also check that HAProxy is listening on port 8080:
sudo ss -lntp | grep 8080

13. Test Malaysia routing #
I’ve included a special testing mechanism:
X-Demo-IP
It works only if the connection itself comes from localhost, so an Internet client cannot use it to spoof their country.
A currently documented Telekom Malaysia range includes 175.136.0.0 and is classified as Malaysia (MY).
Test:
curl -i \
 -H 'X-Demo-IP: 175.136.0.0' \
 http://127.0.0.1:8080/test
You should see response headers similar to:

That proves:

14. Test routing for another country #
Google’s 8.8.8.8 is currently returned as United States (US) by IP2Location.io.
Run:
curl -i \
 -H 'X-Demo-IP: 8.8.8.8' \
 http://127.0.0.1:8080/test
Now you should see:

So:

15. Test an actual client connection #
From another machine, don’t send X-Demo-IP.
Run:
curl -i http://YOUR_HAPROXY_SERVER_IP:8080/test
HAProxy will use:
src
as the IP sent to IP2Location.io.
The returned backend body should show:
"X-IP2Location-IP": "YOUR_PUBLIC_IP"
along with:
"X-IP2Location-Country-Code": "MY"
or whichever country IP2Location.io detects.
16. Test VPN blocking #
If you have an IP known to belong to a VPN service, test locally with:
curl -i \
 -H 'X-Demo-IP: KNOWN_VPN_IP' \
 http://127.0.0.1:8080/test
When IP2Location.io returns:
{
"is_proxy": true,
"proxy": {
"proxy_type": "VPN",
"is_vpn": true,
"is_residential_proxy": false
}
}
HAProxy should immediately return:
HTTP/1.1 403 Forbidden
with:
Blocked by HAProxy
Reason: VPN detected
IP: ...
Country: ...
Proxy type: VPN
IP2Location.io documents VPN as proxy type VPN and provides the proxy.is_vpn boolean for this purpose.
The request never reaches either Python backend.
17. Test residential-proxy blocking #
Likewise:
curl -i \
 -H 'X-Demo-IP: KNOWN_RESIDENTIAL_PROXY_IP' \
 http://127.0.0.1:8080/test
If the API result contains:
{
"is_proxy": true,
"proxy": {
"proxy_type": "RES",
"is_vpn": false,
"is_residential_proxy": true
}
}
HAProxy returns:
HTTP/1.1 403 Forbidden
and:
Blocked by HAProxy
Reason: Residential proxy detected
IP: ...
Country: ...
Proxy type: RES
RES is the documented IP2Location.io proxy type for residential proxies.
18. Change the country-routing rule #
At the moment:
acl country_malaysia var(txn.ip2_country) -m str MY
use_backend be_malaysia if country_malaysia
For the United States instead:
acl country_us var(txn.ip2_country) -m str US
use_backend be_us if country_us
Or several countries:
acl asia_users var(txn.ip2_country) -m str MY SG TH ID PH
use_backend be_asia if asia_users
This lets you build rules such as:

19. Important behavior when IP2Location.io is unavailable #
The Lua script deliberately uses fail-open behavior.
If the lookup fails:
txn.ip2_lookup_status = failed / no_response / http_xxx / invalid_json
country = ZZ
VPN = false
residential proxy = false
The request therefore goes to:
be_global
instead of being blocked.
You can see the failure from:
X-IP2Location-Lookup-Status
For example:
"X-IP2Location-Lookup-Status": "http_401"
would suggest an authentication/API-key problem.
For a tutorial, fail-open makes testing easier. For a security-sensitive production application, whether API failures should fail open or fail closed is a policy decision.
20. One important production consideration #
This tutorial intentionally performs:

That makes the operation very easy to understand and demonstrate, but it isn’t what I’d use unchanged on a high-traffic website.
HAProxy’s native Lua HTTP client is non-blocking from HAProxy’s event-processing perspective, but the individual client request still has to wait for the API result before the country/proxy routing decision can be made. HAProxy specifically provides the native HTTP client for Lua integrations of this kind.
A production version would normally add something like:
For example, caching each IP result for 5–30 minutes can dramatically reduce API queries and eliminate repeated external lookup latency.
And because the backend echoes the headers, the browser/curl output clearly shows things such as:

X-IP2Location-IP
X-IP2Location-Country-Code
X-IP2Location-Country-Name
X-IP2Location-Is-Proxy
X-IP2Location-Proxy-Type
X-IP2Location-Is-VPN
X-IP2Location-Is-Residential-Proxy
X-Demo-Backend
That makes this setup particularly suitable as an IP2Location.io + HAProxy Community country routing and proxy filtering tutorial rather than just a configuration that silently performs the routing.
Conclusion #
In this tutorial, we integrated HAProxy Community with the IP2Location.io API to make routing and access-control decisions using real-time IP intelligence.
HAProxy uses a Lua script to query IP2Location.io and parse the JSON response with lua-cjson. The resulting information is stored in HAProxy transaction variables, which can then be used by ACLs and routing rules.
With this setup, HAProxy can route requests according to a visitor’s country while also rejecting traffic identified as coming from VPNs or residential proxies. The demonstration backends and custom HTTP headers make it easy to see the IP2Location.io results and verify which routing rule was applied.
The same approach can be extended to support more advanced policies, such as routing different countries or regions to separate data centers, blocking additional proxy types, applying different security rules by location, or passing geolocation information to backend applications.
For a production environment, it is also worth considering caching IP2Location.io lookup results so repeated requests from the same IP address do not require a new API request every time. This can reduce API usage and minimize the additional latency introduced by external lookups.
With HAProxy handling the traffic decisions and IP2Location.io providing the geolocation and proxy intelligence, this integration provides a flexible way to add location-aware routing and IP-based security controls to applications running behind HAProxy Community.
