Operations 31 min read

Stop Manually SSHing Servers: Practical Ansible Playbook Examples

This article explains how Ansible automates repetitive operations such as bulk software installation, configuration changes, service restarts, application deployment, and log collection, guiding readers through installation, core concepts, inventory setup, common modules, multiple real‑world Playbooks, role organization, Vault security, troubleshooting, and best‑practice risk warnings.

Ops Community
Ops Community
Ops Community
Stop Manually SSHing Servers: Practical Ansible Playbook Examples

Background and Use Cases

Operations often involve repetitive tasks—installing software, modifying configs, restarting services, deploying applications, and collecting logs on each server. Manually SSHing each host is slow and error‑prone.

Quick Installation and Core Concepts

Install Ansible

# CentOS / RHEL
sudo yum install -y epel-release
sudo yum install -y ansible

# Ubuntu / Debian
sudo apt update
sudo apt install -y ansible

# Verify installation
ansible --version

# macOS via Homebrew
brew install ansible

# Python pip (latest)
pip install ansible
pip install ansible-core  # lighter if only core is needed
Control node requirements : Ansible runs only on the control node; managed nodes need only SSH access.

Core Concepts

Inventory : defines target hosts (IP, hostname, groups). Default file is /etc/ansible/hosts.

Module : smallest unit of work, e.g., yum, copy, service, shell. Ansible ships with thousands of modules.

Task : a single module call, e.g., yum name=nginx state=present.

Playbook : YAML file containing one or more plays, each with a host group and tasks.

Role : reusable structure for organizing tasks, handlers, templates, vars, etc.

Verify SSH Password‑less Login

# Generate SSH key (if needed)
ssh-keygen -t ed25519 -C "ansible@control-node" -f ~/.ssh/id_ed25519

# Copy public key to targets
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]

# Test login
ssh -i ~/.ssh/id_ed25519 [email protected] "hostname"

If you prefer not to set up password‑less SSH, you can pass ansible_user and ansible_password at runtime, though production should use SSH keys.

Inventory Configuration

Basic Inventory File

# ~/ansible-practice/inventory.ini

# Direct IP list
[webservers]
192.168.1.11
192.168.1.12

[dbservers]
192.168.1.21
192.168.1.22

[production:children]
webservers
dbservers

[webservers:vars]
ansible_user=root
nginx_version=1.24.0

Using Hostnames Instead of IPs

[webservers]
nginx-01
nginx-02

[dbservers]
mysql-01
mysql-02

[production:children]
webservers
dbservers

[production:vars]
ansible_user=root
ansible_ssh_private_key_file=~/.ssh/id_ed25519

Specifying Non‑Standard SSH Ports

[webservers]
nginx-01 ansible_port=2222
nginx-02 ansible_port=2222

Validate Inventory

# List all defined hosts
ansible-inventory -i inventory.ini --list

# Tree view
ansible-inventory -i inventory.ini --tree

# Test connectivity
ansible all -i inventory.ini -m ping

Common Modules in Practice

1. command / shell Modules

command

runs a command directly (no shell), while shell runs via /bin/sh and supports pipelines.

# Show disk usage on all web servers
ansible webservers -i inventory.ini -m command -a "df -h /"

# Show memory usage
ansible webservers -i inventory.ini -m shell -a "free -h"

# Show Nginx version
ansible webservers -i inventory.ini -m command -a "nginx -v"

# List running services matching patterns
ansible all -i inventory.ini -m shell -a "systemctl list-units --type=service --state=running | grep -E 'nginx|mysql|redis'"
Note : Prefer command for safety because shell is vulnerable to injection and its return values are less predictable.

2. yum / apt Modules

# Install Nginx on all web servers (RedHat family)
ansible webservers -i inventory.ini -m yum -a "name=nginx state=present" --become

# Install specific version of httpd
ansible webservers -i inventory.ini -m yum -a "name=httpd-2.4.6 state=present" --become

# Update all packages
ansible all -i inventory.ini -m yum -a "name=* state=latest" --become

# Install multiple packages
ansible webservers -i inventory.ini -m yum -a "name=nginx,git,curl state=present" --become

# Ubuntu equivalent using apt
ansible webservers -i inventory.ini -m apt -a "name=nginx state=present update_cache=yes" --become

The --become flag runs the task with sudo (equivalent to ansible_sudo).

3. service Module

# Start and enable Nginx
ansible webservers -i inventory.ini -m service -a "name=nginx state=started enabled=yes" --become

# Restart Nginx to load new config
ansible webservers -i inventory.ini -m service -a "name=nginx state=restarted" --become

