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.
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 neededControl 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.0Using 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_ed25519Specifying Non‑Standard SSH Ports
[webservers]
nginx-01 ansible_port=2222
nginx-02 ansible_port=2222Validate 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 pingCommon Modules in Practice
1. command / shell Modules
commandruns 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" --becomeThe --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" --become4. 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" --become7. 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" --become8. 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: yesPractical 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: restartedPractical 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: restartedPractical 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: reloadedJinja2 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:
- mysqlGenerate Role Skeleton
ansible-galaxy init roles/nginx --init-path ./rolesAnsible 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.ymlRun 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_passCommon 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=60Troubleshooting
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-passProblem 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-handlersProblem 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Ops Community
A leading IT operations community where professionals share and grow together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
