Introduction

Ansible is the simplest infrastructure as code tool, requiring no agents on managed nodes. This tutorial guides you from basic ad-hoc commands to complex playbooks and roles for managing entire server fleets consistently and repeatably.

Ansible Architecture and Installation

Ansible uses push-based model: control node connects via SSH to managed nodes. No agent needed (unlike Puppet, Chef). Install on control node (Linux): sudo apt install ansible (Ubuntu/Debian), sudo yum install ansible (RHEL/CentOS). Check version: ansible --version. Inventory file (/etc/ansible/hosts) defines managed nodes: [webservers], web1.example.com, web2.example.com, [dbservers], db1.example.com, [all:vars], ansible_user=admin, ansible_ssh_private_key_file=/path/to/key. Test connection: ansible webservers -m ping. Ad-hoc commands: ansible webservers -m apt -a "name=nginx state=present" --become.

Inventory Management

Static inventory (INI or YAML format). YAML example: all: hosts: web1.example.com: ansible_host: 192.168.1.10, children: webservers: hosts: web1.example.com, web2.example.com, dbservers: hosts: db1.example.com. Dynamic inventory from cloud providers: aws_ec2 plugin: regions: - us-east-1, keyed_groups: - prefix: tag, key: tags. groups: - key: placement.region, prefix: region. Use with ansible-inventory --list. Variables per host/group: webservers: vars: nginx_version: 1.24, deploy_user: www-data. Host variables in host_vars/web1.example.com.yml. Group variables in group_vars/webservers.yml. Combine with ansible-vault for secrets: ansible-vault encrypt group_vars/all/vault.yml.

Playbooks: The Core of Ansible

Playbooks are YAML files defining automation jobs. Simple playbook: --- - name: Configure web servers, hosts: webservers, become: yes, tasks: - name: Install Nginx, apt: name: nginx state: present, - name: Start Nginx, service: name: nginx state: started enabled: yes, - name: Copy index.html, copy: src: files/index.html dest: /var/www/html/index.html. Run: ansible-playbook webserver.yml. Check syntax: ansible-playbook --syntax-check webserver.yml. Dry run: ansible-playbook --check webserver.yml. Verbose output: -v, -vv, -vvv for debugging. Limit to specific hosts: ansible-playbook webserver.yml --limit web1.example.com.

Ansible Modules Deep Dive

Package management: apt (Debian), yum/dnf (RHEL), pip (Python packages). Example: apt: name: "{{ packages }}", state: latest, update_cache: yes, cache_valid_time: 3600. File operations: file (manage files/directories), copy (copy files), template (Jinja2 templates), lineinfile (manage lines in files). System: user (manage users), group (manage groups), systemd (manage services), mount (filesystems). Command execution: command (default shell-free), shell (full shell), raw (no Python on remote). Command best practices: prefer idempotent modules over command/shell. Use creates/removes arguments for conditional execution. Network: uri (HTTP requests), get_url (download files). Database: mysql_db, postgresql_db. Cloud: ec2, s3, route53 (AWS modules).

Variables and Facts

Variable precedence: command line > playbook > inventory > host_vars > group_vars > role defaults. Define variables: playbook vars: nginx_port: 8080. External variables file: vars_files: - vars/nginx.yml. Register variables from task output: - name: Get disk usage, command: df -h /, register: disk_usage, - debug: msg: "Disk usage is {{ disk_usage.stdout }}". Facts (gathered automatically): ansible_facts: ansible_os_family, ansible_distribution_version, ansible_default_ipv4.address, ansible_memtotal_mb. Disable fact gathering: gather_facts: no. Set custom facts: local facts directory (/etc/ansible/facts.d/) with JSON files.

Conditionals and Loops

Conditional execution with when: - name: Install Apache on Debian, apt: name: apache2 state: present, when: ansible_os_family == "Debian". - name: Install Apache on RedHat, yum: name: httpd state: present, when: ansible_os_family == "RedHat". Multi-condition: when: (ansible_distribution == "Ubuntu" and ansible_distribution_version >= "20.04") or ansible_os_family == "RedHat". Loops: simple loop: - name: Install packages, apt: name: "{{ item }}" state: present, loop: - git - curl - htop. Loop with dict: - name: Create users, user: name: "{{ item.name }}" uid: "{{ item.uid }}", loop: - { name: "alice", uid: 1001 } - { name: "bob", uid: 1002 }. with_items, with_fileglob, with_dict also available (deprecated in favor of loop). Loop with subelements for nested structures.

Templates with Jinja2

Templates (.j2 files) generate dynamic configurations. Example Nginx template: server { listen {{ nginx_port }}; server_name {{ ansible_fqdn }}; root /var/www/{{ project_name }}; {% if enable_ssl %} listen 443 ssl; ssl_certificate /etc/ssl/certs/{{ domain }}.crt; ssl_certificate_key /etc/ssl/private/{{ domain }}.key; {% endif %} }. Use template module: - name: Configure Nginx, template: src: nginx.conf.j2 dest: /etc/nginx/sites-available/default, notify: restart nginx. Filters: {{ some_variable | default("default_value") }}, {{ memory_mb | int }}, {{ string | upper }}, {{ ip_address | ipaddr("public") }}. Lookup plugins: {{ lookup("file", "/path/to/file") }}, {{ lookup("env", "HOME") }}, {{ lookup("password", "/dev/null length=15") }} (generate random password).

