Skip to content

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.

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 optionsemail, 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:

Error: could not render /home/you/caddy/Caddyfile.j2: 'proxy_sufix' is undefined

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.