About Ansible
Ansible is a powerful, agentless automation tool for configuration management, application deployment, and task automation. Unlike other tools (Puppet, Chef), Ansible requires no agents on managed nodes - it uses SSH and Python, both already present on most Linux systems.
Why Ansible?
- Agentless: No software to install on managed nodes
- Simple: YAML-based playbooks, human-readable
- Powerful: Thousands of modules for every scenario
- Idempotent: Safe to run multiple times - only changes what's needed
- Push-based: Control node pushes changes to managed nodes
Key Concepts:
- Control Node: Where Ansible runs (your workstation/management server)
- Managed Nodes: Servers/devices being configured (targets)
- Inventory: List of managed nodes (hosts file)
- Playbooks: YAML files describing desired state
- Tasks: Individual actions (install package, copy file, etc.)
- Modules: Units of code Ansible executes (apt, yum, copy, service, etc.)
- Roles: Reusable collections of playbooks, variables, and files
- Facts: System information gathered from managed nodes
- Handlers: Tasks triggered by changes (restart service after config change)
Common Use Cases:
- Configure multiple servers identically
- Deploy applications across environments (dev, staging, prod)
- Patch management and system updates
- User and SSH key management
- Security hardening and compliance
- Orchestrate complex multi-tier deployments
- Disaster recovery and infrastructure rebuilds
# Install Ansible on control node (Ubuntu/Debian)
$ sudo apt update
$ sudo apt install ansible
# Install on RHEL/CentOS
$ sudo yum install ansible
# Or via pip (any platform)
$ pip3 install ansible --user
# Verify installation
$ ansible --version
| Command |
Description |
ansible |
Ad-hoc command execution on managed nodes |
ansible-playbook |
Execute playbooks (main automation command) |
ansible-inventory |
Display or dump inventory information |
ansible-vault |
Encrypt/decrypt sensitive data |
ansible-galaxy |
Download and manage roles from Ansible Galaxy |
ansible-doc |
View module documentation |
ansible-config |
View and manage Ansible configuration |
| Option |
Description |
-i INVENTORY |
Specify inventory file |
-l LIMIT |
Limit execution to specific hosts |
-t TAGS |
Run only tasks with specific tags |
--skip-tags TAGS |
Skip tasks with specific tags |
-e VARS |
Extra variables (key=value) |
--check |
Dry run - don't make changes |
--diff |
Show file changes (with --check) |
-v, -vv, -vvv |
Increase verbosity (more v's = more detail) |
--become |
Execute with privilege escalation (sudo) |
--ask-become-pass |
Prompt for sudo password |
Example 1Setup Inventory and Ad-Hoc Commands
Create inventory file and run simple ad-hoc commands:
$ mkdir ~/ansible-demo && cd ~/ansible-demo
$ cat > inventory << 'EOF'
[webservers]
web1.example.com
web2.example.com
[databases]
db1.example.com
db2.example.com
[all:vars]
ansible_user=admin
ansible_python_interpreter=/usr/bin/python3
EOF
# Test connectivity to all hosts
$ ansible all -i inventory -m ping
web1.example.com | SUCCESS => {
"changed": false,
"ping": "pong"
}
web2.example.com | SUCCESS => {
"changed": false,
"ping": "pong"
}
db1.example.com | SUCCESS => {
"changed": false,
"ping": "pong"
}
db2.example.com | SUCCESS => {
"changed": false,
"ping": "pong"
}
# Get system info from web servers
$ ansible webservers -i inventory -m setup -a "filter=ansible_distribution*"
Explanation:
- Inventory file defines managed hosts in groups
- [webservers], [databases]: Group names
- [all:vars]: Variables applied to all hosts
-m ping: Use ping module to test connectivity
-m setup: Gather facts (system information)
-a: Arguments to module (filter facts)
- Ad-hoc commands: Quick one-off tasks without playbooks
Tip: Place inventory file in default location /etc/ansible/hosts to avoid using -i flag every time. Or set ANSIBLE_INVENTORY environment variable.
Example 2Basic Playbook - Install and Start Service
Create first playbook to install and configure a web server:
$ cat > webserver.yml << 'EOF'
---
- name: Configure web servers
hosts: webservers
become: yes
tasks:
- name: Install Apache web server
apt:
name: apache2
state: present
update_cache: yes
- name: Start and enable Apache service
service:
name: apache2
state: started
enabled: yes
- name: Create index.html
copy:
content: |
<html>
<body>
<h1>Welcome to {{ ansible_hostname }}</h1>
</body>
</html>
dest: /var/www/html/index.html
owner: www-data
group: www-data
mode: '0644'
EOF
# Run the playbook
$ ansible-playbook -i inventory webserver.yml
PLAY [Configure web servers] ***********************************
TASK [Gathering Facts] *****************************************
ok: [web1.example.com]
ok: [web2.example.com]
TASK [Install Apache web server] *******************************
changed: [web1.example.com]
changed: [web2.example.com]
TASK [Start and enable Apache service] *************************
changed: [web1.example.com]
changed: [web2.example.com]
TASK [Create index.html] ***************************************
changed: [web1.example.com]
changed: [web2.example.com]
PLAY RECAP *****************************************************
web1.example.com : ok=4 changed=3 unreachable=0 failed=0
web2.example.com : ok=4 changed=3 unreachable=0 failed=0
Explanation:
---: YAML file start
hosts: webservers: Target group from inventory
become: yes: Use sudo for all tasks
apt module: Package management (Debian/Ubuntu)
service module: Manage system services
copy module: Create or copy files
{{ ansible_hostname }}: Variable (Jinja2 template)
- changed=3: Three tasks made changes
Note: Run this playbook again and you'll see "ok" instead of "changed" - this is idempotency. Ansible only makes changes when needed, making it safe to run repeatedly.
Example 3Using Variables and Templates
Create playbook with variables and Jinja2 templates:
$ cat > config-template.yml << 'EOF'
---
- name: Deploy configuration with templates
hosts: webservers
become: yes
vars:
apache_port: 8080
admin_email: admin@example.com
max_clients: 150
tasks:
- name: Deploy Apache configuration from template
template:
src: apache-config.j2
dest: /etc/apache2/ports.conf
owner: root
group: root
mode: '0644'
notify: Restart Apache
handlers:
- name: Restart Apache
service:
name: apache2
state: restarted
EOF
$ cat > apache-config.j2 << 'EOF'
# Apache Configuration
# Generated by Ansible on {{ ansible_date_time.date }}
Listen {{ apache_port }}
<IfModule mpm_prefork_module>
StartServers 5
MinSpareServers 5
MaxSpareServers 10
MaxRequestWorkers {{ max_clients }}
MaxConnectionsPerChild 0
</IfModule>
ServerAdmin {{ admin_email }}
EOF
$ ansible-playbook -i inventory config-template.yml
PLAY [Deploy configuration with templates] *********************
TASK [Deploy Apache configuration from template] ***************
changed: [web1.example.com]
changed: [web2.example.com]
RUNNING HANDLER [Restart Apache] *******************************
changed: [web1.example.com]
changed: [web2.example.com]
Explanation:
vars: Define variables in playbook
template module: Process Jinja2 templates
- .j2 extension: Jinja2 template file
{{ variable }}: Insert variable value
notify: Trigger handler when task changes
handlers: Tasks run only when notified
- Handler runs after all tasks complete
Tip: Handlers only run once, even if notified multiple times. Perfect for restart services after multiple configuration changes. They run at the end of the play unless forced earlier with meta: flush_handlers.
Example 4User Management and SSH Keys
Manage users and deploy SSH keys across servers:
$ cat > users.yml << 'EOF'
---
- name: Manage users and SSH keys
hosts: all
become: yes
vars:
admin_users:
- name: alice
comment: "Alice Admin"
ssh_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... alice@laptop"
- name: bob
comment: "Bob Developer"
ssh_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... bob@laptop"
tasks:
- name: Create admin users
user:
name: "{{ item.name }}"
comment: "{{ item.comment }}"
groups: sudo
shell: /bin/bash
state: present
loop: "{{ admin_users }}"
- name: Deploy SSH keys
authorized_key:
user: "{{ item.name }}"
key: "{{ item.ssh_key }}"
state: present
loop: "{{ admin_users }}"
- name: Disable password authentication
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^PasswordAuthentication'
line: 'PasswordAuthentication no'
state: present
notify: Restart SSH
handlers:
- name: Restart SSH
service:
name: sshd
state: restarted
EOF
$ ansible-playbook -i inventory users.yml
TASK [Create admin users] **************************************
changed: [web1.example.com] => (item={'name': 'alice', ...})
changed: [web1.example.com] => (item={'name': 'bob', ...})
TASK [Deploy SSH keys] *****************************************
changed: [web1.example.com] => (item={'name': 'alice', ...})
changed: [web1.example.com] => (item={'name': 'bob', ...})
TASK [Disable password authentication] *************************
changed: [web1.example.com]
Explanation:
user module: Create and manage user accounts
authorized_key module: Manage SSH keys
lineinfile module: Modify configuration files
loop: Iterate over list of items
{{ item.name }}: Access loop item properties
- Creates users, adds to sudo, deploys keys
- Hardens SSH by disabling password auth
Best Practice: Store user lists in separate variable files or group_vars. This playbook shows inline vars for clarity, but production should use: vars_files: users.yml or group_vars/all.yml.
Example 5Conditional Tasks and Facts
Use conditionals to handle different operating systems:
$ cat > multi-os.yml << 'EOF'
---
- name: Install packages on multiple OS families
hosts: all
become: yes
tasks:
- name: Display OS information
debug:
msg: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
- name: Install nginx on Debian/Ubuntu
apt:
name: nginx
state: present
update_cache: yes
when: ansible_os_family == "Debian"
- name: Install nginx on RedHat/CentOS
yum:
name: nginx
state: present
when: ansible_os_family == "RedHat"
- name: Ensure nginx is running
service:
name: nginx
state: started
enabled: yes
- name: Set firewall rules on RedHat systems
firewalld:
service: http
permanent: yes
state: enabled
immediate: yes
when:
- ansible_os_family == "RedHat"
- ansible_distribution_major_version|int >= 7
EOF
$ ansible-playbook -i inventory multi-os.yml
TASK [Display OS information] **********************************
ok: [web1.example.com] => {
"msg": "Ubuntu 22.04"
}
ok: [db1.example.com] => {
"msg": "CentOS 8"
}
TASK [Install nginx on Debian/Ubuntu] **************************
changed: [web1.example.com]
skipping: [db1.example.com]
TASK [Install nginx on RedHat/CentOS] **************************
skipping: [web1.example.com]
changed: [db1.example.com]
Explanation:
ansible_distribution: Fact about OS (Ubuntu, CentOS, etc.)
ansible_os_family: OS family (Debian, RedHat, etc.)
when: Conditional - task runs only if true
debug module: Print messages for troubleshooting
- Different package managers for different OS families
- Multiple conditions with list syntax
- Facts gathered automatically at play start
Tip: View all available facts: ansible hostname -m setup. Common facts: ansible_hostname, ansible_default_ipv4.address, ansible_memtotal_mb, ansible_processor_cores. Use facts to make playbooks adapt to target systems.
Example 6Using Roles for Organization
Structure complex playbooks using roles:
$ # Create role structure
$ ansible-galaxy init webserver
$ tree webserver/
webserver/
├── defaults/
│ └── main.yml
├── files/
├── handlers/
│ └── main.yml
├── tasks/
│ └── main.yml
├── templates/
├── vars/
│ └── main.yml
└── README.md
$ cat > webserver/tasks/main.yml << 'EOF'
---
- name: Install web server packages
apt:
name:
- apache2
- libapache2-mod-php
state: present
- name: Deploy website files
copy:
src: "{{ item }}"
dest: /var/www/html/
with_fileglob: "../files/*"
- name: Deploy configuration
template:
src: vhost.conf.j2
dest: /etc/apache2/sites-available/000-default.conf
notify: Restart Apache
EOF
$ cat > webserver/handlers/main.yml << 'EOF'
---
- name: Restart Apache
service:
name: apache2
state: restarted
EOF
$ cat > site.yml << 'EOF'
---
- name: Deploy complete web application
hosts: webservers
become: yes
roles:
- webserver
- { role: database, when: "'databases' in group_names" }
EOF
$ ansible-playbook -i inventory site.yml
PLAY [Deploy complete web application] *************************
TASK [webserver : Install web server packages] ****************
ok: [web1.example.com]
TASK [webserver : Deploy website files] ***********************
changed: [web1.example.com]
TASK [webserver : Deploy configuration] ***********************
changed: [web1.example.com]
RUNNING HANDLER [webserver : Restart Apache] ******************
changed: [web1.example.com]
Explanation:
ansible-galaxy init: Create role skeleton
- Roles organize playbooks into reusable components
- tasks/: Main task list for role
- handlers/: Handlers for role
- templates/: Jinja2 template files
- files/: Static files to copy
- defaults/: Default variables (low precedence)
- vars/: Role variables (high precedence)
- Roles make playbooks modular and shareable
Best Practice: Use roles for any playbook over ~50 lines or that you'll reuse. Share roles via Ansible Galaxy (ansible-galaxy install username.rolename). Keep roles single-purpose and composable.
Example 7Ansible Vault for Secrets
Encrypt sensitive data like passwords and keys:
$ # Create encrypted variable file
$ ansible-vault create secrets.yml
New Vault password: ********
Confirm New Vault password: ********
# Edit the file (opens in $EDITOR)
# Add content:
---
db_password: SuperSecretPassword123!
api_key: abc123def456ghi789
admin_password: AdminPass456!
$ # View encrypted file
$ cat secrets.yml
$ANSIBLE_VAULT;1.1;AES256
66386439653966336266343732323066323535303538643932633836613365643464613166376432
3731373830616537393361663632616132613730346639310a383638323061383266376636633266
...
$ cat > secure-setup.yml << 'EOF'
---
- name: Setup with encrypted secrets
hosts: databases
become: yes
vars_files:
- secrets.yml
tasks:
- name: Create database user
mysql_user:
name: appuser
password: "{{ db_password }}"
priv: "appdb.*:ALL"
state: present
- name: Configure API credentials
template:
src: api-config.j2
dest: /etc/app/api.conf
mode: '0600'
EOF
# Run playbook with vault password
$ ansible-playbook -i inventory secure-setup.yml --ask-vault-pass
# Or use password file
$ echo "MyVaultPassword" > .vault_pass
$ chmod 600 .vault_pass
$ ansible-playbook -i inventory secure-setup.yml --vault-password-file .vault_pass
Explanation:
ansible-vault create: Create encrypted file
ansible-vault edit: Modify encrypted file
ansible-vault view: View encrypted file
vars_files: Include encrypted variables
--ask-vault-pass: Prompt for decryption password
--vault-password-file: Read password from file
- Variables used normally in playbook
- Encrypted at rest, decrypted during execution
Security Note: Never commit .vault_pass or unencrypted secrets to version control! Add to .gitignore. For production, use ansible-vault with CI/CD environment variables or external secret managers (HashiCorp Vault, AWS Secrets Manager).
Example 8Tags for Selective Execution
Use tags to run specific parts of playbooks:
$ cat > tagged-playbook.yml << 'EOF'
---
- name: Complete server setup
hosts: webservers
become: yes
tasks:
- name: Install base packages
apt:
name:
- vim
- git
- htop
state: present
tags:
- base
- packages
- name: Configure firewall
ufw:
rule: allow
port: "{{ item }}"
loop:
- "22"
- "80"
- "443"
tags:
- firewall
- security
- name: Install Apache
apt:
name: apache2
state: present
tags:
- apache
- webserver
- name: Deploy application
git:
repo: https://github.com/example/app.git
dest: /var/www/app
tags:
- application
- deploy
- name: Configure monitoring
copy:
src: monitoring.conf
dest: /etc/monitoring/
tags:
- monitoring
- never # Only runs when explicitly requested
EOF
# Run only security-related tasks
$ ansible-playbook -i inventory tagged-playbook.yml --tags security
# Run everything except deployment
$ ansible-playbook -i inventory tagged-playbook.yml --skip-tags deploy
# Run base setup and apache
$ ansible-playbook -i inventory tagged-playbook.yml --tags base,apache
# Run ONLY tasks tagged 'never'
$ ansible-playbook -i inventory tagged-playbook.yml --tags never
PLAY [Complete server setup] ***********************************
TASK [Configure firewall] **************************************
changed: [web1.example.com] => (item=22)
changed: [web1.example.com] => (item=80)
changed: [web1.example.com] => (item=443)
PLAY RECAP *****************************************************
web1.example.com : ok=1 changed=1 unreachable=0 failed=0
Explanation:
tags: Label tasks for selective execution
--tags: Run only tasks with these tags
--skip-tags: Run all except these tags
- Multiple tags per task (list format)
never: Special tag - only runs when explicitly called
- Tags inheritance: apply to entire plays or roles
- Useful for maintenance, updates, or debugging
Tip: List all available tags: ansible-playbook playbook.yml --list-tags. Common tag strategy: base, security, config, deploy, monitoring. Use always tag for tasks that should always run regardless of tag selection.
Example 9Error Handling and Debugging
Handle failures gracefully and debug playbook issues:
$ cat > error-handling.yml << 'EOF'
---
- name: Demonstrate error handling
hosts: webservers
become: yes
tasks:
- name: Attempt risky operation
command: /usr/bin/might-not-exist
register: result
ignore_errors: yes
- name: Check if previous task failed
debug:
msg: "Previous task failed with: {{ result.stderr }}"
when: result.failed
- name: Try multiple package managers
block:
- name: Try apt first
apt:
name: nginx
state: present
rescue:
- name: Fall back to yum
yum:
name: nginx
state: present
always:
- name: Verify nginx is installed
command: nginx -v
register: nginx_version
- name: Display nginx version
debug:
msg: "Nginx installed: {{ nginx_version.stdout }}"
- name: Fail with custom message
fail:
msg: "Critical condition detected!"
when: ansible_memfree_mb < 100
- name: Assert conditions are met
assert:
that:
- ansible_os_family == "Debian"
- ansible_distribution_version is version('20.04', '>=')
fail_msg: "This playbook requires Ubuntu 20.04 or newer"
success_msg: "System meets requirements"
EOF
# Run with verbose output for debugging
$ ansible-playbook -i inventory error-handling.yml -vv
# Check mode to see what would change
$ ansible-playbook -i inventory error-handling.yml --check --diff
TASK [Attempt risky operation] *********************************
fatal: [web1.example.com]: FAILED! => {"msg": "/usr/bin/might-not-exist: No such file"}
...ignoring
TASK [Check if previous task failed] **************************
ok: [web1.example.com] => {
"msg": "Previous task failed with: /usr/bin/might-not-exist: No such file"
}
TASK [Try apt first] *******************************************
changed: [web1.example.com]
TASK [Verify nginx is installed] *******************************
changed: [web1.example.com]
TASK [Assert conditions are met] *******************************
ok: [web1.example.com] => {
"msg": "System meets requirements"
}
Explanation:
register: Save task output to variable
ignore_errors: yes: Continue after failure
block/rescue/always: Try/catch/finally pattern
fail module: Deliberately fail with message
assert module: Verify conditions
--check: Dry run mode (no changes)
--diff: Show file differences
-vv: Verbose output for debugging
Tip: Use --start-at-task "Task Name" to resume from specific task during debugging. Increase verbosity with more v's: -vvv shows connection details, -vvvv shows SSH debugging. Use debug module liberally during development.
Example 10Complete Multi-Tier Application Deployment
Real-world example: Deploy complete LAMP stack application:
$ cat > lamp-stack.yml << 'EOF'
---
- name: Deploy LAMP Stack Application
hosts: all
become: yes
vars_files:
- vars/database.yml
pre_tasks:
- name: Update apt cache (Debian)
apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == "Debian"
tasks:
# Database tier
- name: Install MySQL server
apt:
name:
- mysql-server
- python3-pymysql
state: present
when: "'databases' in group_names"
- name: Create application database
mysql_db:
name: "{{ db_name }}"
state: present
when: "'databases' in group_names"
- name: Create database user
mysql_user:
name: "{{ db_user }}"
password: "{{ db_password }}"
priv: "{{ db_name }}.*:ALL"
host: "{{ item }}"
state: present
loop: "{{ groups['webservers'] }}"
when: "'databases' in group_names"
# Web tier
- name: Install Apache and PHP
apt:
name:
- apache2
- php
- php-mysql
- libapache2-mod-php
state: present
when: "'webservers' in group_names"
- name: Deploy application code
git:
repo: https://github.com/example/app.git
dest: /var/www/html/app
version: main
when: "'webservers' in group_names"
notify: Restart Apache
- name: Configure application
template:
src: templates/config.php.j2
dest: /var/www/html/app/config.php
mode: '0644'
when: "'webservers' in group_names"
- name: Set correct permissions
file:
path: /var/www/html/app
owner: www-data
group: www-data
recurse: yes
when: "'webservers' in group_names"
- name: Enable Apache modules
apache2_module:
name: "{{ item }}"
state: present
loop:
- rewrite
- php
when: "'webservers' in group_names"
notify: Restart Apache
post_tasks:
- name: Verify web service is responding
uri:
url: http://{{ ansible_default_ipv4.address }}
status_code: 200
when: "'webservers' in group_names"
handlers:
- name: Restart Apache
service:
name: apache2
state: restarted
EOF
$ cat > templates/config.php.j2 << 'EOF'
<?php
// Database Configuration
define('DB_HOST', '{{ groups["databases"][0] }}');
define('DB_NAME', '{{ db_name }}');
define('DB_USER', '{{ db_user }}');
define('DB_PASS', '{{ db_password }}');
// Application Settings
define('APP_URL', 'http://{{ ansible_default_ipv4.address }}');
define('DEBUG', false);
?>
EOF
$ ansible-playbook -i inventory lamp-stack.yml --ask-vault-pass
PLAY [Deploy LAMP Stack Application] **************************
TASK [Update apt cache (Debian)] ******************************
ok: [web1.example.com]
ok: [db1.example.com]
TASK [Install MySQL server] ************************************
skipping: [web1.example.com]
changed: [db1.example.com]
TASK [Create application database] *****************************
skipping: [web1.example.com]
changed: [db1.example.com]
TASK [Install Apache and PHP] **********************************
changed: [web1.example.com]
skipping: [db1.example.com]
TASK [Deploy application code] *********************************
changed: [web1.example.com]
skipping: [db1.example.com]
TASK [Configure application] ***********************************
changed: [web1.example.com]
skipping: [db1.example.com]
TASK [Verify web service is responding] ***********************
ok: [web1.example.com]
skipping: [db1.example.com]
RUNNING HANDLER [Restart Apache] ******************************
changed: [web1.example.com]
PLAY RECAP *****************************************************
web1.example.com : ok=8 changed=5 unreachable=0 failed=0
db1.example.com : ok=4 changed=3 unreachable=0 failed=0
Explanation:
pre_tasks: Run before main tasks
post_tasks: Run after main tasks and handlers
groups['webservers']: Access inventory groups
group_names: Groups current host belongs to
- Conditional execution based on host groups
- Multi-tier deployment in single playbook
- Template includes data from other hosts
uri module: Verify HTTP endpoints
Production Pattern: This demonstrates a complete application stack deployment. In production, you'd: (1) Split into separate playbooks or roles, (2) Use dynamic inventory from cloud provider, (3) Add health checks and rollback capability, (4) Include monitoring and alerting setup, (5) Use CI/CD pipeline for automated deployment.
Pitfall 1: Running Playbooks Without --check First
Making changes without testing can break production systems.
Solution: Always run --check --diff first to see what would change. Test in dev environment. Use tags for incremental updates. Keep playbooks idempotent so multiple runs are safe.
Pitfall 2: Ignoring Idempotency
Using command or shell modules that aren't idempotent.
Solution: Use proper modules (apt, yum, copy, template) whenever possible. If you must use command/shell, add creates: or when: conditions. Check with: ansible-playbook playbook.yml --check - should show no changes on second run.
Pitfall 3: Hardcoding Values Instead of Variables
Hardcoded IPs, paths, passwords make playbooks inflexible and insecure.
Solution: Use variables, templates, and vault. Store environment-specific vars in group_vars/ and host_vars/. Use inventory variables for host-specific data. Encrypt sensitive data with ansible-vault.
Pitfall 4: Not Using Roles for Organization
Monolithic playbooks become unmaintainable beyond ~100 lines.
Solution: Break into roles once playbooks exceed ~50 lines or contain reusable logic. Structure: roles/webserver/, roles/database/, etc. Share roles internally via git or Ansible Galaxy. Keep roles single-purpose.
Pitfall 5: Inadequate Error Handling
Playbooks fail halfway through, leaving systems in inconsistent state.
Solution: Use block/rescue/always for critical sections. Add assert to verify prerequisites. Use failed_when and changed_when for nuanced control. Implement rollback procedures for deployments.
Pitfall 6: Slow Playbook Execution
Serial execution causes long deployment times for large inventories.
Solution: Increase parallelism: forks = 20 in ansible.cfg. Use strategy: free for independent tasks. Enable SSH pipelining and ControlPersist. Use async and poll for long-running tasks. Profile with ansible-playbook --profile.
Tip 1: Use ansible.cfg for Project Defaults
Create ansible.cfg in project root:
[defaults]
inventory = inventory/production
remote_user = ansible
private_key_file = ~/.ssh/ansible_key
host_key_checking = False
forks = 20
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
retry_files_enabled = False
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
pipelining = True
Speeds up execution and sets consistent defaults across team.
Tip 2: Directory Structure for Large Projects
Standard layout for maintainability:
project/
├── ansible.cfg
├── inventory/
│ ├── production/
│ │ ├── hosts
│ │ └── group_vars/
│ └── staging/
│ ├── hosts
│ └── group_vars/
├── roles/
│ ├── webserver/
│ ├── database/
│ └── common/
├── playbooks/
│ ├── site.yml
│ ├── webservers.yml
│ └── databases.yml
├── group_vars/
│ ├── all.yml
│ └── webservers.yml
├── host_vars/
└── files/
Separate inventories per environment, roles in dedicated directory.
Tip 3: Use Dynamic Inventory for Cloud
Auto-discover instances from cloud providers:
# Install AWS inventory plugin
$ pip install boto3
# Configure inventory plugin (inventory/aws_ec2.yml)
plugin: aws_ec2
regions:
- us-east-1
filters:
tag:Environment: production
keyed_groups:
- key: tags.Role
prefix: role_
# Use dynamic inventory
$ ansible-playbook -i inventory/aws_ec2.yml site.yml
No manual inventory maintenance. Supports AWS, Azure, GCP, VMware, etc.
Tip 4: Ansible Testing Pipeline
Test playbooks before production:
# Syntax check
$ ansible-playbook --syntax-check playbook.yml
# Linting
$ ansible-lint playbook.yml
# Dry run
$ ansible-playbook --check --diff playbook.yml
# Test in staging
$ ansible-playbook -i inventory/staging playbook.yml
# Deploy to production
$ ansible-playbook -i inventory/production playbook.yml
Catch errors early, validate in staging, deploy with confidence.
Tip 5: Document with Comments and README
Every playbook and role needs documentation:
# In playbook
---
# Purpose: Deploy web application to production
# Requirements: Ubuntu 20.04+, sudo access
# Variables: See group_vars/webservers.yml
# Tags: deploy, config, rollback
# In role README.md
# Webserver Role
## Requirements
- Ubuntu 20.04 or CentOS 8+
- Minimum 2GB RAM
## Variables
- `apache_port`: HTTP port (default: 80)
- `max_clients`: Max concurrent connections (default: 150)
Future you (and your replacement) will thank you.
Tip 6: Version Control Everything
Git workflow for Ansible:
$ git init
$ cat > .gitignore << 'EOF'
*.retry
.vault_pass
.DS_Store
__pycache__/
*.pyc
EOF
$ git add .
$ git commit -m "Initial Ansible project"
# Feature branch workflow
$ git checkout -b feature/new-role
# Make changes, test
$ git commit -am "Add database role"
$ git push origin feature/new-role
# Create pull request, get review
$ git checkout main
$ git merge feature/new-role
Track changes, enable collaboration, enable rollback.
Historical Note: Ansible was created by Michael DeHaan in 2012 and acquired by Red Hat in 2015. The name comes from Ursula K. Le Guin's science fiction novels, referring to a fictional device for faster-than-light communication. Ansible's "batteries included" philosophy and agentless architecture made it extremely popular for DevOps automation, competing with Chef and Puppet despite being newer.
| Task |
Command |
| Test connectivity |
ansible all -i inventory -m ping |
| Run ad-hoc command |
ansible all -i inventory -m command -a "uptime" |
| Run playbook |
ansible-playbook -i inventory playbook.yml |
| Dry run (check mode) |
ansible-playbook playbook.yml --check --diff |
| Run specific tags |
ansible-playbook playbook.yml --tags deploy |
| Limit to hosts |
ansible-playbook playbook.yml -l web1.example.com |
| With sudo |
ansible-playbook playbook.yml --become |
| With vault |
ansible-playbook playbook.yml --ask-vault-pass |
| Verbose output |
ansible-playbook playbook.yml -vvv |
| View module docs |
ansible-doc apt |
| List hosts |
ansible all -i inventory --list-hosts |
| Gather facts |
ansible hostname -m setup |