Files
command-center/omp-docs/caddy/caddyfile.md
T
Frank e49537c2ac docs(caddy): where the live Caddyfile is, and what the syntax page's claims cost
The page explained matcher ordering beautifully and never said where the file lives — so the
next agent hunts for a Caddyfile on the host and finds nothing. Measured on Dankclaw
2026-10-21: Caddy is a rootless Quadlet container (`caddy.container`, a *user* unit, so a
root `systemctl is-active caddy` answers `inactive` and means nothing), its config is
`/etc/caddy/Caddyfile` **inside the container**, `/etc/caddy` does not exist on the host, and
`pgrep -a caddy` prints a config path that no host path satisfies. Version is v2.11.7, which
closes the one version-dependent rule on the page: the leading-`/` `rewrite` form needs
2.8.0+, so the `*` disambiguator is legacy in this fleet's config.

Also: the seven caddyserver.com links sat in a mid-page "Sources:" paragraph. OKF wants
external claims under `# Citations`, so they move there and each entry says which claim it
supports. The validate section now points at the container instead of telling you to run the
validator on a file that is not there.

Refs #64
2026-10-21 02:14:39 -07:00

9.1 KiB

type, title, description, resource, tags, verified
type title description resource tags verified
reference Caddyfile Syntax Caddy Caddyfile reference — the syntax that bites (matchers, directive sorting, handle vs route), plus where the fleet's live Caddyfile actually is and how to validate it. https://caddyserver.com/docs/caddyfile
caddy
caddyfile
reverse-proxy
2026-10-21

Caddyfile Syntax

A Caddyfile defines sites. Each site block starts with an address (a host or a port). Directives inside the block control how Caddy serves requests. Matchers classify requests. This page records the syntax that trips people up, grounded in the official docs.

The syntax below is the parts that bite; the sources are listed under # Citations at the end.

Where the live Caddyfile is

Caddy runs as a rootless Quadlet container on Dankclaw (~/.config/containers/systemd/caddy.container, unit caddy.service). It is a user unit: systemctl is-active caddy as root answers inactive and means nothing — ask the user manager (systemctl --user). Its config path lives inside the container, so there is no /etc/caddy on the host: a host-side search for a Caddyfile comes up empty, and pgrep -a caddy prints --config /etc/caddy/Caddyfile for a path that does not exist locally. Everything about it goes through podman exec:

ssh dankclaw podman exec caddy cat /etc/caddy/Caddyfile
ssh dankclaw podman exec caddy caddy version      # v2.11.7, read 2026-10-21
ssh dankclaw systemctl --user restart caddy

The live file's own header says what it fronts (both hosts, franksdank.cloud names, Pi-hole-resolved); its deployment history belongs to the serverAppConfigs vault (Infrastructure/Caddy.md), not to this syntax page.

Directive and matcher syntax

A directive takes an optional matcher as its first argument:

directive [<matcher>] <args...>

The matcher token is one of three forms:

  • * — the wildcard (matches all requests).
  • /path — a path (must start with /).
  • @name — a named matcher.

The matcher follows the directive name. A leading @name before a directive name is parsed as a matcher definition, not a use (see below).

Named matchers

A named matcher is defined outside any directive and reused:

@name {
    <matchers...>
}

If the set has one matcher, a one-liner works:

@post method POST

Use the matcher as the first argument of a directive:

reverse_proxy @post localhost:6001

Pitfall: @name <directive> (the @name before the directive) is parsed as a one-liner definition of @name. Writing it twice re-defines the matcher and Caddy rejects the config with matcher is defined more than once. Always put the matcher after the directive: rewrite @name /to, not @name rewrite /to.

Path matching

