Aurora Libros already builds and deploys itself, but it still lives on one machine. If that machine goes down, the bookshop disappears from the internet. This lesson sets up your first cluster with Docker Swarm: the orchestration closest to what you already know, because it speaks compose.yaml and docker commands.

Contents

  1. What an orchestrator solves
  2. Swarm architecture: managers, workers and Raft
  3. Desired state and the reconciliation loop
  4. Setting up the cluster: init, join and ports
  5. Managing nodes: promote, demote and drain
  6. Services and tasks
  7. Replicas versus global mode
  8. Scaling and placement constraints
  9. Overlay networks: VXLAN between nodes
  10. The routing mesh
  11. Stacks: the compose.yaml you already have
  12. The complete deploy section
  13. Swarm's native secrets and configs
  14. Aurora Libros on a three-node cluster
  15. Swarm in 2026: when it is still the right choice

Warning. A cluster multiplies the networking, storage and access-control decisions you have to make: the ports between nodes, the encryption of internal traffic and the location of persistent data must be agreed with the infrastructure and security officer in your organization before anything goes to production.

  1. What an orchestrator solves

Problem Compose on one machine Orchestrator
The machine goes down Everything falls Tasks are rescheduled on other nodes
A container dies restart: restarts it right there It is recreated wherever there is room
More capacity is needed Only if it fits on that machine You add a node to the cluster
Releasing a version Recreate: a gap with no service Progressive replacement, no interruption
Where does each service go? There is no choice A scheduler decides from resources and rules
Balancing across replicas Nginx by hand Built-in balancing by service name
A deployment goes wrong You redeploy by hand Automatic rollback (06-07)

An orchestrator brings three things Compose on a single machine cannot give you by definition: scheduling (deciding which node each thing goes on), reconciliation (keeping the desired state whatever happens) and networking between machines (letting containers talk even when they are on different hosts).

  1. Swarm architecture: managers, workers and Raft

flowchart TB
    subgraph P["Control plane — Raft"]
        M1["manager-1 (leader)"] <--> M2[manager-2]
        M2 <--> M3[manager-3]
        M3 <--> M1
    end
    M1 -->|assigns tasks| W1[worker-1]
    M1 -->|assigns tasks| W2[worker-2]
    W1 --- W2
    style M1 fill:#e8f0fe,stroke:#3367d6
Role What it does How many
Manager Stores the state, schedules, exposes the API, takes part in Raft 1, 3, 5 or 7 (odd)
Leader The elected manager that makes the scheduling decisions 1, elected automatically
Worker Only runs tasks; makes no decisions As many as you need

The cluster state —which services exist, how many replicas, which secrets— is replicated between the managers with the Raft consensus algorithm. Accepting a change requires the agreement of a majority, the quorum, which is (N/2) + 1.

That is where the odd-number rule comes from, and it is not superstition but arithmetic:

Managers Quorum Failures tolerated Comment
1 1 0 Development. If it dies, there is no cluster
2 2 0 Worse than one: any failure blocks you
3 2 1 The reasonable minimum in production
4 3 1 The same as 3, at a higher cost
5 3 2 Large clusters

The two-manager row is the surprising one: adding a second manager worsens availability, because you need both alive to reach quorum. And there is an important consequence worth being clear about: losing quorum does not stop the running containers, but it leaves the cluster unable to decide anything; you cannot scale, deploy or reschedule until enough managers come back.

  1. Desired state and the reconciliation loop

Here is the mental shift from everything that came before. With docker run you give orders; with an orchestrator you declare how you want the world to look and it takes care of the rest.

The leader runs a permanent loop: it compares the desired state (what you declared) with the actual state (what exists), and if they differ it creates or removes tasks until they match. When they match, it waits for the next change and compares again.

docker service create --replicas 3 aurora-api does not mean "start three containers": it means "I want there to always be three". Kill one and another appears. Shut down a whole node and its tasks are reborn on the ones that remain. Nobody ran a repair command: the loop simply noticed a difference between desired and actual and corrected it.

  1. Setting up the cluster: init, join and ports

