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.localDirectory 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.cfgansible.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 = sudoGroup 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: 50002. 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: yes3. 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: yesHandler
# roles/docker/handlers/main.yml
---
- name: restart docker
service:
name: docker
state: restarted4. 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 existsTraefik 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 traefik7. 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: true8. 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-deployRunning 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: 10Drain 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_linesBackup 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
- app10. Troubleshooting & Quick Reference
Common Ansible Issues
| Problem | Fix |
|---|---|
| python3-docker missing | Include in common role, or pip install docker |
| SSH key issues | ssh-copy-id deploy@node, check ansible.cfg |
| Become password | Use --ask-become-pass or vault |
| Swarm join fails | Check firewall ports (2377, 7946, 4789) |
| Registry push fails | Trust self-signed cert on all nodes |
| Traefik no routes | Check 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