← Back to Observability

Ansible: Docker Swarm Setup Guide

Step-by-Step — From Bare Nodes to Production Swarm with Custom Domain, Registry & Traefik

1. Prerequisites & Inventory

Inventory File

# inventory/hosts.ini
[swarm_managers]
manager-1 ansible_host=10.0.0.1 ansible_user=deploy

[swarm_workers]
worker-1 ansible_host=10.0.0.2 ansible_user=deploy
worker-2 ansible_host=10.0.0.3 ansible_user=deploy

[swarm:children]
swarm_managers
swarm_workers

[swarm:vars]
ansible_python_interpreter=/usr/bin/python3
swarm_interface=eth0
domain=myapp.local
registry_host=registry.myapp.local

Directory Structure

ansible-swarm/
├── inventory/
│   ├── hosts.ini
│   └── group_vars/
│       ├── all.yml
│       └── swarm.yml
├── roles/
│   ├── common/
│   ├── docker/
│   ├── swarm-init/
│   ├── swarm-join/
│   ├── registry/
│   ├── traefik/
│   └── app-deploy/
├── playbooks/
│   ├── site.yml
│   ├── setup-nodes.yml
│   ├── init-swarm.yml
│   ├── deploy-app.yml
│   └── rolling-update.yml
└── ansible.cfg

ansible.cfg

# ansible.cfg
[defaults]
inventory = inventory/hosts.ini
roles_path = roles
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml

[privilege_escalation]
become = True
become_method = sudo

Group Variables

# inventory/group_vars/all.yml
---
docker_version: "5:24.0*"
swarm_advertise_addr: "{{ ansible_host }}"
app_domain: myapp.example.com
traefik_version: "v3.0"
registry_port: 5000

2. Step 1 — Base System Setup (role: common)

# roles/common/tasks/main.yml
---
- name: Update apt cache
  apt:
    update_cache: yes
    cache_valid_time: 3600

- name: Install base packages
  apt:
    name:
      - apt-transport-https
      - ca-certificates
      - curl
      - gnupg
      - lsb-release
      - python3-pip
      - python3-docker
      - ufw
      - ntp
      - jq
      - htop
    state: present

- name: Set hostname
  hostname:
    name: "{{ inventory_hostname }}"

- name: Update /etc/hosts with all swarm nodes
  lineinfile:
    path: /etc/hosts
    line: "{{ hostvars[item].ansible_host }} {{ item }}"
    state: present
  loop: "{{ groups['swarm'] }}"

- name: Configure UFW firewall
  ufw:
    rule: allow
    port: "{{ item.port }}"
    proto: "{{ item.proto }}"
  loop:
    - { port: "22", proto: "tcp" }
    - { port: "80", proto: "tcp" }
    - { port: "443", proto: "tcp" }
    - { port: "2377", proto: "tcp" }    # Swarm management
    - { port: "7946", proto: "tcp" }    # Node communication
    - { port: "7946", proto: "udp" }    # Node communication
    - { port: "4789", proto: "udp" }    # Overlay network
    - { port: "5000", proto: "tcp" }    # Docker registry

- name: Enable UFW
  ufw:
    state: enabled
    policy: deny

- name: Set timezone
  timezone:
    name: Europe/Zurich

- name: Enable NTP
  service:
    name: ntp
    state: started
    enabled: yes

3. Step 2 — Docker Installation (role: docker)

# roles/docker/tasks/main.yml
---
- name: Add Docker GPG key
  apt_key:
    url: https://download.docker.com/linux/ubuntu/gpg
    state: present

- name: Add Docker repository
  apt_repository:
    repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
    state: present

- name: Install Docker
  apt:
    name:
      - docker-ce={{ docker_version }}
      - docker-ce-cli={{ docker_version }}
      - containerd.io
      - docker-compose-plugin
    state: present
    update_cache: yes

- name: Add deploy user to docker group
  user:
    name: "{{ ansible_user }}"
    groups: docker
    append: yes

- name: Configure Docker daemon
  copy:
    content: |
      {
        "log-driver": "json-file",
        "log-opts": {
          "max-size": "10m",
          "max-file": "3"
        },
        "storage-driver": "overlay2",
        "metrics-addr": "0.0.0.0:9323",
        "experimental": true,
        "live-restore": true,
        "default-address-pools": [
          {"base": "172.80.0.0/16", "size": 24}
        ]
      }
    dest: /etc/docker/daemon.json
  notify: restart docker

