The same compose file on a laptop and on a swarm
The previous post split a compose file into shared services that any stack could
use. This one is about the file itself. A compose file written for
docker compose up is not the same artefact as one handed to
docker stack deploy. The syntax looks identical, the behaviour is
not: Swarm has no start ordering, no .env file, no host-specific paths and no
build step. Most of the differences fail quietly, which is what makes them worth
knowing before a file reaches the cluster.
Two commands, two contracts
docker compose up starts containers on the node where you run it. On a manager
it says so out loud: “Compose does not use swarm mode to deploy services to
multiple nodes in a swarm.”
docker stack deploy hands the same file to the orchestrator, which creates
services, spreads replicas, wires an overlay network and has every node pull the
image from a registry. Three consequences follow immediately:
- There is no local build.
build:is ignored, so the image has to be pushed. - It speaks the legacy version 3 compose format, not the Compose specification.
- Interpolation happens in the shell you deploy from, not in a project directory.
What the file may not contain
A top-level name: is rejected outright with “additional property ‘name’ is
not allowed”. The stack name comes from the command line instead.
There is no --env-file flag, and .env is never read. Every ${VARIABLE} is
substituted from the environment of the shell running the deploy. Leave one unset
and it becomes an empty string rather than an error — which, for an image
reference, produces a bare trailing colon and invalid reference format.
env_file: does work, but it is resolved client-side: the file has to sit next
to the compose file on the machine that runs the deploy, not on the nodes.
What Swarm silently drops
Docker prints “Ignoring unsupported options: …” and carries on:
build,restart,links,network_mode,privileged,devicesshm_size,security_opt,ipc,pid,mac_addresscgroup_parent,cgroupns_mode,domainname,external_links,userns_mode
restart is the one that surprises people. On a swarm the restart policy lives
under deploy.restart_policy, and if it is absent the default is
condition: any — a container that keeps crash-looping will keep being
restarted. links is replaced by networks: services on the same overlay
network reach each other by name. container_name is deprecated and ignored,
because Swarm generates task names.
depends_on has no equivalent
There is no start ordering on a swarm, and no health gating. depends_on fails
in two different ways depending on how it is written.
The short list form parses, then disappears without a warning. The long form —
the one with condition: service_healthy — does not survive validation at all:
services.n8n.depends_on: must be a list
Either way the application has to cope with its database not being up yet, which in practice means retrying the connection instead of expecting a started dependency.
A real file, annotated
The n8n compose file is a good specimen, because it uses almost every one of these options:
volumes:
db_storage:
n8n_storage:
services:
postgres:
image: postgres:18
restart: always # ignored by swarm
environment:
- POSTGRES_USER # read from the shell at deploy time
- POSTGRES_PASSWORD
- POSTGRES_DB
- POSTGRES_NON_ROOT_USER
- POSTGRES_NON_ROOT_PASSWORD
volumes:
- db_storage:/var/lib/postgresql/data
- ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}",
]
n8n:
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
restart: always # ignored by swarm
ports:
- 5678:5678
links:
- postgres # ignored by swarm
volumes:
- n8n_storage:/home/node/.n8n
depends_on: # rejected by the v3 schema: must be a list
postgres:
condition: service_healthy
Two lines in there fail without saying so. ${POSTGRES_USER} is interpolated by
Compose, not by the shell inside the container; with the variable unset the probe
becomes pg_isready -h localhost -U -d and never passes. It needs
$$POSTGRES_USER so the container’s own shell expands it. And the pass-through
environment: - POSTGRES_USER is resolved once, at deploy time, from your shell
— not from whatever .env the project had on the laptop.
Ports, paths and volumes
Published ports work, with one difference: a host IP is dropped. 127.0.0.1:8000:8000
produces a warning — “ignoring IP-address (127.0.0.1:8000:8000/tcp) service will
listen on ‘0.0.0.0’” — and the service is reachable through the routing mesh on
every node. Use the long syntax with mode: host when you need a port bound to
one specific node.
Paths are the other silent difference. A bind mount is resolved on whichever node
the task lands on, and Docker will create an empty directory there rather than
fail. A named volume with the default local driver is no better: it lives on one
node only, so a rescheduled database starts on an empty volume and looks like
data loss. Either pin the service with deploy.placement.constraints, or put the
volume on shared storage. The companion post on shared services shows the NFS
version.
Check before you deploy
docker stack config -c paperless.yml
It merges and interpolates the file without touching the swarm, which catches a
missing variable before you deploy. It will not tell you which keys are about to
be ignored — only docker stack deploy prints that warning. Reading the deploy
output once, on a fresh stack, is worth more than any amount of schema checking.
The rule that covers all of it: on a swarm, a compose file describes services, not a machine. Anything that assumed a single host — process start order, host paths, a local Docker build — has to be replaced with something the orchestrator understands.