Skip to content

Ansible — Recap

What it is

Configuration management tool. SSHes into machines and runs tasks — installs packages, runs commands, writes files. You write playbooks in YAML describing what the machine should look like. Ansible makes it so.

Agentless — nothing installed on target machines. Just SSH and Python (Python must exist on the target, which it does on Ubuntu by default).

Idempotent by design — run the same playbook twice, second run changes nothing if everything is already in the correct state.


Where it runs

Your local machine, same as Terraform. Not on your VMs, not in your Swarm.

Your laptop
  └── ansible-playbook playbook.yml
        └── SSHes into manager → runs tasks
        └── SSHes into worker  → runs tasks

Installation

pip install ansible

# verify
ansible --version

Where it fits relative to Terraform

Terraform     provisions VMs, networking, Azure services, SP
               outputs IPs and SP credentials
               stops here

Ansible       reads Terraform outputs
               SSHes into VMs
               installs Docker, inits Swarm, joins worker, loads Docker secrets
               stops here

GitHub Actions  docker stack deploy

Private key lives only in Ansible. Terraform never needs it.


Project structure

ansible/
  inventory.yml     # which machines, how to connect
  playbook.yml      # what to do on them
  vars/
    secrets.yml     # sensitive values — can be encrypted with ansible-vault

Inventory

Tells Ansible which machines exist and how to connect to them. IPs come from Terraform outputs.

# inventory.yml
all:
  children:
    manager:
      hosts:
        aver-manager:
          ansible_host: "{{ manager_ip }}"
          ansible_user: azureuser
          ansible_ssh_private_key_file: ~/.ssh/id_rsa
    worker:
      hosts:
        aver-worker:
          ansible_host: "{{ worker_ip }}"
          ansible_user: azureuser
          ansible_ssh_private_key_file: ~/.ssh/id_rsa

Playbook

Describes what to do on each group of machines. Tasks run in order, top to bottom.

# playbook.yml

# ── Manager ──────────────────────────────────────────────
- hosts: manager
  become: true    # sudo
  tasks:
    - name: Install Docker
      apt:
        name: docker.io
        state: present
        update_cache: yes

    - name: Install Git
      apt:
        name: git
        state: present

    - name: Init Swarm
      shell: >
        docker swarm init
        --advertise-addr {{ ansible_host }}
        --default-addr-pool 10.20.0.0/16
        --default-addr-pool-mask-length 24
      register: swarm_init
      ignore_errors: true    # idempotent — won't fail if already initialised

    - name: Get worker join token
      shell: docker swarm join-token worker -q
      register: join_token

    - name: Load azure_tenant_id as Docker secret
      shell: echo "{{ sp_tenant_id }}" | docker secret create azure_tenant_id -
      ignore_errors: true    # idempotent — won't fail if secret already exists

    - name: Load azure_client_id as Docker secret
      shell: echo "{{ sp_client_id }}" | docker secret create azure_client_id -
      ignore_errors: true

    - name: Load azure_client_secret as Docker secret
      shell: echo "{{ sp_client_secret }}" | docker secret create azure_client_secret -
      ignore_errors: true

# ── Worker ───────────────────────────────────────────────
- hosts: worker
  become: true
  tasks:
    - name: Install Docker
      apt:
        name: docker.io
        state: present
        update_cache: yes

    - name: Join Swarm
      shell: >
        docker swarm join
        --token {{ hostvars['aver-manager']['join_token'].stdout }}
        {{ hostvars['aver-manager']['ansible_host'] }}:2377
      ignore_errors: true    # idempotent — won't fail if already joined

Passing Terraform outputs into Ansible

# read from Terraform
MANAGER_IP=$(terraform output -raw manager_public_ip)
WORKER_IP=$(terraform output -raw worker_public_ip)
SP_TENANT=$(terraform output -raw sp_tenant_id)
SP_CLIENT=$(terraform output -raw sp_client_id)
SP_SECRET=$(terraform output -raw sp_client_secret)

# run Ansible with those values
ansible-playbook playbook.yml \
  -i inventory.yml \
  --extra-vars "manager_ip=$MANAGER_IP \
                worker_ip=$WORKER_IP \
                sp_tenant_id=$SP_TENANT \
                sp_client_id=$SP_CLIENT \
                sp_client_secret=$SP_SECRET"

Running it

ansible-playbook playbook.yml -i inventory.yml

You watch tasks execute in the terminal. Green = success, yellow = changed, red = failed.


Key concepts

become: true — run as sudo on the remote machine

register — capture the output of a task into a variable for use in later tasks. Used to capture the Swarm join token from the manager and pass it to the worker.

hostvars — access variables from a different host in the inventory. How the worker task reads the join token that was registered on the manager.

ignore_errors: true — don't fail the playbook if this task fails. Used for idempotency on tasks that error if already done (Swarm already init, secret already exists).

ansible-vault — encrypts sensitive files at rest in the repo. You'd encrypt vars/secrets.yml so SP credentials aren't plaintext in Git.


