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.
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'
hdr(host) in a response ruleThe 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
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).
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
| 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-Byheaders 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