HAProxy — Hiding server headers and serving custom error pages across backends

Selectively hide the Server/X-Powered-By headers per domain and replace HAProxy's error pages for every backend — not only when HAProxy itself detects an outage.

HAProxy — Hiding server headers and serving custom error pages across backends HAProxy — Hiding server headers and serving custom error pages across backends
Table of Contents

An HAProxy reverse proxy that exposes several internal services (panel, storage, games, monitoring…) raises two recurring problems: some services leak too much information in their HTTP headers, and the default error pages are never consistent from one service to the next. This guide covers both.

Hiding server headers per domain

Goal: hide Server/X-Powered-By on every internal domain, except the main website, where showing the technical stack is a deliberate choice.

The natural first attempt — and the first trap:

acl is_main_domain hdr(host) -i 50bvd.com
http-response del-header Server if !is_main_domain

In an http-response context, hdr(host) is no longer valid: that fetch only exists on the request side. HAProxy rejects the rule with “will never match because it only involves keywords that are incompatible with ‘frontend http-response header rule’“.

root@haproxy:~# haproxy -c -f /etc/haproxy/haproxy.cfg
... will never match because it only involves keywords that are incompatible with 'frontend http-response header rule'
The configuration check rejects hdr(host) in a response rule

The solution is to capture the host during the request in a transaction variable, then read it back on the response side:

http-request set-var(txn.host) req.hdr(host)
acl is_main_domain var(txn.host) -m str -i 50bvd.com

http-response del-header Server     if !is_main_domain
http-response del-header X-Powered-By if !is_main_domain
-m str is mandatory

An ACL based on var() has no implicit type — without -m str, HAProxy refuses to start with ‘matching method must be specified first’.

Custom error pages on every backend

Classic errorfile directives (in defaults) only cover errors generated by HAProxy itself (backend unreachable → 502/503/504, malformed request → 400). They never intercept a 404 or 500 returned by a backend that is otherwise answering normally.

To systematically replace any error code — whether it comes from a healthy backend or from an outage detected by HAProxy — on frontend https_front:

http-response return status 404 content-type text/html \
    file /etc/haproxy/errors/404.http \
    hdr X-Frame-Options "SAMEORIGIN" \
    hdr X-Content-Type-Options "nosniff" \
    hdr Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" \
    hdr Referrer-Policy "strict-origin-when-cross-origin" \
    if { status 404 }

Repeat for each code you want (400, 403, 404, 408, 500, 502, 503, 504).

return replaces the whole response

http-response return completely overwrites the original response, including security headers added by earlier rules — they have to be re-injected directly on the return line with hdr.

Known limitation: SPAs that always answer 200

A Pterodactyl-style panel handles its routes client-side (React/Vue): a URL that doesn’t exist in the app still returns HTTP 200 from the server, with a purely visual “not found”. No HAProxy rule can intercept that case — only real outages (unreachable backend, timeout) remain covered.

Checking

haproxy -c -f /etc/haproxy/haproxy.cfg
rcctl reload haproxy   # or rcctl restart haproxy if reload isn't enough

# Header hidden on an internal service
curl -sI https://portainer.example.com/ | grep -i server

# Header kept on the main website
curl -sI https://50bvd.com/ | grep -i server

# Custom error page on a route that doesn't exist
curl -s https://50bvd.com/route-that-does-not-exist/ | head -c 200
ksh, not bash
On OpenBSD, HAProxy runs through rcctl and the default shell is ksh — bash habits (sed -i without an argument, systemctl…) don’t apply as is. sed needs an explicit empty argument: sed -i ‘’ ‘s … … ’.

Result

  • Server/X-Powered-By headers hidden on every internal domain, kept only on the chosen domain.
  • One consistent, custom error page applied uniformly across every backend and every error code.

Comments