# Reload configuration without disconnecting clients
ansible webservers -i inventory.ini -m systemd -a "name=nginx state=reloaded" --become

4. copy Module

# Copy local file to all web servers
ansible webservers -i inventory.ini -m copy -a "src=./nginx.conf dest=/etc/nginx/nginx.conf mode=0644" --become

# Copy with backup of existing file
ansible webservers -i inventory.ini -m copy -a "src=./nginx.conf dest=/etc/nginx/nginx.conf mode=0644 backup=yes" --become

# Copy entire directory
ansible webservers -i inventory.ini -m copy -a "src=./webroot dest=/var/www/html" --become

# Write content directly without a local file
ansible webservers -i inventory.ini -m copy -a "content='Hello Ansible
' dest=/tmp/test.txt mode=0644"

5. file Module

# Create directory
ansible webservers -i inventory.ini -m file -a "path=/data/logs state=directory mode=0755 owner=www-data group=www-data" --become

# Create symbolic link
ansible webservers -i inventory.ini -m file -a "src=/data/logs dest=/var/log/nginx/logs state=link" --become

# Delete file or directory
ansible webservers -i inventory.ini -m file -a "path=/tmp/cache state=absent"

6. lineinfile Module

# Change Nginx worker_processes to auto
ansible webservers -i inventory.ini -m lineinfile -a "path=/etc/nginx/nginx.conf regexp='worker_processes' line='worker_processes auto;'" --become

# Ensure a line exists (append if missing)
ansible webservers -i inventory.ini -m lineinfile -a "path=/etc/security/limits.conf line='* soft nofile 65535'" --become

# Delete a matching line
ansible webservers -i inventory.ini -m lineinfile -a "path=/etc/nginx/nginx.conf regexp='worker_connections 1024;' state=absent" --become

7. find Module

# Find logs modified in the last 7 days
ansible webservers -i inventory.ini -m find -a "paths=/var/log patterns='*.log' age=7d recurse=yes" --become

# Find files larger than 100 MB
ansible webservers -i inventory.ini -m find -a "paths=/var/log size=100m" --become

8. fetch Module

# Collect Nginx config from all web servers
ansible webservers -i inventory.ini -m fetch -a "src=/etc/nginx/nginx.conf dest=./collected-configs/{{ inventory_hostname }}/nginx.conf flat=yes"

# Collect logs while preserving directory structure
ansible all -i inventory.ini -m fetch -a "src=/var/log/nginx/access.log dest=./logs/{{ inventory_hostname }}/"

Playbook Practice

Running individual modules with ansible is called Ad‑Hoc mode . When tasks grow, organize them into Playbooks.

Playbook Basic Structure

# ~/ansible-practice/deploy-nginx.yml
---
- name: Deploy and configure Nginx on web servers
  hosts: webservers
  become: yes
  vars:
    nginx_version: "1.24.0"
    nginx_port: 80
  tasks:
    - name: Install Nginx
      yum:
        name: nginx
        state: present

    - name: Copy Nginx configuration
      copy:
        src: ./files/nginx.conf
        dest: /etc/nginx/nginx.conf
        mode: "0644"
        backup: yes

    - name: Verify Nginx syntax
      command: nginx -t
      changed_when: false

    - name: Start and enable Nginx
      systemd:
        name: nginx
        state: started
        enabled: yes

Practical Example 1 – Bulk Initialization of New Servers

# ~/ansible-practice/init-server.yml
---
- name: Initialize newly delivered servers
  hosts: newservers
  become: yes
  gather_facts: yes
  vars:
    ntp_server: "pool.ntp.org"
    dns_server: "8.8.8.8"
    admin_user: ops
    admin_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... ops@control-node"
  tasks:
    - name: Set hostname
      hostname:
        name: "{{ inventory_hostname }}"

    - name: Disable firewalld (RedHat)
      systemd:
        name: firewalld
        state: stopped
        enabled: no
      when: ansible_facts['os_family'] == "RedHat"

    - name: Disable UFW (Ubuntu)
      systemd:
        name: ufw
        state: stopped
        enabled: no
      when: ansible_facts['os_family'] == "Debian"

    - name: Install basic packages (RedHat)
      yum:
        name:
          - curl
          - wget
          - vim
          - git
          - htop
          - net-tools
          - ntp
        state: present
      when: ansible_facts['os_family'] == "RedHat"

    - name: Install basic packages (Debian)
      apt:
        name:
          - curl
          - wget
          - vim
          - git
          - htop
          - net-tools
          - ntp
        state: present
        update_cache: yes
      when: ansible_facts['os_family'] == "Debian"

    - name: Configure NTP
      template:
        src: ./templates/ntp.conf.j2
        dest: /etc/ntp.conf
        mode: "0644"
      notify: Restart NTP

    - name: Add admin user
      user:
        name: "{{ admin_user }}"
        groups: wheel
        append: yes
        shell: /bin/bash

    - name: Add SSH public key for admin
      authorized_key:
        user: "{{ admin_user }}"
        key: "{{ admin_ssh_key }}"
        state: present

    - name: Harden SSH (disable root login)
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PermitRootLogin'
        line: "PermitRootLogin no"
        notify: Restart SSH
      when: inventory_hostname in groups['newservers']

    - name: Set timezone to Asia/Shanghai
      timezone:
        name: Asia/Shanghai
  handlers:
    - name: Restart NTP
      systemd:
        name: ntpd
        state: restarted

    - name: Restart SSH
      systemd:
        name: sshd
        state: restarted