Handlers and Notifications

Handlers run once at end of playbook when notified: tasks: - name: Update Nginx config, template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf, notify: restart nginx - reload nginx, handlers: - name: restart nginx, service: name: nginx state: restarted, - name: reload nginx, service: name: nginx state: reloaded. Handlers run in order defined, not notification order. Listen to multiple notifications: handlers: - name: restart nginx, listen: "restart web services", service: name: nginx state: restarted. Use flush_handlers to run immediately: - meta: flush_handlers. Handlers important for service restarts after config changes (idempotency).

Roles: Reusable Components

Roles organize playbooks into reusable units. Create role skeleton: ansible-galaxy init myrole. Role structure: myrole/ (tasks/main.yml, handlers/main.yml, templates/, files/, vars/main.yml, defaults/main.yml, meta/main.yml). Use role in playbook: - hosts: webservers, roles: - role: common, - role: nginx, vars: nginx_port: 8080, tags: [web], - role: postgresql, when: ansible_os_family == "RedHat". Role dependencies in meta/main.yml: dependencies: - role: common - role: firewall. Share roles via Ansible Galaxy: ansible-galaxy install geerlingguy.nginx. Community roles for MySQL, Docker, Kubernetes, AWS, and hundreds more.

Ansible Vault for Secrets

Encrypt sensitive data: ansible-vault create secrets.yml. Edit: ansible-vault edit secrets.yml. Encrypt existing file: ansible-vault encrypt plain.yml. Decrypt: ansible-vault decrypt encrypted.yml. Use vault in playbook: - hosts: all, vars_files: - secrets.yml. Run playbook: ansible-playbook playbook.yml --ask-vault-pass (or --vault-password-file script.sh). Encrypt single variable: ansible-vault encrypt_string "mysecret" --name "db_password". Inline encrypted variable in YAML. Best practice: vault for production secrets, separate vault files per environment (dev_vault.yml, prod_vault.yml). Rotate vault passwords quarterly.

Error Handling and Block/Rescue

Block/Rescue/Always for error handling: - block: - name: Deploy application, command: /deploy.sh, rescue: - name: Rollback, command: /rollback.sh, always: - name: Cleanup, file: path: /tmp/deploy state: absent. ignore_errors for non-critical failures: - name: Optional step, command: /optional.sh, ignore_errors: yes. failed_when for custom failure conditions: - name: Check log, command: grep ERROR /var/log/app.log, register: result, failed_when: result.stdout != "" or result.stderr != "". changed_when to control change reporting: - name: Idempotent script, command: /setup.sh, register: result, changed_when: result.stdout contains "CHANGED". Use any_errors_fatal to stop play on any host failure.

Real-World Playbook Examples

Complete LEMP stack deployment: - hosts: webservers, vars: php_version: 8.2, tasks: - name: Update apt cache, apt: update_cache: yes cache_valid_time: 3600, - name: Install Nginx, MySQL, PHP, apt: name: "{{ packages }}" state: present, vars: packages: [nginx, mysql-server, "php{{ php_version }}-fpm", "php{{ php_version }}-mysql"], - name: Configure MySQL root password, mysql_user: name: root password: "{{ mysql_root_password }}" login_unix_socket: /var/run/mysqld/mysqld.sock, - name: Remove anonymous MySQL users, mysql_user: name: "" state: absent. - name: Create application user, mysql_user: name: appuser password: "{{ app_db_password }}" priv: "appdb.*:ALL". Handlers for restarting PHP-FPM and Nginx after config changes.

Performance Optimization

SSH pipelining reduces connections: ansible.cfg: [ssh_connection], pipelining = True. Enable persistent connections: [defaults], forks = 20 (default 5). Fact caching: [defaults], gathering = smart, fact_caching = jsonfile, fact_caching_connection = /tmp/ansible_cache. Mitogen (third-party) dramatically speeds up: pip install mitogen, ansible.cfg: strategy_plugins = /path/to/mitogen/ansible_mitogen/plugins/strategy, strategy = mitogen_linear. Parallel execution per host group. Use async/poll for long-running tasks: - name: Long task, command: /long-task.sh, async: 3600, poll: 30. Configure control persist: [ssh_connection], ssh_args = -o ControlMaster=auto -o ControlPersist=60s.

Conclusion

Ansible provides the simplest path to infrastructure as code. Start with ad-hoc commands and simple playbooks, progress to roles for reusable components, and implement Ansible Tower/AWX for enterprise scheduling and governance. Regular testing via ansible-lint and molecule ensures playbook quality.