docker swarm init --advertise-addr 10.0.1.10        # on manager-1
# Swarm initialized: current node (k3f9x...) is now a manager.
# To add a worker to this swarm, run the following command:
#     docker swarm join --token SWMTKN-1-49nj1...-8vxv8 10.0.1.10:2377

The --advertise-addr is mandatory the moment the machine has more than one interface, and leaving it out is the first classic mistake: Swarm picks any old IP —often the public one, or one belonging to a VPN— and the other nodes cannot reach it.

docker swarm join-token worker             # the tokens differ by role
docker swarm join-token --rotate manager   # if a token has leaked
docker swarm join --token SWMTKN-1-49nj1...-8vxv8 10.0.1.10:2377   # on each worker
docker node ls                             # from any manager
# ID       HOSTNAME   STATUS  AVAILABILITY  MANAGER STATUS  ENGINE
# k3f9x *  manager-1  Ready   Active        Leader          27.3.1
# p8m2q    worker-1   Ready   Active                        27.3.1
# r5t7w    worker-2   Ready   Active                        27.3.1
Port Protocol What for Between
2377 TCP Cluster management API Nodes → managers
7946 TCP and UDP Discovery and gossip between nodes All ↔ all
4789 UDP VXLAN data traffic for the overlay networks All ↔ all

These three ports cause half the trouble in a new cluster, and always for the same reason: 7946 needs TCP and UDP, and 4789 is UDP. A firewall that only opens TCP produces the most baffling symptom possible: the nodes show up as Ready, the services deploy, but containers on different nodes cannot see each other. Never open 2377 to the internet: anybody who reaches it with a token can join your cluster.

  1. Managing nodes: promote, demote and drain

docker node promote worker-1 worker-2       # from worker to manager
docker node demote manager-3                # from manager to worker
docker node inspect worker-1 --format '{{.Status.State}} {{.Spec.Availability}}'
docker node update --label-add zone=a --label-add disk=ssd worker-1
Availability New tasks Existing tasks When
active Yes They stay Normal
pause No They stay Investigating without more arriving
drain No They move to other nodes Maintenance or decommissioning
docker node update --availability drain worker-1     # empty it before touching the machine
# ... maintenance, reboot, kernel upgrade ...
docker node update --availability active worker-1

The drain → maintenance → active cycle is the basic operating routine of a cluster: the tasks relocate themselves before you touch anything, and nobody is left without service. Watch out for one detail: when you switch back to active, the tasks do not return to the node on their own; they stay where they are until the next deployment.

  1. Services and tasks

Swarm introduces two new concepts on top of what you already know:

  • Service: the declaration. "I want three replicas of this image with this configuration."
  • Task: each unit of work assigned to a node. A task ends up being a container, and it is immutable: it is not modified or restarted, it is replaced by a new one.
docker service create --name aurora-api --replicas 3 \
  --network aurora-backend --env DB_HOST=aurora-db --publish published=8080,target=3000 \
  --limit-memory 512M --limit-cpu 1.0 --reserve-memory 128M \
  --health-interval 10s --health-retries 3 ghcr.io/auroralibros/aurora-api:2.0.0
Command What it shows The equivalent you already know
docker service ls Services and ready replicas docker compose ps
docker service ps <svc> Each task, its node and its history docker ps per service
docker service logs -f <svc> Aggregated logs from every replica docker compose logs -f
docker service inspect <svc> The complete specification docker inspect
docker service update <svc> Changes the desired state Edit and up -d
docker service rm <svc> Removes the service and its tasks docker compose rm

docker service ps is the main diagnostic tool, because it also shows the dead tasks and why they died:

NAME              NODE       DESIRED STATE  CURRENT STATE           ERROR
aurora-api.1      worker-1   Running        Running 4 minutes ago
aurora-api.2      worker-2   Running        Running 4 minutes ago
aurora-api.3      worker-2   Running        Running 40 seconds ago
 \_ aurora-api.3  worker-1   Shutdown       Failed 45 seconds ago   "task: non-zero exit (78)"

That last line tells a complete story: task 3 died on worker-1 with the code 78 you programmed back in 06-01, that is, invalid configuration, and the orchestrator recreated it on worker-2. Without the startup validation, it would show a generic exit (1) there.

  1. Replicas versus global mode

