CertGrid CertGrid
Hands-on Lab·Docker

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

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.

Three hosts, because a swarm needs more than one. DOCKER01 is the single manager and Leader; the other two are workers. One manager means no fault tolerance, which several of these guides make a point of - three would tolerate one failure.
Server NameIP AddressOSRolesCPURAMHDD
DOCKER01192.168.0.21Ubuntu 26.04 LTSSwarm Manager (Leader)2 Core4 GB50 GB
DOCKER02192.168.0.22Ubuntu 26.04 LTSSwarm Worker2 Core4 GB50 GB
DOCKER03192.168.0.23Ubuntu 26.04 LTSSwarm Worker2 Core4 GB50 GB

Before you start

  1. 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 ago

    Expected resultA secret ID and the secret listed by name.

    Success conditionThe secret exists cluster-wide. Piping from echo puts it in your shell history - use a file or a pipe from your password manager for anything real.

  2. 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. Note max_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.

  3. 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_worker

    Expected 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.22

    Success condition4/4 (max 2 per node) - the constraint is visible in the status, not just in the file.

  4. 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 ago

    Expected 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-docker03

    Success conditionThe constraint held. This is the pattern for keeping load off managers in a cluster where managers also run work.

  5. 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 directory

    Expected 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.

  6. 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 inspect returns 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.

  7. 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-dbpass

    Expected resultServices and network removed; the secret removed separately.

    Success conditiondocker stack ls is empty. Note the removal is asynchronous - the overlay network can linger for a few seconds while tasks detach.

Troubleshooting

Official sources