
Routing with HAProxy means deciding, for each request, which backend serves it, and above all which request is served by none. This lesson is for someone who already knows how to install HAProxy and read a frontend: it teaches you to write ACLs (test rules on the request), to order them, to consult a map (a key-value table) for domain names, then to reject what has no route. The running thread is a real trap: four rules, each correct, that together send a request to the wrong place, and that no line-by-line reading exposes.
What you will learn
The lesson goes from the isolated rule to the proven routing table.
- Write an ACL (a named test on the request) with a fetch (which extracts a value), a converter (which transforms it) and a condition.
- Order
use_backendanddefault_backendknowing that the first winning rule stops everything. - Reject an unknown name or a branch of paths without reaching a backend.
- Route by name with a map, and change it live through the admin socket.
- Prove each route at the backend that really served the request.
Prerequisites
The lesson assumes HAProxy is installed and that you can read an haproxy.cfg file.
- Install HAProxy, read
frontendandbackend, validate withhaproxy -c: Install HAProxy and read its configuration sections. - Read an HTTP request, its
Host, its path and its query string: Read HTTP before configuring a proxy. - The lab runs on Debian 13, with HAProxy 3.4.7 (the LTS branch) installed from the
trixie-backports-3.4repository ofhaproxy.debian.net. The facts in this lesson are those of that version.
How does HAProxy pick a backend?
HAProxy evaluates the rules of a frontend in a precise order and stops at the first one that designates a backend; a request that no rule picks falls into default_backend, or is served by nobody if there is none. Understanding this order is the core of the lesson, because every routing mistake comes from it.
A frontend is the configuration section that receives the clients' requests; a backend is the group of servers that serves them. In a frontend in http mode, three families of directives follow one another. The http-request rules run first: they can reject (deny), redirect or rewrite a request. Then come the use_backend rules, evaluated in the order you write them; the first whose condition is true picks the backend, and the following ones are no longer read. Last is default_backend, which applies when no use_backend has picked the request. This lesson assumes healthy backends: what happens when one of them goes down (health checks, removal, draining) is the subject of Choose the load-balancing algorithm and measure health checks.
Keep the consequence in mind: the order between two directives of the same type matters, whereas the order between different types is fixed by HAProxy, whatever the position in the file. An http-request deny rule written at the end of a frontend runs all the same before any use_backend.
A note on the source: the manual of branch 3.4 only says "evaluated in their declaration order" inside one family of rules. The order between families (the http-request rules before all the use_backend rules) comes from HAProxy's code, not from a sentence of the manual. Write your rejections before the use_backend lines, then: the file reads in the real order of execution.
What are an ACL, a fetch and a converter?
An ACL (Access Control List) is a named test on the request: a fetch extracts a value, a converter possibly transforms it, and a pattern compares it. You declare it with acl <name> <fetch> <pattern>, then cite it in an if <name> condition.
A fetch ("sample") goes and gets a piece of information: path gives the URL path, req.hdr(host) the Host header, src the client's address, method the HTTP method. A converter transforms this value before the comparison, for example lower to switch to lowercase. You chain it with a comma: req.hdr(host),lower. The pattern is what you compare to.
Conditions combine ACLs. Two ACLs side by side read as an AND (if a b), the word or gives an OR (if a or b), and the exclamation mark a negation (if !a). An ACL can also carry the same name on several lines: the lines add up as an OR, which lets you list several patterns under one name.
frontend public bind :80
acl hote_blog req.hdr(host),lower -m str blog.pub.lab.test acl chemin_docs path_beg /docs/ acl methode_lue method GET HEAD
use_backend docs if hote_blog chemin_docs methode_lueThis example is deliberately different from the lab's: three ACLs, one condition that combines them as an AND. Read the last line as a sentence: "send to docs if the host is blog.pub.lab.test, and the path starts with /docs/, and the method is GET or HEAD". The -m str option asks for an exact string comparison; without it, HAProxy applies the fetch's default method.
Which path criterion should you pick: exact, prefix or directory?
A prefix is not a path: path_beg /panier also picks up /panierarchive, which has nothing to do with /panier. The comparison criterion decides what the rule catches, and the choice depends on what you really mean.
| Fetch | What it picks | Trap |
|---|---|---|
path | The exact path, no more, no less | /api/ is not /api |
path_beg | Any path that starts with the pattern | Picks up neighbors that share the start of the name |
path_end | Any path that ends with the pattern | Useful for an extension, not for a branch |
path_dir | One or more complete directories of the path, delimited by slashes, located anywhere | /images/png/logo/32x32.png matches /png/logo or images/png, but not png: it is not a prefix, and the pattern can pick up a path from a completely different branch |
path_reg | A regular expression | Powerful, more costly, harder to reread |
Take a /docs branch not to be confused with /docs-archive. path_beg /docs catches both. The /docs/ pattern, with the trailing slash, rules out /docs-archive but no longer catches /docs alone. To cover both the resource and its branch, you declare two patterns under the same ACL name, the exact path and the prefix with a slash. To bound a branch from the root, path_beg with a trailing slash fits; path_dir would let through, to your backend, paths that contain the same name deeper down. Always compare the rule with the neighboring paths your service exposes, not only with the path you have in mind.
Two other sources of discrepancy. Paths are case-sensitive: /Docs/ is not /docs/, unless you add the -i option to the ACL. And the Host can carry a port (blog.pub.lab.test:8080), which req.hdr(host) returns as is: a map that only knows the name without a port will not find it. Normalize with lower, and if needed remove the port with the field(1,:) converter.
Why does rule order decide everything?
When two use_backend rules can pick the same request, only the first counts; a broad rule placed first makes all the following ones unreachable, even if they are written, readable and correct. This is the fault the lab makes you find, and it is the hardest to see because no line is wrong.
The typical case is the map with a default value. A use_backend rule that consults a map and gives it a fallback name always has an answer: it designates a backend for any request whatsoever. Placed before the path exceptions, it deprives them of any chance to run. The remedy is to put the narrowest exceptions first, then the broad rules, and to write a rule that always has an answer only at the end.
The lab therefore requires the conjunction of two measurements: legitimate traffic is served, and the path exception is respected. The first half alone proves nothing, because it is satisfied by configuring nothing at all. A path exception only overrides the default table through the order of the use_backend lines, and it is this order that the second half checks.
A simple method to reread a routing table: sort the rules from the most specific to the most general, then ask, for each rule, "which request gets this far?". If the answer is "none", the rule is dead. This reasoning is also the one the lab's record asks you to write.
How do you reject a request without it touching a backend?
A rejection is done with an http-request deny rule, which runs before any rule that routes, or with a rejection backend that has no server and answers the error itself. A rejection that still reaches a backend is not a rejection: it is a route that answers an error after already having consumed a request on that backend.
The most direct gesture is http-request deny deny_status 404 if <condition>. It is evaluated before the use_backend lines, as seen above, so it does not depend on where you write it relative to them. Status 403 (forbidden) says "I know what you are asking for, it is refused"; 404 (not found) says "this does not exist". The lab accepts either: pick one and stick to it.
You have to tell three rejection situations apart. A branch to reject (/api/v2/ in the lab, which is not deployed: routing it to the API would make it answer as if it existed) is handled by a condition on the path. An unknown name is handled by the map's fallback value, which must designate a rejection backend, or by a rejecting default_backend. And the unexpected path under a known name is not rejected: the table gives each name a default route, and that is what applies. Be careful about what you claim: the lab rejects names with no route and the /api/v2/ branch, not every unlisted path.
What happens without default_backend? A request that no rule picks has no backend: HAProxy answers 503 Service Unavailable, and the manual of use_backend (branch 3.4) says so explicitly for a frontend without default_backend. It is a decision failure more than a deliberate rejection, and the lab's test only accepts 403 or 404. Write an explicit rejection rather than counting on the absence of a rule.
How do you route by name with a map?
A map is a file of "key value" pairs that HAProxy loads at startup and queries with the map converter, which takes the routing table out of the configuration. When you have ten names instead of two, ten lines of ACLs and use_backend become ten lines of data, readable and editable without touching the rules.
The file is plain text, one pair per line, with comments starting with #:
# published name -> default backend for that nameblog.pub.lab.test blogwiki.pub.lab.test wikiIt is consulted with map(<file>,<fallback>) applied to a value extracted from the request. The rule use_backend %[req.hdr(host),lower,map(/etc/haproxy/maps/hotes.map,refus)] reads the Host header, turns it to lowercase, looks the result up in the file and gets a backend name. If the key is absent, the converter returns the fallback value, here refus, which must be an existing backend. The %[ ... ] notation means the backend name is computed at request time, and this is the trap: if the computed name matches no backend, HAProxy evaluates no other rule and applies default_backend, without any error when the configuration is validated (haproxy -c). A typo in the map or in the fallback therefore passes validation and is discovered in production.
A disabled or unpublished backend behaves differently: the use_backend rule that picks it is ignored, and the evaluation continues with the following rules.
The map carries the default route of each name; the path exceptions stay rules. It is a healthy split: the data that changes often (the names) lives in the file, the rare logic (the exceptions) in the configuration. A name absent from the table has no route, so the line that consults the map must designate a fallback, and this fallback must reject.
A frequent trap: a decorative map. It is loaded, but an ACL placed before it already decides the name. The map exists and decides nothing, and you notice the day you change it with no effect. The lab measures it: it changes an entry live and requires the routing to follow.
How do you change a map live, and what happens to the change?
The admin socket changes the map in the process's memory, immediately and without a reload; but the reload rereads the file, so the live change disappears if it is not also written to the file. Memory and file are two versions of the same table, and only the file survives.
The commands go through socat on the lab's UNIX socket:
echo 'show map' | sudo socat -t2 stdio /run/haproxy/admin.sockecho 'get map /etc/haproxy/maps/hotes.map blog.pub.lab.test' | sudo socat -t2 stdio /run/haproxy/admin.sockecho 'set map /etc/haproxy/maps/hotes.map blog.pub.lab.test wiki' | sudo socat -t2 stdio /run/haproxy/admin.sockshow map lists the loaded files, get map reads a key, set map changes the value of an existing key. For a new key, you use add map; careful, it does not overwrite: applied to a key already present, it creates a duplicate instead of changing it, which only set map does. To remove a key, del map. These commands produce no file: I do not show an output, because it depends on your table.
The lab's test plays this scenario in two halves. It changes an entry through the socket and checks that the routing follows, which proves the map really decides. Then it reloads HAProxy and checks that the routing has gone back to what the file says. The second half shows why a live change you want to keep is copied into the file: the reload rereads the file, and the original value comes back. In the lab, the change is the test's: it must not end up in the file, otherwise the routing does not go back after the reload. In operation, it is the opposite: a wanted change, which you made in memory, is copied into the file so that the reload keeps it.
The rule of use is therefore this: use the live change to act fast (cut a name, redirect during an incident), then write the same modification in the file, then reload with systemctl reload haproxy. A change that only exists in memory is a debt that is wiped at the next reload, or the next restart.
How do you prove a route is the right one?
A route is proven at the backend, never in the returned response: the response can look normal while coming from the wrong service. A default_backend panier answers 200 with a plausible body for any name. Only the backend knows whether it served the request.
The lab therefore makes each backend count the requests it served, and exposes this counter on the loopback interface (127.0.0.1). The validation reads the counters before and after each request, and requires a delta of 1 at the expected backend and 0 at all the others. For a rejection, it requires a 403 or 404 status and no counter moving: a rejection that touches a backend is not a rejection. Each request carries a unique token in the query string, which HAProxy does not count in path: it makes the log line recognizable without changing the decision.
Three families of cases are tested. The neighboring paths: /panier, /panier/ajouter, /panierarchive and /panier-public for the prefix rule. The unexpected names, drawn at random at each run: a list written in the test would be handled by name, whereas you want every name absent from the table to be rejected. And the case variants: a Host in uppercase must reach the same route as its lowercase version, which lower guarantees.
You can make the same measurement by hand. Read the counter of each backend as the lab makes it available, send a request with a chosen Host header, read the counters again:
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: blog.pub.lab.test' 'http://127.0.0.1/docs/?jeton=essai1'This command gives you the status; the proof is the difference between the counters of your backends before and after. A 200 status alone proves nothing.
What should the decision record contain?
The record explains the order of your decisions and settles the two cases no rule resolves by itself: a prefix that catches a neighbor, and a branch to reject rather than route. It is read by a human, and its reasoning is not graded: the test only checks that it exists, that it has more than one line per case, and that it covers these two subjects.
A good record is a series of short answers to questions your reviewer would ask. In which order are the rules evaluated, and why this order? Which request would make each rule lie if it were moved? Which neighboring path did you test, and what did you measure? Why reject rather than route a branch that is not deployed? A record that lists keywords teaches you to write keywords; a record that tells the decision teaches you to reason.
Practice
The lab gives you an HAProxy in service, four healthy backends, an admin socket and a routing table that is wrong in four ways, each of which is correct on its own. You repair it so that every combination of name and path reaches the backend named in the acceptance criteria, neighboring paths included; you make sure an unexpected name reaches no backend; you show that the map decides and that a live change does not survive the reload; then you write the record. The validation reads the backend counters, not the responses.
Key points
- The first winning
use_backendrule stops the evaluation; thehttp-requestrules run before all theuse_backendlines, wherever they sit in the file. - An ACL combines a fetch (
path,req.hdr(host)), a converter (lower) and a pattern; two ACLs in a condition read as an AND,orgives an OR. - A prefix is not a path:
path_beg /docspicks up/docs-archive. Always compare with neighboring paths, and keep case in mind (-i). - A map with a fallback value always has an answer: placed first, it makes everything after it unreachable.
- A computed
use_backend(%[...]) whose name matches no backend falls back todefault_backend, without error at validation: test every name, fallback included. - A rejection must touch no backend; an unexpected name is rejected by the map's fallback, an undeployed branch by a condition on the path.
- A live map change (
set map) lives in memory; the reload rereads the file, so the change is copied into the file.add mapdoes not overwrite: it creates a duplicate. - A path exception only overrides the table through the order of the
use_backendlines; the lab requires legitimate traffic served and the exception respected. - A route is proven at the backend by a counter that moves by 1 there and by 0 elsewhere, never by the response returned.
- A known name keeps its default route for an unexpected path: the rejection covers names and named branches.
Knowledge check
Check that you have grasped the essentials of this guide. The questions cover only what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Choose the load-balancing algorithm and measure health checks
A new full quiz with random questions
Work again only on the questions you missed