- name: Ensure Docker is running
  service:
    name: docker
    state: started
    enabled: yes

Handler

# roles/docker/handlers/main.yml
---
- name: restart docker
  service:
    name: docker
    state: restarted

4. Step 3 — Swarm Initialization (role: swarm-init)

# roles/swarm-init/tasks/main.yml
---
- name: Check if Swarm is already initialized
  command: docker info --format '{{ "{{" }}.Swarm.LocalNodeState{{ "}}" }}'
  register: swarm_status
  changed_when: false

- name: Initialize Docker Swarm
  command: >
    docker swarm init
    --advertise-addr {{ swarm_advertise_addr }}
  when: swarm_status.stdout != "active"
  register: swarm_init

- name: Get worker join token
  command: docker swarm join-token -q worker
  register: worker_token
  changed_when: false

- name: Get manager join token
  command: docker swarm join-token -q manager
  register: manager_token
  changed_when: false

- name: Store tokens as facts
  set_fact:
    swarm_worker_token: "{{ worker_token.stdout }}"
    swarm_manager_token: "{{ manager_token.stdout }}"
    swarm_manager_addr: "{{ swarm_advertise_addr }}:2377"

Worker Join Role

# roles/swarm-join/tasks/main.yml
---
- name: Check if already in swarm
  command: docker info --format '{{ "{{" }}.Swarm.LocalNodeState{{ "}}" }}'
  register: swarm_status
  changed_when: false

- name: Join Swarm as worker
  command: >
    docker swarm join
    --token {{ hostvars[groups['swarm_managers'][0]].swarm_worker_token }}
    {{ hostvars[groups['swarm_managers'][0]].swarm_manager_addr }}
  when: swarm_status.stdout != "active"

5. Step 4 — Private Registry Setup (role: registry)

# roles/registry/tasks/main.yml
---
- name: Create registry directories
  file:
    path: "{{ item }}"
    state: directory
    mode: "0755"
  loop:
    - /opt/registry
    - /opt/registry/data
    - /opt/registry/auth
    - /opt/registry/certs

- name: Generate self-signed certificate for registry
  command: >
    openssl req -newkey rsa:4096 -nodes -sha256
    -keyout /opt/registry/certs/registry.key
    -x509 -days 365
    -out /opt/registry/certs/registry.crt
    -subj "/CN={{ registry_host }}"
    -addext "subjectAltName=DNS:{{ registry_host }},IP:{{ ansible_host }}"
  args:
    creates: /opt/registry/certs/registry.crt

- name: Create htpasswd for registry auth
  command: >
    docker run --rm --entrypoint htpasswd
    httpd:2 -Bbn admin {{ registry_password }}
  register: htpasswd_output

- name: Write htpasswd file
  copy:
    content: "{{ htpasswd_output.stdout }}"
    dest: /opt/registry/auth/htpasswd

- name: Deploy registry as swarm service
  command: >
    docker service create
    --name registry
    --publish {{ registry_port }}:5000
    --mount type=bind,source=/opt/registry/data,target=/var/lib/registry
    --mount type=bind,source=/opt/registry/certs,target=/certs
    --mount type=bind,source=/opt/registry/auth,target=/auth
    --env REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry.crt
    --env REGISTRY_HTTP_TLS_KEY=/certs/registry.key
    --env REGISTRY_AUTH=htpasswd
    --env REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm"
    --env REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd
    --constraint 'node.role==manager'
    registry:2

- name: Trust registry cert on all nodes
  copy:
    src: /opt/registry/certs/registry.crt
    dest: /etc/docker/certs.d/{{ registry_host }}:{{ registry_port }}/ca.crt
    remote_src: yes
  delegate_to: "{{ item }}"
  loop: "{{ groups['swarm'] }}"

6. Step 5 — Custom Domain & Traefik (role: traefik)

# roles/traefik/tasks/main.yml
---
- name: Create Traefik directories
  file:
    path: "{{ item }}"
    state: directory
  loop:
    - /opt/traefik
    - /opt/traefik/config
    - /opt/traefik/certs

