Agentless configuration management and automation, hands-on. You'll write your first playbook, manage inventory, make tasks idempotent, template configs, protect secrets, and structure real projects with roles.
| You need | Why |
|---|---|
| A control machine with Python 3 | Ansible itself runs here |
| One or more target hosts over SSH | Even a couple of local VMs or containers work |
| SSH key access to the targets | Ansible connects agentlessly over SSH |
You don't install anything on the targets — only Python (usually already present) and SSH. Confirm Ansible is installed: ansible --version.
Ansible configures servers (and much more) over plain SSH — no agent to install on targets. You describe the desired state in YAML; Ansible connects, checks what's already true, and changes only what's needed.
Two properties make it click:
changed=0.python3 -m pip install --user ansible
ansible --version
Inventory lists your hosts, grouped. Start with an INI file, inventory.ini:
[web]
web1.example.com
web2.example.com
[db]
db1.example.com
[production:children]
web
db
Test connectivity with the ping module (it's not ICMP — it checks SSH + Python):
ansible all -i inventory.ini -m ping
ansible web -i inventory.ini -m command -a "uptime"
ansible <group> -m <module> -a <args> runs an ad-hoc command — great for one-offs. Playbooks are for repeatable work.
A playbook maps groups of hosts to an ordered list of tasks. Save as site.yml:
- name: Configure web servers
hosts: web
become: true # use sudo
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Ensure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
Run it:
ansible-playbook -i inventory.ini site.yml
Run it again — the second run reports changed=0. That's idempotency: the modules checked the current state and did nothing because it already matched.
Before touching production, do a dry run:
ansible-playbook -i inventory.ini site.yml --check --diff
--check predicts changes without making them; --diff shows the exact lines that would change in files. Make this a habit — it's your safety net.
Real configs are generated, not hard-coded. Use a Jinja2 template and a handler that only restarts the service when the config actually changes.
templates/nginx.conf.j2:
worker_processes {{ nginx_workers }};
events { worker_connections {{ nginx_connections }}; }
http {
server {
listen {{ http_port }};
server_name {{ inventory_hostname }};
}
}
site.yml (excerpt):
vars:
nginx_workers: auto
nginx_connections: 1024
http_port: 80
tasks:
- name: Deploy nginx config
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
The template task renders variables into the file. notify triggers the handler only if the file changed — so nginx restarts on config changes, not on every run.
Never commit plaintext passwords. Encrypt sensitive files with Ansible Vault:
ansible-vault create group_vars/production/secrets.yml
# edit; put e.g. db_password: s3cr3t
ansible-playbook -i inventory.ini site.yml --ask-vault-pass
The file is AES-encrypted at rest and decrypted in memory at run time. In CI, pass the password via --vault-password-file backed by a secret store.
Once a playbook grows past ~50 lines, split it into roles — reusable bundles of tasks, templates, handlers, and defaults. Generate the skeleton:
ansible-galaxy role init roles/nginx
roles/nginx/
├── tasks/main.yml # what to do
├── handlers/main.yml # restart hooks
├── templates/ # jinja2 files
├── defaults/main.yml # overridable variables
└── vars/main.yml # internal constants
Your playbook becomes a thin list of roles:
- name: Web tier
hosts: web
become: true
roles:
- nginx
- app
- monitoring
Pull community roles and collections from Ansible Galaxy with ansible-galaxy install.
command/shell — modules are idempotent and report changes; raw shell isn't. If you must shell out, add creates:/changed_when:.group_vars/ and host_vars/ — keep variables next to the inventory, not scattered in playbooks.tags: [config]) so you can run a slice: --tags config.--limit web1.example.com when testing.ansible-lint in CI to catch anti-patterns.The same engine automates far more than VMs: cloud resources (amazon.aws, google.cloud), network gear, container platforms, and Kubernetes (kubernetes.core). The playbook model — inventory + idempotent modules + handlers — stays identical.
command/shell first. Raw shell isn't idempotent and always reports "changed." Prefer a real module; if you must shell out, add creates:/changed_when:.name is your log output — unnamed tasks make runs unreadable and failures hard to locate.notify, so it only fires when the config actually changed.--check --diff dry run first, and --limit to one host before rolling wider.group_vars//host_vars/ next to the inventory.changed=0 — explain why from how the modules work.nginx.conf.j2 with a notify: Restart nginx handler. Run once (it restarts), run again unchanged (it doesn't). Then tweak a variable and confirm the handler fires.--check --diff before applying. What exactly does --diff show you?ansible-galaxy role init roles/nginx, move tasks/templates/handlers/defaults in, and reduce your playbook to a thin roles: list.Self-check:
shell, and how do you make shell safer when unavoidable?You can now write idempotent, templated, secret-safe automation and structure it into reusable roles — the foundation of every Ansible codebase.
Learned the concepts? Test yourself with Ansible interview questions.
Go to the question bank →