Skip to content
← Back to blog

Splitting compose files into reusable swarm services

· Štefan Hudáč

Diagram: Paperless, n8n and Snipe-IT connected over a shared overlay network to one Postgres, alongside Gotenberg and Mosquitto, with their separate databases crossed out

The previous post left the homelab running workloads whose desired state lives in Git. Almost every self-hosted application ships a compose file written for one machine: the application, its database, a Redis, often a document converter, all in one file. On a swarm that convenience becomes waste — each application brings its own Postgres, its own broker, its own converters.

The fix is not a bigger file. It is to split by role: stateful shared resources get their own stacks, applications get theirs, and one overlay network joins them. This post builds that split for Postgres, MariaDB, Gotenberg, Tika and Mosquitto, then runs Paperless-ngx and n8n against them.

Stacks cannot see each other

Every stack gets its own network, named after the stack. A stack called paperless gets paperless_default, a stack called n8n gets n8n_default, and service names resolve only inside their own network. That is why two stacks can both run a service called postgres without colliding — and why neither can reach the other’s database.

Sharing starts with one overlay network created outside any stack:

docker network create --driver overlay shared

Every stack that needs a shared service declares it as external:

networks:
  shared:
    external: true
    name: shared

One caveat. Inside a stack, the short service name is registered as a DNS alias, and on a shared network all of those aliases share one namespace. If two stacks both run a service called postgres and both join shared, queries round-robin between two unrelated databases. Shared services need names that are unique across the whole cluster.

One stack for the databases

Postgres and MariaDB live together because they share a lifecycle and a storage model, and because one stack is easier to back up than two.

services:
  postgres:
    image: docker.io/library/postgres:18
    environment:
      POSTGRES_DB: paperless
      POSTGRES_USER: paperless
      POSTGRES_PASSWORD: change-me
    configs:
      - source: postgres_init
        target: /docker-entrypoint-initdb.d/10-databases.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 10
    volumes:
      - pgdata:/var/lib/postgresql
    networks:
      - shared
    deploy:
      replicas: 1
      update_config:
        order: stop-first
        failure_action: rollback
      restart_policy:
        condition: on-failure

  mariadb:
    image: docker.io/library/mariadb:11.8
    environment:
      MARIADB_DATABASE: snipeit
      MARIADB_USER: snipeit
      MARIADB_PASSWORD: change-me
      MARIADB_ROOT_PASSWORD: change-me
    volumes:
      - mariadbdata:/var/lib/mysql
    networks:
      - shared
    deploy:
      replicas: 1
      update_config:
        order: stop-first
        failure_action: rollback
      restart_policy:
        condition: on-failure

configs:
  postgres_init:
    file: ./postgres-init.sql

networks:
  shared:
    external: true
    name: shared

volumes:
  pgdata:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/postgres"
  mariadbdata:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/mariadb"

The second database is created once, on first start, when the data directory is still empty:

CREATE ROLE n8n LOGIN PASSWORD 'change-me';
CREATE DATABASE n8n OWNER n8n;

replicas: 1 is not a suggestion. Two tasks mounting the same data directory corrupt it, and order: stop-first keeps a rolling update from briefly running two writers. The healthcheck is there for operators, not for Swarm: nothing waits on it.

Volumes point at NFS exports so a task can land on any node and still find its data. A named volume with the default local driver lives on one node only — if the database is rescheduled it starts with an empty directory, which looks exactly like data loss. nolock lets the client do its locking locally instead of asking the server’s lock manager; that is also why replicas have to stay at one, since two nodes writing the same directory would not see each other’s locks. Two things to verify on the NAS: the export needs no_root_squash, and the container uids — 999 for Postgres, 999 for MariaDB — must be able to write it. Otherwise the service restarts forever on permission errors.

Postgres 18 moved PGDATA to /var/lib/postgresql/18/docker and its declared volume to /var/lib/postgresql. Mounting the old /var/lib/postgresql/data path leaves the actual data outside the volume.

Shared document converters

Gotenberg and Tika are stateless: nothing to persist, nothing to schedule, nothing that cares who calls them. They get replicas and no published ports.

services:
  gotenberg:
    image: docker.io/gotenberg/gotenberg:8.37
    command:
      - gotenberg
      - --chromium-disable-javascript=true
      - --chromium-allow-list=file:///tmp/.*
    networks:
      - shared
    deploy:
      replicas: 2
      restart_policy:
        condition: on-failure

  tika:
    image: docker.io/apache/tika:3.3.1.0
    networks:
      - shared
    deploy:
      replicas: 1
      restart_policy:
        condition: on-failure

networks:
  shared:
    external: true
    name: shared

Nothing is published. The swarm keeps an internal VIP per service and balances across the replicas, so http://gotenberg:3000 reaches both tasks without the routing mesh being involved.

Shared MQTT broker

Mosquitto needs a config file and a password file. The config belongs in a swarm config, the password file in a secret:

docker run --rm -it --user "$(id -u):$(id -g)" -v "$PWD:/work" \
  docker.io/library/eclipse-mosquitto:2.1.2-alpine \
  mosquitto_passwd -c -b /work/mqtt.passwd mqtt 'a-long-password'
docker secret create mosquitto_passwd ./mqtt.passwd
services:
  mosquitto:
    image: docker.io/library/eclipse-mosquitto:2.1.2-alpine
    configs:
      - source: mosquitto_conf
        target: /mosquitto/config/mosquitto.conf
    secrets:
      - mosquitto_passwd
    volumes:
      - data:/mosquitto/data
    networks:
      - shared
    ports:
      - target: 1883
        published: 1883
      - target: 9001
        published: 9001
    deploy:
      replicas: 1
      restart_policy:
        condition: on-failure

