Skip to content
← Back to blog

The same compose file on a laptop and on a swarm

· Štefan Hudáč

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, devices
  • shm_size, security_opt, ipc, pid, mac_address
  • cgroup_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.