CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to **Caddy UI** are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning follows [Semantic Versioning](https://semver.org/).
---
## [Unreleased]
---
## [2.68.8] - 2026-11-10 - One "Strip response headers" field instead of two (#173)
### Fixed
- **The host form showed two "Strip response headers" inputs — the response-side twin of #167 (#173).** `strip_resp_headers` (v2.9.35) and `strip_response_headers` (v2.9.168) were two database columns with identical behaviour, so which one a host used came down to which of two boxes the user typed in. Both emitted a *deferred* Caddy response-delete handler ahead of `reverse_proxy`; two delete lists are commutative, so the columns were genuinely interchangeable — and filling both silently emitted **two** strip-response-headers directives. Only `strip_resp_headers` was read by the Caddyfile exporter, so a value in the other column stripped headers in the live config but **was missing from an export**.
- **`strip_resp_headers` is now canonical.** The choice was not automatic: the legacy column owned two integrations the canonical one lacked — the Settings → **Globally stripped response headers** setting, and the precedence that stops the **Security Headers** bundle re-setting a header the host strips. Those were *moved onto* the canonical field rather than dropped, because the exporter gap is a correctness bug while the wiring is mechanical, because `strip_resp_headers` canonicalises header names (`http.CanonicalHeaderKey`) and the v2.9.168 path did not, and because #167 had already made the paired `strip_req_headers` canonical — picking the other column would have left request and response on opposite sides.
- **Both ported behaviours are preserved and now better.** The global strip list is still applied ahead of each host's own list, but is **deduplicated** against it: a header named in both the global setting and a host's own field used to be emitted twice. The security-bundle precedence is unchanged and remains case-insensitive, so stripping `x-frame-options` in lowercase still stops the bundle putting `SAMEORIGIN` back.
- **Existing values are migrated, not dropped.** On upgrade, anything stored in `strip_response_headers` is folded into `strip_resp_headers`: a legacy-only value moves over verbatim, and when both columns are set the header names are **merged and deduplicated** case-insensitively, canonical order kept and new names appended. The legacy column is drained only after its value has been written, in one statement per row, so an interrupted upgrade cannot lose data and a re-run is a no-op.
- **The old `strip_response_headers` form value is still accepted for one release**, merged into the canonical field, so an old bookmarked form or an API client posting the v2.9.168 name does not silently lose data. Marked for removal in v2.70 along with the column.
- The merge itself is now one shared helper (`models.MergeHeaderNameLists`) used by both the request- and response-side consolidations, rather than two copies of the same logic.
- The Caddy config now carries **exactly one** strip-response-headers directive, and an export round-trips header names typed into either box.
### Changed
- **Dependency updates (#172).** `aws-sdk-go-v2` 1.47.4 → 1.47.8, `aws-sdk-go-v2/credentials` 1.20.10 → 1.20.16, `aws-sdk-go-v2/service/route53` 1.70.5 → 1.70.9, `golang.org/x/oauth2` 0.37.0 → 0.38.0 and `modernc.org/sqlite` 1.61.0 → 1.62.0. All patch bumps except `oauth2`, which is a minor.
### Notes
- **This release contains a data migration** (the response-strip column merge described above). It is idempotent, discards nothing, and is covered by tests for the legacy-only carry-over, the merge case, case-insensitive dedupe, idempotency across a real database reopen, and a missing legacy column. **Back up the database before upgrading** — the migration rewrites every stored `proxy_hosts` row on first start. The same caution applied to v2.68.7, and Settings → Backup can now do this from the UI.
- `go test ./...` was run uncached against merged `main` for this release, rather than relying on the per-PR CI runs.
- `applegater/caddyui-caddy` is **not** rebuilt for this release: `Dockerfile.caddy` is unchanged since v2.68.6, so its module set is untouched and `:stable` keeps pointing at the v2.68.5 build. Only `applegater/caddyui` is republished.
- **If you are still on v2.68.6 or earlier, this release carries both column merges** — the request-side one from v2.68.7 (#167) and the response-side one here — so upgrading straight to v2.68.8 runs both migrations in order and skips nothing.
---
## [2.68.7] - 2026-11-10 - Scheduled backups can finally be configured from the UI (#166); one "Strip request headers" field instead of two (#167)
### Fixed
- **Settings → Backup saved nothing, and the scheduled backups shipped in v2.52.0 could not be turned on from the UI at all (#166).** Reported by @BurkhardFS: clicking **Save settings** on the Backup page did nothing — no error, no saved value. The Backup card sat *after* the closing `</form>` of the settings form, so in HTML terms its inputs and both its buttons belonged to no form at all. The reporter's own diagnosis was exact: `button.form === null`. Every value typed there — the schedule, the retention count, the target directory — was discarded on submit, and because the schedule was one of those values, the scheduled-backup feature released in v2.52.0 had been **unreachable from the interface since it shipped**. The Notifier status card had drifted out of the form the same way.
- **Both cards now render inside the `action="/settings"` form**, so they post the `settings_section` marker and the CSRF token like every other section. That token is the reason this is not a cosmetic fix: a POST without it is refused with `403`, so the controls had to be inside the form, not merely adjacent to it.
- **"Back up now" works again too.** It stays a submit button and keeps its `formaction="/settings/backup/run"`, which overrides the form's action for that one button — outside a form, that attribute had nothing to override.
- **A regression test now walks the rendered page and asserts that no named control or submit button is left outside the form**, so a card added after `</form>` in future fails the build instead of silently losing data. Verified end to end against a running instance as well: before the fix all five Backup controls reported `form === null`; after it, all five are owned by `/settings`, values persist across a reload, and **Back up now** produces a valid SQLite backup.
- **The host form showed two identical-looking "Strip request headers" inputs, and they were not the same setting (#167).** Reported by @AlexGuld: `strip_req_headers` (v2.9.35) and `strip_request_headers` (v2.9.250) were two database columns with identical behaviour, so which one a host used came down to which of two boxes the user happened to type in. That was a data-correctness bug, not just a confusing form: only `strip_req_headers` was read by the Caddyfile exporter, so a value in the other column stripped headers in the live config but **was missing from an export**, and filling both silently emitted **two** strip-request-headers directives.
- **`strip_req_headers` is now canonical** — it owns the header-name canonicalising helper, the exporter support, and the paired "Strip response headers" field above it in the form. The duplicate input is gone and the surviving one keeps the form's `ink-*` design tokens, which the v2.9.250 block did not use.
- **Existing values are migrated, not dropped.** On upgrade, anything stored in `strip_request_headers` is folded into `strip_req_headers`: a legacy-only value moves over verbatim, and when both columns are set the header names are **merged and deduplicated** (case-insensitively, since HTTP header names are case-insensitive) with the canonical order kept and new names appended. Nothing is discarded, and re-running the migration is a no-op.
- **The old `strip_request_headers` form value is still accepted for one release**, merged into the canonical field, so an old bookmarked form or an API client posting the v2.9.250 name does not silently lose data. It is marked for removal in v2.70 along with the column.
- The Caddy config now carries **exactly one** strip-request-headers directive, and an export round-trips header names typed into either box.
- **A public health check leaked an idle connection on every probe.** Contributed by @kernaxis (#163): each probe opened a connection and left it in the idle pool, so a fleet with several monitored upstreams accumulated sockets for as long as the process ran. Idle connections are now closed once the probe is done.
- **The top bar's actions stayed reachable with a long server name.** Contributed by @Thibault-MTZ (#159): a long name pushed the action buttons off the edge on narrow and mobile viewports, so they could not be tapped.
### Security
- **`go-jose` moves to 4.1.6, which fixes GHSA-mgcc-cww5-fp2q.** CaddyUI pulls go-jose in through `go-oidc`, so this is on the **OIDC single sign-on** path specifically.
- **Behaviour change worth knowing about if you use SSO:** 4.1.6 adds a cap on the RSA modulus accepted when parsing a JWK, `MaxRSAModulusBits = 4096`. The check rejects a key only when it is *larger* than that (`n.BitLen() > MaxRSAModulusBits`), so a 4096-bit identity-provider signing key — GitLab's size, for example — still parses. An IdP signing with something larger than 4096 bits would be refused; none of the common providers do.
### Changed
- **Spanish (es) interface translation.** Contributed by @gnacho (#162) — 316 strings.
- **The Security settings and sign-in screens are translatable.** Contributed by @Thibault-MTZ (#147): 127 strings on those two screens were hardcoded English and could not be localised. Keys a translation has not caught up with fall back to English rather than rendering blank.
- **Dependency updates.** `go-chi/chi` 5.4.0 → 5.5.0, `modernc.org/sqlite` 1.60.1 → 1.61.0, `modernc.org/libc` 1.77.1 → 1.79.0 and the AWS SDK / Route 53 modules to their current patches (#170, relanding #157).
- **CI pins every action to a commit SHA, and BuildKit to a digest (#152).** A moving tag on a workflow action is a supply-chain risk for a workflow that holds a read/write Docker Hub token.
### Notes
- **This release contains a data migration.** On first start, anything stored in the legacy `strip_request_headers` column is folded into `strip_req_headers` as described above. It is idempotent and discards nothing, and it was verified against a real database by writing to both columns and re-running the upgrade. As always, a backup before upgrading is cheap insurance — and as of this release you can actually configure one from the UI.
- `go test ./...` was run uncached against merged `main` for this release, rather than relying on the per-PR CI runs.
- `applegater/caddyui-caddy` is **not** rebuilt for this release: `Dockerfile.caddy` is unchanged since v2.68.6, so its module set is untouched and `:stable` keeps pointing at the v2.68.5 build. Only `applegater/caddyui` is republished.
- **The `:preview` Docker tag is gone** — it was deleted from Docker Hub in October 2026. If anything still references it, move to `:stable` or pin a version.
---
## [2.68.6] - 2026-10-25 - Source Push sends the wildcard, not the apex (#153); a stale "Issuance error" clears once the CA recovers (#151)
### Fixed
- **Source Push shipped the apex certificate to targets when a Managed ACME definition listed both a wildcard and its apex, breaking HTTPS for every subdomain on those targets (#153).** Caddy stores `*.example.com` and `example.com` as two separate certificates. CaddyUI picked the one with the latest expiry, and adding the apex to an existing wildcard definition meant the apex was obtained last — so targets received a certificate presenting only `example.com`, which covers no subdomain at all. Selection is now by **coverage** of the requested names, with expiry only breaking a tie, and a wildcard outweighs a concrete name because it protects everything behind it. Wildcard matching follows TLS rules: `*.example.com` covers one leftmost label, and never the bare apex.
- **A target certificate row holds exactly one keypair, so a single push still cannot carry both certificates.** For a `*.example.com` + `example.com` definition, the push now sends the wildcard and records in the activity log which names it does not cover, naming them. To serve the apex on a target as well, give it its own certificate definition with its own push targets. Nothing about this release makes one push deliver two certificates.
- **A certificate kept showing "Issuance error" after the CA came back, and Refresh would not clear it (#151).** A transient DNS failure during an ACME Renewal Information (ARI) update was recorded as an issuance failure, and two things then kept it on screen: a later successful ARI update was itself classified as an error rather than as a healthy certificate, and a certificate already in `error` or `retrying` was not re-probed, so the recorded failure was never revisited. A successful ARI update is now recognised as success, and a stale failure is re-probed, so the status clears on its own once issuance is actually working again.
### Notes
- Both fixes are on the certificate path, so they were verified by reverting each cause against the new regression tests and confirming the tests fail without the fix — including the wildcard weighting, without which apex and wildcard tie on coverage and the expiry tie-break reintroduces the original bug.
---
## [2.68.5] - 2026-10-25 - The Caddy image clears its three High CVEs
### Security
- **Trivy no longer flags `applegater/caddyui-caddy` for three High CVEs that have fixes.** Two were in Go code that is not compiled into Caddy, which scanners cannot tell; the third was in the image's `curl`. All three are now on fixed versions:
- **CrowdSec 1.6.3 (CVE-2026-44982).** CrowdSec's AppSec engine skipped request-body rules for chunked and HTTP/2 requests. That code lives in the CrowdSec engine and is not compiled into Caddy, since the bouncer only uses CrowdSec's API client, but scanners flag the whole module. The newest bouncer release, v0.14.1, cannot build against CrowdSec 1.7. The image now pins the upstream bouncer commit that adds 1.7 support, whose only code change since v0.14.1 is that fix, and builds against CrowdSec 1.7.8. **If you run the CrowdSec engine with AppSec, update that container to 1.7.8 or later too**: that is where the bug actually is.
- **golang.org/x/net 0.59.0 (CVE-2026-78669).** A malicious HTTP/2 peer could burn CPU with many small SETTINGS frames. Caddy is built with Go 1.27, where x/net's HTTP/2 client and server both hand off to the standard library's own HTTP/2 code, which Go 1.27.2 already fixed. The vulnerable x/net code was therefore never in the binary. It is now 0.61.0, which also clears four Medium x/net CVEs.
- **brotli-libs 1.2.0 (CVE-2026-111497).** An out-of-bounds write in the Brotli decoder. Caddy does not use this library; the base image's `curl` does. The image now runs `apk upgrade`, so it gets Alpine's fixes even when the upstream `caddy:alpine` base is older. This build also picks up curl 8.23.0.
- **go-chi moves to 5.4.0 (CVE-2026-114437, Medium).** The affected middleware is not used by Caddy. Trivy now reports no Critical, High or Medium findings in the image. The one remaining advisory, GO-2026-5932 (`x/crypto/openpgp` is unmaintained), has no fix, and that package is not in the binary.
- **The security pins can't quietly downgrade a plugin.** xcaddy sets each pinned version exactly, so the pins come before every plugin, and the build now fails if `go get` ever reports a downgrade.
- **Behaviour is unchanged.** The old and new images gave identical results for: a CrowdSec ban and unban in stream and live mode against a CrowdSec 1.8.1 Local API, the Coraza WAF with the OWASP Core Rule Set (SQL injection and XSS blocked, clean requests passed), rate limiting, and an HTTP/2 (h2c) reverse proxy. Caddy stays at 2.11.7 and `caddy list-modules` is identical.
- **Coraza's JSON-schema library moves from 0.4.6 to 0.7.7.** CrowdSec 1.7.8 requires `kaptinlin/jsonschema` 0.7.7, and Go builds one copy of each library, so Coraza's `@validateSchema` operator gets it too. CaddyUI never generates that operator, so only hand-written WAF directives use it. Coraza's own `@validateSchema` tests pass with 0.7.7. The old and new images also gave identical results on 13 schema cases, covering required fields, types, extra properties, nested `$ref` objects, enums and array limits.
### Notes
- CaddyUI itself has no code changes. This release publishes the rebuilt Caddy image. Pull `applegater/caddyui-caddy:stable` (or `:v2.68.5`) and recreate the Caddy container to get it.
---
## [2.68.4] - 2026-10-24 - Go toolchain 1.27.3 distrusts compromised intermediate certificates
### Security
- **The published binaries were built with Go 1.27.2, which govulncheck reports as affected by GO-2026-6809 (CVE-2026-111943).** Go 1.27.3, released on 2026-10-19, adds a denylist to `crypto/x509` so that `Certificate.Verify` refuses the intermediate certificates that are no longer trusted after the eMudhra intermediate CA compromise. This is the same incident behind Node.js adding those certificates to its own trust-store blocklist this month. CaddyUI calls `Certificate.Verify` when it checks certificates and connects to upstreams and DNS providers over TLS. The pinned toolchain is now **Go 1.27.3**, and govulncheck reports no vulnerabilities that CaddyUI calls.
- **The `applegater/caddyui` Docker images were not affected.** They build on `golang:1.27-alpine`, which already resolved to 1.27.3 for v2.68.3. Only the native binaries from the GitHub releases page carried the old runtime, so anyone running a downloaded binary should take v2.68.4.
- **`applegater/caddyui-caddy` is rebuilt as well.** The published image was compiled with Go 1.27.1. The upstream `caddy:2.11.7-builder` image now ships Go 1.27.3, so the rebuild picks up the fix. Caddy stays at 2.11.7, the latest release.
---
## [2.68.3] - 2026-10-24 - Fleet changes reach every node (#150); analytics stays inside each viewer's own hosts; Force SSL redirects survive a catch-all route (#149)
### Security
- **Saving Settings re-syncs every managed server, not only the selected one (#150).** Reported by @hieronymousch: fleet settings were not propagated. Settings are global. The fleet-wide IP blocklist, global maintenance mode, the fallback page and status, stripped response headers and the ACME DNS resolvers all go into every server's config. But a save re-synced only the server picked in the top bar, unless it was the Integrations page. On a real two-node fleet, an address added to the blocklist under Settings → Security was blocked on one node and still served by the other. That lasted until something else happened to sync that node. Every managed server is now synced after a save, the selected one first.
- **Disabling a host now disables its deployed copies too (#150).** The quick enable/disable toggle, the maintenance toggle and the bulk actions on proxy hosts, redirections and advanced routes changed only the source server. A host switched off there kept serving, unchanged, on every automatic deployment target. These changes now follow the automatic targets as a form save does, and as Layer4 proxies already did. A bulk action syncs each target once, not once per host. Node-local resources still stay on their own node. A bulk action on hosts from several servers now also syncs each of those servers, not only the first.
- **A save that does not reach a deployment target now says so (#150).** When a target refused a copy, because it is registered as monitoring-only, because one of the host's names is already claimed there by a different host, or because its Caddy could not be reached, the reason went only to the Activity Log, and that entry did not even include it. The save looked like it had worked, while the target kept serving its old version of the host, without whatever protection had just been added on the source. Saving a proxy host, redirection or advanced route now ends on a notice naming each target that did not get the change and why, and the Activity Log entry carries the reason.
- **The request explorer's counts are scoped to the viewer's own hosts.** Searching with no host picked listed only your own requests, but the *Total* and the status-mix chips counted every host on the fleet. A non-admin could therefore read the size and error rate of other tenants' traffic. This is exactly what a bar on the fleet chart opens since v2.68.2. Total, status mix and host facets now come from a query scoped to the viewer's hosts, wildcards included, rather than from rows filtered afterwards. That also fixes a quieter bug: when other hosts were busier, your own matching requests could be pushed out of the 200 rows shown.
- **A non-admin who can see no hosts no longer gets the whole fleet.** For such an account, the analytics scope came back as "no list" instead of "an empty list". Every caller read that as *admin, no filter*. The account was shown the full `/analytics` overview, including other hosts' names, traffic and charts, and the explorer listed their requests with paths and client IPs. This affects every signed-in role, since Analytics is open to all of them. It could also happen to a real user, for example a new account or one whose hosts were all removed. Host-page drill-downs and CSV export were not affected: they check for an admin explicitly.
### Fixed
- **A Force SSL host requested over plain HTTP gets its `308` redirect again, not a 404.** Reported by @AlexGuld: on CaddyUI's own `:80` server the HTTPS redirect was appended after every other route, so a catch-all Advanced route served on `:80` answered first, and every Force SSL host received an empty `404`. Deleting the route through Caddy's admin API fixed it until the next sync re-added it. The redirect now goes right after the last host-matched route and ahead of any catch-all that follows. That is where Caddy's own automatic HTTPS puts the redirects it adds to an existing `:80` server ([caddyserver/caddy#3212](https://github.com/caddyserver/caddy/issues/3212)). Exact hosts still beat wildcards, and a path-scoped route for a forced host still runs before that host's redirect (#129).
- **Import from Caddy no longer copies CaddyUI's own generated config.** In the report above, the catch-all was CaddyUI's own fallback, stored as an Advanced route (`srv0 route[37] (no host match)`) by importing from a Caddy that CaddyUI already managed. The importer now skips the `caddyui_http` server, which CaddyUI rebuilds from its database on every sync. It also skips the fallback route on any server, with or without the CrowdSec guard, and lists it under *Skipped* along with the reason. Before this change, importing that server also turned the Force SSL redirect into a junk Redirection host that claimed the forced hosts.
- **Adding an automatic deployment target now says what it does not copy (#150).** A target receives what is saved on the source from then on. Hosts, redirections and advanced routes that already existed were not copied, and nothing said so, which made ticking a target look broken. Saving a server that gains a target now reports how many existing resources were left behind, and that one **Sync from current** on the target copies them. The server form and README no longer list toggles and bulk actions among the things that don't deploy.
### Notes
- Hosts that a toggle or bulk action changed on a source before this release still differ on its targets. Run **Sync from current** once on each target, with the source selected, to bring them back in line.
- Existing installs that already hold an imported fallback should delete it from **Advanced routes**. It is the `(no host match)` entry whose JSON is only an `error` / `404` handler. CaddyUI adds its own fallback on every sync, and the stored copy also hides the custom fallback page over HTTPS. Until then, this release keeps it behind the redirects on `:80`.
---
## [2.68.2] - 2026-10-22 - Request charts separate 4xx from 5xx, and the fleet chart drills through (#145)
### Fixed
- **The request chart no longer lumps client errors in with server errors.** Reported by @BenRLange: v2.66.0 drew 4xx and 5xx as a single red band, so a scanner throwing 404s looked exactly like the backend falling over — the two things you most need to tell apart. Bars are now stacked by response class using the same colours as the status-mix chips everywhere else: **2xx** green, **3xx** blue, **4xx** amber, **5xx** red. The hover text names the counts too, as *"73 requests · 2×4xx · 28×5xx"*.
### Added
- **Bars on the main Analytics chart are clickable**, as the per-host ones already were (#145). A spike there now answers *"which hosts, and were they errors?"* without first guessing a host to drill into. Each bar opens the request explorer bounded to its own hour.
- The fleet chart is coloured by response class as well, so both charts read the same way.
### Notes
- The fleet chart's colouring respects who you are: a non-admin sees the status mix of their own hosts only, scoped exactly as the chart above it already was.
- Buckets older than the per-bucket status data fall back to a single neutral bar rather than vanishing.
---
## [2.68.1] - 2026-10-22 - Blocked requests return a readable 403 instead of an empty one (#148)
### Fixed
- **A refused request now carries a body and a `Content-Type`.** Reported by @AlexGuld: an IP allowlist answered with a bare `403` — no body, no content type — and while that is legal HTTP, a browser is given nothing to render and nothing to go on. Mobile Safari and Edge react by offering the empty response as a **`document.txt` download** instead of showing a denial page. Blocked visitors now get `403 Forbidden` as `text/plain; charset=utf-8`.
- **Every refusal CaddyUI generates was fixed, not just the allowlist.** The same bare-status pattern appeared at **twenty** places — IP allowlists and blocklists, the global blocklist, blocked and disallowed methods, blocked paths, blocked user agents, empty user agents, private-IP blocking and the tenant route guard. They now share one helper, so the next refusal added cannot reintroduce this.
- Four of those already had a body but **no content type**, which is the half that actually causes the download. A grep for a missing body would have missed them; they were caught by a test that walks the generated configuration and asserts no refusal is bodiless or typeless.
- **Redirects and `204 No Content` are deliberately left alone.** A `301`/`302` carries a `Location` and a `204` means "no content" — giving either a body would be the opposite mistake, and a `204` with a body is malformed. There is a test for that too.
### Notes
- Verified against Caddy 2.11.7 end to end, not only in unit tests: before, a blocked request returned `Content-Length: 0` with no content type; after, it returns `403 Forbidden` with `Content-Type: text/plain; charset=utf-8`.
- Nothing about **who** is blocked changed — this only affects what a blocked visitor is shown.
---
## [2.68.0] - 2026-10-22 - Users shows how each account signs in
### Added
- **A Sign-in column on the Users page.** Each account shows whether it signs in through your identity provider, with a password, or both — so "who is using Google?" is answerable at a glance instead of by inspecting the database.
- The provider is recorded on **every** SSO sign-in rather than inferred from how the account was created. An account added by hand that later signs in with Google shows **both** badges, which is the truthful answer; guessing from the creation path would have got that wrong.
- The badge is named after the provider — *Google*, *Authentik*, whatever your issuer is — and the full issuer URL and last sign-in time are in its tooltip, so an abbreviation is never the only thing on screen.
- **Accounts with no usable password are flagged `SSO only`.** An account the provider created has no password it is possible to match, so it depends entirely on SSO: disable single sign-on, or remove the person at the provider, and they lose access until an administrator sets a password. That is worth seeing before it happens rather than afterwards.
### Notes
- Existing accounts show no provider badge until their next SSO sign-in — nothing is backfilled, because there is no record of past sign-ins to backfill from. Password accounts are unaffected and display immediately.
---
## [2.67.0] - 2026-10-22 - Restrict SSO to your own email domains
### Added
- **Allowed email domains for SSO**, under *Settings → Security → SSO*. List the domains that may sign in and every other verified address is refused. Leave it blank and nothing changes, so upgrading restricts nobody.
This exists because of a distinction that is easy to miss. With a **private** identity provider — Authelia, Authentik, Keycloak — "anyone my provider authenticates" means your own staff. Point CaddyUI at a **public** one such as **Google** and it means every account in the world; with *Auto-create accounts* switched on, that is a CaddyUI account for anyone who finds your login page, at whatever default role you set. Listing your domains closes that.
- The list gates **sign-in itself**, not just account creation. Gating creation alone would leave an account from outside the listed domains still able to sign in through a provider the administrator believes is restricted.
- Matching is on the exact domain after `@`, case-insensitively, and a leading `@` in an entry is tolerated. **Subdomains are not implied** — listing `example.com` does not admit `user@mail.example.com`, which must be listed separately.
- **Local password sign-in is unaffected**, so this cannot lock anyone out of the product.
- The refusal page does not name the permitted domains; the login page is reachable by anyone and the list is configuration.
### Docs
- **Signing in with Google** is now written up in the in-app documentation: the exact Issuer URL, the redirect URL to register, and the warning above. Google needs no special support — it is an ordinary OIDC provider and CaddyUI has worked with it since SSO shipped — but two things are worth knowing. Group-to-role mapping does **not** work with plain Google, whose ID tokens carry no `groups` claim, so every auto-created user lands on the default role; and Google always returns a verified email address, which CaddyUI requires.
---
## [2.66.1] - 2026-10-20 - Russian interface translation (#146)
### Added
- **Russian (`ru`) interface translation**, contributed by @tkkost — all 314 catalog keys, with placeholders and technical terms preserved. Pick it under **Profile → Language**, or set it as the site-wide default under Settings. CaddyUI now ships **English, Français, Русский and 简体中文**, three of them community-contributed inside a fortnight.
### Notes
- The four colour-theme names under Settings → Appearance (*Forest Green*, *Deep Indigo*, *Carbon Orange*, *Rose / Crimson*) are deliberately left in English in this catalog, the contributor treating them as names of predefined themes rather than ordinary labels. The French and Chinese catalogs translate them; both readings are defensible. The contributor has also offered to keep the Russian translation current as new strings land.
---
## [2.66.0] - 2026-10-19 - Request explorer, latency percentiles and French (#145, #142)
### Added
- **A request explorer at Analytics → Search requests** (#145). One filter over host, response status, user agent, path, client IP and time window, listing the matching requests with a status mix and the hosts they came from. @BenRLange's own examples — *all 500 errors from today*, *user-agent activity for a host yesterday afternoon*, *4xx for one host over a week* — are each a single view now rather than a chain of drill-downs that each dead-ended.
- **The fleet-wide status mix is clickable** (#145). The per-host page's status chips have always been links; the ones on the main Analytics page were plain text, so the most obvious starting point for "what are these errors?" went nowhere. Each class now opens the explorer filtered to it.
- **Request-chart bars are split by response class and drill through to their own period** (#145). A rise in 4xx/5xx is visible in the bar itself, and clicking any bar lists exactly that bucket's requests — so "what happened during that spike" no longer means reconstructing a time range by hand.
- **Exact user agents per host** (#145). The host page had a *browser* breakdown, which buckets agents into Chrome/Firefox/… and hides precisely what you want when a bot is hammering a host. The verbatim agent strings now appear with request and visitor counts, each linking into the explorer.
- **p50, p95 and p99 response times** on the host page (#145), alongside min/max and the sample count. A single percentile can't distinguish a slow tail from a broad shift.
- **French (`fr`) interface translation** (#142, #144), contributed by @Thibault-MTZ — all 314 catalog keys. CaddyUI now ships English, Français and 简体中文.
### Fixed
- **The response-time panel had been showing nothing.** `AVG(duration_ms)` returns a floating-point value, and the query scanned it straight into an integer — which fails, since an average duration is essentially never a whole number. The panel returned a scan error and all-zero statistics on any real data set. Found while adding the percentiles above and confirmed against a 188,000-event database, where it reported zeros before the fix and a 284 ms average after it. This is why latency looked absent rather than merely sparse.
### Notes
- **Upstream time cannot be separated from total time.** Issue #145 asked to distinguish proxy time from backend time; Caddy's access log emits a single `duration` for the whole request, so there is nothing to split. Reporting a guess would be worse than reporting nothing. Everything else in that request is covered.
- The explorer reads raw access events, so it reaches back as far as your access-event retention — the same bound the existing drill-downs have, not the longer daily rollup.
- Non-admins see only hosts they own: a hand-edited `?host=` for someone else's host returns 404 rather than 403, so the set of hosts stays unguessable.
---
## [2.65.1] - 2026-10-16 - Go toolchain 1.27.2 closes 12 standard-library vulnerabilities
### Security
- **The published binaries were built with Go 1.26.8, which govulncheck reports as affected by 12 standard-library vulnerabilities.** Mostly denial-of-service and request-desynchronisation issues in `net/http` — an HTTP/2 HPACK encoder race, double flow-control refunds, memory exhaustion via trailers, unbounded `Range` header parsing, HTTP/1 desync after a CONNECT response — plus a MIME header memory-limit bypass in `net/textproto`, malformed ECH handling in `crypto/tls`, and two `html/template` context-tracking issues reachable from CaddyUI's own page rendering. The pinned toolchain is now **Go 1.27.2** and govulncheck reports zero.
- **The Docker images were never affected.** They build on `golang:1.27-alpine`, which resolves to 1.27.2 — already the fixed release for that line. Only the native binaries from the GitHub releases page carried the vulnerable runtime, so **anyone running `applegater/caddyui` had nothing to do**; anyone running a downloaded binary should take v2.65.1.
- This also closes a gap where one release was compiled two different ways: the binaries from the `toolchain` line in `go.mod` and the images from the Dockerfile's base image. Both now use 1.27.2.
---
## [2.65.0] - 2026-10-16 - Admin allowlist matches the real visitor; SSO roles from group claims (#139, #140)
### Security
- **The admin panel IP allowlist did not restrict what it appeared to.** It compared the raw connection address, ignoring *Trusted proxy IPs/CIDRs* entirely, so behind any reverse proxy it only ever saw the proxy. Two symmetrical failures followed, and @BenRLange hit the first:
- An allowlist naming **your own address locked you out**, because your address never reached the check.
- The obvious workaround — allowlisting **the proxy** — then admitted **every** visitor who arrived through it, at any IP, while Settings still read as restricted. An admin could believe the panel was limited to their home address while it was reachable by anyone who could reach the proxy.
The allowlist now matches the same trusted-proxy-aware address the login rate limiter uses: a forwarded header is believed only when the connection came from a trusted proxy, and the **right-most** `X-Forwarded-For` entry is taken — the one your proxy appended — so a visitor cannot prepend a chosen address and be believed. Same class of bug as #129, which v2.63.2 fixed for the Caddy-side allowlists; this was CaddyUI's own panel and was missed at the time.
**If you use this allowlist, re-check it after upgrading.** A list that "worked" because it named the proxy now restricts by real visitor address, as it always claimed to.
### Added
- **Settings → Security shows the address you are connecting from**, and whether it was read from the connection or from a forwarded header via a trusted proxy (#140). The allowlist is checked against exactly that address, so an entry that would lock you out is visible before you save it rather than after. If it names the proxy instead of you, the hint says to configure *Trusted proxy IPs/CIDRs* first.
- **`CADDYUI_ADMIN_ALLOWLIST` recovers a lockout from compose** (#140). Set it in your compose file and restart: it overrides the stored list, and `CADDYUI_ADMIN_ALLOWLIST=off` disables the gate entirely while you get back in. The saved list is left untouched, so removing the variable restores it. An empty value counts as not set, so a blank entry in compose cannot lock you out by itself.
- **Auto-created SSO accounts can be given a role** (#139). *Settings → Security → SSO* gains a **default role** for accounts created on first sign-in — still `view` unless you change it, so upgrading widens nobody's access — plus optional mapping from a **groups claim**: name the claim your IdP sends and write `group|role` rules, one per line, first match wins. Group names match case-insensitively, since IdPs are inconsistent about casing. A rule naming a role CaddyUI does not have is refused when you save, with the line number, rather than stored and silently ignored.
- The claim is read defensively: an array of strings, a single string, or a space/comma-separated string all work, and any other shape means "no groups" and a sign-in on the default role rather than a failure.
- Roles are applied **at account creation only**. An existing user's role is never changed by signing in, so a mapping cannot demote or escalate someone already set up under *Users*.
### Fixed
- **The SSO callback URL is spelled out in full** (#139). The field's placeholder was the complete `…/auth/oidc/callback`, but the input is too narrow to show the end of it, so it read as a recommendation to register the bare `/auth/oidc` path — and registering that causes the login to fail at the provider. The exact URL, built from the host you are using, is now shown under the field as selectable text where it cannot be clipped.
---
## [2.64.2] - 2026-10-15 - Declining two-factor auth no longer looks like unfinished setup (#138)
### Fixed
- **The readiness banner counted a recommendation as a requirement.** Reported by @tkkost: with two-factor auth deliberately not in use, Operations showed *"Environment readiness · 3 of 4 required"* and **Continue setup** permanently, so a considered decision looked like an install someone had abandoned half-done. The onboarding page labels that same step *"Recommended"*, so the banner was contradicting it. Readiness now counts the three foundations that genuinely are required — your administrator account, a connected Caddy, and a published service — and reaches a finished state without TOTP. The step is still listed, and the security recommendation still appears; neither claims the install is incomplete.
- Worth knowing if you go looking: that counter was driven by **your own account's** TOTP, not the site-wide policy. Toggling the policy never moved it.
- **The "two-factor authentication is not required" recommendation can be dismissed.** Choosing not to require TOTP across the board is a legitimate decision, and a recommendation that cannot be acknowledged is just a permanent nag. There is now a **Dismiss** next to it, following the same pattern as the unused-certificates recommendation. Turning the policy on clears the dismissal, so switching it off again later raises the recommendation afresh rather than leaving it silently suppressed by a decision made months earlier.
### Notes
- Nothing about enforcement changed — this release only affects what the dashboard claims about your setup. If you require TOTP site-wide, it is still required.
- Simplified Chinese: the readiness count and body text are updated, and **Dismiss** is translated.
---
## [2.64.1] - 2026-10-15 - One two-factor policy setting instead of two (#138)
### Fixed
- **Settings › Security had two toggles for one policy.** *Require 2FA for all users* and *Require TOTP for all users* were separate settings whose descriptions said nearly the same thing, and reported by @tkkost as impossible to tell apart. They were: both sent a user without TOTP to the setup page, and the dashboard's security recommendation already treated them as interchangeable. There is now a single **Require two-factor authentication (TOTP) for everyone**.
- **Nothing is switched off by the upgrade.** An install that had set only one of the pair still enforces the policy — both stored keys are honoured, and saving writes both, so downgrading to an earlier release keeps enforcing it too.
- The two old toggles enforced the policy with *different* exclusion lists: the TOTP one would answer a session-authenticated `/api/` request with a redirect to an HTML page, while the 2FA one let it through. The surviving check keeps the wider list, so JSON callers get JSON.
- They also used different checkbox conventions and one saved immediately while the other waited for **Save**. One toggle, one behaviour.
- **The per-user control was hard to find.** The user-menu entry is now **My two-factor authentication** rather than the ambiguous *Two-factor auth*, and the policy setting says in so many words that it is site-wide, that it enrols nobody, and that individual accounts are managed from that menu. @tkkost had reported TOTP as impossible to disable before finding this entry — the disable itself was working correctly all along.
- **The policy applies to the admin who turns it on**, which is easy to walk into: with no TOTP on your own account, saving it sends you to enrolment and you must finish before reaching Settings again, including to switch it back off. That was already true; the setting now warns before you save. The enrolment page stays reachable, so this strands nobody.
### Notes
- Simplified Chinese: the renamed user-menu entry is translated (*我的两步验证*). The new policy description is English until a translation lands.
---
## [2.64.0] - 2026-10-14 - Client certificate authentication (mTLS) on proxy hosts (discussion #137)
### Added
- **A proxy host can require a client certificate.** *Proxy Hosts → edit → Security* gains **Client certificate authentication (mTLS)**: a mode and a list of trusted CA certificate files. Caddy then asks the visitor's browser or client for a certificate during the TLS handshake and refuses the connection unless it checks out. The four modes are Caddy's own — *Require and verify* (the usual meaning of mTLS), *Verify if given* (optional certificate, validated when sent), and *Require* / *Request*, which accept any certificate the client offers for cases where something behind Caddy does the checking.
- The trusted CA files are read **by Caddy, inside the Caddy container**, exactly like certificate files are — so the path is the one Caddy sees, and the file does not need mounting into CaddyUI.
- Your backend reads the certificate through the usual placeholders. For EJBCA and friends, add `SSL_CLIENT_CERT: {http.request.tls.client.certificate_der_base64}` under *Custom headers* on the same page.
- Verified end to end against Caddy 2.11.7 with a throwaway CA: no certificate is refused with *certificate required*, a certificate signed by the trusted CA gets through, and a self-signed one is refused with *unknown ca*.
- **A verifying mode with no trusted CA is refused at save time.** Caddy accepts that combination and then turns away every client, because there is nothing to verify against — the save now explains this instead, and suggests *Request* / *Require* for the no-verification case.
- **`client_auth_mode` and `client_auth_ca_files` on the REST API** (`/api/v1/proxy-hosts`), with the same validation. On `PATCH`, send `"client_auth_mode": "off"` to switch mTLS off — an empty string leaves it unchanged, as it does for every other field there.
- **Export to Caddyfile writes the `tls { client_auth { … } }` block.** Leaving it out would make an exported site look open to anyone.
### Fixed
- **An Advanced route carrying `tls { client_auth … }` no longer discards it in silence.** This is what prompted the discussion: Caddy keeps client authentication in the server's TLS connection policies, not in the route, and an Advanced route stores routes only — so the block vanished during import, the generated JSON contained the `reverse_proxy` headers but no `client_auth`, and the site came up serving everyone while looking configured. Importing such a block is now refused, naming the hostnames affected and pointing at the proxy-host setting above, which does reach Caddy.
### Notes
- Existing hosts are unaffected: the new mode defaults to off, so nothing starts asking for client certificates after the upgrade.
- Not to be confused with **Upstream mTLS Client Certificate / Key** further down the same form — those are Caddy presenting a certificate *to your backend*, the opposite direction. Both can be used at once.
---
## [2.63.2] - 2026-10-10 - IP allowlists work behind a load balancer (#129)
### Fixed
- **IP allowlists and blocklists now honour Settings › Security → Trusted proxy IPs/CIDRs.** Every IP check CaddyUI generates matched Caddy's `remote_ip`, which is always the address of the machine that connects to Caddy. Behind a load balancer, reverse proxy or CDN, that is the balancer, never the visitor, so an allowlist either let everyone in (balancer inside the list) or locked everyone out (balancer outside it), whatever the trusted-proxy setting said. The checks now use Caddy's `client_ip`:
- **No trusted proxies configured:** `client_ip` is exactly the connection's address, so nothing changes and a forged `X-Forwarded-For` header is still ignored.
- **Trusted proxies configured:** the visitor's real address is taken from `X-Forwarded-For` (or the configured *Client IP headers*), but only on connections that come from a trusted proxy.
- This covers proxy-host and redirection allowlists, IP blocklists, *Block private IPs*, the global blocklist and the maintenance-mode bypass list. Verified on Caddy 2.11.7 through a load balancer: with the balancer trusted, an allowed visitor gets through, an outside visitor gets 403, and a forged header sent straight to Caddy is refused.
- The allowlist and trusted-proxy fields explain this, including that anything listed as a trusted proxy can claim any client address.
### Notes
- **Behind a load balancer?** Add its address under **Settings › Security → Trusted proxy IPs/CIDRs**. If it forwards TCP/TLS without HTTP (passthrough), it cannot pass the visitor's address in a header, and IP allowlists cannot tell visitors apart.
- `client_ip` needs **Caddy 2.7 or newer** (the `applegater/caddyui-caddy` image ships 2.11.7).
---
## [2.63.1] - 2026-10-10 - Simplified Chinese covers the Dashboard, Profile and Settings › General (#131–#135)
### Added
- **The Simplified Chinese (`zh-CN`) translation now covers everything on the catalog**, contributed again by @chongfenglaosiji in five pull requests: Settings › General (#131), the Settings navigation, post-apply checks and colour themes (#132), the Dashboard (#133), Profile (#134), and the health-dot tooltips and shared status words (#135). Every one of the 313 keys is translated, so these pages are fully Chinese with no English left over. The *Follow each browser* option was also reworded slightly. Thank you!
---
## [2.63.0] - 2026-10-10 - Dashboard, Profile and Settings › General can be translated (#128)
### Added
- **250 more interface strings are on the translation catalog**, so a translation can now cover the whole **Dashboard** (banners, server and route summary, traffic, system health, *Needs attention*, the fleet cards, recent activity and the proxy-host / advanced-route tables, including the health-dot tooltips drawn by its script), the whole **Profile** page (including its saved/error messages), the **Settings navigation** (every section's name and description, the page heading and the *Save … settings* button) and the full **Settings › General** page (general options, timezone and post-apply checks). English output is unchanged.
- **Messages built on the server can be translated too** — the Profile page's confirmations and errors are now looked up in the reader's language before the redirect.
- Shared status words (*Online*, *Offline*, *Enabled*, *Disabled*, …) have their own `status.*` keys, ready for the list pages to reuse.
### Notes
- Translations simply show English for the new keys until they are updated — nothing breaks in the meantime. The remaining Settings pages, the list pages and the forms follow in the next batches; the large proxy-host form comes last.
- A new test keeps the Settings navigation (defined in Go) and its English catalog text identical, and another renders the Dashboard, Profile and Settings › General pages in a second language.
---
## [2.62.2] - 2026-10-10 - The default language setting now takes effect
### Fixed
- **Settings → General → Default language did nothing for most people.** The language was chosen as: your own profile choice, then the **browser's** language, then the site default — and since every English-language browser matches the English catalog, the site default was never reached. Choosing Simplified Chinese as the default visibly changed nothing. The order is now: your own **Profile → Language** choice, then the **site default** when an administrator has set one, then the browser, then English. Leaving the default on *Follow each browser* keeps the old browser-based behaviour. The setting's help text says so (in English and Chinese).
---
## [2.62.1] - 2026-10-10 - Simplified Chinese (#130)
### Added
- **Simplified Chinese (`zh-CN`) interface translation**, contributed by @chongfenglaosiji (#130) — every key currently in the catalog: the navigation and its sections, the top bar and *Create* menu, the user menu, the command-palette search, the list-page titles and the language settings. Pick it under **Profile → Language**; browsers that ask for Chinese (`zh`, `zh-CN`, `zh-TW`, …) get it automatically. Parts of the interface not yet moved onto the catalog stay in English and will follow release by release. Thank you!
---
## [2.62.0] - 2026-10-09 - Interface languages: the localization plumbing (#128)
Requested by @chongfenglaosiji, who has offered to translate CaddyUI into Simplified Chinese.
### Added
- **CaddyUI can be shown in other languages.** Translations are flat JSON catalogs in `web/i18n/` — one per language, named by its code (`en.json`, `zh-CN.json`, …) and embedded in the binary — with stable dotted keys such as `nav.proxy_hosts`. **English is the source of truth and the fallback**: any key a translation lacks (or leaves empty) shows in English, so a partial translation never breaks a page, and a key missing everywhere shows as itself rather than blank. Texts can carry named placeholders like `{count}`.
- **Each person chooses their own language** under **Profile → Language**, available to every role; the default is *Use my browser's language*. Otherwise the browser's `Accept-Language` decides (a related variant such as `zh-TW` or plain `zh` uses the closest available catalog), then **Settings → General → Default language**, then English. Only languages that have a catalog can be chosen. The page's `<html lang>` follows the choice.
- **Templates and scripts share one catalog**: templates use `{{t $ "key"}}`, and keys starting with `js.` reach the browser as `window.CADDYUI_I18N` with a small `caddyuiT(key, vars)` helper.
- **Guard rails, as tests:** every key a template or script uses must exist in `en.json`, and every translation may only use English's keys and must keep the same placeholders — so a typo or drift fails the build instead of showing a raw key.
- **First strings on the catalog:** the navigation and its section titles, the top bar, the *Create* menu, the user menu, the command-palette search, the list-page titles for proxy hosts, redirections, advanced routes and certificates, and the new language settings. English output is unchanged. The rest of the interface moves onto keys over the next releases, the large proxy-host form last.
- **[docs/TRANSLATING.md](docs/TRANSLATING.md)** explains the format and how to send a translation as a data-only pull request.
### Notes
- This release ships English only; the first translation (Simplified Chinese) is coming from the community. New column `users.locale` and setting `default_locale` are created automatically.
---
## [2.61.3] - 2026-10-09 - Security: a wildcard host could bypass a specific host's IP allowlist (#129)
Reported by @hieronymousch: after upgrading, sites with an IP allowlist accepted every client, and attaching a middleware profile made no difference.
### Security
- **A wildcard proxy host could take over a more specific host's traffic, skipping its IP allowlist and every other protection on that host's route.** Caddy runs the first route that matches a request. CaddyUI emitted host routes in list order — manual sort order, then newest first — so a wildcard host such as `*.example.com` created *after* `app.example.com` came first, and requests for `app.example.com` were served by the wildcard's route. Everything configured on `app.example.com` itself was then skipped: its **IP allowlist**, Basic Auth, forward auth, the middleware profile attached to it and its WAF. Reproduced on a real Caddy: a client outside `app.example.com`'s allowlist got `200` instead of `403`.
- **Fix:** host routes are now ordered by specificity, the way Caddy's own Caddyfile does it: exact hostnames before wildcards, more specific wildcards (`*.a.example.com`) before broader ones (`*.example.com`), and a path-scoped route before the same host's catch-all. A route that also carries a wildcard counts as a wildcard route. Applied to the HTTPS server, the plain-HTTP server (where a forced host's HTTPS redirect now always beats a wildcard host) and the managed wildcard-certificate routes. Only routes that match on hostnames move, and only among themselves: global maintenance, an Advanced route without a host matcher and the fallback keep their exact positions, and the manual order still decides between routes that are equally specific. After the fix the same request gets `403`, and other names still reach the wildcard host.
- **Upgrade if you have both wildcard and specific proxy hosts**, especially when the specific ones rely on an IP allowlist, Basic Auth, forward auth or a WAF profile. The fix applies on the next sync (a restart with sync-on-start, or any save).
---
## [2.61.2] - 2026-10-08 - Certificate export: a permission problem is reported as one (discussion #127)
Reported by @devacc337: *Export failed — Caddy has not stored a certificate for \*.domain.com yet (looked in /caddy-data)*, with the certificate plainly present in the volume.
### Fixed
- **A permission error was reported as a missing certificate.** Caddy keeps its data directory private to the user it runs as — mode `0700`, owned by root in the official image — while the CaddyUI container runs as an unprivileged user (uid 10001), so it could not even list `caddy/certificates`. The lookup swallowed that "permission denied" and fell through to *"Caddy has not stored a certificate … yet"* (with `looked in /caddy-data` and no sign of the `certificates` directory — the tell-tale), sending people to look for a certificate that was sitting right there. Reproduced as uid 10001 against a root-only tree; the same lookup succeeds as root. The error now says what is wrong: *CaddyUI is not allowed to read Caddy's certificate storage (permission denied on …), CaddyUI runs as uid N — run the caddyui container as that user, with the official images `user: "0:0"`, the data volume can stay read-only.* A certificate that really is missing still reports as missing. The same lookup serves *Sync from current* for shared managed certificates, which gets the clearer message too.
- **Export-directory write failures get the same treatment.** A permission error creating or writing the export directory (a fresh Docker volume is root-owned, and CaddyUI runs unprivileged) now says CaddyUI is not allowed to write there and how to fix it.
### Docs
- To use **Export to a directory**, run the `caddyui` service as root — `user: "0:0"` — so it can read Caddy's private data volume (mount it read-only) and write to a fresh export volume. Added to the example `docker-compose.yml` (commented, next to the export mounts), the Data directory help on Caddy Fleet → edit server, and the Documentation page.
---
## [2.61.1] - 2026-10-08 - The Caddy `command` in the example compose files works now (#7)
Reported again on #7 by @devacc337.
### Fixed
- **The example `command` for the Caddy container crashed it.** The snippet added for #7 (`command: >- mkdir -p /config/caddy; [ -f … ] || echo '{}' > …; exec caddy run …`) is not run through a shell, so Compose started `mkdir` on its own and the container exited immediately with code 0 — with `restart: unless-stopped`, a restart loop, and Caddy never started. (Reproduced with `docker compose` on a clean volume.) It is now `command: [/bin/sh, -c, "…"]`, which seeds an empty `{}` `autosave.json` on first boot and leaves an existing one alone, then resumes from it; the same container starts and resumes normally. Fixed in `docker-compose.yml`, the README (both copies), `DOCKERHUB.md` and `docs/dockerhub.md`.
- **If you copied the old snippet** since it was published, replace the `command:` block of your `caddy` service with the new one and run `docker compose up -d --force-recreate caddy`. Setups that mount their own `autosave.json`, or that never used this snippet, are unaffected.
- The README note now explains why the shell wrapper is needed. No application code changed.
---
## [2.61.0] - 2026-10-07 - Coraza WAF in middleware profiles, and Layer4 on a shared port (#126)
### Added
- **Coraza web application firewall in middleware profiles.** A profile can now enable [Coraza](https://coraza.io) with the OWASP Core Rule Set for every host that uses it: **Detection only** (logs what it would block — visible under Server Logs — and blocks nothing; the recommended first step) or **Block** (answers 403), a **paranoia level** from 1 to 4, a switch for the Core Rule Set itself, and **custom SecLang directives** applied last (for example `SecRuleRemoveById 942100` to silence one rule). The WAF runs first in the host's route, ahead of redirects, forward auth and the upstream, and is added when the config is built — the host row never stores it, and detaching the profile removes it. Verified on a real Coraza build of Caddy 2.11.7: SQL injection and XSS probes got `403` in Block mode, passed (and were logged) in Detection mode, and a host without the profile was untouched. Hosts that share a profile share one rule set (memory stayed flat from 1 to 10 hosts).
- **`applegater/caddyui-caddy` now includes the Coraza module** (`github.com/corazawaf/coraza-caddy/v2` v2.6.x, `http.handlers.waf`) alongside the DNS providers, CrowdSec bouncer, rate limiting and `caddy-l4`. The image is about 5 MB larger. Existing installs keep working unchanged; **to use the WAF, pull the new image and recreate your Caddy** (the module only exists in the new build, so that is a brief restart of Caddy itself).
- **A Caddy without the module is refused up front.** Saving a WAF profile, editing one that is in use, or attaching one to a host first asks every affected server's Caddy whether it has the module (through `/adapt`, which applies nothing) and names the ones that do not, instead of letting Caddy reject that server's entire config and block every later sync. If a rejection still happens, the error now says what to do.
- **Layer4 on a shared port (#126).** A Layer4 proxy can ride a port that is already in use instead of binding its own — for example SSH for `ssh.example.com` over `:443` beside normal HTTPS. Pick *Share the HTTPS / HTTP port* on the Layer4 proxy form, then what to take: connections with given **TLS SNI** names (HTTPS port) or **HTTP hostnames** (HTTP port), or an **SSH**, **RDP** or **PostgreSQL** connection. CaddyUI writes a `caddy-l4` **listener wrapper** on the HTTPS (`srv0`) or HTTP (`caddyui_http`) server; everything that matches no shared proxy carries on to Caddy's normal TLS and HTTP handling. An SNI route can pass the TLS connection through to the upstream (default) or terminate TLS in Caddy and send plain TCP. TCP only — wrapping UDP is not offered. Shared proxies deploy to fleet targets, follow automatic deployment targets and the node-local switch like any other Layer4 proxy.
### Changed
- The wrapper is written in the order Caddy needs: any wrapper you configured yourself (PROXY protocol, for example) first, then CaddyUI's layer4 wrapper, then an explicit `tls` wrapper. Found on a real Caddy: with only the layer4 wrapper listed Caddy applies TLS *first*, so the matchers saw decrypted garbage. CaddyUI only ever replaces or removes the layer4 wrapper it wrote itself; a layer4 wrapper set any other way is left alone.
- The Layer4 form's error for ports 80/443 now points at sharing.
- `Client.Validate`'s documentation was wrong: Caddy ignores `validate_only`, so a config it accepts is also applied. The comment now says so; behaviour is unchanged.
### Notes
- Terminating TLS for a shared SNI route needs a certificate Caddy already holds for that hostname (a proxy host or managed certificate for the same name).
- The `caddy-l4` wrapper makes Caddy log `tls is enabled, but listener wrapper returns a connection that doesn't implement connectionStater` once per connection; HTTPS and HTTP/2 were unaffected in testing.
- Profiles cannot yet be set through the REST API.
---
## [2.60.0] - 2026-10-07 - Middleware profiles (#124) and a configurable fallback status (#123)
Two features requested by @kernaxis (#124, also filed as #125) and @tkkost (#123).
### Added
- **Middleware profiles** — a new admin-only **Middleware Profiles** page. A profile bundles HTTP policy that many hosts share:
- **Security headers** — the standard bundle (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) plus X-Frame-Options, Referrer-Policy, Permissions-Policy and Content-Security-Policy values.
- **Forward auth** — auth URL, method, copied response headers, header prefix and skip paths (works with the v2.59.5 generator).
- **IP restrictions** — an allowlist and a blocklist (IP addresses or CIDR ranges, IPv6 included).
- **Extra headers** — request and response header maps.
Pick a profile on a proxy host (the **Middleware profile** selector; step 3 of the guided publish flow, always visible in the advanced editor). **A value set on the host itself wins** over the profile; empty host fields inherit it; forward auth is taken as one unit (a host with its own auth URL keeps only its own settings); the two header maps merge by name with the host winning. The profile is applied when the Caddy config is built — **the host row is never rewritten**, so detaching a profile restores exactly what the host had.
- **Editing a profile re-syncs every server that has a host using it**, so a change reaches Caddy without re-saving each host. Profiles are global to the controller, so hosts on different fleet servers share one definition; a host deployed to a fleet target carries its profile with it, and changing or removing it on the source updates the copy.
- A profile that is still attached to hosts **cannot be deleted** (the page says how many); deleting a host releases it. Cloning a host keeps its profile. Profile names are unique; entries are validated on save (URLs, CIDRs, header names and values).
- **Configurable fallback status** — **Settings → General → Fallback status code** (`400`–`599`, default `404`, blank = default). It is answered for a request that matches no proxy host, redirection or Advanced Route, on the HTTP **and** HTTPS servers and on the managed wildcard-certificate route, so every kind of unknown host behaves the same. *Custom 404 page HTML* is renamed **Custom fallback page HTML** and is sent with the chosen status (the stored setting is unchanged, so existing pages keep working).
### Notes
- Profiles are managed by administrators; any account that can edit a host can attach one. For a non-admin's host, the normal upstream guard still applies to the resulting route (for example a profile's forward-auth upstream may not point at a blocked address).
- Not part of this first version: WAF / Coraza (needs a Caddy module that the published image does not include), and setting a profile through the REST API (UI only).
- New tables `middleware_profiles` and `proxy_host_profiles` (created automatically on SQLite and MariaDB); nothing changes for a host until you attach a profile.
---
## [2.59.5] - 2026-10-07 - Forward Auth works now: `unknown module: http.handlers.forward_auth` (#124, #125)
Reported by @kernaxis while asking for reusable middleware profiles (#124, duplicated as #125): saving a Proxy Host with a Forward Auth URL failed with `unknown module: http.handlers.forward_auth`.
### Fixed
- **Forward Auth never worked.** `forward_auth` is a Caddyfile *directive*, not a JSON handler module, and CaddyUI has emitted `{"handler":"forward_auth"}` since v2.9.27, so Caddy rejected the whole config for any host that used it. CaddyUI now generates exactly what the directive expands to (checked against Caddy's own `/adapt` output): a `reverse_proxy` to the auth service that rewrites the method and URI, passes `X-Forwarded-Method` / `X-Forwarded-Uri` and the original `Host`, and — on a 2xx answer only — copies the configured response headers onto the request that continues to the real upstream. A non-2xx answer (401/403/redirect to a login page) is relayed to the client by reverse_proxy's own behaviour.
- **Copied headers cannot be spoofed.** Each copied header is deleted from the incoming request before the auth service's value is applied, and is only set when the auth service actually sent it, so a client can never supply `X-Authentik-Username` (or any other copied header) itself.
- The existing options now do what their labels say: *Forward Auth Method*, *Copy auth headers*, *Headers prefix* (applied to the header name placed on the upstream request) and *Skip paths*.
- The Forward Auth URL is validated when you save: it must be `http://` or `https://` with a host (for example `http://authentik:9000/outpost.goauthentik.io/auth/caddy`). If a stored URL is ever unusable, the host answers `503` instead of being served without authentication (fail-closed).
- Because the auth service is now a real upstream, a non-admin account can no longer point Forward Auth at an address the upstream guard blocks (loopback, link-local/metadata, the Caddy admin API).
- Verified end to end on a real Caddy 2.11 with a fake auth service: no cookie → `401` with the auth service's body; valid cookie → `200` and the upstream receives the copied header; a client-supplied copy of the header without valid auth → `401`; a skipped path reaches the upstream without an auth call; a prefix renames the copied header.
### Notes
- Upgrading fixes existing hosts on the next sync (restart with sync-on-start, or any save).
- Reusable Forward Auth / middleware profiles (#124) are a feature request and are tracked for the next feature release.
---
## [2.59.4] - 2026-10-07 - Unknown hosts get a 404 instead of an empty 200 (#123)
Reported by @tkkost: a request for a hostname that matches no Proxy Host, Redirection or Advanced Route got an empty `200 OK` over HTTP, while hosts under a managed wildcard certificate got a 404 over HTTPS.
### Fixed
- **Requests that match no host now always get `404 Not Found`, on both the HTTP (`:80`) and HTTPS (`:443`) server.** CaddyUI only generated a fallback route when custom 404 page HTML had been saved in Settings; otherwise Caddy's default for "no route matched" — an empty 200 — applied (reproduced on a real Caddy 2.11: unknown host → `200`, `Content-Length: 0`, on both listeners; the HTTPS 404 reported came from the managed-certificate route, which exists only on `:443` for the wildcard's own hosts). The fallback is now generated whenever the server has routes: the custom HTML when configured (unchanged: status 404), otherwise Caddy's error handler, which shows CaddyUI's branded 404 page on HTTPS and a plain empty 404 on `:80`.
- The fallback is always the **last** route. On `:80` it sits after the HTTPS-redirect route, so a Force-SSL host is still redirected rather than answered with the 404 — checked on a real Caddy together with an unknown host and a normal proxied host.
- A server with no routes at all is left alone as before: no fallback is added and CaddyUI does not take over an empty listener.
### Notes
- This is a visible behaviour change: anything that relied on `http://<server-ip>/` or an unknown hostname answering `200` (an uptime check pointed at the bare IP, for example) now sees `404`. Point such checks at a real host, or add a host for them.
- A configurable fallback status code and a generalised "custom fallback page" are tracked separately for the next feature release.
---
## [2.59.3] - 2026-10-07 - HTTP-only Advanced routes stay off HTTPS, and failed delete calls fixed (#121)
Reported by @tkkost: an Advanced Route written as `http://127.0.0.1 { respond /health "OK" 200 }` was also generated on the HTTPS listener, so Caddy served it over HTTPS and issued an internal certificate for `127.0.0.1`, which then showed up as a "Direct certificate" in the certificates view.
### Fixed
- **A route written with explicit `http://` addresses is now generated only on the plain `:80` server.** v2.58 stopped such a route from asking for a certificate or forcing HTTPS on save, but CaddyUI still copied it onto `:443` (every non-forced Advanced Route goes on both listeners). The HTTPS side — its routes, certificate skip list and DNS-01 certificate policies — now leaves them out, so the route is not reachable over HTTPS and Caddy no longer issues a certificate for its host. This is applied when the config is built, so existing routes are corrected by the next sync (a restart with sync-on-start, or any save) with no migration. Verified on a real Caddy: the route appears on `caddyui_http` only and no TLS policy mentions the host.
- An `http://` route that an older save left with **Force SSL** on is no longer turned into a redirect to HTTPS on `:80`; it is served as written.
- **Three delete calls to Caddy's admin API were sent to `/config/config/...`**, which real Caddy answers with HTTP 500 ("invalid traversal path"). They were the removal of CaddyUI's `:80` server when no `:80` routes remain, the removal of a stale Advanced-route server that has its own port, and clearing the HTTP/3 restriction when it is switched off. Depending on the call that failed a sync or was silently skipped. Fixed once, in the client, which now accepts the path with or without the leading `/config`.
### Notes
- If one Advanced Route's Caddyfile mixes `http://` and `https://` (or plain) site addresses it is not treated as HTTP-only and stays on both listeners; put the `http://` sites in their own route.
- A certificate Caddy already issued for such a host stays in Caddy's storage (and in the certificates view) until it is removed or expires; no new ones are requested.
---
## [2.59.2] - 2026-10-06 - Code-scanning clean-up (no behaviour change)
### Security
- Two CodeQL `go/clear-text-logging` alerts (high) are closed. Two log lines — the import path skipping a proxy host the non-admin upstream guard refuses, and the sync-time tenant route guard skipping a non-admin-owned route — included a reason string that is derived from a generated route, and CodeQL traced a proxy host's API-key value into it. The reasons only ever contained handler names, upstream addresses or placeholder text, never the stored key, so nothing was leaked; but they no longer appear in the log. The log now says the route was refused and names the resource; the full reason is still shown as a form error when the owner saves the resource. No behaviour change.
---
## [2.59.1] - 2026-10-06 - Deleting the last resource now clears the live Caddy (#120)
Reported by @tkkost right after v2.59.0: when deletion propagation removed the **last** host from a target, the resource disappeared from CaddyUI but its route stayed live in that Caddy.
### Fixed
- **The last proxy host, redirection or advanced route on a server was never removed from the live Caddy.** CaddyUI refuses to push a configuration when it holds no entries for a server (so a fresh or wiped database can never blank a live Caddy), and that guard also swallowed the sync after an explicit deletion — the database was empty, Caddy kept serving the old route. Reproduced on a real Caddy 2: after deleting the only host, a normal sync left both routes live. Deletions now sync with an explicit "empty is intended" switch, so the live config converges to the database. This applies to the target of a **propagated deletion** (v2.59.0) and equally to deleting the last resource by hand — the delete buttons, the bulk-delete actions and the REST API.
- The guard itself is unchanged: ordinary syncs (startup sync, auto-sync, any save) still refuse to push when there are no entries, and a regression test keeps it that way.
- The server form no longer says a target left empty keeps its last live routes.
---
## [2.59.0] - 2026-10-06 - Opt-in deletion propagation to automatic deployment targets (#120)
Requested by @tkkost as a follow-up to v2.57.0: let the source server act as the desired state for its **Automatic deployment targets**, deletions included.
### Added
- **Propagate deletions to automatic deployment targets** — a checkbox in the *Automatic deployment targets* section of a Caddy Fleet entry (**off by default**; nothing changes until an administrator ticks it). When on, deleting a **proxy host**, **redirection**, **advanced route** or **Layer4 proxy** on that server deletes the paired copies CaddyUI previously deployed from it to its automatic targets. This covers the single-delete button, the bulk-delete actions and the REST API.
- **It deletes only what it deployed.** Target-local resources, rows created independently on a target (that were never paired), copies on one-off **Also deploy to** servers that are not automatic targets, and a target row that another source resource is still paired with are all left alone. The deletion removes the copy from the target the same way deleting it there by hand would (including its managed DNS record) and then re-syncs that target's Caddy.
- **Unreachable targets converge later.** If a target's Caddy can't be reached (or its sync is held), the deletion is kept as a *pending deletion* — the pairing is not forgotten — and retried every minute in the background until the target has converged. Pending deletions are listed on the source server's edit page with the attempt count and last error, **Retry now**, and **Discard** (admin-only; for a target that is gone for good). They are also recorded in the Activity Log.
- New table `fleet_pending_deletions` and column `caddy_servers.propagate_deletions` (created automatically on SQLite and MariaDB); deleting a server drops its pending entries.
### Changed
- Deleting a source resource now always forgets that resource's pairings (previously they lingered, so a reused row ID could inherit one), whether or not propagation is on. The Layer4 delete confirmation only says "copies were not removed" when propagation is off.
### Notes
- A copy that was *adopted* by an earlier deployment (a target row with the same identity that the deployment then overwrote) counts as paired and is deleted too.
- Certificates keep their own per-certificate targets and are not part of this.
- As with any CaddyUI sync, a target left with **no** hosts at all is not pushed an empty config, so its last live routes stay until something else is configured there.
---
## [2.58.0] - 2026-10-06 - Layer4 Proxies: a dedicated section for TCP/UDP proxies (#122)
Suggested and thoroughly tested by @tkkost. Until now Layer4 meant pasting a raw `layer4 { ... }` block into a server's edit form. Layer4 proxies are now first-class resources, managed like Proxy Hosts.
### Added
- **Layer4 Proxies** (new sidebar entry, admin-only). Each proxy has a **name**, **protocol** (TCP or UDP), **listen address** (optional) and **port**, **upstream host** and **port**, an **enabled** switch, a **node-local** switch and notes. CaddyUI generates the `caddy-l4` configuration (`apps.layer4.servers.caddyui_l4_<id>`) and pushes it to the server selected in the top bar; per-proxy create, edit, enable/disable and delete. A Caddy that lacks the `caddy-l4` module rejects the config at validation time, so a proxy it cannot run is never saved — use `applegater/caddyui-caddy`, which includes the module.
- **Fleet behaviour like Proxy Hosts.** A saved proxy (and a quick enable/disable) is mirrored to the source server's **Automatic deployment targets** plus any one-off **Also deploy to** picks. A **node-local** proxy never leaves its server. Copies are idempotent and tracked in the same mapping table as hosts; a target that already has a proxy on the same protocol and listen socket adopts it instead of duplicating. **Sync from current** now carries Layer4 proxies too. As with hosts, **deleting a proxy does not delete its copies** on other servers.
- Safety checks: ports 80, 443 and 2019 are refused (they belong to Caddy), the same protocol + socket cannot be used twice on a server, hostnames are validated, and `{env.}` / `{file.}` style placeholders are not accepted as upstreams. TCP and UDP can share a port number.
### Compatibility
- The raw **Layer4 app** Caddyfile editor is unchanged and stays under **Caddy Fleet → edit server** (the new page links to it as the Advanced / raw mode). Both feed the same `apps.layer4`: CaddyUI merges the managed `caddyui_l4_*` servers into whatever the raw block produces, so existing raw configurations keep working untouched.
- Removing the last proxy (or disabling every one) on a server clears the generated servers from Caddy on the next sync.
- New table `layer4_proxies` (created automatically on SQLite and MariaDB); deleting a server also deletes its proxies.
### Fixed
- The test fixture for the Caddy admin API matched the layer4 `DELETE` on the wrong path, so clearing `apps.layer4` was never exercised; the real path is now covered.
---
## [2.57.1] - 2026-10-06 - Security hardening release, Caddy 2.11.7, and #121
A full security audit — independent reviews of authentication and authorization, of injection / SSRF / file access, and of secrets and data exposure, plus a manual review of the paths that turn user input into Caddy config — found a number of access-control and isolation gaps. **None was externally reported**; each was reproduced against the real router before being fixed, and every fix ships with a regression test that fails on the previous code. Most of them need a signed-in account below admin, so a single-operator install with no extra users is much less exposed than a multi-user one — but anyone running **user** or **view** accounts should upgrade. Admin behaviour is unchanged.
### Security
**Access control**
- **Any role could read the live Caddy config, which contains certificate private keys and DNS API tokens.** `/caddy-config`, Import from Caddy (`/import`) and the snapshot list / download sat in the read-for-any-role group, so a read-only viewer could read every uploaded certificate's private key and the DNS provider tokens. They, with **snapshot upload / restore / delete** and the auto-snapshot setting (a customer-level user could upload arbitrary JSON and restore it to take over Caddy) and the Porkbun certificate import (which fetched private keys for any domain on the admin's Porkbun account), are now **admin-only**.
- **A customer-level `user` account could download the whole database** (`/backup`: password hashes, TOTP secrets, every stored credential). It sat in the `requireWrite` group, which blocks only read-only viewers. Now admin-only.
- **`GET /api/v1/certificates` returned `key_pem` for every shared certificate to every role**, including read-only API tokens. Private keys and file paths are now returned only to a browser session or full-scope token of someone who can manage that certificate.
- **Missing per-row ownership checks** on the single-host export (`export.json` / `export.caddyfile`, which carry upstream basic-auth and API-key secrets), maintenance toggle, health page and expectation runner, and on certificate inspect. Any account could read or change another tenant's host by ID.
- **API token scope was enforced only inside one route group**, so a `proxy_write` token could create a full-scope token, create an admin account and change settings. Scope is now enforced for every route, and API tokens cannot manage API tokens.
- **A customer's fleet copy could overwrite another tenant's (or the admin's) host on a target server** by saving a host with the same domain and ticking *Also deploy to* — it adopted the unrelated row and reset its owner. A copy of a tenant-owned row now only updates the row CaddyUI created for it. Certificate export to a directory, certificate source-push and copying certificates between servers are admin-only.
- **REST create/update and the JSON import skipped the duplicate-domain check** the UI enforces (a duplicate sorts first and hijacks the existing host's traffic). The import also trusted uploaded DNS record / zone / certificate ids — a crafted record id made deleting the host delete that DNS record with the admin's credentials. A non-admin can no longer reference another tenant's private certificate id.
- Low-privilege roles saw other tenants' data: live traffic and its stream, the activity log and its CSV, the dashboard's recently-edited widget, every node's admin URL (`/api/v1/servers`) and the upstream list. All are now scoped to what the user may see. Helper endpoints that fetch or adapt user-chosen input (`test-upstream`, `preview`, `validate`) and the AI chat (which spends the admin's LLM key) now need a write-capable account.
**Config injection by non-admin users (the "tenant route guard")**
- **`{env.NAME}` and `{file./path}` in any tenant-controlled string are expanded by Caddy at request time** (verified on a real Caddy 2.11): a custom header, redirect target or static-response body could serve the Caddy process's environment — the shipped compose puts the DNS API token there — or any file the Caddy container can read, including its certificate storage. Caddyfile `{$ENV}` does the same while adapting, and an `import /path` reads files (a parse error echoes its first token).
- Existing guards covered individual fields on individual paths, and each audit found another miss (Advanced routes, additional upstream rules, forward-proxy / forward-auth URLs, the redirection advanced JSON). A single guard now inspects the **generated Caddy route** for every non-admin-owned resource, at save time (clear error) and again when the config is built (enforcement for rows already stored, or saved by any path — UI, REST, import, AI tool, clone): blocked upstreams, `file_server` / `templates` / `acme_server`, dynamic upstreams and process-reading placeholders are refused or neutralised. Caddyfile text from a non-admin is refused *before* it is sent to Caddy. The upstream guard also now understands `tcp/` prefixes, unix sockets, placeholders and integer / hex IP forms, blocks more cloud-metadata addresses, and treats **every registered node's** admin API as off limits, not just the primary's.
- CaddyUI's own probes build URLs from a host's first domain and a custom health check: a customer could set the domain to `10.8.0.2:2019` with a custom `POST` check and have CaddyUI send it to that address every minute. Non-admin domains must now be plain hostnames, custom checks are GET / HEAD only, and the direct upstream probe no longer follows redirects.
**Authentication**
- **The login lockout never fired**: it counted a column the failed-login row doesn't write to. `max_login_attempts` now works. Wrong two-factor codes are limited (5 per login attempt, and an account with 10 recent failures cannot start a new second-factor step — keyed on the account, so a shared proxy address cannot lock everyone out).
- A password change or reset now signs the account out everywhere (the profile change keeps only the current browser); disabling 2FA or regenerating backup codes needs the current password; first-run setup is serialized (two simultaneous requests could create two admins) and enforces the 8-character minimum server-side.
- Password-reset and invitation links were built from the request's `Host` header, so an unauthenticated request with a forged Host got a working reset link mailed to the victim. Links now use **`CADDYUI_PUBLIC_URL`**, else your OIDC redirect URL's origin; set it to close this completely.
**Data exposure, hygiene and stability**
- The request log no longer records query strings (reset and invite tokens, a newly created API token, pending 2FA tokens and the OIDC code all travel there); webhook / ntfy URLs and `CADDY_ADMIN_URL` passwords are redacted from other log lines. CSV exports neutralise spreadsheet formulas. Server data written into `innerHTML` is escaped (certificate names and upstream addresses are tenant-controlled). LIKE wildcards in host matching are escaped (a host of `*.%` matched everything). New password hashes use bcrypt cost 12, as `SECURITY.md` always said (existing hashes keep working).
- **`syncCaddy` swapped a shared client for its whole duration**, so concurrent syncs of different servers pushed each other's routes — certificate keys and basic-auth hashes included — to the wrong node (52 and 72 cross-pushes in 40 iterations in a reproduction). Syncs are now serialized.
### Fixed
- **An explicit `http://` Advanced route no longer needs a certificate or an HTTPS wait** ([issue #121](https://github.com/X4Applegate/caddyui/issues/121)): a route written as `http://127.0.0.1 { … }` now drops its certificate and Force SSL on save, skips the "deploying" page that waited for an ACME certificate that never comes, and the form disables both controls with an explanation. Reported by @tkkost.
### Changed
- Bundled **Caddy 2.11.7** (2.11.6 carries security fixes — a `forward_auth` / `reverse_proxy` upstream-connection mix-up (GHSA-6365-7ppr-5r92), path canonicalisation for `handle_path` / `uri strip_*`, `path_regexp` backslash normalisation — and 2.11.7 fixes regressions from 2.11.6). **Go toolchain 1.26.8**, `go-jose` 4.1.5 and other dependency updates; `govulncheck` finds no affected vulnerabilities. If you run your own Caddy, update it too — the image you pull for CaddyUI's Caddy is rebuilt with these.
### Upgrade notes
- **Non-admin accounts:** routes that use `{env.…}` / `{file.…}` placeholders, `file_server`, or an internal upstream are no longer deployed (the save is refused with a reason; an already-stored route is skipped or neutralised at sync and logged as `route skipped`). Read-only accounts lose snapshots, the live config view and the AI chat; user accounts lose the pages listed above. Admins are unaffected.
- Set `CADDYUI_PUBLIC_URL` if you use password reset or invitations.
- Passwords are re-hashed at cost 12 only when next changed.
### Not addressed here (tracked)
Admin-reachable and hardening items found by the same audit and deliberately left for a follow-up: the Tailwind CDN script on authenticated pages (and the `unsafe-inline` / `unsafe-eval` policy that follows from it); the unauthenticated log-ingest TCP listener on `:9019` (documented as relying on network isolation); DNS-rebinding of upstream host names (the guard is config-time); OIDC account linking by verified email only (no `iss`+`sub` binding or PKCE); upstream TLS verification being off by default for `https://` upstreams; the SMTP STARTTLS downgrade; rate limiting on `/forgot-password`; request-body size limits on JSON endpoints; an optional first-run setup token; per-tenant isolation of the post-sync "expectations" auto-rollback (one tenant's failing check can roll back the whole server's config); and AI-chat error bodies being read unbounded.
---
## [2.57.0] - 2026-10-06 - Automatic deployment targets for Caddy Fleet servers
### Added
- **Persistent default deployment targets** ([issue #120](https://github.com/X4Applegate/caddyui/issues/120)): the per-save **Also deploy to** picker only applied to the save it was ticked on, so a fleet where one Caddy is the configuration source for a fixed set of edge nodes had to re-select the same servers on every create and edit. A Caddy Fleet server's edit form now has an **Automatic deployment targets** section: tick the servers it feeds, and every proxy host, redirection and advanced route created or edited on that server in the web UI — by any user who can save there — is deployed to them on save. It uses exactly the same per-resource deployment as **Also deploy to** — idempotent, hostname changes stay attached to the right copy through the durable source-to-target mapping, target DNS records and custom certificate selections remain server-specific, and a managed wildcard certificate definition that covers the host travels with it — so nothing new has to be learned about how a deployment behaves. On the resource forms the targets appear as always-on rows labelled *default*; the **Also deploy to** picker stays available for one-off extra targets, and marking a resource **node-local** is the per-resource opt-out. Independent of the Layer4 **Also copy to** picker on the same server form, which has its own field. **Off by default and fully additive:** every existing server starts with no targets, so nothing deploys anywhere new until an operator picks some. Deleting a server also removes it from every other server's target list, so a reused ID can never start receiving another server's resources. **Deliberately not included:** deleting a resource does not delete its copies on the targets (propagating deletions is destructive, so it is left for an explicit opt-in); quick enable/disable toggles, bulk actions, Caddyfile and JSON import, and the REST API do not deploy; certificates keep their own per-certificate targets; and resources that already exist are deployed the next time they are saved — for a one-time bulk copy use **Sync from current** on the target. Deployment still happens as part of the save, exactly like **Also deploy to**, so a target that is unreachable can delay a save by up to ~10 seconds (the failure is recorded in the Activity Log); targets run one way, from the source to its edge nodes — a server that already deploys to this one cannot be added as its target, so two servers can never overwrite each other. Regression tests drive the real create and edit handlers for proxy hosts, redirections and advanced routes. Requested by @tkkost.
---
## [2.56.4] - 2026-10-06 - Fix: proxy host Advanced config could not import CADDYFILE_PATH snippets
### Fixed
- **`import <snippet>` in a proxy host's Advanced config was rejected with `File to import not found`** ([issue #119](https://github.com/X4Applegate/caddyui/issues/119)): snippet definitions — `(my_snippet) { ... }` — in the Caddyfile mounted at `CADDYFILE_PATH` were auto-loaded for Advanced routes and the Caddyfile paste importer, but a proxy host's own **Advanced config** was adapted without them, so a valid snippet in the mounted file could not be imported there and the save was refused with Caddy's error. All three paths now share a single snippet loader, so they can't drift apart again. As before, only `(name) { ... }` blocks are taken from the file — never its global options or site blocks — and a snippet the text defines itself wins over the mounted one. The `CADDYFILE_PATH` rows in the README and Docker Hub docs now describe this instead of calling it a paste-import reference only. Regression tests added (they fail without the fix, with the exact error from the report) and the fix was also confirmed against a real Caddy 2.11.4. Reported by @kernaxis.
---
## [2.56.3] - 2026-10-05 - Multi-arch Docker images (linux/amd64 + linux/arm64)
### Changed
- **`applegater/caddyui` and `applegater/caddyui-caddy` are now published as multi-arch images** ([issue #115](https://github.com/X4Applegate/caddyui/issues/115) follow-up): every tag (`:vX.Y.Z`, `:stable`, `:latest`) is now a manifest list covering `linux/amd64` and `linux/arm64`, so `docker pull` on an arm64 host (Raspberry Pi, Ampere, Graviton, Apple Silicon) no longer fails with `no matching manifest for linux/arm64`. Previously only the GitHub release archives had an arm64 build; the Docker images were amd64-only, which blocked arm64 edge nodes in a fleet from running the container at all. The `Dockerfile` and `Dockerfile.caddy` now build on the builder's native platform and cross-compile for the target (`GOOS`/`GOARCH`), so no QEMU emulation is involved and one `docker buildx build --platform linux/amd64,linux/arm64` produces both. The CaddyUI `Dockerfile` now also runs `go test ./web/` before compiling, so a builder that drops embedded `web/static/` assets fails the image build instead of shipping a UI that 404s on its font and stylesheet (issue #37). The old note that multi-arch builds were blocked by a BuildKit embed bug no longer reproduces and has been removed. No change to application behavior; amd64 users are unaffected.
---
## [2.56.2] - 2026-10-05 - Security: closed 4 more SSRF-guard gaps and 2 clone-IDOR gaps
### Security
- **Four more proxy-host creation paths skipped the non-admin upstream SSRF guard ([GHSA-r4wm-rgc5-q834](https://github.com/X4Applegate/caddyui/security/advisories/GHSA-r4wm-rgc5-q834) follow-up):** auditing every proxy-host creation path after v2.56.1's AI-exec-tool fix turned up four more that predate `validateProxyUpstreamsForUser` (added in v2.52.2) and were never updated to call it: uploading a JSON file via **Proxy Hosts → Import**, **Import from Caddy** (pulls the live config and classifies it), pasting a Caddyfile block via **Caddyfile Import**, and reclassifying an existing raw route into a proxy host when the classifier improves. All four are reachable by any non-admin write-capable account and could previously create a proxy host pointed at the Caddy admin API, loopback, or link-local/cloud-metadata addresses. Fixed by applying the same guard every REST and form path already enforces.
- **Clone endpoints were missing the ownership check every other access to the same resource enforces:** a non-admin account could clone — and thereby read the full configuration of, including upstream and advanced config — any other tenant's or the admin's proxy host or redirection host, simply by guessing its ID in `/proxy-hosts/{id}/clone` or `/redirection-hosts/{id}/clone`. Fixed with the same `canManageOwned` ownership check used on every other access to these resources.
Both found during an internal audit, not externally reported. Regression tests added for all six.
---
## [2.56.1] - 2026-10-05 - Security: AI tool-calling endpoint enforced write-role and SSRF guard
### Security
- **Read-only viewers could create proxy hosts and redirections via the AI tool-calling endpoint ([GHSA-5h8j-xxm3-7ggr](https://github.com/X4Applegate/caddyui/security/advisories/GHSA-5h8j-xxm3-7ggr)):** `POST /api/ai/exec-tool` was registered outside the `requireWrite`-protected route group that every other mutating route lives in, so an authenticated `viewer`-role account could call it directly and persist a new proxy host or redirection despite being explicitly blocked from every normal write path. The same handler's `create_proxy_host` action also skipped `validateProxyUpstreamsForUser`, the non-admin upstream guard added for GHSA-r4wm-rgc5-q834 — a non-admin account could use the AI tool as a side channel to point a proxy at the Caddy admin API, loopback, or link-local/cloud-metadata addresses even though the normal creation paths already block exactly that. Both gaps are closed: the route now lives in the same `requireWrite` group as every other mutating endpoint, and `create_proxy_host` now runs the same SSRF validation the REST and form paths already enforce. **Anyone running multi-user CaddyUI with `viewer` accounts and the AI assistant enabled should upgrade.** Reported by Sanidi Peiris ([@sanidipeiris-alt](https://github.com/sanidipeiris-alt)) and Sithum Shihara ([@zaara2004](https://github.com/zaara2004)).
---
## [2.56.0] - 2026-10-05 - Layer4 app support for TCP/UDP routing
### Added
- **Per-server layer4 app support** ([issue #113](https://github.com/X4Applegate/caddyui/issues/113)): Caddy servers running [`caddy-l4`](https://github.com/mholt/caddy-l4) — TCP/UDP-level routing such as SNI/protocol multiplexing and raw proxying — can now paste a complete `layer4 { ... }` Caddyfile block, including the wrapper, into a new field on the Caddy server edit form. CaddyUI sends it to that server's own admin API `/adapt` endpoint and merges only the resulting `apps.layer4` subtree into the config it pushes on every sync; any other key the paste happens to adapt to is discarded, so pasting something unrelated can't contaminate the rest of the config. The block is checked against the server's own admin API immediately when the form is saved — a typo, or a Caddy build without the `caddy-l4` module, is refused by the pre-save validation with Caddy's own error, instead of silently breaking the server's next scheduled sync. A server dedicated entirely to layer4 routing (no proxy hosts, redirects, raw routes, or certificates of its own) now syncs normally rather than being refused by the "nothing to push" guard. The same text can be copied to other fleet members via an **Also copy to** picker — the same UX family as the existing "Also deploy to" pickers — with each target validated against its own admin API independently, so a target whose Caddy build lacks the module is skipped rather than silently broken on its next sync. This pass intentionally covers layer4 specifically, not general Caddyfile global-options support (snippets, `trusted_proxies`, and the rest) — see the issue thread for why a fully general "paste any global option" box isn't safe on top of CaddyUI's generated server blocks. Empty by default on every existing server, so this is fully additive. Requires a Caddy build with `caddy-l4` compiled in — the published [`applegater/caddyui-caddy`](https://hub.docker.com/r/applegater/caddyui-caddy) image now includes it. Regression tests in `layer4_test.go`. Requested by @BenRLange.
---
## [2.55.0] - 2026-10-05 - Share one managed ACME certificate across fleet nodes
### Added
- **Opt-in "source push" fleet distribution for Managed ACME certificates** ([issue #115](https://github.com/X4Applegate/caddyui/issues/115)): the "Also configure on" picker on a Managed ACME certificate previously only cloned the DNS-01 *definition* to each target server, so every target independently obtained and renewed the same certificate — multiplying ACME orders against the CA's rate limits and requiring DNS API credentials on every edge node, for fleets that just want one shared wildcard. A new **"Push this certificate instead of letting each server obtain its own"** toggle (shown once a Managed ACME certificate has at least one target checked) switches selected targets to receiving the source's actual certificate and private key instead: the source server still performs the ACME order, and CaddyUI pushes the resulting PEM to the targets through the existing PEM fleet-copy machinery, so they need no DNS credentials and get no `apps.tls.automation.policies` entry of their own. Renewals are picked up automatically by the certificate lifecycle reconciler (within about an hour) without re-saving the certificate form; a first push attempted before the source has completed its own ACME order is skipped and simply retried on the next pass rather than pushing an empty certificate. Requires the source server's **Data directory** to be set (the same prerequisite as the existing "Export to a directory" feature), since reading a private key needs direct access to Caddy's on-disk certificate storage — a live TLS probe can only ever reveal the public certificate, never the key. **Off by default and fully additive:** every certificate that doesn't explicitly opt in keeps today's independent-ACME behavior with no change. Requested by @tkkost.
---
## [2.54.0] - 2026-10-05 - Configurable ACME DNS-01 resolver
### Added
- **Configurable DNS resolver for Caddy's own ACME DNS-01 challenge** ([issue #116](https://github.com/X4Applegate/caddyui/issues/116)): Caddy's DNS-01 solver checks TXT-record propagation using the container's default resolver, which breaks split-horizon DNS setups where the same zone is served privately inside the network and publicly (e.g. via Cloudflare) for ACME validation — the internal resolver returns the private view and the propagation check never sees the record it just created. **Settings → DNS → ACME DNS-01 resolvers** now points Caddy's propagation checks at public or otherwise-reachable resolvers (comma-separated `host:port`, `:53` assumed, IPv6 as `[::1]:53`), independent of the existing "Verification resolver" setting, which only affects CaddyUI's own deploy/readiness checks. Applies to every DNS-01 automation policy — Managed DNS proxy hosts, redirects, raw routes, and managed certificates alike. Leave blank to keep using Caddy's own default resolver. Requested by @tkkost.
---
## [2.53.0] - 2026-09-24 - Security: certificate file reads confined to allowlisted directories
### Security
- **Certificate file reads are now confined to allowlisted directories (review finding #8, defence in depth):** `safeAbsolutePath` rejected `..` and required an absolute path, but confined nothing — so an admin-configured certificate or key path could name *any* absolute file the CaddyUI process could read (`/etc/shadow`, another tenant's key), and its contents could surface through Inspect or be inlined into the database by fleet sync. Reads now go through `confinedReadPath`, which additionally requires the file to sit inside an allowed root: the directory holding CaddyUI's database, every Caddy node's mounted **Data directory**, the conventional locations (`/etc/caddy`, `/etc/ssl`, `/etc/letsencrypt`, `/certs`, `/data`), and any directory added in the new setting below. Symlinks are resolved on **both** sides before comparing, and the resolved path is what gets opened, so a symlink planted inside an allowed root cannot step out of it and there is no gap between the check and the read. A root of `/` is dropped rather than honoured, and with no usable roots nothing is readable (fails closed). Requires admin access to exploit, so this is hardening rather than a privilege boundary. Regression tests in `paths_test.go`.
**This does not affect certificate serving.** Caddy loads certificate files itself, inside the Caddy container — CaddyUI only reads them as a convenience for the expiry column, Inspect, the probe fallback, and fleet copy-by-value. A certificate outside the allowed roots still serves exactly as before; it simply reports as unreadable in the UI, the same way a path that was never mounted into CaddyUI already does, and fleet sync copies it by path reference instead of inlining it as a PEM.
### Added
- **Settings → Security → "Extra certificate directories":** one absolute path per line, for deployments that mount certificates somewhere unconventional. Saved normalised (de-duplicated, unusable entries dropped), and the page lists the roots currently in effect so the confinement is never an invisible DB-only setting. Leave it empty unless CaddyUI reports a certificate it should be able to read.
---
## [2.52.6] - 2026-09-21 - Fix: per-host Basic Auth now enforced + configurable AI chat timeout
### Fixed
- **Per-host HTTP Basic Auth was never enforced ([#110](https://github.com/X4Applegate/caddyui/issues/110)):** enabling "Require Login" on a proxy host stored the bcrypt-hashed users and showed them in the form, but `BuildProxyRoute` never emitted Caddy's `authentication`/`http_basic` handler into the route JSON that is pushed to the live server — so requests passed straight through with no login prompt. CaddyUI now renders the `authentication` handler (bcrypt accounts + realm) as the last gate before `reverse_proxy`, so Basic Auth is actually enforced. Half-filled credential rows (blank username or hash) are skipped. **Anyone relying on per-host Basic Auth should upgrade and re-verify their protected hosts prompt for a login.** Reported by @Bappy1988. (The Caddyfile *export* still lists Basic Auth as a note rather than a directive — it is a lossy, non-round-tripping export; the live config is JSON.)
### Added
- **Configurable AI assistant request timeout ([#109](https://github.com/X4Applegate/caddyui/issues/109)):** the AI chat turn had a fixed 90-second deadline, which cut off slow local-inference rigs with `context deadline exceeded`. Settings → AI assistant now has a **Request timeout (seconds)** field (default 90, range 5–3600) so users running large local models with heavy CPU offload can give a turn the minutes it needs. Requested by @Bappy1988.
---
## [2.52.5] - 2026-09-21 - Session tokens hashed at rest + access-group collaborative management
### Security
- **Session tokens are now stored hashed (defence in depth):** the `sessions` table previously held the raw cookie token, so a database disclosure (backup leak, snapshot, or SQLi elsewhere) yielded directly replayable session cookies. Tokens are now stored as their SHA-256 (`auth.HashSessionToken`), mirroring the already-hashed `api_tokens` table; the raw token lives only in the user's cookie. Regression test in `auth_test.go`. **Upgrade note:** existing sessions are invalidated once — everyone signs in again after updating.
### Changed
- **Access-groups now grant collaborative management (review finding #7):** members of a shared access-group could already *see* each other's proxy hosts, redirection hosts, raw routes, and certificates in list views, but opening/editing/toggling/deleting one returned `403`. Group peers can now fully manage resources owned by users they share a group with (admins and direct owners unchanged; global/NULL-owner rows stay admin-only). All single-object REST and HTML handlers, the bulk handlers, and the list-view edit predicates now funnel through one authorization gate (`Server.canManageOwned`, backed by `models.UsersShareGroup`), so the "visible but not manageable" asymmetry is closed and future handlers cannot drift. Regression test in `group_management_test.go`.
---
## [2.52.4] - 2026-09-21 - Security: login rate-limit hardening + server package split
### Security
- **Login brute-force limiter no longer bypassable via header spoofing (Medium):** the per-IP failed-login lockout keyed on an IP taken from `X-Real-IP` / `X-Forwarded-For` that was trusted unconditionally, so a directly connected attacker could rotate a forged header per request and never trip `max_login_attempts`. The rate-limit key now comes from `rateLimitClientIP`, which only honours forwarding headers when the immediate peer is a trusted proxy — the admin-configured `trusted_proxies` CIDRs, or (when that setting is empty) loopback/private/link-local peers, i.e. the usual "CaddyUI behind a local reverse proxy" topology. When trusted, `X-Real-IP` wins, otherwise the right-most `X-Forwarded-For` entry (the hop appended by the closest proxy, which a client cannot forge); untrusted peers key on the direct connection address. Backed by a regression test (`ratelimit_ip_test.go`). Activity-log display is unchanged.
### Changed
- **`internal/server/server.go` split for maintainability:** the ~17k-line handler file was decomposed (no behavior change) into focused same-package files — `api_v1.go`, `proxy_hosts.go`, `certificates.go`, `raw_routes.go`, `upstream_health.go`, `upstream_health_notifier.go`, and `dns_handlers.go` — dropping `server.go` to ~7k lines. Purely a file reorganization; `go vet`, `gofmt`, and the full test suite pass unchanged.
---
## [2.52.3] - 2026-09-21 - Security: bulk proxy-host ownership + toolchain refresh
### Security
- **Bulk proxy-host toggle/maintenance per-row ownership (IDOR, High):** the HTML bulk endpoints `POST /proxy-hosts/bulk-toggle` and `POST /proxy-hosts/bulk-maintenance` did not enforce the per-row `owner_id` check that the single-object handlers and every other bulk handler (delete, certificate, raw-route toggle, redirection toggle) already apply. A non-admin `user`-role account could enable/disable or flip maintenance mode on **any** proxy host — including hosts owned by other tenants or admins — by submitting arbitrary `ids[]`, an availability/tampering vector on other tenants' routing. Both handlers now skip rows the caller does not own (admins remain unrestricted), matching the ownership model introduced in v2.52.1/v2.52.2. Backed by a new regression test (`bulk_ownership_test.go`). **Multi-account deployments should upgrade.** Found during an internal code review.
- **Go toolchain pinned to 1.26.6 (native binaries):** added a `toolchain go1.26.6` directive so the standalone release binaries build on Go 1.26.6, clearing 7 reachable Go standard-library advisories present in 1.26.5 (GO-2026-6218 `net/url`, GO-2026-6091 `html/template`, GO-2026-6090 `crypto/tls`, GO-2026-6089 / GO-2026-5026 `net/http`, GO-2026-6088 `encoding/xml`, GO-2026-5972 `encoding/asn1`). `govulncheck ./...` now reports no reachable vulnerabilities. The Docker image already builds on `golang:1.27-alpine` and was unaffected.
---
## [2.52.2] - 2026-09-20 - Security: proxy-host upstream SSRF guard
### Security
- **Proxy-host upstream SSRF guard for non-admin accounts** ([GHSA-r4wm-rgc5-q834](https://github.com/X4Applegate/caddyui/security/advisories/GHSA-r4wm-rgc5-q834), **High**): the third, lower-confidence finding noted (but not verified or fixed) in the v2.52.1 advisory is now confirmed and closed. A proxy host's `forward_host`/`forward_port`, its extra upstreams, and its upstream Host-header override flowed unvalidated into Caddy's `reverse_proxy` dial address. A low-privilege `user`-role account (per-row ownership was added in v2.52.1) could therefore create or edit a proxy host whose upstream pointed at the CaddyUI host's own loopback services, cloud metadata (`169.254.169.254`), or the Caddy admin API (`http://localhost:2019`) and reach those internal-only endpoints through the proxy (SSRF). Combined with the user-settable upstream Host-header override — which defeats the admin API's origin check — this exposed `GET /config/` (full config disclosure) and `POST /load` (RCE-grade config rewrite), reproduced end-to-end against a real Caddy. CaddyUI now validates every upstream on create **and** update, in both the REST `/api/v1` handlers and the HTML-form handlers: non-admin accounts may not target loopback, unspecified, or link-local addresses, or the configured Caddy admin endpoint. Admins remain unrestricted and private-LAN upstreams stay allowed, so delegated users can still front their own internal apps and the common single-admin "proxy to a localhost app" setup is unaffected. Backed by unit and end-to-end handler regression tests. **Deployments that grant non-admin `user`-role accounts should upgrade.** Reported by [@kta1kri](https://github.com/kta1kri).
---
## [2.52.1] - 2026-09-20 - Security: REST API v1 authorization fix
### Security
- **REST API v1 per-row ownership enforcement** ([GHSA-r4wm-rgc5-q834](https://github.com/X4Applegate/caddyui/security/advisories/GHSA-r4wm-rgc5-q834), **Critical**): the `/api/v1` JSON handlers for certificates, raw routes, redirection hosts, and the proxy-host toggle/maintenance endpoints omitted the per-row `owner_id` check that the HTML-form handlers already enforce. As a result, any authenticated account — including the read-only `view` role — could read any certificate's TLS private key via `GET /api/v1/certificates/{id}`, and any `user`-role account could read, overwrite, delete, or toggle other tenants' raw routes, redirection hosts, and proxy hosts. All per-row `/api/v1` handlers now pass through a single shared authorization gate (admins may access any row; other accounts only rows they validly own; NULL-owner rows are denied for non-admins), backed by a regression test. **Multi-account deployments should upgrade.** Reported by [@kta1kri](https://github.com/kta1kri).
### Added
- **Scheduled off-host database backups** ([issue #104](https://github.com/X4Applegate/caddyui/issues/104)): under **Settings → Backup**, enable automatic SQLite snapshots (`VACUUM INTO`) written to a directory on an interval, keeping the newest N. Point the directory at a mounted volume, network share, or object-store gateway to keep copies off the host. A **Back up now** button runs one on demand. (MariaDB installs continue to use their platform's own backup tooling.)
- **OIDC / SSO login** ([issue #106](https://github.com/X4Applegate/caddyui/issues/106)): sign in to CaddyUI through an external identity provider (Authelia, Authentik, Keycloak, Google, …) alongside local password + TOTP — configure it under **Settings → Security**. Uses the standard auth-code flow with `state` + `nonce`, and delegates ID-token verification (JWKS, signature, `iss`/`aud`/`exp`, nonce) to the vetted `go-oidc` library. A verified email is required; it matches an existing CaddyUI user by email, or — opt-in — provisions a read-only account on first sign-in. Local login always remains available.
## [2.51.0] - 2026-09-17 - Per-host rate limiting
### Added
- **Per-host rate limiting** ([issue #102](https://github.com/X4Applegate/caddyui/issues/102)): a proxy host can now cap requests — *allow N requests per M seconds, per client IP* — returning **429** on excess via Caddy's sliding-window `rate_limit` handler (keyed by client IP, so it honours Trusted Proxies). Configured under a host's **IP Allowlist / Blocklist** section. A natural companion to the v2.50.0 blocklist: soft-limit noisy or probing clients without a hard ban.
### Changed
- `Dockerfile.caddy` now builds the [`caddy-ratelimit`](https://github.com/mholt/caddy-ratelimit) module. To use rate limiting, run the batteries-included Caddy — enabling it on a Caddy build without the module is refused by the pre-save validation with Caddy's error.
- **Published custom Caddy image** [`applegater/caddyui-caddy`](https://hub.docker.com/r/applegater/caddyui-caddy) (`:stable`/`:latest`/`:v2.51.0`) — the prebuilt `Dockerfile.caddy` with all DNS providers, the CrowdSec bouncer and the rate-limit module, so fleet nodes pull one image instead of each running xcaddy. The Compose file now references it; uncomment the `build:` block to build locally instead.
## [2.50.0] - 2026-09-17 - Block an IP straight from analytics
### Added
- **Block a visitor from the analytics drill-down** ([issue #100](https://github.com/X4Applegate/caddyui/issues/100)): the per-visitor page now has admin-only **Block on <host>** and **Block everywhere (fleet-wide)** actions. "On host" appends the IP (as a `/32` or `/128`) to that host's existing IP blocklist; "everywhere" adds it to a new **global blocklist** applied as a top-level 403 route ahead of all host routing, on every non-external server. Both re-sync automatically. This closes the loop from the v2.47–2.48 drill-down: see who's probing, block them in one click.
- **Global IP blocklist** under **Settings → Security** — view and edit (unblock) the fleet-wide list; one IP/CIDR per line.
## [2.49.0] - 2026-09-17 - Configurable verification resolver
### Added
- **Configurable DNS resolver for verification lookups** ([issue #98](https://github.com/X4Applegate/caddyui/issues/98)): the deploy/readiness checks (proxy hosts and Advanced routes) resolve records over Cloudflare DNS-over-HTTPS by default, which stalls in environments where outbound DNS to Cloudflare is filtered. **Settings → DNS → Verification resolver** now overrides this: leave it blank for the Cloudflare DoH default, enter one or more plain DNS servers (`192.168.1.10:53`, comma-separated; `:53` assumed) to use trusted, internal, or split-horizon resolvers, or a DoH URL that supports the JSON API. Requested by @BenRLange.
## [2.48.0] - 2026-09-16 - Drill down by status class
### Added
- **Status-class drill-down** ([issue #94](https://github.com/X4Applegate/caddyui/issues/94)): the **Status mix** card and the **HTTP status breakdown** legend on a host's Analytics page are now clickable. Selecting a class (2xx/3xx/4xx/5xx/other) opens a view of the exact status codes within it, the top paths that produced them and the top visitors that received them — with the paths and IPs linking on into the per-path and per-visitor views. Same window/server scoping and top-N bounds as the rest of the drill-down. Requested by @BenRLange.
## [2.47.0] - 2026-09-15 - Deeper analytics drill-down
### Added
- **Per-visitor and per-path analytics drill-down** ([issue #94](https://github.com/X4Applegate/caddyui/issues/94)): on a host's Analytics page, client IPs and paths are now links. Clicking a **visitor** opens a view of that IP's top paths, status breakdown, exact error detail and user-agents — useful for telling a noisy/misconfigured client apart from someone probing or fuzzing. Clicking a **path** shows which visitors requested it and its status breakdown. Everything stays scoped to the selected window (1d/7d/30d) and server filter, and reuses the existing per-host access-event range scans.
- **Exact error detail** on the host page and both drill-downs: instead of only a "4xx" total, an **Error detail** table breaks errors down by exact status code + path (e.g. `404 /wp-login.php ×840`), so you can see what is actually failing or being probed.
---
## [2.46.0] - 2026-09-14 - Internal CA for local services
### Added
- **Serve a proxy host from Caddy's internal CA** ([discussion #91](https://github.com/X4Applegate/caddyui/discussions/91)): a new **Internal CA (self-signed)** option in a proxy host's TLS section issues that host's certificate from Caddy's built-in local CA instead of ACME/Let's Encrypt — the equivalent of `tls internal` — for services on a private network where a publicly trusted certificate isn't wanted or possible. On sync CaddyUI emits a `tls.automation` policy scoped to the host's domains with the `internal` issuer, and excludes the host from DNS-01 issuance. The `internal` issuer is a core Caddy module, so no special Caddy build is required; clients must trust Caddy's root CA. Applies only when the TLS certificate is left on **Auto** (a custom or DNS-01 managed certificate still takes precedence).
---
## [2.45.2] - 2026-09-10 - Security headers were never sent
### Fixed
- **Response-header handlers that both deleted and set the same header shipped no header at all.** Caddy applies header operations in the order add → set → delete, so the v2.10.4/v2.10.5 "delete-then-set" pattern used by the Security Headers bundle, the standalone Permissions-Policy, X-Robots-Tag (both toggles), `add_x_xss_protection_disabled` and `add_content_type_nosniff` deleted our own value right after setting it. Every host with Security Headers enabled was serving no `Strict-Transport-Security`, `X-Frame-Options`, `X-Content-Type-Options` or `Referrer-Policy` (Nextcloud's setup check flagged all four). Verified against Caddy 2.11.2. The handlers now `set` only — `set` already replaces any upstream value, so nothing stacks. Existing hosts are fixed on the next sync; no form changes needed.
- **Security Headers bundle now respects the per-host "Strip response headers" list**, the same way it already respected the global one. A host that strips `X-Frame-Options` with the bundle on used to get `SAMEORIGIN` back from the bundle's own set (masked until now by the delete bug above). Regression test added.
---
## [2.45.1] - 2026-09-10 - Gandi collision wording
### Fixed
- The Gandi provider's "record set already exists" message, its docs section and the v2.45.0 release note told users to pick "Override" on the collision warning — an action removed in v2.5.6. They now say what is true: CaddyUI never overwrites records it did not create; remove or change the existing set in the Gandi console, then save again. Caught by the new Claude pull-request review.
---
## [2.45.0] - 2026-09-10 - Gandi managed DNS
### Added
- **Gandi provider support** ([discussion #72](https://github.com/X4Applegate/caddyui/discussions/72)): CaddyUI drives Gandi's LiveDNS v5 API with a Personal Access Token — domain discovery for the zone picker, managed A-record creation, collision detection and cleanup for proxy hosts, redirections and Advanced routes, and the same token is handed to Caddy for DNS-01 certificate issuance (`github.com/caddy-dns/gandi`, now built into `Dockerfile.caddy`). LiveDNS keeps records as sets keyed by name and type, so CaddyUI references them as `name/type`, refuses to create over an existing set (CaddyUI never overwrites records it did not create — remove the set in the Gandi console first), and raises TTLs below the LiveDNS minimum of 300 seconds. Settings → DNS gets a Gandi card and the in-app guide a Gandi section.
### Changed
- **CI**: pull requests are now reviewed by Claude (`anthropics/claude-code-action`) instead of the Codex GitHub App — an automatic review on every non-draft, non-Dependabot pull request, and `@claude` in an issue or PR comment for questions and follow-up changes. Runs on the maintainer's Claude subscription via a `CLAUDE_CODE_OAUTH_TOKEN` repository secret; the jobs skip themselves when the secret is absent. No effect on the shipped image.
---
## [2.44.0] - 2026-09-09 - Settings is now a set of pages
### Changed
- **Settings is split into eight pages** — General, Notifications, DNS, Security, Analytics, Integrations, AI assistant, Backup — with a navigation column (a scrolling row on narrow screens) instead of one form with sixteen cards and a row of jump links. `/settings` opens General; each page lives at `/settings/<page>`, and old in-page links such as `/settings#settings-smtp` are redirected to the page that now holds the card.
- Each page saves only its own settings. The save handler now owns a table of which page holds which key and writes nothing else, so saving General cannot blank the SMTP host or the analytics target. DNS provider credentials and integration settings are only collected from their own pages. Saving Notifications, AI or Backup no longer triggers a Caddy sync, which they never affected.
- Links across the app and the docs that pointed at Settings anchors now open the right page directly.
---
## [2.43.1] - 2026-09-09 - The first big prune no longer slows every page
### Fixed
- The first analytics prune after upgrading to v2.43.0 — tens of millions of backlog rows on an install that had never pruned — made every page take several seconds for the whole run. CaddyUI's SQLite pool is a single connection, and the prune ran its 20,000-row batches back to back, so each request waited for the next batch to finish. Batches are now 5,000 rows with a 200 ms pause between them, so requests interleave; the run takes a little longer and the UI stays responsive. Progress is logged every million rows.
---
## [2.43.0] - 2026-09-09 - Analytics retention actually runs, is configurable, and space can be reclaimed
### Fixed
- **Raw analytics events were never pruned.** The 30-day prune loop has existed since v2.7.0, but nothing ever started it, so `access_events` grew without bound — one production database had reached 22 GB with 63 million rows older than 30 days. The loop now starts with the other background workers, runs an hour after start-up and hourly after that, and deletes in batches of 20,000 so the write lock is never held for long even on a first run against months of backlog.
### Added
- **Settings → Analytics → Keep raw events for N days** (default 30; 0 keeps forever). Per-day totals per host are kept in a separate rollup, so shortening the retention does not blank long-range charts.
- A **Storage** panel on the same card: database file size, space already freed inside the file, oldest and newest event, the last prune (rows removed and kept) and the last reclaim, with **Prune now** and **Reclaim space** buttons. Reclaim runs SQLite's VACUUM in the background — the only thing that actually shrinks the file after rows are deleted — and is refused when the data volume lacks room for a full copy; on MariaDB the panel points at `OPTIMIZE TABLE` instead. Both actions are recorded in the Activity log.
- The Docker image sets `SQLITE_TMPDIR=/data` so VACUUM has a working directory in the scratch-based image, which has no `/tmp`.
---
## [2.42.2] - 2026-09-08 - Advanced config: JSON-style transport names and misplaced transport options are repaired
### Fixed
- Saving a proxy host whose Advanced config used Caddy's JSON field names inside a transport block — `read_buffer_size 16384` / `write_buffer_size 16384`, as reported for a Nextcloud host — failed with `unrecognized subdirective read_buffer_size`. In a Caddyfile those options are spelled `read_buffer`, `write_buffer` and `max_response_header`; CaddyUI now respells them, and moves transport-only options typed directly under `reverse_proxy` (timeouts, buffers, keepalive, TLS, versions…) into the block's `transport http { … }`, merging with an existing one. The repairs also apply when an already-saved Advanced config is adapted for a sync, so such hosts start working on the next sync without being re-saved.
- When Caddy still rejects an unknown sub-directive, the message now says which name it is, how it is spelled in a Caddyfile, where transport options go, and that the same settings exist as dedicated form options (Upstream → timeouts and buffers, Streaming → Flush immediately).
---
## [2.42.1] - 2026-09-08 - Changes Caddy rejects are refused up front, failed syncs are visible
### Fixed
- **A proxy host, redirection or certificate that Caddy would not load was saved anyway** ([#74](https://github.com/X4Applegate/caddyui/issues/74)). The form said "saved", the sync failed only in the log, the live config stayed as it was — and every later sync for that server failed the same way. The reported case: a file-path certificate whose files exist on the host but not inside the Caddy container. Proxy hosts, redirections and certificates are now validated against Caddy before they are saved, as Advanced routes already were; a file Caddy cannot open comes back as "Caddy cannot open /path for certificate X … the file must exist inside the Caddy container — mount the directory into Caddy or paste the PEM instead".
- **A failed sync is now visible.** When a sync is rejected for any reason after a save, an amber banner on every page names the server and Caddy's error, with **Retry sync now** and **Dismiss**, until the next sync succeeds.
- The Caddyfile view of a host with a custom certificate rendered only a comment; a file-path certificate now renders as a real `tls <cert> <key>` line, and stored PEM / managed certificates say how Caddy gets them. The Caddyfile export does the same.
- The Caddyfile view and export printed an Advanced config `reverse_proxy { … }` block verbatim after the host's own `reverse_proxy` block, i.e. as a second one; its sub-directives now render inside the generated block, marked "from Advanced config".
- A reverse_proxy sub-directive typed bare in a host's Advanced config — `flush_interval -1` on its own — is now moved into a `reverse_proxy { … }` block automatically (merged into an existing block when there is one, multi-line sub-directives included) instead of being explained back to you.
### Security
- Two CodeQL findings from v2.38.0: the post-apply banner's "back" redirect no longer accepts a protocol-relative `//host` path, and expectation paths are normalised to exactly one leading slash.
---
## [2.42.0] - 2026-09-08 - Export a managed certificate to a directory
### Added
- **Export to a directory** on managed (ACME) certificates. Other services on the same host — a mail server is the usual case — need the certificate Caddy obtains, and need it again after every renewal. Mount the node's Caddy data volume into the CaddyUI container (read-only is enough, e.g. `caddy_data:/caddy-data:ro`), enter that mount path as the server's new **Data directory** under Caddy Fleet → edit, then give the certificate an export directory and file names (defaults `fullchain.pem` and `privkey.pem`). CaddyUI locates the newest certificate Caddy stored for the definition's domains — across issuers, wildcard names included — and copies the chain and key out atomically, the key readable by owner only. Exports run when the node reports an issuance or renewal (the certificate-event stream CaddyUI already receives), every 10 minutes as a safety net, after the certificate is saved, and on **Export now**; nothing is rewritten while the stored serial is unchanged. The form shows the last export (serial, expiry, files, or the error) and every export is recorded in the Activity log.
- With this, a wildcard that a certbot job maintained for a mail server can move to Managed ACME: Caddy renews it and CaddyUI drops the files where the mail server reads them.
### Changed
- Certificate file paths and export directories typed into forms are vetted before any file access: absolute, cleaned, no `..`.
### Fixed
- Editing a managed certificate showed the Paste PEM text areas above the managed settings; the PEM section is now hidden unless PEM is the selected source.
---
## [2.41.0] - 2026-09-08 - Fleet sync and "Also configure on" for PEM and file-path certificates
### Added
- **Custom certificates travel with the fleet.** Caddy Fleet → Sync configuration and the certificate form's **Also configure on** picker previously copied managed (ACME) definitions only, so a wildcard uploaded as PEM or referenced by file path had to be re-uploaded on every node by hand. Both now copy every certificate: a PEM certificate is stored on the target with its certificate and private key (save it again after replacing the PEM to push a renewal), and a file-path certificate is copied as stored PEM when CaddyUI can read both files — the target host cannot see the source's disk — or by path reference otherwise, in which case the sync summary and Activity log say so and the files must exist on the target at the same paths.
- Proxy hosts, redirections and Advanced routes that a sync or **Also deploy to** creates on a target now reference the copy of their certificate instead of silently falling back to Auto TLS; **Also deploy to** copies a host's custom certificate first so the reference resolves. Existing target hosts keep their own certificate choice, as before.
### Changed
- The fleet sync summary reads "certificates: N added, N updated" (formerly "managed certificates") and appends how many were copied by file path only. The picker's help text describes what travels for each source, and the docs Certificates section too.
---
## [2.40.0] - 2026-09-08 - Advanced config accepts a reverse_proxy block
### Added
- A proxy host's **Advanced config** can now carry a `reverse_proxy { … }` block with no upstream address. Its sub-directives — `flush_interval -1`, `header_up` / `header_down`, `transport http { … }`, `lb_policy`, active and passive health checks, `trusted_proxies`, and the rest — are adapted through Caddy and merged into the host's own generated `reverse_proxy` handler, so the Forward host / port and every option in the form still apply and nested settings (a `transport` with TLS already configured, upstream request headers) combine rather than replace. Blocks that name an upstream or sit behind a matcher are refused with a message saying why.
### Fixed
- Typing a reverse_proxy sub-directive bare in Advanced config — the reported case was `flush_interval -1` — came back from Caddy as `Caddyfile:2: unrecognized directive: flush_interval`, which says nothing about what to do. CaddyUI now catches every reverse_proxy sub-directive at the top level before adapting and explains that it belongs inside a `reverse_proxy { … }` block (with the block spelled out), and points at the dedicated form option where one exists (Streaming → Flush immediately for `flush_interval`).
- The form's help text listed `reverse_proxy` among the rejected directives; it now describes the block instead.
---
## [2.39.0] - 2026-09-08 - File-path certificates show expiry via a live TLS probe
### Added
- **Live TLS probe for custom certificates.** A *file path* certificate names files inside the Caddy container, which CaddyUI usually cannot read from its own container — so the Certificates page showed no expiry at all for them, and Inspect gave up. CaddyUI now opens a TLS connection to the Caddy node for the certificate's domain (the same handshake it already uses to confirm managed certificates) and reads what is actually served: expiry, days left, issuer, SANs, key type, serial and fingerprint. Probes run every 30 minutes, after a certificate is saved, when **Refresh status** is pressed, and before Inspect renders when the last result is stale. Results are kept in the database so they survive a restart.
- The Certificates list marks such rows *live TLS probe*, shows **Not served** when Caddy answered with a certificate for a different name, and **Unverified** when the node could not be reached (hover for the reason). Inspect gets a **Live check** card for every PEM and file-path certificate with a **Probe now** button.
- When CaddyUI *can* read the file (or the PEM is stored), the Live check compares it with what Caddy serves and says **Different certificate** when the serial numbers differ — the usual sign of a file renewed on disk that Caddy has not reloaded yet. The list shows the same warning inline.
- The Operations dashboard's "custom certificates expire soon / are expired" recommendations now count file-path certificates too, using the file when readable and the live probe otherwise. They previously counted stored PEMs only.
### Changed
- **Zero-code alternative documented:** mount the certificate directory into the CaddyUI container read-only at the same path Caddy sees (for example `- /certs:/certs:ro`), and CaddyUI reads the file directly. The docs Certificates section, the file-path form hint and a commented line in `docker-compose.yml` now say so.
---
## [2.38.1] - 2026-09-08 - Dependency updates
### Changed
- **Go modules**: `github.com/aws/aws-sdk-go-v2` 1.43.7 → 1.45.1 (with `credentials` 1.20.2 and `service/route53` 1.68.0), `github.com/go-sql-driver/mysql` 1.10.0 → 1.10.1, `golang.org/x/crypto` 0.55.0 → 0.56.0 and `modernc.org/sqlite` 1.57.0 → 1.58.0. The module's minimum Go version moved from 1.25 to 1.26; the Docker image already builds with Go 1.27, so nothing changes for the shipped binary beyond the newer libraries. No application code changed. (#71)
---
## [2.38.0] - 2026-09-04 - Expectations: post-apply checks with automatic rollback
### Added
- **Expectations** on proxy hosts: a table of checks — method, scheme, path, expected status (or any 2xx/3xx), optional Location prefix for redirects, maximum latency, valid-TLS requirement — that CaddyUI runs against the host's domain right after **every** config sync. If any check fails and **Settings → Post-apply checks → Roll back automatically** is on (the default), the previous live config is loaded straight back into Caddy, automatic syncs for that server are paused, and a banner on every page offers **Re-apply now** or **Keep the rolled-back config**. Off means report-only. Results show on each host's health page (with a **Run now** button) and every run, failure, rollback and re-apply is recorded in the Activity log. Suggested by Matt Holt on the Caddy forum: define the behaviour your sites must keep, and let the tool refuse a change that breaks it.
---
## [2.37.1] - 2026-09-04 - Live Traffic feed shows the date, and Clear sticks
### Fixed
- The Live Traffic feed stamped rows with the time only, like Server Logs did before v2.35.5; rows now show `YYYY-MM-DD HH:MM:SS` in CaddyUI's configured timezone, both for the rows rendered with the page and for those that stream in. (#60 follow-up)
- **Clear** on the Live Traffic feed only emptied the table, and every row came straight back on the next load. It now remembers, per browser and per server filter, the newest request seen when it was pressed: those rows stay hidden after a reload and the stream resumes after them. Nothing in the Analytics database is touched.
---
## [2.37.0] - 2026-09-04 - Each Caddy node ships its logs to a target it can reach
### Fixed
- Every node in the fleet was configured to send its access logs, certificate events and runtime logs to the one fleet-wide ingest target, which defaults to `caddyui:9019` — a Docker service name that only a Caddy on the same Docker network can resolve. Nodes on other hosts therefore shipped nothing, silently: an empty Analytics page for that server, no certificate issuance status, and no Server Logs capture. Each node now has its own **Log ingest target** on its Caddy Fleet entry (blank = the fleet-wide setting), applied to all three log streams.
### Added
- The Caddy Fleet list shows where each managed node ships its logs, and warns when a node reached at another host was handed a bare Docker service name it cannot resolve; the Analytics page shows the same for the node in scope when it has no traffic, instead of an unexplained empty page. Settings → Analytics now says when the fleet-wide default is not enough.
---
## [2.36.2] - 2026-09-04 - Certificates page lists Advanced routes
### Fixed
- The **Caddy-managed (ACME)** section of the Certificates page listed the domains of proxy hosts and redirects but never those of Advanced routes — even though Caddy issues certificates for their host matchers in exactly the same way, and CaddyUI's certificate lifecycle tracking already followed them. Enabled Advanced routes without a custom certificate now appear there too, with the same wildcard mapping and issuance status as everything else. (#65)
## [2.36.1] - 2026-09-04 - Advanced routes on custom ports get their own listener
### Fixed
- An Advanced route pasted as `:7070 { … }` never listened on port 7070. The Caddyfile adapter puts each site address on its own server, but CaddyUI flattened every server's routes into one list — dropping the port — and nested them all under its :443/:80 servers. Advanced routes now remember their listen address(es), and every sync emits one `apps.http.servers.caddyui_listen_<port>` entry per distinct port set: routes sharing a port share a server, stale entries are removed when a port changes or the route is deleted, and servers the operator added by hand under other names are never touched. The port is captured automatically from the Caddyfile block on paste, import and edit; JSON-only routes get a **Listen on** field. Routes on the standard ports keep exactly today's placement, and validate-before-save now includes the new servers, so a port conflict is refused at save time instead of breaking the next sync. (#64)
- A block on a custom port that looks like a plain reverse proxy (`example.com:7070 { reverse_proxy … }`) now stays an Advanced route on paste and import instead of being auto-classified into a proxy host, which has no notion of a port and would have silently lost it.
## [2.36.0] - 2026-09-04 - Health checks can use any HTTP method
### Added
- **Custom monitoring → Method** now offers POST, PUT, PATCH, DELETE and OPTIONS alongside GET and HEAD. A route that only accepts one method — a POST-only API endpoint, say — answers `405` to a GET or HEAD probe from Caddy itself, so the request never reaches the backend and "expected status 405" only ever proved that Caddy was up. Choosing the method the route accepts lets the probe get through to the service. Both HTTP probes (the App/Port check and the Public check) honour it. Methods other than GET/HEAD are sent with an empty body; the form warns that they can have side effects, so point them at an endpoint that tolerates an empty request. Blank or unrecognised values still fall back to GET; TRACE and CONNECT are deliberately not offered. (#59)
## [2.35.5] - 2026-09-04 - Server logs show the date, and Clear now clears
### Fixed
- The Server Logs page stamped every entry with the time only. CaddyUI's runtime-log buffer keeps the last 1000 entries for as long as the process runs, so on a quiet node it spans days and the times read out of order. Entries now show `YYYY-MM-DD HH:MM:SS` in CaddyUI's configured timezone — the same layout the Activity log already uses. (#60)
- **Clear screen** only emptied the table; every entry came straight back on the next page load because the buffer behind it was untouched. The button is now **Clear logs**: it discards the buffered entries for the selected server as well (nothing on disk is involved — runtime logs are never persisted) and records the purge in the Activity log. New endpoint `POST /api/server-logs/clear`. (#60)
## [2.35.4] - 2026-09-04 - One rendering per Advanced route
### Fixed
- `/raw-routes` drew every Advanced route twice at tablet-to-laptop widths (768–1024 CSS px — which is also where a zoomed desktop browser lands): the desktop table became visible at the `md` breakpoint, but the mobile card list only hid from `lg`. Both now switch at `lg`, matching `/certificates` and `/snapshots`. The second copy was the same database row rendered again, not a duplicate route — which is why deleting "one" of them removed "both".
- A template guard test now asserts the table and card-list breakpoints on `/raw-routes` stay paired, so the mismatch can't silently come back.
## [2.35.3] - 2026-09-02 - Go 1.27 build toolchain
### Changed
- **Go toolchain**: the Docker build stage now compiles with Go 1.27 (`golang:1.27-alpine`, up from 1.25). No application code changed — the shipped binary is the same source built on the newer toolchain. (#56)
- **CI**: GitHub Actions workflows moved to `actions/checkout@v7`. No effect on the shipped image. (#57)
## [2.35.2] - 2026-08-31 - SSL Verify Upstream docs fixed, HTTPS-only condition surfaced
### Fixed
- The Caddyfile → form-field mapping table in the docs referred to the "Verify Upstream TLS Certificate" checkbox by its internal field name, "SSL Verify Upstream", instead of its actual UI label — making the field hard to find (#54).
- The checkbox only affects the generated config when Forward Scheme is `https`; toggling it under the default `http` scheme was silently a no-op, and that precondition lived only in a small form hint, not the docs table. The docs table now states it, and the form itself shows an inline warning when the toggle is currently inert for the selected scheme.
## [2.35.1] - 2026-08-21 - An always-present button to the Public health page
### Fixed
- The "Public" health dot on the proxy hosts list was the only way to reach `/proxy-hosts/{id}/health`, and it stops being a clickable link precisely when there's the least data to look at — monitoring off, or no check recorded yet. That was a dead end right where it mattered most. A small, always-present history icon now sits next to the dot regardless of its state, visible to any viewer (it's read-only status, not an edit action).
## [2.35.0] - 2026-08-21 - Monitoring "Off" now covers all three probes
### Fixed
- The v2.28.0 monitoring section promised that setting a host to **Off** would stop "all outbound probes for this host." It didn't. A third probe — the persisted "Public" health check behind `/proxy-hosts/{id}/health` and the left-most dot on the proxy hosts list, which runs every 5 minutes and keeps 24 hours of history — was never wired to it, and kept probing every enabled host regardless of `MonitorMode`.
- The same gap meant **Custom** mode's path, method, and expected status only ever applied to the App and Port dots. A host with a non-default health path or an unusual expected status could still show the Public dot red, because that check always requested `/` and only accepted 2xx/3xx/401/403.
- Both are fixed: the Public checker now reads the same `MonitorSettings` as the other two, is skipped entirely when a host is Off, and paces a custom interval against its own persisted history so pacing survives a restart.
- The proxy hosts list and the health detail page no longer show a stale "up" or "down" status left over from before a host was switched to Off — both check the current `MonitorMode` first and show a neutral "monitoring off" state instead.
- The monitoring section's copy on the proxy host form now names all three probes it drives and links to the Public check's history page, instead of only mentioning the App and Port dots.
`NodeLocal` is deliberately not consulted by the Public checker — it describes where a host's *upstream* resolves, not whether its *public domain* should be checked from the internet-facing side, which is what this probe does.
## [2.34.0] - 2026-08-21 - One place for the update notice
### Removed
- The **"↑ vX.Y.Z available"** pill in the sidebar footer. The Operations dashboard already announces the same update in context and links straight to the release, so the pill was a second place to notice it and a second place to dismiss it. The running version stays in the sidebar, where it is useful on every page.
- The `badge-pulse` animation and `#update-badge` rule that existed only for the pill.
The dashboard banner is unchanged.
## [2.33.0] - 2026-08-21 - Node-local proxy hosts and advanced routes
### Added
- Proxy hosts and advanced routes can be marked **node-local**, which excludes them from full fleet syncs and from **Also deploy to**. Use it when the upstream only means something on the node that owns it — a Docker service name like `immich_server`, a VPN address, a loopback port. Copying such a host to another Caddy produced a route pointing at nothing.
- Fleet sync reports what it left behind: *"skipped as node-local: 3 proxies, 1 advanced routes"*. Skipped resources are counted rather than silently dropped, so a sync summary can be read at face value.
- The proxy host form warns when the upstream looks node-local but the host isn't marked as such — a single-label hostname with no dot, matching the rule CaddyUI already uses to decide it can't resolve a backend itself. Marking the host node-local dismisses the warning and hides the deploy picker.
- Node-local hosts carry a badge in the proxy host list, next to the upstream that pinned them.
### Notes
- Fleet sync copies a host's upstream verbatim, which is correct for **edge replication** — several Caddy nodes fronting the same backends — and wrong for **federated nodes**, where each Caddy fronts its own local stack. `preserveProxyTargetPolicy` already treated certificates, DNS records and keys as node-specific; the upstream address was the field most likely to differ and was still being copied. This closes that gap without introducing per-node upstream overrides.
- The flag is opt-in and defaults to off, so existing hosts sync exactly as before.
## [2.32.0] - 2026-08-20 - Fix bulk-action bar covering the last row
### Fixed
- The floating "N selected" bulk-action bar no longer covers the last row of a long list. It is `position: fixed` to the bottom of the viewport and never reserved space for itself, so once a list needed scrolling — a shorter window, or simply enough proxy hosts / redirections / advanced routes / certificates — the last row's Edit and Delete buttons sat directly under it, overlapping into an unreadable jumble as soon as any rows were selected and you scrolled to the bottom.
- The scroll container now reserves exactly the bar's own rendered height (which can grow if it wraps to two lines on a narrow window) as bottom padding whenever the bar is visible, and releases it when the selection is cleared. Fixed centrally, so it applies to every page using this bar without per-page changes.
## [2.31.0] - 2026-08-20 - Analytics performance at scale
### Fixed
- The Analytics page no longer slows down as proxy hosts are added to a node. Measured on 84,000 access events across 21 hosts, it went from **1.18 s to 0.31 s** end-to-end; the query responsible went from 1068 ms to 109 ms.
- The cause was the server-scoped "top hosts" aggregation (`WHERE ts >= ? AND server_id = ? GROUP BY host`). No index could supply group order for it, so SQLite built temporary B-trees for the grouping, the distinct-visitor count and the sort, and fetched every matching row from the table to read `host` and `client_ip`. A covering index on `(server_id, host, ts, client_ip)` supplies the group order directly and carries every column the query reads.
- Only the server-scoped view was affected, which is why this appeared as "it got slow once I had about twenty hosts". The fleet-wide view already had an index giving it group order and was never slow.
- The index is created automatically on upgrade. Building it takes well under a second on a database of this size, and adds roughly two microseconds per ingested event.
### Added
- Regression tests asserting the query plan still uses the covering index and still avoids a temporary B-tree for `GROUP BY`, so this cannot silently regress.
## [2.30.0] - 2026-08-20 - Deterministic config, visible migrations, CSP
### Fixed
- Generated Caddy config is now byte-identical for identical input. Three handlers built a JSON array by iterating a Go map — the security-headers `delete` list, and the request and response header `delete` lists — and Go randomises map iteration. A host with **Security Headers** enabled produced 5 distinct payloads across 300 identical calls. Because Caddy compares configuration structurally, every sync of an unchanged host looked like a real change: an avoidable config reload each time, and a spurious entry in every config snapshot diff. The emitted configuration is semantically unchanged; only its ordering is now stable.
- Schema-upgrade steps report their failures. 324 of them were written as `_, _ = db.Exec(...)`, so a failed `ALTER TABLE` left the column missing and the application failed later at query time, far from the cause — the shape of [#30](https://github.com/X4Applegate/caddyui/issues/30). Failures are now logged individually and summarised once at the end of the upgrade.
### Security
- CaddyUI's own responses now carry a `Content-Security-Policy` and a `Permissions-Policy`. `X-Frame-Options`, `X-Content-Type-Options` and `Referrer-Policy` have been sent since v2.10.3; CSP was the missing piece, and it matters because authenticated pages load the Tailwind runtime from a CDN. The policy allows exactly the origins the templates use and nothing else. `form-action 'self'` and `connect-src 'self'` are the directives doing the real work.
- `CADDYUI_CSP` overrides the policy: unset uses the default, `off` disables the header, any other value is sent verbatim. A subtly wrong CSP breaks the UI for everyone at once, so the escape hatch does not require waiting for a patch release.
### Added
- Golden-file coverage for `BuildProxyRoute`, which generates the route JSON pushed to Caddy. It is the highest-consequence code in the project and previously had no tests. Regenerate intentional changes with `go test ./internal/caddy -update-golden`.
- A determinism test that fails if any handler is ever added that builds a JSON array from a map without sorting.
- Tests for `internal/auth` — password hashing, session issue/expire/delete, and cookie flags. Coverage went from none of its own to 87.8%.
- `.github/dependabot.yml`. Security updates already worked without it, which is why Dependabot PRs had been merged before; *version* updates need explicit configuration, so routine dependency bumps were never opened.
- CI now fails on unformatted code. Four files had drifted out of `gofmt`; they are reformatted here (whitespace only).
### Changed
- `parseProxyHostForm` and its helper moved verbatim into `internal/server/proxy_host_form.go`. At ~660 lines it was the largest function in the codebase, inside a 16,600-line file. No behaviour changed.
## [2.29.0] - 2026-08-20 - CSRF protection
### Security
- State-changing requests authenticated by session cookie now require a CSRF token. Session cookies were already `SameSite=Lax`, which blocks the classic cross-site form POST in current browsers, but that was a single layer resting on a browser default; this adds a synchroniser token as defence in depth across all 94 authenticated `POST`/`DELETE` routes.
- The token is `HMAC-SHA256(secret, sessionToken)`, so it needs no storage of its own and is bound to one session. Logging out or logging in again rotates it, and a token from a closed session is rejected. The secret is generated once and persisted, so tokens survive a restart rather than 403-ing every logged-in user on deploy.
- Requests authenticated by API bearer token are exempt and unchanged. A browser does not attach an `Authorization` header to a cross-site request on its own, so those calls are not CSRF-reachable.
### Changed
- Pages render into a buffer before being written. This is what lets the hidden token input be stamped into every POST form from a single place, and as a side effect a mid-template error now produces a clean `500` instead of a truncated page followed by an error string.
- `window.fetch` is wrapped in `app.js` to attach the `X-CSRF-Token` header to same-origin requests using an unsafe method, so scripted callers are protected by default. Cross-origin requests are untouched — the token is never sent to a third-party host.
## [2.28.0] - 2026-08-20 - Configurable status monitoring
### Added
- Proxy hosts now control how CaddyUI's own status probes behave, via a **CaddyUI status monitoring** section on the host form. Three modes: **Automatic** (unchanged behaviour), **Custom** (probe path, method, expected status, interval and timeout), and **Off**. ([#39](https://github.com/X4Applegate/caddyui/issues/39))
- Setting a host to **Off** stops CaddyUI issuing any probe for it — both the 60-second public-domain check behind the App dot and the direct upstream check behind the Port dot. Intended for backends that are deliberately unreachable from the CaddyUI container (split-horizon DNS, firewalled networks), where the probes reported a misleading "down" and added recurring noise to firewall logs.
- Hosts with monitoring off show a neutral grey dot labelled "monitoring off for this host" rather than a red "down" — no probe ran, so no verdict is claimed.
### Changed
- The host form now states which system each health setting belongs to. CaddyUI's own monitoring and Caddy's active upstream health checks are separate mechanisms that were easy to confuse; the existing `health_check_*` options configure Caddy and are unchanged.
- Custom probe intervals are floored at 60 seconds and round up to a multiple of the poller's 60-second tick. Timeouts are clamped to 1–120 seconds and expected status to 100–599; an out-of-range entry is pulled to the nearest bound rather than failing the save.
### Fixed
- `ListProxyHostSummaries` now projects `monitor_mode`, which `/api/upstream-health` reads. Without it the Port dot would have resolved every host to "automatic" and ignored the off switch.
## [2.27.0] - 2026-08-20 - Self-contained assets and advanced-route cross-deploy
### Fixed
- Two-factor enrolment now renders its QR code server-side as an embedded PNG instead of loading a script from `cdn.jsdelivr.net`. That CDN path began returning 404 and was additionally blocked by CORB, so no QR code appeared during 2FA setup. ([#37](https://github.com/X4Applegate/caddyui/issues/37))
- The Inter variable font is now committed to the repository rather than downloaded during the Docker image build. Because the font was fetched only at image-build time, binaries built from a source checkout — including every published GitHub release archive — embedded no font file and served 404s for it. ([#37](https://github.com/X4Applegate/caddyui/issues/37))
- Turnstile and reCAPTCHA secret keys are no longer rendered into the settings form. `type="password"` masked them visually, but the plaintext remained readable through the DOM. They now follow the same keep-blank-to-preserve pattern already used for the SMTP password and AI provider keys.
### Added
- Advanced routes now offer the **Also deploy to** picker that proxy hosts and redirections already had. Mirroring an advanced route onto another fleet node previously required a full sync of the destination server, which overwrote unrelated configuration there. ([#38](https://github.com/X4Applegate/caddyui/issues/38))
- Regression coverage asserting that every asset referenced by a template is committed and embedded, that no template loads a third-party script, and that captcha secrets never reach the DOM.
### Removed
- htmx is no longer vendored or loaded. It was added in v2.12.39 and shipped on every authenticated page, but no template ever carried an `hx-*` attribute — every form is a plain `<form method="post">`. This removes a ~48 KB script from every page load, one build-time network fetch, and the vestigial `HX-Trigger` response header on the Caddy reload endpoint.
## [2.26.0] - 2026-08-18 - Live route configuration preview
### Added
- Proxy-host forms now provide an in-browser Caddyfile excerpt alongside the generated Caddy route JSON, both updated from unsaved form changes and copyable without downloading a file. ([#33](https://github.com/X4Applegate/caddyui/issues/33))
- A **Configured** form mode hides option sections with no configured values while keeping the live configuration preview available.
### Fixed
- The existing route JSON preview now opens and refreshes correctly; its client-side lookup previously referenced an ID that was not present in the form.
## [2.25.4] - 2026-08-18 - Operations fleet-node scoping
### Fixed
- Operations traffic totals and sparklines now filter by the selected fleet node as well as hostname, so shared domains no longer merge traffic from multiple Caddy servers.
- The Operations Caddy-version lookup and upstream telemetry now use the selected node's Admin API and saved Basic Auth credentials instead of the bootstrap Caddy client or an unauthenticated request.
- The System health panel now clearly separates CaddyUI-host uptime/load/memory from selected-node Caddy request/upstream telemetry, and the certificate tile is labeled as selected-node certificate definitions.
## [2.25.3] - 2026-08-15 - Certificate and log-stream review follow-up
### Fixed
- The persistent certificate monitor now receives Caddy's DEBUG `events/cert_obtained` records through a dedicated named logger while filtering unrelated event and TLS debug traffic out of the in-memory Server Logs ring.
- Startup certificate reconciliation probes and verifies every identifier independently. A valid certificate for one name in a multi-domain managed definition can no longer mark unverified sibling names as Issued.
- Server Logs retention now evicts the oldest entry ID when the 500-row display limit is reached, regardless of the active visual sort order.
### Changed
- Added regression coverage for all three late automated review findings on PR #31.
## [2.25.2] - 2026-08-14 - Observability reliability follow-up
### Fixed
- Analytics and fleet access-log forwarding no longer select a CaddyUI-owned logger as an HTTP server's default. Existing console, journal, file, and per-host logger configuration is preserved, while legacy v2.25.0/v2.25.1 overrides are repaired automatically.
- Certificate lifecycle success can no longer regress to **Obtaining** when Caddy emits its post-issuance `releasing lock` cleanup line. Structured certificate events are also recognized as authoritative lifecycle signals.
- Managed certificates with missing or stale obtaining/renewing history are reconciled at startup and periodically from the certificate actually served by each managed Caddy node. Retry, error, and revoked states remain visible until Caddy reports a real recovery.
- Server Logs now shows whether its live stream is connected or reconnecting and provides a one-click **Refresh / reconnect** action.
### Added
- Sortable columns on the Server Logs, custom certificate, and Caddy-managed certificate tables.
- A **Refresh status** action on the Certificates page.
- Regression coverage for existing Caddy logging preservation, lifecycle cleanup ordering, startup certificate reconciliation, and the new operational UI controls. ([#26](https://github.com/X4Applegate/caddyui/issues/26))
## [2.25.1] - 2026-08-14 - SQLite upgrade hotfix
### Fixed
- Existing SQLite installations now add the new analytics `server_id` and `server_name` columns before creating the fleet-attribution index. This fixes the CaddyUI startup crash introduced by v2.25.0 while preserving existing analytics rows as legacy/unattributed data.
- Added a regression test that opens and migrates the pre-v2.25.0 analytics schema, verifies the original event data, and confirms the new index is created.
## [2.25.0] - 2026-08-14 - Fleet observability and certificate lifecycle
### Added
- **Certificate lifecycle visibility** projects Caddy's structured TLS logs into per-node states such as obtaining, renewing, retry scheduled, issued, revoked, and issuance error. Managed-certificate lists and live server probes now show the latest actionable message instead of an unconditional green status. Only the compact latest state is persisted; raw logs are not. ([#26](https://github.com/X4Applegate/caddyui/issues/26))
- **Ephemeral Server Logs** gives administrators an in-app, structured runtime stream for a selected managed Caddy node with level and text filters. Full capture leaves Caddy's normal logs unchanged, stays in memory, and automatically disables after 15 minutes. ([#27](https://github.com/X4Applegate/caddyui/issues/27))
- **Authoritative fleet attribution** stamps every analytics event with the Caddy node that handled the request. Live Traffic, Analytics, host drill-downs, and CSV exports can distinguish and filter nodes even when load-balanced servers share the same hostname. ([#28](https://github.com/X4Applegate/caddyui/issues/28))
### Changed
- CaddyUI installs additive named log streams for analytics, certificate events, and temporary runtime capture. Existing Caddy container, journal, and file log outputs remain unchanged.
- Existing analytics rows migrate as legacy/unattributed data instead of guessing a source server from current route ownership.
- SQLite and MariaDB schemas now store access-event server identity and the latest certificate lifecycle projection, with parity and migration coverage for both backends.
## [2.24.0] - 2026-08-13 - Detailed Prometheus metrics
### Added
- **Fleet-scoped Prometheus metrics controls** in Settings enable Caddy HTTP metrics, per-host labels, and `observe_catchall_hosts` on explicitly selected managed servers. The latter is validated against each target and requires Caddy 2.11 or newer. ([#25](https://github.com/X4Applegate/caddyui/issues/25))
- The Metrics card shows the private `/metrics` scrape target derived from each selected server's protected Admin URL and warns against exposing Caddy's unauthenticated admin port publicly.
- Metrics can be configured even on a fresh Caddy server with no CaddyUI-managed routes or certificates.
### Fixed
- Routine CaddyUI route syncs now have an explicit regression guarantee that global/app-level metrics loaded from a Caddyfile or written through the admin API remain untouched unless the administrator selects that server for CaddyUI metrics management. Detailed `per_host`, `observe_catchall_hosts`, and externally managed fields such as `otlp` are preserved.
### Changed
- The repository's custom Caddy image is pinned to Caddy 2.11.4 so the shipped Compose build supports catch-all host metrics deterministically.
## [2.23.2] - 2026-08-13 - Persistent guidance toast fix
### Fixed
- Permanent Settings guidance for Ollama model sizing, Porkbun API access, Route 53 IAM permissions, and CrowdSec modules now remains inline instead of being recreated as warning toasts on every page load.
- Toast auto-conversion now uses explicit `data-toast` markers rather than inferring notification intent from presentation colors. Transient success, warning, and error feedback still appears as toasts without swallowing informational cards, status pills, or documentation callouts.
## [2.23.1] - 2026-08-12 - Route 53 DNS-01 reliability
### Fixed
- Route 53 DNS-01 automation now pins Caddy's provider configuration to the exact public hosted zone selected in CaddyUI. This prevents `_acme-challenge` TXT records from landing in a similarly named or split-horizon zone. ([#24](https://github.com/X4Applegate/caddyui/issues/24))
- Managed DNS automation policies now replace stale policies for the same certificate subjects while preserving unrelated and catch-all policies.
- DNS-01 provider configuration is included in Caddy's preflight validation, so a missing provider module or invalid configuration is reported before route subtrees are changed.
- Saving managed DNS with public A-record creation enabled now gives an actionable form error when the selected Caddy server has no public IP, instead of failing later at the provider.
### Added
- Proxy hosts, redirections, and advanced routes can disable **Create public A records** while continuing to use the selected provider and zone for ACME DNS-01. This supports internal and split-DNS services without publishing a WAN address.
- DNS-01-only mode is persisted across SQLite and MariaDB, preserved as target-specific policy during fleet sync, and shown explicitly in deployment status.
## [2.23.0] - 2026-08-12 - Amazon Route 53 managed DNS
### Added
- **Amazon Route 53 provider support** for public hosted-zone discovery and managed A-record creation, collision detection, retargeting, and cleanup across proxy hosts, redirections, and advanced routes. Existing records are never overwritten automatically. ([#24](https://github.com/X4Applegate/caddyui/issues/24))
- Route 53 credentials in Settings support long-lived IAM access keys plus optional STS session tokens and partition-specific regions. The access-key pair is also emitted to Caddy for ordinary and wildcard DNS-01 certificate issuance.
- The custom Caddy image now builds `github.com/caddy-dns/route53` v1.6.2, with regression tests covering provider configuration, pagination, aliases, and exact record-set deletion.
- The in-app guide includes a scoped IAM policy template covering the CaddyUI record lifecycle and Caddy's DNS-01 challenge flow.
### Security
- Private Route 53 hosted zones are intentionally excluded from the managed-DNS picker because public ACME resolvers cannot query them and split-horizon duplicate names are easy to select accidentally.
- Record cleanup preserves the complete Route 53 record-set shape for the exact-delete semantics required by AWS. CaddyUI refuses to delete Traffic Flow-managed records so it cannot leave a billable traffic-policy instance orphaned.
### Changed
- DNS credential descriptors can now mark region/session fields optional, and blank non-secret settings can be cleared without disturbing saved secret values.
---
## [2.22.0] - 2026-08-12 - Idempotent fleet configuration sync
### Added
- **One-click full routing sync** on Administration → Caddy Fleet merges every proxy host, redirection, advanced route, and managed certificate definition from the environment selected in the application shell into another managed Caddy server. Target-only routes remain intact, while target-specific DNS records and custom certificate choices are preserved. ([#23](https://github.com/X4Applegate/caddyui/issues/23))
- Fleet deployments now persist source-to-target resource mappings so later source edits—including hostname, label, and managed-certificate subject changes—update the same target rows.
### Fixed
- **“Also deploy to” is now idempotent.** Repeating a proxy, redirection, or managed-certificate deployment updates the existing target resource instead of inserting a duplicate.
- Cross-deploy validates managed source/target environments and applies the same proxy/redirect domain-collision safeguards as ordinary resource creation.
- Fleet copies no longer carry source DNS record IDs or custom certificate IDs into another server's configuration.
### Changed
- Added regression coverage for repeated deployment, tracked hostname changes, target-policy preservation, domain collisions, full routing sync, and SQLite/MariaDB schema parity.
---
## [2.21.2] - 2026-08-10 - Resilient analytics log forwarding
### Added
- **Analytics log forwarding can no longer make Caddy depend on CaddyUI at startup.** Settings → Analytics now exposes Caddy's network-writer `soft_start` option and enables it by default, so Caddy loads its config when the CaddyUI ingest listener is unavailable and sends logs to stderr until the connection recovers. ([#22](https://github.com/X4Applegate/caddyui/issues/22))
- **Configurable analytics connection timeout** limits network-writer connection attempts to 1–60 seconds and defaults to 5 seconds.
### Changed
- Enabled analytics loggers are reconciled across managed fleet servers when CaddyUI starts, allowing upgrades to apply the safer writer defaults without requiring an administrator to re-save Settings.
- Added regression tests for generated `soft_start` / `dial_timeout` JSON, upgrade defaults, explicit overrides, and startup reconciliation.
---
## [2.21.1] - 2026-08-10 - Proxy advanced config fleet validation
### Fixed
- **Proxy-host Advanced Config now validates against the selected Caddy Fleet server** instead of always adapting snippets through the default bootstrap client. Hosts saved while a non-primary server is selected no longer fail Advanced Config validation just because that environment uses a different admin URL or module set. ([#21](https://github.com/X4Applegate/caddyui/issues/21))
- Added a regression test covering request-scoped Advanced Config validation so future fleet changes keep using the active server's admin API during proxy-host save operations.
---
## [2.21.0] - 2026-08-01 - Fleet access logging and CrowdSec protection
### Added
- **Independent Caddy file access logs** in Settings, with fleet-server targeting, HTTP/HTTPS scope, native JSON or console encoding, absolute file path, maximum size, retained-file count, and retention-period controls.
- **Analytics-safe logging composition** preserves CaddyUI's existing network analytics logger when file logging is enabled; disabling either destination leaves the other destination intact.
- **CrowdSec bouncer integration** configures the `crowdsec` Caddy app and injects `http.handlers.crowdsec` before every protected CaddyUI-generated proxy, redirect, advanced, and plain-HTTP route.
- **CrowdSec fleet controls** include selected servers, masked keep-existing API keys, streaming mode, refresh interval, hard-fail policy, exact-host exclusions, path-pattern exclusions, pre-save module validation, and a live LAPI connection test.
- **Configurable client IP headers** complement trusted proxy ranges so CrowdSec and access logs can use Caddy's resolved client IP behind Cloudflare or another load balancer.
- The repository's custom Caddy build now includes `github.com/hslatman/caddy-crowdsec-bouncer/http`, and the Compose stack persists `/var/log/caddy` in a named volume.
### Fixed
- Trusted proxy and client-IP-header settings are now written to both CaddyUI-generated HTTP servers during sync and stale values are removed when cleared.
---
## [2.20.2] - 2026-07-30 - Fleet API and TLS/version reliability
### Fixed
- **Version checks now follow every Docker Hub tag page** and select the highest semantic version across the complete repository. Installations no longer report an old `v2.9.x` release after newer tags move beyond the first page.
- **Port-qualified proxy and redirection domains now use port-free Caddy host matchers and certificate identities**. A host such as `project.sub.example.com:8000` correctly reuses a managed `*.sub.example.com` wildcard instead of attempting an invalid exact certificate order containing `:8000`.
- DNS-01 automation subjects, custom-certificate exclusions, wildcard discovery, and cross-server wildcard deployment now share the same normalized hostname behavior.
### Added
- **Read-only fleet API**: authenticated clients can call `GET /api/v1/servers` with a session or API token to retrieve server IDs, names, admin URLs, management types, health, Caddy versions, tags, and last-contact timestamps without scraping the Caddy Fleet page.
- The fleet response intentionally excludes admin credentials and public IP configuration. It is available to every authenticated role, matching the fleet switcher already rendered in the signed-in application shell.
---
## [2.20.1] - 2026-07-29 - Actionable unused-certificate cleanup
### Fixed
- **Unused certificate recommendations now identify the certificates** instead of linking to an unfiltered list with only a count.
- **Managed ACME definitions are no longer reported as unused**. Standalone managed and wildcard certificates are intentionally automated by Caddy and are not attached through a resource `certificate_id`.
- Certificate usage is checked against every proxy host, redirection, and advanced route in the current environment, preventing tenant-scoped views from incorrectly labeling a shared certificate as unused.
### Added
- **Filtered cleanup view**: the Operations recommendation opens `/certificates?usage=unused`, where only unassigned PEM/file-path certificates appear and every row carries an **Unused** marker.
- **Named dashboard detail**: the recommendation includes the first affected certificate names before the operator leaves Operations.
- **Persistent dismissal**: administrators can dismiss the cleanup recommendation for the selected Caddy environment. The exact unused-certificate set is remembered; the recommendation automatically returns if a different certificate becomes unused later.
- **Cleanup guidance** explains that assigning or deleting the listed certificates clears the condition.
---
## [2.20.0] - 2026-07-29 - Enterprise workflows and guided operations
### Added
- **First-run account wizard**: new installations begin with a clear four-stage operating journey—create the administrator, connect Caddy, secure the account, and publish the first service.
- **Live readiness guide**: **Getting Started** derives progress from the actual selected Caddy environment, TOTP state, DNS profiles, and proxy inventory instead of storing a dismissible tutorial flag. Administrators can return to it whenever an environment changes.
- **Guided service publishing**: new proxy hosts now use a focused Hostname → Upstream → Policy → Review workflow. The final review shows the effective hostname, upstream, HTTPS policy, DNS automation, and initial state before CaddyUI writes configuration.
- **Advanced-editor escape hatch**: experienced operators can open the complete proxy-host editor with one click. Existing hosts continue to use the full editor, and guided creation preserves every established advanced default.
- **Global create menu**: the persistent application bar provides direct entry points for publishing a service, creating a redirection, adding a certificate, or building an advanced route.
- **Environment readiness on Operations**: incomplete environments receive a compact, actionable readiness summary that links back to the live guide.
### Changed
- **Semantic color contract** is now explicit and consistent: blue means action/current context, green means healthy/enabled/completed, amber means pending/degraded/maintenance, red means failure/offline/destructive, violet means TLS/automation/managed policy, and gray means disabled/unknown/informational.
- **Caddy fleet workflow** now presents registered environments as an operational inventory with environment, health, admin endpoint, management policy, last contact, and scoped actions.
- **Caddy connection form** now follows an Identify → Connect → Authorize sequence and explains safe admin-API connectivity before configuration is saved.
- **Proxy Hosts** has an enterprise page header, workflow language, and a primary **Publish service** action.
- **Required readiness** uses four foundations. DNS automation remains visible as an optional capability and never prevents an environment from reaching 100% readiness.
- **Navigation terminology** now uses **Caddy Fleet** and **Publish service** consistently where those labels better describe the operator's goal.
### Accessibility
- Guided steps expose current-state semantics, native form validation, predictable Back/Continue controls, keyboard focus targets, and a review stage before submission.
- Setup, onboarding, publishing, fleet, and connection workflows have dedicated compact layouts for narrow screens and retain the existing reduced-motion behavior.
- The enterprise workflow system supports light, dark, Carbon Orange, Forest, Rose, and Indigo themes through shared semantic tokens.
---
## [2.19.0] - 2026-07-29 - MariaDB and large-installation performance
### Added
- **Optional MariaDB backend**: set `CADDYUI_DB_DRIVER=mariadb` and `CADDYUI_DB_DSN` to run CaddyUI on MariaDB while SQLite remains the default.
- **Automatic MariaDB schema and migrations**: fresh databases receive the complete CaddyUI schema, including all current proxy-host fields and performance indexes.
- **Read-only SQLite → MariaDB migration command**: `caddyui migrate-db --from-sqlite /data/caddyui.db` preserves IDs and relationships, copies in bounded transactions, rejects non-empty destinations, and supports `--skip-analytics` for oversized traffic histories.
- **MariaDB deployment overlay and CI**: `docker-compose.mariadb.yml` provides a health-checked MariaDB 11.4 service; CI validates schema parity, core resource queries, and cross-database migration.
### Performance
- **Proxy Hosts traffic lookup** now queries only domains visible on the page. SQLite uses the existing `(host, ts)` covering index instead of scanning the complete analytics history.
- **Post-render upstream checks** start after the browser's initial paint and pause while the tab is hidden.
- **List-page indexes** cover proxy ordering, advanced routes, certificate options, and last-sync activity lookups.
- **Portable daily rollups** use timestamp ranges instead of engine-specific date conversion, improving index use on both SQLite and MariaDB.
### Changed
- Sessions, login throttling, activity retention, settings upserts, and analytics rollups now use portable SQL shared by both database backends.
- Settings explains that MariaDB backups belong to the database platform; Caddy configuration snapshots continue to work normally.
---
## [2.18.0] - 2026-07-29 - Enterprise operations UI
### Added
- **Enterprise operations center**: the dashboard now organizes active-server identity, route inventory, host health, traffic, system telemetry, recommendations, fleet status, recent changes, and live upstream reachability into one operational hierarchy.
- **Semantic design system**: reusable surface, border, text, brand, status, spacing, radius, shadow, panel, button, metric, table, and empty-state primitives establish a consistent foundation for future page migrations.
- **Persistent command search**: desktop users can open the existing resource search directly from the top bar; the button uses the same reset, fetch, focus, and keyboard navigation workflow as `Ctrl/⌘+K`.
- **Workflow-first actions**: Create proxy host, Sync Caddy, and Manage servers are visible at the point of use. The create action remains hidden for read-only viewers.
### Changed
- **Application navigation**: destinations are regrouped into Overview, Traffic, Observe, Configuration, Developer, and Administration sections without changing routes or role-based access.
- **Environment context**: the active Caddy server and status are promoted in both the sidebar and persistent top bar, with faster switching in multi-server installations.
- **Mobile shell**: the previous stacked mobile toolbars are consolidated into one compact bar with navigation, environment, search, theme, and account controls.
- **Dashboard density**: redundant cards are consolidated into structured panels and responsive data grids that scale from a phone to a wide operations display.
- **Dashboard documentation**: the README screenshot now reflects the enterprise operations layout.
### Accessibility
- **Keyboard focus visibility** is standardized across links, buttons, inputs, selects, textareas, and disclosure controls.
- **Reduced-motion support** disables nonessential application-shell transitions and animations when requested by the operating system.
- **Responsive validation** covers 390 px mobile and 1440 px desktop layouts without horizontal page overflow.
---
## [2.17.3] - 2026-07-29 - Visible certificate selection for Auto TLS
### Added
- **Certificate identity in the Caddy-managed table**: the Certificates page now shows which certificate each Auto TLS domain uses. Hosts covered by a managed wildcard display that certificate's CaddyUI name; hosts without wildcard coverage display **Direct certificate**.
- **Responsive certificate identity**: the same wildcard/direct distinction appears in both the desktop table and mobile certificate cards.
---
## [2.17.2] - 2026-07-29 - Enforced wildcard reuse and live renewal visibility
### Fixed
- **Auto TLS now reuses managed wildcards**: exact proxy, redirect, and advanced-route hostnames covered by a standalone managed wildcard are added to Caddy's `automatic_https.skip_certificates` list. HTTPS remains enabled, but Caddy no longer obtains an unnecessary exact-host certificate and instead serves the wildcard already managed in its cache.
- **Safe wildcard boundaries**: only exact hostnames covered by a one-label wildcard are skipped. The wildcard subject itself remains eligible for DNS-01 issuance; apex and multi-level names still obtain their own certificate unless separately covered.
### Added
- **Live per-server managed certificate status**: editing a managed certificate now shows every configured Caddy server, whether the definition is deployed there, the certificate actually served, issuer, expiration timestamp, days remaining, and renewal problems such as expired, mismatched, or unreachable certificates.
- **Wildcard-safe certificate probing**: CaddyUI uses a synthetic wildcard-covered SNI name and connects directly to each Caddy server, so a standalone wildcard can be inspected without creating public DNS for a fake hostname.
- **Managed wildcard documentation**: the GitHub README and Docker Hub overview now include the complete setup, multi-server deployment, Auto TLS reuse, provider-module requirement, and renewal-status workflow.
---
## [2.17.1] - 2026-07-29 - Multi-server wildcard certificate workflow
### Added
- **Managed certificate cross-deploy**: Certificates → New/Edit can copy a managed ACME DNS-01 definition to selected Caddy servers. Each server obtains and renews its own certificate and private key; secrets and certificate material are not copied between Caddy instances.
- **Automatic wildcard discovery during proxy cross-deploy**: when “Also deploy to” is selected on a proxy host, CaddyUI finds managed certificates on the source server that cover the hostname and ensures equivalent definitions exist on every target. This works for servers added later without permanent cross-server certificate links.
- **Wildcard matching safeguards**: automatic reuse follows WebPKI semantics—`*.example.com` covers `app.example.com`, but not the apex or multi-level `deep.app.example.com`.
### Fixed
- **Managed certificate expiry display**: standalone ACME rows now show **Auto-renewed** in the Expires column instead of an unexplained blank value.
- **Multi-server form guidance**: certificate and proxy forms now explain which configuration is replicated, that each Caddy instance owns its own key/order, and how managed wildcards are reused.
---
## [2.17.0] - 2026-07-29 - Standalone managed wildcard certificates
### Added
- **Managed ACME source on Certificates**: create `*.example.com`, `*.sub.example.com`, or ordinary SAN certificates using any saved DNS credential profile. Caddy obtains and renews them through DNS-01; CaddyUI never stores or exports the private key.
- **Certificate-only wildcard routes**: managed entries emit a terminal 404 fallback host route, which instructs Caddy Automatic HTTPS to manage the requested subjects without requiring a fake proxy upstream. Real proxy and redirect routes retain priority and reuse the wildcard certificate.
- **Guided setup**: the certificate form and built-in documentation explain the Settings → DNS prerequisite, provider-module requirement, expected 404 fallback, and how Caddy reuses the certificate.
### Changed
- **Certificate selectors stay unambiguous**: managed ACME entries do not appear in the custom-certificate dropdown because they are automatically selected by SNI rather than attached as uploaded PEM material.
---
## [2.16.10] - 2026-07-28 - DNS automation sync compatibility
### Fixed
- **Fresh Caddy DNS-01 sync**: Managed DNS automation policies now create the missing `apps.tls` configuration branch before writing `apps.tls.automation`. This prevents `sync_apply_automation_failed` with Caddy's `invalid traversal path at: config/apps/tls/automation` error on installations that do not already have a TLS app.
- **Existing TLS settings preserved**: installations that already have `apps.tls` continue using the narrow automation-only update, preserving unrelated TLS configuration and avoiding unnecessary TLS module reprovisioning.
---
## [2.16.9] - 2026-07-26 - Caddy HTTP listener compatibility
### Fixed
- **Default port-80 sync failure**: CaddyUI now owns one explicit HTTP server for both open-HTTP hosts and Force SSL redirects, while disabling Caddy's competing automatic redirect listener. Sync works with Caddy's default `http_port` and no longer requires moving that port as a workaround.
- **Force SSL routing**: proxy hosts, redirections, and advanced routes with Force SSL enabled receive a CaddyUI-managed permanent HTTPS redirect; routes with Force SSL disabled continue serving normally over both HTTP and HTTPS.
- **Safe listener transitions**: sync disables Caddy's automatic redirect listener before creating the CaddyUI listener, and reverses that order when removing it, so live subtree updates never attempt to bind port 80 twice.
---
## [2.16.8] - 2026-07-25 - Faster Proxy Hosts page
### Fixed
- **Proxy Hosts page load time**: the list now selects only the 19 fields it renders instead of loading and scanning every optional proxy-host field, including large advanced-config, header, rule, and custom-page blobs.
- **Duplicate live Caddy request**: the separate Live upstream status panel no longer starts a second Caddy admin request automatically when the page opens. Row health continues loading normally; the detailed panel refreshes on demand.
- **Health endpoint query size**: live row-health checks reuse the lightweight proxy-host summary query.
---
## [2.16.7] - 2026-07-25 - DNS-01 certificate support and Caddy sync compatibility
### Added
- **DNS-01 for every Managed DNS provider**: proxy hosts, redirections, and advanced routes with Managed DNS selected now reuse those credentials for Let's Encrypt DNS-01 issuance. Supported providers are Cloudflare, Porkbun, Namecheap, GoDaddy, DigitalOcean, and Hetzner; the connected Caddy build must include the matching `caddy-dns` module.
- **All-provider Caddy build**: `Dockerfile.caddy` now includes the six DNS provider modules supported by CaddyUI.
### Fixed
- **Caddy sync `skip_redirects` rejection**: CaddyUI no longer emits the unsupported `automatic_https.skip_redirects` field. Hosts with Force SSL disabled are served through an explicit CaddyUI-owned `:80` server, matching Caddy's native Caddyfile adaptation while retaining HTTPS on `:443`.
- **Affected config cleanup**: sync removes any stale `skip_redirects` key before validation, so existing installations recover on their next sync.
---
## [2.16.6] - 2026-07-24 - MCP community integration and token-scope hardening
### Added
- **Community MCP integration docs**: added a README link to the community-maintained [CaddyUI-MCP](https://github.com/loryanstrant/CaddyUI-MCP) project, with guidance to use dedicated users and narrow API token scopes for agent automation.
- **API token scope documentation**: the REST API reference now explains `read_only`, `proxy_write`, and `full` token behavior, including the expectation that integrations managing redirections, raw routes, or certificates need a full-access token from an appropriately limited user.
### Fixed
- **`proxy_write` API token enforcement**: `proxy_write` bearer tokens are now limited to writes under `/api/v1/proxy-hosts`. Other write routes remain read-only for that token scope instead of behaving like full access.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.16.6` published.
---
## [2.16.3] - 2026-07-21 - Dashboard recommendations and CI gate
### Added
- **Dashboard recommended actions**: the dashboard now surfaces a compact advisor panel for actionable operational issues, including down upstreams, maintenance mode, SSL-disabled enabled hosts, incomplete managed DNS, missing DNS profile references, expired or soon-expiring custom PEM certificates, stale or missing syncs, missing snapshots, disabled 2FA enforcement, and an empty admin IP allowlist.
- **Basic Go CI gate**: added a GitHub Actions workflow that runs `go test ./...`, `go vet ./...`, and `go build ./cmd/caddyui` on pushes and pull requests to `main`.
- **Dashboard advisor tests**: added focused unit tests covering operational recommendations, admin hardening recommendations, certificate expiry detection, and recommendation noise limits.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.16.3` published.
---
## [2.16.2] - 2026-07-21 - Proxy Hosts loading speed hotfix
### Fixed
- **Proxy Hosts page slowdown after v2.16.1**: the new bulk certificate selector now loads lightweight certificate options (`id`, `name`, `domains`) instead of full certificate PEM/key payloads. This avoids dragging private key blobs into the proxy-host list render path.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.16.2` published.
---
## [2.16.1] - 2026-07-21 - Bulk proxy certificate assignment
### Added
- **Bulk apply certificates for proxy hosts**: the proxy-host bulk action bar now includes a certificate selector. Select multiple proxy hosts, choose a custom certificate or `Auto / ACME`, and apply once.
### Changed
- **Single sync after bulk certificate changes**: CaddyUI updates the selected proxy hosts and syncs Caddy once with TLS refresh, avoiding the old one-host-at-a-time workflow when rotating renewed certificates across many containers.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.16.1` published.
---
## [2.16.0] - 2026-07-21 - Multiple DNS credential profiles
### Added
- **Multiple Cloudflare DNS credential profiles**: Settings → DNS now supports additional named Cloudflare profiles, each with its own API token, proxy mode, and zone allow-list. This lets one CaddyUI install manage domains split across multiple Cloudflare accounts or tokens.
- **Per-host DNS profile selection**: proxy hosts, redirection hosts, and raw routes can pick a specific DNS credential profile while the legacy provider-wide settings continue to work unchanged.
### Changed
- **Managed DNS lifecycle**: zone loading, existing-record checks, record create/delete, public-IP retargeting, and wildcard DNS-01 automation now resolve credentials from the selected profile.
- **Safety guard**: if a saved row references a deleted/missing DNS profile, provider calls no-op instead of falling back to the global token and risking changes in the wrong account.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.16.0` published.
---
## [2.15.3] - 2026-07-21 - Security dependency refresh
### Security
- **Dependabot crypto alerts**: updated `golang.org/x/crypto` from `v0.45.0` to `v0.52.0`, resolving the open GitHub Dependabot alerts for the module.
- **Go toolchain**: updated the module and Docker build stage to Go 1.25 so the patched crypto module can be used.
- **Transitive modules**: refreshed `golang.org/x/sys` from `v0.38.0` to `v0.45.0` via `go mod tidy`.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.15.3` published.
---
## [2.15.2] - 2026-05-25 - Simple mode keeps DNS & TLS visible
### Fixed
- **Proxy form Simple mode**: DNS and TLS sections (`ph-dns`, `ph-tls`) now stay visible in Simple mode — those are the two most essential sections for any proxy host. Previously Simple mode hid everything including those, making it unusable. All other advanced sections (Security, Headers, Path routing, Rewrites, etc.) remain hidden until Advanced mode is selected.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.15.2` published.
---
## [2.15.1] - 2026-05-25 - Cache-bust static assets so new themes load immediately
### Fixed
- **Static asset cache-busting**: `app.css`, `app.js`, and `htmx.min.js` URLs now include `?v={{.AppVersion}}` so browsers always fetch the latest version after an upgrade instead of serving the 24h-cached old file. This was why the Forest / Rose / Indigo themes appeared as blue on first load — the new CSS never arrived.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.15.1` published.
---
## [2.15.0] - 2026-05-25 - Major UI overhaul: toasts, sparklines, themes, cert bars, quick-mode form
### Added
- **Toast notifications**: all form success/error/warning banners now slide in as auto-dismissing toasts (bottom-right, spring animation). Errors stay until dismissed. Maintenance banners remain inline. `window.caddyuiToast(msg, type, duration)` exposed for programmatic use.
- **Dashboard sparklines**: the three stat cards (Requests / Visitors / Bandwidth) now show a 7-day trend sparkline inline with the number. Fetched from new `/api/dashboard-sparklines` endpoint after first paint.
- **Certificate expiry progress bars**: each certificate now shows a thin horizontal bar below the name — green (≥ 60 d), yellow (< 60 d), amber (< 30 d), red (< 15 d / expired). Visual urgency at a glance in both mobile cards and desktop table.
- **3 new color themes**: Forest Green, Rose / Crimson, Deep Indigo — each with full light + dark palette. Theme picker in Settings now offers 5 options (+ existing Default Slate Blue and Carbon Orange).
- **Proxy form Simple / Advanced mode**: a pill toggle above the proxy host form hides all `<details>` expansion sections in Simple mode, showing only Domain, Forward Host/Port, and SSL — perfect for new users. Preference persisted to `localStorage`. Defaults to Simple on `/new`, Advanced on edit.
- **Toast container** (`#toast-container`) added to layout — `pointer-events: none` wrapper so it never blocks clicks.
### Fixed
- **Dark mode inconsistency in Settings**: all `dark:*-gray-*` Tailwind utility classes in `settings.html` replaced with the canonical `ink-*` token system used everywhere else. Resolves card background mismatches in dark mode (e.g., `dark:bg-gray-900` vs the `.dark .bg-white` override).
### Backend
- **`GET /api/dashboard-sparklines`**: new authenticated endpoint returning 7 days of daily `{ views, visitors, bandwidth }` totals scoped to the active server's hostnames. Used by JS sparkline renderer.
- **Color theme validation**: `postMyColorTheme` now accepts `forest`, `rose`, `indigo` in addition to `default` and `orange`.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.15.0` published.
---
## [2.14.3] - 2026-05-15 - Fresh-install Caddy crash-loop fix
### Fixed
- **Fresh install**: `docker-compose.yml` command now bootstraps an empty `{}` config on first boot so Caddy starts cleanly when `autosave.json` doesn't exist yet on a fresh volume. Previously Caddy would crash-loop with `open /config/caddy/autosave.json: no such file or directory`. Existing installs are unaffected — the one-liner is guarded by `[ -f ... ] ||` and skips entirely when the file already exists. ([#7](https://github.com/X4Applegate/caddyui/issues/7))
### Docs
- `README.md` Quick Start and Agent mode compose examples updated with the bootstrap command.
- `DOCKERHUB.md` compose example updated with the bootstrap command and a fresh-install callout note.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.14.3` published.
---
## [2.14.2] - 2026-05-13 - Porkbun API spec full compatibility
### Fixed
- **Porkbun domain struct**: added `securityLock`, `apiAccess`, and `notLocal` fields to the `Domain` struct - previously missing, silently dropped during decode. All three use the flexible `pbInt` type to handle string-or-int like the other integer fields.
- **Porkbun SSL import**: `publickey` field from `/ssl/retrieve` is now captured and returned in the `SSLBundle`.
- **Porkbun DNS create**: `id` field in the create-record response is now decoded as `string` (matching the v3 spec) instead of `json.Number`.
> `:preview`, `:stable`, `:latest` updated. Pinned tag `:v2.14.2` published.
---
## [2.14.1] - 2026-05-13 - Porkbun whoisPrivacy / autoRenew type fix
### Fixed
- **Porkbun JSON type mismatch**: Porkbun's `/domain/listAll` returns `whoisPrivacy` and `autoRenew` as quoted strings instead of bare integers. Added a private `pbInt` type with a custom `UnmarshalJSON` that accepts both forms. Resolves the "cannot unmarshal string into Go struct field Domain.domains.whoisPrivacy of type int" error on the Import from Porkbun and DNS pages. (Reported: issue #6)
> `:preview` updated. Pinned tag `:v2.14.1` published.
---
## [2.14.0] — 2026-05-13 · Porkbun SSL certificate import
### Added
- **Import from Porkbun**: new button on the Certificates page (visible when Porkbun API credentials are configured) that opens a domain picker and pulls the SSL bundle directly from your Porkbun account into CaddyUI as a stored PEM certificate. Uses the Porkbun API v3 endpoint.
- method added to the internal Porkbun client ().
- New page at with domain dropdown (lists all Porkbun domains via ).
## [2.13.0] — 2026-05-10 · REST API v1 — full coverage for raw routes and certificates
> `:preview` updated. Pinned tag `:v2.13.0` published.
### Added
- **v2.13.0 — REST API v1 for raw routes.** Full CRUD + toggle: `GET /api/v1/raw-routes`, `GET /api/v1/raw-routes/{id}`, `POST /api/v1/raw-routes`, `PUT /api/v1/raw-routes/{id}`, `DELETE /api/v1/raw-routes/{id}`, `POST /api/v1/raw-routes/{id}/toggle`. Create/update accept `label`, `json_data`, `caddyfile_src`, `enabled`, `certificate_id`, `force_ssl`, `block_common_exploits`. Responses include the same fields plus `owner_email`, `created_at`, `updated_at`. Caddy is synced after every write. Requires write-scoped token or admin/write role for mutations.
- **v2.13.0 — REST API v1 for certificates.** Full CRUD: `GET /api/v1/certificates`, `GET /api/v1/certificates/{id}`, `POST /api/v1/certificates`, `PUT /api/v1/certificates/{id}`, `DELETE /api/v1/certificates/{id}`. Create/update accept `name`, `domains`, `source` (`pem` or `path`), `cert_pem`, `key_pem`, `cert_path`, `key_path`. Delete is blocked with HTTP 409 if the certificate is still referenced by any proxy host, redirection host, or raw route. Respects the existing per-user ownership model — non-admin users see only their own uploads plus global (admin-owned) certs.
- **v2.13.0 — `models.ToggleRawRoute`.** Added `ToggleRawRoute` to the models package (mirrors `ToggleProxyHost` / `ToggleRedirectionHost`) to support the new API toggle endpoint cleanly.
---
## [2.12.54] — 2026-05-10 · REST API v1 — full proxy host field coverage + docs fixes
> `:preview` updated. Pinned tag `:v2.12.54` published (multi-arch `linux/amd64` + `linux/arm64`).
### Added / Fixed
- **v2.12.54 — PUT /api/v1/proxy-hosts/{id} now applies all 42 fields.** The update handler previously merged only 16 of the 42 fields accepted by the create endpoint — CORS settings, compression, security headers, TLS minimum version, health-check URI/interval/method, maintenance message, upstream timeout, keepalive connections, sticky sessions, load-balancer policy, strip-response headers, blocked agents, upstream SNI, HSTS preload, max connections per host, upstream retries, force-HTTP/1, proxy protocol, extra upstreams, basic-auth enabled, and certificate ID were all silently ignored on PUT. Every field in `apiProxyHostInput` is now applied on update (strings: merge if non-empty; booleans/ints: always applied).
- **v2.12.54 — API docs corrected field names.** The `/api/docs` reference page used wrong field names in several examples: `domain_names` → `domains`, `forward_domain_name` → `forward_domain`, `http_code` → `forward_http_code`. Examples now show the actual JSON keys the API returns and include the full set of response fields.
---
## [2.12.53] — 2026-05-03 · Multi-provider AI + Lighthouse 99/100/100/100 + Caddyfile export + bug fixes (cumulative)
> **Patch wave on top of v2.12.35.** Pinned tag `:v2.12.53` (multi-arch `linux/amd64` + `linux/arm64`). `:latest` + `:stable` retag from v2.12.35 → v2.12.53. `:preview` rolls on every dev push.
Eighteen patch versions in one cumulative entry, covering the multi-provider AI assistant (v2.12.36), the long performance + accessibility wave that took PageSpeed from ~30 to **99 desktop / 76 mobile / 100 a11y / 100 SEO / 100 best-practices** on `/login`, the new Caddyfile export, and a critical SQL-INSERT column-count fix that affected fresh installs of v2.12.48–v2.12.52.
### Critical fix (v2.12.53)
- **v2.12.53 — proxy host INSERT had 331 placeholders for 330 columns.** Introduced in v2.12.52 when `disable_upstream_compression` was added and an unrelated field was partly reverted, leaving one stray `?` in the `VALUES (...)` line. Reproducible on a fresh DB: any "New Proxy Host" submit failed with `SQL logic error: 331 values for 330 columns (1)`. Reported via [issue #5](https://github.com/X4Applegate/caddyui/issues/5). Fixed by regenerating the `VALUES` line via Python so the count is provably correct, plus an audit script in the commit message that catches this kind of regression across every INSERT in `models.go`.
- **v2.12.53 — friendly bind-mount permission error.** Bind-mount users on docker-compose / podman-compose used to hit cryptic SQLite "unable to open database file (1)" errors when the host directory wasn't owned by uid 10001. The binary now probes `/data` writability at startup and prints an actionable multi-line error pointing at the three reasonable fixes (chown / `--user 0:0` / named volume).
### Caddyfile export (v2.12.49 + v2.12.50)
- **v2.12.49 — Caddyfile export, full-server.** New `GET /proxy-hosts/export-all.caddyfile` endpoint + UI button next to "Export JSON". Exports every enabled proxy host, redirection, and raw route on the active server as a Caddyfile, round-trippable through the existing `/caddyfile-import` paste flow for the directives it contains. Same RBAC scoping as the JSON exports — admin gets the full server config, non-admin only their own resources. Stable sort by hostname for diffability. Per-host `# Notes:` block lists settings that don't fit cleanly into Caddyfile syntax (maintenance windows, basic-auth users, scheduled blocks, IP allow/deny, URL rewrites, path-based upstreams, blocked agents) so users see what was elided rather than silently losing it on round-trip.
- **v2.12.50 — Caddyfile export, per-host.** Single-host download from the proxy-host edit page. Same renderer as the full-server export.
### Notification channels (v2.12.51)
- **v2.12.51 — ntfy.sh notification channel + sendNotification fan-out wrapper.** Adds [ntfy.sh](https://ntfy.sh) alongside the existing webhook + email notification channels. Useful for self-hosted push notifications without running a Discord/Slack workspace. Configure in **Settings → Notifications**: ntfy server URL (default `https://ntfy.sh`), topic name, optional auth token. The new `sendNotification` wrapper fans out cert-expiry / upstream-down events to every enabled channel in one call.
### Performance + accessibility wave (v2.12.38 → v2.12.48)
| Version | Change | Mobile | Desktop |
|---|---|---|---|
| ~v2.12.37 baseline | Pre-tuning | ~30–40 | ~50–60 |
| v2.12.38 | `Cache-Control: max-age=86400` on `/static/*` + custom request/response header `+ Add` buttons (the buttons existed but had no JS click handler) | ~40 | ~70 |
| v2.12.39 | Self-host Inter variable font + htmx (drop fonts.gstatic.com + unpkg.com from cold loads); service-worker rewrite (auto-purges stale caches per release) | ~50 | ~80 |
| v2.12.40 | Preload Inter font, preconnect cdn.tailwindcss.com | ~55 | **91** |
| v2.12.41 | Externalize 28 KB inline JS to `/static/app.js` (cached across navigations); TBT 2,230 ms → 690 ms | ~55 | ~95 |
| v2.12.42 | Preload `app.js`, defer non-critical `/api/version-check` + `/api/ai/status` past Lighthouse's measurement window; TBT 690 ms → 0 ms | 55 | ~96 |
| v2.12.43 | `auth.css` for `/login` (24 KB precompiled, drops 124 KB Tailwind CDN runtime on the page Lighthouse always tests) | **76** | ~99 |
| v2.12.44 | Section anchor pills on the proxy host form, ordered by usage frequency | 76 | 99 |
| v2.12.45 | Physical reorder of proxy host form sections to match the pill priority | 76 | 99 |
| v2.12.46 | `aria-current="page"` on active nav link, `aria-label`s on icon-only buttons, skip-to-main-content link | 76 | 99 |
| v2.12.47 | `<label for>` ↔ `<input id>` associations on every unauth page (login, setup, forgot/reset-password, accept-invite) | 76 | 99 |
| v2.12.48 | `<meta name="description">` on every page for SEO 100 | **76** | **99** |
End state on `/login`: **Performance 99 · Accessibility 100 · Best Practices 100 · SEO 100** with FCP 0.5 s, LCP 0.9 s, TBT 0 ms, CLS 0.001. Mobile 76 with the same metrics shape (FCP/LCP higher only because of Lighthouse's simulated 4× CPU + 1.6 Mbps mobile network — a residential ISP can't beat that without Cloudflare in front).
### Multi-provider AI assistant (v2.12.36 → v2.12.37)
- **v2.12.36 — AI assistant supports any backend.** New provider selector in **Settings → AI assistant**: Ollama (local) / Ollama Cloud / Anthropic Claude (Haiku 4.5 / Sonnet 4.6 / Opus 4.7) / any OpenAI-compatible API (also drives OpenRouter, Groq, Together, vLLM, LM Studio). Each provider has its own URL/key/model fields with show/hide JS. Three Go adapter funcs (`aiCallOllama`, `aiCallAnthropic`, `aiCallOpenAI`) translate messages + tool definitions to each provider's schema and normalize back to the existing `{reply, tool_calls}` contract so the chat panel didn't need to change. Adapters handle the per-provider quirks: Anthropic puts `system` at the top level (not in messages), tools use `input_schema` (no `function` wrapper), tool calls come as `tool_use` content blocks. OpenAI returns tool-call arguments as a JSON-encoded **string**, not an object — needs an extra `Unmarshal` pass.
- **v2.12.37 — F12 leak fix for AI provider keys.** v2.12.36 rendered saved Anthropic / OpenAI / Ollama Cloud keys directly into `<input value="...">` on the Settings page; `type="password"` masked them visually but DevTools → Elements showed each in plaintext. Fixed by matching the existing SMTP-password pattern: input always renders `value=""`, conditional placeholder `•••••••• (key set)` when stored, save only persists when the user types a fresh value (empty submit keeps existing key). `getSettings` passes only `*KeySet` booleans to the template — never the actual keys.
### Other v2.12.x patch-wave items
- **v2.12.36 + v2.12.37** also shipped a major README + landing-page rewrite with the AI multi-provider explainer + AI auto-fill walkthrough, and a docs/index.html cleanup (orphan files removed).
- **v2.12.41** also removed the Tailwind self-host attempt that produced a 3.76 MB tailwind.css due to opacity-modifier auto-expansion across the default theme.colors palette. Reverted; `cdn.tailwindcss.com` stays for authenticated pages where the JIT runtime makes sense (the `auth.css` work in v2.12.43 covered the unauth pages where it doesn't).
- **v2.12.52 — per-host "Disable upstream compression" toggle.** Emits `transport http { compression off }` so Caddy doesn't ask the upstream to send compressed responses. Useful when the host has the existing Compression toggle on (`encode zstd gzip`) — pairs cleanly so Caddy is the single source of truth for compression. Caddy JSON: `transport.http.compression: false`. Caddyfile: `compression off` inside the `transport http { ... }` block. Round-trips through `/caddyfile-import`.
### Upgrade
```
docker pull applegater/caddyui:v2.12.53 # pinned (this release)
docker pull applegater/caddyui:latest # rolling, retagged from v2.12.35 → v2.12.53
docker pull applegater/caddyui:stable # alias of :latest
docker pull applegater/caddyui:preview # rolling dev push
```
In Portainer: **Recreate** → enable **Re-pull image**. Migrations run automatically. No downtime beyond the container restart.
> **One-time service-worker cleanup** if upgrading from v2.12.38 or earlier: F12 → **Application** → **Service Workers** → **Unregister** next to your CaddyUI domain, then Ctrl+Shift+R. The new SW (v2.12.39+) auto-purges old caches per release, so this is needed only once.
Multi-arch on Docker Hub (`linux/amd64` + `linux/arm64`, scratch base, non-root UID 10001).
### Per-version detail
The eighteen patch versions in this wave each have their own commit message with full per-version detail. See `git log v2.12.35..v2.12.53` for the chronological narrative, or the GitHub releases for v2.12.37, .39, .40, .43, .53 for the headline cuts.
---
## [2.12.35] — 2026-04-29 · Carbon Orange theme + UI modernization + AI assistant overhaul + sync/header fixes (cumulative)
> **Patch wave on top of v2.12.12.** Pinned tag `:v2.12.35` (multi-arch). Rolling Docker tags (`:latest`, `:stable`) untouched in this release; `:preview` reflects the latest dev push as usual.
23 patch versions in one cumulative entry, headlined by a new Carbon Orange color theme that syncs across devices, a UI modernization pass, and a deep AI-assistant overhaul that makes the chat panel actually understand CaddyUI's own surface area.
### Added
- **v2.12.34 — AI assistant gets full CaddyUI app knowledge.** Added a `CADDYUI APP KNOWLEDGE` section to the default system prompt mapping every sidebar page (Dashboard, Proxy Hosts, Redirections, Advanced, Certificates, Import, Caddyfile, Analytics, Live Traffic, Snapshots, Activity, Caddy Config, API, API Tokens, Servers, Users, Groups, Settings), every Settings section with field inventory, multi-server architecture, RBAC roles, cross-cutting features, and the proxy-host form's section structure. The model now points users at the exact page → section → field instead of guessing. Users with a custom system prompt won't inherit this — clear the field to pick up the new default.
- **v2.12.33 — AI assistant defaults to production-grade Caddyfiles.** Rewrote the prompt's example library from 3-line stubs to production-shape templates (encode zstd gzip, `@blocked` path filter, security header bundle, full `header_up` trio for `reverse_proxy`). Richer per-app templates for Nextcloud (with the well-known CalDAV/CardDAV redirects), HTTPS upstream + custom Host header + transport block, WebSocket/SSE/streaming backends with `flush_interval -1`, 301/302 redirects, and wildcard DNS-01 certs. New rule: model follows each Caddyfile with 3–6 bullets mapping each directive to its CaddyUI form field.
- **v2.12.28 — Server picker dropdown in the top bar.** The current server's status dot + name + URL in the persistent top bar is now a button that opens a dropdown listing every Caddy server with its status, name, admin URL, and a checkmark on the current selection. Submission goes to `/servers/{id}/select` (same endpoint the sidebar picker uses) so behaviour is identical — just reachable from every breakpoint. Single-server installs still get the read-only label.
- **v2.12.27 — Color theme syncs across devices via per-user DB column.** New `users.color_theme` column (`''` = default, `'orange'` = carbon orange) populated server-side so the layout renders `<html data-color-theme="...">` before any script runs (no paint flash). Bootstrap script reconciles localStorage with the server value. New `POST /api/me/color-theme` endpoint validates the theme name. Picker is fire-and-forget on change so the visual update is instant.
- **v2.12.25 — UI modernization pass.** (1) Avatar dropdown menu collapses Profile / 2FA / Sign out under the avatar+chevron, dropping the persistent top bar from 8 visible items to 4 on mobile. (2) Card shadows simplified from a double-stack to a single 1px lift, with a `shadow-card-hover` variant for elevated states. (3) Typography: `font-optical-sizing: auto` (Inter has a variable opsz axis), `font-feature-settings ss03+cv11`, tighter `-0.022em` letter-spacing on display sizes. (4) Spacing: Settings form `space-y-6` → `space-y-8`, dashboard `mb-6` → `mb-8` between section cards. (5) Sidebar section labels (Routes / Config / System) now render as inline caps + a thin trailing hairline divider instead of standalone uppercase text.
- **v2.12.22 — Carbon Orange color theme + per-device picker in Settings.** Adds an orange/black palette as an alternative to the default slate-blue, combinable with the existing light/dark/auto toggle. CSS overrides target Tailwind utility classes via `[data-color-theme="orange"]` attribute selectors layered after the `.dark` rules. Brand blue → orange (`#e8890a`/`#b86d08`/`#f4a85e`); dark+orange swaps the slate background for warm carbon blacks (`#0c0b09`/`#131210`/`#1a1814`) with cream text. Bootstrap script in `<head>` reads `localStorage('caddyui-color-theme')` synchronously before first paint to avoid flash. `window.caddyuiSetColorTheme()` exposed for programmatic use. Palette inspired by the BNN Label Automation page that prompted this work.
- **v2.12.14 — Global strip-response-headers setting.** New Settings → General → "Globally stripped response headers" field — comma-separated header names that get deleted from every proxy host's upstream response. Useful for apps that ship with `helmet` middleware adding `X-Frame-Options` / `X-Powered-By` etc. that you want gone fleet-wide. `syncCaddy` reads the setting once per sync and prepends the global list to each proxy host's `StripResponseHeaders` field before `BuildProxyRoute`. Per-host `strip_response_headers` still works independently — both lists concatenate.
### Fixed
- **v2.12.35 — Silenced `syncCaddy` errors + removed dead Upstream Network field.** ~23 handlers were calling `s.syncCaddy()` and silently dropping the error — proxy/redirect/cert/raw-route create+update+delete, AI tool calls, and several bulk actions all flipped state in the DB but never logged when Caddy rejected the new config. The v2.12.20 `network` field validation bug was undetectable in production for weeks because of this. New helper `trySyncCaddy(serverID, forceTLS)` does the call + logs on error; bulk-replaced every silent call site. Also removed the dead "Upstream Network" form field (DB column + model field kept for back-compat); replaced with a comment pointing to the legitimate "DNS Resolver" field.
- **v2.12.32 — AI chat input is a textarea (wraps + auto-grows + Enter-to-send).** Previously a single-line `<input type="text">` — pasting a multi-line Caddyfile compressed it to one horizontally-scrolling line. Now a `<textarea>` with `rows=1` default (matches the previous single-line look), auto-grow up to 8rem (~6 lines) on input, internal scroll past that, whitespace wrapping by default. `Enter` submits, `Shift+Enter` / `Ctrl+Enter` inserts a newline. Form's flex alignment changed from `items-center` to `items-end` so the Send button sits at the bottom-right of a tall textarea.
- **v2.12.31 — AI chat code blocks wrap long lines, no more horizontal overflow.** `<pre>` switched to `whitespace-pre-wrap break-all` so long lines wrap at any character instead of overflowing. Removed `overflow-x-auto`; added `max-w-full`. Bubble widened from `max-w-[85%]` to `max-w-[95%]` for code-heavy replies. `spellcheck="false"` suppresses the red-squiggle artifacts browsers drew on Caddyfile keywords like `keepalive_idle_conns`.
- **v2.12.30 — Proxy host form search hides empty `<details>` sections.** The form has 36 collapsible sections covering 300+ options. The existing field-search hid non-matching fields but left every `<details>` header visible — typing "X-Frame" produced a list of empty section labels with the actual hit buried inside. Now: any `<details>` that doesn't contain a matched field is hidden entirely; sections with matches stay visible AND auto-open as before. Empty query restores everything.
- **v2.12.29 — Carbon dropdown panels in dark+orange + toggle sync error logging + AI prompt feature inventory.** (1) User-menu and server-menu dropdowns in dark+orange were inheriting `dark:bg-slate-800` (bluish-slate) inside the otherwise warm orange theme; now explicitly painted with the carbon palette (`#131210` background, `#2a2720` borders). (2) The proxy host enable/disable toggle's auto-sync now logs sync errors instead of silently swallowing them — past bugs (the v2.12.20 `network` field) were invisible because of this. (3) AI assistant system prompt's UI feature inventory expanded so the model stops sending users to the non-existent "manual Caddyfile editing" path for things that ARE UI-supported (security headers bundle, path blocking, compression, custom headers, upstream TLS, keepalive, timeouts, `flush_interval`, etc.).
- **v2.12.26 — Green status dot + orange-accented top bar in orange theme.** (1) Server online status dot was using `bg-brand-500` (orange in the orange theme), conflicting with offline (red). Switched to `bg-emerald-500` — online = green is a universal UI convention we shouldn't break for theme aesthetics. (2) Persistent top bar now reads as part of the orange palette: `data-topbar` hook attribute, bottom border switches to orange `#e8890a` + 1px inset shadow, dark+orange uses warmer carbon `#1a1408` for the bar background.
- **v2.12.24 — Hide duplicate moon on mobile + polish top-bar icon buttons.** Two top bars stack on mobile (the dedicated `<header lg:hidden>` and the sticky persistent top bar visible at all widths). Both contained a theme-toggle moon, so squeezing the viewport showed two moons. Persistent top-bar moon now `hidden lg:inline-flex` — mobile keeps exactly one, desktop keeps exactly one. Search + theme buttons in the persistent top bar redone as consistent rounded-full pills (`w-8 h-8`) with subtle `bg-ink-100` hover.
- **v2.12.23 — Orange theme repaints `blue-*` utility classes too.** v2.12.22 only overrode `brand-*` color classes, but the codebase uses raw Tailwind `blue-*` utilities directly in places — notably `text-blue-400` on the active sidebar nav-item icon. Added overrides for `bg-blue-{50,100,400,500,600,700,900}`, `text-blue-{400,500,600,700}`, `ring-blue-500`, `bg-slate-700/60` (active nav background → semi-transparent orange tint), and `hover:bg-slate-800` (subtle orange hover for inactive items).
- **v2.12.21 — Defer all `response.delete/set` to actually catch upstream headers.** Caddy's `headers` handler runs request-side by default. Without `deferred: true`, a `response.delete` fires BEFORE `reverse_proxy` is called — the header doesn't exist yet, the delete is a no-op, and upstream's value flows through unchanged. Concrete bug: Settings → "Globally stripped response headers" with `X-Frame-Options` was leaking upstream's `SAMEORIGIN` value through to the client even with the strip handler in the route. Fix: a single loop in `BuildProxyRoute` post-hoc applies `deferred: true` to every response op rather than re-wiring 30+ emit sites in `client.go`.
- **v2.12.20 — Drop invalid `network` field from `reverse_proxy` transport.** Caddy's `http.reverse_proxy.transport.http` schema has no `network` field. v2.9.63 emitted `transport["network"] = p.UpstreamNetwork` to force IPv4/IPv6 — older Caddy silently ignored the unknown key, so it looked like it worked. Current Caddy versions reject the config at `/load` with `json: unknown field "network"`, breaking every Sync Caddy. Stop emitting the field. The `UpstreamNetwork` model field + Settings UI stay (so existing preferences aren't wiped) but the field is a no-op until a real implementation lands (`dial_address` with explicit-family resolver or a custom dialer module).
- **v2.12.19 — Sync Caddy button no longer triggers a "reload" file download.** `/caddy/reload` was returning `204 No Content` + an `HX-Trigger` response header — assuming the dashboard's Sync Caddy form was HTMX-driven. It isn't; it's a plain form POST. Recent Chromium versions treat a `204` from a plain form POST with no `Content-Type` as "save the URL's last segment as a file" — users were seeing repeated downloads of a zero-byte file named `reload`. `reloadCaddy` now redirects (303 See Other) back to the `Referer`, completing the form submit cleanly. `HX-Trigger` preserved for any future HTMX wiring.
- **v2.12.18 — Revert `stripHeaderWriter` (broke streaming/Content-Type) + auto-sync after Settings save.** (1) Reverted the `stripHeaderWriter` from v2.12.15 — it didn't implement `http.Flusher` / `http.Hijacker`, and some response paths (Caddy-served static, template streaming) lost their `Content-Type` handshake, causing `/admin` pages to come down as downloads. The conditional `stripped[...]` skips on the three explicit `Set` calls cover the actual case the global-strip was trying to address. (2) `postSettings` now spawns `syncCaddy(serverID, false)` as a goroutine before the redirect-back-to-`/settings` so changes affecting live Caddy config (`global_strip_response_headers`, `catch_all_404_html`, `global_maintenance`) take effect on save without manual Sync Caddy clicks.
- **v2.12.17 — Analytics page scope includes redirection hosts.** `scopedHostsForAnalytics` was iterating proxy hosts + raw routes but silently skipping redirection hosts. Result: when an admin scoped to a specific server in the analytics picker, traffic to redirect-only hostnames didn't contribute to today's totals, the 24h chart, or top-hosts. Now the scope-builder also lists `ListRedirectionHosts(serverID, ...)` and feeds each row's `DomainList()` into the allowed-hosts set — same treatment proxy hosts already get. Dashboard cards already counted redirections (v2.12.8); this brings the analytics page in line.
- **v2.12.16 — SecurityHeaders bundle skips headers in the global-strip list.** v2.12.14 + v2.12.15 stripped headers from upstream responses + from CaddyUI's own pages, but the per-host `SecurityHeadersEnabled` bundle was still emitting `response.set X-Frame-Options: SAMEORIGIN`. The bundle's set fires LAST in the response chain, so the strip's deletion was being undone. Fix: `BuildProxyRoute` filters the bundle map against `p.GlobalStripHeaders` before emitting — headers in both lists are removed from the bundle entirely. Empty bundle → handler block skipped entirely. New in-memory-only `GlobalStripHeaders` field on `ProxyHost` (`json:"-"`, not persisted); `syncCaddy` populates it once per sync.
- **v2.12.15 — Global strip-headers also strips CaddyUI's own response headers.** v2.12.14 only stripped from upstream proxy-host responses; the `securityHeaders` middleware on CaddyUI's own pages (login redirect, admin UI, `/api/*`) was still unconditionally setting `X-Frame-Options: SAMEORIGIN`. Now `securityHeaders` reads the global-strip list at request time and skips the unconditional `Set` for any header in the list. (Also added a `stripHeaderWriter` to drop matching headers downstream handlers tried to set on their own — *reverted in v2.12.18* after it broke streaming.)
- **v2.12.13 — `/analytics` defaults to active server (cross-fleet is opt-in).** Bare `/analytics` was hitting the cross-fleet hot path — single `AccessTotalsSince("")` query plus per-host iteration across every hostname any registered server routes. Slow on multi-server installs and almost never what the user wants. When `?server=` is absent, it now scopes to the picker-active server (`s.currentServerID(r)`, the same cookie that drives the rest of the UI). Cross-fleet still available — pick "All servers (slow)" in the page picker, which sends `?server=all` explicitly. `?server=0` still works as the cross-fleet alias for back-compat.
---
## [2.12.12] — 2026-04-28 · AI tool calling + dashboard scoping + 13 CodeQL fixes (cumulative)
> **Patch wave on top of v2.12.4.** Republishes `:latest`, `:stable`, `:preview`, and `:v2.12.12` (multi-arch). `:latest` retags from v2.12.4 → v2.12.12.
Eight patch versions in one image, headlined by a real LLM tool-calling pipeline that can actually create proxy hosts and redirections from a chat prompt.
### Added
- **v2.12.11 — AI tool calling.** The chat panel now exposes `create_proxy_host` and `create_redirection` as Ollama tools. The model decides when to emit a `tool_call` (vs. a Caddyfile snippet); the frontend renders a confirmation card with the exact arguments; the user clicks Apply, and the resource is actually created. Every exec writes an `ai_tool_call` activity-log row. Works on qwen2.5 / llama3.1+ / gemma2; older models silently ignore the tools field.
- **v2.12.10 — AI conversation memory + custom system prompt.** `/api/ai/chat` now accepts `{messages:[{role,content},...]}` for multi-turn context — "make one here" actually refers back to what was discussed. New "New chat" button in the modal header clears context. New Settings → AI assistant → Custom system prompt textarea overrides the built-in CaddyUI prompt without writing an Ollama Modelfile.
- **v2.12.7 — Markdown rendering in AI chat bubbles.** Llama / Qwen output `**bold**`, fenced code blocks, ` `inline code` `, lists. Tiny in-line renderer (no CDN, no library) handles them safely (HTML-escape first, then regex-replace markers).
- **v2.12.6 — Per-section Save buttons on Settings.** General / AI assistant / DNS IPs / Captcha sections now have Save buttons inline so users don't have to scroll to the page bottom for every toggle change.
### Fixed
- **v2.12.12 — 13 CodeQL js/xss-through-dom alerts** silenced by rewriting bulk-action-bar hidden-input building (`/proxy-hosts`, `/redirection-hosts`, `/raw-routes`, `/certificates`) and the path-based-upstream-rules row builder (proxy_host_form) from `innerHTML` string concat to DOM API (`createElement` + `.value` + `replaceChildren`). No behaviour change; just safer construction that CodeQL can verify.
- **v2.12.9 — Wildcard hostnames count traffic in dashboard + analytics.** Proxy hosts with `*.example.com` in Domains were being passed as a literal `host = '*.example.com'` filter to `AccessTotalsSince` / `BandwidthSince` — never matched. New `hostMatchClause` helper detects the `*.` prefix and translates to `host LIKE '%.example.com'`. Wildcard-routed traffic now contributes to per-server cards.
- **v2.12.8 — Dashboard cards scoped to active server.** Requests / Visitors / Bandwidth Today were summing across the whole fleet instead of the active picker selection. Now loops over the active server's hostnames and sums per-host (same pattern `/analytics?server=<id>` already used).
- **v2.12.5 — AI assistant beefed-up system prompt + model-size hint.** llama3.2:3b was hallucinating Caddy v1 trivia and inventing directives. Concrete Caddyfile examples in the prompt + an explicit "say I don't know" rule. Settings now warns about 3B-class models and recommends qwen2.5:14b / qwen2.5-coder:14b / gemma2:9b / llama3.1:8b for actually-correct answers.
---
## [2.12.4] — 2026-04-28 · Managed DNS on redirections + multi-domain checks (cumulative)
> **Patch wave on top of v2.12.0.** Republishes `:latest`, `:stable`, `:preview`, and `:v2.12.4` (multi-arch). `:latest` retags from v2.12.0 → v2.12.4.
This block consolidates four small releases that landed right after v2.12.0:
### Added
- **v2.12.1 — per-hostname DNS-record pre-flight on multi-domain routes.** The Managed-DNS form now iterates *every* hostname in the route's `match.host` (or the proxy host's Domains CSV) and shows a per-hostname status checklist: `⚠ already exists, will skip` vs `✓ will be created`. Backend has been creating records per hostname since v2.5.9; the form now matches.
- **v2.12.2 — Managed DNS for redirection hosts.** Closes the long-standing gap where proxy hosts and raw routes had Managed DNS but redirections didn't. New `dns_provider` / `dns_zone_id` / `dns_zone_name` / `dns_record_id` columns on `redirection_hosts`; new picker on the redirection edit form (right under TLS Certificate); `dnsCreateRecordForRedirection` mirrors the proxy-host helper. A record per hostname auto-created on save, deleted on row removal.
### Fixed
- **v2.12.3** — closed an unbalanced `{{if .AnyDNSEnabled}}` block in `redirection_host_form.html` that surfaced as `template: redirection_host_form.html:486: unexpected EOF` in the server log on every redirection-form render after v2.12.2.
- **v2.12.4** — redirection-host zone picker was checking `data.zones` but `/api/dns-zones` returns a flat `[{id,name},...]` array, so Cloudflare zones never populated. Picker now accepts both shapes.
---
## [2.12.0] — 2026-04-28 · UX & navigation polish — major release
> **Major release.** Published as `applegater/caddyui:v2.12.0`, `:preview`, `:stable`, and `:latest` (multi-arch `linux/amd64` + `linux/arm64`). Consolidates the v2.11.0 → v2.11.18 preview cycle into the new GA tag. **`:latest` now points at v2.12.0**, replacing the previous v2.11.7.
### What's included
The v2.11 preview cycle was the largest UX-only release in CaddyUI's history — 18 numbered preview builds spanning navigation, search, form ergonomics, bulk operations, and live-edit tooling. v2.12.0 ships all of it as one stable cut.
#### Navigation & search
- **⌘K / Ctrl+K command palette** searching every proxy host, redirection, raw route, and certificate (v2.11.5).
- **Live filter inputs** on `/raw-routes`, `/certificates`, `/redirection-hosts` (matching the existing `/proxy-hosts` pattern; v2.11.0).
- **Tri-state theme toggle** (auto / light / dark) tracking system preference by default (v2.11.0).
- **Quick-nav chord shortcuts** (`g d` / `g p` / `g r` / `g c` / `g a` / `g n`) (v2.11.0).
- **Keyboard-shortcut overlay** (`?`) listing every binding (v2.11.0).
#### Proxy-host edit form (top-down reorder)
After v2.11.0 → v2.11.8, the form reads:
1. Domain names
2. Forward Scheme + Host + Port + Test upstream
3. Enabled / Auto SSL / Force SSL
4. Managed DNS
5. TLS Certificate
6. Path matching + tags + notes (collapsed)
7. Upstream TLS & advanced settings (collapsed)
8. Options & Forwarded Headers (collapsed, ~70 fields)
Plus: configured-field count badges on every collapsed `<details>`, sticky save bar, live route-JSON preview.
#### Bulk operations on every list page
- `/proxy-hosts` (existing) — Enable / Disable / Delete / Maintenance bulk
- `/redirection-hosts` (v2.11.6) — Enable / Disable / Delete bulk
- `/raw-routes` (v2.11.9) — Enable / Disable / Delete bulk
- `/certificates` (v2.11.10) — Delete bulk (honours in-use guard)
#### Drag-to-reorder
Per-row ⠿ handles on `/proxy-hosts` and `/redirection-hosts` — UI-only, writes `sort_order = index*10` (v2.11.11).
#### Dashboard widgets
- **Recently edited** strip — last 8 CRUD events across all resource types (v2.11.12).
- **Caddy fleet status grid** — one card per registered server with status, host count, version, last seen (v2.11.16). Renders only when more than one server is registered.
#### Operational features
- **Live route-JSON preview** on the proxy-host form — shows the exact Caddy JSON the form would push, refreshing as you edit. Lenient on missing required fields (v2.11.13, fixes in v2.11.17 and v2.11.18).
- **Notifier covers ACME / Let's Encrypt managed live certs** — daily TLS dial against each enabled SSL-on proxy host, fires webhook + email when expiry is within threshold (v2.11.14).
- **AI assistant powered by local Ollama** — opt-in floating chat button. Settings: enable toggle, Ollama URL (default `http://ollama:11434`), model name (default `llama3.2:latest`). Runs locally, no cloud calls (v2.11.15).
- **Wildcard cert auto-issuance via DNS-01** — `*.example.com` in Domains now triggers an automatic `apps.tls.automation.policies` entry that drives Caddy's DNS-01 challenge using the Cloudflare token already configured for managed DNS. Frontend shows a live callout when wildcards are detected in the form. Cloudflare-only for v2.12.0; other DNS providers can be added incrementally (v2.11.19).
### Upgrade
```
docker compose pull && docker compose up -d
# or in Portainer: Recreate → enable Re-pull image
```
`:latest`, `:stable`, `:preview`, and `:v2.12.0` all point at the same multi-arch manifest. Migrations run automatically on startup. No downtime beyond the container restart.
### Per-version preview cycle
The v2.11.0 → v2.11.18 entries below remain for traceability — every preview build's individual changes are documented in this changelog.
---
## [2.11.19] — 2026-04-28 · Wildcard SAN auto-detection + DNS-01 ACME automation
> Preview build folded into v2.12.0.
### Added
- **Wildcard auto-detection on the proxy-host form.** When the Domains field has a `*.foo.bar` entry, a live callout under the field explains that DNS-01 ACME challenge will be used and asks for / confirms the Managed DNS provider selection.
- **Auto-emitted `apps.tls.automation.policies` for wildcard hosts.** `syncCaddy` now scans every enabled SSL-on proxy host, groups wildcard subjects by their Managed-DNS provider, and pushes a per-provider automation policy via the existing `pushAutomationPolicies` plumbing. The DNS provider's existing credentials (e.g. `cf_api_token`) are reused — no duplicate-entry friction.
- Cloudflare is the only provider mapped for v2.12.0. Other DNS providers slot into `caddyDNSProviderConfig` as their caddy-dns plugin schemas are confirmed.
### Caveats
- Caddy must include the `caddy-dns/<provider>` plugin (xcaddy build). Default `caddy:2-alpine` has no DNS plugins; users hitting "unknown module" errors should switch to a custom xcaddy build.
- The auto-emit is non-fatal: if Caddy rejects the policy, routes + apex certs are still applied. The failure surfaces as `sync_apply_automation_failed` in the activity log.
---
## [2.11.16] — 2026-04-28 · Multi-server health widget on the dashboard
> Preview build (`applegater/caddyui:v2.11.16` and `:preview`). `:latest` still pinned at v2.11.7.
### Added
- **Caddy fleet status grid on `/`.** One card per registered Caddy server with online/offline/unknown status, host count, Caddy version, and "last seen" relative timestamp. The active server (matching the picker) gets a brand-tinted ring; non-active rows show a "Switch to this server" button. External (read-only) servers carry a `read-only` tag so users remember they can observe but not push config there.
- Renders only when more than one Caddy server is registered. Single-server installs see no widget.
---
## [2.11.15] — 2026-04-28 · AI assistant powered by local Ollama
> Preview build (`applegater/caddyui:v2.11.15`).
### Added
- **Floating AI chat button** (bottom-right) that proxies prompts to a local Ollama instance. Useful for "explain this proxy host", "convert this Caddyfile snippet", "suggest headers for Nextcloud", etc. Runs locally on your GPU — no cloud calls.
- **New settings section** (Settings → AI assistant): enable toggle, Ollama base URL (default `http://ollama:11434`), model name (default `llama3.2:latest`).
- New endpoints `GET /api/ai/status` (so the floating button hides when AI is off) and `POST /api/ai/chat` (proxies to Ollama's `/api/chat` with a CaddyUI-flavoured system prompt that steers generic models toward Caddy / TLS / DNS answers).
- 60-second context-deadline on the upstream call so a slow / hung Ollama doesn't park a request handler indefinitely.
- MVP scope: single-shot Q&A, no server-side history. `Esc` closes the modal; `Enter` sends.
---
## [2.11.14] — 2026-04-28 · Notifier covers Let's Encrypt / ACME-managed live certs
> Preview build (`applegater/caddyui:v2.11.14`).
### Added
- **Cert-expiry alerts now cover Caddy-managed (ACME / LE / ZeroSSL) certs.** The existing notifier only inspected custom certs stored in CaddyUI's DB — production deployments using auto-issuance got no warning when those expired. v2.11.14 adds a daily TLS dial against `{first_domain}:443` for every enabled SSL-enabled proxy host, reads the peer cert chain, and fires the same webhook + email channels when expiry is within the configured threshold.
### Internal
- Reuses `notifierState.lastNotified` for 24h dedup; entries are keyed `proxy:<domain>` so they don't collide with custom-cert dedup keys.
- Wildcard SANs are skipped (can't dial `*.example.com` directly). Failed dials are silently retried tomorrow.
---
## [2.11.13] — 2026-04-28 · Live route-JSON preview on the proxy-host edit form
> Preview build (`applegater/caddyui:v2.11.13`).
### Added
- **Live route-JSON preview pane** at the bottom of the proxy-host edit form. Collapsed by default; expanding it shows the exact Caddy route JSON the form would push, refreshing ~500ms after the last keystroke or toggle. Useful for debugging why a header isn't being added or why a path matcher isn't firing.
- Reuses the same `parseProxyHostForm` + `caddy.BuildProxyRoute` path as `createProxyHost`, so what the user sees here is exactly what gets pushed on save. Lenient on missing fields — shows `⚠ strconv.Atoi: parsing "": invalid syntax` instead of breaking the pane.
### Internal
- New endpoint `POST /api/proxy-hosts/preview`. Skips fetches while the `<details>` is collapsed so background traffic stays minimal.
---
## [2.11.12] — 2026-04-28 · "Recently edited" widget on the dashboard
> Preview build (`applegater/caddyui:v2.11.12`).
### Added
- **Recent-CRUD activity strip on `/`.** Last 8 successful create / update / delete events across proxy hosts, redirects, raw routes, certificates, groups, and API tokens, plus `caddyfile_import` and `raw_reclassify`. Skips login / logout / sync_applied / snapshot / profile noise so the widget reads as "what just changed in CaddyUI".
- Each row shows a colour-coded resource pill matching the brand colours used elsewhere (Proxy / Redirect / Advanced / Cert / Group / API key), the actor, the verb (created / updated / deleted), the target, and a relative timestamp.
### Internal
- Adds `hasPrefix` and `hasSuffix` to the template `FuncMap` so action-string classification stays in the template.
---
## [2.11.11] — 2026-04-28 · Drag-to-reorder rows on /proxy-hosts and /redirection-hosts
> Preview build (`applegater/caddyui:v2.11.11`).
### Added
- **HTML5 drag handle (⠿)** in the leftmost cell of every desktop-table row on `/proxy-hosts` and `/redirection-hosts`. Drag the handle to reflow the row immediately; the new order POSTs to the matching `/reorder` endpoint and persists. Skips Caddy sync — list ordering is UI-only and doesn't change the generated config.
### Internal
- New endpoints `POST /proxy-hosts/reorder` and `POST /redirection-hosts/reorder`. Each accepts `ids[]` in desired display order and writes `sort_order = (index * 10)` per row. The `*10` multiplier leaves room for future single-row Sort Order edits to wedge between drag-saved rows without a full re-renumber.
- Non-admins can only reorder rows they own; foreign rows in the list are silently ignored.
---
## [2.11.10] — 2026-04-28 · Bulk delete on /certificates
> Preview build (`applegater/caddyui:v2.11.10`).
### Added
- **Floating bulk-action bar on `/certificates`** — Delete only since certs have no enable/disable state. Per-row checkbox is gated by `.CanEdit` so non-admins only see checkboxes on certs they own.
### Internal
- New endpoint `POST /certificates/bulk-delete`. Honours the same ownership + in-use guards as the single-row `deleteCertificate` handler — non-admins can only delete their own certs AND only when no other user's site still references the cert. A successful batch triggers a single `syncCaddy(forceTLS=true)` call so hosts that referenced any deleted cert revert to auto-issuance.
---
## [2.11.9] — 2026-04-28 · Bulk multi-select on /raw-routes
> Preview build (`applegater/caddyui:v2.11.9`).
### Added
- **Floating bulk-action bar on `/raw-routes`** — same pattern as `/proxy-hosts` and `/redirection-hosts`. Per-row checkbox, select-all in the header, **Enable / Disable / Delete** buttons that operate on every selected row in one shot.
### Internal
- New endpoints `POST /raw-routes/bulk-toggle` (action=enable|disable) and `POST /raw-routes/bulk-delete`. Both honour per-row ownership; each successful batch triggers a single `syncCaddy` call.
---
## [2.11.8] — 2026-04-28 · Proxy-form: hoist Forward Scheme / Host / Port + Test upstream to under Domain
> Preview build (`applegater/caddyui:v2.11.8`).
### Changed
- **Forward Scheme + Host + Port + Test upstream** moved to directly under the Domain row. Pairs naturally with the Domain field — no reason to scroll past Enabled / Auto-SSL / Managed-DNS / TLS-Cert / collapsed-metadata to set them.
### New top-of-form order
1. Domain names + www redirect + trailing slash
2. **Forward Scheme + Host + Port + Test upstream** ← v2.11.8
3. Enabled / Auto SSL / Force SSL ← v2.11.4
4. Managed DNS ← v2.11.7
5. TLS Certificate ← v2.11.7
6. *Path matching, query string, tags & notes* (collapsed)
7. *Upstream TLS & advanced settings* (collapsed)
8. *Options & Forwarded Headers* (collapsed)
---
## [2.11.7] — 2026-04-28 · Proxy-form: hoist Managed DNS + TLS Certificate to top, collapse path/metadata + upstream TLS
> **`:latest` retag.** Published as `applegater/caddyui:v2.11.7`, `:preview`, `:stable`, and `:latest` (multi-arch `linux/amd64` + `linux/arm64`). Fifteen features past v2.10.0 — the cadence rule (retag `:latest` every ~8 features) has been overdue since v2.11.0, and v2.11.7 is the moment.
### Changed
- **Managed DNS** and **TLS Certificate** sections moved to the top of the proxy-host edit form, directly under the v2.11.4 essentials row (Enabled / Auto SSL / Force SSL). They previously lived ~700 lines below, between the big Options block and the path-based upstream overrides — too far down for sections picked on almost every host. Both still carry `data-keep-open` so they stay open on both create and edit.
- **Path matching, query string, tags & notes** is now a single collapsed `<details>` covering Path prefix, Strip prefix, Strip query string, Delete query params, Add query params, Tags, Notes, Color label, and Sort Order. The v2.11.0 section badges count configured fields inside, so users can spot what's set without expanding.
- **Upstream TLS & advanced settings** is a new collapsed `<details>` covering Upstream TLS SNI, minimum TLS version, Forward proxy URL, Upstream path prefix, Strip path suffix, and Upstream Host header. Most users never touch any of these.
### Top of form now reads (top-down)
1. Domain names + www redirect + trailing slash
2. Enabled / Auto SSL / Force SSL
3. **Managed DNS** (visible)
4. **TLS Certificate** (visible)
5. *Path matching, query string, tags & notes* (collapsed)
6. Forward Scheme + Host + Port + Test-upstream button
7. *Upstream TLS & advanced settings* (collapsed)
8. *Options & Forwarded Headers* (collapsed, existing)
---
## [2.11.6] — 2026-04-28 · Bulk multi-select on /redirection-hosts
> Preview build (`applegater/caddyui:v2.11.6` and `:preview`). `:latest` still pinned at v2.10.0.
### Added
- **Bulk action bar on `/redirection-hosts`** — same floating bottom-bar pattern `/proxy-hosts` has had. Per-row checkbox, select-all in the header, and Enable / Disable / Delete buttons that act on every selected row in one shot. Each successful batch triggers a single `syncCaddy` call instead of one-per-row.
### Internal
- New routes: `POST /redirection-hosts/bulk-toggle` (action=enable|disable) and `POST /redirection-hosts/bulk-delete`. Both honour per-row ownership — non-admins can only toggle / delete their own rows.
- Mobile card view stays checkbox-free; bulk select is rare on a phone screen and matches the `/proxy-hosts` decision.
---
## [2.11.5] — 2026-04-28 · ⌘K / Ctrl+K command palette — global resource search
> Preview build (`applegater/caddyui:v2.11.5` and `:preview`).
### Added
- **Global command palette.** Press `⌘K` (macOS) or `Ctrl+K` (everywhere else) on any page to open a modal that searches across every proxy host, redirection, raw route, and certificate visible to the current user on the active server. `↑/↓` navigates results, `Enter` opens the highlighted item, `Esc` or backdrop-click closes.
- **Type pills** colour-code results: <span style="color:#1d4ed8">Proxy</span> · <span style="color:#0369a1">Redirect</span> · <span style="color:#7e22ce">Advanced</span> · <span style="color:#047857">Cert</span> — same brand colours used elsewhere in the app.
- **Keyboard-shortcut overlay (`?`)** updated to list the new binding.
### Internal
- New endpoint `GET /api/search` returns a flat list of `{type, id, label, sub, url}` items, scoped through the same viewer / peer / admin permission filtering the list pages already use.
- Frontend caches the response per palette open with a 60-second freshness window — reopening the palette is instant; a stale cache silently re-fetches in the background.
---
## [2.11.4] — 2026-04-28 · Proxy-form: hoist Enabled / Auto SSL / Force SSL to top
> Preview build (`applegater/caddyui:v2.11.4` and `:preview`).
### Changed
- **Three essential toggles** (`Enabled`, `Auto SSL (Let's Encrypt)`, `Force SSL (HTTPS redirect)`) now sit in their own always-visible row directly under the Domain names input — alongside Managed DNS and TLS Certificate as the form's "essentials" area. Previously they were buried inside the collapsed `Options & Forwarded Headers` section after v2.11.2, so flipping them required expanding a 50-field block first.
### Kept
- The `Options & Forwarded Headers` section keeps the rest (Verify Upstream TLS, X-Forwarded-* headers, blocking toggles, ~50 fields) collapsed by default — same `data-keep-open` / collapse pattern as before.
---
## [2.11.3] — 2026-04-27 · Proxy-form: collapse on edit too
> Preview build (`applegater/caddyui:v2.11.3` and `:preview`). `:latest` still pinned at v2.10.0.
### Changed
- **All `<details>` sections start collapsed on edit too**, not just create. Section badges (added in v2.11.0) carry the field-count summary so users can see what's configured without expanding. Avoids the form auto-expanding 8+ sections at once when opening a configured host.
### Kept
- **Managed DNS** and **TLS Certificate** remain auto-open on both create and edit via `data-keep-open` — the two sections users almost always need.
---
## [2.11.2] — 2026-04-27 · Proxy-form: wrap the Options/X-Forwarded block in `<details>`
> Preview build (`applegater/caddyui:v2.11.2`).
### Fixed
- **Options & Forwarded Headers block is now collapsible.** The ~50-field block of TLS settings, X-Forwarded-* toggles, and blocking flags was a plain `<div>`, so the v2.11.1 collapse-on-create logic didn't apply. Wrapped in a `<details>` so it follows the same pattern as every other section.
### Changed
- **Managed DNS + TLS Certificate marked `data-keep-open`** — they stay open regardless of the global collapse rule because they're picked on almost every host.
---
## [2.11.1] — 2026-04-27 · Proxy-form: collapse all sections on create
> Preview build (`applegater/caddyui:v2.11.1`).
### Changed
- On **create**, every `<details>` section starts collapsed so the user can expand one feature, edit it, collapse back, and move to the next without the form scrolling for pages.
- On **edit**, sections with configured fields still auto-open so users immediately see what's set. (Edit-side auto-open was later reverted in v2.11.3 — badges replaced it.)
---
## [2.11.0] — 2026-04-27 · UX & navigation polish — 8-feature batch
> Preview build (`applegater/caddyui:v2.11.0` and `:preview`). `:latest` still pinned at v2.10.0. Eight features that round off the v2.10 import-classification arc with nav, search, and form-ergonomics improvements.
### Added
- **Live filter on `/raw-routes`** — search input above the table, JS filters by host / label / Caddyfile content. Honours `?q=…` deep-links. Matches the `/proxy-hosts` and `/redirection-hosts` pattern.
- **Live filter on `/certificates`** — same pattern; matches by name, domain SAN list, or owner email.
- **Tri-state theme toggle** (`auto` / `light` / `dark`). Was a binary toggle — once a user clicked it, system preference was permanently overridden. New default is `auto` which actively tracks `prefers-color-scheme` via a `matchMedia` listener; explicit choices override. Cycles through the three states; icon + tooltip update to show the active mode.
- **Sticky save bar on `proxy_host_form`.** With ~70 per-host options plus the form-search, the in-form Save button is often offscreen. `IntersectionObserver` shows a floating Save / Cancel pill at the bottom of the viewport whenever the in-form actions row scrolls out of view. Re-uses the same `<form>` via the `form=` attribute so there's no duplicate state.
- **Section badges on `proxy_host_form`.** Each `<details>` section gets a small brand-coloured pill in its `<summary>` showing how many fields inside it are configured (non-default). Defaults: `input.value !== ''` for text, `.checked` for checkboxes, `selectedIndex > 0` for selects. Sections with set fields originally auto-opened (changed in v2.11.3 — badges-only).
- **Keyboard-shortcut overlay (`?`).** Press `?` on any page to open a modal listing every global shortcut. `Esc` closes; clicks outside dismiss. Bindings ignore key presses while typing in inputs / textareas / contenteditables so `/` etc. don't fire mid-edit.
- **Quick-nav chord shortcuts** (Vim-style `g` + letter):
- `g d` → / (dashboard)
- `g p` → /proxy-hosts
- `g r` → /redirection-hosts
- `g c` → /certificates
- `g a` → /raw-routes
- `g n` → /analytics
- 1.2s window for the second key; out-of-window resets the chord.
- **Richer empty states on `/proxy-hosts` and `/redirection-hosts`.** Replaces the bare "No proxy hosts yet · + Create your first" with a card that explains what the resource IS, offers three on-ramps (manual / paste Caddyfile / import live config), and links to the docs. Brand-tinted (proxy = brand, redir = sky) so the visual identity matches the rest of the app.
---
## [2.10.0] — 2026-04-27 · Per-host options expansion + analytics speedup + path-routing + multi-upstream
> **Major release.** Published as `applegater/caddyui:v2.10.0` and `:latest` (multi-arch `linux/amd64` + `linux/arm64`). Consolidates the entire v2.9.1 → v2.9.267 preview cycle into one released entry — the numeric tags in that range were internal-only builds during development. **`:latest` now points at v2.10.0**, replacing the previous v2.9.0 GA build.
### Added
This batch is a sustained expansion of the **per-host configuration surface** introduced in v2.9.0. Every option below is a column on `proxy_hosts`, a field on the proxy-host edit form, and a Caddy-JSON builder branch in `internal/caddy/client.go`. Defaults are off / empty so existing rows behave identically until you opt in. Grouped by category for readability.
#### Request headers forwarded to upstream
- **`add_x_real_ip`** (v2.9.149) — `X-Real-IP: {http.request.remote.host}`.
- **`add_x_real_scheme`** (v2.9.191) — `X-Real-Scheme: {http.request.scheme}`.
- **`add_x_forwarded_scheme`** (v2.9.143) — `X-Forwarded-Scheme: {http.request.scheme}`.
- **`add_x_forwarded_uri`** (v2.9.199) — `X-Forwarded-URI: {http.request.uri}`.
- **`strip_incoming_x_forwarded_for`** (v2.9.150) — drop client-supplied `X-Forwarded-For` before Caddy adds its own.
- **`add_x_request_path` / `add_x_request_method` / `add_x_request_query` / `add_x_request_hostname` / `add_x_request_referer` / `add_x_request_origin`** (v2.9.181, .188, .189, .201, .197, .198) — toggle individual `X-Request-*` debug/trace headers populated from `{http.request.*}` placeholders.
- **`add_x_request_start`** (v2.9.140) — `X-Request-Start: t={time.now.unix_ms}` for upstream latency tracing.
- **`add_origin_header`** (v2.9.159) — set a static `Origin` request header.
- **`add_x_forwarded_user` / `add_x_forwarded_email` / `add_x_forwarded_groups` / `add_x_forwarded_roles`** (v2.9.190, .194, .193, .195) — static identity headers for trusted-network impersonation patterns.
- **`add_x_request_remote_port` / `add_x_request_protocol`** (v2.9.212, .213) — toggle `X-Request-Remote-Port: {http.request.remote.port}` (client TCP source port) and `X-Request-Protocol: {http.request.proto}` (HTTP/1.1, HTTP/2, etc.).
- **`add_x_environment`** (v2.9.217) — static `X-Environment` value (`production` / `staging` / `dev`) for upstream-side env detection.
- **`add_x_trace_id` / `add_x_session_id`** (v2.9.218, .220) — `X-Trace-ID` and `X-Session-ID` populated from `{http.request.uuid}` so distributed-tracing pipelines have a stable per-request correlation key.
- **`add_x_request_local_addr` / `add_x_request_local_port`** (v2.9.222, .223) — Caddy's *listening* IP and port (vs the client-side remote IP/port) so multi-listener installs can route by which interface the request hit.
- **`add_x_request_path_info`** (v2.9.224) — `X-PathInfo: {http.request.uri.path}` for CGI-style backends that consume `PATH_INFO`.
#### Response headers (security & cache)
- **`add_x_permitted_cross_domain_policies` / `add_document_policy` / `add_origin_agent_cluster`** (v2.9.167, .156, .192) — modern cross-origin / isolation hints.
- **`add_x_xss_protection_disabled` / `add_x_download_options` / `add_x_no_archive` / `add_x_dns_prefetch_control`** (v2.9.202, .177, .200, .163) — security-hardening toggles.
- **`add_x_powered_by` / `add_x_clacks_overhead` / `add_x_ua_compatible`** (v2.9.154, .182, .183) — custom branding / legacy-IE / GNU-tradition headers.
- **`add_cors_vary_header`** (v2.9.152) — `Vary: Origin` on every response (CDN-correctness for CORS).
- **`add_link_preload`** (v2.9.145) — `Link: …` resource hints.
- **`add_accept_ranges`** (v2.9.164) — `Accept-Ranges: bytes` to advertise byte-range support.
- **`add_content_disposition` / `add_content_language`** (v2.9.165, .175) — content metadata headers.
- **`response_cache_ttl_sec`** (v2.9.144) — emits `Cache-Control: max-age=N`.
- **`add_age_zero` / `add_pragma_no_cache` / `add_surrogate_control` / `add_warning_header`** (v2.9.185, .179, .186, .187) — cache-bypass / CDN-only / RFC-7234 advisory directives.
- **`add_server_timing_header`** (v2.9.161) — `Server-Timing: upstream;dur=…` for browser DevTools timing.
- **`add_clear_site_data`** (v2.9.162) — `Clear-Site-Data` value for logout / data-purge endpoints.
- **`add_request_id_to_response`** (v2.9.147) — copy the request trace ID into the response.
- **`add_accept_ch` / `add_critical_ch`** (v2.9.173, .176) — Client Hints negotiation.
- **`add_alt_svc`** (v2.9.174) — advertise alternative services (HTTP/3 upgrade, etc.).
- **`add_service_worker_allowed`** (v2.9.172) — expand SW scope beyond script URL.
- **`add_report_to` / `add_nel_header`** (v2.9.169, .170) — Reporting API + Network Error Logging.
- **`strip_response_headers`** (v2.9.168) — comma-separated list of response headers to delete from upstream replies.
- **`add_save_data_vary`** (v2.9.214) — appends `Save-Data` to the `Vary` response header so caches keep separate copies for clients sending the `Save-Data: on` client hint.
- **`add_x_response_trace_id`** (v2.9.221) — sets `X-Response-Trace-ID` on the response to Caddy's per-request UUID so clients can correlate responses with server-side trace logs.
#### Request blocking (returns 403 / 405)
- **`deny_path_regexp`** (v2.9.146) — 403 when path matches a regexp.
- **`block_query_params`** (v2.9.155) — 403 when any of the named query parameters are present.
- **`block_query_param_regexp`** (v2.9.196) — 403 when the raw query string matches a regexp.
- **`deny_user_agent_regexp`** (v2.9.178) — 403 when User-Agent matches a regexp.
- **`block_http_methods`** (v2.9.171) — 405 with `Allow:` header for listed methods.
#### Active health checks
- **`health_check_tls_server_name`** (v2.9.148) — SNI override for probe connections.
- **`health_check_tls_insecure_skip_verify`** (v2.9.151) — skip cert verification on probes.
- **`health_check_user_agent`** (v2.9.180) — custom User-Agent for probe requests.
- **`health_check_query_params`** (v2.9.219) — query string appended to the active probe URL (e.g. `?token=abc&deep=1`) so endpoints requiring auth tokens or mode flags can be probed without baking them into `health_check_uri`.
#### Upstream TLS
- **`upstream_tls_alpn`** (v2.9.153) — ALPN protocol list for upstream TLS.
- **`upstream_tls_ca_pem_inline`** (v2.9.160) — inline PEM CA bundle for upstream verification.
- **`upstream_tls_server_name_from_host`** (v2.9.166) — derive upstream SNI from the incoming `Host` header (dynamic).
#### Forward auth
- **`forward_auth_skip_paths`** (v2.9.184) — comma-separated path prefixes that bypass `forward_auth` (health, metrics, public assets) by wrapping the auth handler in a `not { path … }` subroute.
#### Load balancing & connections
- **`lb_random_choose_count`** (v2.9.142) — number of upstreams sampled by the `random_choice` LB policy.
- **`upstream_keepalive_max_lifetime_sec`** (v2.9.158) — `transport.keep_alive.maximum_connection_lifetime`.
#### Maintenance window
- **`maintenance_window_timezone`** (v2.9.141) — evaluate the configured maintenance window in a specific IANA tz instead of the host clock.
- **`maintenance_redirect_url`** (v2.9.157) — when set, the maintenance handler returns a 302 redirect instead of the default 503 HTML page.
### Internal
- **DB migrations are additive only.** Each v2.9.x option lands as an `ALTER TABLE proxy_hosts ADD COLUMN <name> … DEFAULT <off>` guarded by `columnExists2`, so upgrading is a no-op for existing rows. Downgrade is supported (older builds simply ignore the new columns).
- **`scanProxyHost` / `proxyHostBaseCols` / `CreateProxyHost` / `UpdateProxyHost` extended uniformly.** No schema fan-out or feature-table indirection — every option is a flat column read by the same scan path. Cuts query overhead for list views to a single `SELECT`.
### Added — major new features (v2.9.228 → v2.9.267)
#### Advanced (raw routes)
- **Caddyfile / JSON tabbed editor on the raw-route form.** Either field can be filled; Caddyfile wins on save (re-runs through Caddy's `/adapt` to regenerate the JSON). New routes default to the Caddyfile tab — far more readable than the JSON shape. Existing rows open on whichever tab already has content. The plumbing was there since the import-Caddyfile feature shipped; v2.9.228 just exposed it as a UX-first toggle on every form. (v2.9.228)
- **Validate button on raw routes.** New endpoint `POST /api/raw-routes/validate`. For Caddyfile mode it round-trips through `/adapt` (true read-only validation). For JSON mode it parses + structurally checks (must have non-empty `handle[]`); deeper semantic errors surface at sync time. (v2.9.228 — initial broken implementation; **v2.9.233** rewrote it because the original POSTed to `/load?validate_only=true` which Caddy *ignores* — the synthetic config was actually applied to running Caddy. The fix avoids `/load` entirely.)
#### Redirections
- **Path-based redirect rules on redirection hosts.** New JSON column `redirect_rules`; each rule is `{path, code, destination}`. When non-empty, per-rule path matchers fire BEFORE the host-wide redirect catch-all, enabling partial-migration patterns (`/old-blog/* → newblog.com/{uri}`) without affecting the rest of the host. Empty destination + 410 = "Gone" for retired URLs. UI is a dynamic add/remove/edit table; a hidden input keeps the JSON array in sync for the form post. (v2.9.229)
- **`redirect_strip_path_prefix`** — drop a leading prefix from the captured path before composing Location. Mirrors the proxy-host `strip_path_prefix` lifted to redirects. (v2.9.230)
- **`redirect_wildcard_subdomain`** — substitute the matched subdomain label into the destination via `{http.request.host.labels.0}`. Enables `*.old.com → *.new.com` per-tenant migrations. (v2.9.231)
- **`sunset_at`** — ISO-8601 date column. Once today (UTC) is on or after the configured date, the redirect short-circuits to **410 Gone** with body "This URL has been retired." Compliance/cleanup helper for "redirect old → new until 2027-01-01, then drop." (v2.9.232)
#### Proxy hosts — multi-path routing
- **`proxy_redirect_rules`** — same JSON-array pattern as redirection hosts but on proxy hosts. Each rule's path matcher fires BEFORE the reverse_proxy. Drives the common Caddyfile pattern: `@root path / + redir @root /webmail 302` alongside a regular reverse_proxy. (v2.9.266)
- **`additional_upstream_rules`** — path-based upstream overrides. Each entry is `{path, scheme, host, port, strip_prefix, add_x_real_ip}`. Lets a single proxy host route different paths to different upstreams — what the user's Nextcloud Caddyfile needed (`/push/* → notify_push`, `/exapps/* → AppAPI`, everything else → Nextcloud, all on one host). `strip_prefix` mirrors Caddyfile's `handle_path`; `add_x_real_ip` is a per-rule override of the host-wide flag. UI is a dynamic table with two checkboxes per row for those flags. (v2.9.267)
### Added — per-host options expansion (continued from v2.9.234 onwards)
#### Identity / blocking / preload / health auth (v2.9.234-241)
- **`add_x_authenticated_user`**, **`add_x_remote_user`** — static auth identity request headers (Apache/Nginx CGI conventions). (v2.9.234, .237)
- **`block_path_extensions`** — comma-separated file extensions returning 403 (matches `*.<ext>` via path globs). Good for blocking `.php` / `.git` / `.bak` on non-PHP backends. (v2.9.235)
- **`add_link_modulepreload`** — `Link: <…>; rel=modulepreload` response header for ES module preloading. (v2.9.236)
- **`add_x_forwarded_path`** — `X-Forwarded-Path: {http.request.uri.path}` request header. (v2.9.238)
- **`add_x_geo_country_code`** — static `X-Geo-Country` header (CDN convention). (v2.9.239)
- **`add_x_request_priority`** — `X-Request-Priority` response header (RFC 9218). (v2.9.240)
- **`health_check_basic_auth`** — `user:pass` sent as Basic auth on every active health-check probe; encoded once at config-build time. (v2.9.241)
#### TLS visibility / debugging / blocking / reporting (v2.9.242-249)
- **`add_x_real_ssl_protocol` / `add_x_real_ssl_cipher`** — forward TLS version + negotiated cipher to upstream so backends can record / log / decide on the original handshake details. (v2.9.242, .243)
- **`add_x_cache_status`** — static `X-Cache-Status` response header. (v2.9.244)
- **`deny_referer_regexp`** — return 403 when Referer matches a regexp; symmetric with `deny_user_agent_regexp` / `block_query_param_regexp`. (v2.9.245)
- **`add_x_request_user_agent`** — echo UA to upstream as a separate header (debug / multi-proxy-chain visibility). (v2.9.246)
- **`add_reporting_endpoints`** — `Reporting-Endpoints` response header per RFC 8942 (modern successor to Report-To). (v2.9.247)
- **`add_x_request_byte_count`** — forward Content-Length as `X-Request-Byte-Count` for upstream bandwidth accounting. (v2.9.248)
- **`add_x_request_received_at`** — forward `{time.now}` as `X-Request-Received-At` for request-trace latency attribution. (v2.9.249)
#### Strip request headers / forwarding / tracing (v2.9.250-257)
- **`strip_request_headers`** — comma-separated request header names to delete before forwarding (symmetric with `strip_response_headers` from v2.9.168). (v2.9.250)
- **`add_x_forwarded_method`** — `X-Forwarded-Method: {http.request.method}`. (v2.9.251)
- **`add_x_request_original_host`** — preserve pre-rewrite Host header. (v2.9.252)
- **`add_x_request_dnt`** — forward DNT (Do Not Track) signal as `X-Request-DNT`. (v2.9.253)
- **`add_x_geo_region`** — static `X-Geo-Region` (ISO 3166-2 region code). (v2.9.254)
- **`add_x_request_secure`** — `X-Request-Secure` with TLS version (empty if plain). (v2.9.255)
- **`add_x_request_query_count`** — raw query string copied as `X-Request-Query-Count` (debug header). (v2.9.256)
- **`add_x_request_id_header_response`** — echo Caddy's request UUID to the response so clients can correlate API calls with server logs. (v2.9.257)
#### Canonical host / bot+admin blocking / Link hints / CSP / method override (v2.9.258-265)
- **`force_canonical_host`** — canonical Host for SEO consolidation. Any request whose Host doesn't equal this value gets a 301 redirect (path + query preserved). Common pattern: `www.example.com → example.com`. (v2.9.258)
- **`add_x_robots_noindex_quick`** — one-click `X-Robots-Tag: noindex, nofollow` toggle. (v2.9.259)
- **`block_bot_user_agents`** — built-in regex blocklist for AhrefsBot, SemrushBot, Bytespider, MJ12bot, DotBot, PetalBot, DataForSeoBot, YandexBot, BLEXBot, SerpstatBot. Doesn't touch Google/Bing. (v2.9.260)
- **`block_admin_paths`** — 404 on `/wp-admin`, `/wp-login`, `/.git`, `/.env`, `/phpmyadmin`, `/myadmin`, `/.svn`, `/.hg`, `/.aws`, `/.ssh`, `/admin/config.php`. 404 (not 403) so scanners don't learn the path exists. (v2.9.261)
- **`add_link_dns_prefetch` / `add_link_preconnect`** — `Link: <…>; rel=dns-prefetch` / `rel=preconnect` response headers. (v2.9.262, .263)
- **`add_x_csp_disabled`** — delete the upstream's `Content-Security-Policy` at the edge. (v2.9.264)
- **`add_x_request_method_override`** — rewrite request method from `X-HTTP-Method-Override` header (firewall workaround). (v2.9.265)
### Fixed
- **`/redirection-hosts` rendered an empty page.** The list template called `{{range .TagList}}` but `TagList()` was only defined on `ProxyHost`, never on `RedirectionHost`. `html/template` aborts execution on a missing method, so the page silently broke. Added the matching method on `RedirectionHost`. (v2.9.204)
- **"Test Upstream" button reported `host and port are required`** even with both fields filled in. Root cause: the handler called `r.ParseForm()` first, which initialises `r.Form` to non-nil for any content type — and `r.FormValue()` only triggers `ParseMultipartForm` automatically when `r.Form` is still nil. Result: multipart bodies (which the JS sends via `FormData`) were silently empty. Removed the redundant `ParseForm()` so `FormValue` auto-parses correctly. (v2.9.203)
- **Pasting the same Caddyfile twice silently created duplicate raw routes.** `postCaddyfileImport` blindly created a new row per adapted route while the parallel `postImport` path (sync from Caddy admin API) properly deduped against existing proxies, redirects, and raw routes. Brought `postCaddyfileImport` to parity — duplicates now report as `skipped` in the result table instead of being created. (v2.9.205)
- **`/api/docs` link to API Tokens went to `/settings`.** The hint paragraph at the top of the API reference used `<a href="/settings">Settings → API Tokens</a>` from when the tokens page was planned to live under Settings; the actual route is `/api-tokens`. Link now points there directly with text "API Tokens (sidebar → API Tokens)". (v2.9.208)
- **Successful logins, logouts, and TOTP attempts were never logged.** Only `login_fail` was written to `activity_log`; admins viewing `/activity` couldn't see who actually signed in or when. v2.9.210 adds `login_success`, `login_totp_success`, `login_totp_fail`, and `logout` events; the User-Agent goes into the Detail column for forensic context.
- **Activity log recorded `127.0.0.1` for every login.** Behind a reverse proxy (the typical CaddyUI setup — it sits behind the very Caddy it manages) `r.RemoteAddr` is always the proxy's loopback IP. New `clientIPFromRequest` helper checks `X-Real-IP` then `X-Forwarded-For` first entry then `r.RemoteAddr`, so the activity feed shows the real visitor's address. The brute-force-protection check at line 1349 also uses the helper now, so per-IP rate-limiting works correctly behind a proxy. (v2.9.210)
- **Login / logout / TOTP rows were invisible on `/activity`** even after the logging fix. `ListActivity` and `ListActivitySearch` filter `WHERE server_id = ?`, but auth events have no Caddy-server context (they use `server_id = 0`). Without explicit handling they got hidden by the per-server scope. Both queries now include `OR server_id = 0` so global auth events surface alongside the current server's rows. (v2.9.211)
- **"Test Upstream" error messages now carry actionable hints.** Generic Go net errors (`Get "https://Anthem-Omada:8043/": dial tcp: lookup … no such host`) are tedious to interpret, especially the very common DNS-from-container failures. The handler now classifies failures into DNS / TLS / connection refused / timeout categories and surfaces a one-liner hint below the raw error: e.g. for DNS it suggests `--add-host=name:ip` or `--dns=<router>` on the CaddyUI container. The form JS renders the hint as a second line under the red error. (v2.9.212)
- **Live upstream status panel painted every upstream red** even though Caddy reported all of them with `fails: 0` (healthy). The `UpstreamStatus.Healthy bool` field had no `json:"healthy"` tag, so Go marshalled it as `"Healthy"` (capital H); the proxy_hosts.html JS read `u.healthy` (lowercase) → undefined → falsy → `bg-red-500` for everyone. Single-character fix: added the `json:"healthy"` tag. (v2.9.216)
- **Validate button accidentally applied configs.** First implementation in v2.9.228 used `/load?validate_only=true`, which Caddy's admin API ignores — the synthetic test config was actually loaded into running Caddy each time someone clicked Validate, polluting autosave with a `_caddyui_validate` ghost server. v2.9.233 rewrote the handler: Caddyfile mode uses `/adapt` (true read-only), JSON mode uses parse + structural-shape check (no admin call). (v2.9.233)
- **DOM-text-as-URL hardening on two sites** (analytics server selector, layout row-click-to-edit). Both were server-rendered with trusted values, but the inputs went through type/shape validation now (`parseInt(this.value, 10) || 0` + `> 0` check; `href[0] === '/' && href[1] !== '/'` check) to satisfy CodeQL `js/xss-through-dom` and harden defense-in-depth against attribute tampering. (v2.9.226)
### Security
- **SMTP header CRLF injection fixed in both `sendEmail` and `sendEmailTo`.** Both functions interpolated `From` / `Subject` / recipient values straight into the message envelope without sanitisation. A user-controlled subject of `hi\r\nBcc: attacker@evil.com` would smuggle an extra `Bcc:` header into the outgoing mail. New `stripCRLF` helper replaces CR/LF with space on those header values before composing the message; body content is left alone (newlines in body are content, not header boundaries). Resolves the GitHub code-scanning **"Email content injection"** alert as a real fix, not a suppression. (v2.9.209 — `sendEmail`; v2.9.225 — `sendEmailTo`, the sibling function CodeQL flagged separately)
- **Log injection fixed in unauthenticated handler paths.** The `forgot-password` and `invite` handlers logged the user-submitted email verbatim. Attacker types `victim@example.com\n[CRITICAL] system compromised` into the form → log shows a forged line. New `sanitizeForLog` helper escapes `\r`/`\n` to literal `\\r`/`\\n` (preserves diagnostics, breaks line-injection vector). Applied to both call sites. (v2.9.225)
- **CodeQL custom config** — switched from GitHub default-setup to advanced via `.github/workflows/codeql.yml` + `.github/codeql/codeql-config.yml`. Four queries are excluded with documented rationale because their findings are intentional design for a reverse-proxy manager:
- `go/disabled-certificate-check` — `InsecureSkipVerify: true` on probe-only HTTP clients (Test Upstream, active health checks, app-health poller) is required to reach self-signed/internal-CA backends; the flag is never set on the forward path Caddy serves to end users.
- `go/request-forgery` — CaddyUI exists to issue HTTP requests to user-specified targets (Test Upstream, health checks, forward_auth subrequests, DNS provider APIs, NTFY/webhook notifications); SSRF as a class doesn't apply because the user driving these requests is the authenticated admin, not an untrusted external party.
- `go/email-injection` — `stripCRLF` already neutralises the SMTP-header-smuggling vector in both email functions; CodeQL's data-flow analyser doesn't recognise the sanitiser.
- `go/log-injection` — the remaining flagged sites are in DNS plumbing where the user-controlled values are FQDNs / zone names / provider IDs / IPs the admin themselves typed. The realistic threat is "an authenticated admin smuggles a forged log line by typing `\n` into their own DNS setting" — they can already do anything they want to the DB and log file. Unauthenticated input paths use `sanitizeForLog` (above). (v2.9.209, .225, .227)
### Performance — analytics
- **SQLite tuned for analytics workloads.** New PRAGMAs in the connection DSN: `cache_size=-262144` (256 MiB page cache, was ~2 MiB default), `mmap_size=268435456` (256 MiB mmap window for index scans), `synchronous=NORMAL` (safe with WAL, faster writes during analytics reads), `temp_store=MEMORY` (GROUP BY / DISTINCT temp tables stay in RAM). Existing WAL mode + busy_timeout retained. (v2.9.206)
- **`access_daily` rollup is now actually used.** The schema for it was added in v2.7.0 but never populated and never queried — every analytics card scanned `access_events` directly even for 30/90/365-day windows. v2.9.206 adds a startup + hourly aggregator (`AggregateAccessDaily`) that backfills missing UTC days. v2.9.207 extends the rollup with per-status-class buckets (`s2xx`/`s3xx`/`s4xx`/`s5xx`/`s_other`) and an idempotent column migration for existing tables. The aggregator detects pre-status-bucket rows and re-aggregates them.
- **`AccessTotalsSince` and `StatusBucketsSince` rollup-aware.** Long windows (≥ today's UTC midnight in the past) now read past-day totals from `access_daily` and only scan `access_events` for today, dropping the cost of "Last 30 days" / "Last 90 days" / status-pie cards from `O(rows-in-window)` to `O(rows-today + days-in-window)`. Multi-second scans become ~10ms lookups on installs with millions of events. Visitor counts from the rollup are best-effort approximate (same IP across multiple days counted multiple times) — schema comment already noted this. (v2.9.206 — totals; v2.9.207 — status)
- **Two missing indexes on `access_events`** for the status-code breakdown card and unique-visitor count: `idx_access_events_status_ts(status, ts)` and `idx_access_events_client_ip_ts(client_ip, ts)`. `path` was deliberately *not* indexed — high-cardinality and the index would be larger than the table; top-paths queries already use the `(host, ts)` range scan + GROUP BY which is fast enough. (v2.9.206)
### UI
- **API Tokens** sidebar entry added between **API** and the admin section (visible to all authenticated users, not just admin). The route, handler, and template existed since the API tokens feature shipped, but there was never a link to reach them from the UI. New `key` icon added to the icon helper. (v2.9.207)
### Docker
- Published as `applegater/caddyui:v2.10.0` and `:latest` (multi-arch `linux/amd64` + `linux/arm64`).
- The `:preview` tag was used during the v2.9.1 → v2.9.267 development cycle and now points at v2.10.0 as well.
### Upgrade note
- Just `docker pull` won't activate the new image — recreate the container (Portainer's **Recreate** button with "Pull latest image" toggled, or the `recreate.sh` helper script).
- DB schema migration runs automatically on first start; no manual SQL needed.
- All new options default to off / empty — existing rows behave identically until you edit them and opt in.
- On first start the access_daily backfill aggregator runs once for installs with historical events. Watch for `access_daily: aggregated N day(s)` in the log; subsequent loads of `/analytics` are then instant.
- If your CaddyUI container can't resolve the LAN hostnames your proxy hosts use (e.g. `Anthem-Omada`, `omada-controller` — Docker's embedded DNS at `127.0.0.11` doesn't see your LAN), add `--dns=<router-ip>` or `--add-host=<name>:<ip>` to the container args, or run with `--network=host`. The Live upstream panel reads from Caddy's authoritative view (which has its own resolver) so it's unaffected; only CaddyUI's own port-dot probe needs DNS.
- **Open the Validate button**: in v2.9.228 the original implementation accidentally polluted Caddy's autosave with a `_caddyui_validate` ghost server. v2.9.233 fixed the bug. If you're upgrading from v2.9.228-232 directly to v2.10.0 (skipping the in-between fix), trigger any sync from CaddyUI after the upgrade to overwrite autosave.
---
## [2.7.9] — 2026-04-26 · Source column on /raw-routes + Recent advanced routes on dashboard
### Added
- **Source column on `/raw-routes`.** Mirrors `/proxy-hosts` so the table reads the same way at a glance — leftmost column shows the route's `match.host[]` entries as clickable domain pills (each opens the hostname in a new tab). Path-only / port-only routes (no host matcher) render `— no host matcher` in italic ink-300 instead of an empty cell. Existing columns (Label, Config, Owner, Status, Actions) shift right one slot; empty-state colspan bumped from 4/5 to 5/6 to match. Mobile-card variant promotes the host pills above the label so the card has the same identifying anchor as the desktop row.
- **"Recent advanced routes" block on the dashboard.** Renders below the existing "Recent proxy hosts" block — same mobile-card / desktop-table split, same Source-style domain pills, same Edit-on-click behaviour. Only shows when there's at least one raw route in the DB so fresh installs stay uncluttered. Tile counters at the top of the dashboard already linked to `/raw-routes`; the new block surfaces the actual route list inline so users with mostly-advanced setups don't need to click through.
- **`rawRouteSourceHosts` template helper** (`internal/server/server.go` parseTemplates). Wraps the existing internal `rawRouteHosts(models.RawRoute)` so templates can extract `match.host[]` from a JSON string with `{{range rawRouteSourceHosts .JSONData}}`. Returns `nil` for path-only / port-only routes — templates fall back to the route label so the Source column never renders empty.
### Changed
- **Dashboard handler now passes the full `RawRoutes` slice** (in addition to the existing `RawCount`) so the new block can render. Same pattern as `ProxyHosts` / `RedirectionHosts` — no slicing or limit here, mirroring the existing "Recent proxy hosts" behaviour. If you have hundreds of advanced routes the block will render all of them; same caveat already applies to the proxy-hosts block.
### Docker
- Published as `applegater/caddyui:v2.7.9` and `:latest` (multi-arch `linux/amd64` + `linux/arm64`).
---
## [2.7.8] — 2026-04-25 · Enforce zone ↔ hostname match on proxy hosts and raw routes
### Fixed
- **The DNS picker would let you save a hostname into the wrong zone.** Reported live: a route for `richardapplegate.io` ended up paired with the `applegatecloud.com` zone in the dropdown. The form rendered the v2.5.1 amber "isn't a subdomain of …" warning, but it was advisory only — clicking Save committed the row, and the subsequent `dnsCreateRecord` call either failed at the provider API or silently put the A record in the wrong zone. Symmetric on both the proxy-host form and the raw-route form, since both share the picker logic.
- **Front-end: hostname-derived zone now always wins.** `renderZones` (in both `web/templates/proxy_host_form.html` and `web/templates/raw_route_form.html`) used to call `bestZoneMatch(firstDomain, zones)` only when nothing was pre-selected and the user hadn't manually picked. That left two failure paths: (a) editing an existing row whose saved `dns_zone_id` was wrong — the dropdown stayed on the wrong zone — and (b) a manual mis-pick stuck around even after the user changed the hostname. v2.7.8 strips both gates: if any zone in the list is a parent of the route's first hostname, the dropdown snaps to it on every render. Falls back to the saved selection only when no zone matches (e.g. the user has zones for other domains and is willing to save without managed DNS, in which case they should pick "(none)" as the provider anyway).
- **Server-side: new `validateZoneMatchesHostname(provider, zoneID, zoneName, domains)` validator wired into all four DNS-aware save handlers** — `createProxyHost`, `updateProxyHost`, `createRawRoute`, `updateRawRoute`. Returns "" when no managed DNS is configured (provider or zoneID empty) or when the first hostname is a suffix of the zone name; otherwise returns a user-facing error and the row is refused. Matching is case-insensitive and trim-tolerant via the new `domainInZone(fqdn, zoneName)` helper, which mirrors how Caddy and every DNS provider we integrate with normalises FQDNs (case-insensitive, trailing-dot stripped). Validates the *first* hostname only (matching what `dnsCreateRecord` actually provisions records for) — extra entries in the comma-separated Domains field are SAN aliases on the same TLS cert, not separate DNS records.
- **Error message is actionable.** `Hostname "richardapplegate.io" doesn't live in DNS zone "applegatecloud.com". Pick a zone whose apex matches the hostname (e.g. zone "richardapplegate.io" for hostname "richardapplegate.io"), or change the DNS provider to (none) if you don't want CaddyUI to manage the A record.` The suggested apex comes from a `guessApex` helper that takes the rightmost two labels — good enough for `.com`/`.io`/`.net` style TLDs; doesn't try to be public-suffix-list-aware (the user picks the actual zone from the dropdown anyway, the suggestion is just a hint in the error text).
### Changed
- **Mismatch warning text strengthened** from "saving will create the DNS record in the wrong place otherwise" (advisory) to "Save will be rejected — pick a zone whose apex matches the hostname, or set the provider to (none) to skip managed DNS" (actionable). Matches the new server-side reality where the save is, in fact, rejected.
### Docker
- Published as `applegater/caddyui:v2.7.8` and `:latest` (multi-arch `linux/amd64` + `linux/arm64`).
### Compatibility
- **Existing rows with mismatched zone/hostname pairings are not auto-cleaned.** The validator only fires on save. Re-saving an affected row (via the edit form) will surface the new error and force you to pick the correct zone before it'll save. Rows you don't re-save keep their current behaviour — the next `dnsCreateRecord` call still fails the same way it always did, but now the new amber warning text on the form makes it obvious why.
---
## [2.7.7] — 2026-04-25 · Reject duplicate domains across proxies and redirects
### Fixed
- **A proxy or redirect could silently shadow another row with the same hostname.** Reported by users seeing "my domain got overridden to a different upstream" after another teammate (or the same admin in a second tab) created a second proxy claiming the same FQDN. Caddy's route table only keeps one match per host, so on the next `syncCaddy` push the newer entry's `reverse_proxy` handler took the slot and the older row stopped working — even though both rows were still listed in `/proxy-hosts` as enabled. There was no save-time guard against this: the form accepted any hostname the user typed and `CreateProxyHost` / `UpdateProxyHost` wrote it straight to the DB.
- **Root cause: `ProxyHostDomainsConflict` (in `internal/models/models.go`) existed but was never called.** Dead code since v2.2 — none of the four save handlers (`createProxyHost`, `updateProxyHost`, `createRedirectionHost`, `updateRedirectionHost`) referenced it. The function also had a latent bug: the redirection-host loop didn't honour the `excludeID` parameter, so editing a redirect would have falsely flagged a self-conflict the moment we tried to wire it in.
- **Fix: rewrote the helper as `models.DomainsConflict(db, serverID, domains, excludeProxyID, excludeRedirectID)` and called it from all four handlers.** The two-exclude split is required because proxy hosts and redirection hosts live in separate tables with overlapping ID space — a single `excludeID` would be ambiguous. Create-path callers pass `(0, 0)` (nothing to exclude); update-path callers pass `(p.ID, 0)` for proxy edits and `(0, rh.ID)` for redirect edits, so a no-op save (e.g. toggling SSL on the same row) doesn't trip the new guard.
- **Comparison is case-insensitive and trim-tolerant.** `Example.com `, `example.com`, and `EXAMPLE.COM` all collapse to the same key, matching Caddy's host-matcher semantics. Returns the conflicting domain in its original casing so the error message reads back what the user actually typed.
- **Admin view (`isAdmin=true`) is forced for the conflict check** so user A can't claim `example.com` after user B already claimed it via a different account. Caddy resolves routes by hostname, not by who owns the row in the UI, so the conflict has to be evaluated globally — owner-scoped checking would let two users each create a working-looking proxy and only one would actually receive traffic.
### UX
- **Error renders inline on the form, the user's input is preserved.** Both `renderProxyHostFormError` and `renderRedirectionHostFormError` (the same paths the SSL-flag and Advanced-config validators already use) now surface the conflict message: `Domain "example.com" is already in use by another proxy or redirect on this server. Each domain can only be claimed once — edit the existing entry or remove it before reusing the name.` No 500, no half-saved row, no opaque sync error after the fact.
### Not changed
- **Raw routes are intentionally not part of the conflict check yet.** Their host matchers live inside the route's JSON body rather than a flat column, and the `postImport` flow already covers raw-route deduplication via `rawRouteHosts`. Most of the user complaints we received were proxy↔proxy or proxy↔redirect, so the v2.7.7 scope is those two; raw-route conflict checking can land separately when there's evidence it matters.
### Docker
- Published as `applegater/caddyui:v2.7.7` and `:latest` (multi-arch `linux/amd64` + `linux/arm64`).
---
## [2.7.6] — 2026-04-24 · Fix /analytics server filter on multi-server installs
### Fixed
- **Picking a secondary Caddy server in the `/analytics` server filter showed "no data"** even on live, traffic-serving servers. Root cause: `applyAnalyticsToggle` (the function that wires Caddy's access-log forwarding when the admin flips the Analytics toggle in Settings) only called `EnableAccessLogs` / `DisableAccessLogs` on `s.Caddy` — the single primary client built from `CADDY_ADMIN_URL` — and ignored every other row in the `caddy_servers` table. Secondary Caddy instances registered through `/servers` never had the `caddyui_access` logger installed, so they never shipped access-log entries to the ingest listener; the `access_events` table had zero rows for any hostname routed by a secondary. When the `/analytics` filter narrowed the dashboard to a secondary server, `scopedHostsForAnalytics` correctly returned that server's hostnames, but the subsequent `AccessTotalsSince(host=...)` queries found no matching events and every card rendered zeros. The symptom only manifested on multi-server installs — single-server admins never noticed because their primary was (accidentally) the only server the toggle ever touched.
- **`applyAnalyticsToggle` now iterates every managed Caddy in the DB**, building a per-server `caddy.Client` via `newCaddyClient(adminURL, user, pass)` and applying the enable/disable action to each. External-type servers (rows with `type = 'external'` — CaddyUI-observed-but-not-authoritative) are skipped because their admin API isn't writable. Per-server errors are collected and returned as a single aggregated error at the end of the loop, so one unreachable Caddy doesn't short-circuit the wiring on the other three.
- **Fallback path preserved for fresh installs.** When `ListCaddyServers` returns an empty slice (DB just initialised, `SeedBootstrapServer` hasn't populated the primary row yet, or the servers table was manually cleared), the function falls back to the pre-2.7.6 single-`s.Caddy` call so first-boot analytics enable still works out of the box.
### Docker
- Published as `applegater/caddyui:v2.7.6` and `:latest` (multi-arch `linux/amd64` + `linux/arm64`).
### Upgrade note
- After upgrading, **save your Analytics settings once** (Settings → Analytics → Save) even if nothing changed. That single save re-runs the toggle through the new multi-server loop and installs the `caddyui_access` logger on every secondary Caddy you've added through `/servers`. You should see events from every server in the ingest-health card within a minute of traffic hitting them.
---
## [2.7.5] — 2026-04-24 · Fix /backup download on scratch-based image
### Fixed
- **`/backup` download returned `backup failed: unable to open database file: unable to open database: /tmp/caddyui-backup-YYYYMMDD-HHMMSS.db (14)`.** Reported from a live install. SQLite error 14 is `SQLITE_CANTOPEN` — the `VACUUM INTO` target couldn't be created because the final image (`FROM scratch`, non-root UID 10001) simply has no `/tmp` directory and the unprivileged process can't mkdir it at the filesystem root. The regression landed in v2.7.0 when the Dockerfile switched to `scratch`; it didn't surface until a user actually tried the backup button on that image, and every install built from the pre-2.7.0 images kept working by coincidence. Two fixes, deliberately stacked:
- **Handler-level fix.** `getBackup` now writes the temp file next to the live DB (`filepath.Dir(s.DBPath)`) instead of `os.TempDir()`. Same filesystem as the source DB, same UID ownership as the existing `caddyui.db` / `.db-wal` / `.db-shm`, zero chance of CANTOPEN on a directory that was already open two lines earlier. Falls back to `os.TempDir()` only if `DBPath` is empty — defence-in-depth against a future constructor regression.
- **Image-level fix.** Dockerfile pre-creates `/tmp` with `1777` (sticky world-writable) in the build stage and copies it into the scratch final image. The backup handler doesn't need this any more, but mime/multipart file uploads, any third-party Go library that calls `os.TempDir()` internally, and anything we add later that reaches for `/tmp` would otherwise silently break the same way. Catches the *next* instance of this bug before a user has to file it.
### Changed
- **`server.New` takes a new `dbPath string` argument.** Plumbed from `main.go` (which already owns the `CADDYUI_DB` env lookup) through to `Server.DBPath`. Only the backup handler reads it today; future operational handlers (restore, compact, integrity-check) can reuse the same field instead of re-threading the env variable.
### Docker
- Published as `applegater/caddyui:v2.7.5` and `:latest` (multi-arch `linux/amd64` + `linux/arm64`).
---
## [2.7.4] — 2026-04-24 · Groups: team-level resource visibility
### Added
- **Admin-managed Groups (`/groups`).** New admin-only page bundles `user`-role accounts into a team. Every member of a group sees every other member's proxy hosts, redirection hosts, advanced (raw) routes, and certificates in their own list views — **read-only** (edit / delete stays owner-scoped). Use case: a customer has two staff accounts, `alice@customer` and `bob@customer`; admin drops both into a `Customer X` group and they each see the full site roster without either being able to stomp the other's rows. Admins themselves see everything regardless and don't need to be in any group.
- **Team chip on shared rows.** Proxy-host / redirection-host / raw-route / certificate list rows that belong to a group teammate (not the viewer, not global) render a small amber `Team` chip next to the owner's email so it's clear at a glance which rows are "mine" vs. "my teammate's". Owner column stays visible on admin views; non-admin users in a group get the chip plus the teammate's email inline so they know who to ping about a row before trying to edit it.
- **Sidebar `Groups` item in the admin Access Control section.** Appears only for `admin`-role accounts, slots next to Users. Icon is a two-person cluster glyph (added inline to the shared `icon` template) to visually distinguish it from the single-person Users entry.
### Changed
- **`ListProxyHosts` / `ListRedirectionHosts` / `ListRawRoutes` / `ListCertificatesForUser` all take a new `peerIDs []int64` argument.** Non-admin viewer path now matches `owner_id IS NULL OR owner_id = viewerID OR owner_id IN (peerIDs...)` instead of the old two-term `OR`. `nil` / empty peerIDs collapses cleanly to the pre-2.7.4 two-term behaviour — users with no group membership see exactly what they saw before. Admin branch is unchanged (unfiltered).
- **`Server.groupPeerIDs(r)` is the single call site for teammate expansion.** Every list handler that previously passed `viewer.ID` to a `List*` call now also threads `s.groupPeerIDs(r)` as the new final argument. The helper short-circuits to `nil` for admin, view-role, and signed-out requests so the teammate-IDs query only runs on paths that will actually use it. Swallows DB errors to `nil` — a transient `groups`-table hiccup degrades to "no group visibility" rather than 500-ing the whole list page.
- **`advancedRouteRow` struct grew `OwnerEmail` / `CanEdit` / `IsTeamRow` fields.** The proxy-hosts advanced-routes table is the only place that uses this view struct (it's how raw routes surface on the `/proxy-hosts` page), and it wasn't plumbed through the ownership model at all before 2.7.4. Populated inline during the `advancedRows` loop using the same `isAdmin || (ownerID == viewerID)` predicate the proxy-host / cert paths use, so gating stays consistent across every resource on every page.
### Implementation notes
- **`groups` + `user_groups` schema ships via the idempotent `CREATE TABLE IF NOT EXISTS` migration block in `internal/db/db.go`.** Two tables: `groups(id, name, description, created_at, updated_at)` and a `user_groups(user_id, group_id)` many-to-many join with `ON DELETE CASCADE` on both foreign keys so deleting a user or a group cleans up membership rows automatically. An index on `user_groups(group_id)` keeps the `GroupPeerIDs` self-join fast even on servers with hundreds of memberships. No destructive migration — every pre-2.7.4 row keeps working because a user with no group memberships resolves to `peerIDs = nil` and takes the old query path.
- **`GroupPeerIDs(db, viewerID)` is a self-join on `user_groups`.** `SELECT DISTINCT ug2.user_id FROM user_groups ug1 INNER JOIN user_groups ug2 ON ug2.group_id = ug1.group_id WHERE ug1.user_id = ? AND ug2.user_id != ?` — finds every user that shares at least one group with the viewer, de-duped (two users in three shared groups still appears once), and excludes the viewer themselves (they're already covered by the `owner_id = viewerID` leg of the WHERE clause). De-dup happens at the SQL level so the returned slice can be spliced directly into an `IN (...)` without a downstream uniq pass.
- **`inClause(ids)` helper returns `("NULL", nil)` on empty input.** Keeps list-query callers branch-free — they can always splice the returned fragment into `owner_id IN (...)` without a nil-check, and `owner_id IN (NULL)` is always false in SQL three-valued logic, which is exactly the right behaviour when a viewer has no peers (no rows beyond their own + globals should appear). Non-empty input returns `("?,?,?", args)` with positional placeholders, so callers stay parameterised — no string-concat SQL.
- **Per-row `{{$canEdit := ...}}` pattern instead of a view-struct wrapper.** Rather than extend every model with a `view` wrapper type (as v2.7.2 did with `certView.CanEdit`), the group-visibility templates compute `$canEdit` and `$isTeam` inline at the top of each `{{range}}` block: `{{$canEdit := or (eq $.User.Role "admin") (and .OwnerID.Valid (eq .OwnerID.Int64 $.ViewerID))}}`. Value is reused across pencil icon, delete button, tap-to-edit wrapper, cursor-pointer classes, and Team chip visibility so the predicate can't drift between decorations. `ViewerID` is injected into the render context for the three list views that didn't already have it (`listProxyHosts`, `listRedirectionHosts`, `listRawRoutes`).
- **Group-form member picker is a checkbox array, POSTed as `member_ids[]`.** `SetGroupMembers(db, groupID, userIDs)` runs the membership rebuild as a single transaction —