Docker Swarm Stacks and Secrets
Deploy a Compose file to the whole cluster with one command, and distribute a password to only the services that need it - proved by the service that should not have it finding nothing at all.
Swarm and Multi-Host (optional) Guide 46 of 46 Advanced
- Cluster1 manager, 2 workers
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2 on every node
- Architectureamd64
- TimeAbout 15 min
- Reviewed22 August 2026
Tested on the versions above. Captured on a real three-node cluster. Node names, IDs and addresses are specific to it - match the shape of the output, not the values.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Swarm Manager (Leader) | 2 Core | 4 GB | 50 GB |
| DOCKER02 | 192.168.0.22 | Ubuntu 26.04 LTS | Swarm Worker | 2 Core | 4 GB | 50 GB |
| DOCKER03 | 192.168.0.23 | Ubuntu 26.04 LTS | Swarm Worker | 2 Core | 4 GB | 50 GB |
Before you start
- You can write a Compose file - guide 24 in this path.
- Services and constraints - guide 42 in this path.
- Compose secrets on a single host - guide 27 in this path.
-
Create a secret in the cluster first
A swarm secret is stored encrypted in the raft log and delivered only to the nodes running tasks that need it. Create it before the stack that references it - the stack below declares it
external: true, meaning "this already exists, do not manage it here", which keeps the value out of your compose file and out of git.bash Example session echo swarm-db-password-v1 | docker secret create cg-dbpass -hqqhgr0qhafkqsaxxcpej904ydocker secret lshqqhgr0qhafkqsaxxcpej904y cg-dbpass Less than a second agoExpected resultA secret ID and the secret listed by name.
Success conditionThe secret exists cluster-wide. Piping from
echoputs it in your shell history - use a file or a pipe from your password manager for anything real. -
A stack file is a Compose file plus deploy
The same format you already know. Everything cluster-specific lives under
deploy:, which single-host Compose ignores - so one file can serve both. Notemax_replicas_per_node, the constraint keeping the worker off managers, and resource limits carried over from guide 30.bash cat stack.yamlservices: web: image: nginx:alpine ports: ["8081:80"] networks: [appnet] deploy: replicas: 4 placement: max_replicas_per_node: 2 update_config: { parallelism: 2, order: start-first } resources: limits: { cpus: "0.25", memory: 64M } worker: image: alpine:3.22 secrets: [cg-dbpass] deploy: replicas: 2 placement: constraints: [node.role == worker] networks: { appnet: { driver: overlay } }secrets: { cg-dbpass: { external: true } }Expected resultTwo services, an overlay network and an external secret.
Success conditionYou can point at the parts single-host Compose would ignore.
deploy:is the whole difference. -
Deploy the whole thing at once
One command creates the network and both services across the cluster. The stack name prefixes everything it creates, which is how a stack stays separable from anything else running.
bash Example session docker stack deploy -c stack.yaml cgappCreating network cgapp_appnetCreating service cgapp_webCreating service cgapp_workerExpected resultBoth services converged, with the placement limit shown in the replica column.
Verify it worked
bash Example session docker stack services cgappNAME MODE REPLICAS IMAGE PORTScgapp_web replicated 4/4 (max 2 per node) nginx:alpine *:8081->80/tcpcgapp_worker replicated 2/2 alpine:3.22Success condition
4/4 (max 2 per node)- the constraint is visible in the status, not just in the file. -
Placement rules were honoured
The worker was constrained to
node.role == worker, and it ran only on the two workers - never on the manager. The web service spread across all three but never more than two to a node.bash Example session docker stack ps cgapp --format "table {{.Name}}\t{{.Node}}\t{{.CurrentState}}" | head -7NAME NODE CURRENT STATEcgapp_web.1 ahm-docker03 Running 15 seconds agocgapp_web.2 ahm-docker03 Running 15 seconds agocgapp_web.3 ahm-docker01 Running 15 seconds agocgapp_web.4 ahm-docker02 Running 15 seconds agocgapp_worker.1 ahm-docker03 Running 13 seconds agocgapp_worker.2 ahm-docker02 Running 7 seconds agoExpected resultNo worker task on ahm-docker01, the manager.
Verify it worked
bash Example session docker service ps cgapp_worker --format "{{.Node}}" | sort | uniq -c 1 ahm-docker02 1 ahm-docker03Success conditionThe constraint held. This is the pattern for keeping load off managers in a cluster where managers also run work.
-
The secret reached exactly one service
The proof that matters. The worker declared the secret and can read it; the web service did not declare it, and the secrets directory does not exist for it at all. Distribution is per service, not per node - two containers on the SAME machine differ.
bash Example session docker service logs cgapp_worker | head -2cgapp_worker.2.za1rhkpjh5qg@ahm-docker02 | swarm-db-password-v1cgapp_worker.1.gq8e0em4cm5f@ahm-docker03 | swarm-db-password-v1docker exec $(docker ps -q -f name=cgapp_web | head -1) ls /run/secrets/ls: /run/secrets/: No such file or directoryExpected resultThe worker printing the value; the web service having no secrets directory whatsoever.
Success conditionLeast privilege, enforced by the platform. A service that never declared the secret cannot reach it even from the same host.
-
The value cannot be read back
Unlike an environment variable or a Compose file on disk, a swarm secret is write-only from the API's point of view.
secret inspectreturns metadata and an empty data field - there is no command that prints the value to an operator.bash Example session docker secret inspect cg-dbpass --format "name={{.Spec.Name}} data={{.Spec.Data}}"name=cg-dbpass data=[]Expected resultAn empty data field.
Success conditionYou cannot recover a lost secret from the cluster. Rotating means creating a NEW secret and updating the service to use it - secrets are immutable once created.
-
Tear the stack down
One command removes every service and network the stack created. External secrets are deliberately left alone - the stack did not create them, so it does not destroy them.
bash Example session docker stack rm cgappRemoving service cgapp_webRemoving service cgapp_workerRemoving network cgapp_appnetdocker secret rm cg-dbpassExpected resultServices and network removed; the secret removed separately.
Success condition
docker stack lsis empty. Note the removal is asynchronous - the overlay network can linger for a few seconds while tasks detach.
Troubleshooting
secret not found when deploying
Why: The stack declares the secret
external: truebut it does not exist in the cluster yet, or the name differs.Fix:Create it first, or let the stack manage it with a
file:source instead ofexternal.bash docker secret lsThe deploy key was ignored
Why:
docker compose upignoresdeploy:anddocker stack deployignores several Compose-only keys -build,depends_onconditions,profilesand others.Fix:Build and push images before deploying a stack; a cluster cannot build from your laptop's context. Check what the cluster actually received.
bash docker stack config -c stack.yaml# also warns about unsupported keysA service is stuck at 0/N after deploying
Why: Usually a placement constraint nothing satisfies, or an image the nodes cannot pull.
Fix:Read the per-task error, then check the constraint against real node labels.
bash docker stack ps cgapp --no-trunc --format "{{.Name}} {{.CurrentState}} {{.Error}}"You need to change a secret's value
Why: Secrets are immutable. There is no update.
Fix:Create a new secret under a new name, update the service to mount it at the same target path, then remove the old one.
bash docker service update --secret-rm cg-dbpass --secret-add source=cg-dbpass-v2,target=cg-dbpass SERVICE