Docker Swarm
Swarm mode turns a group of Docker hosts into a single cluster with scheduling, service discovery, rolling updates, and secret distribution — using the Docker CLI and Compose files you already know. It is built into the engine, so there is nothing to install.
Swarm is far simpler than Kubernetes and correspondingly less capable. That trade is a genuine advantage for small clusters, homelabs, and teams without a platform engineer, and a real limitation once you need autoscaling, a rich operator ecosystem, or fine-grained RBAC.
Table of Contents
- Swarm or Kubernetes
- Architecture
- Creating a Cluster
- Managing Nodes
- Services
- Rolling Updates and Rollback
- Stacks
- Overlay Networking
- Secrets and Configs
- Placement Control
- Storage
- Operations
- Troubleshooting
Swarm or Kubernetes
| Swarm | Kubernetes | |
|---|---|---|
| Setup | docker swarm init |
Managed service, or kubeadm and a CNI |
| Learning curve | Hours | Weeks |
| Config format | Compose files | Manifests, Helm, Kustomize |
| Autoscaling | Manual scaling only | HPA, VPA, cluster autoscaler |
| Ecosystem | Small | Very large |
| RBAC | Minimal | Comprehensive |
| Ingress | Routing mesh, or a proxy service | Ingress controllers, Gateway API |
| Development pace | Maintenance-level | Rapid |
Swarm remains supported and is a reasonable choice for a handful of nodes running services you deploy by hand, a homelab, or an edge site where a Kubernetes control plane is disproportionate. Choose Kubernetes when you need autoscaling, multi-tenancy, or the wider ecosystem — and see Container Orchestration and Kubernetes for that path.
Note
Swarm development is in maintenance mode: it receives fixes and security updates rather than significant new capability. Factor that into decisions with a long horizon.
Architecture
Manager nodes maintain cluster state and schedule work. They use the Raft consensus algorithm, so a majority must be reachable for the cluster to accept changes.
Worker nodes run tasks. Managers are also workers by default.
| Managers | Failures tolerated |
|---|---|
| 1 | 0 — any failure loses the cluster |
| 3 | 1 |
| 5 | 2 |
| 7 | 3 |
Use an odd number of managers — an even count adds no fault tolerance over the odd number below it. Three is right for most clusters; five for larger ones. More than seven degrades write performance without meaningful benefit.
Important
Losing manager quorum does not stop running containers, but the cluster becomes read-only:
no deployments, no rescheduling, no scaling. Recovering requires either restoring a manager
or forcing a new cluster with --force-new-cluster, which is disruptive. Spread managers
across failure domains.
Required Ports
| Port | Protocol | Purpose |
|---|---|---|
| 2377 | TCP | Cluster management — managers only |
| 7946 | TCP + UDP | Node-to-node communication and gossip |
| 4789 | UDP | Overlay network traffic (VXLAN) |
sudo ufw allow 2377/tcp
sudo ufw allow 7946
sudo ufw allow 4789/udp
Blocked port 7946 or 4789 produces the characteristic failure where nodes join successfully but services cannot reach each other across hosts.
Creating a Cluster
# On the first manager — advertise-addr matters on multi-homed hosts
docker swarm init --advertise-addr 192.168.1.10
The output includes the worker join command. Retrieve join tokens later with:
docker swarm join-token worker
docker swarm join-token manager
# Just the token
docker swarm join-token -q worker
# On each worker
docker swarm join --token SWMTKN-1-xxxx 192.168.1.10:2377
# On additional managers
docker swarm join --token SWMTKN-1-yyyy 192.168.1.10:2377
docker node ls
docker info | grep -A10 Swarm
Leaving and Dissolving
# On a worker
docker swarm leave
# On a manager (last one, or forcing)
docker swarm leave --force
# Remove a departed node from the roster, from a manager
docker node rm <node-id>
Locking the Swarm
Raft logs and TLS keys sit encrypted on disk, but the unlock key is stored in the clear by default so the daemon can restart unattended. Autolock requires the key on every restart:
docker swarm init --autolock
docker swarm update --autolock=true
# After a daemon restart
docker swarm unlock
# Retrieve or rotate the key
docker swarm unlock-key
docker swarm unlock-key --rotate
Store the key in a password manager. Losing it with autolock enabled means rebuilding the cluster.
Managing Nodes
docker node ls
docker node inspect <node> --pretty
docker node ps <node> # tasks on that node
Availability
docker node update --availability drain worker-2 # evacuate for maintenance
docker node update --availability pause worker-2 # no new tasks, keep existing
docker node update --availability active worker-2 # back into rotation
Draining a manager is good practice on small clusters — it keeps the control plane responsive under load.
Promotion and Labels
docker node promote worker-1
docker node demote manager-3
# Labels drive placement constraints
docker node update --label-add zone=rack1 --label-add disk=ssd worker-1
docker node inspect worker-1 --format '{{json .Spec.Labels}}'
Services
A service is the declared desired state; Swarm creates tasks (containers) to satisfy it.
docker service create \
--name web \
--replicas 3 \
--publish published=80,target=80 \
nginx:1.25-alpine
docker service ls
docker service ps web
docker service inspect web --pretty
docker service logs -f web
Replicated vs Global
# Replicated — a fixed number of tasks, placed anywhere eligible
docker service create --name api --replicas 5 myapp:1.0
# Global — exactly one task on every eligible node
docker service create --name node-exporter --mode global \
--mount type=bind,source=/proc,target=/host/proc,readonly \
prom/node-exporter
Global mode suits agents: log shippers, metrics exporters, monitoring.
Scaling
docker service scale web=5
docker service scale web=5 api=3
docker service update --replicas 10 web
Swarm has no autoscaling. Scaling is a deliberate operation, which is one of the sharper differences from Kubernetes.
Resource Limits and Health
docker service create \
--name api \
--replicas 3 \
--limit-cpu 1.0 --limit-memory 512M \
--reserve-cpu 0.25 --reserve-memory 128M \
--health-cmd "curl -fsS http://localhost:8080/health || exit 1" \
--health-interval 30s \
--health-retries 3 \
--health-start-period 40s \
--restart-condition on-failure \
--restart-max-attempts 3 \
myapp:1.0
Reservations are what the scheduler uses to decide placement; limits are enforced at runtime. Setting reservations too high leaves tasks pending with no eligible node — a common cause of a service that never starts.
Health checks matter more here than in standalone Docker: rolling updates use them to decide whether a new task is good before continuing.
Rolling Updates and Rollback
docker service update \
--image myapp:2.0 \
--update-parallelism 1 \
--update-delay 30s \
--update-order start-first \
--update-failure-action rollback \
--update-monitor 60s \
--update-max-failure-ratio 0.25 \
api
| Option | Effect |
|---|---|
--update-parallelism |
Tasks updated simultaneously. 1 is safest. |
--update-delay |
Pause between batches |
--update-order |
start-first (new task healthy before old stops) or stop-first (default) |
--update-failure-action |
pause (default), continue, or rollback |
--update-monitor |
Window in which a task failure counts against the update |
--update-max-failure-ratio |
Failure fraction tolerated before the action triggers |
--update-order start-first with a health check is what gives zero-downtime deployment.
stop-first briefly reduces capacity and can drop requests.
# Watch progress
docker service ps api
# Manual rollback to the previous spec
docker service rollback api
Configure rollback behavior in advance:
docker service update \
--rollback-parallelism 2 \
--rollback-delay 10s \
--rollback-failure-action pause \
api
Tip
Deploy by digest rather than tag. docker service update --image myapp:2.0 resolves the
tag once at update time and pins the digest, but referencing an immutable digest directly
makes what is running unambiguous. See Digest Pinning.
Forcing a Restart
docker service update --force api
Useful after a config change that does not alter the service spec.
Stacks
A stack deploys a whole Compose file to the cluster.
# docker-stack.yml
services:
web:
image: nginx:1.25-alpine
ports:
- "80:80"
networks:
- frontend
configs:
- source: nginx_conf
target: /etc/nginx/conf.d/default.conf
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 20s
order: start-first
failure_action: rollback
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
resources:
limits:
cpus: "0.5"
memory: 256M
reservations:
cpus: "0.1"
memory: 64M
placement:
constraints:
- node.role == worker
api:
image: registry.example.com/myapp:1.0
networks:
- frontend
- backend
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
deploy:
replicas: 4
placement:
max_replicas_per_node: 2
db:
image: postgres:16-alpine
networks:
- backend
volumes:
- db-data:/var/lib/postgresql/data
secrets:
- db_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
deploy:
replicas: 1
placement:
constraints:
- node.labels.disk == ssd
networks:
frontend:
driver: overlay
backend:
driver: overlay
internal: true
volumes:
db-data:
secrets:
db_password:
external: true
configs:
nginx_conf:
file: ./nginx.conf
docker stack deploy -c docker-stack.yml myapp
docker stack ls
docker stack services myapp
docker stack ps myapp
docker stack rm myapp
Redeploying the same file applies changes as rolling updates — docker stack deploy is both
create and update.
# Pull newer images on every node during deploy
docker stack deploy -c docker-stack.yml --with-registry-auth --resolve-image always myapp
--with-registry-auth forwards your registry credentials to the workers, and is required for
private images — without it workers fail to pull with an authentication error even though the
manager pulled fine.
Compose Differences
docker stack deploy and docker compose up read the same file but honor different keys:
| Key | docker compose up |
docker stack deploy |
|---|---|---|
build: |
Used | Ignored — push to a registry first |
depends_on: |
Controls start order | Ignored |
container_name: |
Used | Ignored |
profiles: |
Used | Ignored |
restart: |
Used | Ignored — use deploy.restart_policy |
deploy: is not simply "Swarm-only" — Compose v2 honors part of it and silently ignores the
rest:
deploy key |
docker compose up |
docker stack deploy |
|---|---|---|
replicas |
Used — creates N containers | Used |
resources.limits / reservations |
Used — limits applied; reservations inform scheduling only | Used |
restart_policy |
Used | Used |
mode: global |
Ignored | Used |
endpoint_mode |
Ignored | Used |
update_config / rollback_config |
Ignored | Used |
placement |
Ignored | Used |
The ignored keys produce no warning — a Compose file with mode: global and
update_config comes up cleanly, having applied neither. Do not read a successful
docker compose up as confirmation that a stack file's orchestration settings are in effect.
The build: and depends_on: differences catch people out most often. Swarm has no startup
ordering at all — services must tolerate their dependencies being temporarily absent, which
is sound design regardless but is mandatory here.
See Docker Compose for the file format itself.
Overlay Networking
Overlay networks span nodes using VXLAN, so containers on different hosts share a subnet.
docker network create -d overlay backend
# Allow standalone containers to attach, not just services
docker network create -d overlay --attachable dev-net
# Encrypt the data plane with IPsec
docker network create -d overlay --opt encrypted secure-net
docker network create -d overlay --subnet 10.20.0.0/16 --gateway 10.20.0.1 custom
Encryption costs throughput; it is worthwhile when nodes communicate over untrusted networks and unnecessary within a trusted private VLAN.
The Routing Mesh
Publishing a service port makes it reachable on every node, regardless of where tasks actually run. A request to a node without a local task is forwarded to one that has it.
docker service create --name web --replicas 2 --publish published=80,target=80 nginx
# Reachable on port 80 of every node in the cluster
This makes external load balancing simple — point at any node, or all of them. It also means the source IP seen by the container is a mesh address rather than the real client. To preserve client IPs, bypass the mesh:
docker service create \
--name web \
--mode global \
--publish mode=host,published=80,target=80 \
nginx
mode=host publishes only on nodes running a task, so pair it with --mode global or an
external load balancer that knows where tasks are.
Service Discovery
Every service gets a DNS name resolving to a virtual IP that load-balances across healthy tasks:
docker service create --name api --network backend --replicas 3 myapp:1.0
# Other services on 'backend' simply use http://api:8080
For the individual task addresses instead of a VIP:
docker service create --endpoint-mode dnsrr --name api myapp:1.0
VIP mode is the default and generally what you want; dnsrr is for cases where a client does
its own load balancing, and it does not work with the routing mesh.
Secrets and Configs
Swarm secrets are encrypted in the Raft log, transmitted over mutual TLS, and mounted into tasks on an in-memory filesystem.
# From a file
docker secret create db_password ./db_password.txt
# From stdin — avoids leaving the value on disk
printf 'S3cr3t!' | docker secret create db_password -
docker secret ls
docker secret inspect db_password # metadata only; the value is never readable
docker service create \
--name api \
--secret db_password \
--secret source=tls_cert,target=/etc/ssl/cert.pem,mode=0400 \
myapp:1.0
Secrets appear at /run/secrets/<name>. Most well-behaved images accept a _FILE variant of
their environment variables so the value never lands in the environment:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
Configs work identically for non-sensitive files, and are not encrypted at rest:
docker config create nginx_conf ./nginx.conf
docker service create --name web --config source=nginx_conf,target=/etc/nginx/nginx.conf nginx
Rotation
Secrets and configs are immutable. Rotating means creating a new one and updating the service:
printf 'newS3cr3t!' | docker secret create db_password_v2 -
docker service update \
--secret-rm db_password \
--secret-add source=db_password_v2,target=/run/secrets/db_password \
api
docker secret rm db_password
Keeping target= stable means the application path does not change.
Placement Control
# Constraints — hard requirements
docker service create --name db \
--constraint 'node.role == worker' \
--constraint 'node.labels.disk == ssd' \
--constraint 'node.hostname != worker-3' \
postgres:16
# Preferences — soft spreading
docker service create --name api \
--replicas 9 \
--placement-pref 'spread=node.labels.zone' \
myapp:1.0
# Spread across nodes
docker service create --name api --replicas 6 \
--placement-pref 'spread=node.id' \
myapp:1.0
Available attributes: node.id, node.hostname, node.role, node.platform.os,
node.platform.arch, node.labels.*, engine.labels.*.
In a stack file:
deploy:
placement:
constraints:
- node.role == worker
- node.labels.zone == east
preferences:
- spread: node.labels.rack
max_replicas_per_node: 2
Mixed-OS clusters use the platform attributes:
deploy:
placement:
constraints:
- node.platform.os == windows
See Windows Containers.
Storage
Swarm does not move volume data between nodes. A task rescheduled onto another host finds an empty local volume — which silently loses data for a stateful service unless you plan for it.
Options, in order of robustness:
- Pin the service to one node with a constraint, and accept that the node is a single point of failure.
- Use shared storage — NFS, CIFS, or a clustered filesystem — so any node sees the same data.
- Run stateful services outside the cluster, and treat Swarm as stateless compute.
volumes:
db-data:
driver: local
driver_opts:
type: nfs
o: "addr=192.168.1.100,rw,nfsvers=4,hard"
device: ":/exports/db-data"
See Volume Drivers. For databases specifically, option 3 is usually the sound choice — replication and failover are the database's job, not the orchestrator's.
Operations
Backing Up Cluster State
Swarm state lives in /var/lib/docker/swarm on managers.
# On a manager — stop the daemon for a consistent copy
sudo systemctl stop docker
sudo tar czf swarm-backup-$(date +%F).tar.gz -C /var/lib/docker swarm
sudo systemctl start docker
Restoring:
sudo systemctl stop docker
sudo rm -rf /var/lib/docker/swarm
sudo tar xzf swarm-backup-2026-08-01.tar.gz -C /var/lib/docker
sudo systemctl start docker
docker swarm init --force-new-cluster --advertise-addr 192.168.1.10
--force-new-cluster rebuilds a single-manager cluster from the restored Raft log, preserving
services and secrets. Rejoin the other managers afterwards.
Important
The backup contains the Raft log, and therefore every secret. Encrypt it and restrict access accordingly.
Certificate Rotation
Swarm issues mutual TLS certificates to nodes automatically, rotating every 90 days by default.
docker swarm update --cert-expiry 720h
docker swarm ca --rotate # rotate the CA itself
Monitoring
docker node ls
docker service ls
docker stack ps myapp --no-trunc
docker service ps api --filter 'desired-state=running'
The Prometheus metrics endpoint from Daemon Configuration exposes Swarm metrics on managers. See Monitoring and Logging.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
Tasks stuck in Pending |
No node satisfies constraints or reservations — docker service ps --no-trunc |
no suitable node |
Constraints too narrow, or all candidates drained |
| Nodes join but services cannot communicate | Ports 7946 or 4789 blocked |
Down nodes that are actually up |
Clock skew, or 7946 gossip blocked |
| Workers cannot pull private images | Deploy with --with-registry-auth |
| Service keeps restarting | Failing health check — docker service logs and docker service ps --no-trunc |
| Data lost after rescheduling | Local volume on a different node; see Storage |
| Cluster read-only, deploys hang | Lost manager quorum |
| Client IPs show as mesh addresses | Routing mesh; use mode=host publishing |
# The most useful single command — shows the error for failed tasks
docker service ps api --no-trunc
# Task-level detail
docker inspect <task-id>
# Per-node view
docker node ps <node> --no-trunc
# Daemon logs on the affected node
sudo journalctl -u docker -n 100
Recovering Lost Quorum
With a majority of managers permanently gone, force a new cluster from a survivor:
# On a surviving manager
docker swarm init --force-new-cluster --advertise-addr 192.168.1.10
# Then rejoin the rest
docker node rm <dead-node>
docker swarm join-token manager
Running services continue throughout — this restores the control plane, not the workloads.
Related Topics
- Docker Compose — the file format stacks build on
- Docker Networking — the networking concepts overlays extend
- Docker Storage — volume drivers for shared storage
- Registries — private image access from workers
- Daemon Configuration — cluster host configuration
- Container Orchestration — comparison with other orchestrators
- Kubernetes — the alternative when Swarm is not enough