My Caddyfile

Last updated Tagged /meta=only, /caddy

This my 2026 website is the first time I’ve used Caddy.
It’s quite possible some of these things could be done better even inside Caddy.
And others of them, well, doing them inside Caddy is madness.
But I wanted to see how far I could push it.
It’s been kinda fun.

If you think this looks bad, I assure you that at some stages it was much worse.
As I learned more about Caddy, I was able to implement better solutions.

I invite suggestions about ways of improving it.

chrismorgan.info {
	log {
		output file /var/log/caddy/chrismorgan.info-access.log
	}
	
	header {
		Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
		-Server
	}

	root /srv/http/{host}
	file_server {
		precompressed br
	}
	encode
	templates {
		mime text/html
		between "⦃" "⦄"
	}

	map {cookie.dev} {pages_dir} {C.dev} {
	    1            pages       "checked "
	    default      pages.min   ""
	}

	map {cookie.sans} {C.sans_0} {C.sans_1} {
	    1             ""         "checked "
	    default       "checked " ""
	}

	map {cookie.font} {C.font_0} {C.font_1} {
	    1             ""         "checked "
	    default       "checked " ""
	}

	map {cookie.dark}      {C.dark_auto} {C.dark_0} {C.dark_6} {C.dark_89} {C.dark_94} {C.dark_100} {
	    1                  ""            ""         ""         ""          ""          "checked "
	    0.94               ""            ""         ""         ""          "checked "  ""
	    0.89               ""            ""         ""         "checked "  ""          ""
	    0.06               ""            ""         "checked " ""          ""          ""
	    0                  ""            "checked " ""         ""          ""          ""
	    ~^(0\.[01234]\d*)$ ""            ""         ""         ""          ""          ""
	    ~^(0\.[56789]\d*)$ ""            ""         ""         ""          ""          ""
	    default            "checked "    ""         ""         ""          ""          ""
	}

	map {cookie.sans},{cookie.font} {root_class} {
	                1,1             ` class="with-sans with-font"`
	                1,              ` class=with-sans`
	                 ,1             ` class=with-font`
	    default ""
	}

	map {cookie.dark}        {root_style} {
	    ~^(0\.[01234]\d*|0)$ " style=--d:${1};color-scheme:light"
	    ~^(0\.[56789]\d*|1)$ " style=--d:${1};color-scheme:dark"
	    default              ""
	}

	@has_query_string `{http.request.orig_uri}.contains("?")`
	@has_multiple_slashes `{http.request.orig_uri.path}.contains("//")`
	handle {
		error @has_query_string 414
		error /calendar-availability/ "<p>[explanation about the one page from the Zola era that I deliberately removed, and errors/410.html includes {{placeholder "http.error.message"}} to use this]" 410
		# Displeased I need this. I reckon it’s *bad* that it serves without redirect.
		# As for whether . or .. path segments get served, that’s surprisingly hard to test!
		# Curl and browsers both normalise before sending.
		error @has_multiple_slashes 404
	}

	handle /.prefs {
		@not-post not method POST
		handle @not-post {
			header allow POST
			error @not-post 405
		}

		handle {
			request_body {
				# Form data: longest that should presently be possible is 30 characters: dev=on&sans=0&font=0&dark=auto
				# For some reason this doesn’t do what every single reasonable person would expect and produce a 413 error,
				# but instead just *stops reading* at 100 bytes, so http.request.body is truncated.
				# This seems a terrible idea, so I filed <https://github.com/caddyserver/caddy/issues/7691>.
				max_size 100
			}

			@dev-on vars_regexp {http.request.body} "(?:^|&)dev=on(?:$|&)"
			@dev-off not vars_regexp {http.request.body} "(?:^|&)dev=on(?:$|&)"
			@sans-off vars_regexp {http.request.body} "(?:^|&)sans=0(?:$|&)"
			@sans-on vars_regexp {http.request.body} "(?:^|&)sans=1(?:$|&)"
			@font-off vars_regexp {http.request.body} "(?:^|&)font=0(?:$|&)"
			@font-on vars_regexp {http.request.body} "(?:^|&)font=1(?:$|&)"
			@dark-auto vars_regexp {http.request.body} "(?:^|&)dark=auto(?:$|&)"
			@dark-manual vars_regexp {http.request.body} "(?:^|&)dark=(0|0\.\d+|1)(?:$|&)"

			header @dev-off +set-cookie "dev=; max-age=0"
			header @dev-on +set-cookie "dev=1; max-age=34560000"
			header @sans-off +set-cookie "sans=; max-age=0"
			header @sans-on +set-cookie "sans=1; max-age=34560000"
			header @font-off +set-cookie "font=; max-age=0"
			header @font-on +set-cookie "font=1; max-age=34560000"
			header @dark-auto +set-cookie "dark=; max-age=0"
			header @dark-manual +set-cookie "dark={re.dark-manual.1}; max-age=34560000"

			@has-referrer {
				header referer {http.request.scheme}://{http.request.hostport}/*
				header referer /*
			}
			redir @has-referrer {http.request.header.referer} 303
			redir * / 303
		}
	}

	handle {
		# Has to be a separate handle block or /non-existent? gives qs error page with status 404
		try_files {pages_dir}/{path}.html static/{path} static/{path}/ =404

		# You’d think there’d be a better way to say “.feed files are text/xml; charset=utf-8”, but apparently not—
		# the “proper” solution seems to involve adding stuff to /usr/share/mime/packages/,
		# which is ridiculous.
		# You even have to use ? here because otherwise it’ll apply to 404s too.
		# I’m also baffled that there’s no real documentation of this stuff,
		# and as I have so far found customary,
		# the little discussion there is turns out to be problems in something Caddy was reverse proxying.
		# I’m finding a *lot* of deficiencies in Caddy documentation, it only covering happy path.
		@feed path *.feed
		# Not serving as application/atom+xml because it’s not web-compatible:
		# • Firefox triggers a download (and `content-disposition: inline` doesn’t help);
		# • Chromium displays as plain text (not even as an XML tree);
		# • Epiphany (and thus probably Safari) works.
		# You’d think we’d have sorted this stuff out by now, but no.
		# (The unregistered but conventional application/rss+xml behaves the same.)
		header @feed ?content-type "text/xml; charset=utf-8"

		@dev-on `{cookie.dev} != null`
		@sans-on `{cookie.sans} != null`
		@font-on `{cookie.font} != null`
		@dark-on `{cookie.dark} != null`
		header @dev-on +set-cookie "dev=1; max-age=34560000"
		header @sans-on +set-cookie "sans=1; max-age=34560000"
		header @font-on +set-cookie "font=1; max-age=34560000"
		header @dark-on +set-cookie "dark={cookie.dark}; max-age=34560000"
	}

	handle_errors {
		rewrite /{err.status_code}
		file_server {
			precompressed br
		}
		encode
		try_files errors/{path}.html
		rewrite @has_query_string errors/qs.html
		@410 `{err.status_code} == 410`
		templates @410
	}

	redir /media/images/favicon.png /favicon.ico permanent
	# [omitted: 42 more redirects from the Hyde and earlier Zola eras]
	redir /feed.xml /*.feed permanent
}

No query strings

I banned query strings. Here are the relevant lines of configuration:

@has_query_string `{http.request.orig_uri}.contains("?")`
handle {
	error @has_query_string 414
}
handle_errors {
	rewrite @has_query_string errors/qs.html
}

It doesn’t use {http.request.orig_uri.query},
because that can’t distinguish between no query (like the current URL)
and empty query (as in https://chrismorgan.info/Caddyfile?).

Templating theming

One of the crazier things I’ve done is handling preferences in Caddy with some simple cookies and templating.

Based on cookie values, I set a bunch of placeholders to either "checked " or "" as appropriate,
then insert those into the served HTML.

See the relevant Caddyfile lines above. Then, pages contain stuff like this:

<html lang=en-AU ⦃- placeholder "root_class"placeholder "root_style">
<input type=radio name=sans value=0 placeholder "C.sans_0"id=pref-serif oninput="set_cookie('sans','')">

The C.* mapped placeholder approach is a little odd, but that’s how it developed organically;
I was already using map for {pages_dir} (which has to be done that way),
so adding the dev mode checked attribute as a second output was obvious, and it grew from there.
Had I been accustomed to Go templates beforehand, I might have started with this instead:

<input type=radio name=sans value=0 id=pref-serif oninput="set_cookie('sans','')"
	⦃- if ne (placeholder "http.request.cookie.sans") "1" checkedend>

But even now I’m sticking with the approach I have, out of pure whim. It’s my site, I’ll do what I want.
I plan on shifting it all into a Rust server before very long anyway.

My choice of is a bit of fun, since I was already using {{}} for the build-time templates.

I found I could already type them in easily, because they happen to be in kragen’s .XCompose which I include:
Compose | { produces ⦃, Compose | } produces ⦄.

Body text: Fonts:
Theme:
Explanation of all this
(yes, this works without JavaScript; persists to cookies)