Mode How many tasks When you add a node What for
--mode replicated (default) As many as you ask for Nothing changes Applications: aurora-api, aurora-web
--mode global One per node, always A new one appears Agents: Promtail, cAdvisor, node-exporter
--mode replicated-job N runs and then it finishes Migrations, one-off tasks

Global mode is exactly what the metric and log collectors from module 5 need: every node needs its own cAdvisor and Promtail, and you want them to appear by themselves on every machine you add to the cluster without having to remember anything.

  1. Scaling and placement constraints

docker service scale aurora-api=5 aurora-web=3        # several at once
docker service update --replicas 2 aurora-api         # equivalent

# HARD constraints: if they are not met, the task is not scheduled (it stays Pending)
docker service update --constraint-add 'node.labels.disk==ssd' aurora-db
# SOFT preferences: they spread things out, but they do not prevent anything
docker service update --placement-pref 'spread=node.labels.zone' aurora-api
Expression Meaning
node.role==worker Workers only (keeps the managers unloaded)
node.labels.zone==a Only on nodes with that label
node.hostname!=worker-2 On any node but that one
spread=node.labels.zone An even spread across zones

The difference between a constraint and a preference matters a great deal in practice: an impossible constraint leaves the service Pending forever, with no obvious error, whereas a preference only influences the spread. If a service does not start and docker service ps shows no error, suspect a constraint that no node satisfies.

  1. Overlay networks: VXLAN between nodes

The bridge network from 03-05 only connects containers on the same host. An overlay network connects containers on different nodes as if they shared a cable.

docker network create -d overlay --attachable --subnet 10.10.0.0/24 aurora-frontend
docker network create -d overlay --opt encrypted --internal aurora-backend

Under the hood it is a VXLAN tunnel: each Ethernet packet from the container is encapsulated inside a UDP datagram aimed at port 4789 on the destination node, which unwraps it and delivers it. As far as the containers are concerned, the existence of two machines is invisible; they carry on resolving aurora-db over DNS and talking to its IP on the overlay network.

Option Effect Cost
--attachable Lets you attach standalone containers, not just services None
--opt encrypted IPsec-encrypts the traffic between nodes 10-30 % of performance
--internal No route out to the internet None
--subnet A fixed range, avoiding collisions with your corporate network None

The --opt encrypted deserves a conscious decision. VXLAN traffic travels in the clear by default: if your nodes sit on a private network inside a single data center that may be acceptable, but if they cross the internet or a shared network, the catalog, the queries and any credentials that pass through are readable by anybody with access to the medium. This is exactly the kind of decision you must validate with your security officer. Note as well: the encryption applies to the data plane, and it cannot be turned on or off without recreating the network.

  1. The routing mesh

flowchart TB
    C[Client] -->|:8080| N2["worker-2<br/>(no local replica)"]
    N2 -->|IPVS over the overlay| T1["task 1 @ worker-1"]
    N2 --> T2["task 2 @ manager-1"]
    style N2 fill:#fff4e5,stroke:#e8a33d

When you publish a port in ingress mode (the default), every node in the cluster listens on it, even those running no replica of that service. The node that receives the connection distributes it via IPVS among the live tasks, wherever they are.

curl http://worker-2:8080/health/live    # works even though worker-2 has no replicas

The advantage is enormous for the entry load balancer: point it at any node, or at all of them, and it does not need to know where anything is. There are two drawbacks, and they are worth knowing: there is an extra network hop when the receiving node does not hold the task, and the source IP your application sees is the node's, not the real client's, because there is SNAT in between.

Publishing mode Syntax Behavior
ingress (default) --publish 8080:3000 Every node listens; IPVS balancing
host --publish mode=host,published=8080,target=3000 Only the nodes with a replica; the client's real IP, no extra hop

host mode is the way out when you need the client's IP (access logging, per-IP limits, geolocation) or the last drop of latency, and it comes at a price: it forces the external load balancer to know which nodes hold replicas, and you cannot have two replicas of the same service on one node, because they would clash on the port.

  1. Stacks: the compose.yaml you already have

Here comes the payoff for all of module 4: docker stack deploy accepts your compose.yaml.

