InfraRunBook
    Back to articles

    HAProxy Header Manipulation Not Working

    HAProxy
    Published: Apr 20, 2026
    Updated: Apr 20, 2026

    Troubleshoot why HAProxy is failing to inject, modify, or forward HTTP headers. Covers reqadd syntax, http-request set-header, option forwardfor, ACL conditions, and downstream overwrites.

    HAProxy Header Manipulation Not Working

    Symptoms

    You've configured HAProxy to inject or modify HTTP headers — maybe you're adding an

    X-Forwarded-For
    header, inserting a custom
    X-Backend-Server
    header, or rewriting a
    Host
    header 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

    reqadd
    directive 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.

    reqadd
    takes 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.

    reqadd
    does 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

    tcpdump
    on 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:

    reqadd
    is 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

    reqadd
    entirely. The modern equivalent is
    http-request add-header
    for appending a new header instance, or
    http-request set-header
    for 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-header
    is 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-request
    directives must live inside a
    frontend
    ,
    listen
    , or
    backend
    section. If you accidentally put one in the
    global
    or
    defaults
    section, 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-request
    directives 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-IP
    reads
    src
    literally, you've confirmed the fetch expression bug.

    Another thing to keep straight:

    set-header
    replaces any existing header with that name, while
    add-header
    appends a new header instance without removing existing ones. Using
    set-header
    when you meant
    add-header
    silently 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-For
    to 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 forwardfor
    enabled, HAProxy injects the client IP into an
    X-Forwarded-For
    header 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-For
    header set by that upstream device. By default,
    option forwardfor
    appends the connecting IP to whatever
    X-Forwarded-For
    value 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-For
    when 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 forwardfor
    is 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_header
    directives, 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_addr
    in 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-For
    that HAProxy set. Fix it by using Nginx's
    $proxy_add_x_forwarded_for
    variable 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_header
    directives 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-Header
    as 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-request
    directive. 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/users
    with 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

    -i
    flag 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

    Authorization
    header:

    # 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.hdr
    in 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

    if
    clause in an
    http-request
    directive 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

    if
    clause 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-Route
    header 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 tcp
    and
    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 tcp
    in the
    defaults
    block 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

    Host
    header, stripping a path prefix from a URL header, or transforming a cookie — you're likely using
    http-request replace-header
    or 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

    Host
    header 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.cfg
    before 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-request
    and
    http-response
    families 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-format
    to 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.

    Frequently Asked Questions

    Why does http-request set-header work in staging but not in production?

    The most common reason is a mode difference between environments — production may have 'mode tcp' set in defaults without an explicit 'mode http' override in the frontend. Also check whether production has a downstream Nginx or application middleware that overwrites the header before it reaches your application code.

    What is the difference between http-request add-header and http-request set-header in HAProxy?

    set-header replaces any existing header with that name entirely, while add-header appends a new header instance alongside any existing ones with the same name. Use set-header when you want a single authoritative value; use add-header when you want to accumulate values (as X-Forwarded-For does across proxy hops).

    Does HAProxy automatically add X-Forwarded-For headers?

    No. You must explicitly enable it with 'option forwardfor' in your frontend or defaults section, and your frontend must be running in 'mode http'. Without this directive, backends only see HAProxy's own IP as the connection source — the real client IP is never forwarded.

    How do I debug which branch an HAProxy ACL condition is taking at runtime?

    Add a paired set of debug headers using 'http-request set-header X-Debug matched if your_acl' and 'http-request set-header X-Debug no-match if !your_acl'. Every request will carry an X-Debug header indicating which branch evaluated. Inspect those headers on the backend side to confirm ACL behavior, then remove the debug directives once validated.

    Why was reqadd deprecated and should I still use it?

    reqadd was deprecated in HAProxy 2.2 because http-request add-header and http-request set-header offer more flexible, consistent, and readable syntax with full access to HAProxy's sample-fetch language. You should migrate away from reqadd — it is removed in strict mode in newer HAProxy versions and carries a syntax that is easy to get wrong with quoting.

    Related Articles