The 60 Ansible questions interviewers actually ask, with direct answers, runnable playbooks, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Ansible is an open-source IT automation tool that Michael DeHaan created in 2012 and Red Hat now maintains. You describe the state you want in YAML files called playbooks, and Ansible connects to your servers over SSH to make that state real: install packages, edit config files, start services, deploy code. It's agentless, so nothing runs on the managed hosts between jobs, which is a big part of why teams pick it over heavier tools. Runs are idempotent by design: running the same playbook twice leaves the system in the same state, so re-runs are safe. In interviews, Ansible questions probe idempotency, the module system, inventory and variables, roles, and the gotchas that bite in production, not memorized flag lists. This page collects the 60 questions that come up most, each with a direct answer and runnable YAML. If you're building fundamentals, the official Ansible Getting Started guide from Red Hat is the canonical path; increasingly the first technical round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Ansible for Beginners to Advanced (DevOps Bootcamp)
Video: Ansible for Beginners to Advanced (DevOps Bootcamp) (TechWorld with Nana, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Ansible certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Ansible is an open-source automation tool for configuration management, application deployment, and orchestration. You describe the desired state in YAML, and Ansible connects to your servers over SSH to make it happen.
It solves the problem of doing the same setup by hand across many machines. Instead of SSHing into fifty servers to install and configure the same thing, you write one playbook and run it against all of them, repeatably and safely.
Key point: A one-line definition plus the concrete pain it removes (manual, repeated server setup) beats a feature list. Interviewers open with this to hear how you frame a tool.
Watch a deeper explanation
Video: You Need to Learn Ansible RIGHT NOW (Linux Automation) (NetworkChuck, YouTube)
Agentless means you don't install any Ansible software or daemon on the machines you manage. The control node connects to targets over SSH (or WinRM for Windows) using credentials you already have, runs the work, and disconnects.
It matters because there's nothing to install, patch, or keep running on hundreds of hosts, and no extra open port or background process to secure. The requirement is just SSH access and Python on most targets.
| Agentless (Ansible) | Agent-based (Puppet, Chef) | |
|---|---|---|
| Software on targets | None persistent | A running agent |
| Connection | Push over SSH | Agent pulls from a server |
| Setup cost | Low | Higher |
| Firewall needs | SSH only | Agent port to the master |
Key point: The follow-up is usually 'what does Ansible need on the target then?'. Answer: SSH access and, for most modules, Python.
Watch a deeper explanation
Video: Getting Started with Ansible 01: Introduction (Learn Linux TV, YouTube)
Idempotency means running the same task again produces the same end state without redoing work. An idempotent task checks the current state first and only acts when something differs, reporting ok instead of changed when the system already matches.
Ansible cares because it lets you run a playbook safely any number of times. You can re-run after a partial failure or on a schedule without worrying that it'll duplicate users, append config twice, or restart services needlessly.
- name: Ensure nginx is installed
ansible.builtin.package:
name: nginx
state: present
# First run: installs nginx, reports changed
# Second run: already present, reports ok (no change)Key point: This is the single most probed Ansible idea. Say the definition, then give an example of an idempotent module versus a non-idempotent shell command.
A task is a single action, one call to a module, like installing a package. A play maps a set of tasks to a group of hosts. A playbook is a YAML file containing one or more plays, run top to bottom.
So the nesting is playbook contains plays, a play contains tasks, a task calls a module. That hierarchy is worth being able to draw quickly.
# a playbook (one file)
- name: Configure web servers # a play
hosts: webservers
tasks:
- name: Install nginx # a task
ansible.builtin.package:
name: nginx
state: presentInventory is the list of hosts Ansible manages, usually organized into groups. It can be a static file (INI or YAML) or a dynamic script or plugin that pulls hosts from a cloud provider at runtime.
Groups let a play target a subset of machines, and you can attach variables to hosts or groups. The default file is /etc/ansible/hosts, but most teams keep a project-local inventory and pass it with -i.
[webservers]
web1.example.com
web2.example.com
[dbservers]
db1.example.com
[production:children]
webservers
dbserversA module is a small program Ansible ships to a managed host to do one job: manage a package, copy a file, create a user, restart a service. Tasks call modules. Ansible has thousands, grouped into collections.
Most modules are idempotent and declarative: you The end state (state: present) and the module figures out whether any change is needed. Modern names are fully qualified, like ansible.builtin.copy.
- name: Copy a config file
ansible.builtin.copy:
src: files/app.conf
dest: /etc/app/app.conf
owner: root
mode: '0644'An ad-hoc command runs a single module against hosts straight from the command line, without writing a playbook. You use the ansible command with -m for the module and -a for its arguments.
They're for quick one-offs: check uptime, restart a service, ping all hosts to confirm reachability. For anything you'll repeat or want in version control, write a playbook instead.
# ping all hosts
ansible all -m ping
# check disk usage on webservers
ansible webservers -m ansible.builtin.command -a 'df -h'
# restart nginx, with sudo
ansible webservers -m ansible.builtin.service -a 'name=nginx state=restarted' --becomeWatch a deeper explanation
Video: Getting Started with Ansible 05: Running Elevated Ad-Hoc Commands (Learn Linux TV, YouTube)
Ansible uses YAML because it's readable without knowing a programming language: indentation shows structure, and lists and key-value pairs map cleanly to plays and tasks. Non-programmers can read a playbook and follow it.
The classic gotchas: tabs are illegal (spaces only), indentation must be consistent, and values like yes, no, on, off get read as booleans unless quoted. A colon inside an unquoted string also breaks parsing.
Key point: the boolean and quoting traps matters.
Variables can be set in the playbook (vars), in separate vars files, in inventory (host and group vars), passed on the command line with -e, or registered from a task's output. You reference them with Jinja2: {{ my_var }}.
Group and host variables usually live in group_vars/ and host_vars/ directories, which keeps environment-specific values out of the playbook itself.
- hosts: webservers
vars:
app_port: 8080
tasks:
- name: Show the port
ansible.builtin.debug:
msg: "App runs on port {{ app_port }}"A handler is a task that runs only when another task notifies it, and only if that task actually changed something. Handlers run once, at the end of the play, no matter how many tasks notified them.
The classic use is restarting a service after its config changed. If the config file didn't change, the task reports ok, the handler isn't notified, and the service isn't restarted needlessly.
tasks:
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restartedKey point: The expected depth point: handlers run at the end and only on change. Volunteer that and you've answered the follow-up.
Facts are pieces of system information Ansible collects from each host before running tasks: OS family, IP addresses, memory, disk, and more. They're exposed as variables under ansible_facts.
Gathering happens automatically via the setup module at the start of a play. You can turn it off with gather_facts: false to speed things up when you don't need them.
- hosts: all
tasks:
- name: Show the OS family
ansible.builtin.debug:
msg: "{{ ansible_facts['os_family'] }}"Use become: true to run a task or play as another user, root by default, through a privilege tool like sudo. become_user sets the target user and become_method sets the mechanism.
You can set it at the play level for everything or on a single task that needs root. The connecting SSH user still needs permission to escalate.
- hosts: webservers
become: true
tasks:
- name: Install a package (needs root)
ansible.builtin.package:
name: htop
state: presentUse the loop keyword and reference each element with the item variable. It replaces writing the same task many times, so you can create several users or install several packages in one task.
Older playbooks use with_items, which still works but loop is the current recommendation for simple lists.
- name: Create several users
ansible.builtin.user:
name: "{{ item }}"
state: present
loop:
- alice
- bob
- carolwhen runs a task only if a Jinja2 expression is true. You reference facts or variables directly (no curly braces needed inside when), so you can branch on the OS, on a registered result, or on any variable.
Common uses: run apt only on Debian family and yum only on Red Hat family, or skip a step unless a previous task changed something.
- name: Install nginx on Debian family only
ansible.builtin.apt:
name: nginx
state: present
when: ansible_facts['os_family'] == 'Debian'Watch a deeper explanation
Video: Getting Started with Ansible 07: The when Conditional (Learn Linux TV, YouTube)
register captures a task's output into a variable so later tasks can use it. The registered value holds fields like stdout, rc (return code), and changed, depending on the module.
It's how you make one task's result drive the next: run a command, register the result, then act on it with when or debug.
- name: Check if a file exists
ansible.builtin.stat:
path: /etc/app/config
register: cfg
- name: Warn if missing
ansible.builtin.debug:
msg: "Config is missing"
when: not cfg.stat.existsA template is a file with Jinja2 placeholders that Ansible fills with variables and facts, then copies to the host. The template module renders a .j2 file per host, so each machine gets a config with its own values.
Templates cover config files that differ by host: a port here, a hostname there, a loop over a list of upstreams. Jinja2 gives you variables, conditionals, loops, and filters inside those files.
- name: Render the app config
ansible.builtin.template:
src: templates/app.conf.j2
dest: /etc/app/app.conf
# app.conf.j2 contains: listen {{ app_port }};ansible.cfg holds configuration defaults: the inventory path, remote user, privilege settings, SSH options, and where roles and collections live. It saves passing the same flags every run.
Ansible searches for it in a set order: the ANSIBLE_CONFIG env var, then the current directory, then the user's home, then /etc/ansible. A project-local ansible.cfg is the usual pattern.
Ansible Vault encrypts sensitive data (passwords, API keys, certificates) so it can live in version control safely. You can encrypt a whole file or single values, and Ansible decrypts them at runtime with a password or key.
You edit encrypted files with ansible-vault edit and supply the password at run time with --ask-vault-pass or a vault password file.
# encrypt a secrets file
ansible-vault encrypt group_vars/prod/secrets.yml
# run a playbook that uses it
ansible-playbook site.yml --ask-vault-passcommand runs a program directly without a shell, so pipes, redirects, and environment variable expansion don't work. shell runs the command through /bin/sh, so those features do work.
Prefer command when you can, because it's safer (no shell injection surface). Reach for shell only when you genuinely need shell features. And prefer a purpose-built module over either, since command and shell aren't idempotent.
Key point: The senior-sounding close: 'both are a last resort because they aren't idempotent, so I use changed_when or a real module instead.'
For candidates with working experience: playbook design, variable precedence, roles, and the questions that separate users from understanders.
A role is a reusable package of automation: tasks, handlers, variables, defaults, templates, and files organized in a standard directory layout. You include a role in a play and it drops in a whole chunk of proven behavior.
The layout uses fixed subdirectories, each with a main.yml Ansible loads automatically. This convention is why roles are shareable, testable, and publishable to Ansible Galaxy.
roles/
webserver/
tasks/main.yml
handlers/main.yml
templates/
files/
vars/main.yml
defaults/main.yml
meta/main.ymlKey point: Knowing which directory holds what (defaults are lowest precedence, vars are higher) is the follow-up. Have that ready.
Watch a deeper explanation
Video: Getting Started with Ansible 06: Writing Our First Playbook (Learn Linux TV, YouTube)
When the same variable is set in multiple places, Ansible follows a defined precedence order and the highest wins. Roughly, from lowest to highest: role defaults, inventory vars, playbook vars, host and group vars, facts, and extra vars (-e), which beats everything.
The two anchors worth memorizing: role defaults are the weakest (they're meant to be overridden) and extra vars on the command line are the strongest. Everything else falls between.
| Source | Precedence |
|---|---|
| role defaults | Lowest |
| inventory group_vars / host_vars | Low to middle |
| play vars / vars_files | Middle |
| role vars (vars/main.yml) | High |
| extra vars (-e) | Highest, wins everything |
Key point: Nobody memorizes all 22 tiers. Naming the two extremes correctly (defaults weakest, -e strongest) is what the question actually checks.
import is static: Ansible processes it when it parses the playbook, before the run. include (include_tasks) is dynamic: it's resolved during execution, when the task is reached.
The practical consequences: imports let tags and handlers apply to the imported tasks up front, but you can't loop over an import or use a runtime variable in its filename. Includes can loop and use runtime values, but tags behave differently. Choose static unless you need runtime behavior.
| import_tasks | include_tasks | |
|---|---|---|
| When resolved | Parse time (static) | Run time (dynamic) |
| Can loop over it | No | Yes |
| Runtime var in filename | No | Yes |
| Tags apply to inner tasks | Yes, directly | Only via apply/rules |
A block groups tasks. Pair it with rescue (runs if any task in the block fails) and always (runs no matter what) to get try/catch/finally style error handling in a playbook.
This lets you attempt something risky, recover or clean up on failure, and guarantee a final step. Blocks also let you apply when, become, or tags to a group of tasks at once.
- block:
- name: Try a deployment
ansible.builtin.command: /opt/deploy.sh
rescue:
- name: Roll back on failure
ansible.builtin.command: /opt/rollback.sh
always:
- name: Always notify
ansible.builtin.debug:
msg: "Deploy step finished"They override how Ansible decides a task's status. changed_when tells Ansible when to report changed (useful for command/shell, which always report changed by default). failed_when defines a custom failure condition.
These are how you make otherwise non-idempotent commands behave: run a check, register the result, and set changed_when: false so the task reports ok when nothing actually changed.
- name: Check app health
ansible.builtin.command: /opt/app/health
register: health
changed_when: false
failed_when: health.rc != 0 and 'starting' not in health.stdoutDynamic inventory generates the host list at runtime from an external source instead of a static file. Inventory plugins query AWS, Azure, GCP, or a CMDB and return current hosts, grouped by tags or attributes.
You need it whenever hosts change often: autoscaling groups, cloud instances that come and go, or any environment where maintaining a static file by hand would drift out of date immediately.
# aws_ec2.yml inventory plugin config
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
keyed_groups:
- key: tags.Role
prefix: roleFilters transform a value inside a template or expression, written with a pipe: {{ name | upper }}. Ansible ships many for defaults, type conversion, lists, and formatting.
Everyday ones: default (supply a fallback), to_json / to_yaml, join, map, select and reject (filter lists), and length. default is the one that prevents undefined-variable errors.
- name: Use a fallback if a var is unset
ansible.builtin.debug:
msg: "Port is {{ app_port | default(8080) }}"
- name: Join a list
ansible.builtin.debug:
msg: "{{ ['a', 'b', 'c'] | join(', ') }}"Galaxy is the public hub for sharing roles and collections. ansible-galaxy install pulls a role, and ansible-galaxy collection install pulls a collection, so you reuse community automation instead of rewriting it.
A requirements.yml file pins the roles and collections a project needs, which makes environments reproducible. ansible-galaxy init also scaffolds a new role's directory structure for you.
# install roles and collections from requirements.yml
ansible-galaxy install -r requirements.yml
ansible-galaxy collection install -r requirements.ymlA collection is a distributable bundle of modules, roles, plugins, and playbooks under a namespace, like community.general or amazon.aws. Since Ansible 2.10, most content lives in collections rather than the core install.
The move split the huge monolith into ansible-core plus separately versioned collections, so content ships and updates on its own schedule. That's why you use fully qualified names like ansible.builtin.copy now.
delegate_to runs a specific task on a different host than the one the play is currently targeting, while keeping that host's variables and context. The play loops over web servers, but one task runs somewhere else.
The classic uses: add or remove a host from a load balancer (run the LB task on the LB), or update DNS or monitoring from a control host during a rolling deploy.
- name: Take server out of the load balancer
ansible.builtin.command: /usr/bin/lb-remove {{ inventory_hostname }}
delegate_to: loadbalancer.example.comserial limits how many hosts a play runs on at once. Instead of hitting all web servers simultaneously, serial: 2 updates two, then the next two, so the service stays available during a deploy.
You can pass a number, a percentage, or a list to ramp up (1, then 5, then the rest). Combine it with health checks and load balancer delegation for a safe rolling deployment.
- hosts: webservers
serial: 2 # two at a time
max_fail_percentage: 25
tasks:
- name: Deploy new release
ansible.builtin.command: /opt/deploy.shasync sets a maximum runtime and poll sets how often Ansible checks in. With poll greater than zero, Ansible waits but avoids SSH timeouts on slow tasks. With poll: 0, it fires the task and moves on (fire and forget).
For fire-and-forget work, register the job and check it later with the async_status module. This suits long-running installs or jobs that outlive the SSH connection.
- name: Run a long job without blocking
ansible.builtin.command: /opt/long-import.sh
async: 3600
poll: 0
register: long_jobFact caching stores gathered facts (in Redis, a JSON file, or memory) so later runs can reuse them instead of gathering from scratch. You configure a caching plugin in ansible.cfg.
It speeds up playbooks against large inventories and lets one host reference another host's facts even when that host isn't in the current play. The trade-off is staleness, so set a sensible timeout.
set_fact creates or updates a variable during a run from any expression you build. register captures a task's raw output. You often register a result, then set_fact to extract or compute the piece you actually want.
set_fact values persist for the rest of the play on that host and, unlike vars, they're evaluated at that moment, so they're the tool for values computed mid-run.
- name: Compute a derived value
ansible.builtin.set_fact:
backup_name: "app-{{ ansible_date_time.date }}.tar.gz"
- ansible.builtin.debug:
var: backup_nameloop is the current, recommended way to iterate over a simple list. The older with_items, with_dict, with_nested, and similar keywords use lookup plugins and still work, but loop plus filters covers most of them more clearly.
For complex iteration (nested lists, dict items, indexed loops), you combine loop with filters like dict2items or the subelements lookup rather than a dedicated with_ keyword.
# modern: loop over dict items
- name: Create users with shells
ansible.builtin.user:
name: "{{ item.key }}"
shell: "{{ item.value }}"
loop: "{{ user_shells | dict2items }}"vars_prompt asks the user for a value when the playbook starts, before any task runs. You can mark a prompt private so the input is hidden, which suits passwords.
Use it sparingly: interactive prompts block automation and CI. For secrets in automated runs, Vault or a secret manager fits better; prompts are for occasional manual playbooks.
- hosts: all
vars_prompt:
- name: db_password
prompt: "Enter the database password"
private: true
tasks:
- ansible.builtin.debug:
msg: "Received {{ db_password | length }} characters"By default command and shell always report changed and always run, so they break idempotency. You fix this three ways: use creates or removes so the task skips if a file already exists, set changed_when based on the output, or replace it with a real module.
The best fix is usually a purpose-built module. When you must shell out, creates is the cleanest guard, and changed_when: false stops it from lying about changes.
- name: Extract only if not already done
ansible.builtin.command: tar xzf /tmp/app.tar.gz -C /opt/app
args:
creates: /opt/app/VERSION # skips if this existsKey point: This question is really 'do you understand idempotency deeply?'. Naming creates plus changed_when plus 'prefer a module' is the full-marks answer.
ignore_errors: true lets a play continue even if a task fails, instead of stopping the host. The task still reports failed, but execution moves on.
Use it sparingly, mainly with a following task that checks the registered result and reacts. Blanket ignore_errors hides real problems; a block with rescue is usually the cleaner way to handle expected failures.
The environment keyword injects variables into a task's or play's execution environment, which suits commands that read things like PATH, http_proxy, or a tool's config location.
It applies to the task it's attached to (or the whole play if set there) and doesn't persist on the host beyond the run.
- name: Run a build behind a proxy
ansible.builtin.command: make build
environment:
http_proxy: http://proxy.example.com:8080
PATH: "/opt/tools/bin:{{ ansible_env.PATH }}"advanced rounds probe design judgment, scale, security, and production scars. Expect every answer here to draw a follow-up.
For each task, Ansible takes the module code, combines it with the arguments, and transfers a self-contained package to the target over SSH. The host runs it (usually with Python), returns JSON, and Ansible cleans up. Nothing persists.
Senior-level extras: modules run one host at a time up to the fork count, connection pipelining reduces round trips, and the strategy plugin (linear by default, free as an alternative) controls how hosts progress through the play.
Key point: pipelining and the fork count matters.
A strategy plugin decides how hosts move through a play's tasks. The default, linear, runs each task on all hosts before moving to the next, so hosts stay in lockstep. free lets each host race ahead through tasks as fast as it can.
linear makes ordering predictable and handlers cleaner across hosts. free finishes faster when hosts vary in speed but sacrifices that lockstep. There's also host_pinned for keeping a host on one worker.
| Strategy | Behavior | Best for |
|---|---|---|
| linear (default) | All hosts finish a task before the next | Predictable ordering, most plays |
| free | Each host races ahead independently | Uneven host speeds, faster overall |
| host_pinned | A host sticks to one worker | Stateful or connection-heavy work |
Raise forks to run more hosts in parallel, enable SSH pipelining and ControlPersist to cut connection overhead, use fact caching to skip repeated gathering, and lean on dynamic inventory so the host list stays accurate. For very large fleets, split into batches with serial.
Beyond one control node, teams add AWX or Ansible Automation Platform for a job queue, distributed execution, RBAC, and scheduling. The honest coverage names both the tuning knobs and the point where a controller pays off.
Write one when no existing module fits and you'd otherwise abuse command or shell in a non-idempotent way. A module is usually a Python script that reads JSON arguments, does its work, and returns JSON with changed and any results.
The AnsibleModule helper handles argument parsing, check mode, and the output format. Before writing one, check whether a collection already covers it, since the ecosystem is large.
from ansible.module_utils.basic import AnsibleModule
def main():
module = AnsibleModule(argument_spec=dict(
name=dict(type='str', required=True),
))
# ... do work, decide if anything changed ...
module.exit_json(changed=True, name=module.params['name'])
if __name__ == '__main__':
main()Vault encrypts secrets at rest in the repo, and you separate secrets per environment with vault IDs so prod and staging use different keys. For dynamic or centrally rotated secrets, pull them at runtime from a secret manager (HashiCorp Vault, AWS Secrets Manager) via lookup plugins rather than storing them at all.
The mature setup mixes both: Ansible Vault for static config secrets in git, an external manager for high-value or rotating credentials, and vault passwords supplied by a script, never committed.
Molecule is the standard: it spins up a container or VM, applies the role, and runs verifications, including an idempotence check that fails if a second run reports changes. Add ansible-lint for style and correctness, and yamllint for the YAML itself.
A strong pipeline runs lint, then molecule converge and idempotence, then verify assertions, in CI on every change. The idempotence test is the one that catches the most real bugs.
# typical CI sequence
ansible-lint
molecule test # create, converge, idempotence, verify, destroyKey point: Naming molecule's idempotence step specifically signals you've tested roles properly, not just run them once and shipped.
They wrap Ansible in a web UI and API: a job queue, scheduled runs, role-based access control, encrypted credential storage, a visual inventory, and workflows that chain playbooks. AWX is the open-source upstream; Automation Platform is the supported Red Hat product.
The value is operational, not new automation power. Teams adopt it when many people need to run playbooks safely, with audit logs, approvals, and secrets that operators never see in plaintext.
Terraform provisions infrastructure (VMs, networks, load balancers) and tracks it in state. Ansible configures what's inside those machines after they exist: packages, users, services, app code. They're sequential, not competing.
A common pipeline: Terraform creates the servers and outputs their addresses, then Ansible reads that inventory (often dynamic) and configures them. Using each for its strength, provisioning versus configuration, is the answer the question needs.
| Terraform | Ansible | |
|---|---|---|
| Primary job | Provision infrastructure | Configure machines |
| State tracking | Yes, a state file | No, checks live state each run |
| Language | HCL | YAML |
| Typical order | First | Second |
A connection plugin defines how Ansible reaches a host. ssh is the default for Linux; winrm connects to Windows; local runs on the control node; and there are plugins for containers (docker) and network devices.
For Windows, you set ansible_connection: winrm, use the windows and ansible.windows collections, and PowerShell-based modules replace the Python ones. Networking gear often uses network_cli with vendor collections and no Python on the device.
Handlers run at the end of the play, so if the play fails before reaching them, notified handlers don't run and the change (like a config edit without a restart) is left half-applied. force_handlers or meta: flush_handlers addresses this.
Other traps: a handler runs once even if notified many times, handlers are matched by name so duplicates collide, and flush_handlers lets you force them mid-play when order matters. These details separate real experience from tutorial knowledge.
tasks:
- name: Update config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app.conf
notify: Restart app
- name: Force handlers to run now
ansible.builtin.meta: flush_handlersMeasure first with profiling callbacks (the profile_tasks callback shows per-task time). Then attack the biggest costs: raise forks for more parallelism, enable SSH pipelining, cache facts or disable gathering where not needed, and cut needless loops and command tasks.
Structural wins matter too: fewer round trips per host (pipelining), async for long jobs, and batching hosts. the check is that you profile before guessing, then fix in impact order.
# ansible.cfg
[defaults]
forks = 50
callbacks_enabled = profile_tasks
[ssh_connection]
pipelining = TrueCombine serial for rolling batches, max_fail_percentage to abort if too many hosts fail, health checks between batches, and block/rescue to roll back a failed host cleanly. Delegate load balancer removal and re-add around each batch so users never hit a broken node.
The mindset to convey: assume a host will fail, and design so one failure stops the rollout early instead of taking down the fleet. any_errors_fatal is the harshest version, halting on the first failure.
- hosts: webservers
serial: "25%"
max_fail_percentage: 10
any_errors_fatal: false
tasks:
- name: Deploy release
ansible.builtin.command: /opt/deploy.shWhat one rolling batch looks like
max_fail_percentage stops the rollout early instead of breaking every host.
Lookups fetch data from outside the playbook during templating: read a file, query a secret manager, pull an environment variable, or generate a password. You call them with the lookup function or the query form.
The difference from filters: a filter transforms a value you already have; a lookup goes and gets a value from an external source. Lookups run on the control node, which matters for security and for where files must exist.
- name: Read a secret from the environment
ansible.builtin.debug:
msg: "{{ lookup('env', 'DB_PASSWORD') }}"
- name: Pull each line of a file
ansible.builtin.debug:
msg: "{{ item }}"
loop: "{{ query('lines', 'cat /etc/hosts') }}"Split shared, reusable content into internal collections and roles with pinned versions in requirements.yml, keep environment data in per-environment inventory with group_vars, and separate secrets into Vault or an external manager. Playbooks stay thin, orchestrating roles rather than holding logic.
Add CI that lints and molecule-tests every role, a controller (AWX or Automation Platform) for RBAC and audited runs, and clear naming conventions. The theme is the same as any codebase: reuse, versioning, testing, and least privilege.
Check mode can't run everything accurately: tasks that depend on a change made earlier in the play (a package that a later task configures) may report wrong because the earlier change didn't actually happen. Some modules don't support check mode at all.
Workarounds: mark read-only tasks with check_mode: false so they run even in a dry run, use --diff to see real file changes, and accept that check mode is a strong preview, not a perfect simulation. Saying that boundary out loud is the production signal.
ansible-pull inverts the model: each host runs Ansible on itself, pulling playbooks from a git repo and applying them locally, usually on a cron or systemd timer. It's pull-based, like Puppet or Chef.
Use it when you have many nodes that should self-configure without a central push, or nodes that aren't always reachable from a control node (edge devices, autoscaling instances that configure themselves at boot). The trade-off is less central control and no single run view without extra tooling.
# each host pulls and applies its own config
ansible-pull -U https://git.example.com/infra.git local.ymlNarrow scope first: run against one failing host with --limit and bump verbosity to -vvv to see the module and connection detail. Compare a failing host's facts and group_vars against a passing one, since precedence or a missing variable is a common cause.
Then reproduce safely with --check --diff or --start-at-task to jump to the failing step, register and debug the offending task's output, and correlate with recent inventory or role changes. The structure, observe, isolate, compare, verify, is what matters.
Key point: The methodology is; --limit and -vvv are supporting evidence.
command and shell without creates/changed_when, the lineinfile module when a regex matches loosely and edits the wrong line, template output that includes a timestamp so it always differs, and any module used with a force option that overwrites unconditionally.
The senior point: idempotency is a property you have to protect, not a guarantee. You verify it by running twice and confirming the second run reports zero changes, which is exactly what molecule's idempotence test automates.
Key point: Giving concrete non-idempotent examples (not just the definition) is what separates someone who's debugged this from someone who's read about it.
A role packages a set of tasks and their assets for one job: configure nginx, set up a user. A collection is broader: it can hold many roles plus custom modules, plugins, and playbooks under a namespace, versioned and distributable as a unit.
Reach for a role when you're bundling tasks for reuse inside your projects. Reach for a collection when you're shipping modules or plugins, or distributing a whole toolkit across teams or publicly, since only collections carry plugin code cleanly.
Network modules run on the control node and talk to devices over their management API or CLI (network_cli, netconf, httpapi connection plugins), because switches and routers usually can't run Python locally. Vendor collections (cisco.ios, arista.eos, junipernetworks.junos) provide the modules.
The patterns differ from server config: you push configuration lines or structured intent, gather device facts, and often render config from templates. Idempotency comes from the module comparing running config to desired, not from a package manager.
Ansible wins when you want low setup cost and a gentle on-ramp: agentless push over SSH, YAML instead of a custom language, and one binary on the control node. It trades some scale ergonomics for that simplicity, so very large fleets sometimes reach for a pull-based agent model. Puppet and Chef are agent-based and use a declarative DSL or Ruby, which suits big managed estates but costs more to stand up. Terraform is a different job entirely: it provisions cloud infrastructure and tracks state, where Ansible configures machines after they exist. Teams often run Terraform then Ansible, not one instead of the other. Saying these boundaries out loud is itself an interview signal.
| Tool | Model | Language | Best at |
|---|---|---|---|
| Ansible | Agentless push (SSH) | YAML | Config management, app deploy, quick start |
| Puppet | Agent, pull | Puppet DSL | Large managed fleets, enforced state |
| Chef | Agent, pull | Ruby DSL | Programmable config, developer-heavy shops |
| Terraform | Agentless, state file | HCL | Provisioning cloud infrastructure |
| SaltStack | Agent or agentless | YAML + Python | Event-driven automation at scale |
Prepare in layers, and practice out loud. Most Ansible rounds move from concept questions to reading or writing a playbook to a scenario or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Ansible interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Ansible questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works