docker stack deploy -c compose.swarm.yaml --with-registry-auth aurora
docker stack ls
docker stack services aurora
docker stack ps aurora --no-trunc
docker stack rm aurora

The --with-registry-auth is essential with a private registry like ghcr.io: without it the manager understands the credentials but does not forward them to the workers, and the tasks fail with no basic auth credentials on every node except the one where you ran docker login.

Compose key In Swarm
image, environment, networks, ports, secrets, configs, healthcheck They work the same
deploy: It only takes effect here (Compose ignored it apart from resources)
build: Ignored: you have to publish the image to a registry
depends_on: Ignored: there is no startup ordering, there are retries
restart:, container_name, develop, profiles Ignored
volumes: with a relative path Dangerous: it is resolved on each node

The two ignored keys in the middle change how you design the application. Without build, the flow forces you through the registry, which is the right thing in production. And without depends_on, your API has to cope with starting before the database: that is why you implemented the retry with backoff in 04-04, and why the probes from 06-01 return 503 until the dependencies are up.

  1. The complete deploy section

services:
  aurora-api:
    image: ghcr.io/auroralibros/aurora-api:2.0.0
    deploy:
      mode: replicated
      replicas: 3
      placement:
        constraints: ["node.role==worker"]
        preferences: [{ spread: node.labels.zone }]
        max_replicas_per_node: 2
      resources:
        limits:       { cpus: "1.0", memory: 512M }
        reservations: { cpus: "0.1", memory: 128M }   # the scheduler DOES honor these
      restart_policy:
        condition: on-failure      # any | on-failure | none
        delay: 5s
        max_attempts: 3
        window: 120s
      update_config:               # covered in detail in 06-07
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config: { parallelism: 2, order: stop-first }
      labels: { com.aurora.component: api }

One nuance that separates reservations from limits and that did not exist in Compose: in Swarm, reservations affect scheduling. The scheduler adds up the reservations of the tasks already placed on each node and only puts a new one where it fits. Over-reserving leaves nodes empty with services stuck Pending; under-reserving piles tasks up until the node runs out of memory. The measured numbers from 06-01 are exactly what you need here.

  1. Swarm's native secrets and configs

printf 'aurora-dummy-secret' | docker secret create aurora_db_password -
docker config create aurora_init_sql ./db/init.sql
docker secret ls && docker config ls
services:
  aurora-api:
    secrets:
      - source: aurora_db_password
        target: db_password        # it lands in /run/secrets/db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password    # the _FILE pattern from 06-01, untouched
secrets:
  aurora_db_password:
    external: true
configs:
  aurora_init_sql:
    external: true

Secrets travel to the nodes encrypted by mutual TLS, are stored encrypted in the Raft state and are mounted on a tmpfs that never touches disk; exercise 3 verifies this dimension by dimension. On top of that, a secret is immutable: it cannot be modified. Rotating it means creating a new one under another name, updating the service with --secret-rm and --secret-add and deleting the old one. It is deliberately inconvenient: it forces rotation to be a traceable deployment and not a silent change.

configs work the same way but with no encryption at rest, and they are the natural route for init.sql, nginx.conf or prometheus.yml: configuration files that have to reach every node without you copying them by hand.

  1. Aurora Libros on a three-node cluster

