Lesson 1
Caddy sorts your routes before it runs them
A Caddyfile is not what Caddy runs. The adapter reads it and emits a JSON configuration, and along
the way it reorders the handle blocks. At request time Caddy walks the
sorted list and takes the first route that matches. Knowing that order is knowing the answer.
The file you write is not the config that runs
caddy adapt prints the generated JSON, so nothing has to be guessed. Take a Caddyfile
with five blocks written in no particular order:
handle /a* { ... }
handle /abcdef* { ... }
handle /abc* { ... }
handle /x/y/z* { ... }
handle /x* { ... }
handle { ... }
The route order in the JSON that caddy adapt produces:
0 path /abcdef*
1 path /x/y/z*
2 path /abc*
3 path /a*
4 path /x*
5 (no matcher)
The order is nothing like the file. /abcdef* was written second and is evaluated first;
/a* was written first and drops to fourth.
The sorting rule, from 24 measurements
The rule below is not copied from documentation. It is what fits 24 Caddyfiles put through
caddy adapt, most of them written as pairs in opposite orders so that the effect of
sorting can be separated from the effect of the order you typed.
* removed
The more specific route is evaluated first. On a tie, the pattern without the *
goes first. On a remaining tie, the paths are compared as strings and the smaller one goes first —
not the one written earlier.
Four pairs isolate one clause of the rule each. Each pair was run twice, with the two blocks swapped:
| The two blocks | Specificity | Order after adapt | What it shows |
|---|---|---|---|
/abc and /abcd* |
4 and 5 | /abcd* first |
longer wins, even against an exact match |
/abc and /xy* |
4 and 3 | /abc first |
the * is not counted in the length |
/abcd and /abcd* |
5 and 5 | /abcd first |
on a tie the pattern without * wins |
/a* and /x* |
2 and 2 | /a* first |
a full tie is broken by string comparison, not by file order |
handle /x* above handle /a* and run caddy adapt:
/a* still comes first. The same holds for /z* against /b*
and for /bb against /az. When specificity ties, the path that sorts first
as a string is evaluated first, and the order in the file plays no part at all.
A non-path matcher stops the reordering
This is the second hard-to-guess part. A route carrying a different matcher — path_regexp,
for instance — stays exactly where it is, and the reordering cannot cross it:
handle /a* { ... }
@re path_regexp \.gif$
handle @re { ... }
handle /abcdef* { ... }
If sorting were global, /abcdef* (specificity 7) would have to jump above
/a* (specificity 2). What caddy adapt prints is:
0 path /a*
1 path_regexp \.gif$
2 path /abcdef*
3 (no matcher)
Unchanged. The description that fits every measurement: split the list into contiguous runs
of routes carrying only a path matcher, sort inside each run, and leave everything else
in place, ending the run there. A handle block with no pattern behaves the same way —
it matches every request and is evaluated exactly where you wrote it, so putting one in the middle
of a file kills everything below it.
/img/a.gif:
handle /img/* @gif path_regexp \.gif$
@gif path_regexp \.gif$ handle /img/*
handle @gif handle @gif
curl /img/a.gif curl /img/a.gif
→ "prefix /img/*" → "regexp .gif$"
Two blocks with the same matcher merge silently
Write two handle /ab* blocks in the same site block. caddy adapt does not
emit two routes; it emits one, with the two handlers chained inside it:
"routes": [ { "group": "group2",
"match": [ { "path": ["/ab*"] } ],
"handle": [ { "handler": "subroute", … "body": "P" },
{ "handler": "subroute", … "body": "Q" } ] } ]
curl /ab/x returns P: the block written first wins and the second becomes
dead code. Caddy starts normally and says nothing. The merge does not require the blocks to be
adjacent either — writing /ab*, /zz*, /ab* gives the same result.
nginx handles the same situation the other way round. With location /ab/ written twice,
nginx -t says:
nginx: [emerg] duplicate location "/ab/" in /etc/nginx/conf.d/default.conf:4
nginx: configuration file /etc/nginx/nginx.conf test failed
Against nginx
Run the swap-two-lines experiment on nginx 1.27.5 with location /img/ and
location ~ \.gif$, in both orders: both times /img/a.gif lands in the regex
block. nginx does not care about the written order between a prefix block and a regex block, because
it runs five fixed steps and the regex step always precedes the fall-back-to-prefix step.
| nginx 1.27.5 | Caddy v2.11.4 | |
|---|---|---|
| Mechanism | five fixed steps over the list as written | sort the list at config-load time, then take the first matching route |
| Does the written order matter | only among regex blocks | yes, whenever a route is fenced in by a non-path matcher |
| Two blocks with the same pattern | refuses to start, duplicate location | merges silently, the first one runs |
| Can you see the real order | no command prints it | caddy adapt prints the list that will run |
| Prefix that blocks regexes | yes, written ^~ | no such thing; position in the file is the only lever |
The lab
Edit the Caddyfile and the path as you like. The algorithm running in your browser is a
reimplementation of the rule above, checked against 24 caddy adapt results
and 14 curl results from a real Caddy — all of them agree.
The left column is the line number in the file, so you can see which route moved where and which
blocks were merged.
The handle blocks
Request path to test
The order after Caddy has sorted them
Check yourself
You write handle /api/* on the last line and handle /api/v1/* on the first. Which block serves /api/v1/x?
/api/v1/*. Its specificity is 8 against 5 for /api/*, so it is evaluated first no matter where it is written — provided no route with a different matcher sits between the two lines.
What happens if you put a bare handle in the middle of the file?
Every route written below it becomes dead code: a block with no pattern matches every request and is evaluated exactly where you wrote it, so nothing gets past it. Try it in the lab — the list will show the routes below it are never reached.
How do you make a path_regexp always beat a long prefix?
Write the regexp block above the prefix block. There is no equivalent of nginx's ^~; in Caddy the position in the file is that lever, because a regexp route does not take part in the reordering.
What does Caddy report if you write handle /ab* twice?
Nothing. The two blocks merge into one route, the first one runs, and the second never does. This is where Caddy and nginx part company: nginx refuses to start with duplicate location. The lab names the line that was merged into which.