Concepts

August 29, 2026 · View on GitHub

The ideas the CLI and API surface, and why they are shaped the way they are. If you have used Argo CD, most of this will rhyme; the differences are where Swarm differs from Kubernetes.

The pull loop

swarmcli-cd is a controller that runs in the swarm and pulls. On a schedule, for each application, it fetches the repository at the pinned revision, renders the desired state, plans it against what is actually running, and — when the sync policy says so — applies it.

This is the half that CI running swarmcli charts apply after a merge does not give you: CI holds cluster write credentials, runs only on a push, and corrects nothing that drifts between deploys. A controller holds the credentials itself, inside the swarm, and reconciles continuously.

Each application runs on its own schedule in its own goroutine. One application whose repository is unreachable backs off and retries without stalling the others. The default interval is three minutes; an application that needs to be quicker sets its own syncPolicy.interval.

Drift, sync, and health

Two questions, deliberately kept apart.

Sync answers does the swarm match git? It is synced or out-of-sync. The out-of-sync case carries a summary of the plan that made it so, counted by action — how many releases would be installed, upgraded, or left unchanged.

Health answers is what is running actually working? It is healthy, progressing, degraded, missing, or unknown. missing — declared but not present — is deliberately distinct from degraded — present but unhealthy; an operator and a UI both need to tell those apart. unknown is the swarm not having been readable at all, which is distinct again: an unreachable daemon is not a stack that has gone away, and reporting it as one would raise the loudest alarm this axis has about something that is very likely fine.

They are independent axes. A stack can be perfectly synced and badly degraded at the same time, and collapsing the two would lose the distinction that makes the view useful. Every list row shows both, plus a 3/4 service count, without having to open the application.

Both are the last successful observation, so a reconcile that never reached one leaves them saying what they said before. When that happens the list grows a RECONCILE column marking the affected rows, and prints the reason under the table; app get and the JSON carry the full error either way. Without the column, an application whose repository has been unreachable for a week reports last week's verdict beside a timestamp of seconds ago.

Drift is what the sync axis reports between deploys. On a manual application, the controller reconciles, sees the swarm no longer matches git, and records out-of-sync — but does not apply it. The drift is observed; acting on it waits for an explicit app sync. On an automated application the same reconcile applies the change.

Two ways to be out of sync

driftDetection decides which of them the controller looks for.

manifest (the default) compares the rendered manifest against what was last applied. It catches a changed chart version, changed values and a changed template — everything that happens because git moved.

It cannot catch anything that happened to the swarm afterwards. Swarm has no server-side apply, so docker service update --replicas 10 produces no conflict signal at all: the next reconcile computes the same desired spec, finds it unchanged, and does nothing. The hand-scaled service stays hand-scaled until some unrelated commit triggers a deploy, and is then silently overwritten.

live additionally compares the running ServiceSpec of each settled release against the one the repository renders to. That is the only thing that catches the swarm moving, and it makes the controller report — and, with an automated policy, correct — a change nobody committed.

The two are separate axes in the status for the same reason sync and health are. sync goes out-of-sync either way, so the sync button and anything alerting on that field work unchanged; drift beside it says the reason was the swarm rather than git, which is a different thing for an operator to do something about. A commit is something to review; a change nobody recorded is something to ask about.

The comparison is at ServiceSpec level, never at YAML level, and is a named list of fields rather than everything — see configuration § driftDetection for what is compared and what is not.

Revision, and last sync

The sync state carries two revisions, and they answer different questions. The assessment's revision is the commit the swarm was compared against — the tip of the pinned branch, right now. The lastSync.revision is the commit that was actually deployed.

When they differ, there is a newer commit that has not been applied yet — which is a different condition from being out-of-sync against the commit you are on, and a UI shows both. A manual application that has drifted, and an automated one mid-way between its interval ticks, are both this case.

Automated versus manual

syncPolicy.automated is the difference between a controller that deploys for you and one that only tells you what it would deploy.

  • Automated — reconcile and apply on the schedule. This is GitOps in the usual sense: merge to the branch, and the swarm converges.
  • Manual — reconcile and report, but apply only when you run app sync. "Manual" means "not on a schedule", not "never": an explicit sync still deploys. This is the mode for a production swarm where a human approves each rollout after reading the diff.

