Caddyfile templates¶
By default labops renders your Caddyfile from a built-in Jinja template,
ansible/files/proxy/Caddyfile.j2. Routes come from every web_services entry
in the config that has a proxy_name; entries without one are tracked but not
routed.
You can supply your own template instead — most often by extending the built-in one and overriding a block or two.
labops owns the config file only. The Caddy instance — its image, the DNS provider plugin, the environment it runs in — is managed outside labops.
Use labops proxy render to print the result without deploying anything, and
labops proxy render -o ./Caddyfile to save a copy.
Using your own template¶
Point settings.proxy.template at a Jinja file. Relative paths resolve against
the config file's directory; absolute paths are used as-is.
settings:
proxy:
proxy_suffix: .example.com
template: ./caddy/Caddyfile.j2
default_access: local
access_lists:
local:
accept:
- 10.0.10.0/24
The path is validated when the config is loaded, so a typo fails at
labops validate rather than part-way through a deploy:
╭───────────────── YAML Validation Failed ─────────────────╮
│ • settings.proxy.template: Path does not point to a file │
╰──────────────────────────────────────────────────────────╯
There are two ways to write one.
Extending the built-in template (recommended)¶
Most customizations add something rather than replace everything. Extend the built-in template and override just the blocks you care about:
{% extends "builtin/Caddyfile.j2" %}
{% block global_options %}
{
email me@example.com
}
{% endblock %}
Everything you don't override — the banner, the wildcard site block, the TLS directives, the generated routes, the 404 fallback — still comes from the built-in template and keeps working as labops evolves.
Always use the builtin/ prefix to extend. The loader searches your
template's own directory first, so if your file is also named Caddyfile.j2, a
bare {% extends "Caddyfile.j2" %} would resolve to itself and recurse. The
prefixed name is unambiguous no matter what you call your file.
Available blocks¶
In render order:
| Block | Default content | Typical use |
|---|---|---|
header |
The "Managed by labops" banner and the required-plugin note | Add your own banner or a link to your docs |
global_options |
empty | Caddy global options — email, admin off, servers { … } |
log |
Access log to stdout | Log to a file, change the format, drop logging |
routes |
The generated handle block per web_service |
Rarely — see the caveat below |
extra |
empty | Extra directives inside the site block, applied to every service |
fallback |
respond "Unknown service" 404 |
A different catch-all: a redirect, a branded page, abort |
global_options sits before the site block, because Caddy requires global
options to precede every site.
Examples¶
Shared response headers for every proxied service:
{% extends "builtin/Caddyfile.j2" %}
{% block extra %}
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options nosniff
-Server
}
{% endblock %}
Redirect unknown subdomains instead of returning a 404:
{% extends "builtin/Caddyfile.j2" %}
{% block fallback %}
handle {
redir https://www.example.com{uri} permanent
}
{% endblock %}
Log to a file, and set an ACME contact address:
{% extends "builtin/Caddyfile.j2" %}
{% block global_options %}
{
email admin@example.com
}
{% endblock %}
{% block log %}
log {
output file /var/log/caddy/access.log {
roll_size 10MiB
roll_keep 5
}
}
{% endblock %}
Replacing the built-in template¶
A template that doesn't extend anything replaces the built-in one outright — its output is the Caddyfile. You get the same variables; everything else is yours.
# Generated by labops.
*{{ proxy_suffix }} {
tls {
{% for line in tls_lines %}
{{ line }}
{% endfor %}
}
{% for r in routes %}
@{{ r.name }} host {{ r.host }}
handle @{{ r.name }} {
reverse_proxy {{ r.target }}
}
{% endfor %}
}
Template variables¶
These are what labops passes in. Treat additions as additive and removals as
breaking; the authoritative definition lives in
src/proxy/render.py:_render_context.
| Variable | Type | Description |
|---|---|---|
proxy_suffix |
str |
e.g. .example.com. The site address is * + this. |
tls_lines |
list[str] or None |
Directives for the tls block, e.g. dns cloudflare {env.CF_API_TOKEN}. None when TLS is off — no tls: block, or provider: none — in which case the site should be plain http://. |
tls_plugin |
str or None |
The caddy-dns module the Caddy image must be built with, e.g. github.com/caddy-dns/cloudflare. None when TLS is off. labops cannot check that the image carries it, so the built-in template records it as a comment. |
trusted_proxies |
list[str] or None |
CIDRs of reverse proxies in front of Caddy. None when unset. |
ip_matcher |
str |
"client_ip" when trusted_proxies is set, "remote_ip" otherwise. Use this in matchers so the right directive is selected automatically. |
routes |
list[dict] |
One entry per routed web_service. Fields below. |
Each entry in routes:
| Field | Type | Description |
|---|---|---|
name |
str |
Matcher label — the validated proxy_name. Safe to use as @{{ r.name }}. |
host |
str |
Full hostname: name + proxy_suffix. |
target |
str |
Upstream ip:port. Carries an https:// scheme only when the upstream speaks HTTPS. |
insecure |
bool |
True when the upstream's TLS certificate must not be verified (a self-signed cert, as on Proxmox :8006). Pair with transport http { tls_insecure_skip_verify }. |
accept |
list[str] or None |
Allowed CIDRs, resolved from the service's access lists (or settings.proxy.default_access). None means no restriction. |
deny |
list[str] or None |
Blocked CIDRs. None when the resolved list defines none. |
A referenced variable that doesn't exist is an error, not an empty string —
a typo like {{ proxy_sufix }} fails the render instead of quietly producing a
broken Caddyfile that only falls over on the target:
Syntax errors are reported the same way, naming the file.
Caveats¶
Overriding routes takes on the access lists and the matcher choice. The generated handle blocks
are what apply accept / deny via {{ ip_matcher }}. If you replace that block, settings.proxy.access_lists
stops having any effect unless your template implements the matchers itself — and
you must use {{ ip_matcher }} (not a hard-coded remote_ip) to respect trusted_proxies. For
"add a directive to every service", use extra instead.
Directive order is Caddy's, not the file's. Caddy sorts directives inside a
block by its own directive order,
not by the order you write them. Two responds keep their relative order, and
respond sorts ahead of reverse_proxy, which is what makes the built-in
access-list 403s work. If you write something order-sensitive, wrap it in
route { … } to make the order explicit.
IPv4-only accept lists deny IPv6 clients. A list of 0.0.0.0/0 does not
match an IPv6 client, so a service you meant to be public returns 403 over IPv6.
Add ::/0 alongside it.
Test before you deploy. labops proxy render shows exactly what would be
written. There is currently no caddy validate step in the deploy playbook, so
a template that renders successfully but that Caddy rejects will land on the
target and fail at reload.