Path matching is an exact match by default. Append * for a prefix match:

  • /foo* matches /foo, /foo/, and /foobar.
  • /foo/* matches /foo/ and /foo/anything, but not /foo or /foobar.

A handle inline matcher is a single token. To match several paths, use a named matcher:

@data path /a.json /b.js
handle @data { ... }

Directive sorting (the key rule)

Caddy sorts top-level directives by a fixed canonical order. It does not run them in file order. The relevant order (partial): vars, root, redir, then rewrite, try_files, uri, then basic_auth, then file_server.

Consequence: a top-level rewrite runs before a top-level basic_auth. If the rewrite changes the path, the path-based auth matcher sees the rewritten path, not the original. This silently applies the wrong auth block.

Fix: wrap the ordered directives in handle or route. These blocks run their contents in file order (no re-sort).

handle vs route

  • handle [<matcher>] { ... } — mutually exclusive. Only the first matching handle runs. A handle with no matcher is the catch-all.
  • route [<matcher>] { ... } — runs its contents as one unit, in file order, and the request continues to later top-level directives.

Use handle for mutually exclusive branches (for example, per-path pages). Use route when several directives must all run in order.

rewrite

rewrite [<matcher>] <to>

<to> is the new URI (path or query). If <to> starts with /, Caddy 2.8.0+ parses it directly. Before 2.8.0 a leading / was confused with a path matcher, so a * disambiguator was needed: rewrite * /index.html. The fleet runs v2.11.7, so write the plain form.

try_files <files...> rewrites to the first listed file that exists. It is the clean way to serve a named file for a path prefix:

try_files /admin.html
file_server

file_server and root

root * /srv/site
file_server

root sets the site root. file_server serves files from the root by appending the request path. The * is the wildcard matcher (it disambiguates the path argument).

basic_auth

basic_auth [<matcher>] {
    <username> <bcrypt_hash>
    ...
}

The default hash algorithm is bcrypt. A block may list several users; any of them is accepted. basicauth is deprecated in favor of basic_auth, but both work.

forward_auth

forward_auth is Caddy's auth gate for an external identity provider (OIDC, Authelia, Authentik, Tailscale, ...). It clones the incoming request, sends it to an auth upstream, and only proxies to the app if the upstream answers 2xx.

forward_auth [<matcher>] [<upstreams...>] {
	uri <to>
	copy_headers <fields...>
	trusted_proxies [private_ranges] <ranges...>
}
  • <upstreams...> — the auth gateway address (positional, required).
  • uri <to> — the path the cloned request hits on the gateway (the verification endpoint, e.g. /outpost.goauthentik.io/auth/caddy).
  • copy_headers <fields...> — header fields to copy from the gateway's 2xx response onto the proxied request (e.g. X-Authentik-Username).
  • All reverse_proxy subdirectives pass through, including trusted_proxies.
  • trusted_proxies accepts CIDRs or the keyword private_ranges (all private ranges). It controls which sources Caddy trusts for X-Forwarded-*.

Pitfall — the outpost path: an embedded outpost (Authentik) serves its own login UI at a path (e.g. /outpost.goauthentik.io/*). That path must be proxied to the gateway before forward_auth runs, or the login page is itself gated. Wrap both in a route (file order), outpost first:

example.com {
	route {
		reverse_proxy /outpost.goauthentik.io/* authentik:9000
		forward_auth authentik:9000 {
			uri /outpost.goauthentik.io/auth/caddy
			copy_headers X-Authentik-Username X-Authentik-Groups
			trusted_proxies private_ranges
		}
		reverse_proxy app:8080
	}
}

The reverse_proxy on the outpost path is terminal: a matched request proxies to the gateway and the route stops. Non-outpost paths fall through to forward_auth, then the app.

Worked example: per-path auth with shared data

Serve /admin/ (operator only) and / (family), where both pages fetch the same data files. The browser reuses the page's credential for the data fetches, so the data block must accept both users.

example.com {
	root * /srv/site

	# Operator-only page. Auth is checked on the original path, before any
	# rewrite, so the path matcher is reliable.
	handle /admin* {
		basic_auth {
			frank $2a$14$<operator_hash>
		}
		try_files /admin.html
		file_server
	}

	# Shared data + assets: the family OR the operator.
	@data path /services.json /status.json /app.js /style.css
	handle @data {
		basic_auth {
			dank $2a$14$<family_hash>
			frank $2a$14$<operator_hash>
		}
		file_server
	}

	# Everything else: the family.
	handle {
		basic_auth {
			dank $2a$14$<family_hash>
		}
		try_files /index.html
		file_server
	}
}

handle blocks run in file order and are mutually exclusive (first match wins). Put the specific handle /admin* before the catch-all handle.

Validate before you deploy

Run caddy validate --config <file> --adapter caddyfile before loading a Caddyfile. A placeholder hash (not a real bcrypt hash) fails validation with base64-decoding password. Render the real hashes first, then validate. On the fleet's Caddy the file is inside the container — run the validator there, see Where the live Caddyfile is.

Citations

  • Caddyfile tutorial and concepts — directive form, the three matcher token forms, and the canonical sort order behind the handle/route rule.
  • matchers — exact vs * prefix path matching, named matchers, matcher is defined more than once.
  • rewrite, handle and reverse_proxy — the 2.8.0 leading-/ change, handle vs route semantics, trusted_proxies.
  • forward_auth — the outpost-before-gate ordering and copy_headers.