Ownership

Every release swarmcli-cd installs is stamped with an owner, cd/<controller>/<application>, recorded in the release-history config and in the stored record. The stamp is what lets a later reconcile tell a release this application installed from one it has never seen — the prerequisite for pruning safely, because it separates "this is obsolete" from "I do not recognise this".

The stamp names the controller as well as the application — cd/<controller>/<application> — because prune acts on the difference between "mine" and "not mine". Without the controller half, a second swarmcli-cd on the same swarm would read the first one's applications as departed and delete them. Two controllers sharing a swarm must therefore have distinct --controller-id values; see configuration § two controllers on one swarm.

That stamp lives on the swarm, not in the controller's memory, which is what makes prune survive a restart: a controller that comes up and finds a release stamped for one of its own applications that its app set no longer declares knows the application departed, even though it never watched it leave. It is also why prune needs no database — the swarm is the record.

A stamp records who installed a release, and it is only rewritten by a reconcile that deploys. A stamp naming an application that no longer exists — exactly what renaming one produces — is therefore corrected rather than permanent: the owner is part of what the plan compares, so the release is redeployed once under the new name. But it is corrected no sooner than the next reconcile that gets that far, and a prune sweep can run first. That is why prune asks a second question before it deletes anything: not only "whose stamp is this" but "is any application still declaring this release". Only a release that fails both is left behind; see configuration § renaming an application.

The same question is asked one scope down, and for all four kinds a manifest declares. A service, network, config or secret inside a release carries only the stack's com.docker.stack.namespace label, which says where it lives and not who put it there — anything can set it. So before deleting one its chart has stopped declaring, the controller looks for a stored revision of that release, stamped by it for this application, that declared it. One that passes is orphaned: provably installed from this repository and provably no longer wanted. One that does not is unmanaged, reported and never touched. It is the release-level orphan/unmanaged distinction applied to what is inside a release; see configuration § what a chart stops declaring.

One rule and one ownership model, applied once per kind rather than over a merged list — Swarm scopes all four into a single namespace of names, so a name is only ever evidence about its own kind.

What a release name may claim

A release name is the stack namespace, so choosing one is a claim on everything already carrying that label — and it is the release name that claims it, never the application's. An application named eldara-zammad installing a release named zammad deploys the stack zammad. A chart application that names no release installs one named after itself, which is usually what was meant; a releaseFile application installs whatever its file declares, because one application there is several releases and cannot be any one of them.

Three claims are refused.

A release may not be named after the stack the controller itself runs as — the swarmcli-cd in docker stack deploy -c stack.yml swarmcli-cd. Deploying one would write that chart's services over the controller's own, and removing one would delete the controller along with the volume holding every application's clone and chart cache. The controller reads that name off its own service, so a development run that is not a swarm service has no name to protect and nothing to refuse.

Unless that is the point. An application marked self: true deploys the controller's own stack, and for it the collision is what an upgrade is — so the deploy is permitted and its release name is required to be that name rather than refused for being it. Only the deploy: removing the controller's stack stays refused whatever the app set says, because there is no correct way for a controller to delete itself.

And a release may not deploy into a namespace whose services this controller has no release record for. Those services were put there by something else — a docker stack deploy, another tool, a shell — and the namespace label they carry says where they live, not who deployed them. An existing stack is brought under GitOps by removing it and letting the controller install it, not by naming a release after it: the release is also the unit an uninstall and a prune act on, so sharing a namespace with a stack the controller did not install means sharing that too.

And two applications may not claim one release. A shared release name is a shared stack: each application deploys its own manifest over the other's on every interval, and each takes the owner stamp from the other as it goes — after which the one that stops declaring the release reads the other's live stack as its own orphan. The app-set file is refused at load when it can see the collision, which is when both are chart applications; a release file's releases are in a repository the loader has not fetched, so the second half of the rule is at reconcile time, where no application deletes a release, or anything inside one, that another application in the set still declares. It is held rather than deleted, and the log line says which two applications to look at.

This is the same ownership mechanism CE's charts apply uses, with one consequence worth stating plainly: when your release file is consumed by swarmcli-cd, its own owner: field is ignored — the controller substitutes its own stamp. Set the application's identity in applications.yaml, not in the release file. The full ownership model, including the orphan-versus-unmanaged distinction, is documented on the engine: swarmcli charts README § ownership.

Declaring a name is not owning it

Swarm secrets and configs are cluster-global and addressed by name, so any guard over them is a name comparison — and that is where the trap is.

A compose entry that is not marked external: can still name itself anything:

secrets:
  x:
    name: swarmcli-cd-token     # not this stack's to name
    file: ./whatever

A guard that only inspects what a manifest references sees an ordinary, stack-owned secret. The deploy then adopts the cluster-global name — which, for swarmcli-cd-token, is a token exfiltration (swarmcli-cd#86).

So a name-based guard must check the declared set, not only the referenced one. backend/backend.go's externalRefs / rejectForbiddenResources is the implementation, and it names that issue in its own comment. Any new guard over a cluster-global namespace inherits the same requirement.

Rollback comes from Swarm

When syncPolicy.wait is set, a release does not just get applied and forgotten: the controller waits for its services to converge. And when a service declares update_config.failure_action: rollback, a rollout that fails to converge is rolled back to its previous spec — not something swarmcli-cd builds, but Swarm's own failure_action and the PreviousSpec the platform keeps for every service. The applier uses what the platform gives away for free, which is also why it can diff, prune and roll back things docker stack deploy cannot.

What the controller does add is knowing that it happened. A rollback leaves the service running something the repository does not ask for, which under driftDetection: live is indistinguishable from somebody editing it by hand — and the two want opposite responses. So a service Swarm reverted is reported under its own reason and never redeployed, because the platform has already judged that spec. See configuration § a service Swarm rolled back.

Why the applier is not docker stack deploy

The obvious way to apply a compose file is to shell out to docker stack deploy. It cannot be the applier here, for reasons established with reproductions in issue #1: its --prune removes services only — never networks, configs or secrets — and swallows its own list errors; it has no dry-run, so nothing can be shown before it is applied; and --detach=true returns before convergence. The applier is built directly on docker/cli's exported loader and the moby client instead, which is what lets it diff, prune and roll back at all. The image carries no docker binary because it needs none.

Chart compatibility

A chart may declare the engine it needs (swarmcliVersion: ">= 1.13.0" in its Chart.yaml). The controller embeds one chart-engine version — whichever swarmcli release this build pinned — and refuses to apply a plan containing a release that engine is too old for, recording why on the application's status. Releases that would be unchanged are exempt, since applying will not touch them.

The refusal matters because there is no operator standing by to ask: the alternative is a failure minutes later inside the render, naming whatever feature happened to be missing. A build with an unstamped engine (a plain go build) reports every compatibility check as unknown rather than blocking — fine for development, not for anything that deploys. See RELEASING.md.

Read-only applications, and the two tiers

The applications file is the only source of truth, and the API serves it read-only. This is not an unfinished CRUD API: the way to change what the controller runs is to change that file, and the API's job is to show you the result. The paths are nouns (/applications/{app}) so that write operations can be added later without any of them moving.

Where the file lives is a deployment choice, and the two options behave differently on purpose. Mounted as a Docker config it cannot change under the process at all: configs are immutable, changing one replaces the container, and a file-watcher would never fire because the process that would notice does not outlive the change. Sourced from git — or from a directory something else keeps current — it is re-read on an interval, validated in full, and swapped in only if it is valid, so adding an application is a commit rather than a redeploy.

The controller's own bootstrap stays in the first category either way. That is what makes it an anchor: it says which repository is authoritative, and nothing in that repository can say otherwise. See configuration § where the app set lives.

The open-core seams

Four behaviours are pluggable through interfaces with working OSS defaults — swarm registry, authorizer, notifier, secret provider — replaced by a private companion, and a fifth seam through which that companion adds HTTP routes rather than replacing anything. The public build is a complete product; the seams just mark where the licensed edition swaps multi-swarm, SSO/RBAC, Slack notifications and SOPS decryption in, and where it adds the endpoints those need. The authorizer is the one that has been replaced so far — see single sign-on. This is developer-facing detail; it lives in extensibility.md.

See also