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
- What an orchestrator solves
- Swarm architecture: managers, workers and Raft
- Desired state and the reconciliation loop
- Setting up the cluster:
init,joinand ports - Managing nodes: promote, demote and drain
- Services and tasks
- Replicas versus global mode
- Scaling and placement constraints
- Overlay networks: VXLAN between nodes
- The routing mesh
- Stacks: the
compose.yamlyou already have - The complete
deploysection - Swarm's native secrets and configs
- Aurora Libros on a three-node cluster
- 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.
- 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).
- 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.
- 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.
- Setting up the cluster:
init, join and ports
init, join and portsdocker 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:2377The --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.
- 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-1Availability |
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-1The 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.
- 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.
- 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.
- 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.
- 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-backendUnder 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.
- 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.
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.
- Stacks: the
compose.yaml you already have
compose.yaml you already haveHere 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 auroraThe --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.
- The complete
deploy section
deploy sectionservices:
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.
- 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 lsservices:
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: trueSecrets 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.
- 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 auroraNAME 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/tcpWarning about the data. The
constraintonaurora-dbis a patch, not a solution. Local volumes do not travel: ifworker-1dies, 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.
- 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
Readynodes 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 randocker 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
Pendingwith no clear message. Check withdocker node inspect. - Tip: use
docker service ps --no-trunc. The fullERRORcolumn 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/3The 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 -> 200All 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:80And 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_passwordThe 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
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
