Symptoms
You've configured HAProxy to inject or modify HTTP headers — maybe you're adding an
X-Forwarded-Forheader, inserting a custom
X-Backend-Serverheader, or rewriting a
Hostheader before the request hits your application. The config looks right. HAProxy reloads without complaint. But when you curl the backend directly or check your application logs, the header is missing, carries the wrong value, or the original untouched value is still sitting there.
Sometimes it's subtler: the header shows up intermittently, only on certain URL paths, or only for some source IPs. You might see correct behavior in staging but not in production. In my experience, these inconsistencies are almost always a config problem — not a bug in HAProxy itself.
This article walks through every common reason HAProxy header manipulation fails, how to positively identify each one, and how to fix it for good.
Root Cause 1: reqadd Syntax Is Wrong
The
reqadddirective is the old HAProxy 1.x way of appending headers to forwarded requests. A lot of tutorials and Stack Overflow answers still reference it, and if you're running HAProxy 2.x — which most shops are by now — you need to understand exactly how it behaves and where it breaks down.
reqaddtakes a raw header line as its argument. The format is rigid: the header name, a colon, a space, then the value. Get that format wrong and HAProxy either silently drops the directive or injects a malformed header that the backend ignores or misparses.
A very common broken pattern looks like this:
frontend http-in
bind *:80
default_backend app-servers
reqadd X-Custom-Header "my-value"
The quotes around the value are wrong.
reqadddoes not strip them — it will literally send
X-Custom-Header: "my-value"with the quotation marks included in the header value. Many application frameworks will reject or misparse that. The correct form is:
frontend http-in
bind *:80
default_backend app-servers
reqadd X-Custom-Header:\ my-value
Or equivalently, without the backslash-space trick:
reqadd X-Custom-Header: my-value
To confirm exactly what headers are arriving at the backend, run
tcpdumpon the backend-facing interface and capture the raw HTTP stream:
tcpdump -i eth0 -A -s 0 'tcp port 8080' | grep -A 30 'GET\|POST\|PUT\|DELETE'
You'll see the headers verbatim. If you spot literal quotes in the header value, or no header at all, you've found your problem. Another way to check is with
ngrep:
ngrep -d eth0 -W byline '' 'tcp port 8080'
Also worth knowing:
reqaddis deprecated as of HAProxy 2.2. It still functions but the config parser will emit a warning every time you reload:
[WARNING] 110/143021 (12450) : parsing [/etc/haproxy/haproxy.cfg:18]: 'reqadd' is deprecated. Please use 'http-request add-header' instead.
The right move is to stop using
reqaddentirely. The modern equivalent is
http-request add-headerfor appending a new header instance, or
http-request set-headerfor replacing any existing value. The migrated config looks like this:
frontend http-in
bind *:80
mode http
default_backend app-servers
http-request add-header X-Custom-Header my-value
No colon, no quotes — just the header name, a space, and the value. That's the syntax. Keep it simple.
Root Cause 2: http-request set-header Syntax or Placement Is Wrong
http-request set-headeris the correct, modern way to manipulate request headers in HAProxy 2.x. But it has its own failure modes, and I've watched engineers spend entire afternoons chasing bugs that came down to a single misplaced directive or a misunderstood fetch expression.
Placement matters first. All
http-requestdirectives must live inside a
frontend,
listen, or
backendsection. If you accidentally put one in the
globalor
defaultssection, HAProxy will reject the config outright or silently ignore the directive:
haproxy -c -f /etc/haproxy/haproxy.cfg
[ALERT] 110/143512 (12601) : parsing [/etc/haproxy/haproxy.cfg:9]: 'http-request' not allowed in 'defaults' section
Good — at least that case surfaces as an error. The quieter failure is a bad fetch expression in the value. HAProxy's
http-requestdirectives support a rich sample-fetch language, and getting the expression wrong causes the header to be set to an empty value or the literal string you typed, not the runtime value you intended.
# Correct — sets X-Real-IP to the TCP source address
http-request set-header X-Real-IP %[src]
# Wrong — sets X-Real-IP to the literal string "src"
http-request set-header X-Real-IP src
HAProxy won't catch that second form as an error at config-check time. It's syntactically valid — it just evaluates to the static string "src". You'll only catch it by inspecting what the backend actually receives.
To verify what HAProxy is sending, spin up a temporary echo listener on the backend host (10.10.10.20 in this example):
# On 10.10.10.20
nc -lk 8080
# From a client hitting HAProxy at 10.10.10.1
curl -v http://10.10.10.1/test
The netcat output shows the raw HTTP request headers exactly as HAProxy forwarded them. If
X-Real-IPreads
srcliterally, you've confirmed the fetch expression bug.
Another thing to keep straight:
set-headerreplaces any existing header with that name, while
add-headerappends a new header instance without removing existing ones. Using
set-headerwhen you meant
add-headersilently discards whatever the client sent. Know which one you need before you pick.
Root Cause 3: option forwardfor Not Set
This catches a lot of people who are new to HAProxy. They expect
X-Forwarded-Forto be injected automatically — after all, that's what it's for, right? But HAProxy doesn't add it unless you explicitly tell it to with
option forwardfor.
Without
option forwardfor, your backend application only sees the source IP of the TCP connection, which is HAProxy's own address. Every single request looks like it comes from the same machine. Your application logs end up looking like this:
10.10.10.1 - - [20/Apr/2026:14:22:01 +0000] "GET /api/users HTTP/1.1" 200 512
10.10.10.1 - - [20/Apr/2026:14:22:03 +0000] "POST /api/login HTTP/1.1" 200 88
10.10.10.1 - - [20/Apr/2026:14:22:07 +0000] "GET /api/status HTTP/1.1" 200 312
Every request from HAProxy's IP — 10.10.10.1. The real client IPs are invisible to the application. With
option forwardforenabled, HAProxy injects the client IP into an
X-Forwarded-Forheader before proxying:
203.0.113.45 - - [20/Apr/2026:14:22:01 +0000] "GET /api/users HTTP/1.1" 200 512
To check whether it's enabled in your current config:
grep -n 'forwardfor' /etc/haproxy/haproxy.cfg
No output means it's not set anywhere. Add it to your frontend:
frontend http-in
bind *:80
mode http
option forwardfor
default_backend app-servers
There's an important nuance in layered proxy setups. If HAProxy itself sits behind a CDN or another load balancer, the incoming request may already carry an
X-Forwarded-Forheader set by that upstream device. By default,
option forwardforappends the connecting IP to whatever
X-Forwarded-Forvalue is already there — which is usually correct behavior. But if you can't trust what's upstream (because external clients could forge the header), strip it first:
frontend http-in
bind *:80
mode http
http-request del-header X-Forwarded-For
option forwardfor
default_backend app-servers
This ensures the only value in
X-Forwarded-Forwhen it reaches the backend is the one HAProxy put there. Also double-check that you're running in
mode http. If the frontend is in TCP mode,
option forwardforis silently ignored because HAProxy isn't parsing the HTTP layer at all.
Root Cause 4: The Header Is Being Overwritten Downstream
This one is particularly frustrating to debug because HAProxy is actually doing its job correctly. You can confirm it with tcpdump on the interface facing the backend — the header is present, the value is right. But by the time the request reaches your application code, the header is gone or has a different value. Something downstream is overwriting it.
The most common offender in multi-tier architectures is Nginx sitting between HAProxy and the application as an additional reverse proxy. Nginx's default configuration does not transparently forward all headers. If the Nginx config has its own
proxy_set_headerdirectives, those will overwrite whatever HAProxy sent. A very common example:
# In /etc/nginx/sites-available/app.solvethenetwork.com
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Host $host;
}
That
$remote_addrin Nginx is the IP of the connection source — which is HAProxy (10.10.10.1) — not the real client. So Nginx overwrites the carefully-constructed
X-Forwarded-Forthat HAProxy set. Fix it by using Nginx's
$proxy_add_x_forwarded_forvariable instead, which appends to any existing value rather than replacing it:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Or, if HAProxy is already handling all header injection, remove those
proxy_set_headerdirectives from Nginx entirely and let the headers pass through untouched.
Application frameworks can also be the culprit. Some middleware stacks in Django, Rails, or Express normalize or strip certain headers before they reach your route handlers. If you've confirmed the header is reaching the web server process but your application code never sees it, look at your middleware pipeline.
To definitively isolate whether the problem is in HAProxy or downstream, bypass all intermediate layers temporarily. On the backend host (10.10.10.20), start a simple Python HTTP server on an unused port:
python3 -m http.server 9999
Add a temporary backend definition in HAProxy pointing at port 9999, route some test traffic there, and check what Python logs as the received headers. If the headers are correct at 9999 but wrong at your real application port, the problem is definitively downstream of HAProxy.
You can also use HAProxy's own log-format to record header values at the moment HAProxy forwards the request. Add this to your frontend temporarily:
frontend http-in
bind *:80
mode http
option forwardfor
log-format "%ci:%cp [%t] %f %b/%s %ST %{+Q}r hdr=%[req.hdr(X-Custom-Header)]"
default_backend app-servers
This logs the value of
X-Custom-Headeras seen by HAProxy on each request. If the log shows the correct value but your application doesn't receive it, the overwrite is happening downstream — not in HAProxy.
Root Cause 5: ACL Condition Is Wrong
Conditional header injection is where things get interesting. You only set a header when a request matches a certain path, originates from a specific IP range, or uses a particular HTTP method. When header injection works sometimes but not others — or never — a broken ACL condition is very often why.
The insidious thing about ACL failures is that they produce no error output. If the ACL doesn't match, HAProxy simply skips the
http-requestdirective. Nothing is logged, nothing is warned. The header just doesn't appear.
The most common mistake is case sensitivity. ACL string comparisons are case-sensitive by default. If your URL is
/API/v1/userswith a capital
API, and your ACL uses lowercase:
acl is_api path_beg /api
http-request set-header X-Route-Type api if is_api
The ACL evaluates to false. The header never gets set. The fix is the
-iflag for case-insensitive matching:
acl is_api path_beg -i /api
http-request set-header X-Route-Type api if is_api
Another common mistake is using the wrong fetch method for what you're trying to match. Say you want to conditionally set a header only when the client sends an
Authorizationheader:
# Wrong context — res.hdr matches response headers, not request headers
acl has_auth res.hdr(Authorization) -m found
# Correct — req.hdr matches request headers
acl has_auth req.hdr(Authorization) -m found
Using
res.hdrin a frontend context either fails silently or evaluates incorrectly because response headers don't exist yet when HAProxy is processing an inbound request. Always match the fetch type to the direction of traffic you're inspecting.
Logical operator mistakes are another trap. The
ifclause in an
http-requestdirective evaluates a boolean expression, and it's easy to get AND vs OR wrong:
# This sets the header if EITHER acl1 OR acl2 is true (OR is implicit with space)
http-request set-header X-Type special if acl1 acl2
# Wait — actually spaces between ACL names mean AND in this context
# Use || for OR:
http-request set-header X-Type special if acl1 || acl2
In HAProxy's ACL syntax, multiple ACL names separated by spaces in an
ifclause form an AND condition — all must be true. Use
||explicitly for OR. Getting this backwards means the condition is almost never true, and the header is almost never set.
To debug ACL evaluation in real time, add temporary diagnostic headers that fire on both sides of the condition:
frontend http-in
bind *:80
mode http
acl is_api path_beg -i /api
http-request set-header X-Debug-Route api-match if is_api
http-request set-header X-Debug-Route no-match if !is_api
default_backend app-servers
Every request will now carry an
X-Debug-Routeheader indicating which branch was taken. Send a few requests through different paths and inspect the backend-side headers. You'll immediately see whether your ACL is firing as you expect. Pull those debug headers out once you've confirmed the logic is correct.
If you have the HAProxy stats socket enabled, you can also inspect loaded ACL entries at runtime:
echo "show acl" | socat stdio /var/run/haproxy/admin.sock
This lists all named ACLs and their current entries, which is particularly useful when you're loading ACL patterns from external files and want to verify the file was parsed correctly and the expected entries are actually loaded.
Root Cause 6: HAProxy Is Not Running in HTTP Mode
Short but important. HAProxy has two operating modes:
mode tcpand
mode http. In TCP mode, HAProxy proxies raw byte streams without any HTTP parsing. All HTTP-level directives —
http-request,
http-response,
option forwardfor,
reqadd— are completely and silently ignored.
A very common misconfiguration is setting
mode tcpin the
defaultsblock and forgetting to override it in the frontend:
defaults
mode tcp
timeout connect 5s
timeout client 30s
timeout server 30s
frontend http-in
bind *:80
# mode http NOT set — inheriting tcp from defaults
option forwardfor # silently ignored
http-request set-header X-Custom my-value # silently ignored
default_backend app-servers
Config check passes. HAProxy reloads fine. No errors. Every HTTP directive just does nothing. The fix is explicit mode declaration at the frontend and backend level:
frontend http-in
bind *:80
mode http
option forwardfor
http-request set-header X-Custom my-value
default_backend app-servers
backend app-servers
mode http
server app1 10.10.10.20:8080 check
Don't rely on mode inheritance. Be explicit in every frontend and backend block that handles HTTP traffic.
Root Cause 7: Header Rewrite Regex Is Not Matching
If you're modifying an existing header value — rewriting a
Hostheader, stripping a path prefix from a URL header, or transforming a cookie — you're likely using
http-request replace-headeror the deprecated
reqrep. Regex mistakes here produce a silent non-match: the original header value stays exactly as-is.
A typical example of a working-but-fragile rewrite:
http-request replace-header Host ^(.+)\.solvethenetwork\.com$ \1.internal.solvethenetwork.com
If the incoming
Hostheader includes a port (like
app.solvethenetwork.com:80), that regex won't match because of the colon and port number. The header is left untouched. Test your regex against realistic inputs before deploying:
echo "app.solvethenetwork.com:80" | grep -E '^(.+)\.solvethenetwork\.com$'
No match. You'd need to account for the optional port in your regex, or strip the port first with a separate directive. HAProxy uses POSIX extended regex by default — Perl-compatible features like lookaheads and non-greedy quantifiers are not available. If you developed your regex in a PCRE context and it uses
(?=...)or
*?, it won't work in HAProxy and will silently fail to match.
Prevention
Most of these issues are caught before production if you build header verification into your deployment process. Always run
haproxy -c -f /etc/haproxy/haproxy.cfgbefore every reload — it catches syntax errors and some semantic problems, though not all runtime fetch expression issues.
Keep a debug backend available in your staging environment. A netcat listener or Python's built-in HTTP server on an unused port is enough. Route test traffic there, inspect the raw headers, and confirm that every header injection rule works end-to-end before the config goes anywhere near production. Don't trust application logs alone — middleware can silently transform headers before they reach your logging layer.
When adding conditional header directives, always write a paired negative-branch debug header during initial testing. This confirms the ACL is being evaluated, not just that the positive path works. Once you've validated behavior in both branches, remove the debug headers.
Migrate away from deprecated directives.
reqadd,
reqrep,
rspadd, and their siblings are removed in strict mode in HAProxy 2.5+ and will eventually disappear entirely. The
http-requestand
http-responsefamilies are richer, clearer, and won't suddenly break on an upgrade.
In multi-tier setups, document which layer owns which header. If HAProxy sets
X-Forwarded-For, put a comment in every Nginx and application config file that says so explicitly. Three layers all trying to manage the same header is one of the most common and most time-consuming problems I've seen in production proxy stacks. Pick one place, configure it there, and configure everything else to pass that header through untouched.
Use HAProxy's
log-formatto capture key header values in your access logs. Knowing what HAProxy actually forwarded — not what you think it forwarded — is invaluable when something goes wrong at 2am. Pull those logs before reaching for tcpdump.