Practical Example 2 – Bulk Docker Installation with Mirror Configuration

# ~/ansible-practice/install-docker.yml
---
- name: Install and configure Docker
  hosts: all
  become: yes
  vars:
    docker_repo_url: "https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo"
    docker_mirror: "https://mirrors.aliyun.com"
    docker_data_dir: "/data/docker"
  tasks:
    - name: Install required packages
      yum:
        name:
          - yum-utils
          - device-mapper-persistent-data
          - lvm2
        state: present

    - name: Add Docker CE repo
      get_url:
        url: "{{ docker_repo_url }}"
        dest: /etc/yum.repos.d/docker-ce.repo
        mode: "0644"

    - name: Install Docker packages
      yum:
        name:
          - docker-ce
          - docker-ce-cli
          - containerd.io
        state: present

    - name: Create Docker data directory
      file:
        path: "{{ docker_data_dir }}"
        state: directory
        owner: root
        group: root
        mode: "0755"

    - name: Configure daemon.json
      copy:
        dest: /etc/docker/daemon.json
        content: |
          {
            "data-root": "{{ docker_data_dir }}",
            "registry-mirrors": ["{{ docker_mirror }}"],
            "log-driver": "json-file",
            "log-opts": {
              "max-size": "100m",
              "max-file": "3"
            },
            "storage-driver": "overlay2"
          }

    - name: Start and enable Docker
      systemd:
        name: docker
        state: started
        enabled: yes

    - name: Verify Docker installation
      command: docker --version
      register: docker_version
      changed_when: false

    - name: Print Docker version
      debug:
        msg: "Docker version: {{ docker_version.stdout }}"

Practical Example 3 – Bulk MySQL Master‑Slave Replication Setup

# ~/ansible-practice/mysql-replication.yml
---
- name: Setup MySQL master‑slave replication
  hosts: dbservers
  become: yes
  vars:
    mysql_root_password: "ChangeThisPassword123!"
    mysql_version: "5.7"
    repl_user: replicator
    repl_password: "ReplPassword456!"
  tasks:
    - name: Install MySQL server
      yum:
        name:
          - mysql-server
          - MySQL-python
        state: present

    - name: Start and enable MySQL
      systemd:
        name: mysqld
        state: started
        enabled: yes

    - name: Wait for MySQL to be ready
      wait_for:
        port: 3306
        delay: 5
        timeout: 30

    - name: Set MySQL root password (first time only)
      mysql_user:
        name: root
        host: localhost
        password: "{{ mysql_root_password }}"
        priv: "*.*:ALL,GRANT"
        state: present
      register: mysql_root_set

    - name: Create replication user on master only
      mysql_user:
        name: "{{ repl_user }}"
        host: "%"
        password: "{{ repl_password }}"
        priv: "*.*:REPLICATION SLAVE,REPLICATION CLIENT"
        state: present
      when: "'master' in group_names"

    - name: Configure server_id based on hostname
      lineinfile:
        path: /etc/my.cnf
        regexp: '^server-id'
        line: "server-id={{ 10 + ansible_play_hosts.index(inventory_hostname) }}"
        create: yes
      notify: Restart MySQL

    - name: Enable binlog on master
      lineinfile:
        path: /etc/my.cnf
        regexp: '^log-bin'
        line: "log-bin=mysql-bin"
        create: yes
        notify: Restart MySQL
      when: "'master' in group_names"

    - name: Get master status
      command: mysql -uroot -p{{ mysql_root_password }} -e "SHOW MASTER STATUS;"
      register: master_status
      changed_when: false
      when: "'master' in group_names"

    - name: Get slave status
      command: mysql -uroot -p{{ mysql_root_password }} -e "SHOW SLAVE STATUS\G"
      register: slave_status
      changed_when: false
      when: "'slave' in group_names"

    - name: Display master status
      debug:
        msg: "Master binlog file: {{ master_status.stdout_lines[1] }}"
      when: "'master' in group_names"
  handlers:
    - name: Restart MySQL
      systemd:
        name: mysqld
        state: restarted

