Introduction
Ansible is a configuration management and automation tool that describes the desired state of a fleet of machines as plain YAML, then pushes that state out over SSH — no agents to install, no daemons to keep patched on every managed host. You write what the end state should look like (a package installed, a service running, a config file templated with the right values), and Ansible figures out whether anything needs to change on each run.
This guide covers the full path from a single ad-hoc command to a production-grade, role-based deployment: inventories (static and dynamic), playbooks and variable precedence, roles and handlers, and secrets management with Ansible Vault. The worked examples — server hardening, Nginx installation, and a zero-downtime rolling restart across a web fleet — are the same patterns used to manage real production infrastructure.
Who this is for
- Linux administrators moving from one-off shell scripts to repeatable automation.
- Anyone managing more than a handful of servers by hand and feeling the pain.
- DevOps engineers who need configuration management alongside CI/CD and cloud provisioning.
Prerequisites
You should have the following before working through the commands in this guide:
- A control node — any Linux machine (or WSL) with Python 3.9+ installed.
- At least one managed host reachable over SSH, ideally two or three to see groups in action.
- SSH key-based access from the control node to each managed host (ssh-copy-id already run).
- sudo/root privileges on the managed hosts for tasks that need privilege escalation.
- Basic YAML familiarity — indentation is meaningful and this trips up almost everyone at first.
No agent required
Ansible needs nothing installed on managed hosts beyond Python and SSH access, which ship on virtually every Linux distribution by default. All the logic lives on the control node.Theory
Agentless, push-based architecture
Tools like Puppet or Chef run a persistent agent on every managed node that periodically pulls configuration from a central server. Ansible inverts this: a single control node pushes instructions to managed hosts over standard SSH, executes a small Python module on the remote end, and then removes it. There is nothing running in the background on managed hosts between runs.
Idempotency
Every built-in Ansible module is designed to be idempotent — running the same playbook twice in a row produces the same end state, and the second run reports no changes if nothing needs to change. A task that says state: presentfor a package checks whether it's already installed before doing anything; a task that templates a config file only rewrites it if the rendered content actually differs. This is what makes playbooks safe to re-run constantly, including on a schedule or in CI, without side effects piling up.
Declarative, not procedural
A playbook describes the desired state("Nginx should be installed and running"), not a sequence of imperative steps to get there. Ansible's modules contain the logic to inspect current state and reconcile it — you rarely need conditionals to check "is this already done?" the way you would in a raw shell script.
Facts
At the start of a play, Ansible gathers facts — OS family, IP addresses, memory, mounted disks — from each managed host via the setup module, and exposes them as variables (ansible_facts, or directly as ansible_distribution, etc.) that tasks and templates can branch on.
Architecture
The control node reads an inventory that groups managed hosts, then connects to each one over SSH to run modules — no software is required on the managed side beyond Python and an SSH daemon:
Control node reads the inventory, then pushes modules to each managed host over SSH
Dynamic inventory
Static files work well for a handful of hosts. At cloud scale, a dynamic inventory plugin (amazon.aws.aws_ec2, azure.azcollection.azure_rm, etc.) queries the cloud provider's API at run time and builds the host list and groups automatically from tags, so the inventory never goes stale as instances are created and destroyed.Installation
Install Ansible on the control node only — never on the managed hosts.
# Official PPA gets you the latest stable release
sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install -y ansible
# Verify
ansible --versionansible vs ansible-core
Theansible package bundles a large set of community collections; ansible-core is the minimal engine. Production pipelines often pin ansible-core plus an explicit requirements.yml of only the collections they use, for reproducible, faster installs.Configuration
ansible.cfg
Ansible reads configuration from (in order of precedence) the ANSIBLE_CONFIG environment variable, ./ansible.cfg in the current directory, ~/.ansible.cfg, then /etc/ansible/ansible.cfg. A project-local ansible.cfg keeps settings versioned alongside the playbooks that need them.
[defaults]
inventory = ./inventory/hosts.yml
remote_user = deploy
host_key_checking = True
retry_files_enabled = False
roles_path = ./roles
interpreter_python = auto_silent
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False
[ssh_connection]
pipelining = True
control_path = ~/.ansible/cp/%%h-%%p-%%rStatic inventory — INI format
[webservers]
web01.internal ansible_host=10.0.1.11
web02.internal ansible_host=10.0.1.12
[dbservers]
db01.internal ansible_host=10.0.2.11
[webservers:vars]
http_port=80
# A group of groups
[production:children]
webservers
dbserversStatic inventory — YAML format
The YAML form is preferred for larger inventories because it nests groups and variables more clearly than the INI format:
all:
children:
webservers:
hosts:
web01.internal:
ansible_host: 10.0.1.11
web02.internal:
ansible_host: 10.0.1.12
vars:
http_port: 80
dbservers:
hosts:
db01.internal:
ansible_host: 10.0.2.11
production:
children:
webservers:
dbservers:Host and group patterns
ansible webservers -m ping # one group
ansible 'webservers:dbservers' -m ping # union of two groups
ansible 'webservers:!web02*' -m ping # exclude a host
ansible 'web0[1:2].internal' -m ping # numeric range pattern
ansible all -m ping # every host in inventoryVariables and precedence
Variables can be defined in a dozen different places, which is powerful but easy to get wrong. From lowest to highestprecedence (simplified to the ones you'll actually hit day to day):
| Source | Scope |
|---|---|
Role defaults/main.yml | Lowest — easily overridden defaults |
Inventory group vars (group_vars/all.yml) | All hosts |
group_vars/<groupname>.yml | One group |
host_vars/<hostname>.yml | One host |
Play vars: / vars_files: | Current play |
Role vars/main.yml | Current role, harder to override than defaults |
Task-level vars: | Single task |
-e / --extra-vars on the CLI | Highest — always wins |
# Directory layout Ansible auto-loads variables from
inventory/
hosts.yml
group_vars/
all.yml # applies to every host
webservers.yml # applies only to the webservers group
host_vars/
web01.internal.yml # applies only to this one hosthttp_port: 80
nginx_worker_processes: auto
app_env: production
# vars_files can also be referenced explicitly from a play
# vars_files:
# - vars/nginx_defaults.ymlJinja2 templating
Variables are interpolated into tasks and config files with {{ variable }} Jinja2 syntax. The template module renders a .j2 file on the control node and copies the result to the managed host:
server {
listen {{ http_port }};
server_name {{ inventory_hostname }};
location / {
root /var/www/{{ app_env }};
index index.html;
}
}Commands
ansible — ad-hoc commands
Ad-hoc commands run a single module against a set of hosts without writing a playbook — ideal for quick checks and one-off fixes.
ansible all -m ping # connectivity + Python check
ansible webservers -a "uptime" # raw command via the default 'command' module
ansible webservers -m shell -a "df -h" -b # -b = become (sudo)
ansible webservers -m yum -a "name=httpd state=latest" -b
ansible webservers -m service -a "name=nginx state=restarted" -b
ansible webservers -m copy -a "src=motd dest=/etc/motd" -b
ansible all -m setup # dump gathered facts
ansible all -m setup -a "filter=ansible_distribution*" # filtered factsansible-playbook
ansible-playbook site.yml # run the full playbook
ansible-playbook site.yml --check # dry run, no changes made
ansible-playbook site.yml --diff # show file/content diffs
ansible-playbook site.yml --limit webservers # restrict to a group/host
ansible-playbook site.yml --tags nginx # run only tagged tasks
ansible-playbook site.yml --skip-tags hardening
ansible-playbook site.yml -e "app_version=2.4.1" # override a variable
ansible-playbook site.yml -i inventory/staging.yml
ansible-playbook site.yml -vvv # verbose, for debuggingansible-vault — secrets management
ansible-vault create secrets.yml # create + encrypt a new file
ansible-vault edit secrets.yml # decrypt, open in $EDITOR, re-encrypt
ansible-vault view secrets.yml # print decrypted contents
ansible-vault encrypt vars/prod.yml # encrypt an existing plaintext file
ansible-vault decrypt vars/prod.yml # permanently decrypt (careful!)
ansible-vault rekey secrets.yml # change the vault password
# Encrypt a single string inline for use in an otherwise plaintext file
ansible-vault encrypt_string 'S3cretPass!' --name 'db_password'
# Running a playbook that touches vaulted files
ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass.txtExamples
A complete playbook that hardens a fresh host and installs Nginx — a realistic "day one" provisioning play:
---
- name: Harden baseline and install Nginx
hosts: webservers
become: true
vars:
allowed_ssh_users:
- deploy
ssh_port: 22
tasks:
- name: Ensure required packages are present
ansible.builtin.package:
name:
- nginx
- fail2ban
- ufw
state: present
- name: Disable root SSH login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
validate: '/usr/sbin/sshd -T -f %s'
notify: Restart sshd
- name: Disable SSH password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
validate: '/usr/sbin/sshd -T -f %s'
notify: Restart sshd
- name: Allow SSH and HTTP through ufw
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- "{{ ssh_port }}"
- "80"
- "443"
- name: Enable ufw
community.general.ufw:
state: enabled
policy: deny
- name: Deploy Nginx virtual host from template
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/default
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Ensure nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Restart sshd
ansible.builtin.service:
name: sshd
state: restarted
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloadedWrite the playbook and inventory
Definewebserversin the inventory and write the tasks above inharden-and-nginx.yml.Dry-run before touching production
ansible-playbook harden-and-nginx.yml --check --diffshows exactly what would change without making any modification.Run it for real
ansible-playbook harden-and-nginx.ymlapplies the changes; handlers only fire once, at the end of the play, even if multiple tasks notify the same handler.Re-run to confirm idempotency
Running the exact same command again should reportchanged=0for every task — proof the play is safe to schedule or re-apply at any time.
Screenshots
ansible-playbook run output — per-task ok/changed/failed summary with the final PLAY RECAP
Screenshot placeholder
ansible-playbook --check --diff showing a templated nginx.conf change before it is applied
Screenshot placeholder
Real World Example
Scenario: a fleet of six Nginx web servers behind a load balancer needs a new release deployed. Restarting all six at once would drop traffic during the restart window, so the rollout must go through the load balancer one host at a time.
Role directory structure, scaffolded with ansible-galaxy init:
ansible-galaxy init roles/webapp
roles/webapp/
tasks/main.yml # the work: install, template, notify handlers
handlers/main.yml # restart/reload actions triggered by notify
templates/ # .j2 config/app files
files/ # static files copied verbatim
vars/main.yml # role-specific, high-precedence variables
defaults/main.yml # role-specific, low-precedence variables (safe overrides)
meta/main.yml # role metadata and dependenciesapp_release_version: "2.4.1"
app_artifact_url: "https://artifacts.internal.example.com/webapp/{{ app_release_version }}.tar.gz"
app_dir: /srv/webapp---
- name: Drain this host from the load balancer
community.general.haproxy:
state: disabled
backend: web_backend
host: "{{ inventory_hostname }}"
socket: /var/run/haproxy.sock
delegate_to: lb01.internal
- name: Fetch and unpack the new release
ansible.builtin.unarchive:
src: "{{ app_artifact_url }}"
dest: "{{ app_dir }}"
remote_src: true
notify: Restart webapp
- name: Template application config
ansible.builtin.template:
src: app.conf.j2
dest: "{{ app_dir }}/config/app.conf"
mode: "0640"
notify: Restart webapp
- name: Flush handlers now so the restart happens before re-enabling
ansible.builtin.meta: flush_handlers
- name: Wait for the app to answer health checks locally
ansible.builtin.uri:
url: "http://127.0.0.1:8080/healthz"
status_code: 200
register: health
retries: 10
delay: 3
until: health.status == 200
- name: Re-enable this host in the load balancer
community.general.haproxy:
state: enabled
backend: web_backend
host: "{{ inventory_hostname }}"
socket: /var/run/haproxy.sock
delegate_to: lb01.internal---
- name: Restart webapp
ansible.builtin.service:
name: webapp
state: restarted---
- name: Rolling zero-downtime webapp deploy
hosts: webservers
become: true
serial: 1 # one host at a time — the key to zero downtime
max_fail_percentage: 0 # stop the whole run if any host fails
roles:
- webappserial: 1 controls the batch size
Ansible processes exactly one host through the entire role — drain, deploy, health check, re-enable — before starting the next, so at least five of six backends are always serving traffic.Drain before touching the app
delegate_toruns the HAProxy task against the load balancer, not the web host, removing this one host from rotation before its service is restarted.Handlers batch the restart
Both the unarchive and template tasks notify the sameRestart webapphandler, but it only runs once per host — no redundant restarts even if both tasks report a change.Health check gates re-enabling
until+retrieson theuritask blocks until the freshly restarted app actually answers/healthz, so a broken release never gets re-added to the load balancer.
Common Issues
SSH connectivity / host key checking failures
UNREACHABLE!is almost always SSH: wrong user, missing key, or a host key prompt Ansible can't answer non-interactively. Test with ssh -i key.pem user@host directly first, and either accept the host key once manually or set host_key_checking = False in ansible.cfg for ephemeral/CI environments only.become / privilege escalation failures
Missing sudo password means the remote user needs NOPASSWD in sudoers or you need --ask-become-pass. Confirm with sudo -l as that user on the managed host before assuming the playbook is wrong.YAML indentation errors
Ansible YAML is whitespace-sensitive and tabs are invalid — a task misaligned by one space silently becomes a sibling of the wrong key. Runansible-playbook site.yml --syntax-check before every real run, and use an editor with YAML linting enabled.Idempotency broken by shell/command modules
shell and commandhave no built-in concept of "already done" — they report changed on every run. Guard them with creates:/removes:, a preceding check task with register + when, or better, replace them with a proper idempotent module (package, copy, lineinfile) where one exists.Vault password mismatch
Decryption failed means the wrong vault password or password file was supplied, or the file was encrypted with a different vault ID. Check for multiple vault IDs with --vault-id label@path if different environments use different vault passwords.Best Practices
- Keep inventories and playbooks in version control; treat them with the same review process as application code.
- Prefer roles over one giant playbook — they're reusable, testable in isolation, and easy to share via Ansible Galaxy.
- Always run
--check --diffagainst production before the real apply, especially for config file changes. - Use
group_vars/host_varsinstead of scattering-eoverrides across ad-hoc CLI invocations. - Pin collection and role versions in
requirements.ymlso a fresh checkout behaves identically months later. - Tag tasks meaningfully (
--tags/--skip-tags) so a huge playbook can still be run selectively during an incident.
Security Hardening
- Ansible Vault — never commit plaintext secrets; encrypt entire files or individual strings with
ansible-vault encrypt_string. - no_log — set
no_log: trueon any task that handles passwords or tokens so they never appear in verbose output or logs. - Least-privilege become — scope
become_userto the specific account a task actually needs rather than defaulting every play to root. - SSH key-based auth — the control node should authenticate to managed hosts with keys only, ideally through an SSH agent, never stored passwords.
- Vault password files — keep the vault password file outside version control and restrict its permissions to
0600; consider a secrets manager (HashiCorp Vault, AWS Secrets Manager) for the password itself in CI.
Interview Questions
Cheat Sheet
# --- Connectivity / ad-hoc ---
ansible all -m ping
ansible webservers -a "uptime"
ansible webservers -m shell -a "df -h" -b
ansible all -m setup -a "filter=ansible_distribution*"
# --- Playbooks ---
ansible-playbook site.yml
ansible-playbook site.yml --check --diff
ansible-playbook site.yml --limit webservers --tags nginx
ansible-playbook site.yml -e "app_version=2.4.1"
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml -vvv
# --- Vault ---
ansible-vault create secrets.yml
ansible-vault edit secrets.yml
ansible-vault encrypt_string 'secret' --name 'db_password'
ansible-playbook site.yml --vault-password-file ~/.vault_pass.txt
# --- Roles ---
ansible-galaxy init roles/myrole
ansible-galaxy install -r requirements.yml
# --- Inventory ---
ansible-inventory -i inventory/hosts.yml --graph
ansible-inventory -i inventory/hosts.yml --listSummary
Ansible turns fleet management into version-controlled, idempotent YAML instead of tribal knowledge and one-off shell scripts. The core mental model carries everything else: an inventory groups hosts, playbooks describe desired state through modules, roles package that logic for reuse, handlers batch disruptive actions like restarts, and Vault keeps secrets out of plaintext. Master ad-hoc commands for quick fixes, then graduate to role-based playbooks with serial and health checks for safe, repeatable production deployments.
Resources
- Official Ansible documentation —
docs.ansible.com - Ansible Galaxy (community roles and collections) —
galaxy.ansible.com - Ansible source and issue tracker —
github.com/ansible/ansible - Red Hat Ansible Automation Platform docs —
docs.redhat.com - Jinja2 templating reference —
jinja.palletsprojects.com