Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

About caddy/ui

A modern web interface for managing your Caddy server. Built from scratch in a single conversation with Claude.

Overview

caddy/ui manages one or more Caddy servers through their admin APIs. It runs as two Docker containers alongside Caddy. Your Caddyfile remains the source of truth: you can edit it directly or through the UI.

Start with the quickstart, explore the features and screenshots, or read the architecture guide.

caddy/ui dashboard showing instance health, servers and TLS status
Dashboard
Caddyfile editor with syntax highlighting and configuration controls
Caddyfile editor
Route manager listing reverse proxy routes and upstream health
Route manager

Features

Manage Caddy configuration, monitor your servers, and inspect logs from one interface. Your Caddyfile remains the source of truth.

Configure traffic

  • Caddyfile editor Edit with syntax highlighting, validate and format configurations, and restore saved versions.
  • Route manager Find routes across HTTP servers, edit upstreams, and keep notes alongside them. Routes without an explicit title display the first hostname’s leftmost DNS label in italics (for example, jellyfin.example.de becomes jellyfin). Clearing a custom title restores this default. Uptime labels use the same fallback; status history headings keep the full domain unless an explicit title exists. The fallback is display-only and does not change notes or the Caddyfile; wildcard, IP, placeholder and hostless routes stay untitled.
  • Configuration workflow Import or export a Caddyfile and reload Caddy from the UI.

See the editor, route manager, and Caddyfile guide.

Monitor health

  • Dashboard Check server status, upstream health, TLS state, and Caddy process information.
  • Metrics and uptime Follow traffic, latency, and upstream history; enable optional route checks.
  • Logs Tail live output, filter by severity or keyword, export logs, and configure access logging.
  • Notifications Receive alerts for health changes and expiring certificates through your chosen notification service, including self-hosted services on your network.

See metrics, logs, and integrations.

Manage certificates and instances

  • TLS certificates Inspect expiry dates, remove orphaned certificates, and download Caddy’s root CA.
  • Multiple instances Discover Docker containers or add local Caddy installations, then switch between them from the sidebar.

See certificates and the instance guide.

Adjust access and appearance

  • Authentication Protect the UI and API with persistent administrator credentials, cookie sessions and CSRF checks.
  • Themes Choose built-in dark and light palettes or provide a custom theme.
  • Navigation Bookmark pages and filtered views, with a responsive layout for smaller screens.

See authentication and custom themes.

Detailed capabilities

Expand either list for the complete feature reference, including configuration options and implementation details.

Full frontend feature list
  • Dashboard — Server and TLS status, HTTP server labels/listeners/route counts, upstream health, and Caddy version, uptime, memory and reload information
  • Caddyfile Editor — Caddyfile grammar highlighting, live validation, caddy fmt, site sorting, import/export, and version history with previews and rollback
  • Route Manager — Routes across HTTP servers, health and uptime, search, sortable/configurable columns, domain/upstream links, inline editing and notes
  • TLS Certificates — Certificate status and expiry, orphan cleanup, and root CA download with OS installation instructions
  • Logs — Live SSE streaming, keyword/severity filters, export, and access-log configuration
  • Metrics — Request counts, RPS, response times, status codes and p50/p95/p99 latency from Caddy’s Prometheus endpoint
  • Uptime — Upstream state, uptime and history sampled every 30 seconds. Optional route checks request sites through Caddy every 5 minutes (caddy-ui-route-check), detecting 5xx responses, timeouts and invalid TLS certificates; Caddy’s local CA is trusted automatically. Failures show their cause, and individual routes can be skipped. Route checks default to off because they add access-log entries and request metrics.
  • Notifications — ntfy, Discord, Slack, Pushover or custom webhooks for upstream/route state changes and expiring certificates or API tokens, with trigger controls and debounce. Route alerts require route checks. LAN/Docker destinations work; loopback and link-local addresses are blocked, and redirects are not followed.
  • Themes — 13 dark and 13 light palettes, independent dark/light selections, a first-run picker, and custom themes in THEMES_PATH. Preferences persist in settings.json.
  • Navigation — Browser history, bookmarks and filtered deep links (e.g. /routes?filter=srv0)
  • Authentication — Persistent administrator credentials, cookie sessions, CSRF protection and recovery keys
  • Multi-Instance — Discover Caddy containers, add/edit/remove instances, and switch servers from the sidebar
  • Mobile Friendly — Responsive layout with collapsible sidebar
Backend capabilities
  • Caddy admin API access (/config, /id, /load) and Caddyfile validation via /adapt
  • Caddyfile reads/writes, site sorting (public → internal → http), and route block updates
  • Formatting and version detection through docker exec in Docker mode (requires the socket mount)
  • Route discovery across HTTP servers, including nested upstreams
  • Upstream pool health checks with TCP fallback, plus persisted upstream/route history (7-day default retention)
  • Notification providers and trigger controls with configurable debounce
  • Certificate expiry checks, orphan/superseded/expired certificate cleanup, and root CA download
  • Caddyfile snapshots on save, live SSE logs, and access-log configuration
  • Persistent server labels, route notes and UI settings
  • Administrator cookie sessions, CSRF protection and scoped read-only integration tokens
  • Caddy process/HTTP metrics, metrics configuration, and optional public Prometheus scraping
  • Structured JSON logs (LOG_LEVEL) and status/uptime data for Homepage widgets

Screenshots

Dashboard

Caddyfile Editor

Route Manager

TLS Certificates

Access Logs

Metrics

Notifications

Instances

Architecture

The Nginx frontend proxies API requests to the Node.js/Express backend, which talks to Caddy’s admin API.

Docker mode uses the socket for container discovery and file operations. Local mode supports Caddy outside Docker. The backend’s persistent config directory holds credentials, settings and instance data.

Request flow

flowchart LR
    accTitle: Request flow
    accDescr: The frontend proxies API requests to the backend. The backend communicates with Caddy's admin API.
    FE["caddy/ui frontend<br/>React + nginx<br/>:9877"]
    BE["caddy/ui backend<br/>Node.js + Express<br/>:9876"]
    CA["Caddy<br/>:2019 Admin API<br/>:80 / :443"]

    FE -->|"/api/*"| BE
    BE -->|"Admin API"| CA
    BE -->|"upstream TCP checks + passive health pool"| CA
    BE -->|"Prometheus metrics"| CA
    BE -->|"/adapt validation"| CA
    BE -->|"/pki/ca/local"| CA

The frontend serves the interface and proxies API requests. The backend handles Caddy operations, application settings, and instance data.

Files and persistent data

flowchart TB
    accTitle: Files and persistent data
    accDescr: In Docker mode the backend accesses Caddy container files through the Docker Engine. In local mode it accesses mounted files directly. Application data is stored separately.
    BE["caddy/ui backend"]
    DOCKER["Docker Engine"]
    LOCAL["Mounted Caddy files<br/>Local mode"]
    DATA[("caddy/ui data<br/>/etc/caddy-ui")]
    FILES["Caddy container files<br/>Caddyfile, logs, certificates"]

    BE -->|"Docker socket"| DOCKER
    DOCKER -->|"exec / stream"| FILES
    BE -->|"Direct access"| LOCAL
    BE -->|"Read / write"| DATA

Docker mode uses container discovery and commands inside the selected Caddy container. Local mode reads and writes paths mounted into the backend and uses its bundled Caddy binary. Both modes communicate with the instance’s admin API. See the instance guide for setup and file permissions.

Responsibilities