configs:
  mosquitto_conf:
    file: ./mosquitto.conf

secrets:
  mosquitto_passwd:
    external: true

networks:
  shared:
    external: true
    name: shared

volumes:
  data:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/mosquitto"
listener 1883
protocol mqtt

listener 9001
protocol websockets

allow_anonymous false
password_file /run/secrets/mosquitto_passwd

persistence true
persistence_location /mosquitto/data/

Configs and secrets are immutable in Swarm: edit mosquitto.conf, redeploy the same stack, and it fails with “config already exists”. The config needs a new name, or has to be removed first.

Using them from Paperless

With the shared pieces in place, the application file loses its database, both converters and the last of its host paths:

services:
  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:3.3.4
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_DBHOST: postgres
      PAPERLESS_DBNAME: paperless
      PAPERLESS_DBUSER: paperless
      PAPERLESS_DBPASS: change-me
      PAPERLESS_TIKA_ENABLED: 1
      PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
      PAPERLESS_TIKA_ENDPOINT: http://tika:9998
    volumes:
      - data:/usr/src/paperless/data
      - media:/usr/src/paperless/media
      - consume:/usr/src/paperless/consume
      - export:/usr/src/paperless/export
    networks:
      - default
      - shared
    ports:
      - target: 8000
        published: 8000
    deploy:
      replicas: 1
      restart_policy:
        condition: on-failure

  broker:
    image: docker.io/valkey/valkey:9-alpine
    volumes:
      - broker:/data
    networks:
      - default
    deploy:
      replicas: 1
      restart_policy:
        condition: on-failure

networks:
  default:
  shared:
    external: true
    name: shared

volumes:
  data:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/paperless/data"
  media:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/paperless/media"
  consume:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/paperless/consume"
  export:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/paperless/export"
  broker:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/paperless/broker"

The broker stays in this stack. It is Paperless’s work queue, not a shared service, and nothing else has any business reading it. The database and both converters are resolved by name over shared, and because the volumes are NFS exports, scan folders and exports are just directories on the NAS that you can drop files into from any machine.

Using them from n8n

Same idea, with Postgres and the runners:

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n:2.44.7
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: change-me
      N8N_RUNNERS_MODE: external
      N8N_RUNNERS_AUTH_TOKEN: change-me
      N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0
    volumes:
      - data:/home/node/.n8n
    networks:
      - default
      - shared
    ports:
      - target: 5678
        published: 5678
    deploy:
      replicas: 1
      restart_policy:
        condition: on-failure

  runner:
    image: docker.io/n8nio/runners:2.44.7
    environment:
      N8N_RUNNERS_AUTH_TOKEN: change-me
      N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
    networks:
      - default
    deploy:
      replicas: 2
      restart_policy:
        condition: on-failure

networks:
  default:
  shared:
    external: true
    name: shared

volumes:
  data:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/n8n"

The n8n data volume is not optional even though the database is external: it holds the encryption key that decrypts every stored credential. Lose it and the workflows still run, with credentials nobody can read.

Backups

One backup service covers every database instead of a sidecar per application. GoBackup reads a config mounted as a swarm config and runs on a schedule:

services:
  gobackup:
    image: huacnlee/gobackup:v3.1.1
    configs:
      - source: gobackup_yml
        target: /etc/gobackup/gobackup.yml
    environment:
      POSTGRES_PASSWORD: change-me
      MARIADB_ROOT_PASSWORD: change-me
    volumes:
      - backups:/root/backups
    networks:
      - shared
    deploy:
      replicas: 1
      restart_policy:
        condition: on-failure

configs:
  gobackup_yml:
    file: ./gobackup.yml

networks:
  shared:
    external: true
    name: shared

volumes:
  backups:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=nas.example.lan,rw,nfsvers=4,proto=tcp,nolock"
      device: ":/export/swarm/backups"
models:
  databases:
    schedule:
      every: "1day"
      at: "02:30"
    compress_with:
      type: tgz
    storages:
      nas:
        type: local
        keep: 14
        path: /root/backups
    databases:
      paperless:
        type: postgresql
        host: postgres
        port: 5432
        database: paperless
        username: paperless
        password: $POSTGRES_PASSWORD
      snipeit:
        type: mysql
        host: mariadb
        port: 3306
        database: snipeit
        username: root
        password: $MARIADB_ROOT_PASSWORD

$POSTGRES_PASSWORD there is expanded by GoBackup from the service environment, not by Compose, because the config file is mounted at runtime. Compose would have required ${POSTGRES_PASSWORD} and a shell variable at deploy time.

Deploying it

docker network create --driver overlay shared
docker stack deploy -c databases.yml databases
docker stack deploy -c documents.yml documents
docker stack deploy -c mosquitto.yml mqtt
docker stack deploy -c paperless.yml paperless
docker stack deploy -c n8n.yml n8n
docker stack deploy -c backup.yml backup

The order matters only in that the shared network has to exist first and the shared services should be running before the applications call them. Nothing in Swarm enforces either — depends_on does not survive deployment, which is the subject of the next post.

What this costs

This is not the default answer, and it is not right for every situation. Putting a database on NFS turns the NAS into the database’s disk: a NAS outage stops every application that depends on it, and the export has to run with synchronous writes or fsync is a promise the server does not keep. One Postgres for many applications also means one upgrade window and one failure domain — a docker service update on it is a maintenance announcement, not a routine deploy.

The trade is deliberate: a swarm built from scrap hardware is short on memory and CPU, not on applications. In that situation one carefully run database beats five neglected ones. When the goal is a complete setup for one customer or a disposable test environment, the self-contained stack is still the better answer.