Practical Example 4 – Bulk Server Inspection

# ~/ansible-practice/server-inspection.yml
---
- name: Server inspection playbook
  hosts: all
  become: yes
  gather_facts: yes
  tasks:
    - name: Check disk usage
      shell: |
        df -h | awk 'NR>1 {printf "%s: %s used (%s), available: %s
", $6, $5, $3, $4}'
      register: disk_usage
      changed_when: false

    - name: Check memory usage
      shell: free -h | grep Mem
      register: mem_usage
      changed_when: false

    - name: Check CPU load average
      shell: uptime
      register: cpu_load
      changed_when: false

    - name: Check running services
      shell: systemctl list-units --type=service --state=running --no-pager | grep -E 'nginx|mysql|redis|docker|php|python'
      register: running_services
      changed_when: false
      failed_when: false

    - name: Check system uptime
      shell: uptime -p
      register: uptime
      changed_when: false

    - name: Print inspection report
      debug:
        msg: |
          =================== {{ inventory_hostname }} ===================
          Hostname: {{ ansible_facts['hostname'] }}
          OS: {{ ansible_facts['os_distribution'] }} {{ ansible_facts['os_version'] }}
          CPU: {{ ansible_facts['processor_vcpus'] }} vCPUs
          Memory: {{ ansible_facts['memtotal_mb'] }} MB
          Uptime: {{ uptime.stdout }}
          Disk Usage:
          {{ disk_usage.stdout }}
          Memory:
          {{ mem_usage.stdout }}
          Load: {{ cpu_load.stdout }}
          Running Services:
          {{ running_services.stdout | default('No monitored services running') }}

Practical Example 5 – Canary Deployment of Nginx (Hot Config Update)

# ~/ansible-practice/nginx-canary-deploy.yml
---
- name: Nginx configuration and canary deployment
  hosts: webservers
  become: yes
  vars:
    nginx_config_version: "v2.1.0"
    upstream_backends:
      - "192.168.1.21:8080"
      - "192.168.1.22:8080"
  tasks:
    - name: Backup current Nginx config
      command: cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.backup.{{ ansible_date_time.epoch }}
      changed_when: false

    - name: Copy new upstream config
      template:
        src: ./templates/upstream.conf.j2
        dest: /etc/nginx/conf.d/upstream.conf
        mode: "0644"
      notify: Reload Nginx

    - name: Verify Nginx configuration
      command: nginx -t
      register: nginx_test
      changed_when: false

    - name: Display Nginx test result
      debug:
        msg: "Nginx config test: {{ nginx_test.stdout_lines[0] }}"
      failed_when: nginx_test.rc != 0
  handlers:
    - name: Reload Nginx
      systemd:
        name: nginx
        state: reloaded

Jinja2 Template for Upstream

upstream backend {
    {% for backend in upstream_backends %}
    server {{ backend }} max_fails=3 fail_timeout=30s;
    {% endfor %}
    keepalive 32;
}

Roles – Organizing Large Playbooks

When Playbooks become large, use Roles. A typical role directory layout:

roles/
└── common/
    ├── defaults/      # lowest‑priority variables (main.yml)
    ├── files/          # static files (e.g., ntp.conf)
    ├── handlers/        # handler definitions (main.yml)
    ├── meta/            # role metadata, dependencies (main.yml)
    ├── tasks/           # task list (main.yml)
    ├── templates/       # Jinja2 templates (ntp.conf.j2)
    └── vars/            # higher‑priority variables (main.yml)

Using a Role in a Playbook

# site.yml
---
- name: Apply common role to all servers
  hosts: all
  roles:
    - common

- name: Deploy web server role
  hosts: webservers
  roles:
    - nginx
    - app-server

- name: Deploy database role
  hosts: dbservers
  roles:
    - mysql

Generate Role Skeleton

ansible-galaxy init roles/nginx --init-path ./roles

Ansible Vault for Sensitive Information

Never store passwords or keys in plain text. Use Vault to encrypt variables.

# Create encrypted file
ansible-vault create vars/secrets.yml

# Edit encrypted file
ansible-vault edit vars/secrets.yml