Connection or storagePurpose
Frontend → backendForward /api/* requests from the browser.
Backend → Caddy admin APIRead configuration, validate through /adapt, reload through /load, inspect the upstream health pool, and download the root CA through /pki/ca/local.
Backend → metrics and upstreamsRead Prometheus metrics; run TCP health checks and optional HTTP route checks.
Backend → Docker EngineDiscover Caddy containers and access their Caddyfiles, logs, and certificates through the Docker socket.
Backend → mounted Caddy filesAccess files directly in local mode.
Backend → /etc/caddy-uiPersist instance registrations, configuration history, route notes, settings, and application configuration.

See the API reference, configuration, and integrations for individual endpoints and settings.

Quickstart

For Caddy installations outside Docker, see local-mode instances.

Prerequisites

  • Docker and Docker Compose
  • An existing Caddy container with the admin API enabled on 0.0.0.0:2019

1. Enable Caddy’s admin API

Add the following to your Caddyfile global block:

{
    admin 0.0.0.0:2019
    email {$EMAIL}
}

Note: the CADDY_ADMIN environment variable does not configure this — it must be set in the Caddyfile.

2. Create required directories

mkdir -p /docker/caddy/logs
mkdir -p /docker/caddy-ui

3. Plan administrator setup

No authentication environment variables are needed for a new installation. Open the UI after deployment, create the administrator and save the one-time recovery key. Until setup finishes, management endpoints remain locked. The first visitor can establish credentials, so keep an uninitialized installation reachable only by its intended administrator.

Keep /etc/caddy-ui mounted persistently: it contains the credential hashes alongside the existing settings and metadata.

4. Update your compose file

services:
  caddy:
    image: caddy:latest
    container_name: caddy
    restart: unless-stopped
    ports:
      - 80:80
      - 443:443
    environment:
      - CADDY_LOG_PATH=/var/log/caddy/access.log
      - DOMAIN=example.com
      - EMAIL=you@example.com
      - TZ=America/New_York
    volumes:
      - /docker/caddy/Caddyfile:/etc/caddy/Caddyfile
      - /docker/caddy/data:/data
      - /docker/caddy/config:/config
      - /docker/caddy/logs:/var/log/caddy
    networks:
      - caddy-ui

  caddy-ui-backend:
    image: zackwag/caddy-ui-backend:latest
    container_name: caddy-ui-backend
    restart: unless-stopped
    ports:
      - 9876:3001
    environment:
      - TZ=America/New_York
      # Optional -- opt into installed preview UI languages (reviewed languages are enabled by default)
      - CADDY_UI_ADDITIONAL_LANGUAGES=es-es
      # Browser-facing URL: replace your-server with the host you open in your browser.
      # Required for browser writes over plain HTTP; behind HTTPS use the external HTTPS URL.
      - CADDY_UI_EXTERNAL_URL=http://your-server:9877
      # Authentication is required; create credentials in the UI.
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /docker/caddy-ui:/etc/caddy-ui
    networks:
      - caddy-ui
    depends_on:
      - caddy

  caddy-ui-frontend:
    image: zackwag/caddy-ui-frontend:latest
    container_name: caddy-ui-frontend
    restart: unless-stopped
    ports:
      - 9877:80
    networks:
      - caddy-ui
    depends_on:
      - caddy-ui-backend

networks:
  caddy-ui:
    driver: bridge

The backend needs only the Docker socket for discovery and file operations in Caddy containers, plus its persistent config directory for credentials, instances, history and settings. It does not need Caddy’s volumes mounted directly.

5. Deploy

docker compose up -d

Open http://your-server:9877 in your browser.

Continue with configuration or instance discovery.

Configuration

Environment Variables

Most backend variables have sensible defaults. Set CADDY_UI_EXTERNAL_URL for browser writes over plain HTTP; otherwise only set what you need to override.

VariableDefaultDescription
APP_VERSIONdevcaddy-ui’s own version, exposed at GET /api/version and shown in the sidebar. Baked in automatically by the release build (docker build --build-arg APP_VERSION=...) — no need to set by hand unless building from source and want the UI to report a specific version.
CADDY_ADMIN_URL—URL of Caddy’s admin API. If set, a default instance is auto-created on first startup. If unset, caddy/ui starts with no instances and guides you through discovery.
CADDYFILE_TITLESautoStore route titles as # comments in the Caddyfile (see Caddyfile Titles)
CADDY_CONFIG_PATH/etc/caddy/CaddyfilePath to the Caddyfile inside the Caddy container (only used with CADDY_ADMIN_URL)
CADDY_CONTAINER_NAMEcaddyName of the Caddy Docker container (only used with CADDY_ADMIN_URL)
CADDY_DATA_PATH/data/caddyPath to Caddy’s data directory inside the container (only used with CADDY_ADMIN_URL)
CADDY_LOG_PATH/var/log/caddy/access.logPath to Caddy’s access log inside the container (only used with CADDY_ADMIN_URL)
CADDY_SERVER_NAMEsrv0Server ID of the Caddy HTTP server new routes are added to, usually srv0 (only used with CADDY_ADMIN_URL). Server IDs come from Caddy; set your own with the name option of the Caddyfile’s global servers block
CADDY_UI_ADDITIONAL_LANGUAGES—Comma-separated preview UI language codes to opt into (for example es-es for the partial Spanish (Spain) catalog). Reviewed languages (currently English, Arabic and German) are enabled by default. Only installed frontend catalogs are shown; this setting controls availability, not build contents. Missing translations fall back to English. Bundled fonts (e.g. Noto Sans Arabic) load automatically when their language is selected.
CADDY_UI_EXTERNAL_URL—Comma-separated browser-facing UI URLs, including scheme and external port (e.g. https://caddy.internal,http://192.168.4.87:9877). Their origins validate browser writes when Fetch Metadata is absent, as on plain HTTP. Required for those writes; never inferred from proxy headers. Passkeys use the first HTTPS non-IP URL.
CADDY_UI_TRUSTED_PROXIESbundled frontendBackend: unset discovers only caddy-ui-frontend by DNS; blank disables trust. An explicit comma-separated IP/CIDR list overrides discovery. See client addresses and rate limits.
CADDY_UI_TRUSTED_UPSTREAMS—Frontend nginx: comma-separated upstream proxy IPs/CIDRs allowed to supply client addresses, such as the Caddy serving the UI. Unset trusts none.
CADDY_UI_PASSWORD—Legacy password, used only to authenticate credential enrollment before auth.json exists
CADDY_UI_PUBLIC_METRICSfalseExpose /api/metrics/raw without auth
CADDY_UI_UPDATE_CHECKtrueCheck hourly for newer caddy-ui images and show an “Update available” badge in the sidebar (plus a notification for new releases, if notifications are on). Reads a small versions.json from GitHub (raw.githubusercontent.com); set to false to disable
CADDY_UI_UPDATE_CHECK_URLhttps://raw.githubusercontent.com/zackwag/caddy-ui/versions/versions.jsonWhere the update check reads published image versions from
CADDY_UI_USER—Legacy username for credential enrollment; ignored after stored credentials are initialized
AUTH_PATH/etc/caddy-ui/auth.jsonPersistent administrator credential/recovery hashes and passkey records. Keep on a writable persistent volume, separate from UI settings.
DOCKER_SOCKET/var/run/docker.sockPath to the Docker socket for container discovery and docker exec operations
HISTORY_PATH/etc/caddy-ui/historyPersistent Caddyfile versions and actor metadata; see the history guide
INSTANCES_PATH/etc/caddy-ui/instances.jsonPath to the multi-instance configuration file (see Multi-Instance)
JWT_SECRET—Legacy configuration marker required alongside the legacy username/password before enrollment. No new JWTs are issued.
LOG_LEVELinfoLog verbosity (debug, info, warn, error)
MAINTENANCE_PATH/etc/caddy-ui/maintenance.jsonPath to the maintenance mode state file
NOTIFICATIONS_CONFIG_PATH/etc/caddy-ui/notifications.jsonPath to the notification settings file
PORT3001Port the backend listens on
ROUTE_NOTES_PATH/etc/caddy-ui/route-notes.jsonPath to the route notes file
SERVER_NAMES_PATH/etc/caddy-ui/server-names.jsonPath to the per-instance server labels file
SETTINGS_PATH/etc/caddy-ui/settings.jsonPath to the UI settings file (theme preferences, column visibility)
THEMES_PATH/etc/caddy-ui/themesPath to the custom themes directory (see Custom Themes)
UPTIME_HISTORY_PATH/etc/caddy-ui/uptime-history.jsonPath to the persisted upstream and route uptime history file
UPTIME_HISTORY_RETENTION_DAYS7How many days of upstream and route health checks to retain
CADDY_UI_URL—Override the backend address that Caddy uses to fetch maintenance pages (e.g. caddy-ui-backend:3001). Auto-detected from the Docker socket when available
WWW_PATH/etc/caddy-ui/wwwDirectory for HTML pages served at /public/responses/ (maintenance page created on first use, inline fallback if the directory is unwritable)
NODE_EXTRA_CA_CERTS—Path to a PEM bundle of extra CAs for route checks and notification webhooks to trust, for sites with certificates from a private CA other than Caddy’s local one (standard Node.js variable)

Authentication

Authentication is required. With no stored credentials or legacy environment credentials, the UI offers administrator setup while management endpoints remain locked. The first visitor can create the administrator; restrict access until setup is complete.

Existing installations

Settings, labels, instances, snapshots and the Caddyfile remain in their current stores. They are installation-wide and are not reassigned or rewritten when the administrator username changes during setup.

If CADDY_UI_USER, CADDY_UI_PASSWORD and JWT_SECRET are configured, sign in with the existing username/password to authorize enrollment. This produces a limited session that can complete setup but cannot manage Caddy. All three legacy variables must be non-empty before enrollment; partial or blank configuration still fails startup, reporting variable names only. Once new credentials are saved successfully, stored credentials take precedence and the old environment variables can be removed, including JWT_SECRET.

An existing installation that had authentication disabled receives the same setup form as a new installation, preserving its data. Initialization never depends on theme preferences, labels or the age of the installation. Unreadable, corrupt or unsupported credential storage fails startup rather than reopening setup. Inspect failures with docker compose logs --tail=100 caddy-ui-backend (using your service name).

Sessions and recovery

Passwords and recovery keys are stored as salted scrypt hashes in AUTH_PATH, with owner-only file permissions. Passwords require at least 12 characters and at most 1024 bytes. Credential writes are atomic, and concurrent setup requests cannot overwrite an initialized administrator.

The browser receives a host-only, HttpOnly, SameSite=Strict cookie scoped to /api. It does not store authentication tokens in localStorage or URLs. Management sessions expire after 24 hours; enrollment and pre-login contexts expire after 10 minutes. Sessions are process-local: restarting the backend signs everyone out. Logout revokes the session on the server, including its live log streams.

Open Security → User to change the administrator username or password. A username-only change requires the current password; password changes require fresh passkey verification or the password plus TOTP when enabled. Successful changes renew this browser’s session and revoke every old session and live log stream. API tokens, passkeys and the recovery key remain valid. Failed saves leave the existing credentials and sessions unchanged.

Security → Auth supports three passkeys and one TOTP authenticator. Password sign-in requires a code when TOTP is enabled; passkeys work independently. Failed TOTP checks share a persistent account cooldown across sessions. Credential changes require fresh verification. Passkeys need HTTPS and a matching CADDY_UI_EXTERNAL_URL; TOTP also works over HTTP but cannot protect that connection from interception. Records persist in AUTH_PATH when its volume is retained. Auth backups contain the recoverable TOTP seed; protect them and consider host/dataset encryption. See the passkey guide.

Save the recovery key shown after setup. The recovery form accepts that key and a new password, rotates the recovery key, disables TOTP and revokes every session, API token and passkey. Save the replacement key, then sign in normally. Recovery does not grant an unrestricted bypass session. Key responses are marked Cache-Control: no-store and keys are never deliberately logged.

If both password and recovery key are lost, stop the backend and restrict network access before removing only AUTH_PATH to repeat credential setup. Remove obsolete legacy auth variables as well, or those credentials will be required for enrollment. Do not remove the settings directory or Caddyfile. Restore normal access only after setup is complete.

Public endpoints

GET /api/auth/status, GET /api/auth/csrf, GET /api/version and GET /api/languages are public. With CADDY_UI_PUBLIC_METRICS=true, the parsed and raw metrics GET endpoints are also public. Management endpoints require a session; scoped API tokens permit selected status/metrics reads only.

API compatibility

Legacy JWTs and ?token= authentication are removed. Create scoped status/metrics tokens in Security → API tokens in the sidebar. Secrets are shown once and stored hashed in AUTH_PATH; recovery revokes all. See the token guide for expiry, revocation and response details.

Send Authorization: Bearer <token> and X-Instance-Id: <id>. Without the header, a token permitting one instance reads that instance; a token permitting several reads default, which must be allowed. Tokens permit reads only. Token management requires a session and CSRF; bearer reads need no CSRF. CORS remains disabled.

For a cookie-aware client, first fetch /api/auth/csrf, preserve its cookie and send the returned token as X-CSRF-Token on every write, including login, setup, recovery and logout. After a session change, fetch a fresh token. A rejected write is never automatically replayed by the UI.

Browser request origins

Set CADDY_UI_EXTERNAL_URL to one or more browser-facing URLs (comma-separated), including scheme and external port, such as https://caddy.internal,http://192.168.4.87:9877. Each origin validates browser writes; paths are ignored. Invalid URLs, non-HTTP(S) schemes and embedded credentials fail startup. Restart after changing it.

Ordinary HTTP browser writes require this setting because Fetch Metadata may be absent. Trustworthy localhost requests with same-origin Fetch Metadata work without it. For HTTPS, configure the external HTTPS URL so cookies are marked Secure, even when the reverse proxy reaches nginx/backend over HTTP. Origin is never inferred from Host, Forwarded or X-Forwarded-*. Alternate hostnames, schemes and ports are different origins. This setting does not enable TLS. HTTP is supported with a non-Secure HttpOnly cookie, but network interception can expose passwords and sessions.

Startup warns if Secure cookies are not enabled. This is informational; it does not infer the public scheme behind a proxy or prevent HTTP/local deployments.

Cross-site and same-site Fetch Metadata reject writes. Otherwise, a supplied Origin must match exactly; Origin: null is rejected. Without Origin, a supplied Referer must match. GET, HEAD and OPTIONS are unchanged; CORS remains disabled.

Every write also requires X-CSRF-Token bound to the current cookie context, including login, setup, recovery and logout. Headerless scripts have no exemption. Tokens rotate with sessions. These controls do not replace network access restrictions or prevent DNS rebinding.

Client addresses and rate limits

Rate limits use 15 authentication writes and 200 API requests per client per minute. Request logs use the same verified client address.

With CADDY_UI_TRUSTED_PROXIES unset, the backend resolves only the bundled caddy-ui-frontend service and trusts that address as its immediate proxy. Addresses refresh every 30 seconds; lookup failures disable trust until discovery recovers. Direct connections from other peers ignore forwarding headers. The bundled nginx replaces incoming X-Forwarded-For with its verified client address, so direct frontend clients retain separate budgets without trusting arbitrary Docker peers.

Set CADDY_UI_TRUSTED_PROXIES to an empty value to disable proxy trust, or to explicit comma-separated IPv4/IPv6 addresses or CIDRs to override discovery. Boolean values, hop counts, hostnames, zero-length prefixes and IPv4-mapped IPv6 prefixes below /96 fail startup. Custom frontend names or non-Docker deployments need explicit proxy addresses. Restart after changing the setting. Keep the backend private where possible; avoid trusting client networks or shared NAT gateways.

When Caddy or another reverse proxy serves the UI through nginx, set CADDY_UI_TRUSTED_UPSTREAMS on the frontend container to that proxy’s actual source IP or a narrow, controlled CIDR. For example:

# caddy-ui-frontend service
    environment:
      - CADDY_UI_TRUSTED_UPSTREAMS=172.30.0.2

nginx accepts X-Forwarded-For only from configured upstreams, resolves the last untrusted address in the chain, and sends the backend a single verified address. Untrusted direct clients cannot supply their own identity. Use stable upstream addresses; restart nginx after changing the setting. Custom nginx configurations must retain equivalent real-IP and header-sanitization rules.

Without upstream trust, clients behind Caddy share Caddy’s budget and its address appears in logs. If frontend discovery is unavailable, clients behind nginx share nginx’s budget. No broader trust is inferred to avoid this limitation. Every trusted upstream must sanitize forwarding headers; an overly broad trust range can reintroduce spoofing.

For deployment-specific behavior, see instances, themes, and Caddyfile behavior.

Security and passkeys

Open Security in the sidebar to manage the administrator account, passkeys, authenticator codes (TOTP) and API tokens.

Set up HTTPS first

Passkeys require a browser-supported, secure HTTPS connection to Caddy UI itself. Serving another application through HTTPS does not secure your UI connection. Put the frontend behind an HTTPS reverse proxy, trust its certificate on your devices, and reopen the UI at that HTTPS address. The TLS tab lets you inspect the managed Caddy instance’s certificates; it does not automatically place the UI behind HTTPS.

Set the backend’s CADDY_UI_EXTERNAL_URL to the same browser-facing address, including its scheme and external port, for example https://caddy-ui.example.com. Restart the backend after changing it. The configured origin and hostname are used for WebAuthn verification; forwarded headers are not used to infer them. IP addresses cannot be used as WebAuthn RP IDs; use a DNS hostname (or HTTPS localhost for development). Keep this hostname stable: moving to another hostname can require registering passkeys again.

The Auth section distinguishes an HTTP connection, an unsupported browser and missing or mismatched backend configuration. Enrollment stays disabled until those requirements are met. Password login remains available over HTTP; passkeys are intentionally HTTPS-only, including on localhost.

Add a passkey

  1. Sign in and open Security → Auth → Passkeys.
  2. Give the passkey a recognizable name, such as “Laptop” or “Phone”.
  3. Verify with an existing passkey, or your current password plus an authenticator code when TOTP is enabled. Fresh verification is required for every addition.
  4. Choose an authenticator in the browser prompt and complete its fingerprint, face or PIN verification.

Use an up-to-date browser and an authenticator supporting discoverable FIDO2 credentials and user verification. The browser and operating system control the available device, security key and password manager choices; Caddy UI does not force a particular provider.

The administrator can register three passkeys. A passkey synchronized by a provider can be available on several devices while counting as one registered credential. Removing a credential in Caddy UI revokes that credential across all its synchronized copies.

Sign in and remove passkeys

After registration, the login page offers Sign in with a passkey. No username entry is needed. On a different HTTPS origin, the button is disabled and a message identifies the configured address to open. Password sign-in remains available if the authenticator is missing, broken or unavailable.

Remove a passkey under Security → Auth → Passkeys. Deleting any passkey requires fresh verification with a passkey, or your current password plus an authenticator code when TOTP is enabled, even if other passkeys remain. A signed-in session alone is insufficient. Any removal signs out all previous sessions and live log streams, including sessions that the removed credential may have opened, and renews the browser performing the removal. API tokens and the recovery key remain valid.

Ordinary username and password changes preserve passkeys. A stable stored account identifier keeps them independent of the administrator’s username. Authenticators may continue to display the username used when the credential was created.

Persistence and recovery

Public keys, credential identifiers, the account identifier and verification metadata are saved atomically alongside credential hashes in AUTH_PATH (default /etc/caddy-ui/auth.json). Preserve the backend’s /etc/caddy-ui volume, or your custom auth file’s persistent mount, across image upgrades, rebuilds and container replacements. There is no separate database. The private key remains with the authenticator or its passkey provider.

A backend restart signs out browser sessions and discards pending challenges, but preserves registered passkeys. Failed credential writes leave the prior auth record intact; a failed registration must be started again, including fresh credential verification. If the authenticator retained a credential from that failed attempt, remove that unused entry through its provider before retrying if necessary.

Keep your password and recovery key available. Account recovery rotates the recovery key, disables TOTP and removes all passkeys, API tokens and sessions so a lost or compromised credential cannot regain access. Sign in using the replacement password and register your trusted authenticators again.

Verification and testing

Registration and sign-in use SimpleWebAuthn with required user verification, exact configured origin and RP ID checks, and session-bound challenges that expire after two minutes. Challenges are consumed once; registration rechecks the three-key limit when it finishes. Writes use the existing CSRF protection and auth rate limiter. Successful sign-in persists verification counters before issuing a session; authenticators that legitimately use zero counters are supported.

Automated tests include signed WebAuthn assertions, failure and concurrency cases, and Chromium virtual authenticators over a pinned test HTTPS certificate. Browser coverage replaces the backend process and signs in using the persisted passkey. Actual Windows Hello, phone, security key and password manager prompts should also be checked manually before release; virtual authenticator tests cannot establish device-specific compatibility.

Authenticator codes (TOTP)

Open Security → Auth → Authenticator app (TOTP) and choose Set up authenticator. Security sections start collapsed. TOTP adds a six-digit authenticator code to password sign-in. A passkey remains sufficient on its own; it does not also require TOTP.

Set up or replace an authenticator

  1. Verify with your current password, or a fresh passkey ceremony if you already have a compatible passkey. When TOTP is already enabled, the password option also requires a current authenticator code.
  2. Scan the QR code in your authenticator app, or enter the displayed setup key manually. Caddy UI generates the QR code in your browser without contacting an external QR service.
  3. Enter a code from the new authenticator and choose Confirm authenticator.

Setup expires after five minutes or five incorrect confirmation attempts. Until confirmation, the proposed seed lives only in the current backend session; closing the panel does not enable it. Cancel discards it. During replacement, the existing authenticator remains active until the new one is confirmed. Confirmation saves the new enrollment atomically, revokes previous sessions and live log streams, and renews your browser’s session.

Only one TOTP enrollment is active for the administrator. The implementation uses HMAC-SHA1, six digits and a 30-second period, compatible with common authenticator apps. Keep the backend host and authenticator device clocks synchronized. One adjacent time step in either direction is accepted for clock drift. Once accepted, a code’s time step cannot be used again, including after a backend restart. If you just signed in or changed a credential, wait for a new code before another protected action.

Sign-in and credential changes

Password sign-in first checks the username and password, then asks for the authenticator code. The intermediate session cannot manage Caddy, read protected APIs or create integration tokens. It expires after five minutes or five failed code attempts. Go back to sign-in to restart, use a passkey, or recover your account.

Changing the password, adding/removing any passkey, and enabling/replacing/disabling TOTP require fresh verification:

  • A passkey is offered first when it is usable at the current HTTPS origin. The signed challenge authorizes one named action, in one session, for two minutes. The resulting authorization can be attempted once and cannot be reused for a different change or target passkey.
  • Password plus TOTP remains available when TOTP is enabled; otherwise the current password suffices. Having a passkey does not remove this fallback.

A username-only change still requires the current password, without an authenticator code. API-token creation retains its current management-session and CSRF requirements; stronger reauthentication for that operation is a separate follow-up.

Disabling TOTP removes the stored seed and returns password sign-in to password-only. It requires the same fresh verification and revokes previous sessions, while keeping the verified caller signed in. Normal credential changes preserve API tokens and the recovery key.

Account-wide cooldown

Active TOTP verification shares one persistent failure budget across password sign-in and password-plus-TOTP credential changes. Five consecutive failed checks trigger a five-minute cooldown. Starting another password sign-in, changing the username or restarting the backend does not clear it. Requests during the cooldown return TOTP_RATE_LIMITED (HTTP 429) with a Retry-After header and do not extend the deadline. A successful TOTP check resets the consecutive-failure count, and an expired cooldown starts a fresh budget.

Only requests that have already verified the password and have the required pending or management session can consume this budget. Anonymous requests and wrong passwords cannot trigger the TOTP cooldown. The existing per-IP auth write limiter remains in place as an additional control. Confirmation of a proposed enrollment retains its separate five-attempt limit.

Existing management sessions remain usable during cooldown. Passkey sign-in, fresh passkey verification and recovery remain available; recovery removes the TOTP seed and its failure state. Someone who knows the password can trigger another temporary cooldown after the previous one expires, so passkeys and the recovery key remain useful alternatives. No permanent suspension or manual unlock is introduced.

Persistence, backups and recovery

The TOTP seed and last accepted time step are stored in AUTH_PATH (default /etc/caddy-ui/auth.json), alongside the password/recovery hashes and passkey records. The seed must be recoverable by the backend to verify codes; it is not a password hash and is not encrypted by Caddy UI. The file is owner-only (0600), and replacements use the existing atomic credential writes. Failed writes do not grant access or replace the active enrollment.

While TOTP is active, the auth record uses format version 2. Older images that only support version 1 refuse to start instead of silently skipping the second factor. Disable TOTP using a supported backend before downgrading; disabling it or completing recovery returns the record to version 1.

Keep the backend auth volume mounted persistently across container recreation, upgrades and rebuilds. Backups of that volume contain the TOTP seed, so protect them like credentials. Host or dataset encryption is recommended for offline theft and backup protection. A key stored alongside the seed would travel with the same backup and is deliberately not added here. File permissions and disk encryption do not protect credentials from an attacker controlling the running backend or an account that can read its auth volume.

Save the recovery key supplied during account setup. Recovery sets a replacement password, rotates the recovery key, disables TOTP, and revokes all passkeys, API tokens and browser sessions. Save the replacement key, sign in using the new password, and enroll trusted authenticators again. The old TOTP device cannot regain access using its codes.

HTTP and HTTPS

Authenticator codes work over HTTP and HTTPS. The HTTP panel warns that passwords, setup keys, codes and session cookies can be intercepted. TOTP does not encrypt the connection or stop a live interception attack; HTTPS is strongly recommended. Passkeys remain HTTPS-only with the configured matching origin.

API sequence

All endpoints below require the existing session-bound CSRF header and use the auth write rate limiter. Responses are Cache-Control: no-store.

  • Password login: POST /api/auth/sessions returns { ok: true, totpRequired: true } when the password is correct and TOTP is enabled. Complete it with POST /api/auth/sessions/totp, body { code }. Fetch fresh CSRF state after each session change.
  • Enrollment: POST /api/auth/totp/setup with fresh credential proof returns { secret, uri, expiresAt } once. POST /api/auth/totp/confirm, body { code }, activates the proposed seed. DELETE /api/auth/totp/setup cancels a proposal. DELETE /api/auth/totp disables the active enrollment using fresh proof.
  • Password proof: { currentPassword, totpCode } (omit totpCode when disabled). Passkey proof: { reauthToken }, obtained with a fresh assertion through POST /api/auth/passkeys/reauthentication/options and POST /api/auth/passkeys/reauthentication.
  • The reauthentication options body is { action }: password.change, totp.setup, totp.disable, passkey.add, or passkey.remove:<credential-id>. Submit the browser assertion as the reauthentication body; its response supplies the one-use reauthToken for the corresponding operation.

Invalid/replayed codes use TOTP_INVALID; the shared cooldown uses TOTP_RATE_LIMITED; expired/exhausted login and setup attempts use TOTP_CHALLENGE_EXPIRED and TOTP_SETUP_EXPIRED. Expired, mismatched or reused passkey authorizations use REAUTH_INVALID. Storage failures remain AUTH_PERSISTENCE_FAILED. Seeds and codes are never deliberately logged or included in authentication notifications; password sign-in succeeds only after both checks finish.

Multi-Instance

caddy/ui can manage multiple Caddy instances from a single interface. There are two modes for accessing Caddy’s files, and you can mix them per-instance:

Docker modeLocal mode
How it worksdocker exec via the Docker socketDirect filesystem reads/writes
Volumes neededDocker socket onlyCaddy’s Caddyfile, logs, and data dirs mounted into the backend
Auto-discoveryYesNo
Container Name fieldSet to the Caddy container nameLeave empty

Mount the Docker socket into the backend and let caddy/ui talk to Caddy containers via docker exec. No per-instance volume mounts needed.

On first launch with no CADDY_ADMIN_URL set and no instances.json, caddy/ui starts with zero instances and redirects to the Instances page. From there:

  1. Click Discover — caddy/ui scans the Docker network for running Caddy containers
  2. Click Add on a discovered container — the form is pre-filled with the container’s name, admin URL, and environment variables
  3. Review and save

That’s it. The instance appears in the sidebar and all pages load its data. Discovery also polls automatically every 30 seconds, so new containers appear on their own.

To add more instances, put them on the same Docker network and they’ll show up in discovery:

  caddy-staging:
    image: caddy:latest
    container_name: caddy-staging
    # ... ports, volumes, environment for the staging Caddy
    networks:
      - caddy-ui

Local mode (no Docker socket)

If you can’t or don’t want to mount the Docker socket, leave the Container Name field empty when adding an instance. caddy/ui will access Caddyfiles, logs, and certificates directly from the filesystem and use the bundled caddy binary for formatting, validation, and version detection. Reloads go through the instance’s admin API.

This requires mounting each Caddy instance’s files into the backend container:

  caddy-ui-backend:
    volumes:
      - /docker/caddy-ui:/etc/caddy-ui
      - /docker/caddy/Caddyfile:/etc/caddy/Caddyfile
      - /docker/caddy/logs:/var/log/caddy
      - /docker/caddy/data:/data

Mount each directory at the same path the Caddy container uses. The official Caddy image keeps its data in /data/caddy (certificates are in /data/caddy/certificates), so mounting the same host directory at /data lets the default Data Path of /data/caddy work unchanged. If you mount it elsewhere, point Data Path at the directory that contains certificates.

Then add the instance manually on the Instances page with Container Name left empty. The paths in the instance config must match where the files are mounted in the backend container.

Permissions: The backend image runs as root, so it can read Caddy’s files with no changes, including the private keys Caddy creates with owner-only permissions. You don’t need to change the user Caddy runs as. If you run the backend as a non-root user (a user: setting, rootless Docker, or Podman), that user needs read access to Caddy’s data directory and logs, and write access to the Caddyfile and the directory containing it (caddy/ui writes a temporary file there to validate edits). If the TLS page is empty, the backend log says whether the certificates path was missing or unreadable.

Note: When managing multiple instances in local mode, the mount paths can’t collide. Two Caddy instances both using /etc/caddy/Caddyfile inside their own containers would need to be mounted to different paths in the backend (e.g., /etc/caddy-prod/Caddyfile and /etc/caddy-staging/Caddyfile). The same goes for data directories: only one instance can use /data, so mount another at e.g. /data-staging and set its Data Path to /data-staging/caddy.

Instance switcher

When more than one instance is configured, an instance switcher dropdown appears in the sidebar showing each instance with its online/offline status. Switching instances navigates to the Dashboard and reloads all data from the selected instance.

If the selected instance is removed, from this browser or another, caddy/ui drops the selection and reloads the current page from the fallback instance.

Selecting an instance in API requests

Instance-scoped endpoints read the instance id from the X-Instance-Id header:

  • Header omitted, or set to default: uses the instance with id default, or the first configured instance if none has that id.
  • Any other id that doesn’t exist: the request is rejected with 404 and { "code": "INSTANCE_SELECTION_INVALID" } and never reaches another instance.
  • No instances configured: 503 with { "code": "NO_INSTANCES" }.

What’s per instance and what’s shared

Per instance. Each instance has its own copy, and it’s only shown when that instance is selected:

  • Caddyfile and its saved history.
  • Routes, TLS certificates, access logs and metrics.
  • Uptime history.
  • Server labels.
  • Maintenance mode.
  • Route notes.

Two instances can use the same hostname without sharing any of these.

Shared by every instance. These don’t need an instance and keep working when none is configured:

  • Appearance and language settings.
  • Route table columns and route check settings.
  • Custom themes.
  • Notification settings.
  • caddy/ui’s own logs.
  • Sign-in.

Route notes saved before notes were per instance are copied to every instance that serves the note’s domain, so each instance keeps showing what it showed before. After that, each copy is edited separately. Notes for a domain no instance serves are removed by the usual startup cleanup. When an instance is removed, its notes are kept aside rather than handed to a new instance that reuses its id. An instance that serves the same domains can pick them up.

Manual configuration

Instances can also be configured via the REST API (POST /api/instances) or by placing an instances.json file at /etc/caddy-ui/instances.json:

[
  {
    "id": "production",
    "name": "Production",
    "adminUrl": "http://caddy:2019",
    "configPath": "/etc/caddy/Caddyfile",
    "logPath": "/var/log/caddy/access.log",
    "dataPath": "/data/caddy",
    "containerName": "caddy",
    "serverName": "srv0"
  },
  {
    "id": "local-caddy",
    "name": "Local Caddy",
    "adminUrl": "http://localhost:2019",
    "configPath": "/etc/caddy-local/Caddyfile",
    "logPath": "/var/log/caddy-local/access.log",
    "dataPath": "/data/caddy-local/caddy",
    "containerName": "",
    "serverName": "srv0"
  }
]

Set containerName to the Docker container name for Docker mode, or "" for local mode.

Backward compatibility

Setting CADDY_ADMIN_URL still creates a default instance on startup for existing deployments. Remove it to use discovery-based setup instead.

Custom Themes

Drop your own palette in THEMES_PATH (/etc/caddy-ui/themes by default) — no rebuild or in-app editor needed. On startup, caddy/ui creates themes/dark/ and themes/light/ (with a template README.md in each) and scans both for .json files.

The folder a file lives in determines whether it shows up under “Dark theme” or “Light theme” in the picker; the filename becomes its id. Each file needs a label and a vars object defining all 18 required CSS variables (colors for background, surface, borders, text, accents, and the Caddyfile editor) — see the generated README.md for the full schema and an example. Invalid or incomplete files are skipped and logged, not fatal to startup.

Caddyfile behavior

Saved versions and attribution

Saving commits the resulting Caddyfile as a new history version. Editing text in the editor alone does not create a version; a confirmed import does. Restoring a version saves its exact content without formatting or sorting as a new version attributed to the current account; it does not change the original author. Save, restore and import have distinct action labels.

History shows the authenticated account or API-token identity, action and apply outcome for each version. A selected version initially compares with its recorded predecessor, when retained. Comparisons across several saves show metadata for both selected versions; they do not assign every changed line to one actor.

The administrator has a persistent account ID, independent of its username. Snapshots reference that ID, or a token’s existing ID. Active names are resolved when listing history, so renaming an account does not rewrite its snapshots. A small actors.json registry retains the last recorded name for identities that are no longer present. API tokens remain read-only; token attribution prepares history for future write scopes without granting them any additional access.

The previous file is preserved before writing. If it differs from the latest version, it becomes an Unknown baseline: a file edited outside the UI is not attributed to the administrator who next saves it. Old snapshots also remain Unknown. This does not add background file monitoring or change notifications.

Rejected changes that roll back do not create an attributed saved version. Force saves kept on disk despite a failed reload are marked Saved; Caddy rejected the reload. If preserving the previous file fails, the save stops before changing the Caddyfile. If recording history fails after a save has already succeeded, the API completes successfully with a warning that history could not be recorded. The UI shows a warning and operation-specific bookkeeping, including maintenance recovery state, still completes. The applied change may therefore lack a history version; it is not reported as rejected or rolled back.

Malformed or unreadable versions are skipped with a log warning rather than blocking other history or saves. If the identity registry cannot be read, current identities can still resolve; unavailable historical names appear as Unknown. Damaged files are not automatically deleted. History caches parsed metadata for unchanged versions and keeps an in-memory identity registry with serialized writes.

Route, maintenance, logging and metrics edits transform the latest file inside a per-instance queue, preventing concurrent local commands from dropping each other’s file changes. Editor saves and imports intentionally replace the full file. External file/API writers do not participate in this queue; continuous drift detection and stronger conflict checks remain separate follow-ups.

Each instance retains up to 20 versions. New versions store content and metadata together in atomic JSON files under HISTORY_PATH; older plain Caddyfile snapshots remain readable. Downloading a version through the history API still returns plain Caddyfile content. Include both HISTORY_PATH and AUTH_PATH in persistent storage and backups. Losing or resetting authentication storage creates a new account identity rather than assigning its history to a new account with the same name.

Attribution identifies the credential used to submit a change. It is not proof of which person used that credential, and local history is not tamper-proof.

Caddyfile Titles

When enabled, route titles are stored as # comments inside each site block in your Caddyfile instead of in route-notes.json. This keeps titles co-located with the routes they describe and follows caddy/ui’s principle of using the Caddyfile as the source of truth.

blog.example.com {
    # My Blog
    reverse_proxy 192.168.4.88:8250
}

The first # comment inside a site block is treated as the title. It appears in the Route Manager search results and in the edit modal’s Title field.

Auto-detection (default): On startup, if route-notes.json has existing entries, Caddyfile titles are disabled so nothing changes. On a fresh install with no notes, it is enabled automatically.

Manual override: Set the CADDYFILE_TITLES environment variable to true or false to force the behavior. When switching from route-notes.json to Caddyfile titles, existing notes will appear as the default title in the edit modal — save the route to write the title into the Caddyfile.

Environment Variables in Caddyfile

Caddy supports {$VAR_NAME} syntax in the Caddyfile. Set the vars in your Caddy container’s environment and they will be substituted at reload time:

{
    admin 0.0.0.0:2019
    email {$EMAIL}
}

blog.{$DOMAIN} {
    reverse_proxy 192.168.4.88:8250
}

Caddyfile Syntax Highlighting

The editor’s Caddyfile highlighting is a CodeMirror 6 StreamLanguage mode generated from zackwag/caddyfile-codemirror, a small companion repo that stays in sync — automatically, every day — with the Caddyfile keyword vocabulary (directives, global options, subdirectives, matcher names, and plugin vocabulary) from Sean Whalen’s rouge-lexer-caddyfile, the Rouge lexer Jekyll/GitHub Pages uses to highlight ```caddyfile code blocks. That gem is the actual research into Caddy’s (and its most-downloaded plugins’) documented syntax; the CodeMirror mode is a hand-written translation of its grammar into CodeMirror’s token-stream API.

src/frontend/scripts/fetch-caddyfile-mode.mjs fetches the latest generated file at dev/build time (wired into npm run dev and npm run build via predev/prebuild), so src/frontend/src/lib/caddyfileMode.js is gitignored here rather than vendored by hand — it always reflects whatever caddyfile-codemirror last synced from the gem. If you’re building offline, run npm run dev/npm run build once with connectivity first so the fetch has a local copy to fall back on.

Integrations

Use read-only API tokens for authenticated integrations.

Authentication notifications

Enable Authentication notifications on the Notifications page to send account access events through the existing configured provider. Both this trigger and the main notification switch must be enabled. The trigger defaults to off, including for existing installations. There are no separate destinations or event switches.

EventApplication log levelNotification priority
Successful password sign-ininfoNormal
Successful password sign-in while passkeys are registeredwarnHigh
Successful passkey sign-ininfoNormal
Rejected password or passkey sign-inwarnNormal
Successful account recoverywarnHigh
Rejected recovery keywarnHigh

The first failed credential check is notified immediately. Additional failures are counted separately for password, passkey and recovery attempts, across all source IPs. At the end of the configured cooldown, one summary per method reports the additional count and the latest attempt’s timestamp and source IP. Successful events are sent individually. Changing notification settings discards pending summaries. Failure windows are held in memory and restart with the backend.

Events contain the timestamp, method, outcome and source IP resolved by Express’s configured proxy trust. They omit submitted usernames, passwords, recovery keys, passkey assertions, tokens and session identifiers. Browser cancellation before submitting a passkey assertion is not a backend credential check. Setup, legacy enrollment, API-token requests and password confirmation inside an existing session are not recorded as successful sign-ins. Requests rejected before credential verification, such as CSRF or rate-limit failures, keep their existing request diagnostics rather than generating authentication notifications.

Authentication event logging does not depend on notification switches, but still respects LOG_LEVEL. For example, LOG_LEVEL=error filters out these events. The UI’s recent application-log buffer is bounded and is not a persistent audit archive; configure Docker or host log retention if needed.

Notification delivery is best effort and never waits inside authentication or recovery. At most eight authentication sends run concurrently; excess sends are skipped with an application warning. Failed delivery is logged without including provider response contents and is not automatically retried. Changing settings does not recall a send already in progress.

External Caddyfile change detection is a separate follow-up.

Prometheus Metrics

Enable Caddy metrics in the Metrics tab or Caddyfile. Use a metrics:read token for selected instances. CADDY_UI_PUBLIC_METRICS=true instead exposes metrics without authentication.

scrape_configs:
  - job_name: caddy
    static_configs:
      - targets: ['caddy-ui-backend:3001']
    metrics_path: /api/metrics/raw
    authorization:
      type: Bearer
      credentials_file: /run/secrets/caddy_ui_metrics_token
    http_headers:
      X-Instance-Id:
        values: ['default']

The endpoint includes upstream uptime history (instance_id, instance_name, upstream labels) and enabled route history (instance_id, instance_name, route), even with Caddy metrics disabled. Tokens include only permitted instances; administrator sessions and public scrapes include all:

MetricDescription
caddy_ui_upstream_upResult of the most recent check (1 = online, 0 = offline)
caddy_ui_upstream_uptime_ratioFraction of retained checks that were online (see UPTIME_HISTORY_RETENTION_DAYS)
caddy_ui_upstream_state_duration_secondsTime the upstream has been in its current state
caddy_ui_route_upResult of the most recent route check (1 = online, 0 = offline)
caddy_ui_route_uptime_ratioFraction of retained route checks that were online
caddy_ui_route_state_duration_secondsTime the route has been in its current state
caddy_ui_caddy_metrics_upWhether Caddy’s /metrics could be scraped for the requested instance

Upstreams that haven’t been checked in 5 minutes, and routes that haven’t been checked in 12.5 minutes (2.5 route check intervals), are omitted: they’ve been removed from the config, skipped, or their instance is unreachable.

Homepage Widget

The status endpoint returns enriched data for Homepage. Create a status:read token and supply it through Homepage’s environment substitution:

- Caddy:
    href: https://caddy.home
    icon: caddy.png
    widget:
        type: customapi
        url: http://caddy-ui-backend:3001/api/status
        headers:
            Authorization: Bearer {{HOMEPAGE_VAR_CADDY_UI_TOKEN}}
            X-Instance-Id: default
        mappings:
            - field: online
              label: Status
              format: text
              remap:
                  - value: true
                    to: Online
                  - value: false
                    to: Offline
            - field: routeCount
              label: Routes
              format: number
            - field: upstreamsOnline
              label: Upstreams
              format: number
            - field: uptime
              label: Uptime
              format: text

Read-only API tokens

Create tokens under Security → API tokens in the sidebar after signing in. Give each integration its own name, select permissions and specific Caddy instances, and choose an expiry. Copy the token when it is displayed: it cannot be retrieved later. Tokens are not saved in browser storage. If a token is lost, revoke it and create another.

While the one-time token is visible, Close and Escape are disabled. Choose I have saved the token to continue, or Discard and revoke token to invalidate it before hiding it. Failed revocation keeps the token visible so you can retry. Closing the browser or reloading can still lose an unsaved token; revoke that entry after signing in again.

The UI offers 30, 90 or 365 days; the API accepts 1–365 days. Up to 64 token records, including expired ones, can be stored. Revoke unused or expired tokens before reaching that limit. There is no write scope, wildcard instance scope or automatic access to instances added later.

Permissions

ScopeAllowed requests
status:readGET / HEAD /api/status
metrics:readGET / HEAD /api/metrics, /api/metrics/raw

Other routes are denied, including status process details, metrics configuration, Caddyfile, logs, settings, discovery and authentication endpoints. Supplying a valid administrator cookie alongside a bearer token does not broaden that token’s access. Unknown, expired or revoked tokens are rejected without falling back to cookie authentication or public metrics.

Status tokens receive availability, instance ID/name, aggregate server/route/upstream counts, uptime and the existing TLS flag. They do not receive the Caddy admin URL or server names/listener addresses (adminUrl and servers). Administrator sessions retain the full status response. Metrics remain more detailed: Caddy metric labels and permitted instances’ historical series can contain hostnames and upstream addresses; grant metrics:read only to integrations that need them.

Send the token as an explicit header, never a URL parameter:

# CADDY_UI_API_TOKEN comes from your integration's secret storage.
curl --fail-with-body \
  --header "Authorization: Bearer $CADDY_UI_API_TOKEN" \
  --header 'X-Instance-Id: default' \
  https://caddy-ui.example.com/api/status

Use the instance’s stable id, shown in the token dialog and available from GET /api/instances with an administrator session. Without X-Instance-Id, a token permitting one instance reads that instance, and a token permitting several reads default, which must be explicitly permitted by the token. An unknown or removed instance never falls back to another instance. Renaming an instance does not change its ID. Raw Prometheus responses filter caddy/ui’s historical series to the token’s permitted instance IDs, in addition to reading Caddy metrics for the requested instance.

The integration examples show Homepage and Prometheus configuration. Keep CADDY_UI_PUBLIC_METRICS=false when metrics must be protected: the public option deliberately exposes parsed/raw metrics without a token.

CSRF and session boundaries

Bearer authentication is read-only. No POST, PUT, PATCH or DELETE request can be authorized with these tokens, even with a valid CSRF token or administrator cookie. There is no CSRF exemption for token-backed writes.

Creation and revocation use the existing browser session and require its X-CSRF-Token. Listing token metadata also requires the administrator session. The existing Origin/Referer checks remain active; CORS is not enabled. Safe bearer reads do not need a CSRF token because the secret is sent explicitly in the Authorization header rather than automatically attached by the browser.

Other authentication schemes forwarded by a proxy, such as HTTP Basic, do not grant API access. They leave the existing cookie/session checks in place so the UI can work behind a separately authenticated proxy.

Management requestBody
GET /api/auth/tokensReturns metadata only; never token secrets or hashes
POST /api/auth/tokens{ "name": "Homepage", "scopes": ["status:read"], "instanceIds": ["default"], "expiresInDays": 90 }
DELETE /api/auth/tokens/:idRevokes immediately for subsequent requests

Creation returns HTTP 201 and the token once, with Cache-Control: no-store. Metadata includes the token ID, name, scopes, instance IDs, creation time and expiry. Password recovery invalidates every integration token, as well as browser sessions, after credentials are saved successfully. Browser logout does not revoke integration tokens.

Deployment and storage

Tokens use 256 bits of cryptographic randomness. Only their SHA-256 hashes and metadata are persisted alongside password/recovery hashes in AUTH_PATH. Random tokens do not require a password KDF. Persistence retains the existing owner-only permissions, serialized writes and atomic replacement; pre-token credential files remain valid. Invalid token records fail startup rather than silently broadening access. The store assumes one backend process, as do browser sessions.

HTTP/local deployments remain supported, but HTTP cannot protect bearer tokens from network interception. Use HTTPS across untrusted networks and your integration’s secret storage. Do not place tokens in URLs, source control or screenshots. Creation/revocation logs contain IDs and scope/instance metadata, never the bearer secret. Stopping the backend and removing authentication storage also removes integration tokens; restrict access before any reset.

API reference

Management endpoints use cookie-session authentication and CSRF protection. For multi-instance behavior, see instances.

MethodEndpointDescription
POST/api/auth/sessionsCheck password; return totpRequired when a code is needed (CSRF required)
POST/api/auth/sessions/totpComplete password sign-in with { code } (pending session + CSRF)
POST/api/auth/totp/setupPropose a seed using fresh credential proof (management + CSRF)
POST/api/auth/totp/confirmActivate the proposed seed using { code } (management + CSRF)
DELETE/api/auth/totp/setupCancel proposed enrollment (management + CSRF)
DELETE/api/auth/totpDisable TOTP using fresh credential proof (management + CSRF)
POST/api/auth/passkeys/reauthentication/optionsBegin action-bound passkey verification (management + CSRF)
POST/api/auth/passkeys/reauthenticationVerify assertion; return one-use credential proof (management + CSRF)
DELETE/api/auth/sessions/currentRevoke the current session (CSRF required)
GET/api/auth/csrfObtain the CSRF token for the current or a new pre-login context
POST/api/auth/setupInitialize stored credentials once and return a recovery key (CSRF required)
GET/api/auth/credentialsRead the administrator username (management session required)
PATCH/api/auth/credentialsUpdate username (current password) or password (fresh credential proof); renew session and revoke old sessions (management + CSRF)
GET/api/auth/passkeysList passkey metadata and HTTPS configuration (management session required)
POST/api/auth/passkeys/registration/optionsBegin registration with name and fresh credential proof (management + CSRF)
POST/api/auth/passkeys/registrationVerify and persist a registration response (management session + CSRF)
POST/api/auth/passkeys/authentication/optionsBegin discoverable passkey sign-in (anonymous context + CSRF)
POST/api/auth/passkeys/authenticationVerify a signed assertion and issue a management session (anonymous context + CSRF)
DELETE/api/auth/passkeys/:idRemove with fresh credential proof; revoke old sessions and renew this one (management + CSRF)
POST/api/auth/recoveryReset password using a recovery key and rotate that key (CSRF required)
GET/api/auth/tokensList token metadata (management session only)
POST/api/auth/tokensCreate a read-only integration token (session + CSRF)
DELETE/api/auth/tokens/:idRevoke an integration token (session + CSRF)
GET/api/auth/statusRead authentication, enrollment and session state without credentials
GET/api/metricsParsed HTTP metrics for the Metrics tab
GET/api/metrics/configCheck if Caddy metrics directive is enabled
PUT/api/metrics/configEnable or disable Caddy metrics directive
GET/api/metrics/rawRaw Prometheus metrics (public if CADDY_UI_PUBLIC_METRICS=true)
GET/api/statusServer status, route count, upstream health, and uptime
GET/api/status/caddy-configFull raw Caddy JSON config
GET/api/status/processCaddy process info including version
GET/api/caddyfileRead Caddyfile from disk
GET/api/caddyfile?download=trueDownload Caddyfile as a timestamped attachment
PUT/api/caddyfileSave, validate, format, and reload
POST/api/caddyfile/validationsValidate (and optionally format) without saving
POST/api/caddyfile/reloadsReload Caddy from disk
GET/api/caddyfile/historyList all Caddyfile snapshots
GET/api/caddyfile/history/:filenameRead a specific snapshot
DELETE/api/caddyfile/history/:filenameDelete a snapshot
GET/api/routesList all routes across all HTTP servers
POST/api/routesAdd a reverse proxy route
PATCH/api/routes/:idUpdate a UI-managed route by ID
DELETE/api/routes/:idRemove a route and its Caddyfile block
GET/api/route-notesRead all route notes
PUT/api/route-notes/:domainSet or clear a route note
GET/api/healthLive upstream check via Caddy pool API with TCP fallback (not recorded in uptime history)
GET/api/health/uptimeUptime stats per upstream from persisted history, sampled every 30 seconds by the backend
GET/api/health/historyRaw timestamped checks for one upstream, for the route status history view
GET/api/tlsList all certificates with expiry and status
DELETE/api/tls/:domainDelete an orphaned, superseded, or expired internal cert
GET/api/tls/caDownload Caddy’s root CA certificate
GET/api/logsLast 200 lines of the configured Caddy log; 404 if missing, 503 if the container is unreachable, 500 if reading fails
GET/api/logs/streamSSE stream of live log output; failures include an error code and request ID
GET/api/logs/configRead log configuration; coded read/parse failures
PUT/api/logs/configUpdate validated log configuration
GET/api/server-namesRead server labels for the selected instance
GET/api/server-names/allRead server labels for every instance
PUT/api/server-names/:idSet a server label
DELETE/api/server-names/:idRemove a server label
GET/api/notifications/configRead notification settings
PUT/api/notifications/configUpdate notification settings
POST/api/notifications/testSend a test notification

Empty and unavailable results

An empty result means the resource is empty, not that it couldn’t be read:

  • GET /api/routes returns [] only when Caddy has no HTTP routes. If Caddy’s config can’t be read, it returns 503 with { "code": "CADDY_CONFIG_UNAVAILABLE" }.
  • GET /api/tls returns [] when Caddy hasn’t stored any certificates yet. Caddy only creates its data directory when it first stores something, so a missing data path also returns []; in local mode the backend log notes when that path may be a missing volume mount. If certificate storage exists but can’t be read (permissions, or the container can’t be reached), it returns 500 with { "code": "CERTIFICATE_READ_FAILED" }. Check the backend log for the reason and path.
  • When certificate storage is readable but Caddy’s config isn’t, GET /api/tls still lists the certificates with isManaged: null. Without Caddy’s config, caddy/ui can’t tell whether a certificate is still in use, so none are marked orphaned.
  • GET /api/route-notes returns {} when no notes file exists yet. If the notes file can’t be read or isn’t a valid JSON object, it returns 500 with { "code": "ROUTE_NOTES_READ_FAILED" }. PUT /api/route-notes/:domain fails the same way and leaves the file unchanged.
  • GET /api/caddyfile/history returns [] when there are no snapshots. If the history directory can’t be read, it returns 500 with { "code": "CADDYFILE_HISTORY_READ_FAILED" }.
  • GET /api/logs returns 200 with { "lines": [], "path": "..." } for an empty readable file. A missing file returns 404 with LOG_FILE_MISSING, and a Docker-mode container that can’t be reached returns 503 with LOG_SOURCE_UNAVAILABLE. Permissions, command or other read failures return 500 with LOG_READ_FAILED. Failure responses include a request ID; the application logs retain the underlying reason and instance path.
  • GET /api/logs/stream reports failures as SSE events on the established HTTP stream. Events include code, error and requestId, using LOG_FILE_MISSING for a missing file, LOG_SOURCE_UNAVAILABLE for an unreachable container and LOG_STREAM_ERROR for other failures. The UI stops failed live mode; start it again after correcting the problem.

The Caddy log source reads the selected instance’s configured log file, rather than Docker’s captured stdout/stderr. A writable directory mount alone does not enable file logging: Caddy must be configured to write to the selected path. The UI displays unavailable reads separately from an empty file. Refresh retries the read and clears the error after success.

Logging configuration

GET /api/logs/config returns defaults when the Caddyfile has no default logger. A Caddyfile read failure returns 500 with CADDYFILE_READ_FAILED; a configuration the structural scanner cannot interpret returns 500 with LOG_CONFIG_PARSE_FAILED. Both include a request ID and retain the cause in the application logs. Parsing failures never masquerade as disabled logging. The configuration panel shows a localized error instead of loading indefinitely.

PUT /api/logs/config requires a boolean enabled. When enabling logging, provide a nonempty string path without control characters, a single size token such as 50mb or 1.5GiB for rollSize, a non-negative safe integer for rollKeep, json or console for format, and DEBUG, INFO, WARN or ERROR for level. Paths containing spaces are supported. Invalid request values return 400 with VALIDATION_FAILED before a write is prepared. Disabling requires only { "enabled": false }; unused option values are not serialized.

Caddy still validates the edited configuration before it is written and applied. A semantic rejection returns 422 with CADDYFILE_VALIDATION_FAILED, leaving the file and live configuration unchanged. Logging edits replace the existing default logger, including a bare log or log default, and preserve named loggers and unrelated source bytes. New logger text follows the local indentation and line endings; the whole file is not formatted.

Building and custom Nginx

Building from Source

docker build -t caddy-ui-backend ./src/backend
docker build -t caddy-ui-frontend ./src/frontend

The backend image copies the Caddy binary from caddy:latest at build time for caddy fmt formatting and version detection. To pin to a specific Caddy version, change the COPY --from=caddy:latest line in src/backend/Dockerfile before building:

COPY --from=caddy:v2.11.2 /usr/bin/caddy /usr/bin/caddy

If you run Caddy outside of Docker (e.g. systemd), the backend will still work correctly — caddy fmt and version detection will degrade gracefully if the binary version doesn’t match or is unavailable.

Custom Nginx Config

The frontend proxies API requests to caddy-ui-backend:3001 by default. To preserve client addresses behind Caddy or another reverse proxy, set CADDY_UI_TRUSTED_UPSTREAMS on the frontend container to that proxy’s specific IP/CIDR (comma-separated for multiple proxies). Unset trusts no upstream forwarding headers. See client addresses and rate limits.

These settings also apply to prebuilt images. If your backend has a different hostname or port, mount a custom Nginx config:

volumes:
  - ./nginx.conf:/etc/nginx/conf.d/default.conf

Project Structure

caddy-ui/
├── .devcontainer/
│   └── devcontainer.example.json
├── .github/
│   ├── scripts/
│   │   ├── dockerhub-docs.mjs
│   │   ├── dockerhub-docs.test.mjs
│   │   ├── publish-versions.sh
│   │   └── record-versions.mjs
│   ├── workflows/
│   │   ├── beta.yml
│   │   ├── conventional-commits.yml
│   │   ├── docs.yml
│   │   ├── edge.yml
│   │   ├── localization-preview-data.yml
│   │   ├── release-please.yml
│   │   ├── release.yml
│   │   ├── stale.yml
│   │   ├── sync-caddyfile-vocab.yml
│   │   └── test.yml
│   ├── FUNDING.yml
│   └── dependabot.yml
├── dev/  ← development tooling, integration fixtures and browser scenarios
│   ├── cloud-metadata/
│   │   ├── results/
│   │   │   ├── README.md
│   │   │   └── snapshot.json
│   │   ├── .gitignore
│   │   ├── README.md
│   │   ├── collect.py
│   │   ├── reference.json
│   │   └── test_collect.py
│   ├── dev-caddy/  ← disposable Caddy lifecycle commands and ownership checks
│   │   ├── cli.mjs
│   │   ├── dev.mjs
│   │   ├── examples.mjs
│   │   ├── harness.mjs
│   │   └── harness.test.mjs
│   ├── e2e/  ← browser fixtures and scenarios
│   │   ├── api-tokens.mjs
│   │   ├── auth-notifications.mjs
│   │   ├── auth.mjs
│   │   ├── cross-site.mjs
│   │   ├── error-logging.mjs
│   │   ├── fixture.mjs
│   │   ├── history.mjs
│   │   ├── languages.mjs
│   │   └── totp.mjs
│   ├── integration/  ← real-service fixtures and lifecycle/readiness tests
│   │   ├── api.mjs
│   │   ├── caddy.mjs
│   │   ├── diagnostics.mjs
│   │   ├── examples.mjs
│   │   ├── lifecycle.mjs
│   │   ├── lifecycle.test.mjs
│   │   ├── network-readiness.test.mjs
│   │   ├── nginx-config.test.mjs
│   │   ├── rate-limits.mjs
│   │   ├── readiness.test.mjs
│   │   ├── stack.mjs
│   │   └── startup.mjs
│   ├── playground/  ← localization preview and captured-response tooling
│   │   ├── captures/
│   │   │   └── dashboard.json
│   │   ├── test/
│   │   │   ├── playground-capture.test.js
│   │   │   └── playground.test.jsx
│   │   ├── PreviewFrame.jsx
│   │   ├── README.md
│   │   ├── Workbench.jsx
│   │   ├── capture-format.js
│   │   ├── capture-source.mjs
│   │   ├── capture.integration.mjs
│   │   ├── capture.mjs
│   │   ├── check-browser.mjs
│   │   ├── dom-context.js
│   │   ├── fixtures.jsx
│   │   ├── frame.html
│   │   ├── frame.jsx
│   │   ├── index.html
│   │   ├── main.jsx
│   │   ├── model.js
│   │   ├── registry.jsx
│   │   ├── vite.config.js
│   │   └── workbench.css
│   ├── eslint.config.js
│   ├── package-lock.json
│   └── package.json  ← shared development dependencies and commands
├── docs/  ← mdBook source, theme, and build tooling
│   ├── screenshots/
│   │   ├── captures/
│   │   │   └── screenshots.json
│   │   ├── capture.integration.mjs
│   │   ├── capture.mjs
│   │   ├── replay.mjs
│   │   └── screenshot.mjs
│   ├── scripts/
│   │   ├── html-region.mjs
│   │   ├── project-structure.mjs
│   │   ├── project-structure.test.mjs
│   │   └── screenshots.test.mjs
│   ├── src/
│   │   ├── SUMMARY.md
│   │   ├── about.md
│   │   ├── api-tokens.md
│   │   ├── api.md
│   │   ├── architecture.md
│   │   ├── building.md
│   │   ├── caddyfile.md
│   │   ├── configuration.md
│   │   ├── contributing-codebase.md
│   │   ├── contributing-documentation.md
│   │   ├── contributing-feedback.md
│   │   ├── contributing-localization.md
│   │   ├── contributing.md
│   │   ├── development-caddy.md
│   │   ├── features.md
│   │   ├── instances.md
│   │   ├── integrations.md
│   │   ├── passkeys.md
│   │   ├── project-structure.md
│   │   ├── releases.md
│   │   ├── screenshots.md
│   │   ├── setup.md
│   │   ├── themes.md
│   │   └── totp.md
│   ├── theme/
│   │   ├── caddy-ui.css
│   │   ├── favicon.ico
│   │   ├── favicon.svg
│   │   ├── mermaid-init.js
│   │   ├── palettes.css
│   │   └── sidebar.js
│   ├── README.md
│   ├── book.toml
│   ├── package-lock.json
│   ├── package.json
│   └── project-structure-notes.json  ← persistent annotations keyed by repository path
├── src/  ← independently buildable application packages
│   ├── backend/  ← Express API and Caddy integration
│   │   ├── src/
│   │   │   ├── middleware/
│   │   │   │   ├── apiToken.js
│   │   │   │   ├── auth.js
│   │   │   │   ├── crossSite.js
│   │   │   │   ├── csrf.js
│   │   │   │   └── instance.js
│   │   │   ├── routes/
│   │   │   │   ├── auth.js
│   │   │   │   ├── caddyfile.js
│   │   │   │   ├── customThemes.js
│   │   │   │   ├── health.js
│   │   │   │   ├── instances.js
│   │   │   │   ├── logs.js
│   │   │   │   ├── metrics.js
│   │   │   │   ├── notifications.js
│   │   │   │   ├── pages.js
│   │   │   │   ├── routenotes.js
│   │   │   │   ├── routes.js
│   │   │   │   ├── servernames.js
│   │   │   │   ├── settings.js
│   │   │   │   ├── status.js
│   │   │   │   └── tls.js
│   │   │   ├── AppError.js
│   │   │   ├── apiTokenPolicy.js
│   │   │   ├── authEvents.js
│   │   │   ├── authService.js
│   │   │   ├── authSessions.js
│   │   │   ├── authStore.js
│   │   │   ├── caddy.js
│   │   │   ├── caddyfileBlocks.js
│   │   │   ├── caddyfileHistory.js
│   │   │   ├── caddyfileTitles.js
│   │   │   ├── containerFs.js  ← docker exec wrapper for file operations inside Caddy containers
│   │   │   ├── customThemes.js
│   │   │   ├── docker.js
│   │   │   ├── dockerDiscovery.js  ← auto-discover Caddy containers via Docker socket
│   │   │   ├── errorRegistry.js
│   │   │   ├── errorReporting.js
│   │   │   ├── index.js
│   │   │   ├── instances.js
│   │   │   ├── languages.js
│   │   │   ├── logReadError.js
│   │   │   ├── logger.js
│   │   │   ├── maintenance.js
│   │   │   ├── notifications.js
│   │   │   ├── observations.js
│   │   │   ├── passkeyPolicy.js
│   │   │   ├── passkeys.js
│   │   │   ├── routeChecks.js
│   │   │   ├── routeMonitor.js
│   │   │   ├── routeNotes.js
│   │   │   ├── serverNames.js
│   │   │   ├── settings.js
│   │   │   ├── totp.js
│   │   │   ├── totpPolicy.js
│   │   │   ├── trustedProxies.js
│   │   │   ├── updateCheck.js
│   │   │   ├── upstreamChecks.js
│   │   │   ├── upstreamMonitor.js
│   │   │   ├── uptimeHistory.js
│   │   │   ├── validation.js
│   │   │   └── webhookTransport.js
│   │   ├── test/
│   │   │   ├── fixtures/
│   │   │   │   ├── app.example.com.crt
│   │   │   │   ├── auth.mjs
│   │   │   │   ├── crossSite.mjs
│   │   │   │   └── webauthn.mjs
│   │   │   ├── actorIdentity.test.js
│   │   │   ├── apiTokens.test.js
│   │   │   ├── appError.test.js
│   │   │   ├── auth.test.js
│   │   │   ├── authAudit.test.js
│   │   │   ├── authConfiguration.test.js
│   │   │   ├── authCredentials.test.js
│   │   │   ├── authEvents.test.js
│   │   │   ├── authSessions.test.js
│   │   │   ├── authStore.test.js
│   │   │   ├── authStoreMigration.test.js
│   │   │   ├── authenticationNotifications.test.js
│   │   │   ├── caddyfile.test.js
│   │   │   ├── caddyfileBlocks.test.js
│   │   │   ├── caddyfileHistory.test.js
│   │   │   ├── caddyfileTitles.test.js
│   │   │   ├── containerFs.test.js
│   │   │   ├── credentialReauthentication.test.js
│   │   │   ├── crossSite.test.js
│   │   │   ├── customThemes.test.js
│   │   │   ├── errorReporting.test.js
│   │   │   ├── healthRoute.test.js
│   │   │   ├── instanceMiddleware.test.js
│   │   │   ├── instances.test.js
│   │   │   ├── languages.test.js
│   │   │   ├── listAvailability.test.js
│   │   │   ├── logReadErrors.test.js
│   │   │   ├── logger.test.js
│   │   │   ├── logs.test.js
│   │   │   ├── logsConfigRoute.test.js
│   │   │   ├── maintenance.test.js
│   │   │   ├── maintenanceInstances.test.js
│   │   │   ├── metrics.test.js
│   │   │   ├── notificationCategoryIsolation.test.js
│   │   │   ├── notifications.test.js
│   │   │   ├── notificationsRoute.test.js
│   │   │   ├── observations.test.js
│   │   │   ├── passkeyLogin.test.js
│   │   │   ├── passkeyRegistration.test.js
│   │   │   ├── passkeyStore.test.js
│   │   │   ├── readFailures.test.js
│   │   │   ├── routeChecks.test.js
│   │   │   ├── routeNotesInstances.test.js
│   │   │   ├── routes.test.js
│   │   │   ├── saveCaddyfileChange.test.js
│   │   │   ├── serverNames.test.js
│   │   │   ├── status.test.js
│   │   │   ├── statusRoute.test.js
│   │   │   ├── tls.test.js
│   │   │   ├── totpAuthentication.test.js
│   │   │   ├── totpStore.test.js
│   │   │   ├── trustedProxies.test.js
│   │   │   ├── updateCheck.test.js
│   │   │   ├── upstreamChecks.test.js
│   │   │   ├── upstreamRetries.test.js
│   │   │   ├── uptimeHistory.test.js
│   │   │   ├── validation.test.js
│   │   │   └── webhookTransport.test.js
│   │   ├── DOCKERHUB.md
│   │   ├── Dockerfile
│   │   ├── LICENSE
│   │   ├── eslint.config.js
│   │   ├── package-lock.json
│   │   └── package.json
│   └── frontend/  ← React application
│       ├── eslint/
│       │   ├── i18n-literal-options.mjs
│       │   ├── localized-error-toast.mjs
│       │   └── test-localized-error-toast.mjs
│       ├── public/
│       │   └── licenses/
│       │       └── noto-sans-arabic-OFL.txt
│       ├── scripts/
│       │   ├── fetch-caddyfile-mode.mjs
│       │   └── localization.mjs
│       ├── src/
│       │   ├── components/
│       │   │   ├── ApiTokens.jsx
│       │   │   ├── AuthRecovery.jsx
│       │   │   ├── AuthSetup.jsx
│       │   │   ├── AuthenticatorCode.jsx
│       │   │   ├── CaddyFile.jsx
│       │   │   ├── Confirm.jsx
│       │   │   ├── CredentialVerification.jsx
│       │   │   ├── Dashboard.jsx
│       │   │   ├── Icons.jsx
│       │   │   ├── Instances.jsx  ← instance management page with discovery
│       │   │   ├── LanguagePicker.jsx
│       │   │   ├── Login.jsx
│       │   │   ├── Logs.jsx
│       │   │   ├── Metrics.jsx
│       │   │   ├── Notifications.jsx
│       │   │   ├── PaneDivider.jsx
│       │   │   ├── Passkeys.jsx
│       │   │   ├── RouteHistoryModal.jsx
│       │   │   ├── Routes.jsx
│       │   │   ├── SecuritySettings.jsx
│       │   │   ├── Sidebar.jsx
│       │   │   ├── TLS.jsx
│       │   │   ├── ThemePicker.jsx
│       │   │   ├── ThemeWelcome.jsx
│       │   │   ├── Toasts.jsx
│       │   │   ├── TotpLogin.jsx
│       │   │   ├── TotpSettings.jsx
│       │   │   ├── UpstreamUptime.jsx
│       │   │   └── UserSettings.jsx
│       │   ├── hooks/
│       │   │   ├── useClickOutside.js
│       │   │   └── useInstanceStatus.js
│       │   ├── i18n/
│       │   │   ├── fonts/
│       │   │   │   └── arabic.css
│       │   │   ├── locales/
│       │   │   │   ├── ar.js
│       │   │   │   ├── de.js
│       │   │   │   ├── en.js
│       │   │   │   ├── es-es.js
│       │   │   │   └── es-mx.js
│       │   │   ├── fonts.js
│       │   │   └── index.js
│       │   ├── utils/
│       │   │   ├── api.js
│       │   │   ├── caddyfileHistory.js
│       │   │   ├── credentialVerification.js
│       │   │   ├── errorMessages.js
│       │   │   ├── errorRegistry.js
│       │   │   ├── format.js
│       │   │   ├── lineDiff.js
│       │   │   ├── logSource.js
│       │   │   ├── notificationCenter.js
│       │   │   ├── passkeys.js
│       │   │   ├── routes.js
│       │   │   └── updateLink.js
│       │   ├── App.jsx
│       │   ├── main.jsx
│       │   └── styles.js
│       ├── test/
│       │   ├── api.test.js
│       │   ├── caddyfileHistory.test.js
│       │   ├── credentialVerification.test.js
│       │   ├── errorMessages.test.js
│       │   ├── errorRegistry.test.js
│       │   ├── fonts.test.js
│       │   ├── i18n.test.jsx
│       │   ├── localization.test.js
│       │   ├── logSource.test.js
│       │   ├── notificationCenter.test.js
│       │   ├── passkeys.test.js
│       │   ├── routes.test.js
│       │   └── updateLink.test.js
│       ├── DOCKERHUB.md
│       ├── Dockerfile
│       ├── caddyfile-mode.sha
│       ├── caddyui-logo.ico
│       ├── caddyui-logo.png
│       ├── caddyui-logo.svg
│       ├── eslint.config.js
│       ├── index.html
│       ├── nginx.conf
│       ├── package-lock.json
│       ├── package.json
│       ├── trusted-upstreams.sh
│       └── vite.config.js
├── .gitignore
├── .release-please-manifest.json
├── AGENTS.md
├── CHANGELOG.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── package.json  ← stable repository-level command entry points
└── release-please-config.json

src/frontend/src/lib/caddyfileMode.js is fetched at frontend build time and ignored by Git, so it is intentionally absent from this tree.

Images and releases

Authentication migration — breaking change

Warning

Existing automation must be reviewed before upgrading. Authentication is mandatory; unauthenticated management access is no longer supported. Legacy JWT authentication is removed: Bearer JWTs and ?token= authentication are no longer accepted. POST, PUT, PATCH and DELETE requests require a management session and its X-CSRF-Token (or a pre-login context for authentication endpoints); origin checks also remain active.

Integrations must replace legacy JWTs with scoped, read-only API tokens created in the UI. These allow status/metrics reads for selected Caddy instances only; write-capable automation still requires a browser-style session and CSRF context. Coordinate the upgrade with existing scripts and integrations.

Restrict access until administrator enrollment is complete. When neither stored nor legacy credentials exist, the first person reaching setup can claim administrator access. This also applies to upgrades from previously unauthenticated installations, even when settings and labels already exist. The backend emits a startup warning in this state.

Docker Hub

Image Tags

TagDescription
latestMost recent stable release
betaPre-release build of a specific branch or pull request, published manually for testing
edgeMost recent build of any kind: every push to main and every beta build. May contain unreleased or unmerged changes

See GitHub releases for versioned image tags and release notes.

Changelog

Releases from v1.18 onward are generated by release-please from Conventional Commit PR titles — see CHANGELOG.md. The table below covers earlier releases.

VersionDescription
v1.17Report the running caddy-ui version via GET /api/version and the sidebar
v1.16Real Caddyfile-grammar syntax highlighting (directives, matchers, placeholders, env vars) in place of a generic nginx-mode approximation
v1.13import directive support in Caddyfile validation, reload, and sort; force-save option; validation timeout
v1.12Push notifications via ntfy, Discord, Slack, Pushover, or custom webhook; automated release workflow with Docker Hub sync
v1.11Caddy binary bundled, upstream pool health checks, CA download via admin API, simplified TLS cert deletion
v1.10.1Caddy /adapt API validation, Docker socket removal
v1.10React Router navigation, RESTful API audit, Homepage widget, enriched status endpoint
v1.9Dark/light theme, log export, root CA download, env var support in Caddyfile
v1.8Metrics tab, upstream uptime tracking
v1.7JWT auth, Caddy process info, metrics toggle, public metrics endpoint
v1.6Edit routes, route notes, Caddyfile syntax highlighting
v1.5Caddyfile version history, log search and level filters
v1.4Dashboard health summary, route search/filter, Caddyfile backup/restore
v1.3Upstream healthchecks, clickable domain/upstream links
v1.2TLS certificate tab, orphaned cert cleanup, mobile layout
v1.1Mobile responsive layout, hamburger menu
v1.0Initial release

Contributing

caddy/ui welcomes code, documentation, translations, and feedback. Choose the path that matches what you would like to improve:

PathA good first contributionStart here
CodebaseReproduce a bug, improve an existing view, or add a focused testCodebase onboarding
DocumentationFix an unclear setup step, add an example, or explain a special caseDocumentation onboarding
LocalizationTranslate a small set of related UI strings into a language you knowLocalization onboarding
Feedback and improvementsReport a bug, describe an unclear interaction, or suggest an improvementFeedback guide

Documentation changes can start in GitHub’s browser editor. Code changes and local previews use a checkout of the repository. The onboarding pages explain the files to edit, how to check your work, and what to include in a pull request.

Keep your first change small enough for someone else to review easily. For a larger feature or documentation reorganization, open an issue describing the intended result before doing substantial work.

You do not need a development environment or a code change to contribute feedback. The feedback guide explains how to report a problem or propose an improvement.

Contributing to the codebase

This project has two application services: a Node/Express backend and a React/Vite frontend. The backend manages Caddy through its admin API and Docker or local file access. See the architecture guide for the runtime request flow.

Repository responsibilities

LocationOwns
src/backend/Backend source, unit/API tests, package dependencies and Dockerfile
src/frontend/Frontend source, component tests, build scripts and Dockerfile
dev/dev-caddy/Disposable Caddy lifecycle commands and ownership checks
dev/integration/Real-service fixtures, scenarios and lifecycle/readiness tests
dev/e2e/Browser fixtures and scenarios
dev/playground/Localization preview, captured responses and capture tools
docs/Book content, theme and documentation tooling

The root package.json provides stable development commands. Application packages, shared development tooling and documentation have separate manifests and lockfiles. There are no npm workspaces or additional build orchestrators. The generated project structure shows the current files.

Getting set up

Use Node.js 22.16+ or 24 and npm. From the repository root:

npm ci --prefix src/backend
npm ci --prefix src/frontend
npm ci --prefix dev

For a ready-to-run environment with real Caddy containers, follow the Disposable Caddy integration environment guide. It covers Docker prerequisites, startup, managing test instances, exercising failures and cleanup.

You can also develop against an existing Caddy instance. Its admin API must be reachable through CADDY_ADMIN_URL (default http://caddy:2019). Run the backend and frontend in separate terminals:

npm run dev --prefix src/backend
npm run dev --prefix src/frontend

The backend listens on port 3001 by default; the frontend’s Vite server proxies /api there. Relevant environment variables are documented in the configuration reference.

Frontend predev and prebuild fetch the generated CodeMirror grammar into src/frontend/src/lib/caddyfileMode.js from zackwag/caddyfile-codemirror. The file is ignored by Git. Run development or a build with network access before working offline; later runs can reuse the local copy.

Tests and lint

From the repository root:

npm test --prefix src/backend
npm run lint --prefix src/backend
npm test --prefix src/frontend
npm run lint --prefix src/frontend -- --max-warnings=0
npm run lint:dev-caddy
npm run test:dev-caddy

Backend tests use Vitest for helpers and HTTP contracts. Frontend tests cover component contracts, API/error behavior, localization and playground logic. The playground tests currently remain under src/frontend/test/ and require the dev package; moving them to their owning module is a separate follow-up.

Shared dev lint borrows backend ESLint and frontend rule configuration, so both application packages must be installed. It covers harness, integration, browser and playground code. Lifecycle/readiness unit tests need no Docker Engine.

Real-Caddy integration and Chromium checks use the same root commands locally and in CI. Follow the integration testing guide for browser installation, fixture ownership and cleanup. The required Test check includes both application tests and the real-service/browser integration job.

Verifying UI changes

Run the app and exercise affected views in a browser before opening a PR. Unit tests do not establish rendering or interaction correctness. Check both dark and light themes when changing styling, and include a narrow viewport when changing layout.

Code style and boundaries

  • Match the existing file style: ES modules, four-space indentation, single quotes and semicolons.
  • Keep application-specific unit tests and build scripts with their package. Shared development tools belong under dev/; documentation tooling stays with the book; GitHub-specific orchestration stays under .github/.
  • Before moving files, identify their imports, runtime paths, package commands, workflow filters, cache paths, build contexts and documentation consumers.
  • Preserve root command names and generated-file boundaries. Update generators or their canonical inputs rather than editing generated output.
  • Keep changes focused. Dependency upgrades, behavior changes and additional architecture changes should have their own justification and review scope.

Opening a PR

  1. Branch off main and keep the diff focused on one change.
  2. Explain the problem, resulting behavior and affected boundaries.
  3. Run the tests and lint for the packages you changed. For shared fixtures or tooling, also run the affected root suite and npm run lint:dev-caddy.
  4. Use a Conventional Commit PR title, such as fix:, feat:, test:, build: or docs:.
  5. Do not edit CHANGELOG.md or bump versions by hand. Release-please manages both after squash-merging.
  6. Open the PR against main; the Test workflow runs automatically.

Browser-origin security checks

The focused Chromium suite needs backend and dev dependencies plus a Playwright browser; it does not need Docker or a Caddy instance:

npm exec --prefix dev -- playwright install chromium
npm run test:cross-site:browser

The same cases run through npm run test:dev-caddy:e2e in CI. They use the real backend middleware and local HTTP servers, with no request interception or injected browser headers. An IPv4-mapped loopback URL reproduces Chromium’s untrustworthy HTTP destination behavior without an external LAN. Assertions check that Fetch Metadata is actually absent, forged fetches/forms never reach the write handler, and configured same-origin HTTP writes still work. Each case owns its browser and servers and closes them on completion or failure. Backend policy and route assertions also run with npm test --prefix src/backend.

Disposable Caddy integration environment

Use this environment to test frontend changes, backend behavior, error handling, and logging against real Caddy instances. It keeps development configuration and data separate from production. The root npm package contains development scripts; src/backend/, src/frontend/ and dev/ have separate dependency packages.

Prerequisites and startup

Use Node.js 22.16+ (or 24), npm, and a running Docker Engine or Docker Desktop. The Docker CLI must be on PATH and docker info must succeed for your user. The harness and backend Docker exec use the same Docker CLI context. Discovery separately uses DOCKER_SOCKET (default /var/run/docker.sock); it does not support remote/TLS Docker contexts. The harness targets local Docker Engines.

From the repository root:

npm ci --prefix src/backend
npm ci --prefix src/frontend
npm ci --prefix dev
npm run dev:with-caddy

Open http://localhost:5173. Vite defaults to 127.0.0.1:5173 and proxies API requests to the backend on port 3001. Set CADDY_UI_DEV_HOST=0.0.0.0 explicitly to allow access through a container network interface or LAN reverse proxy. Both ports must be free; the runner rejects occupied ports. The frontend’s normal Caddyfile syntax-mode fetch runs before Vite starts and needs network access on first use. The default image is pinned to caddy:2.10.2-alpine.

Startup creates/restarts default and an owned example-upstream Caddy service, waits for the default instance’s admin API and the backend, then starts Vite. New default volumes use the same real example sites as documentation capture: example.com, media.home.arpa and api.internal, with titles, metrics, JSON access logs, tls internal and reverse proxies to their own ports (8123, 8096 and 8080) on example-upstream. Caddy generates local certificates and keys in its own volume; it does not contact a public certificate authority or install its root CA on your computer. No private keys are checked into Git.

The printed HTTP URL retains /health (200), /error (503), and a catch-all response. HTTPS also has a dynamically allocated loopback port, printed as httpsUrl; use an example hostname for SNI and trust the instance’s root CA when testing it. The TLS page reads the generated certificates directly from Caddy storage. This setup does not seed screenshot uptime history, credentials, notification settings or other UI preferences.

Caddy admin listens on port 2019 inside its container; published HTTP, HTTPS and admin ports are dynamically allocated and bound to host loopback. Do not expose these admin ports externally: they have no authentication.

All UI state is redirected to .dev-caddy/backend/, including instance registrations, settings, notifications, history, themes, uptime and credential hashes. On first use, complete administrator setup, or sign in with inherited legacy credentials to enroll. Authentication is required, including for local sessions; browser writes also require CSRF protection. Automated integration and capture fixtures enroll disposable administrators and use real sessions. The harness disables update checks and proxy trust, so local tests do not inherit deployment-specific forwarding trust. It seeds a default registration only when the isolated instances file does not exist, and refreshes that registration’s address on startup while preserving other UI edits. It never rewrites the registry while the backend is running.

Ctrl-C stops frontend and backend processes. Caddy containers and persistent data remain for inspection and later startup. Use the explicit lifecycle commands to stop or remove them. A startup failure also leaves Caddy available for diagnosis.

Instance management

npm run dev:instances -- create alpha
npm run dev:instances -- create beta
npm run dev:instances -- create preview --examples  # real example sites and local TLS
npm run dev:instances -- list
npm run dev:instances -- stop alpha
npm run dev:instances -- create alpha     # restarts it, preserving edits
npm run dev:instances -- remove alpha     # removes container, retains volume
npm run dev:instances -- remove alpha --purge  # confirms before deleting its data
npm run dev:instances -- remove-all       # confirms; retains all volumes
npm run dev:instances -- remove-all --purge --yes  # explicit destructive consent

Names are lowercase letters, digits and hyphens, starting with a letter, at most 32 characters. Multiple instances use separate volumes and random published ports. Create is repeatable. List reports stopped containers and retained volumes. example-upstream is reserved for the shared example service. Basic create commands and focused integration/E2E scenarios retain their minimal HTTP fixture.

Existing Caddyfiles are preserved, including retained volumes from an older HTTP-only development setup. To try the richer fixture without deleting those edits, create a fresh preview --examples instance and register its printed details in the UI. The preset is applied only when the volume has no Caddyfile.

Docker lifecycle and UI registration are separate. Create prints the exact fields for the UI’s instance form. Add extra instances in the Instances screen using that adminUrl, container name and paths. You can also use the existing POST /api/instances endpoint with the printed instance JSON for future E2E setup (authenticate when enabled). Removing/stopping a container leaves its UI registration visible for offline/error testing. Delete the registration in the UI when desired. Recreating a removed container may change its published ports/IP; update its registration with the newly printed address. Discovery is a convenience, not lifecycle management: it scans all Caddy images, assumes admin port 2019, and suggests container IPs that may be unreachable from a host on Docker Desktop. Use the printed address rather than relying on discovery there.

Editable files and deterministic failures

Each instance has one persistent, labeled Docker volume mounted at /data/dev-caddy:

PathPurpose
/data/dev-caddy/CaddyfileEditable source configuration
/data/dev-caddy/logs/access.logHTTP access logs
/data/dev-caddy/data/caddyCaddy data
/data/dev-caddy/config/caddyCaddy autosave state

The default Caddyfile is written only if missing. Container restart/recreation loads this file and preserves all data. UI edit, validation, reload and log viewing use the existing backend Docker exec implementation and the real Caddy binary. No host Caddy binary is necessary. For local editor access, copy the file out and back (replace the name with the printed containerName):

docker cp <containerName>:/data/dev-caddy/Caddyfile ./work/Caddyfile
docker cp ./work/Caddyfile <containerName>:/data/dev-caddy/Caddyfile

Create work/ first if needed. Reload through the UI after copying back. Do not use production files as fixtures. A host file is a working copy, not a live bind mount; GUI edits are persisted directly in the volume.

For deterministic tests, request /error to exercise an HTTP 503 and logging; stop an instance to exercise connection errors and offline status; submit an invalid Caddyfile through the UI to exercise validation/reload failures. Invalid content saved directly to disk can prevent a restart; restore the file before restarting. For future browser E2E tests use a fresh checkout/namespace, the printed URLs, existing registration API, and confirmed cleanup in a finally block. Lifecycle functions accept an injected Docker command runner for automated tests.

Networking

Access through a VM or HTTPS reverse proxy

Editor forwardPorts entries do not publish a Dev Container port on the VM’s network interface. For an image/Dockerfile-based Dev Container, add "appPort": ["15173:5173"] to .devcontainer/devcontainer.json and rebuild the container. The provided example includes this mapping and explicitly opts into wide binding through CADDY_UI_DEV_HOST=0.0.0.0 in remoteEnv. Ordinary dev:with-caddy runs stay loopback-bound unless that variable is set. For a Compose-based Dev Container, add ports: ["15173:5173"] to its development service instead.

Your reverse proxy can then target the Docker host/VM’s stable LAN address on port 15173. It should proxy the frontend, which forwards /api internally. Check the mapping with docker ps --format 'table {{.Names}}\t{{.Ports}}' on the Docker host; expect 0.0.0.0:15173->5173/tcp. A DHCP reservation or static VM address avoids depending on a Dev Container IP that can change after recreation.

For HTTPS/passkey testing, start the runner with the public UI URL:

CADDY_UI_DEV_HOST=0.0.0.0 CADDY_UI_EXTERNAL_URL=https://caddy-ui-dev.example.com npm run dev:with-caddy

The runner adds that hostname to Vite’s allowed hosts. Keep the proxy’s Host header matching the public hostname. Open the HTTPS URL in your browser; the proxy-to-frontend connection can use HTTP. The managed test Caddy’s unauthenticated admin ports remain loopback-bound and are not reverse-proxy targets.

Managed Caddy and upstream connectivity

Run the harness against a local Docker Engine or Docker Desktop. It uses loopback-published ports for the frontend and backend to reach Caddy. Remote Docker Engines are unsupported because published loopback ports belong to the remote host. Use the printed HTTP URL to test a route and the printed admin URL when registering an instance; container IPs suggested by discovery may be unreachable from the host. The example reverse proxies connect to their owned peer’s Docker-network address. For backend upstream checks on Docker Desktop, run inside the Dev Container on the same Docker network; a host process may not reach private container IPs. Docker can give example-upstream a new IP whenever it starts. Existing Caddyfiles are never rewritten, so startup and create --examples warn when a retained Caddyfile still proxies to an old address; update those reverse_proxy lines to the address in the warning.

Troubleshooting and cleanup safety

  • Docker unavailable: install/start Docker and verify that docker info succeeds for your user. Check socket group permissions or select the appropriate rootless Docker CLI context; set DOCKER_SOCKET separately if discovery is needed.
  • Readiness failure: use list, docker logs <containerName>, and inspect the Caddyfile. Check that the printed admin address is reachable.
  • Ports occupied: stop other UI development processes and rerun startup.
  • Failed editor-mode download: check network access to the source GitHub repo; the frontend fetch script supports its normal local cache.
  • After an interrupted lifecycle command, .dev-caddy/lifecycle.lock may remain. After a crashed runner, .dev-caddy/runner.lock may remain. Remove only the relevant lock directory after verifying its process is no longer running.

The checkout stores a random namespace in .dev-caddy/namespace. Container and volume names include it; all resources also carry io.caddy-ui.dev=true, project and instance labels. Every mutation verifies labels and the exact expected name. Bulk cleanup filters by both management and project labels, validates ownership, and requires a typed confirmation or --yes. It never runs Docker prune or removes networks. Different checkouts have independent namespaces. Avoid copying .dev-caddy to another checkout, which would share ownership.

Do not delete .dev-caddy/namespace before Docker cleanup: doing so loses this checkout’s ownership key and leaves orphan resources. For a complete reset, stop the UI runner, run remove-all --purge --yes, then delete .dev-caddy/. Reinstalling or rerunning startup is otherwise safe and repeatable. Registry cleanup is deliberately separate; deleting .dev-caddy/backend/ while stopped resets UI state without touching any Caddy volumes.

Run shared tooling lint with npm run lint:dev-caddy (requires backend and frontend dependencies for shared rules) and lifecycle safety tests with npm run test:dev-caddy. They need no Docker Engine. npm run test:dev-caddy:integration runs real-container checks. They use fresh namespaces and purge only their own resources, covering /health, /error, reload, logging, restart/recreation persistence and independent simultaneous instances. It requires Docker and may pull the pinned image.

Automated integration and browser checks

Install src/backend, src/frontend and dev dependencies as shown above, then run:

npm run test:dev-caddy
npm run test:dev-caddy:integration
npm exec --prefix dev -- playwright install chromium
npm run test:dev-caddy:e2e

Integration checks also exercise the real backend’s registration API: creating or removing a Docker container does not create or remove its UI registration. The tests cover reload, retained configuration, instance isolation and cleanup after failed startup. Diagnostic regressions check complete route paths, semantic error codes, sanitized read failures, warning severity and request-ID correlation. Browser checks cover localized errors, links to correlated logs, live log streams, incident recovery, network loss and the empty-instance state.

The Test workflow runs lifecycle and browser checks against Docker and Chromium. Its required Test check includes the integration job, so container or browser failures cannot be hidden by passing unit tests.

Each fixture uses a fresh labeled Docker namespace, temporary backend state and dynamic service ports. It does not read or change .dev-caddy. Readiness waits are bounded, and teardown stops its backend and browser before purging its own containers and volumes, including when a test fails. Tests do not use production configuration or require a running development stack.

Fixture cleanup is registered before startup and follows the test’s cancellation signal. It waits for in-flight resource creation before stopping services and purging resources, so an acquisition that finishes after a timeout is still cleaned up. Every cleanup layer is attempted even when another layer fails.

Before inspecting or creating Caddy resources, a fixture atomically claims its namespace with a stopped Docker container named caddy-ui-test-<namespace>-lease. The lease uses separate test-fixture labels and is removed after all other cleanup layers. Concurrent clients cannot acquire the same namespace. If a process is killed without cleanup, inspect its lease and verify the owner has stopped before manually removing that specific lease container.

The backend lets the OS allocate its port (PORT=0) and reports the bound port through Node’s IPC channel before HTTP readiness checks. It keeps ownership of that port throughout startup, allowing concurrent fixtures without reserving and releasing candidate ports.

The browser suite uses Playwright’s Chromium by default. Set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to an installed Chromium executable when using a managed environment that provides its own browser.

Code boundaries and review order

Read the support code in this order:

FileResponsibility
dev/dev-caddy/harness.mjsExisting Docker operations, ownership checks and environment paths; shared by development commands and tests.
dev/dev-caddy/examples.mjsReal example site configuration shared by documentation capture and interactive development.
dev/integration/lifecycle.mjsResource acquisition, cancellation and reverse-order cleanup. It has no Docker, backend or browser dependencies.
dev/integration/stack.mjsClaim namespace, create Caddy, seed temporary state, start backend, discover its bound port, and wait for HTTP readiness.
dev/e2e/fixture.mjsAdd Vite, Chromium, a browser context and bounded diagnostics to the same resource owner.

FixtureScope.run tracks a step that must finish before cleanup. acquire also registers disposal of the returned resource, including a resource that arrives after cancellation. defer registers cleanup for partial resources such as a container creation that fails after creating its volume. close is idempotent, waits for pending steps, and attempts every disposer in reverse order. The scope’s bookkeeping is private; callers use these methods and its signal.

useTestStack registers the owner with a Node test before acquiring resources. The browser fixture passes its own owner to createTestStack, so the browser, frontend, backend and Caddy resources follow one cleanup order.

Scenario files contain the actions and expected behavior:

  • dev/integration/caddy.mjs: Caddy lifecycle and persistent data without the UI backend.
  • dev/integration/api.mjs: registration versus container lifecycle and instance isolation.
  • dev/integration/startup.mjs: failed startup, cancellation, port ownership and namespace exclusivity.
  • dev/integration/diagnostics.mjs: public error contracts and correlated backend logs.
  • dev/e2e/error-logging.mjs: browser feedback, live logs, recovery, network loss and locale fallback.
  • dev/e2e/languages.mjs: reviewed-language availability, Arabic font loading and fallback after a failed font download.

*.test.mjs files in the integration package exercise lifecycle and readiness without Docker. The root npm commands select unit, container and browser suites explicitly; CI invokes those same commands. A bounded browser-suite hook prepares editor mode once, rather than downloading it during each fixture acquisition.

Each browser scenario gets its own entire stack, including its registry, page, locale and viewport. This costs more startup time than one shared stack, but scenarios can be selected or reordered without inheriting another test’s state. Uncaught JavaScript errors are checked during each scenario’s teardown, rather than attributed to a separate test at the end of the file.

Contributing to documentation

You can help by explaining a confusing step, correcting a reference, improving a screenshot, or adding an example from a deployment you have tested. Programming experience is not required for a documentation contribution.

Make your first change in the browser

  1. Find the page you want to improve in the book’s source list.
  2. Open its Markdown file under docs/src on GitHub. For a shared reference, use the source table below to find the right file.
  3. Choose GitHub’s edit button. GitHub can create a fork for your proposed change if you do not have write access to the project.
  4. Edit a small section. Use Markdown Preview to check headings, lists, and code fences. Give the change a descriptive commit message such as docs: clarify local-mode certificate paths.
  5. Open a pull request against zackwag/caddy-ui’s main branch. Explain what was confusing and how your change helps. The Documentation workflow builds the complete book and attaches a preview artifact to the workflow run.

Markdown Preview shows the file you edit. The mdBook build resolves shared includes and produces the final sidebar and pages.

Choose the source to edit

ContentSource
Explanations, special cases, and contributor onboardingThe relevant page in docs/src/
Environment-variable and API references, Compose example, or other text shared with Docker HubThe marked section in the root README.md
Short project introduction and contribution entry pointREADME.md and CONTRIBUTING.md
Screenshotsdocs/src/screenshots/
Page order and nestingdocs/src/SUMMARY.md
Documentation themedocs/theme/
Comments beside paths in the generated project treedocs/project-structure-notes.json

The book uses mdBook’s built-in includes for shared references. Those references are authored once in the README; edit the original section there. Leave the include directive in the book page intact.

Keep the README’s dockerhub:... routing comments and ANCHOR / ANCHOR_END comments paired. Routing selects text for the Docker Hub generator; anchors select excerpts for the book. Never edit src/backend/DOCKERHUB.md or src/frontend/DOCKERHUB.md by hand.

Preview locally

Work in a checkout of your fork. Install the prebuilt mdBook 0.5.4 binary for your environment and put it on your PATH. For a Linux devcontainer or WSL, use the Linux binary; for a native Windows terminal, use the Windows binary. Install Node.js 22.12 or newer and run npm ci --prefix docs --ignore-scripts once before building or serving.

From the repository root:

npm run i18n:coverage --prefix src/frontend
mdbook serve docs --hostname 127.0.0.1 --port 3000

Open http://localhost:3000. In a devcontainer, use mdbook serve docs --hostname 0.0.0.0 --port 3000 and forward port 3000 in VS Code. The server watches authored book files and their included sources, then rebuilds when you save a change. Check both the changed page and any pages that include it. The coverage command generates an ignored asset from the catalogs; rerun it after catalog changes. It does not require installing frontend dependencies.

Before submitting, build the book:

mdbook build docs

If you changed a marked README block, also validate the existing Docker Hub generator from the repository root (this requires Node.js and the documentation dependencies installed above):

The generator uses parse5’s HTML tokenizer to preserve comments, tag attributes and code elements while rewriting Markdown links. It keeps the original source text and does not sanitize HTML. Its tests cover browser-recognized comment endings, quoted and multiline attributes, custom tag names and nested code elements.

node --test .github/scripts/dockerhub-docs.test.mjs
node .github/scripts/dockerhub-docs.mjs --dry-run

Make the change easy to review

Refresh documentation screenshots

npm run docs:screenshots renders the checked-in API capture without a running backend. After API or authentication contract changes, first run npm run docs:screenshots:capture with Docker and backend dependencies available, then render the screenshots again. Live capture enrolls an isolated administrator and sends session-bound CSRF tokens for setup and configuration writes. Cookies, credentials, recovery keys and CSRF tokens remain in memory; the saved capture contains read responses only.

The example domains and titles are configured in the first disposable Caddy. It issues its own local certificates at runtime, proxies to the second owned Caddy, and receives traffic to populate logs and metrics. Uptime history is illustrative sample data seeded into the backend’s normal history file before startup, as noted in the capture provenance; it is not a performance measurement. No certificate keys are committed or included in the capture.

Commit the refreshed docs/screenshots/captures/screenshots.json alongside capture changes. The renderer rejects unauthenticated snapshots, unrecorded requests and views that never become ready. npm run test:screenshots --prefix docs exercises live capture, all eight views, cleanup and rejection of incomplete replay data.

Review documentation changes

Use fenced mermaid code blocks for diagrams. They render locally in the book and follow the selected light/dark theme; preview diagrams in the built book. The project tree is generated automatically. Add file/directory comments in docs/project-structure-notes.json, keyed by repository-relative path (directories end in /). Builds preserve these annotations; update the key after a rename.

  • Keep examples runnable and state any deployment assumptions.
  • Preserve code indentation, environment-variable names, and paths exactly.
  • Use relative links between book pages and include the target heading when useful.
  • For a new page, add its Markdown file and an entry in SUMMARY.md.
  • Give each page one # title and use ## for its main sections. Shared includes should contain the section’s content without repeating the page title; mdBook also adds section headings to the sidebar.
  • Remove credentials and private information from examples and screenshots.
  • For layout or theme changes, include a screenshot and check desktop/mobile views.

Use a docs: PR title. Include the page you changed, why you changed it, and which examples or previews you checked. Generated docs/book/ output is not committed; the workflow builds and publishes it.

Contributing to localization

You can start with a handful of related strings in a language you know. A locale does not have to be complete: missing translations fall back to English, so a small, reviewed contribution is useful.

Find the strings

  1. Fork and clone the project, or update your existing checkout. Create a branch for your contribution.
  2. Open src/frontend/src/i18n/locales/en.js. English defines the key structure and the fallback text; use it as your reference.
  3. Open the target language’s file in the same directory. Choose a small group of strings belonging to one screen or interaction, and read their English context.
  4. Add or improve those values while preserving the existing keys and nesting.

For a new language, use a lowercase two- or three-letter language-code filename, such as de.js. Export the same object shape as the existing catalogs, including a non-empty test.languageLoaded value supplied in your language. Include only the keys you intend to translate; copying every English value is unnecessary.

Translation rules

Translations must be human-supplied or reviewed by a human who can vouch for them. Machine-generated, LLM/agentic, translation-API, or bulk machine translations are not accepted. Do not ask a coding agent to author non-English values. By opening a translation PR, you attest that the submitted values meet the project’s human translation policy.

  • English remains the canonical schema and fallback language.
  • Do not add keys or change key-tree shapes that are absent from en.js.
  • Every locale must contain the non-empty test.languageLoaded probe.
  • Preserve interpolation names such as {{domain}}, their spelling, and their braces; translate the surrounding text.
  • Preserve intentional technical values such as API paths, Caddy directives, environment-variable names, and protocol identifiers.
  • Check wording in context, especially button labels, confirmation dialogs, and messages containing a name or count.

Validate and preview

From the repository root:

npm ci --prefix src/frontend
npm ci --prefix dev
npm run i18n:check --prefix src/frontend
npm test --prefix src/frontend
npm run lint --prefix src/frontend -- --max-warnings=0

The catalog check catches unknown keys, incompatible structure, and a missing language probe. It permits incomplete translations. Tests and lint provide the additional checks used by the localization implementation.

For a full UI preview, follow codebase setup. Reviewed languages are available by default; English remains the fallback. For a preview catalog, add its code to CADDY_UI_ADDITIONAL_LANGUAGES in the backend environment, restart the backend, and select the language in the UI. A matching frontend locale file must exist. Preview availability does not change the translation authorship and review requirements above.

Spanish (Spain) is available as a partial, opt-in preview catalog. Set CADDY_UI_ADDITIONAL_LANGUAGES=es-es on the backend, restart it, and select Spanish (Spain) in the UI language picker. Only human-reviewed strings are included; missing translations fall back to English. This catalog is separate from Mexican Spanish (es-mx) and is not enabled by default.

Default availability is an explicit maintainer decision, recorded in REVIEWED_LANGUAGES in src/backend/src/languages.js; it is not inferred from translation coverage. Bundled fonts load automatically when their language is selected.

Visual translation playground

The playground lives under dev/playground/. After installing frontend and dev dependencies above, run from the repository root:

npm run i18n:preview

Open http://localhost:5174. Choose an installed locale, search for an English key or saved value, choose a component state, and try your translation in its real component preview. Some keys may not have a registered preview yet. Highlights identify matching text, tooltips, or input labels; the context inspector provides DOM selectors and interpolation values. Ambiguous wording is marked explicitly.

The playground keeps drafts only for the current session. Copy your value and paste it into the target locale file yourself; it does not save edits to the catalog. Reloading discards drafts. Preserve placeholders and review any warnings. The playground replays captured real-backend responses without a running backend or Docker. Copy preview link shares a key, language, and state without including draft text. For capture refresh, optional browser checks, and adding component states, see the playground developer guide.

Submit a focused PR

Keep the PR to one language and a manageable group of related strings. State the language, the screens or key groups you covered, whether a fluent reviewer checked the wording, and which validation/preview commands you ran. If a string needs more context, explain that in the PR rather than guessing.

The coverage graph is generated from the catalogs when the documentation is built, so it is not part of your PR. If you need to inspect it, run npm run i18n:coverage --prefix src/frontend.

Feedback and improvements

Reporting a problem or suggesting an improvement is a contribution in its own right. You do not need to write code or set up a development environment.

Start by searching the existing issues. If someone has reported the same problem, add useful details to that thread. Otherwise, open a new issue.

Report a problem

Include enough information for someone else to understand and reproduce it:

  • Your caddy/ui version, shown in the sidebar or at GET /api/version.
  • The affected page or operation and the steps that trigger the problem.
  • What you expected to happen and what actually happened.
  • Relevant error messages, logs, or screenshots.
  • Your deployment setup and, for browser issues, the browser and device you use.

Application logs are available in the log viewer. If more detail is needed, LOG_LEVEL=debug enables additional backend diagnostics. Remove credentials and other private information before sharing logs or configuration excerpts.

Suggest an improvement

Explain the problem you want to solve, how you currently handle it, and what behavior would help. A concrete example or screenshot makes a proposal easier to discuss. You can suggest a solution, but a clear description of the need is enough to start the conversation.

For larger changes, discuss the proposal in an issue before starting substantial implementation work.

Follow up

Keep the issue updated if you find a workaround, additional reproduction steps, or discover that a newer release resolves it. Some inactive issues are marked stale and later closed automatically; reply to the stale notice if the issue is still relevant.