- name: Create Traefik static config
  copy:
    dest: /opt/traefik/traefik.yml
    content: |
      api:
        dashboard: true
        insecure: true

      entryPoints:
        web:
          address: ":80"
          http:
            redirections:
              entryPoint:
                to: websecure
                scheme: https
        websecure:
          address: ":443"

      providers:
        docker:
          endpoint: "unix:///var/run/docker.sock"
          swarmMode: true
          exposedByDefault: false
          network: traefik-public

      certificatesResolvers:
        letsencrypt:
          acme:
            email: admin@{{ app_domain }}
            storage: /certs/acme.json
            httpChallenge:
              entryPoint: web

# For local/development with custom domain:
- name: Add custom domain to /etc/hosts on all nodes
  lineinfile:
    path: /etc/hosts
    line: "{{ hostvars[groups['swarm_managers'][0]].ansible_host }} {{ app_domain }} {{ registry_host }} traefik.{{ app_domain }}"
    state: present
  delegate_to: "{{ item }}"
  loop: "{{ groups['swarm'] }}"

- name: Create traefik-public overlay network
  command: docker network create --driver overlay --attachable traefik-public
  ignore_errors: yes  # fails if already exists

Traefik Stack File & Deploy

- name: Deploy Traefik stack
  copy:
    dest: /opt/traefik/docker-stack.yml
    content: |
      version: "3.8"
      services:
        traefik:
          image: traefik:{{ traefik_version }}
          ports:
            - "80:80"
            - "443:443"
            - "8080:8080"
          volumes:
            - /var/run/docker.sock:/var/run/docker.sock:ro
            - /opt/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
            - /opt/traefik/certs:/certs
          networks:
            - traefik-public
          deploy:
            placement:
              constraints:
                - node.role == manager
            labels:
              - "traefik.enable=true"
              - "traefik.http.routers.dashboard.rule=Host(`traefik.{{ app_domain }}`)"
              - "traefik.http.routers.dashboard.service=api@internal"
              - "traefik.http.services.dashboard.loadbalancer.server.port=8080"

      networks:
        traefik-public:
          external: true

- name: Deploy Traefik
  command: docker stack deploy -c /opt/traefik/docker-stack.yml traefik

7. Step 6 — Build, Push & Deploy Application

# roles/app-deploy/tasks/main.yml
---
# Build locally and push to swarm registry
- name: Build application image
  command: >
    docker build -t {{ registry_host }}:{{ registry_port }}/{{ app_name }}:{{ app_version }}
    {{ app_build_context }}
  delegate_to: localhost
  become: no

- name: Push image to registry
  command: >
    docker push {{ registry_host }}:{{ registry_port }}/{{ app_name }}:{{ app_version }}
  delegate_to: localhost
  become: no

- name: Create app stack file
  template:
    src: app-stack.yml.j2
    dest: /opt/app/docker-stack.yml

- name: Deploy application stack
  command: >
    docker stack deploy
    --with-registry-auth
    -c /opt/app/docker-stack.yml
    {{ app_name }}

App Stack Template

# roles/app-deploy/templates/app-stack.yml.j2
version: "3.8"

services:
  app:
    image: {{ registry_host }}:{{ registry_port }}/{{ app_name }}:{{ app_version }}
    deploy:
      replicas: {{ app_replicas | default(3) }}
      update_config:
        parallelism: 1
        delay: 30s
        failure_action: rollback
        order: start-first
      labels:
        - "traefik.enable=true"
        - "traefik.http.routers.{{ app_name }}.rule=Host(`{{ app_domain }}`)"
        - "traefik.http.routers.{{ app_name }}.entrypoints=websecure"
        - "traefik.http.routers.{{ app_name }}.tls.certresolver=letsencrypt"
        - "traefik.http.services.{{ app_name }}.loadbalancer.server.port={{ app_port }}"
    networks:
      - traefik-public
      - backend
    secrets:
      - db_password
    environment:
      DATABASE_URL: "postgres://app:{{ '{{' }} .Secrets.db_password {{ '}}' }}@db:5432/{{ app_name }}"

networks:
  traefik-public:
    external: true
  backend:
    driver: overlay
    internal: true

secrets:
  db_password:
    external: true

8. Master Playbooks

# playbooks/site.yml — Full setup from scratch
---
- name: Setup base system
  hosts: swarm
  roles:
    - common
    - docker

- name: Initialize Swarm
  hosts: swarm_managers[0]
  roles:
    - swarm-init

