Caddy Cutover & the Edge Router
The edge-router driver port, the Caddy implementation, drift detection, and generation-based compare-and-swap for release state.
Caddy Cutover & the Edge Router
EdgeRouterInterface is the Ops Kit port for "whatever sits in front of your app and decides which color receives traffic." The shipped driver targets Caddy's admin API; the port itself doesn't care what's underneath.
interface EdgeRouterInterface extends DriverInterface
{
public function cutover(DesiredRoute $desired): CutoverResult;
public function liveRoute(): ?LiveRoute;
public function reconcile(DesiredRoute $desired): ReconcileResult;
}Cutting over
CutoverCoordinator is the orchestration layer above the raw driver call. It does three things a raw $edgeRouter->cutover() call alone wouldn't:
- Verifies the new upstream is actually live after the switch —
CutoverResult::$verifiedLiveUpstreammust be true, or the coordinator treats the cutover as failed. - Reverts automatically on verification failure — if there was a previous color, it switches back to it and records why via
CutoverEventRecorderInterface::recordRevert(), then throwsCutoverRevertedException. - Records the new release with a generation number — only after a verified cutover.
$result = $cutoverCoordinator->cutover(
desired: $desiredRoute,
imageDigest: $digest,
buildId: $buildId,
planHash: $planHash,
previousEndpoint: $blueEndpoint,
);A cutover never leaves you pointed at an unverified upstream silently — it either confirms success or reverts and tells you exactly what it reverted to.
Domain and TLS preservation
A Caddy POST /load replaces the entire active config document — any field the posted config omits reverts to Caddy's built-in default. EdgeConfigGenerator is the single source of truth for the config shape, and every config it builds for a domain-bearing route carries both a host matcher and a tls.automation policy for that domain:
$desired = new DesiredRoute(
env: 'production',
activeColor: ActiveColor::Blue,
upstream: $blueEndpoint,
domain: 'api.example.com', // ← threaded through to the host matcher + tls.automation
);Because the generated config always echoes the domain's tls.automation, a cutover preserves the domain's Let's Encrypt certificate (cached in caddy_data) instead of clobbering it to Caddy's internal default. The driver fails the cutover closed if the generated config is ever missing the domain's TLS policy, so a TLS-dropping config can never reach the live edge. Set the domain from your deploy config; leave it null only for an internal, non-TLS edge.
Admin bind vs. connect
The admin bind address written into the pushed config (CADDY_ADMIN_LISTEN, default localhost:2019) is separate from the admin connect URL the deploy dials (CADDY_ADMIN_URL, default http://<listen>). In the deploy-in-image topology the edge is its own container, so set e.g. CADDY_ADMIN_LISTEN=:2019 and CADDY_ADMIN_URL=http://edge:2019.
Durable edge state and boot reconstruction
The live switch happens over the admin API, but a restarted, replaced, or newly scaled edge node must be able to reconstruct which color is live — otherwise an edge restart would drop the active route. Every cutover persists the routing intent (active color, domain, upstream, weight) to an EdgeStateStore:
redis(default) — a control-plane store backed by the shared framework Redis, so a fleet of stateless edge nodes all agree on the active color from one central key.file— a single-node fallback underEDGE_STATE_DIR(default/opt/vortos/edge), selected withEDGE_STATE_STORE=file.
Because the Caddy edge image has no PHP, it can't query the store itself. The generated edge compose runs an edge-init step (the app image) that renders the config from the store before Caddy starts:
php bin/console deploy:edge:hydrate-config --env=production --out=/config/caddy.jsonOn first boot — before any cutover has recorded state — it falls back to the scaffolded bootstrap config (--fallback) so the edge can still serve HTTPS and accept the first cutover; otherwise it fails closed rather than serving an empty config.
Generations and compare-and-swap
Every CurrentRelease carries a generation integer that increments by exactly one on each successful cutover:
$newGeneration = ($prev !== null ? $prev->generation : 0) + 1;This is what makes the release store safe against a stale reconciler: if two processes race to reconcile state, the one operating on an older generation can be detected and rejected rather than silently overwriting a newer release with stale desired state.
Drift detection and reconciliation
EdgeReconciler is what deploy:reconcile runs. It compares what's actually live ($edgeRouter->liveRoute()) against what should be live (derived from the recorded CurrentRelease):
desired = endpoint for currentRelease.activeColor
live = edgeRouter->liveRoute()
if live matches desired → in sync, nothing to do
if live differs → drift detected
if rate-limited → record drift, skip correction this cycle
else → cutover to desired, record correctionphp bin/console deploy:reconcile --env=productionReconcileRateLimiter exists so a flapping or misbehaving edge router doesn't get hammered with repeated correction attempts — drift is still recorded via CutoverEventRecorderInterface::recordDrift() every cycle, even when a correction is skipped, so you can see drift happening in your event log even during a rate-limited window.
Why reconciliation is a separate command from cutover
A cutover is a deliberate action tied to a specific deploy. Reconciliation is a background safety net — run it on a schedule (cron, a sidecar loop) so that if the edge router's config drifts from the desired state for any reason (a manual edit, a crash mid-write, an out-of-band change), it self-heals rather than staying wrong until the next deploy.
Scaffolding the Caddy config
php bin/console deploy:edge:init api.example.comScaffolds the edge files for a blue/green setup: a bootstrap caddy-config.json (domain + TLS, ready to serve HTTPS before the first cutover) and a docker-compose.edge.yaml that wires the admin API plus the edge-init hydrate step the edge uses to reconstruct its route on boot.
Worker drain during cutover
The Caddy driver's DrainObserver coordinates the brief overlap window during cutover — workers behind the color being retired get a deadline to finish in-flight requests (drain_deadline_seconds in the strategy's SwitchUpstream step) before the old upstream is removed from rotation entirely. DrainResult reports whether every worker drained cleanly within the deadline or had to be force-stopped.
Phase Gate & Rollback Guard
Expand/contract migration safety, phase ordering rules, and the rollback guard that consults Release before allowing a rollback.
Credentials
Zero-standing-secrets credential providers — SSH-CA-OIDC, SSH key, and pull-agent — and the non-mutating preflight every one of them supports.