caddy-config is designed around strict process isolation, single-goroutine state confinement, and direct storage volume manipulation over the Incus API.
caddy-config separates control-plane responsibilities from the data plane across two isolated processes:
flowchart TD
subgraph ControlPlane["Control Plane (Private)"]
CC["caddy-config Controller"]
Credentials[("Incus Client Certificate<br/>(/var/lib/caddy-config)")]
CC --- Credentials
end
subgraph DataPlane["Data Plane (Public)"]
Caddy["Caddy Reverse Proxy"]
Ports["Public Ports 80 / 443"]
Ports --- Caddy
end
IncusDaemon["Incus Daemon API<br/>(HTTPS / Unix Socket)"]
CC -->|"1. Subscribes to events<br/>2. Discovers instances"| IncusDaemon
CC -->|"3. SFTP directly to storage volume"| StorageVolume[("Incus Storage Volume<br/>(caddy-config)")]
CC -->|"4. Exec 'caddy validate' & 'caddy reload'"| Caddy
StorageVolume -.->|mounted at /config| Caddy
| Property | caddy-config |
caddy |
|---|---|---|
| Role | Control-plane event listener & config reconciler | Data-plane HTTP/HTTPS reverse proxy |
| Network Exposure | None (internal :9153 healthcheck only) |
Public ports 80 and 443 |
| Incus Credentials | Holds client TLS certificate or trust token | None (zero access to Incus API) |
| Caddy Admin API | Accesses via in-container exec (caddy reload) |
Bound to localhost:2019 inside container |
| Disk Access | Writes to storage volume via Incus SFTP | Mounts /config and /data storage volumes |
Because Caddy holds no Incus credentials and its Admin API is bound strictly to localhost:2019, a compromised backend service or proxy vulnerability cannot access the Incus API or manipulate container definitions.
Caddy persists its configuration in an Incus custom storage volume (e.g. caddy-config mounted to /config).
Rather than requiring caddy-config to mount the storage volume locally on the host or in its own container, caddy-config communicates entirely through the Incus API:
sequenceDiagram
participant P as caddy-config Plugin
participant API as Incus API
participant Vol as Incus Storage Volume
participant C as Caddy Container
P->>API: GetInstance(project, instance)
API-->>P: Instance device configuration
Note over P: resolveVolume inspects disk devices<br/>attached to /config
alt Custom Storage Volume Attached
P->>API: GetStoragePoolVolumeFileSFTP(pool, "custom", name)
API-->>P: sftp.Client (volume root)
else No Volume Device
P->>API: GetInstanceFileSFTP(project, instance)
API-->>P: sftp.Client (container root)
end
P->>Vol: Create & write /.Caddyfile.tmp
alt Container is Running
P->>API: ExecInstance("caddy validate --config /.Caddyfile.tmp")
API->>C: caddy validate
C-->>API: exit code 0
API-->>P: Validation succeeded
P->>Vol: sftp.PosixRename("/.Caddyfile.tmp", "/Caddyfile")
P->>API: ExecInstance("caddy reload --config /config/Caddyfile")
API->>C: caddy reload
else Container is Stopped (Cold Staging)
P->>Vol: sftp.PosixRename("/.Caddyfile.tmp", "/Caddyfile")
Note over P,Vol: Staged directly onto volume.<br/>Caddy boots with valid config on start.
end
resolveVolume):
deploy() inspects the instance's expanded devices to locate a disk device whose mount path matches the parent directory of --caddyfile-path (default /config). It extracts the pool and volume source name.caddy-config still updates the Caddyfile on the storage volume. When Caddy starts up or reboots, it reads the updated configuration immediately.caddy-config never writes directly over the active Caddyfile. It writes to /.Caddyfile.tmp and only renames via sftp.PosixRename after in-container validation succeeds.Caddyfile remains completely untouched.--os-path)When caddy-config runs alongside Caddy on the same host, container, or VM:
.<base>.tmp in the target directory and atomically swaps via os.Rename.caddy validate --config <staging> --adapter caddyfile on the local machine.caddy reload --config <path> --adapter caddyfile. If the Caddy daemon is offline during boot, the file remains deployed on disk for Caddy's startup.--caddy-instance targets within the exact same single-goroutine loop.All state in caddy.Plugin (instances, lastDeployed, chain) is strictly confined to p.Run(ctx)'s single goroutine:
flowchart LR
subgraph ieventChain["ievent Chain"]
Next["next(ev)"]
end
Event[Incoming Event] --> Handle["p.Handle(ev)"]
Handle --> Next
Handle -->|non-blocking send| Inbox[("p.inbox (chan *Event)")]
subgraph RunLoop["p.Run(ctx) Single Goroutine"]
Inbox --> RunSelect{select}
CommandIn[p.commandIn] --> RunSelect
Ctx[ctx.Done] --> RunSelect
RunSelect -->|event| Process["processEvent()"]
Process --> Reconcile["reconcile()"]
end
Plugin contains zero mutexes (sync.Mutex or sync.RWMutex) and zero atomic flags.Handle() are placed into a buffered inbox channel (p.inbox) and forwarded along the ievent chain immediately.processEvent() and reconcile() execute exclusively on the Run() goroutine. Race conditions between concurrent state updates and Caddy reloads are impossible by design.iutil.CommandDrain), p.Run drains remaining events in p.inbox via drainInbox(), forwards the drain command to p.commandOut, and exits cleanly.caddy-config coordinates with the ievent enricher to avoid flapping routes during startup or daemon reconnection:
iutil.ChainCold):
When the process starts, the enricher performs an initial fleet sweep of all monitored projects. During this sweep, instance events are recorded into p.instances, but all Caddy deployments are gated and paused.iutil.ChainWarm):
Once the sweep finishes, the enricher emits ActionSweepEnd. caddy-config transitions to ChainWarm and performs a single initial reconcile() deploying the complete routing table.ChainCold. Caddy retains its last known good configuration on the storage volume while caddy-config reconnects and re-synchronizes the fleet.