# compose.swarm.yaml — the aurora stack (an extract of the four services)
services:
  aurora-web:
    image: nginx:alpine
    ports: ["80:80"]
    configs: [{ source: aurora_nginx, target: /etc/nginx/conf.d/default.conf }]
    networks: [frontend]
    deploy:
      mode: global                       # one per node: any of them serves
      update_config: { parallelism: 1, order: start-first }

  aurora-api:
    image: ghcr.io/auroralibros/aurora-api:2.0.0
    networks: [frontend, backend]
    environment:
      DB_HOST: aurora-db
      DB_USER: aurora
      DB_NAME: aurora_books
      DB_PASSWORD_FILE: /run/secrets/db_password
      REDIS_HOST: aurora-cache
    secrets: [{ source: aurora_db_password, target: db_password }]
    healthcheck:
      test: ["CMD","node","-e","require('http').get('http://127.0.0.1:3000/health/ready',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
      interval: 10s
      start_period: 20s
    deploy:
      replicas: 3
      placement: { constraints: ["node.role==worker"] }
      resources: { limits: { memory: 512M, cpus: "1.0" }, reservations: { memory: 128M } }
      update_config: { parallelism: 1, delay: 15s, order: start-first, failure_action: rollback }

  aurora-cache:
    image: redis:7-alpine
    command: ["redis-server","--maxmemory","200mb","--maxmemory-policy","allkeys-lru"]
    networks: [backend]
    deploy: { replicas: 1, resources: { limits: { memory: 256M } } }

  aurora-db:
    image: postgres:16-alpine
    environment: { POSTGRES_USER: aurora, POSTGRES_DB: aurora_books, POSTGRES_PASSWORD_FILE: /run/secrets/db_password }
    secrets: [{ source: aurora_db_password, target: db_password }]
    volumes: ["aurora-data:/var/lib/postgresql/data"]
    configs: [{ source: aurora_init_sql, target: /docker-entrypoint-initdb.d/init.sql }]
    networks: [backend]
    deploy:
      replicas: 1
      placement: { constraints: ["node.labels.data==yes"] }  # pinned to the volume's node
      update_config: { order: stop-first }   # never two PostgreSQL instances over the same data

networks:
  frontend: { driver: overlay }
  backend: { driver: overlay, internal: true, driver_opts: { encrypted: "" } }
volumes: { aurora-data: {} }
secrets: { aurora_db_password: { external: true } }
configs:
  aurora_nginx:    { external: true }
  aurora_init_sql: { external: true }
docker node update --label-add data=yes worker-1
docker stack deploy -c compose.swarm.yaml --with-registry-auth aurora
docker stack services aurora
NAME                MODE        REPLICAS  IMAGE                                    PORTS
aurora_aurora-api   replicated  3/3       ghcr.io/auroralibros/aurora-api:2.0.0
aurora_aurora-cache replicated  1/1       redis:7-alpine
aurora_aurora-db    replicated  1/1       postgres:16-alpine
aurora_aurora-web   global      3/3       nginx:alpine                             *:80->80/tcp

Warning about the data. The constraint on aurora-db is a patch, not a solution. Local volumes do not travel: if worker-1 dies, Swarm will recreate the PostgreSQL task on another node and find an empty volume, which means the catalog disappears. A real cluster needs network storage (NFS, iSCSI, Ceph, a cloud provider's block volume) or a managed database outside the cluster. Discuss this with your infrastructure officer before putting real data here: it is not a configuration detail, it is an architectural decision.

  1. Swarm in 2026: when it is still the right choice

Swarm is still included in Docker Engine and maintained, but it is a minority option: the industry standardized on Kubernetes, and that is where the managed providers, the tooling and most of the documentation live.

Situation Reasonable choice
2-10 nodes, a small team, no dedicated platform Swarm
The team already knows Compose inside out and there is no time for training Swarm
A cluster on the customer's premises, maintained by somebody else Swarm
Managed cloud (EKS, GKE, AKS) available Kubernetes
Autoscaling, operators, service mesh, policies, ecosystem Kubernetes

You will do the detailed comparison in 07-02. What is worth holding on to: almost everything in this lesson —desired state, reconciliation, services and replicas, networking between nodes, secrets mounted as files, rolling updates— is exactly the same mental model you are about to use in Kubernetes, under different names. Swarm has not been a detour: it has been the simple introduction to the same problem.

Common Mistakes and Tips

  • Two managers. It makes availability worse than one. Always 1, 3, 5 or 7.
  • Forgetting --advertise-addr. With several interfaces, Swarm picks badly and the cluster never forms. Always set it explicitly.
  • Only opening TCP in the firewall. 7946 needs UDP too and 4789 is pure UDP. The symptom is Ready nodes with a dead overlay.
  • Expecting build: to work. Swarm does not build: publish the image first and reference it by tag or digest. And do not forget --with-registry-auth, or the tasks will fail on every node except the one where you ran docker login.
  • Local volumes for data. The task is rescheduled and the volume does not follow it. Network storage or an external database.
  • Constraints no node satisfies. The service sits in Pending with no clear message. Check with docker node inspect.
  • Tip: use docker service ps --no-trunc. The full ERROR column usually contains the exact cause of the failure.
  • Tip: label your nodes from day one (zone, disk, data). Relocating services later is then a matter of one constraint.

Exercises

Exercise 1. Set up a three-node cluster, deploy aurora-api with three replicas and demonstrate reconciliation: kill a container by hand and then drain a whole node, checking in each case where the tasks reappear.

Exercise 2. Demonstrate the routing mesh: publish aurora-web with a single replica, check that it answers from all three nodes and find out which one is actually serving.

Exercise 3. Compare a Swarm secret with an environment variable: try to extract both values from outside the container with docker inspect and from inside through /proc, and explain where each one physically lives.

Solutions

Solution 1.

docker service ps aurora_aurora-api --format '{{.Name}}\t{{.Node}}\t{{.CurrentState}}'
docker kill "$(docker ps -q --filter name=aurora_aurora-api)"     # run on worker-1
sleep 8
docker service ps aurora_aurora-api --format '{{.Name}}\t{{.Node}}\t{{.CurrentState}}'
aurora_aurora-api.1   worker-1   Running 6 minutes ago
aurora_aurora-api.2   worker-2   Running 6 minutes ago
aurora_aurora-api.3   worker-2   Running 6 minutes ago
--- after the kill ---
aurora_aurora-api.1   worker-1   Running 5 seconds ago
 \_ aurora_aurora-api.1  worker-1  Failed 7 seconds ago  "task: non-zero exit (137)"
docker node update --availability drain worker-2 && sleep 15
docker service ps aurora_aurora-api --filter desired-state=running --format '{{.Name}}\t{{.Node}}'
docker service ls --filter name=aurora_aurora-api
# aurora_aurora-api.1   worker-1
# aurora_aurora-api.2   manager-1
# aurora_aurora-api.3   worker-1
# aurora_aurora-api   replicated   3/3

The two tests show the same machinery at two scales. When you killed the container, task .1 was recreated on the same node, because the node was still healthy and was the preferred spot; the 137 is the SIGKILL from your docker kill, and the history line with \_ preserves the failed attempt. When you drained worker-2, its two tasks were relocated onto the other two nodes, and the count went back to 3/3 in about fifteen seconds.

What matters is what you did not do: you ran no repair command. You declared "I want three" once, and the reconciliation loop keeps that statement true in the face of a dead container, a drained node or a machine that gets switched off. It is the same mechanism that governs a Deployment in Kubernetes, and it is why restart: always stops making sense here: the restart policy belongs to the service, not to the container.

Notice as well that task .2 ended up on manager-1. With the node.role==worker constraint from compose.swarm.yaml it could not have: it would have stayed Pending until worker-2 came back. That is the trade-off between protecting the managers and having somewhere to reschedule.

Solution 2.

docker service update --replicas 1 aurora_aurora-web
docker service ps aurora_aurora-web --filter desired-state=running --format '{{.Node}}'
for n in manager-1 worker-1 worker-2; do
  printf '%s -> %s\n' "$n" "$(curl -s -o /dev/null -w '%{http_code}' http://$n/health/live)"
done
# worker-2
# manager-1 -> 200
# worker-1  -> 200
# worker-2  -> 200

All three nodes answer 200 even though only worker-2 is running the replica. That is the routing mesh: by publishing in ingress mode, every node opens port 80 and forwards over IPVS through the overlay network to wherever the live tasks are.

docker service logs aurora_aurora-web --tail 3
sudo iptables -t nat -L DOCKER-INGRESS -n | head -4
# 10.0.0.2 - - [05/Aug/2026:11:42:07] "GET /health/live HTTP/1.1" 200
# DNAT  tcp  0.0.0.0/0  0.0.0.0/0  tcp dpt:80 to:172.18.0.2:80

And there is the side effect you need to know about: the IP recorded is 10.0.0.2, an address on the ingress network, not the real client's. The DNAT in the nat table rewrites the destination and, along the way, the SNAT replaces the source. If Aurora Libros needed the real IP —to rate-limit per client or for analytics—, you would have to publish in mode=host, accepting that only the nodes with a replica would then answer and that the external load balancer has to know that.

The advantage pays off in most cases: the entry load balancer can point at all three nodes without knowing anything about where each service lives, and adding or removing replicas does not force it to change its configuration.

Solution 3.

docker service create --name env-test --env KEY=dummy-in-environment alpine sleep 600
docker service create --name secret-test --secret aurora_db_password alpine sleep 600

docker service inspect env-test --format '{{.Spec.TaskTemplate.ContainerSpec.Env}}'
docker service inspect secret-test --format '{{json .Spec.TaskTemplate.ContainerSpec.Secrets}}' | jq -r '.[0].SecretName'
# [KEY=dummy-in-environment]
# aurora_db_password

The asymmetry is total from the very first check: the variable is read in full from the service specification, which anybody with access to the Docker API can query; of the secret you only see the name and the mount point.

cid=$(docker ps -q --filter name=secret-test)
docker exec "$cid" cat /run/secrets/aurora_db_password; echo
docker exec "$cid" df -h /run/secrets | tail -1
sudo tr '\0' '\n' < /proc/$(docker inspect "$cid" --format '{{.State.Pid}}')/environ | grep -c KEY
# aurora-dummy-secret
# tmpfs   64M  4.0K  64M   1%  /run/secrets
# 0
Dimension Environment variable Swarm secret
In docker service inspect The full value Only the name
In /proc/<pid>/environ Readable Absent
Physical medium The process's memory A 64 MB tmpfs (RAM)
In the cluster state In the clear in Raft Encrypted in Raft
Inheritance by subprocesses Automatic None
When the task stops It is unmounted and disappears

The tmpfs on the second line is the key to the design: the secret never touches disk on any node. When the task stops, the filesystem is unmounted and the value ceases to exist; it is not left in an image layer, or a volume, or a log file, or a backup of the node's filesystem.

The inheritance row is the one that causes the most incidents in practice. An environment variable is visible to every subprocess: if your API invokes pg_dump, a deployment script or any third-party tool, that password goes with them, and all it takes is one library logging its environment when it fails —plenty of them do— for the credential to end up in an external error-tracking service. The file in /run/secrets/ is only read by whoever decides to read it, which is exactly what your config.js from 06-01 does, once, at startup.

Conclusion

Aurora Libros no longer depends on a single machine. You know what an orchestrator brings —scheduling, reconciliation and networking between nodes— and you have built a Swarm cluster with docker swarm init --advertise-addr, different tokens per role and the three ports that need opening, with the detail that costs people whole afternoons: 7946 on TCP and UDP, 4789 on UDP. You understand why managers should be an odd number and why two are worse than one, and you can run the drain → maintenance → active routine that lets you touch a machine without anybody losing the ability to buy books.

You have changed mental model: you no longer give orders, you declare a desired state and a loop keeps it true. You verified it by killing a container and draining a whole node, watching the tasks reappear on their own without you running a single repair. You can tell a service from a task, replicas from global mode —the one Promtail and cAdvisor need—, and you know that an impossible constraint leaves a service silently Pending. You have created overlay networks over VXLAN tunnels, with a conscious decision about --opt encrypted for traffic crossing networks you do not control, and you have seen the routing mesh answer from all three nodes with a single live replica, along with its price: the IP Nginx logs belongs to the ingress network, not to the client.

The compose.yaml from module 4 has become a stack with docker stack deploy, with the table of what Swarm ignores —build, depends_on, restart— and the complete deploy section, where reservations are no longer decorative because they govern scheduling. Native secrets have demonstrated their advantage measurably: invisible in inspect, absent from /proc/<pid>/environ, mounted on a tmpfs that never touches disk and not inherited by subprocesses, while the _FILE pattern from 06-01 keeps working without a single line of code changing. And the real limitation is noted in red: local volumes do not travel, so aurora-db is pinned by a node label and that is a patch which has to be replaced by network storage or a managed database before you put real data here.

In the next lesson, Introduction to Kubernetes, you change tools but not ideas. You will see the control plane with its kube-apiserver, its etcd and its scheduler, the fundamental objects —Pod, Deployment, Service, Ingress, ConfigMap, Secret, PVC— with their minimal annotated YAML, the central role of labels and selectors, the kubectl table translated into the Docker commands you already have down, and you will set up a local cluster with kind to take your first steps.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved