← swarm-exec.cloud-surfers.net

swarmexec_ docs

Operating the client — commands, flags, config & the TUI.

swarmexec gives you docker exec -it, logs, port-forwarding and volume management against any container in a Docker Swarm, from one terminal. A small per-node agent (a global Swarm service) does the work on its node; the client asks the Swarm manager which node runs your target, then connects straight to that node's agent over mTLS. This page documents the client.

1. How it works

Two binaries, one wire protocol:

operator terminal ──gRPC/mTLS──> agent(nodeN) ──docker.sock──> container └── Docker manager API (resolve node + container id)

The manager connection uses your Docker CLI context (so it honours --context, ssh:// bastions, mTLS, etc.). The agent connection is authenticated with mutual TLS, or with a shared secret against a self-signed agent — see Authentication.

2. Install

Requires Docker Engine 19.03 or newer (API 1.40) on the manager and on every node. swarmexec refuses an older daemon at connect with a message naming both versions, rather than connecting and then failing one view at a time. Two features want a little more from the node's daemon — live resource usage and the per-node image view need Docker 23.0 (API 1.41/1.42); below that they stay empty and say so instead of failing.

The client is a single static binary. Grab the latest release build for your platform:

$ curl -fsSLo swarmexec \ https://gitlab.logle.io/cs-public/swarm-remote-exec/-/releases/permalink/latest/downloads/bin/swarmexec-linux-amd64 $ chmod +x swarmexec && sudo mv swarmexec /usr/local/bin/ $ swarmexec --version

Builds: linux-amd64, linux-arm64, darwin-arm64, darwin-amd64, windows-amd64. The agent image is public on Docker Hub as logleio/swarmexec-agent.

3. Quick start

Point your Docker context at a Swarm manager, then provision the agents once. init creates a shared secret, deploys the agent on every node in self-signed mode, and writes a matching client config — so the other commands work immediately.

$ swarmexec --context my-swarm init # deploy agents on every node (once) $ swarmexec ps # list tasks and the node each runs on $ swarmexec exec web -- sh # shell into service "web", any node $ swarmexec logs -f web # follow a service's logs $ swarmexec port-forward db 5432 # localhost:5432 → container:5432 $ swarmexec ui # interactive TUI

Tear the agents down again with swarmexec down (it leaves your client config untouched).

4. Target selectors

exec, logs and port-forward take a target as their first argument. It is resolved in this order:

FormExampleMeaning
servicewebThe service's running task. If it has more than one replica, this is ambiguous — see below.
service.slotweb.2A specific replica slot. During a rolling update the newest task in the slot wins.
task-idxxh8k1…A Swarm task ID.
container-id3f9a2b…A container ID prefix. Needs a node: pass --node, or it is found by scanning running tasks.

Ambiguity. A bare service name with several replicas cannot be resolved to one task. exec prompts you to pick from a numbered list when run interactively; logs and port-forward never prompt — they print the candidate list and exit. Disambiguate with a slot (web.0, web.1, …).

--node is only consulted for container-id targets and may be a hostname, IP, or node ID. Service / slot / task targets are located via the manager API, so they never need it.

5. Configuration

Settings come from four layers, each overriding the one before it:

built-in defaults config file environment command-line flags

A flag only overrides when you actually pass it, so a value in the file or the environment survives unless explicitly overridden.

Config file

The default path is the first of these that is set:

  1. $SWARMEXEC_CONFIG
  2. $XDG_CONFIG_HOME/swarmexec/config.yaml
  3. ~/.config/swarmexec/config.yaml

Override it with --config <path>. The file is YAML; a missing file is fine, a malformed one is an error. It is written 0600 (it may hold the shared secret). The log file lives next to it by default — ~/.config/swarmexec/swarmexec.log — unless you set --log-file. Keys:

KeyTypeMeaning
castringCA cert that verifies the agent's server certificate (mTLS)
certstringclient certificate — its CN is your operator identity
keystringclient private key
portintagent port (default 9443)
addr_modestringhostname (default) or ip — how to dial a node
server_namestringoverride the TLS server name used to verify the agent
agent_secretstringshared secret for a self-signed agent
agent_secret_filestringread the secret from this file (takes precedence over agent_secret)
insecureboolskip agent server-cert verification (self-signed agents)
legacy_secretboolalso send the raw shared secret, for agents older than v1.17.3 — off by default; see below
operatorstringaudit identity when no client cert is used (default: OS username)
logs.formatstringdefault log format for logs and the TUI: classic | json | logfmt | gelf | raw (empty = classic)
logs.min_levelstringdefault minimum level: trace..fatal (omit for no level filter)
ui.dimfloathow far the backdrop behind an open overlay is faded, a fraction 01 (default 0.6; 0 = no dimming)

The logs: section sets defaults for format-aware log parsing and filtering; the logs command's --log-format / --min-level flags override them:

logs: format: json # classic | json | logfmt | gelf | raw min_level: warn # trace..fatal; omit for no level filter

Inspect the effective, merged configuration (secret masked) with:

$ swarmexec config show # or: --json

addr-mode

hostname (default) dials the node's reported hostname; ip dials its advertised address. Use ip when node hostnames aren't resolvable from your workstation — init writes addr_mode: ip into the generated config for exactly that reason. The swarm leader reports 0.0.0.0 for itself; the client recovers its real address from the raft peer list automatically.

Environment variables

VariableSets
SWARMEXEC_CONFIGconfig file path
SWARMEXEC_CA / _CERT / _KEYmTLS material
SWARMEXEC_PORTagent port
SWARMEXEC_ADDR_MODEhostname / ip
SWARMEXEC_SERVER_NAMETLS server name
SWARMEXEC_AGENT_SECRET / _FILEshared secret / secret file
SWARMEXEC_INSECUREskip verification (1/true/yes/on)
SWARMEXEC_OPERATORaudit identity
SWARMEXEC_UI_DIMoverlay backdrop dim (ui.dim)
SWARMEXEC_KEYSpath to the TUI keymap file (default keys.yaml next to the config)
SWARMEXEC_SSH_MULTIPLEX0/off/false/no switches ssh connection sharing off (see ssh connection sharing)
DOCKER_CONTEXTDocker context for the manager API
XDG_CONFIG_HOMEbase for the default config path

6. Authentication

The client authenticates to the agent in one of two modes.

Mode A — mutual TLS (default)

Set ca, cert and key (all three required). The client verifies the agent against your CA and presents its certificate; the agent authorises and audits you by the certificate's CN. This is the default and the recommended posture.

$ swarmexec --ca ca.crt --cert me.crt --key me.key ps

Mode B — self-signed agent + shared secret

Simpler to run (one secret, no PKI) — this is what init sets up. Set agent_secret (or agent_secret_file), and either a ca to verify the agent or insecure: true to skip verification. A client cert is optional (but cert and key must be set together or both empty). Your audit identity is the operator value (default: your OS username).

$ swarmexec --agent-secret "$SECRET" --insecure ps
insecure skips the agent's server-certificate check. It does not put the secret at risk: the client never sends it, only a proof computed from the secret and the certificate of the connection it travels on, so a server you did not verify receives nothing that works against a real agent. What remains is that the agent is not authenticated to you — whoever answers can read what that session sends them. Prefer a ca on any network you do not trust, and keep the secret out of your shell history (use agent_secret_file or the config file).

Upgrading to v1.17.3 — update the agents first

A v1.17.3 client cannot authenticate to an older agent. It sends only the connection-bound proof, and agents before v1.17.3 do not recognise that form — they answer invalid or missing agent secret even though your secret is correct. Update them first:
$ swarmexec init --force
While a fleet is mixed, legacy_secret: true in the client config also sends the raw secret, with the exposure described above. Remove it once the agents are current, and add -allow-legacy-secret=false to the agent so no client can put the credential on the wire by accident.

7. Docker context & SSH

--context selects the Docker CLI context used for the manager API. Resolution order: --context$DOCKER_CONTEXT$DOCKER_HOST → the active context in ~/.docker/config.json → the local socket unix:///var/run/docker.sock.

No local Docker required. The client never runs the docker binary — it links the Docker Go SDK and speaks the manager API directly, reading any context metadata from files itself. All it needs is a reachable Swarm manager endpoint (the node-resolution calls only work against a manager):
  • $DOCKER_HOST at a remote tcp:// (mTLS) manager — then there is no Docker installed on your workstation at all;
  • an ssh:// context — needs the ssh client (not docker); the manager and agent traffic both tunnel over it;
  • the local socket unix:///var/run/docker.sock — only the default fallback, and the one option that implies a local daemon.
The docker CLI itself is needed only to create named contexts (docker context create) — or create them with swarmexec context create <name> --docker-host …, so the docker CLI is no longer needed even for that; use $DOCKER_HOST to skip named contexts entirely. (init reads local docker login credentials only for a private agent image — not for the public default.)

SSH bastion. If the context host is an ssh:// endpoint, the manager API is tunnelled over SSH — and so is the agent connection: since the nodes' node:9443 endpoints usually aren't routable from your workstation, the client tunnels the agent's gRPC traffic over the same SSH host automatically. No extra flags needed.

$ docker context create swarm --docker host=ssh://ops@manager.example.com $ swarmexec --context swarm exec web -- sh

8. Global flags

These persistent flags apply to every command:

FlagDefaultDescription
--configconfig file path (default ~/.config/swarmexec/config.yaml)
--contextdocker context for the manager API; supports ssh:// (also $DOCKER_CONTEXT)
--port9443agent port
--addr-modehostnamenode dial address: hostname | ip
--caCA certificate to verify the agent (mTLS)
--certclient certificate (mTLS; CN is the operator identity)
--keyclient private key (mTLS)
--server-nameoverride TLS server name for agent verification
--agent-secretshared secret for a self-signed agent
--agent-secret-filefile to read the shared secret from
--insecurefalseskip agent server-certificate verification
--operatorOS usernameoperator identity reported for audit
--log-levelinfolog verbosity: debug | info | warn | error | off (off disables logging entirely)
--log-filelog file path (default swarmexec.log next to the config file)
--infoshow version, license, and contact info
--versionprint the client and protocol version
Logging. swarmexec logs in structured form (Go slog, text format) to the log file and an in-memory ring buffer. In the TUI, logs are never written to the terminal (that would corrupt the screen) — the ring buffer feeds a live in-app viewer instead (see The TUI). --log-level off disables logging altogether; a log-file that can't be opened is non-fatal (it falls back to the ring buffer only).

9. Commands

init — provision the agents

Deploys the agent as a global Swarm service via the manager API: creates a shared-secret Docker secret, runs the agent in self-signed mode on every node (host port 9443), and writes the matching client config. Run it once per swarm.

swarmexec init
FlagDefaultDescription
--imagedocker.io/logleio/swarmexec-agent:latestagent image to deploy
--secretrandomshared secret to use (default: generate a random one). Note: if the agent secret already exists it is kept as-is (Docker secrets are immutable), so --secret is not applied to the cluster — it is only written to your client config, and init warns about the mismatch risk. To change the cluster secret, remove it first (no service may reference it), then re-run init
--service-nameswarmexec_agentname for the agent service
--port9443host port the agent publishes
--forcefalseupdate the service if it already exists
--save-configtruewrite the client config
--registry-authtruepass local registry credentials so nodes can pull a private image
--waittruewait for the agents to come up and report progress
--rollout-timeout90show long to wait for agents to start
The default agent image is public on Docker Hub, so no registry login is needed. Re-run with --force to roll out a new agent version — see re-rolling the agents without changing the secret.

down — remove the agents

The inverse of init: removes the global agent service and, by default, the shared-secret Docker secret. It does not touch your client config.

swarmexec down
FlagDefaultDescription
--service-nameswarmexec_agentname of the agent service to remove
--keep-secretfalsedo not remove the shared-secret Docker secret
-y, --yesfalsedo not prompt for confirmation

doctor — diagnose the swarm

Checks the manager connection, whether the agent service is deployed, and probes every ready node's agent for reachability and version skew. Prints a per-node table (NODE  AGENT  VERSION  PROTO); exits non-zero if the manager is unreachable or any agent is unhealthy. A node reported as too old (init --force) runs an agent older than your client — see re-rolling the agents without changing the secret.

swarmexec doctor
FlagDefaultDescription
--connect-timeout10sper-node connect timeout
--jsonfalseoutput JSON instead of a table

security report — a Markdown report for the whole cluster

The security-risks overlay scans service specs and shows the result on screen. This writes the same scan — plus the checks that belong to the cluster rather than to any one service — as Markdown, so it can be reviewed away from the terminal, attached to a ticket, or committed next to your stack files and diffed release over release. It goes to stdout unless -o is given, so it pipes as readily as it saves.

swarmexec security report -o security.md

Four checks run at the cluster level, none of which a service spec can answer for:

CheckSeverityWhat it flags
network-unencrypted — “overlay traffic is not encrypted”mediuman overlay network carrying service traffic without data-plane encryption. Swarm tunnels container traffic between nodes over VXLAN in the clear unless the network was created with --opt encrypted, and nothing about the running service looks any different either way. Networks with nothing attached are not flagged — they carry nothing — and neither is ingress, which cannot be encrypted at all, so a finding on it could never be cleared
autolock-disabled — “managers are not autolocked”mediumthe managers' raft store is not encrypted at rest. It holds every secret, every config and the cluster CA key, and its key sits on the same disk — so a manager's disk yields the lot. Enable with docker swarm update --autolock=true and keep the unlock key: a restarted manager will ask for it. If the swarm configuration cannot be read, the report says autolock-unknown rather than guessing — assuming “off” would invent a finding and assuming “on” would be a false all-clear
agent-proto-mismatch / agent-version-skew / agent-too-old / agent-unreachablehigh / medium / lowthe agent is what enforces authorization on every exec, log and port-forward, so one older than your client may not enforce a rule the client assumes. A protocol mismatch is high and outranks a version difference: the two ends disagree about the contract itself, not merely about which build implements it. An agent that did not answer is reported as a gap, not as a pass. Skew is not reported for an unreleased (dev) client, which differs from every released agent by construction — the same exemption doctor makes
unused-secret / unused-configlowa secret or config no service references. It is still distributed through the raft store and still readable by anything that reaches a manager — usually a credential someone rotated and never removed, which means the old value is still live in the cluster long after everyone believes it is gone

What the report says about itself. It is read away from the terminal, by someone who was not there when it ran, so it carries its own context: which cluster, when, and with which build. It also has a Not covered section — unreachable agents, an unreadable swarm configuration, an empty node list — because silence in a security report reads as an all-clear for ground it never reached. The ordering depends only on the findings, so two reports of an unchanged cluster differ only in their timestamp and can be diffed against each other; that is worth having only because an absent finding means not found and never not looked at.

The file is written 0600, creating any parent directories 0700. It names every service, network and secret in the cluster together with every weakness found in it — that is a map of where to attack, and it has no business being readable by every account on the machine.

FlagDefaultDescription
-o, --outputstdoutwrite to this file instead of stdout
--connect-timeout10sper-node connect timeout when checking agents
--skip-agentsfalsedo not contact the node agents. Faster, and agent skew is then listed under Not covered rather than silently omitted

The same report can be written from the TUI: press w in the security-risks overlay. It is a fresh cluster-wide gather, not a dump of what the overlay is showing, and it runs off the ui goroutine so the interface stays responsive while it talks to every node.

stack export / stack diff — a deployed stack as a file

stack export reads a deployed stack back out of the cluster and writes it as compose-shaped YAML. stack diff compares a stack file against what is actually deployed and prints a unified diff: a + line is something deploying the file would add, a - is something it would remove. Like git diff --exit-code it exits 1 when anything differs, so it works in CI.

swarmexec stack ls swarmexec stack export postgres -o postgres.yml swarmexec stack diff postgres.yml postgres

In the TUI, E on the Stacks/Services tab offers both for the stack under the cursor — the stack row, a service in it, or a container of one.

Why it does not diff text. A deployed stack carries things the file never had: the image digest the daemon resolved at deploy time, its own defaults for restart and update policy, and the stack namespace prefixed onto every network, secret and volume. Comparing the two as text reports dozens of differences for a stack that is exactly in sync — which is worse than no tool at all, because it teaches you to ignore the output. Instead the file is run through docker's own compose loader and converter, the same code docker stack deploy uses, and both sides are then reduced by the same function. The question answered is “would deploying this file change anything?”, not “are these two files spelled the same?”. Daemon defaults are dropped from both sides; a value that is not the default still shows, so nothing real is hidden.

An export is a description, not a backup. A secret's value is write-only in the engine API — it can never be read back — and a volume's contents live on the nodes. Both are therefore declared external, and the exported file says so in its own header: recreating the stack elsewhere means creating the secrets first. Spec fields the rendering does not carry (tty, ulimits, seccomp and AppArmor settings and a few others) are named in the same place, because the dangerous failure of a comparison tool is not a wrong answer but a confident silence — so “no differences” always arrives with its caveats attached.

Variables are interpolated from your environment, exactly as docker stack deploy does. A file containing ${TAG} therefore describes a different stack for a different TAG, and the diff depends on the environment it runs in — which is correct: pretending otherwise would report no change for a deploy that would change the image.

CommandDescription
stack lsthe stacks deployed on the cluster
stack export <stack>write the stack as compose YAML; -o to a file, otherwise stdout
stack diff <file> [stack]compare a file against the deployed stack. The stack name defaults to the file's base name; --stack or a second argument overrides it. Exits 1 on any difference

stack deploy — apply a stack file, after checking it

Loads a stack file, runs the security checks over what it would actually deploy, and applies it. What makes this worth having over docker stack deploy is the gate: the file is checked before anything is created, and if something above informational is found the deploy stops and asks.

swarmexec stack deploy web.yml # asks if anything is found swarmexec stack deploy web.yml --check # only check, never deploy swarmexec stack deploy web.yml --force # deploy despite findings, deliberately

In the TUI, EDeploy a file does the same: the findings are shown first, d proceeds, Esc walks away. Nothing has been created at that point, which is the whole purpose of the pause.

The checks are not a second set of rules. The file is converted into the very ServiceSpec values the deploy would submit, and the existing eight analyzers run on those — the same checks that put a shield in the tree, fill the ! overlay and the security report. Two sets would drift: a rule tightened in one place and not the other means the gate passes a file that the tree flags the moment it is running, and an operator who was told “nothing found” was told something false. It also means the checks see what the cluster will do, not what the file says — compose's defaults and shorthands sit in between.

--yes does not mean “ignore the findings”. It means “do not ask”, and a run that finds something above informational still refuses and exits non-zero. Anything else would turn the gate into a formality the first time someone put it in CI. To deploy despite findings you have to say --force, which is a different thing to type and a different thing to explain afterwards. A non-interactive run with no answer on stdin counts as no.

FlagDescription
--checkrun the checks and stop. Exits 1 if anything above informational was found, so it gates a pipeline
-y, --yesdo not ask. Deploys when the checks are clean; refuses when they are not
--forcedeploy despite findings, without asking
--pruneremove services of the stack the file no longer declares. Off by default, as in docker: a file that is a subset of the stack is far more often an accident than an instruction to delete

What a deploy does, in order: networks, then secrets, then configs, then services — a service that references something not yet there fails, and the stack is left half-applied. External networks are checked first, because one that does not exist is the most common way a deploy stops halfway. An existing secret is never overwritten: a secret's value is immutable in swarm, so changing one means creating a new one under a new name. If a deploy does fail partway, what had already been applied is reported alongside the error rather than left to be guessed at.

Re-rolling the agents without changing the secret

Sooner or later you have to redeploy the agents on a swarm that is already provisioned: an agent hangs, doctor shows a node as too old (init --force), a command fails with “agent is older than this client (missing RPC) — update it with swarmexec init --force, or a node was rebuilt and its agent never came back. The fix is to re-run init with --force, which tells it to update the service if it already exists. Without the flag, running init against an existing service is a usage error telling you to add --force — nothing is changed.

$ swarmexec init --force

This redeploys the agent on every node and leaves the shared secret alone. Docker secrets are immutable, so init finds the existing swarmexec_agent_secret, reports it as reusing existing, and keeps the value that is already in the cluster. Every client that worked before the re-roll keeps working — there is nothing to redistribute.

Do not pass --secret on a re-roll. If the secret already exists it cannot be overwritten, so your value is not applied to the cluster — but it is still written into your client config. Unless it happens to be the existing secret's real value, the agents will reject you from that moment on, and the failure surfaces later as what looks like a broken agent rather than a wrong local config. init warns about exactly this when it happens; take the warning seriously. To genuinely change the cluster secret you must remove the secret first (no service may reference it) and re-run init.

--save-config defaults to true, so a re-roll also rewrites ~/.config/swarmexec/config.yaml. Pass --save-config=false whenever the local client config must stay untouched — for example when you re-roll the agents from a machine whose config is already correct, or from CI.

$ swarmexec init --force --save-config=false

Then verify. doctor reports a status per node; ok on every node means client, agent and secret line up again.

$ swarmexec doctor

ps — list tasks

Lists candidate tasks/containers and the node each runs on. Takes an optional service filter. Columns: SERVICE  SLOT  CONTAINER  NODE  IP  UPTIME. This talks only to the manager API — it works even before agents are reachable.

swarmexec ps [service]
FlagDefaultDescription
--jsonfalseoutput JSON instead of a table

exec — run a command / open a shell

Exec into a container running anywhere in the swarm. With no command it opens /bin/sh. A TTY is allocated automatically when stdin is a terminal and you gave no command; force it with -t. The remote command's own exit code is propagated verbatim.

swarmexec exec [flags] <target> [-- <cmd> [args...]]
FlagDefaultDescription
-i, --stdintruekeep stdin open
-t, --ttyautoallocate a TTY (auto: true iff stdin is a terminal and no command)
-u, --userusername or UID (e.g. 1000:1000)
-w, --workdirworking directory inside the container
-e, --envset environment variables (KEY=VALUE, repeatable)
--nodenode hint/override for container-id targets
--connect-timeout10stimeout for connecting to the agent
$ swarmexec exec web -- sh $ swarmexec exec web.2 -- cat /etc/hostname # a specific replica $ swarmexec exec -u root -w /app web -- ls -la $ swarmexec exec -e FOO=bar web -- env

logs — stream logs

Stream a container's logs from anywhere in the swarm.

swarmexec logs [flags] <target>
FlagDefaultDescription
-f, --followfalsekeep streaming new log lines
--tail0lines from the end to start with (0 = all)
-t, --timestampsfalseprefix each line with a timestamp
--since0only logs newer than this (e.g. 10m, 1h)
--log-formatclassicparse lines as classic | json | logfmt | gelf | raw (default from config, else classic)
--min-levelonly show this level and above: trace | debug | info | warn | error | fatal
--greponly show lines whose (parsed) message matches this Go regexp
--nodenode hint/override for container-id targets
--connect-timeout10stimeout for connecting to the agent

Format-aware parsing & filtering. --log-format tells the client how to read each line so it can pull out a level and a message: classic extracts a level from a plain text line, json parses logstash-style JSON (level/message fields), logfmt parses the key=value style used by many Go apps, the Docker daemon and HashiCorp tools (msg/message, level/lvl/severity, ts/time), gelf parses Graylog GELF JSON (numeric syslog level), and raw passes lines through unchanged. --min-level then drops anything below the chosen level, and --grep keeps only lines whose parsed message matches the regexp.

Lines with no detectable level always pass the --min-level filter, so multi-line stack traces aren't lost. The same defaults can be set once under the logs: config section (flags override).

Follow across container replacement. With -f on a service or service.slot target, logs keeps following when the container it is streaming is replaced by a rolling update, restart or reschedule: it re-resolves the service's current running container (the same slot, or the same node for a global service), reconnects automatically — like docker service logs -f — and prints a dim notice line (container replaced; reconnected to <id> on <node>). It waits up to ~30s for a replacement to schedule before giving up, and stops cleanly if the service is removed. A bare container-id target has no successor, so it simply stops as before.

Detection lives in the client: it learns topology from the swarm manager it is already connected to — the per-node agents are local to their node and cannot see swarm-level replacements.
$ swarmexec logs -f --tail 100 web $ swarmexec logs --since 15m -t web $ swarmexec logs --log-format json --min-level warn web $ swarmexec logs --grep 'timeout|refused' -f web
Forward-looking: the format selection is designed to later support automatic per-service format detection via an online service (not implemented yet).

port-forward (alias pf) — forward a local port

Bind a local TCP port and forward it to a port inside a container, without publishing that port on the cluster. The local port defaults to the remote one. Targeting a service forwards to exactly one of its tasks (the one the target resolves to), not across replicas. Bound to 127.0.0.1 by default. Press Ctrl-C to stop.

swarmexec port-forward [flags] <target> [local:]remote
FlagDefaultDescription
--address127.0.0.1local address to bind (loopback keeps the port off your network)
--nodenode hint/override for container-id targets
--connect-timeout10stimeout for connecting to the agent
$ swarmexec port-forward db 5432 # localhost:5432 → container:5432 $ swarmexec pf web 9090:8080 # localhost:9090 → container:8080

volume ls — list volumes

Lists volumes across all nodes and which nodes hold each. Swarm volumes are node-local, so the client queries every node and aggregates. Optional substring name filter. Columns: VOLUME  DRIVER  NODES  USED BY  AGE (plus SIZE with --size).

swarmexec volume ls [name-filter]
FlagDefaultDescription
--sizefalsealso compute each volume's on-disk size (slower: du per volume)
--sortnamesort by: name | nodes | used | age | size (size implies --size)
--reversefalsereverse the sort direction
--connect-timeout10sper-node connect timeout
--jsonfalseoutput JSON instead of a table

volume rm — remove a volume

Remove a volume on every node that holds it (--all) or on specific nodes (--node, repeatable). One of the two is required.

swarmexec volume rm <name> (--all | --node N …)
FlagDefaultDescription
--allfalseremove on every node that holds the volume
--noderemove only on these nodes (repeatable)
--forcefalsepass docker's force flag
-y, --yesfalsedo not prompt for confirmation
--connect-timeout10sper-node connect timeout

ui — interactive TUI

An interactive view of containers and volumes, with exec, logs and port-forwarding built in. Needs an interactive terminal. See The TUI for keys.

swarmexec ui [service]
FlagDefaultDescription
--connect-timeout10stimeout for connecting to an agent

config show — inspect config

Prints the effective, merged client configuration with the secret masked.

swarmexec config show [--json]

context (alias ctx) — manage Docker contexts

Create and manage the Docker contexts that --context (and $DOCKER_CONTEXT) resolve for the manager API. swarmexec writes docker's own on-disk store, so contexts made here are interchangeable with the docker CLI — and you no longer need docker installed even to create one. The built-in default context cannot be removed.

context create <name> — create a context pointing at a manager host (takes exactly one name argument):

swarmexec context create <name> --docker-host <endpoint>
FlagDefaultDescription
--docker-hostrequired — docker daemon endpoint: ssh:// | tcp:// | unix:// | npipe://
--descriptionoptional description
--ssh-jumpssh jump host(s) for an ssh:// context, comma-separated (multi-hop ProxyJump / -J); injected into both the Docker-API and the agent-tunnel connections
--usefalsealso make it the current context

context ls (alias list) — list contexts; columns NAME  CURRENT  DOCKER ENDPOINT (the active one marked *):

swarmexec context ls [--json]

context use <name> — set the current context:

swarmexec context use <name>

context rm <name> [name...] (alias remove) — remove one or more contexts:

swarmexec context rm <name> [name...]
FlagDefaultDescription
-f, --forcefalserequired to remove the current context (its selection resets to default)
$ swarmexec context create prod --docker-host ssh://ops@manager --use $ swarmexec context ls $ swarmexec --context prod ps

ssh connection sharing

With an ssh:// context, swarmexec reaches the cluster over ssh twice over: the Docker manager API through ssh's connection helper, and every node agent through its own tunnel to the same host. Each exec, log stream, port-forward, stats poll and refresh cycle used to open a fresh ssh connection — a TCP handshake, a key exchange and an authentication per dial, and again per jump host — against a bastion that was connected a moment earlier.

swarmexec now lets them share one transport, using OpenSSH's own ControlMaster: the first connection to a destination opens it, and every later one becomes a channel on it. Measured on a three-node cluster behind a jump host, swarmexec doctor went from 8 authentications and 3.6 s to 2 and 1.0 s. Nothing about the traffic itself changes, and the effect grows with how much you do in one session.

10. The TUI

swarmexec ui has seven tabs — Stacks/Services (1), Volumes (2), Forwards (3), Networks (4), Secrets (5), Nodes (6), Configs (7) — a context sidebar down the right-hand side, and a two-line footer: the per-tab key hints on top, then a status line with the active docker context (ctx <name>, so it's always clear which cluster you're on), a live cluster summary, the forward count and — on the Volumes tab — how many volumes you have selected. These keys work on every tab:

Changed: the contexts used to be tab 6, and are now the sidebar (c). Nodes and Configs moved up to 6 and 7.

The tab bar is responsive: on a narrow terminal it switches to short labels — St/Sv, Vol, Fwd, Net, Sec, Node, Cfg — so all seven tabs stay visible instead of the last ones being clipped. (Cfg is Configs.) The shortcut digits and the mouse hit-boxes are unaffected.

KeyAction
?open the keybindings overlay — the complete, live key reference (it is generated from your keymap, so remapped keys show correctly); the one-line footer only has room for the most used keys
Tabcycle to the next tab
18jump to Stacks/Services / Volumes / Forwards / Networks / Secrets / Contexts / Nodes / Configs
j kmove down / up (also )
rrefresh the active tab and the cluster summary
ycopy the current list to the clipboard (OSC52)
mtoggle mouse capture (off = your terminal's own select/copy)
`open / close the live log viewer (see below)
qquit

Stacks/Services tab

Tab 1, formerly labelled Containers. It is one tree, stack → service → container, and it is where exec, logs, port-forwarding, inspect and the service editors live.

:latest version check. Swarm pins :latest to a digest at deploy time, so a service tagged :latest actually runs a fixed image. swarmexec resolves that against the registry (using your local docker credentials) and annotates the service row right after the image URI: the real version in parentheses — read from the image's org.opencontainers.image.version label — and an up-arrow ↑ when the registry's current :latest is a newer digest than the one the service is pinned to (e.g. nginx:latest (1.4.0) ↑). Best-effort and cached: registry errors just leave the row unannotated. The inspect overlay's IMAGE section shows the same version and, when a newer image exists, a selectable newer version available row — move onto it and press u (or Enter) to update the service.

Setting a version with u — not only when an update exists. u is not gated on an upgrade being offered. It is available on any service inspect whose image tags swarmexec could read from the registry — version-pinned and :latest services alike — and from any row of the overlay, so you never have to hunt for the hint. Setting the same version again, pinning what is currently running, or going back to an older tag are all normal uses; you don't have to wait for an upgrade to exist. The footer wording tells you which situation you are in: u update version when a newer version was found (the newer version available row is there too, carrying a concrete target), and u set version when it wasn't.

What u opens depends on what the registry knows. On a version-pinned service with a newer version it is the version picker: an input whose autocomplete suggests the newer same-family tags, highest first and prefilled with the highest. On one that is already on the newest tag it is the same picker, only the suggestions fall back to every tag the repo lists (prefilled with the running tag) — that is how you pin or roll back. A service on :latest gets the picker too, with all known tags: choosing a concrete version there is how you pin a :latest service off the floating tag, which is exactly the fix the unpinned-image risk finding (see the security-risks overlay below) asks for. The one exception is a :latest service with a digest update pending — that stays the single confirm on the registry's new digest it has always been, because a picker there would invite typing latest, which resolves to the bare tag and would silently drop the very digest pinning that the update exists to refresh.

In the picker the suggestion list is capped at 25 entries (a busy repo can list hundreds) and the field stays free-text, so you can type any existing tag — including an older one to pin or roll back to a known-good release. A typed tag is validated against the repo (a tag it doesn't list is rejected), and choosing an older one shows a downgrade warning before it applies. In every case the change is one ServiceUpdate / rolling update, behind a confirm. If the registry returned no tags at all and there is no concrete target to fall back on — a private or unreachable registry, or a service pinned by digest — you get an explanatory notice and nothing is changed.

KeyAction
/open the search bar (filters by service / container / node)
h lfold / unfold, walking all three levels. h folds the row under the cursor — a stack folds the whole group, an expanded service folds its containers — and where there is nothing left to fold it steps out to the parent instead, so repeated presses walk up container → service → stack. l expands the row under the cursor, or descends to its first child when it is already open
Enteron a container: open the action menu; on a service or a stack: expand / collapse it
stoggle stack grouping — grouped tree ⟷ flat service list (see below)
Llogs (capital L) — on a container its own logs, on a service the aggregated logs of all its tasks
!open the security-risks overlay for the whole service list (see below)
pport-forward the task under the cursor
iinspect the node under the cursor — a navigable overlay with a tabular summary; move the selection with / or j/k, y/Enter copies the selected line, a tab strip at the top names the three views and 1/2/3 select table / stats / raw daemon JSON directly while t cycles them (Esc/q/i closes). On a service the overlay also edits it: s scale, f force-update, p ports, l labels, e env, n networks, S secrets, v mounts, A aliases; D diagnoses why it isn't running everywhere; X removes it
Xremove a service straight from the tree (capital X, i.e. Shift+x) — no detour through the inspect overlay. What it acts on follows the row under the cursor: on a service row it removes that service; on a container row it removes that container's owning service, because a single task cannot be removed on its own — Swarm would immediately reschedule it; on a stack row it removes nothing and instead notes briefly in the footer that a stack's services have to be removed individually, or with docker stack rm <stack>. It goes through the same confirm as the inspect X below — it permanently deletes the service and stops all its tasks, cannot be undone, and afterwards offers to delete any now-orphaned secrets. A fixed capital key, not a remappable keymap action; the footer lists it in red as X remove and the ? overlay lists it too. The X inside the inspect overlay is unchanged

Every service is shown (even one scaled to zero), each row rendered like a docker service ls line — name, mode, running/desired count, image and published ports — and coloured by how it is actually doing: the running/desired count sets the base colour (aqua = all tasks up, orange = partial, red = down, grey = scaled to zero), and a failing healthcheck overrides it (see below). A / marker shows whether a service is collapsed or expanded; its running containers nest underneath. A service that is mid rolling-update carries a coloured badge on its tree row — ⟳ updating or ↺ rolling back — and the same status appears in the service inspect overlay; it clears once the update completes.

Coloured by healthcheck, not only by replica count. A service row's colour used to come from running vs desired alone — so a service whose every container was failing its healthcheck still rendered as a calm aqua 3/3. The count was true and the row was misleading. Swarm's task state does not help here either: a task reads running while its container fails every probe, so the verdict has to come from the node. A failing probe is now treated as degradation of the same kind as a missing replica:

The markers. A service row carries ✖ N unhealthy in red, or ◌ N starting in yellow while the probes have not passed yet; a container leaf carries ✖ unhealthy / ◌ starting. Unhealthy outranks starting — it is the one that needs acting on. A stack row rolls the same marker up over everything inside it (see Grouped by stack below). A healthy container gets no marker, and neither does one without a healthcheck, so the marker keeps meaning something. These sit alongside the cpu/mem resource markers below, and health comes first in the row: it is the thing contradicting the count standing next to it.

Where the verdict comes from — and what it costs. Nothing extra: it rides along with the resource readings below, same agent, same call, no additional API request. The container list the agent already fetches on each sampling pass carries the verdict in its status line (Up 3 days (healthy)) — the same string docker ps prints. The parser is deliberately strict: anything it does not recognise becomes no verdict rather than a guess, so if the daemon ever rewords that line swarmexec stops claiming to know instead of reporting a failing container as healthy. Same precondition as the usage numbers, too — it needs agents from this release or newer, so a cluster that has not been re-rolled shows no health markers at all until you roll them out with swarmexec init --force.

Live CPU & memory markers. Next to those badges a row can also carry what it is actually using right now — until now swarmexec could only show what the scheduler had booked (see the Nodes tab below). A container or a service whose usage crosses 70% gets an orange marker and 90% a red one, at the end of the row: the resource named in wordscpu or mem — followed by the percentage (cpu 94%, mem 91%). Both appear when both are hot. Words rather than symbols, and at the same width: a marker whose meaning has to be looked up is not doing its job, and letters cannot fail to render in a terminal. Below 70% nothing is drawn at all, so the markers stay a signal rather than wallpaper. A service row takes the worst of its replicas, not an average — an average hides the one container that is about to die, which is exactly the one worth seeing. (Absolute memory on a service row is the sum across replicas; the percentage is the peak.)

What the percentages are of. Each one is measured against what that container may actually use: its own limit when it has one, the node's capacity when it does not. That distinction is the whole point — 91% of a 256 MB limit means an OOM kill is close, 91% of a 64 GB node is a different conversation. The inspect overlay's stats view prints both sides of that division per container and names the basis under the table.

Where the numbers come from — and why they arrive late. Usage is not in the manager API: container stats are per node. They come from a node-local agent RPC (Stats); each agent samples its own containers in the background and answers from memory, so the client just polls it on the refresh cycle it already has. Two consequences worth knowing. A CPU percentage is a delta between two readings, so it does not exist until the agent has taken two (a few seconds) — while it isn't ready the views show , never 0%; memory needs no delta and appears from the first reading. And an agent only samples while someone is actually looking: it stops about a minute after the last request, and forgets its readings rather than serving stale ones. So the very first numbers after you open the UI take a moment to fill in. That is deliberate — an agent nobody is watching should cost nothing.
No markers anywhere? Check the agent version. Usage is supplementary, and it degrades quietly: a node whose agent is unreachable — or older than this release and therefore unaware of the Stats RPC — simply contributes no readings. The markers stay off, the node block stays absent, and everything else is untouched; the client also backs off from such a node rather than probing it every refresh. On a cluster that has not been re-rolled yet there are therefore no usage numbers and no health markers at all until the agents are updated — roll them out with swarmexec init --force. This is by far the most likely reason you see nothing.

Grouped by stack. docker stack deploy labels every service it creates with com.docker.stack.namespace. swarmexec reads that label off the service spec the manager already returned — Swarm has no stack object, and this is the only link — and nests the tree three levels deep: stack → service → container. A stack row shows the stack name, how many services are under it and the summed running/desired task count ((3 svc · 7/8)), coloured by exactly the same rule as a service row — the summed running/desired count, with the same healthcheck override on top of it. Roll-up counters follow it when they are non-zero: ⟳ n — services in that stack currently mid rolling-update🛡 n — services with an actionable security finding, the same shield the service rows carry — and then the health marker, ✖ N unhealthy or ◌ N starting, summed over every container of every service in the stack. So a folded stack still tells you whether anything inside it needs attention. Health rolling up matters most here: the stack row is the level an operator scans first, so a misleading “everything is up” is worse there than one tier down, not better.

Services created with docker service create carry no stack label; they are collected under (no stack), which always sorts last so it never pushes real stacks down the list. Nothing is hidden — every service still appears exactly once. Stacks sort by name and start expanded, so the grouped tree shows the same services the flat one did; folds you make survive the auto-refresh.

Grouping is on by default but only takes effect when at least one service in the cluster actually carries a stack label — on a cluster without stacks the tree looks exactly as it always did, with no pointless (no stack) parent. Press s to switch between the grouped tree and the flat service list (the footer lists it as s stacks); with no stack labels anywhere it says so rather than redrawing an identical tree. The key is remappable as stack_group in keys.yaml.

Searching (/) is unchanged: the filter still matches service, container and node. A stack the filter empties disappears from the tree, and each stack row's counts describe what actually ended up under it — so the numbers follow the filter instead of advertising services you filtered away.

The action menu offers Logs, Bash, Sh, Shell as user… (prompts for a user/UID, like docker exec -u, for images whose default user lacks the tools or permissions you need) and Port forward (unavailable shells are greyed out after a probe). In the search bar, Enter keeps the filter and returns to the list; Esc clears the filter and closes the bar.

Security risks (!). Every time the service list is fetched, swarmexec runs a set of small static analyzers over each service's spec — the same spec the manager already returned, so there is no extra API call, no agent involvement and nothing is executed inside your containers. A service with a finding worth acting on is marked with a leading 🛡 in the service tree, in a fixed-width slot ahead of the name, so the shields line up as a vertical scan column. Press ! to open the security-risks overlay, and w inside it to write a cluster-wide Markdown report — which covers more than the overlay does, since it adds the checks that belong to the cluster rather than to a service.

The eight checks that ship today. The ones that can mark a service come first; the purely informational ones are grouped at the end:

CheckSeverityWhat it flags
docker-socket — “Docker socket mounted in”highthe most serious finding here. A bind mount whose source is the Docker daemon socket — /var/run/docker.sock, /run/docker.sock or any source path ending in /docker.sock. Anything in that container can talk to the socket, and anything that can talk to the socket can start a privileged container on that node — so it is effectively root on the host, whatever the container itself runs as. Read-only does not mitigate it: the socket is an API, not a file whose contents matter — the finding says so itself when the mount is read-only. It names the target path the socket is mounted at
added-capability — “capability NAME added”high / mediuma Linux capability the service adds to its container. Swarm has no --privileged, so capabilities are how a service asks for extra host power — which makes the added set worth reading. Eight are high, each reported with the reason it carries: ALL (grants every capability), SYS_ADMIN (near-root: mount, namespace and cgroup control), SYS_MODULE (can load kernel modules), SYS_PTRACE (can inspect and control other processes), SYS_RAWIO (raw I/O access to devices), DAC_READ_SEARCH (bypasses file read permission checks), NET_ADMIN (full control of the node's networking), NET_RAW (can forge and sniff raw packets). Any other added capability is medium — a privilege beyond the default set. One finding per added capability; both spellings are recognised, in any case (CAP_SYS_ADMIN and SYS_ADMIN)
host-network — “runs on the host network”highthe service is attached to the network named host: the container shares the node's network stack, so there is no network isolation and swarm's published-port mapping no longer applies. It can reach anything the node can — including services bound to localhost, which are usually assumed to be out of a container's reach
unconfined — “seccomp disabled” / “AppArmor disabled”highthe container's kernel-level sandbox has been switched off: seccomp set to unconfined — the container may make any syscall, which removes the main barrier to kernel exploits — or AppArmor confinement disabled. The two are reported separately, so a service that turns off both gets two findings
root-user — “runs as root”highthe spec pins the container to root explicitlyUser=root or uid 0 (numeric forms like 00 or +0 are caught too; only the user part of user:group is looked at)
secret-in-env — “secret in environment variable”higha credential-looking env key holds a literal value in the service spec, readable by anyone who can read the spec. Use a Docker secret or the *_FILE convention instead
root-user — “no user set”lowno user is set at all, so the container runs as the image's own default user, which is often root. Informational only, and it does not mark the service: an unset user is the Swarm default on nearly every service, and whether it really is root depends on the image's USER, which the manager spec doesn't reveal
no-resource-limits — “no resource limits”lowthe task sets neither a CPU nor a memory limit, so a runaway container can consume the whole node and starve everything else on it. Informational only — see below
unpinned-image — “image not pinned”lowthe image is pinned to :latest, or carries no tag at all (which resolves to :latest) — the running version can change without a spec change, so a deploy is not reproducible. A digest-pinned image (…@sha256:…) is exactly reproducible and is never flagged, and a colon that belongs to a registry host's port (registry:5000/img) is not mistaken for a tag. Informational only — see below

Why the informational checks never mark a service. Only a finding above low — high or medium — makes a service actionable and puts the 🛡 on its tree row. The low ones (no-resource-limits, unpinned-image and root-user's “no user set”) are true of almost every service in a real cluster: badging them would put a shield on nearly every row and destroy the at-a-glance signal the marker exists for. They are not swept under the rug, though — once a service is flagged for something else, its low findings are listed with the rest in the overlay, so you see them where they are actually worth reading.

No secret value is ever displayed. Of the eight checks only secret-in-env looks at environment variables at all, and its finding names only the env var's key; the value is never read into the finding, never rendered and never copied. The check is also written to stay quiet: it matches whole _-delimited tokens (PASSWORD, PASSWD, PASS, PASSPHRASE, SECRET, TOKEN, APIKEY, CREDENTIAL(S), PRIVATEKEY, and the pairs API_KEY, ACCESS_KEY, PRIVATE_KEY, SECRET_KEY, CLIENT_SECRET, AUTH_TOKEN), so COMPASS, PASSENGER_PORT or BYPASS_AUTH are not reported. Keys that merely reference a credential are exempt (_FILE, _PATH, _URL, _URI, _NAME, _ID, _TYPE, _ENABLED, _REQUIRED, _LENGTH, _TIMEOUT, _TTL, _EXPIRY, _ALGORITHM), and so are values that plainly aren't a credential — an absolute path, a boolean, a number.

The overlay lists every flagged service (“N of M service(s) flagged · 8 checks”), grouped by service, each finding on its own line with a severity dot — ● high, ● medium, ● low — a short title and a one-line explanation. Findings are ordered worst-first. Once a service is flagged for something actionable, its informational low findings are listed too. If the service under the cursor is among them it is pre-highlighted and scrolled into view. When nothing is flagged the overlay says so explicitly and names every check that ran (“Checked: Docker socket mounted in, added Linux capabilities, host network, seccomp / AppArmor disabled, container user, secrets in environment variables, missing resource limits, unpinned image.”), so an all-clear tells you what it actually covers; when the service list hasn't loaded yet it says nothing has been scanned rather than giving a false all-clear. j/k scroll, Esc (or q, or ! again) closes.

The scan is an extensible registry of analyzers, not a fixed list: a new check surfaces in both the tree marker and the overlay automatically. The overlay's own wording comes from that registry too — the check count in its header and the “Checked:” list are generated from the analyzers themselves, so they cannot drift from what actually ran the way a hardcoded list does. The key is remappable as security_risks in keys.yaml.

i opens an inspect overlay for the node under the cursor — on a service it shows docker service inspect, and on a container leaf the swarm task inspect (the manager's view of that instance: state, slot, node, container id, container spec, resources and status history). Both come from the swarm manager. The overlay opens on a tabular, operator-first summary with sections ordered by operational relevance — networks, labels, volumes/mounts and secrets (and configs) first, then ports, image, mode, env, resources, placement and update policy (plus state, node and container id for a container/task), with ids and timestamps last. The overlay's first line, inside the border and pinned above the scrolling content, is a tab strip naming its three views — table 1   stats 2   raw json 3 — with the current one in the accent colour; it is the same shape as the main window's tab bar, a label followed by the digit that selects it. 1, 2 and 3 jump straight to table, stats and raw daemon JSON, and t still cycles table → stats → raw json and back. The footer hint is simply 1-3/t view, and the border title no longer repeats the current view's name — it just reads inspect service foo, because the strip already says where you are. Note this tabular view is the manager's task view, not a full node-local docker container inspect of the running container — a real container inspect via the agent is planned.

The stats view. The middle of the three is live resource usage — the same readings that mark the tree, so opening it costs no extra call and the numbers keep refreshing while it is open. It is a table, one row per container of the service (or the single container of a task inspect):

RESOURCE USAGE measured on each node, refreshed with the tree CONTAINER NODE HEALTH CPU MEMORY gl_gitlab.1 docker3v2 healthy 0.07 / 4.00 cores (2%) 6.823GiB / 8GiB (85%) limits are the container's own where it has one, otherwise the node's capacity

A table, because a bare percentage is not readable if you do not already know how the tool measures — 94% of what? Every cell carries its own units, so 0.07 / 4.00 cores needs no legend and the percentage sits next to the figure it is a percentage of; and several replicas are compared by scanning a column rather than by comparing stacked blocks. The HEALTH column words the verdict — healthy, unhealthy, starting, or none for a container that declares no healthcheck. none and healthy are deliberately different words rather than a word and a blank: no healthcheck configured and the probe passes are different facts, and a blank would read as the second. A container the agents did not report — unreachable node, an agent too old for the Stats RPC, or one started since the last reading — shows no reading in both resource columns rather than a zero, which would read as idle.

The footnote under the table names the denominator: the container's own limit where it has one, otherwise the node's capacity. That is the thing that makes a percentage mean something — 85% of an 8 GiB limit and 85% of a 64 GiB node are different conversations. Memory is shown in binary units (MiB/GiB), the same units the node detail uses and the same ones docker itself reports, so the same figure never reads differently in two places. With more than one measured container the view adds a roll-up under the table: CPU and memory of the worst replica — not an average, which hides the one container about to be OOM-killed — plus the total memory across them. The ? help overlay's t entry reads cycle table / stats / raw JSON accordingly.

The overlay is navigable and selectable, not just a plain scroll: data lines are individually selectable and you move the cursor line by line with / or j/k (section headers and blank lines are skipped). y always copies the selected line to your clipboard over OSC52 — the same yank mechanism used elsewhere — so you can grab a single id, mount or address without selecting text by hand. Enter also copies, except on a collapsible NETWORKS row, where it instead expands/collapses that row (see below). All three views (the tabular summary, the stats view and the raw JSON, reached with 13 or cycled with t) are selectable and copyable line by line. A footer always lists the available keys, so what you can do is visible at a glance; for a service the footer also shows the edit keys below.

KeyAction
j kmove the selection between data lines (headers/blanks skipped)
ycopy the selected line to the clipboard (OSC52) — always
Enteron a collapsible NETWORKS row, expand/collapse that level — the rows nest two deep, a network and its N containers drill-down; on any other line, copy it (OSC52)
1 2 3select a view directly — 1 tabular summary, 2 stats (live CPU / memory, see below), 3 raw daemon JSON; these are the digits the tab strip shows
tcycle the three views — tabular summary → stats (live CPU / memory, see below) → raw daemon JSON → back; all three selectable, and the tab strip at the top marks the one you are in
aactions menu — every service editor and action in one list, no Shift needed (service only; see below)
s f p l e n S v Aedit the service — scale / force-update / ports / labels / env / networks / secrets / mounts / aliases (service only; S is capital, Shift+s, to distinguish it from s scale; A is capital too; see below)
Esc q iclose the overlay

The NETWORKS section is collapsible, and it answers “which address is this reached at” before you expand anything: each attached network shows as a collapsed row + <network> 🔒 vip 10.0.5.2/24 (2 dns names). A 🔒 lock icon after the network name marks an encrypted overlay network (data-plane encryption, --opt encrypted); its absence means unencrypted. On a service inspect the address is labelled vip — the service's virtual IP on that network, the address the swarm load balancer answers on, taken from the manager's ServiceInspect (Endpoint.VirtualIPs). On a container/task inspect it is labelled addr: that container's own address there. A service in dnsrr endpoint mode has no VIP at all — that is by design, not missing data — so instead of a blank the expanded network carries the line no vip — dnsrr endpoint mode, the DNS name resolves to the containers. The ingress network is listed here too, even though no spec ever mentions it — swarm attaches the service to it by itself (see below). It is appended after the networks the spec declares, never interleaved with them, and its header reads + ingress vip 10.0.0.250/24 (routing mesh): the (routing mesh) note stands in for a dns-name count that could only ever say (0 dns names).

Move onto a network row and press Enter to expand or collapse it. Expanded, it lists the DNS names that resolve to the service/container on that network — the service name, tasks.<service> and any custom aliases — so you can see under which DNS name it is reached and which aliases it carries, per network. Below them sits a second collapsible row, + 2 containers (singularised, + 1 container), and Enter on that opens the deepest level: one column-aligned row per container, web.1 10.0.1.5/24 host-a — task name, its address on this network, and the node it runs on (replicated tasks are named <service>.<slot>, global ones <service>.<node>, having no slot).

What is listed is filtered on the task's desired state, not its current one — every task the manager still wants running appears, including ones that are only preparing, assigned or starting: they already hold their address, and during a rolling update that is most of them, so filtering on the current state would empty the drill-down exactly when it is most interesting. A task the manager has given up on — desired state shutdown, i.e. replaced by a rolling update, scaled away or failed — is not listed: its address has been released and may already belong to a different container, so showing it would be actively wrong. A task that is not serving traffic yet carries its state in brackets at the end of its row, web.1 10.0.1.5/24 host-a (starting); a running one has no suffix. The rows come from a manager-API TaskList filtered to the service and are best-effort — an API error just leaves the drill-down empty. The two levels collapse independently; collapsing the network hides the container level with it.

The ingress row is the one that is in no spec. Swarm joins a service to the ingress network on its own as soon as the service publishes a port in ingress mode, and that attachment appears nowhere in the service spec — yet its vip is the address the routing mesh actually answers on, which is the first thing you want when a published port misbehaves. So it gets a row of its own, appended after the networks the spec declares and shaped slightly differently. No service DNS name resolves on ingress, so where another network counts its names this header carries the note (routing mesh); expanded, the row lists instead the ingress-published ports that put the service there — the reason the row exists at all — one line each, published 2222 -> 22/tcp. Ports published in host mode bypass the routing mesh and are deliberately not listed there. Below them it drills down to the containers exactly like every other network row: the same + 1 container level, with each container's own address on the ingress network. A service that publishes no ingress port has no ingress VIP — and therefore no such row at all.

A GitLab service publishing SSH on 2222, both levels expanded:

- dbnet vip 10.0.1.137/24 (2 dns names) gl_gitlab tasks.gl_gitlab - 1 container gl_gitlab.1 10.0.1.54/24 docker3v2 - ingress vip 10.0.0.250/24 (routing mesh) published 2222 -> 22/tcp - 1 container gl_gitlab.1 10.0.0.64/24 docker3v2

Copying inside NETWORKS yields the address without its mask10.0.5.2, the form you paste into a curl or a ping: on a network row its vip/addr, on a container row that container's address. A network row that has no address (dnsrr) copies the network name, as before. On both kinds of collapsible row Enter toggles; on every other line it copies (and y always copies).

When you inspect a service (not a container/task leaf, whose overlay stays read-only), the overlay footer also exposes several edit keys, each a manager-API ServiceUpdate that triggers a rolling update / reconciliation of the service's tasks.

Start with a — the actions menu. On a service inspect (service only), a opens a single list of every editor and action the overlay offers, so you don't have to remember ~14 case-sensitive keys (d/D, s/f, p/l/e, n/S/v, r/P, R, X). It is the discoverable, no-Shift path — and not a reduced one: choosing an entry closes the menu and opens exactly what the direct key opens, so the two are equivalent, not variants with different behaviour. The entries, in order: Update image version… (Set image version… when no newer one was found — pinning or rolling back to a tag of your choosing is just as valid), Diff spec (previous → current), Roll back to the previous version, Why — placement diagnosis, Scale, Force-update, Edit ports, Edit labels, Edit env, Edit networks, Edit secrets, Edit mounts, Edit resources, Edit placement, Remove service — the destructive one, marked in red — and Cancel. Move with j/k (g/G jump to the first/last entry), Enter selects; Esc, or the Cancel entry, closes the menu without doing anything. The image-version picker is first, because setting a version is the most ordinary service change there is; its direct key u still works from any row of the inspect. When the image is one no version can be chosen for — an untagged reference, or a registry that will not list the repo's tags, typically a private one the client has no credentials for — the entry stays in the list and says so rather than quietly disappearing, and points at the docker service update --image that does work. Each entry's direct key:

KeyAction
ddiff — shows a unified diff of the service's current spec against its previous one (what the last rolling update changed), using Swarm's PreviousSpec. Changed fields are listed grouped by field with - removed / + added lines (image, mode/replicas, env, labels, networks, aliases, secrets, mounts, ports, placement constraints, spread preferences, resource limits/reservations); unchanged fields are omitted. If the service has never been updated (no previous spec) it says so. Read-only overlay — j/k scroll, Esc closes
Rroll back to the previous version (capital R, i.e. Shift+r, to distinguish it from r edit resources) — the counterpart of the d diff above: it undoes the last rolling update. The confirm dialog shows that same diff reversed under “This will undo:”, because that is what the rollback will actually change — a line the update added is listed as - removed, and one it removed comes back as + added. The preview is capped at 12 entries, followed by an “… and N more” note pointing back at d for the full diff (if only metadata changed it says there are no field-level differences). Confirming does a server-side rollback — one manager-API ServiceUpdate with ServiceUpdateOptions{Rollback: "previous"}, not a client-side re-apply of PreviousSpec — so the manager drives it and honours the service's own rollback configuration (parallelism, delay, failure action), and reports progress as the update state rollback_started: exactly the ↺ rolling back badge described above, on the tree row and in this overlay. A service that has never been updated has no previous spec — you get an explanatory notice, not an error. Also reachable from the a actions menu. Like d, D and X this is a fixed inspect key, not a remappable keymap action
sscale — prompts for a new replica count and applies it (replicated services only; a global service reports it can't be scaled)
fforce-update — redeploys the service without changing its spec (the equivalent of docker service update --force: bumps TaskTemplate.ForceUpdate), after a confirm. Every task is restarted / rescheduled, which is how you unstick a service sitting in an incomplete state (e.g. 1/2 replicas). One ServiceUpdate (rolling update)
Xremove the service (capital X) — permanently deletes it and stops all its tasks, behind a confirm; cannot be undone. One manager-API ServiceRemove; on success the inspect overlay closes and the service tree refreshes. If the service was the sole user of one or more secrets, swarmexec then asks whether to delete those now-orphaned secrets too (secrets still referenced by another service are left alone)
Ddiagnose placement (capital D) — answers why a service isn't running everywhere you expect in one view, instead of chasing it through several docker commands. For a global service it lists every node with ✓ running or ✗ and the reason it is excluded (availability drain/pause, a down state, an unmet placement constraint, or a platform mismatch) — which is why a 3-node cluster can legitimately show 2/2 (the third node is not eligible). For a replicated service it lists each task that is not running with the scheduler's own message (constraints, insufficient resources, image-pull errors, …). Constraints that can't be checked client-side (engine.labels.*) are noted, not guessed. Read-only overlay
pedit published ports — opens a staged list editor of the service's ports, each in the form PUBLISHED:TARGET[/proto] (proto tcp|udp|sctp, default tcp), e.g. 8080:80/tcp
ledit labels — the same staged list editor over key=value entries
eedit environment variables — the same staged list editor over the ContainerSpec env, each entered as KEY=VALUE (e.g. LOG_LEVEL=debug); the key is required, the value may be empty and may itself contain =. Like ports/labels/mounts this editor does allow e edit, so you can adjust a value in place. Unlike the other editors, e here opens the entry in a scrollable multi-line text area (rather than a one-line field) so long values such as GITLAB_OMNIBUS_CONFIG are comfortable to edit — type freely (Enter inserts a newline), Ctrl-S saves, Esc cancels. Applying replaces the service's env in one ServiceUpdate (rolling update)
nedit networks — a staged list editor over the service's network attachments; since an entry is just a name it's add/remove only (no e edit). The add input autocompletes network names (suggesting networks the service isn't already attached to, or type one yourself). Applying replaces the attachments in one ServiceUpdate. DNS aliases are edited from here too: select a network in this editor and press A to open a staged list of the service's DNS aliases on that network (a add / e edit / d delete / y copy / u undo / w apply / Esc cancel); applying replaces that network's aliases in one ServiceUpdate. Works even when the service has no aliases yet. (You must apply a newly-added network first before you can set its aliases.)
Sedit secrets (capital S, i.e. Shift+s, to distinguish it from s scale) — the same staged list editor as networks over the secrets the service references; an entry is just a name, so it's add/remove only (no e edit). The add input autocompletes secret names (suggesting secrets the service doesn't already use, or type one yourself). Applying replaces the service's secret references in one ServiceUpdate (rolling update); each secret is mounted at /run/secrets/<name>. Works even when the service currently has no secrets
vedit mounts — a staged list editor over the service's volumes and bind mounts. Adding (a) or editing (e) opens a small form instead of a single text field: a bind mount checkbox, a source (when the box is unchecked it's a volume name with autocomplete of existing cluster volumes; when checked it's a host path), a container path, and a read-only checkbox. (Under the hood each entry is still volume:NAME:TARGET[:ro] / bind:/host/path:TARGET[:ro].) Bind sources and all targets must be absolute paths. Applying replaces the mounts in one ServiceUpdate (rolling update). Bind-mount guard (soft): if the staged list contains any bind mounts, applying first shows a warning listing the nodes the service could be scheduled on (computed from placement constraints plus node role/labels/availability) and reminds you that each bind source must already exist on all of them — swarmexec cannot verify host paths (the agent has no host filesystem access), so bind paths are not autocompleted or existence-checked; it is advisory only
redit resource limits — a small form to set, change or clear the service's CPU and memory limits (and, optionally, reservations). CPU is given in cores (e.g. 0.5, 2); memory as a human size (e.g. 512m, 2g, 1.5GiB). Leaving a field empty clears that limit (Swarm then treats it as unlimited). A reservation may not exceed its limit. Applying does one ServiceUpdate (rolling update); existing Pids limits and device/generic reservations are preserved
Pedit placement (capital P, i.e. Shift+p, to distinguish it from p ports) — opens a small menu with two staged editors: Constraints and Spread preferences.
  • Constraints — the service's hard placement constraints (node.role, node.hostname, node.id, node.platform.os/arch, node.labels.<k>, engine.labels.<k>, each with == or !=, e.g. node.role==manager, node.labels.zone!=eu). The add/edit input autocompletes fully-formed candidates built from the current cluster — every node's role, hostname, platform and node/engine labels — so you can usually pick a constraint instead of typing it (the == form is suggested; type != by hand for a negation). The resulting node set also feeds the mounts editor's bind-mount guard.
  • Spread preferences — the soft --placement-pref spread=… strategies: a staged, ordered list of bare node attributes (e.g. node.labels.zone, node.hostname) that Swarm spreads tasks evenly over. The input autocompletes the cluster's node/engine label keys and node attributes (a descriptor is just the attribute — no operator or value).
Applying either replaces that part of the placement in one ServiceUpdate (rolling update); the other part is preserved.

These editors are staged: a adds an entry, d deletes the one under the cursor, y copies the selected entry to your clipboard over OSC52 (so you can grab e.g. a single environment variable while viewing it), u undoes the last staged change (add / edit / delete) as long as you haven't applied yet — press it repeatedly to step back through the staged history — and w applies all changes at once in a single ServiceUpdate (so adding, say, several volumes and then pressing w triggers one rolling update, not one per entry), behind a confirmation. If you press Esc while changes are still staged, the editor doesn't silently drop them — it asks whether to apply, discard, or keep editing. While changes are still staged they are highlighted so you can see exactly what will change: added or edited entries show in green (new) and removed entries linger as dim red “removed” rows — the highlighting clears once you apply. The ports, labels, mounts, env and aliases editors (aliases reached from the networks editor via A) also have e to edit the entry under the cursor; the networks and secrets editors have no e — a network or secret entry is just a name, so it's add (a) / remove (d) only. The env editor's e opens a scrollable multi-line text area (Ctrl-S save / Esc cancel) for long values; the other editors keep the single-line input with Enter to confirm.

The tree auto-refreshes every ~10s, so a container replaced or a service scaled in the background shows up on its own — the cursor and any expanded services are preserved. r still forces an immediate refresh.

Volumes tab

KeyAction
/search — filter the list by volume name, driver or node
ncreate a volume — a form with name, driver (default local), labels (k=v,k=v) and a node target (autocompletes node names; leave blank to create on every node, since volumes are node-local). Creates it on each target node's agent and reports per-node success/failure. Tab moves between fields, Esc cancels
spaceselect / deselect the volume (marked ) for a bulk delete
aselect / deselect all currently displayed volumes
ddelete the selected volumes — or the one under the cursor — on every node that holds them, behind a confirm (with a progress overlay; deletes run with bounded parallelism)
Pprune: delete every volume no running container mounts and no service declares, behind a confirm
Entershow which nodes hold the volume; the volume's labels are listed read-only above the nodes (Docker has no volume-update API, so labels can't be edited after creation — set them when you create the volume)
ishow which services/containers use it
Aattach the volume to a service: pick a service (name autocomplete) → enter the container target path (absolute) → choose Attach or Attach read-only. Adds the mount via one ServiceUpdate (rolling update) — the volume counterpart to the network/secret attach actions; the reverse lives in the service inspect v mounts editor
s Scycle the sort field (name → nodes → used → age → size) / reverse

Deleting is node-aware: a volume is removed on every node holding it. Prune spares volumes a service declares even when no task is running, so it won't wipe a stopped stack's data. For finer control, Enter opens the per-node list where space selects nodes, d deletes the highlighted node's copy and a deletes on all nodes — each behind a confirmation.

Forwards tab

KeyAction
Entershow the forward's full detail (including any error)
dstop the selected forward
ocopy the forward's URL (http://127.0.0.1:<port>)

A running container is annotated in the tree as local→remote (e.g. 9090→8080). Forwards keep running when their overlay closes and when you switch to another cluster; they stop on d or when you quit the UI. The CLUSTER column names the context each one belongs to, dimmed for the cluster you are currently looking at — a forward on another cluster is still yours and still listening, but its CONTAINER and NODE name things you cannot see from here. The tree annotation is per cluster for the same reason: a container id is only unique within its own daemon.

Because of that, a local port you are already using is refused up front, naming the forward that holds it and the cluster it belongs to — the prompt stays open so you can pick another port. The operating system's own "address already in use" is kept for the case it is right about: a port held by some other program, which swarmexec cannot name.

Networks tab

A list of the swarm's networks — name, driver, scope, type (ingress / internal / attachable), whether the network is encrypted and how many services attach to each. The TYPE column shows overlay or the driver name (e.g. bridge) for plain networks rather than a dash. The ENC column shows 🔒 yes when the overlay network has data-plane encryption on (created with --opt encrypted), else a dash. Each network's name and its TYPE cell are colour-coded by kind (highest priority first):

KeyAction
ncreate a network — opens a form (default driver overlay) with the common swarm options: attachable (let standalone containers join), encrypted (overlay data-plane encryption), internal (no external routing), IPv6, an optional MTU, an optional subnet/gateway (IPAM) and labels (k=v,k=v). Tab moves between fields, Enter on Create confirms, Esc cancels. Creates it via one manager-API NetworkCreate, then refreshes the tab
Enter / ishow the attached services, each with its running containers nested underneath. The network's own labels are listed read-only at the top (Docker has no network-update API, so network labels can't be edited after creation — set them when you create the network). Each service header also shows how many DNS aliases it has on this network (or no aliases)
Enterin the members view: expand / collapse the selected service's DNS aliases (kept collapsed by default so the list stays compact); the aliases appear indented under the service header
Ain the members view: add / edit the selected service's DNS aliases on this network — opens the same staged alias editor as the inspect view (a add / e edit / d delete / w apply / Esc cancel); applying replaces that network's aliases in one ServiceUpdate and refreshes the view. Works even when the service has no aliases yet. (A is capital, Shift+a, to distinguish it from a attach; standalone non-swarm containers are not editable — aliases are a per-service ServiceUpdate)
ain the members view: attach a service to this network — an autocomplete input (suggesting services not already attached; you may also type any name), then a rolling-update confirmation
din the members view: detach a service from this network — an autocomplete input (suggesting the services currently attached; you may also type any name), then a rolling-update confirmation

Attaching or detaching does a service update that adds or removes the network in the service spec, then refreshes the Networks tab. This is a manager-API operation (ServiceUpdate) — the same channel the client uses for topology — and it triggers a rolling update that restarts the service's tasks.

Secrets tab

A read-only list of the swarm's secrets — name, how many services use each, age, last update and label count. Secret values are never shown: the Docker API does not expose them.

KeyAction
Entershow the secret's metadata and the services/containers that use it
don the secrets list: delete the selected secret — permanently removes it (one SecretRemove), behind a confirm; cannot be undone. Docker refuses to remove a secret still referenced by a service, so the confirm warns when it's in use and lists those services (detach it there first). Not to be confused with the d inside the detail view, which detaches the secret from a service
ain the detail view: attach the secret to a service — an autocomplete input (suggesting services that don't already use it; you may also type any name), then a rolling-update confirmation
din the detail view: detach the secret from a service — an autocomplete input (suggesting services that currently use it; you may also type any name), then a rolling-update confirmation

Attaching adds the secret to the service, mounted at /run/secrets/<name> (like docker service update --secret-add); detaching removes the secret reference. Then the Secrets tab refreshes. Like the network attach/detach, each is a manager-API ServiceUpdate that triggers a rolling update restarting the service's tasks.

Context sidebar

The clusters live in a column down the right-hand side, not in a tab. Switching cluster is something you do from wherever you are — and the list of clusters is what tells you where that is, so it belongs on screen rather than on a page you have to travel to.

c gives it the keyboard from any tab; c again or Esc hands it back to the tab you were on. The active cluster is marked — the same name the footer shows. A cluster you have already visited this session is marked · in green: it is still connected, so switching to it is instant. One that refused a connection is marked in red. The built-in default context is protected.

The sidebar sizes itself to the longest context name and steps aside on a narrow terminal: below roughly 92 columns it hides rather than clip the tree, and reappears for as long as c holds it. Endpoints are not shown in the column — there is no room for them there, and i shows the full detail.

Switching cluster keeps your session. The UI does not rebuild itself: where the cursor stood, which stacks and services were unfolded and the / filter are remembered per cluster, so going back puts you exactly where you left off. Port forwards keep running across a switch — the Forwards tab gains a CLUSTER column so you can see at a glance which ones belong to the cluster you are looking at and which do not.

Only the cluster you are looking at is polled: switching away stops the other one's refresh, and its ssh connection expires on its own shortly after. The first switch to a cluster takes as long as connecting to it does, because the connection is made before anything on screen changes — if it fails, you are told which cluster and stay on the one that works. Every later switch back is immediate.

KeyAction
cfocus the sidebar from any tab; c again or Esc gives the keyboard back
icontext details — endpoint and jump hosts, which the column itself has no room for
u / Enteractivate the selected context — makes it the current one (docker's stored current context too) and switches the UI to that cluster, keeping your position, your filters and your port-forwards
ncreate a context — a guided form. A checkbox “Connect to Docker over SSH” decides the endpoint: when on, you fill in SSH user / host / port and a second checkbox “Use a jump host” reveals a jump host(s) field (comma-separated, multi-hop ProxyJump) — swarmexec stores them on the context and injects -J into both the Docker-API and the agent-tunnel ssh connections, so bastions work without editing ~/.ssh/config. When off, you enter a plain tcp:// / unix:// host. The form assembles the docker host for you, and Test pings the assembled endpoint before you save. Also on the CLI: swarmexec context create <name> --docker-host ssh://ops@mgr --ssh-jump bastion1,bastion2
dremove the selected context (behind a confirm); removing the current one resets the selection to default

Nodes tab

Lists the swarm's nodes with general info and a few aggregations — NODE (hostname; managers in aqua, the leader marked ), ROLE (manager / worker), AVAIL (active / pause / drain, colour-coded), STATE (ready / down), ENGINE version, TASKS (running tasks scheduled on the node), VOLS (volumes the node holds — node-local, so it fills in a moment after the rest) and LABELS (count).

KeyAction
Enter / inode details — a read-only overlay with hostname, id, role/leader, availability, state, address, engine, platform, CPUs, memory, running-task count, the reserved resources, in-use and images on this node blocks (see below), volume count and the full label list
ledit the selected node's labels — a staged list editor (a add / e edit / d delete / w apply / Esc cancel) over key=value entries. Applying does one NodeUpdate — unlike a service, a node update applies immediately (no rolling update). Node labels are commonly used as placement-constraint targets (node.labels.<k>)
aset the node's availability — a menu of Active / Pause / Drain that marks the state the node is currently in. Active schedules tasks here normally; Pause keeps the running tasks and only stops new placements; Drain moves every task off the node. Active and Pause apply straight away; Drain goes through a confirm naming how many running tasks swarm will stop and reschedule elsewhere (services whose tasks can't be placed anywhere else go unschedulable). Like a label change it is one NodeUpdate (read-modify-write on the node spec) and applies immediately — nodes have no rolling update. Remappable as node_availability
Preclaim image disk space on the node (capital P, i.e. Shift+p — the same gesture the Volumes tab uses for its prune). Opens a small menu with the two reclaim modes described below, each behind its own confirm; it is worth reading which is which before picking one. The footer ends with P reclaim images and the ? overlay lists it as reclaim image disk space on the node. Remappable as node_prune_images

Reserved resources. The node detail carries a reserved by tasks (scheduler's view) block: for CPU and memory, a bar plus reserved / capacity and how much is free. The bar is green, turns yellow from 75% and red at 100% or above; over-commitment is called out in words, and “free” never goes negative. A node that reports no capacity says so instead of drawing a bar. The block costs no extra API call — the nodes list already fetches the task list.

These are reservations, not usage. The figures are the sum of the tasks' declared Reservations — exactly the arithmetic the swarm scheduler itself does when deciding whether a task fits on a node, and the usual reason a service sits unschedulable. Live CPU/RAM consumption is node-local and is not what this shows — it has a block of its own, right below. Tasks that declare no reservation are counted and reported separately: they book nothing here yet can still consume the whole node, so a low percentage does not mean the node is idle — and in most clusters few services set reservations at all. A task holds its reservation from the moment it is assigned until it reaches a terminal state, so tasks that are preparing or starting count, while shutdown / failed / complete / rejected ones do not (swarm keeps those in the task list as history).

In use by containers. Beneath that sits a second block — in use by containers (measured on the node) — drawn with the same proportional bars: what the node's containers are actually using right now, CPU in cores and memory, each against the node's own capacity. The two blocks are kept deliberately apart because they answer different questions and are routinely far apart: a node can be fully booked and idle, or barely booked and on fire. The figures come from that node's agent, the same source as the usage markers in the service tree — while the CPU delta isn't ready yet the line reads measuring…, and a node whose agent is unreachable or too old gets no block at all rather than a row of zeros that would read as “idle”.

Containers only. This counts the node's containers — the kernel, the docker daemon and anything running outside docker are not in the figure. It is not the node's load average, and the block says so itself.

Images on this node. Last in the detail comes an images on this node block: how much the node's layer store holds and in how many images, and how much of that is reclaimable in untagged leftovers (a node with nothing untagged says so rather than offering a zero). When there is any, one further line notes how much more is tagged but unused — phrased as a cost, removing it means pulling those images again, not as an offer. The block exists because images are the one node-local resource the cluster side cannot see at all: the manager API has no view of images whatsoever, unlike volumes, which it at least knows are mounted. Volumes could already be managed cluster-wide from swarmexec; a node quietly filling up with old layers could not even be seen. It loads on its own, in parallel with the node list, so it appears a moment after the rest of the overlay.

Reclaiming it — P on the Nodes tab. The menu it opens has two entries, deliberately not one action with a checkbox, because they are different acts and a checkbox invites getting the destructive one by accident:

EntryWhat it removes
Untagged leftoversthe layers left behind by rebuilds, which nothing can start from. Safe: it cannot stop a service and it cannot force a pull
Every unused imagealso removes tagged images that no container is running right now. On a swarm node that includes every service currently scaled to zero and every task between restarts — each of them will have to pull its image again
Do not read the second entry as “the same, but more”. “Every unused image” removes images that belong to something which is merely not running at this instant. A service scaled to zero, a task restarting, a stack you stopped yesterday — all of them lose their image and must pull it again to come back, and if the registry is unreachable they will not come back up. Each of the two entries goes through its own confirm dialog that states exactly that consequence and names the bytes each one would free; they are separate texts precisely so neither can drift into describing the other.

Two modes, two permissions. The agent authorizes them as two distinct actions — image.prune for the untagged leftovers and image.prune.all for the sweep — so a policy can allow reclaiming untagged layers without allowing the destructive one. Every prune is written to the agent's audit log with which mode was used, how much was reclaimed and how many images went.

Where the numbers come from. They are read from the same source docker system df reports from, and match it exactly — verified on a real node at 56 images, 32.70 GB on disk, 22.99 GB reclaimable. In particular swarmexec does not add up the individual image sizes: an image's size includes every layer it is built from, and layers are shared, so that sum over-reports badly — during development it claimed 32.7 GB where the daemon said 22.2 GB. One caveat worth stating: the untagged-only figure is a lower bound — a layer shared by two untagged images belongs to neither one's unique size — so an untagged prune may free a little more than advertised, never less.

Same precondition as the usage and health numbers above: it needs agents from this release or newer. A node whose agent is older simply shows no image block at all, and reports that it did not answer if you press P — roll the agents out with swarmexec init --force.

Configs tab

Tab 8 — the Secrets tab's twin for the other object swarm hands to containers, with one decisive difference: a config's content is readable. The Secrets tab can never show a value (the Docker API does not return it); the config detail shows the actual content, so you can see what a service really receives without dropping to docker config inspect. That is the point of this tab.

The list is read-only: CONFIG (name), USED BY (how many services mount it), SIZE, AGE, UPDATED and LABELS (count).

KeyAction
Enter / iopen the detail overlay: name, id, size, created, updated, the services that mount it, its labels — and the config's content
j k / g Gin the detail overlay: scroll · jump to top / bottom (Esc closes)

The content is fetched on demand, when the overlay opens — not together with the list — because a config can be a whole nginx.conf; the overlay shows loading… until it arrives, and an empty config says (empty). What you get is a preview, capped at both 64 KiB and 500 lines. Both caps are needed: bounding only the bytes still lets a file of very short lines become tens of thousands of rendered rows — each indented — which is what actually makes the view crawl. Truncation is always stated (“… truncated at 500 lines”, or at the byte cap), never a silent cut, and binary content is reported, not dumped (“(binary content, N — not shown)”, decided by looking for NUL bytes in the head of the payload). The whole config is one docker config inspect away.

USED BY is derived exactly the way the Secrets tab derives it: swarm keeps no reverse index from a config to its consumers, so swarmexec reads the service specs (one ServiceList) and matches a reference by config ID or name — a spec may carry either form, so both are looked up and merged. Best-effort: if the service list can't be read, the configs still render, just without membership.

Embedded shell & logs

In an embedded shell, Ctrl-] detaches (without ending the process). The logs view — a single container or a service's aggregated logs — supports the same format-aware parsing and filtering as the logs command:

KeyAction
ftoggle follow
Fcycle log format (classic → json → logfmt → gelf → raw)
lcycle minimum level filter (off → trace → … → fatal → off)
/enter a grep regexp on the message (empty clears it)
mtoggle mouse capture (off = your terminal's own select/copy)
scroll
Esc / qclose

The view re-renders the buffered lines live when you change format, level or grep, and the title bar shows the active fmt:… lvl:… grep:… status.

When following, the log view reconnects across container replacement the same way the logs command does — for both a single container's logs and a service's aggregated logs (each replica followed by slot). Reconnect notices appear inline in the log view.

Log viewer

Press ` (backtick) from any tab to toggle a live log overlay. It shows the most recent records from the in-memory ring buffer — newest at the bottom — refreshing while open and colour-coded by level (red = error, yellow = warn, gray = debug). / scroll; Esc, q or ` close it. This is the TUI's window onto the same logs that go to the log file, since the terminal itself can't show them while the UI is drawing.

Responsiveness. The container list refreshes off the UI thread (its two manager API calls used to run inline and could briefly freeze the TUI), and a background watchdog logs a warning when the event loop stalls — handy for diagnosing sluggishness from the log viewer.

Custom keys

The shortcut keys are remappable in ~/.config/swarmexec/keys.yaml (next to config.yaml; override the path with $SWARMEXEC_KEYS). Each value is a single key, or the word space. The structural keys — Enter, Esc, Tab, the arrows, the tab-number keys 18 and the vim aliases j/k/g/G — are fixed. The footer always shows your actual keys, and the ? overlay lists all of them.

# ~/.config/swarmexec/keys.yaml — omit any line to keep its default quit: q refresh: r copy: y toggle_mouse: m search: / fold: h unfold: l forward: p container_inspect: i logs: L security_risks: "!" # "!" must be quoted — YAML stack_group: s volume_select: space volume_select_all: a volume_delete: d volume_prune: P volume_used_by: i volume_attach: A volume_sort: s volume_sort_reverse: S volume_new: n network_attached: i network_new: n context_use: u context_new: n context_delete: d node_edit_labels: l node_availability: a node_prune_images: P secret_delete: d secret_new: n forward_stop: d forward_copy_url: o

Loading never fails: an invalid or reserved key, an unknown action, or two actions bound to the same key on one tab each fall back to the default, and the UI shows the warnings once on startup.

11. Exit codes

CodeMeaning
0success
2usage / flag / configuration error, or an aborted interactive selection
125transport failure — unreachable manager or agent, dial/TLS error, stream error, aborted confirmation (mirrors Docker's 125)
Nfor exec, the remote command's own non-zero exit code, propagated verbatim