Caddy
Caddy reverse-proxy and service management for a Linux host with systemd. WHAT IT DOES
Manages a Caddy reverse proxy on a Linux host with systemd — install the binary, run it as a systemd user service, and drive routes, static file serving, and TLS live through Caddy's admin API. Prefer it over hand-editing a Caddyfile or a Go Caddy plugin when you want routes added and removed without a restart and managed from swamp; no Go toolchain is needed. Adding a route implies the DNS and TLS it needs: every route hostname under baseDomain automatically gains a TLS subject and an A record, so a single ensureDnsProxy makes a host reachable without hand-listing subjects or records. When a DNS provider is configured, the DNS-01 challenge covers EVERY name the config serves — route hostnames and the status page's hostnames (the baseDomain and declared record names), not just the subjects a model listed — so no served name falls back to HTTP-01 (which ACME cannot validate for a name that resolves to a private address). It can also publish a settings/document directory over HTTP(S) with file_server, issue wildcard certificates through DNS-challenge providers (including multi-field ones like Gandi, Namecheap, and DreamHost), manage static A/AAAA/CNAME records through any dns.providers module, and serve a default health contract (/, /teapot) on each configured hostname.
It runs unprivileged (plain HTTP on high ports) or as a Let's Encrypt TLS proxy on 80/443, and routes persist across restarts via autosave + --resume.
INSTALL
swamp extension pull @svendowideit/caddyDEPENDENCIES
@svendowideit/sudo — pulled automatically. Granting CAP_NET_BIND_SERVICE so
the unprivileged user service can bind 80/443 (the setcap that caddy applies
after it writes a binary) is delegated to that extension's elevation ladder,
so caddy never calls sudo itself. Otherwise it needs only the swamp CLI, a
Linux host with systemd user services, and network access to caddyserver.com
to download the binary.
RUN
# Create the model, choosing your public domain and ACME contact address.
swamp model create @svendowideit/caddy my-caddy \
--global-arg baseDomain=example.com \
--global-arg letsEncryptEmail=admin@example.com
# Download the Caddy binary to caddyBinPath (run once per host).
swamp model method run my-caddy installCaddy
# Write and start the systemd user service (run once, or after an upgrade).
swamp model method run my-caddy createService
swamp model method run my-caddy startService
# Do the whole setup idempotently in one command: install the binary, grant
# the capability as its own placed @svendowideit/sudo step (operation
# setcap — never runModel, which cannot run on a worker), create/start the
# service, configure TLS (provider inferred from plugins), apply DNS
# records, and install the status page. A single caddy model is set up
# automatically; with several it lists them.
swamp workflow run @svendowideit/caddy-setup
# Upgrade the Caddy this repo manages after updating the extension. With a
# single caddy model it upgrades automatically (no name to remember); with
# several it lists them and stops without changing anything, printing the
# command for each. It prints a gap report (everything missing to serve:
# capability for 80/443, baseDomain, ACME email, TLS, DNS records) with the
# exact fix command for each, then pauses for approval before
# rebuilding/restarting.
swamp workflow run @svendowideit/caddy-upgrade
swamp workflow run @svendowideit/caddy-upgrade --input model=my-caddy
# Point a hostname at a backend, creating the route if it is missing.
swamp model method run my-caddy ensureDnsProxy \
--input hostname=foo.example.com --input upstream=127.0.0.1:8080
# Publish a directory of documents over HTTPS at a hostname.
swamp model method run my-caddy serveSettings \
--input hostname=settings.example.com --input root=/srv/settings/current
# Grant CAP_NET_BIND_SERVICE so the unprivileged user service can bind
# 80/443. A caddy method never elevates in-process (context.runModel is
# unavailable under swamp worker remote execution), so the grant is its
# own step driving the @svendowideit/sudo model — exactly what the
# caddy-setup/caddy-upgrade workflows do. Path must be absolute.
swamp model @svendowideit/sudo method run run sudo-caddy \
--global-arg 'allowedOperations:json=["setcap"]' \
--input operation=setcap \
--input 'args:json={"path":"/home/you/.local/bin/caddy","capabilities":"cap_net_bind_service=+ep"}'
# Install the default status page that answers on http://localhost and
# https://localhost (done automatically by installCaddy/startService).
swamp model method run my-caddy configureStatusPage
# Inspect EVERYTHING in one command: it finds all caddy models, groups
# them by the Caddy they manage, merges what each wants, reads each live
# config, and prints a table with drift. No model names needed; the
# trailing name is a scratch instance it auto-creates. Read-only.
swamp model method run @svendowideit/caddy audit caddy-status
swamp data get caddy-status audit --json | jq '.content.cadies[] | {target, models, desiredRoutes, onlyDesired, onlyActual, inSync}'
# To diff the whole desired config against the live config for one Caddy:
swamp model method run @svendowideit/caddy plan caddy-status
swamp data get caddy-status plan --json | jq '.content | {desiredConfig, actualConfig}'
# --- Wildcard certificate via DNS-01 (worked example: Gandi) ---
# A wildcard cert (*.example.com) cannot use the HTTP-01 challenge: Caddy
# must write a _acme-challenge TXT record through the provider's API. That
# needs TWO things set up BEFORE configureTls, or it silently fails:
# (a) the provider driver compiled into the binary, via `plugins`; and
# (b) its token in the service's environment, via `environmentFile`.
#
# 1. Create the model with the Gandi driver and an env file for the token.
swamp model create @svendowideit/caddy my-caddy \
--global-arg baseDomain=example.com \
--global-arg letsEncryptEmail=admin@example.com \
--global-arg 'plugins:json=["github.com/caddy-dns/gandi"]' \
--global-arg environmentFile=~/.config/caddy/dns.env
#
# 2. Install the binary, put your Gandi Personal Access Token in the env
# file (field name for Gandi is bearer_token), then create the service
# so the unit loads it. Gandi's old API key is NOT supported.
swamp model method run my-caddy installCaddy
printf 'GANDI_BEARER_TOKEN=YOUR_GANDI_PAT\n' > ~/.config/caddy/dns.env
chmod 600 ~/.config/caddy/dns.env
swamp model method run my-caddy createService # renders EnvironmentFile=
swamp model method run my-caddy startService
#
# 3. Configure the ACME DNS-01 challenge: map the driver's credential
# field to the env var name, and list the wildcard + apex subjects.
swamp model method run my-caddy configureTls \
--input dnsProvider=gandi \
--input 'providerConfig:json={"bearer_token":"GANDI_BEARER_TOKEN"}' \
--input 'subjects:json=["*.example.com","example.com"]'
#
# 4. Verify: point a route under the wildcard at a backend and request it;
# a valid cert means the DNS-01 challenge succeeded.
swamp model method run my-caddy ensureDnsProxy \
--input hostname=app.example.com --input upstream=127.0.0.1:8080
#
# Serve an app that lives under a sub-path (e.g. /dashboard) at the
# hostname root: only "/" is rewritten, so /dashboard/assets/... still
# reaches the app.
swamp model method run my-caddy ensureDnsProxy \
--input hostname=dashboard.example.com \
--input upstream=127.0.0.1:9090 --input rootPath=/dashboard
#
# See the README's "Wildcard certificates with a DNS provider (step by
# step)" section for the full walkthrough and troubleshooting.
# --- Static A/AAAA/CNAME records (same provider as TLS) ---
# Caddy owns the (name, type) RRset: each reconcile replaces it with
# exactly the declared values. The provider block reuses configureTls's
# provider + credential, so one token in the env file covers both DNS-01
# and record-writing. Records are declared as a global arg.
swamp model create @svendowideit/caddy dns-caddy \
--global-arg baseDomain=example.com \
--global-arg 'plugins:json=["github.com/caddy-dns/gandi"]' \
--global-arg environmentFile=~/.config/caddy/dns.env \
--global-arg 'dnsRecords:json=[{"name":"otel.example.com","type":"A","value":["192.0.2.5"],"zone":"example.com"}]'
swamp model method run dns-caddy configureTls \
--input dnsProvider=gandi \
--input 'providerConfig:json={"bearer_token":"GANDI_TOKEN"}'
swamp model method run dns-caddy installCaddy # compiles caddy-host-dns in
# Apply the records as its own step — re-run after any dnsRecords edit:
swamp model method run dns-caddy applyDnsRecords
#
# zone is required unless the provider implements libdns.ZoneLister
# (Gandi does not); set it to the registered zone, e.g. fi.gy.
# To delete a record, declare it under dnsRemovals (dropping it from
# dnsRecords leaves it in DNS), then run applyDnsRecords.
# --- Health contract (default on) ---
# On each status/default hostname Caddy answers '/' -> 200 (status page),
# '/teapot' -> 418, and any other path -> 404. Verify after a reconcile:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/ # 200
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/teapot # 418
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/nope # 404
# Set healthRoutes=false to omit it.MANAGING THE MODEL
Find the model(s): swamp model list --json | jq -r '.results[] | select(.type=="@svendowideit/caddy") | .name' Inspect one: swamp model get --json | jq '.globalArguments' A model is "active" because YOU name it on the command; its serviceName (default "caddy") is the systemd unit and adminApiAddr (localhost:2019) is the running Caddy's admin API. The status page names the model that last configured it.
Change it: swamp model create fails on an existing name by design. Edit instead, non-interactively, by piping the full definition to stdin: swamp model get --json | jq '{name,version,tags,globalArguments} | .globalArguments.baseDomain="new.example.com"' | swamp model edit --json Global args only take effect on the NEXT method run: re-run what consumes the arg (plugins -> installCaddy; listenAddrs/autoHttps/environmentFile -> createService + restartService; baseDomain -> configureTls etc.).
Multiple models: fully supported — several models that manage the SAME Caddy
are MERGED, not fought over. Each mutating method records what that model
wants (a desired resource), then reconcile unions every model whose
(target, serviceName) matches into one valid config and applies it. Routes
union; listenAddrs and TLS subjects union; a real conflict (two different
upstreams for the same hostname, or differing TLS email/provider) is
reported naming the models rather than silently resolved. swamp-managed
routes are tagged with a Caddy @id, so hand-added routes are preserved.
A model only manages a SEPARATE Caddy when it has a different serviceName
(e.g. a second instance on other ports) — set target= for another host.
target defaults to this machine's hostname; reconcile defaults to true.
CONFIGURE
All arguments are optional and set as global args when creating/editing the model:
baseDomain Base domain for derived hostnames
letsEncryptEmail ACME email (enables automatic HTTPS)
autoHttps on|off|disable_redirects|disable_certs|...
listenAddrs HTTP listen addresses (default [":443",":80"])
plugins Extra Caddy modules to compile in (caddy-host-dns and
caddy-teapot-module are always added)
dnsRecords Static records to own, e.g.
[{"name":"otel.example.com","type":"A","value":["192.0.2.5"],"zone":"example.com"}]
(type A|AAAA|CNAME; optional ttl). zone is required
unless the provider implements libdns.ZoneLister
(Gandi does not). Needs tls's dnsProvider set; apply
with `applyDnsRecords`.
dnsRemovals Records to delete each reconcile, e.g.
[{"name":"old.example.com","type":"A","zone":"example.com"}]
(empty value removes the whole name+type RRset);
apply with `applyDnsRecords`
dnsTtl Default TTL for dnsRecords (default 5m) so changes
propagate quickly; a record's own ttl overrides it
dnsSoa Manage the zone SOA through caddy-host-dns (the
dns_records app's soa block; needs a 2026-10-18+ binary),
e.g. {"mname":"ns1.gandi.net.","rname":"hostmaster.gandi.net.",
"refresh":"10800s","retry":"3600s","expire":"604800s",
"minimum":"300s"}. `minimum` is the RFC 2308 negative-
caching TTL: lower it so a new/changed name is usable
sooner. Gandi does not return the SOA, so set mname/rname.
Empty leaves the zone SOA untouched
healthRoutes true (default): on status/default hostnames serve
'/' -> 200, '/teapot' -> 418, other -> 404
adminApiAddr Admin API address (or unix//path)
vaultName Vault used by storeConfig
environmentFile systemd EnvironmentFile for DNS-provider tokens
(e.g. ~/.config/caddy/dns.env); createService renders
EnvironmentFile=-<path> so wildcard/DNS-01 works without
hand-editing the unit
target Host this model's Caddy runs on (default: this machine).
With serviceName it identifies the Caddy; models sharing
(target, serviceName) merge. Set it for a remote/worker
host or to keep a second instance separate.
reconcile true (default) merges all models for this target; false
writes only this model's configPorts 80/443 need CAP_NET_BIND_SERVICE on the binary. No caddy method elevates in-process: context.runModel is unavailable under swamp worker remote execution, so the grant is its own placed workflow step driving the @svendowideit/sudo model — the caddy-setup and caddy-upgrade workflows do this (installCaddy takes setCapabilities=false; setCapabilities only checks and reports). A config is never rejected for a privileged-port bind failure: until the capability is granted, reconcile serves what is wanted on unprivileged ports (:80 -> :8080, :443 -> :8443, plain HTTP) so Caddy always loads it, and reapplies the desired settings once the capability arrives.
A status page is wanted by default (opt out with configureStatusPage disabled=true): it answers on localhost and, once the port/TLS layer is up, on each configured hostname, listing the install (version, domain, ACME email, privileged-port capability, and the compiled-in plugins) and linking to the admin API and routes; reconfigure it with configureStatusPage.
Override per call with --input, e.g. addProxyService --input serviceName=app
--input upstream=127.0.0.1:8080, or configureTls --input
dnsProvider=cloudflare --input 'subjects:json=["*.example.com"]'. For
multi-field DNS providers pass a providerConfig map of credential field to
environment variable, e.g. Gandi '{"bearer_token":"GANDI_TOKEN"}',
Namecheap '{"api_key":"NC_KEY","user":"NC_USER"}', DreamHost
'{"api_key":"DH_KEY"}' — but a DNS provider only works if its driver is in
plugins AND its token is in the service environment (environmentFile);
see the RUN wildcard example and the README's step-by-step section.
UPGRADE
Update the extension (swamp extension update), then run the upgrade
workflow. It reads the caddy model definitions on disk — you never need to
know a name: a single model is upgraded automatically, and when there are
several it lists them, changes nothing, and prints the command for each:
swamp workflow run @svendowideit/caddy-upgradeBefore changing anything it prints a gap report: every setting that stops Caddy serving (capability to bind 80/443, baseDomain, ACME email, TLS, DNS provider/records, a stale binary) with the exact command to fix each. It pauses at one approval gate, then rebuilds the binary if stale, re-grants the capability, regenerates the unit, and restarts; and re-runs the gap report so you see what is left. Idempotent:
swamp workflow run @svendowideit/caddy-upgrade --input model=my-caddy Approve the gate with swamp workflow approve @svendowideit/caddy-upgrade confirm then swamp workflow resume @svendowideit/caddy-upgrade. Secrets
(a DNS token) are never collected over a signal — the gap report names the
model's environmentFile to write instead.
WHAT IT INSTALLS
A Caddy binary at caddyBinPath (default ~/.local/bin/caddy) — optionally with CAP_NET_BIND_SERVICE set so it can bind 80/443 unprivileged, and always including github.com/SvenDowideit/caddy-host-dns and github.com/hairyhenderson/caddy-teapot-module — a systemd user service (default caddy.service), a Caddyfile under ~/.config/caddy, and network-facing reverse-proxy and file_server routes — plus a default status page and health contract (/, /teapot) — via Caddy's admin API. It installs no cron triggers or webhooks.
| Argument | Type | Description |
|---|---|---|
| plugins? | array | Override the module packages to include (defaults to global plugins) |
| force? | boolean | Reinstall even if the binary already exists |
| setCapabilities? | boolean | After install, check whether the binary has CAP_NET_BIND_SERVICE and, if not, report the placed step that grants it (the @svendowideit/sudo model's `run` method, operation setcap). It never elevates in-process: context.runModel is unavailable under swamp worker remote execution, so the grant must be its own workflow step — see the @svendowideit/caddy-setup and @svendowideit/caddy-upgrade workflows. Defaults to false. |
| configureStatusPage? | boolean | After install, install a default status page answering on localhost, summarising the install and linking to the admin API. Requires the admin API to be reachable (a running service); skips with a log otherwise. |
| Argument | Type | Description |
|---|---|---|
| capabilities? | array | Linux capabilities to grant (default ['cap_net_bind_service=+ep']) |
| binPath? | string | Override the Caddy binary path (defaults to global caddyBinPath) |
| quiet? | boolean | Suppress the log message describing the command |
| Argument | Type | Description |
|---|---|---|
| serviceName? | string | Override the systemd service name (defaults to global serviceName) |
| Argument | Type | Description |
|---|---|---|
| serviceName? | string | Override the systemd service name (defaults to global serviceName) |
| Argument | Type | Description |
|---|---|---|
| serviceName | string | Service name; hostname is derived as <service-name>.<base-domain> |
| upstream | string | Backend host:port to proxy to (e.g. 127.0.0.1:8080 or https://127.0.0.1:8443) |
| baseDomain? | string | Override the base domain (defaults to global baseDomain) |
| Argument | Type | Description |
|---|---|---|
| serviceName | string | Service name whose route and systemd service to remove |
| baseDomain? | string | Override the base domain (defaults to global baseDomain) |
| Argument | Type | Description |
|---|---|---|
| serviceName | string | systemd user service name to act on |
| Argument | Type | Description |
|---|---|---|
| serviceName | string | systemd user service name to act on |
| Argument | Type | Description |
|---|---|---|
| serviceName | string | systemd user service name to act on |
| Argument | Type | Description |
|---|---|---|
| baseDomain? | string | Base domain to store in the Vault (e.g. example.com) |
| letsEncryptEmail? | string | Let's Encrypt / ACME email to store in the Vault |
| adminApiToken? | string | Admin API token to store in the Vault |
| vaultName? | string | Override the Vault name (defaults to global vaultName) |
| Argument | Type | Description |
|---|---|---|
| email? | string | ACME email (defaults to global letsEncryptEmail) |
| dnsProvider? | string | DNS provider name for DNS-challenge issuance (e.g. cloudflare) |
| dnsEnvVar? | string | Environment variable holding a single-field DNS provider credential (default CADDY_DNS_API_TOKEN). Ignored when providerConfig is set. |
| providerConfig? | record | Provider credential field -> environment variable, for multi-field providers. e.g. \ |
| subjects? | array | Subjects for the TLS policy (e.g. *.example.com, example.com) |
| issuer? | string | Issuer module: "acme" (default, Let\ |
| keyType? | string | Private-key type for issued certificates: "rsa2048", "p256" (default), "p384", "p521" or "ed25519". Set "rsa2048" for a device that only accepts RSA keys. |
| resolvers? | array | Recursive DNS servers for the ACME DNS-01 propagation check (e.g. ["1.1.1.1:53"]). Empty uses the model\ |
| skipPropagationCheck? | boolean | Skip certmagic's DNS-01 propagation self-check (propagation_timeout=-1) and rely on propagationDelay plus ACME's own validation. Defaults to the model's dnsSkipPropagationCheck (true). |
| propagationDelay? | string | How long to wait after writing the challenge record before use (e.g. 30s). Defaults to the model's dnsPropagationDelay. |
| Argument | Type | Description |
|---|---|---|
| baseDomain? | string | Override the base domain (defaults to global baseDomain) |
| prefix? | string | systemd unit name prefix for swamp serve instances |
| port? | number | Port swamp serve instances listen on |
| Argument | Type | Description |
|---|---|---|
| plugins? | array | Module packages to include (defaults to global plugins) |
| confirm? | string | Set to 'upgrade' to confirm replacing the binary and restarting the service |
| Argument | Type | Description |
|---|---|---|
| serviceName? | string | Override the systemd service name (defaults to global serviceName) |
| Argument | Type | Description |
|---|---|---|
| serviceName? | string | Override the systemd service name (defaults to global serviceName) |
| Argument | Type | Description |
|---|---|---|
| serviceName? | string | Override the systemd service name (defaults to global serviceName) |
| Argument | Type | Description |
|---|---|---|
| hostname | string | Full hostname to proxy (e.g. foo.example.com) |
| upstream | string | Backend host:port to proxy to (e.g. 127.0.0.1:8080 or https://192.0.2.10:8443) |
| rootPath? | string | Serve an app that lives under this sub-path (e.g. '/dashboard') at the |
| Argument | Type | Description |
|---|---|---|
| hostname | string | Full hostname to serve the settings documents on (e.g. settings.otel.fi.gy) |
| root | string | Directory holding the rendered settings documents (e.g. ~/.local/share/otel-settings/current) |
| browse? | boolean | Enable directory browsing (off by default; individual documents remain fetchable) |
| Argument | Type | Description |
|---|---|---|
| title? | string | Heading shown on the status page |
| extraLinks? | array | Additional links to show on the status page |
| disabled? | boolean | Set true to remove the status page route instead of adding it |
Resources
Set up the Caddy this repo manages, idempotently, in one command. Run it after `swamp extension pull @svendowideit/caddy` and `swamp model create @svendowideit/caddy <name> --global-arg baseDomain=… --global-arg letsEncryptEmail=…`. It reads the @svendowideit/caddy model this repo defines (so a never-run model is found and the auto-created scratch instance never is); if exactly one exists it is set up automatically, otherwise pass --input model=<name>. In order it: installs the binary (grantin
Upgrade the Caddy managed by @svendowideit/caddy — with no model name to remember — and tell you exactly what is still missing. Run it after updating the extension (`swamp extension update`). Caddy global arguments only take effect on the next method run, and a replaced binary drops its capability, so a version bump alone is not enough. It first discovers every @svendowideit/caddy model this repo defines (read from the model definitions on disk, so a never-run model is found and the auto-creat
Human-readable summary of which models manage each Caddy and whether the running config matches
- Has README or module doc2/2earned
- README has a code example1/1earned
- README is substantive1/1earned
- Most symbols documented1/1earned
- No slow types (deprecated)1/1earned
- Dependencies pass trust audit2/2earned
- Has description1/1earned
- Platform support declared (or universal)2/2earned
- License2/2earned
- Verified public repository2/2earned
Spawns a subprocess with Deno.Command().
- models/caddy.ts:3453 — no reason given
- models/caddy.ts:5025 — no reason given
- models/caddy.ts:5066 — no reason given
A source line over 500 characters, which can hide embedded data.
- models/caddy.ts:5346 — no reason given
- models/caddy.ts:5359 — no reason given
- models/caddy.ts:5372 — no reason given
- models/caddy.ts:5379 — no reason given
- models/caddy.ts:5386 — no reason given
- models/caddy.ts:5393 — no reason given
- models/caddy.ts:5400 — no reason given
- models/caddy.ts:5407 — no reason given
- models/caddy.ts:5438 — no reason given
- models/caddy.ts:5451 — no reason given
- models/caddy.ts:5464 — no reason given
- models/caddy.ts:5471 — no reason given
- models/caddy.ts:5478 — no reason given
- models/caddy.ts:5485 — no reason given
- models/caddy.ts:5492 — no reason given
- models/caddy.ts:5499 — no reason given
- models/caddy.ts:5506 — no reason given
- models/caddy.ts:5513 — no reason given
- models/caddy.ts:5520 — no reason given
- models/caddy.ts:5527 — no reason given
IPv4 addresses that may be real infrastructure rather than documentation examples.
- files/README.md:148 — no reason given