Not everything runs in a container. Kubernetes nodes, database servers, build agents, network devices and the legacy estate every company has are still machines that need packages, users, config files and services kept in a known state. Terraform creates the machine; something has to configure what is inside it. Ansible does that over plain SSH with no agent to install, which is why it remains the most approachable configuration management tool.
- Explain Ansible's agentless push model and where it fits beside Terraform and containers
- Write inventories with groups and variables, and target hosts with patterns
- Write idempotent playbooks using modules, variables, templates, handlers and conditionals
- Organise reusable automation into roles and protect secrets with Ansible Vault
- Test changes safely with check mode, diff mode, limits and linting
How Ansible works
Ansible runs on a control node, your laptop or a CI runner, and connects to managed nodes over SSH. For each task it copies a small Python program to the target, runs it, collects the JSON result and removes it. There is no daemon on the targets and no central server to maintain; the requirements are SSH access and Python on the managed node.
You describe the state you want, in YAML, using modules. A module knows how to check the current state and change it only if needed. "Package nginx is present" installs it the first time and does nothing the second. That property, idempotency, is the point of the tool, and it is what separates a playbook from a shell script. Each task reports ok (already correct), changed (it fixed something) or failed.
| Tool | Manages | Typical question it answers |
|---|---|---|
| Terraform | Cloud resources: networks, VMs, databases, DNS | Does this server exist? |
| Ansible | What is inside a machine: packages, files, users, services | Is this server configured correctly? |
| Docker / Kubernetes | Applications packaged as images | Is this app running? |
python3 -m pip install --user ansible
ansible --version
# ad-hoc commands: one module, right now, no playbook
ansible all -i inventory.ini -m ansible.builtin.ping
ansible web -i inventory.ini -m ansible.builtin.command -a "uptime"
ansible db -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_distribution*"The ping module is not ICMP. It proves that Ansible can log in over SSH and run Python on the target, which is the real prerequisite for everything else.
Inventory
The inventory lists the hosts Ansible may touch and arranges them into groups. Groups are how you say "all web servers" or "everything in production", and variables can be attached to a group or a single host.
all:
children:
web:
hosts:
web1.example.com:
web2.example.com:
vars:
http_port: 8080
db:
hosts:
db1.example.com:
postgres_role: primary
vars:
ansible_user: deploy
ansible_ssh_private_key_file: ~/.ssh/deploy_ed25519Keep one inventory per environment (inventory/staging.yml, inventory/production.yml) so that choosing production is always an explicit -i flag and never an accident. Variables that belong to groups are tidier in files than inline: Ansible automatically loads group_vars/web.yml and host_vars/db1.example.com.yml found next to the inventory or the playbook.
In the cloud, servers come and go, so a static file goes stale. Dynamic inventory plugins query the provider's API and build groups from tags, which ties in neatly with the tagging discipline you will meet in the FinOps module.
ansible-inventory -i inventory/production.yml --graph # groups and hosts as a tree
ansible-inventory -i inventory/production.yml --host web1.example.com # merged variablesPlaybooks
A playbook is a list of plays. Each play maps a group of hosts to an ordered list of tasks, and each task calls one module. Ansible runs a task on every host in the play before moving to the next task.
- name: Configure web servers
hosts: web
become: true
vars:
app_user: shop
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Create the application user
ansible.builtin.user:
name: "{{ app_user }}"
system: true
shell: /usr/sbin/nologin
- name: Deploy the nginx site config
ansible.builtin.template:
src: templates/shop.conf.j2
dest: /etc/nginx/sites-available/shop.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Make sure nginx is running and enabled at boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloadedbecome: trueescalates privileges withsudo, the same principle of least privilege as in the Linux module: connect as an ordinary user and escalate only where needed.- Modules are referred to by their fully qualified collection name, such as
ansible.builtin.apt, which avoids ambiguity when several collections are installed. - A handler runs only if a task that notifies it reported
changed, and only once, at the end of the play. That is how you reload a service when its config changed, and not otherwise. state: startedplusenabled: trueis the "start versus enable" distinction from the Linux module, expressed declaratively.- Quote file modes (
"0644"). Unquoted, YAML reads the number as decimal and you get the wrong permissions.
ansible-playbook -i inventory/staging.yml site.yml
ansible-playbook -i inventory/production.yml site.yml --limit web1.example.com
ansible-playbook -i inventory/production.yml site.yml --tags nginxVariables, facts, templates and conditionals
Before the first task, Ansible gathers facts about each host: operating system, addresses, memory, CPU count. Facts and your own variables are available in tasks and in Jinja2 templates, which is how one template produces the right config file for each host.
# Managed by Ansible. Manual changes will be overwritten.
upstream shop_backend {
{% for host in groups['web'] %}
server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:{{ http_port }};
{% endfor %}
}
server {
listen 80;
server_name {{ inventory_hostname }};
# {{ ansible_facts['distribution'] }} host with {{ ansible_facts['processor_vcpus'] }} vCPUs
location / {
proxy_pass http://shop_backend;
}
}- name: Install the firewall package on Debian-family hosts only
ansible.builtin.apt:
name: ufw
state: present
when: ansible_facts['os_family'] == "Debian"
- name: Create several directories
ansible.builtin.file:
path: "/srv/shop/{{ item }}"
state: directory
owner: "{{ app_user }}"
mode: "0750"
loop:
- releases
- shared
- logs
- name: Check the running application version
ansible.builtin.command: /srv/shop/bin/shop --version
register: shop_version
changed_when: false # a read-only command never counts as a change
failed_when: shop_version.rc not in [0, 3]Variables can be defined in more than twenty places, with a defined precedence. You do not need the whole list. Remember the ends: role defaults are the weakest, so they are where a role puts overridable settings, and extra vars passed with -e on the command line beat everything.
command and shell run arbitrary commands and cannot know whether anything changed, so they report changed every time and break idempotency. Reach for a purpose-built module first. When you must use them, add creates: or changed_when: so that a second run is a no-op.
Roles and secrets
A role packages tasks, handlers, templates, files and default variables in a standard directory layout, so that the same automation can be reused across playbooks and shared through Ansible Galaxy.
roles/nginx/
defaults/main.yml # overridable settings (lowest precedence)
vars/main.yml # internal constants (high precedence)
tasks/main.yml # entry point for the role's tasks
handlers/main.yml
templates/ # Jinja2 templates
files/ # static files
meta/main.yml # dependencies on other roles- name: Web tier
hosts: web
become: true
roles:
- common
- role: nginx
vars:
nginx_worker_connections: 4096Pin third-party roles and collections in a requirements.yml and install them with ansible-galaxy install -r requirements.yml, for the same reproducibility reasons as any other dependency.
Ansible Vault encrypts variable files so that they can live in Git. The decryption password is supplied at run time, from a prompt locally or from a secret in CI.
ansible-vault create group_vars/production/vault.yml
ansible-vault edit group_vars/production/vault.yml
ansible-vault view group_vars/production/vault.yml
ansible-playbook -i inventory/production.yml site.yml --ask-vault-pass
ansible-playbook -i inventory/production.yml site.yml --vault-password-file ~/.vault_passAdd no_log: true to any task that handles a secret, or its value can appear in the output and in CI logs.
Running changes safely
ansible-playbook site.yml --syntax-check
ansible-lint site.yml # separate tool; catches bad practice
ansible-playbook -i inventory/production.yml site.yml --check --diff # dry run with file diffs
ansible-playbook -i inventory/production.yml site.yml --limit 'web[0]' # one canary host first
ansible-playbook -i inventory/production.yml site.yml --list-hosts # who would be touched?--check asks modules to report what they would change without doing it, and --diff shows the file changes line by line. Together they are Ansible's equivalent of terraform plan. They are not perfect, because a task that depends on the result of an earlier task may misreport in check mode, but they catch most surprises.
For fleets, control the blast radius inside the play: serial updates hosts in batches instead of all at once, and max_fail_percentage stops the rollout when too many hosts fail.
- name: Rolling update of the web tier
hosts: web
become: true
serial: "25%"
max_fail_percentage: 10
roles:
- shop_appThe idempotency test for any playbook is simple: run it twice. The second run must report changed=0. If it does not, a task is not describing state, and it will keep causing needless restarts and noisy diffs.