The full bootstrapping sequence

# 1. provision infrastructure
terraform apply

# 2. capture outputs and configure cluster
MANAGER_IP=$(terraform output -raw manager_public_ip)
WORKER_IP=$(terraform output -raw worker_public_ip)
SP_TENANT=$(terraform output -raw sp_tenant_id)
SP_CLIENT=$(terraform output -raw sp_client_id)
SP_SECRET=$(terraform output -raw sp_client_secret)

ansible-playbook playbook.yml \
  -i inventory.yml \
  --extra-vars "manager_ip=$MANAGER_IP worker_ip=$WORKER_IP \
                sp_tenant_id=$SP_TENANT sp_client_id=$SP_CLIENT \
                sp_client_secret=$SP_SECRET"

# 3. deploy the stack (manual first time, CI after)
docker stack deploy -c stack.yml aver

Full runnable code — complete Ansible setup

inventory.yml

all:
  children:
    manager:
      hosts:
        aver-manager:
          ansible_host: "{{ manager_ip }}"
          ansible_user: azureuser
          ansible_ssh_private_key_file: ~/.ssh/id_rsa
    worker:
      hosts:
        aver-worker:
          ansible_host: "{{ worker_ip }}"
          ansible_user: azureuser
          ansible_ssh_private_key_file: ~/.ssh/id_rsa

playbook.yml

# ── Manager ──────────────────────────────────────────────
- hosts: manager
  become: true
  tasks:
    - name: Update apt cache
      apt:
        update_cache: yes

    - name: Install Docker
      apt:
        name: docker.io
        state: present

    - name: Install Git
      apt:
        name: git
        state: present

    - name: Add azureuser to docker group
      user:
        name: azureuser
        groups: docker
        append: yes

    - name: Init Swarm
      shell: >
        docker swarm init
        --advertise-addr {{ ansible_host }}
        --default-addr-pool 10.20.0.0/16
        --default-addr-pool-mask-length 24
      register: swarm_init
      ignore_errors: true

    - name: Get worker join token
      shell: docker swarm join-token worker -q
      register: join_token

    - name: Load azure_tenant_id as Docker secret
      shell: |
        echo "{{ sp_tenant_id }}" | docker secret create azure_tenant_id - 2>/dev/null || true

    - name: Load azure_client_id as Docker secret
      shell: |
        echo "{{ sp_client_id }}" | docker secret create azure_client_id - 2>/dev/null || true

    - name: Load azure_client_secret as Docker secret
      shell: |
        echo "{{ sp_client_secret }}" | docker secret create azure_client_secret - 2>/dev/null || true

# ── Worker ───────────────────────────────────────────────
- hosts: worker
  become: true
  tasks:
    - name: Update apt cache
      apt:
        update_cache: yes

    - name: Install Docker
      apt:
        name: docker.io
        state: present

    - name: Add azureuser to docker group
      user:
        name: azureuser
        groups: docker
        append: yes

    - name: Join Swarm
      shell: >
        docker swarm join
        --token {{ hostvars['aver-manager']['join_token'].stdout }}
        {{ hostvars['aver-manager']['ansible_host'] }}:2377
      ignore_errors: true

bootstrap.sh — ties Terraform outputs into Ansible

#!/bin/bash
set -e

echo "Reading Terraform outputs..."
MANAGER_IP=$(terraform output -raw manager_public_ip)
WORKER_IP=$(terraform output -raw worker_public_ip)
SP_TENANT=$(terraform output -raw sp_tenant_id)
SP_CLIENT=$(terraform output -raw sp_client_id)
SP_SECRET=$(terraform output -raw sp_client_secret)

echo "Manager IP: $MANAGER_IP"
echo "Worker IP:  $WORKER_IP"

echo "Waiting for VMs to be ready..."
sleep 30

echo "Running Ansible..."
ansible-playbook playbook.yml \
  -i inventory.yml \
  --extra-vars "manager_ip=$MANAGER_IP \
                worker_ip=$WORKER_IP \
                sp_tenant_id=$SP_TENANT \
                sp_client_id=$SP_CLIENT \
                sp_client_secret=$SP_SECRET"

echo "Done. Cluster is ready."
echo "SSH into manager: ssh azureuser@$MANAGER_IP"

Full run from scratch

# 1. provision infrastructure
cd terraform-repo
az login
terraform init
terraform apply

# 2. configure cluster
cd ../ansible
chmod +x bootstrap.sh
./bootstrap.sh

# 3. deploy stack (first time manual, after this CI handles it)
ssh azureuser@<manager_ip>
docker stack deploy -c /path/to/stack.yml aver --with-registry-auth

Interview one liner

"I use Ansible for post-provisioning configuration — Docker install, Swarm init, joining worker nodes, and loading Azure Service Principal credentials as Docker secrets. It reads IPs and credentials directly from Terraform outputs. Agentless, idempotent, clean handoff from Terraform."