# Encrypt existing file
ansible-vault encrypt vars/secrets.yml

# Decrypt (use with caution)
ansible-vault decrypt vars/secrets.yml

# View encrypted content
ansible-vault view vars/secrets.yml

Run playbooks with the vault password:

# Interactive prompt
ansible-playbook -i inventory.ini site.yml --ask-vault-pass

# Password file
ansible-playbook -i inventory.ini site.yml --vault-password-file ~/.vault_pass

Common Command Cheat Sheet

# List all defined hosts
ansible-inventory -i inventory.ini --list

# Test connectivity
ansible all -i inventory.ini -m ping

# Gather host facts summary
ansible all -i inventory.ini -m setup

# Filter specific facts
ansible all -i inventory.ini -m setup -a "filter=ansible_memtotal_mb"

# Check mode (dry run)
ansible-playbook -i inventory.ini deploy.yml --check

# Check mode with diff
ansible-playbook -i inventory.ini deploy.yml --check --diff

# Run specific tags
ansible-playbook -i inventory.ini deploy.yml --tags="config,start"

# Skip tags
ansible-playbook -i inventory.ini deploy.yml --skip-tags="backup"

# Limit execution to a host
ansible-playbook -i inventory.ini deploy.yml --limit="192.168.1.11"

# Use multiple inventory files
ansible-playbook -i inventory.ini -i inventory2.ini site.yml

# Set forks and timeout
ansible-playbook -i inventory.ini deploy.yml --forks=10 --timeout=60

Troubleshooting

Problem 1 – SSH Connection Failure

Symptom : UNREACHABLE! error.

# Debug SSH connection
ansible all -i inventory.ini -m ping -vvv

# Common causes:
# 1. SSH key not configured
ssh -i ~/.ssh/id_ed25519 [email protected]

# 2. Host fingerprint changed
ssh-keygen -R 192.168.1.11

# 3. Non‑standard SSH port (add ansible_port=xxx in inventory.ini)

# 4. Wrong SSH username (add ansible_user=xxx in inventory.ini)

Problem 2 – Permission denied (sudo)

Symptom : Task fails with Sorry, you must have a password to use sudo.

# Option 1: Configure password‑less sudo on the managed node (e.g., /etc/sudoers.d/ansible-user)
# ansible-user ALL=(ALL) NOPASSWD: ALL

# Option 2: Set ansible_become_pass in inventory (prefer Vault encryption)

# Option 3: Prompt for sudo password at runtime
ansible-playbook -i inventory.ini deploy.yml --ask-become-pass

Problem 3 – Playbook Fails Mid‑Execution, Remaining Hosts Skip

By default Ansible runs in parallel. To stop all hosts when any host fails:

- name: Deploy app
  hosts: webservers
  any_errors_fatal: true   # abort on first error
  serial: 1               # optional: run one host at a time (rolling update)

Problem 4 – Handlers Not Triggered

Handlers fire only when a task reports changed. If the task makes no change, the handler is skipped.

# Force handlers even if task fails
ansible-playbook -i inventory.ini deploy.yml --force-handlers

Problem 5 – Undefined or Misspelled Variables

Debug variables to locate issues:

# Print all variables
ansible all -i inventory.ini -m debug -a "var=hostvars"

# Print a specific variable
ansible all -i inventory.ini -m debug -a "var=ansible_default_ipv4"

Risk Warnings

Idempotency : Most Ansible modules are idempotent, but command and shell are not. Ensure your commands are safe to run repeatedly.

Irreversible Bulk Operations : Operations like state=absent, rm -rf, or yum remove cannot be undone. Test with --check first.

Canary Deployments : In production, avoid full‑scale rollouts. Use the serial parameter or --limit to update a few machines at a time.

Change Windows : Perform configuration changes within approved maintenance windows and have a rollback plan.

Sensitive Data : Never store passwords or keys in plain text. Encrypt them with Ansible Vault.

Conclusion

Ansible provides a low‑barrier entry to automation. A simple ansible all -m ping verifies connectivity; the yum module installs packages across many hosts; the copy module distributes configuration files—operations that would take orders of magnitude more time when done manually via SSH.

This guide covered bulk software installation, configuration management, service control, file distribution and collection, structured Playbook authoring, role organization, Vault security, troubleshooting, and risk mitigation. Mastering these patterns equips you to handle the majority of day‑to‑day operational automation tasks.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

automationConfiguration ManagementDevOpsInfrastructure as CodeAnsiblePlaybook
Ops Community
Written by

Ops Community

A leading IT operations community where professionals share and grow together.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.