- name: Join workers to Swarm
  hosts: swarm_workers
  roles:
    - swarm-join

- name: Setup Registry
  hosts: swarm_managers[0]
  roles:
    - registry

- name: Setup Traefik
  hosts: swarm_managers[0]
  roles:
    - traefik

- name: Deploy Application
  hosts: swarm_managers[0]
  roles:
    - app-deploy

Running Playbooks

# Run it all
ansible-playbook playbooks/site.yml

# Or step by step
ansible-playbook playbooks/setup-nodes.yml
ansible-playbook playbooks/init-swarm.yml
ansible-playbook playbooks/deploy-app.yml

# Deploy new version
ansible-playbook playbooks/deploy-app.yml -e "app_version=2.1.0"

# Rolling update
ansible-playbook playbooks/rolling-update.yml -e "app_version=2.1.0"

# Add new worker node
ansible-playbook playbooks/site.yml --limit new-worker-4

# Run only on specific tags
ansible-playbook playbooks/site.yml --tags "docker,swarm"

9. Day-2 Operations Playbooks

Rolling Update

# playbooks/rolling-update.yml
---
- hosts: swarm_managers[0]
  tasks:
    - name: Build and push new image
      command: >
        docker build -t {{ registry_host }}:{{ registry_port }}/{{ app_name }}:{{ app_version }}
        {{ app_build_context }}
      delegate_to: localhost
      become: no

    - name: Push image
      command: docker push {{ registry_host }}:{{ registry_port }}/{{ app_name }}:{{ app_version }}
      delegate_to: localhost
      become: no

    - name: Update service image
      command: >
        docker service update
        --image {{ registry_host }}:{{ registry_port }}/{{ app_name }}:{{ app_version }}
        --with-registry-auth
        {{ app_name }}_app

    - name: Wait for rollout
      command: >
        docker service inspect --format '{{ "{{" }}.UpdateStatus.State{{ "}}" }}'
        {{ app_name }}_app
      register: update_status
      until: update_status.stdout == "completed"
      retries: 30
      delay: 10

Drain Node for Maintenance

# playbooks/drain-node.yml
---
- hosts: swarm_managers[0]
  tasks:
    - name: Drain node for maintenance
      command: docker node update --availability drain {{ target_node }}

    - name: Wait for tasks to migrate
      pause:
        seconds: 30

    - name: Show node status
      command: docker node ls
      register: node_status

    - debug:
        var: node_status.stdout_lines

Backup Swarm

# playbooks/backup-swarm.yml
---
- hosts: swarm_managers[0]
  tasks:
    - name: Create backup directory
      file:
        path: /backup/swarm/{{ ansible_date_time.date }}
        state: directory

    - name: Stop Docker (briefly) to backup Raft data
      service:
        name: docker
        state: stopped

    - name: Backup Swarm state
      archive:
        path: /var/lib/docker/swarm
        dest: /backup/swarm/{{ ansible_date_time.date }}/swarm-raft.tar.gz

    - name: Start Docker
      service:
        name: docker
        state: started

    - name: Backup stack files
      copy:
        src: /opt/{{ item }}/docker-stack.yml
        dest: /backup/swarm/{{ ansible_date_time.date }}/{{ item }}-stack.yml
        remote_src: yes
      loop:
        - traefik
        - app

10. Troubleshooting & Quick Reference

Common Ansible Issues

ProblemFix
python3-docker missingInclude in common role, or pip install docker
SSH key issuesssh-copy-id deploy@node, check ansible.cfg
Become passwordUse --ask-become-pass or vault
Swarm join failsCheck firewall ports (2377, 7946, 4789)
Registry push failsTrust self-signed cert on all nodes
Traefik no routesCheck traefik-public network, service labels

Quick Command Reference

# Test connectivity
ansible swarm -m ping

# Run ad-hoc command on all nodes
ansible swarm -a "docker node ls"

# Check Docker version on all nodes
ansible swarm -a "docker version --format '{{ '{{' }}.Server.Version{{ '}}' }}'"

# View swarm status from any manager
ansible swarm_managers -a "docker service ls" --limit "swarm_managers[0]"

# Encrypt secrets with Ansible Vault
ansible-vault encrypt_string 'mysecretpassword' --name 'db_password'

# Run with vault
ansible-playbook site.yml --ask-vault-pass