A platform engineer candidate at a 200-person fintech startup walked through a clean playbook in March 2026, then got asked one follow-up: what happens when this same playbook runs against a server that's already configured. He said "it just runs again" and stopped there. That's not wrong exactly, but it's incomplete, and it's the single most common gap in Ansible interviews right now: people who can write a playbook fine and can't explain, in their own words, why running it twice is supposed to be boring.
Here's an opinion that might not hold up: most Ansible tutorials treat idempotency as a one-paragraph definition near the top of chapter one, when it's really the entire reason the tool works the way it does. Terraform gets asked about state files. Puppet and Chef get asked about their agent daemons. Ansible interviews circle back to idempotency, the agentless SSH model, and variable precedence more than any other three topics combined, because those are the places where a candidate's mental model either matches reality or doesn't.
This page covers the Ansible interview questions that come up across DevOps, SRE, and platform engineering loops in 2026: agentless architecture, inventory, playbooks and tasks, modules and idempotency, ad-hoc commands, variables and facts, roles and Galaxy, and handlers, Jinja2, and Vault. It's organized by topic instead of by seniority level, because the same questions get asked at a junior screen and a staff onsite, just with a harder follow-up attached.
Ansible basics: what "agentless" actually buys you
Every loop opens here, and it's an easy section to breeze through with a memorized one-liner. Interviewers use it to check whether that one-liner is backed by an actual mental model of the connection.
Easy questions
15It means managed nodes don't run any persistent Ansible daemon, service, or background process. Ansible connects over standard SSH (or WinRM for Windows targets), does the work, and disconnects, leaving nothing running on the host afterward.
Operationally that's a smaller attack surface (no extra port to open beyond SSH, no agent binary to patch and version-track across a thousand hosts) and it means a brand-new box can be managed the moment it has an SSH key, with zero bootstrap step. Puppet and Chef both need an agent installed and registered with a master before they can do anything (Red Hat / Ansible, How Ansible Works).
A static inventory is a plain file, INI or YAML, that you maintain by hand and list hosts in directly. A dynamic inventory is a script or plugin that queries something external at runtime, a cloud provider's API, a CMDB, a service registry, and returns the current set of hosts as JSON every time Ansible runs.
The practical difference shows up the moment your infrastructure changes shape faster than a human can update a text file. A static file for 12 servers is fine. A static file for an autoscaling group is a file that's wrong within the hour.
Square-bracket headers define groups, and you list hosts under each one. A :children suffix on a group name lets you nest existing groups inside a parent group instead of repeating hostnames.
[webservers]
web1.internal
web2.internal
[dbservers]
db1.internal
[production:children]
webservers
dbserversEverything in production here inherits any group_vars set on the parent, which is exactly how most teams layer environment-wide settings (region, ssh_user, a shared proxy) on top of role-specific ones without duplicating them per group.
A playbook is the YAML file itself, and it can hold more than one play. A play maps a group of hosts to an ordered list of things to run against them, plus its own settings like become or variables scoped to just that play. A task is one single module call inside a play, one action with its arguments.
So a playbook could open with a play targeting dbservers and a second play right below it targeting webservers, each with a completely different set of tasks, in one file.
It means running the exact same task against a host in the exact same state twice produces the exact same result both times, and the second run reports "ok" (nothing changed) instead of erroring or repeating the action. Idempotent modules check the current state before doing anything.
That's the whole design premise behind Ansible: you describe the state you want, run the playbook as many times as you want, and it converges toward that state instead of blindly re-executing commands.
The command module runs your program directly without going through a shell, so pipes, redirects, and environment-variable expansion don't work at all. The shell module runs everything through /bin/sh -c, so pipes and redirects work, but so does shell injection if you're interpolating anything a user could control into the command string.
Default to command unless you specifically need shell features. It's the safer choice, and most tasks that reach for shell out of habit don't actually need a pipe or a redirect at all.
It's a single module call run straight from the command line, no YAML file involved, for something quick you won't need to repeat or version-control: checking connectivity, restarting one service, grabbing uptime across a group.
ansible webservers -m pingIf you find yourself running the same ad-hoc command more than twice, that's the signal it belongs in a playbook instead.
Facts are data Ansible discovers about a host at runtime rather than data you define yourself: OS family, IP addresses, memory, mounted filesystems. The setup module collects them at the start of a play and stores them under the ansible_facts namespace.
Older playbooks sometimes reference facts as bare top-level variables, ansible_default_ipv4 instead of ansible_facts['default_ipv4']. That still works by default, but it's a legacy compatibility shim, not the pattern anyone should be writing new code against in 2026.
Galaxy is a public (and privately-hostable) registry of shared roles and collections, roughly the npm-for-roles comparison, browsable and installable with ansible-galaxy install.
Reach for a Galaxy role on widely-solved, boring problems, installing certbot, a standard Nginx baseline, that are actively maintained by someone else. Write your own the moment the logic is specific to your internal deploy process or app, because an abandoned community role becomes tech debt faster than most teams expect.
ansible.cfg is where you set defaults so you don't have to pass the same flags on every ansible-playbook invocation: inventory path, default remote user, forks, become settings, SSH args, retry file behavior, and so on. Anything not set there falls back to Ansible's built-in defaults.
Ansible checks a specific order and stops at the first file it finds: the ANSIBLE_CONFIG environment variable if set, then ansible.cfg in the current directory, then ~/.ansible.cfg in the user's home directory, then /etc/ansible/ansible.cfg. That's a common source of confusion on shared boxes, someone runs a playbook from a different directory and gets different behavior because a project-local ansible.cfg wasn't picked up. Good practice is keeping one ansible.cfg checked into the repo next to the playbooks so everyone gets the same settings regardless of where they run from.
A module is the unit of work that actually runs on the target host, or sometimes locally, to do something concrete: install a package, copy a file, manage a user, hit a cloud API. Modules are self-contained scripts shipped over to the remote machine, executed, and returned as JSON. That's the thing you call in a task with a name like apt, copy, or ec2_instance.
A plugin is code that runs on the control node and extends how Ansible itself behaves, not what happens on the remote host. Connection plugins decide how Ansible talks to a host (SSH, WinRM, local). Lookup plugins pull data in from outside the play, like reading a file or querying an API for a variable value. Callback plugins change how output gets formatted or where it gets sent. Filter plugins add things you can use in Jinja2, like the default filter. Modules do the work, plugins change how Ansible operates around that work.
become tells Ansible to escalate privileges on the target host for a task, play, or whole playbook, usually to root, using sudo by default. You set become: true and optionally become_user if you want something other than root, and become_method if the host uses something other than sudo, like su or pfexec on Solaris.
It's different from SSHing in as root because your connection user and your execution user stay separate. You connect as a normal, audited user with SSH keys, and Ansible escalates only for the task that needs it. That keeps SSH access logs meaningful and means a misconfigured playbook doesn't automatically have root over the wire, the escalation happens locally on the host through sudo's own controls, including sudoers restrictions and logging.
register captures the full result of a task, the same JSON structure the module returns, into a variable you name, so later tasks can act on it. That includes stdout, stderr, rc, changed, failed, and any module-specific keys, not just a simple string.
A common pattern is running a command, registering the result, then using it in a when clause on a later task, like checking result.rc == 0 or scanning result.stdout for a string. One gotcha: if the task that registers the variable gets skipped by its own when condition, the variable still exists but with skipped: true and no stdout, so a later task that blindly reads result.stdout will fail unless you guard for that case.
Tags let you label tasks, plays, or whole roles, then run only the labeled subset with --tags, or run everything except a subset with --skip-tags. It's the difference between re-running an entire 40 minute deploy playbook and just re-running the three tasks that push config files.
The common mistake is tagging tasks inconsistently, someone tags the install tasks in one role but forgets on another, so --tags install silently skips a whole role because none of its tasks carry that tag. The other one is forgetting that handlers only fire if a tagged task that notifies them actually ran, so --tags config can update a file without restarting the service if the handler notification lives outside what got selected.
group_vars and host_vars are directories Ansible checks automatically alongside your inventory, without you referencing them anywhere. A file at group_vars/webservers.yml applies to every host in the webservers group, and a file at host_vars/db1.example.com.yml applies only to that one host. You can also use directories instead of single files, group_vars/webservers/main.yml plus group_vars/webservers/secrets.yml, which is handy for splitting vaulted secrets from plain variables in the same group.
Ansible matches the filename to the inventory group or hostname exactly, so a typo in either one means the vars file silently never applies, no error, the play just runs with whatever the next precedence level provides instead. That's a frequent cause of "why isn't my variable set" bugs, worth checking with ansible-inventory --host
Medium questions
25Ansible connects over SSH, then for each task it builds a small Python script (the module plus its arguments serialized as JSON), copies that script to a temp directory on the remote host, executes it using the remote machine's own Python interpreter, reads back a JSON result over stdout, and deletes the temp files.
That last cleanup step is why most people never see the generated code. Setting ANSIBLE_KEEP_REMOTE_FILES=1 before a run stops Ansible from deleting the temp files, which is the fastest way to actually see the module script that got shipped and executed on a host you're debugging (Ansible Documentation, connection methods). Candidates who've done this once tend to answer this question a lot more confidently than candidates who've only read about it.
Push-based means the control node initiates every connection outward to the managed hosts, on a schedule or on demand, from one central place. Pull flips that: each node reaches out on its own, usually via a cron job, to fetch a playbook from a git repo and run it locally against itself.
ansible-pull earns its keep at real scale, thousands of ephemeral autoscaling instances, or hosts sitting behind a firewall that won't accept inbound SSH from a central control node. I don't have great data on how common pull mode actually is in production shops versus how often it comes up as an interview question, which is a wider gap than I'd expect given how useful it is for autoscaling groups.
Ansible processes hosts in batches sized by the forks setting (default is 5), gathering facts on that batch first unless facts are disabled, then runs task 1 across every host in the batch before moving to task 2, and so on, using the default "linear" strategy.
That's a detail that trips people up: it's not task-1-through-task-10 on host A, then the same on host B. It's every host doing task 1 together, then every host doing task 2 together. Handlers, if any got notified, fire at the very end of the play, once, after every task has run.
Gathering facts runs the setup module on every host before your first real task, collecting somewhere around 200 individual facts, OS version, network interfaces, mounted disks, memory. That's real per-host latency, and it adds up fast once you're past a few hundred hosts.
Turn it off when the playbook never references a fact anywhere, common on single-purpose ephemeral containers where you already know exactly what's inside. Leave it on the moment any task, template, or conditional touches ansible_facts, even indirectly through a role you didn't write yourself.
The command module handles this fine since df -h doesn't need a pipe or redirect, though plenty of engineers reach for shell here purely out of habit.
ansible webservers -m command -a "df -h"Add -b if the check needs elevated privileges, and -i inventory.ini if you're not pointed at the default inventory path already.
Pull it straight out of the ansible_facts dictionary using the fully-namespaced key.
server_ip = {{ ansible_facts['default_ipv4']['address'] }}Namespacing it explicitly like this instead of the bare ansible_default_ipv4 shorthand is the safer habit, and it's the version that keeps working even in environments where the legacy fact-injection shim gets disabled.
Ansible auto-loads specific files by convention, so a correctly-shaped role never needs an explicit include: anywhere for its own tasks, handlers, or variables.
roles/webserver/
tasks/main.yml
handlers/main.yml
templates/
files/
vars/main.yml
defaults/main.yml
meta/main.ymlDrop a role into another project with this exact structure and it just works, because Ansible is looking for tasks/main.yml by name, not because you told it where to look. That convention-over-configuration design is also what makes Ansible Galaxy possible at all, roles from strangers just plug in.
A handler is a task that only runs when a regular task notifies it by reporting "changed," typically used for things like restarting a service after its config file actually got updated, not on every single run.
Ansible deduplicates handler notifications by name within a play. Three tasks can all fire notify: restart nginx in the same run, and the handler still executes exactly once, at the very end of the play, after every task has finished (or earlier, if something explicitly flushes handlers mid-play).
Jinja2 is the templating engine evaluating anything wrapped in double curly braces, which includes conditionals, loops, and filters used directly inside playbook YAML, not just files rendered through the template module.
worker_count: {{ ansible_facts['processor_vcpus'] | default(2) }}Any time you see {{ }} in a playbook, that's Jinja2 evaluating an expression at runtime, before Ansible even parses the surrounding line as YAML. The .j2 extension convention is just a naming habit for files meant to be rendered with the template module, it isn't what actually enables Jinja2 syntax.
loop is the newer, more predictable way to iterate, it takes a list and hands each item to the task one at a time as the item variable. with_items and the rest of the with_ lookup family (with_dict, with_together, and so on) are older and each one is backed by a different lookup plugin under the hood, which means their behavior around things like flattening nested lists isn't consistent across them.
Sticking with with_items you lose some clarity and newer features. loop plays better with loop_control, which lets you rename the loop variable (loop_var) so nested loops don't clobber each other's item, and lets you add a label so verbose output doesn't dump an entire dict per iteration. with_items also auto-flattens a list of lists one level deep, which is a subtle behavior difference that trips people up when they expect loop's more literal, non-flattening iteration.
The most common cause is a type mismatch. Facts like ansible_distribution_major_version come back as strings, not integers, so comparing to an integer literal fails silently, it just evaluates false, Ansible doesn't error out on a mismatched comparison like that. You need == "7", or cast it explicitly with something like ansible_distribution_major_version | int == 7.
The other common cause is that gather_facts was disabled or the play ran before facts were collected, so the fact simply doesn't exist and the comparison evaluates as undefined, which Jinja2 also treats as falsy in a when clause rather than raising an error you'd notice immediately. Running with -vvv shows the exact value Ansible evaluated the condition against, which is usually the fastest way to spot which of the two it is.
block groups tasks so you can apply a shared when, become, or tags setting once instead of repeating it on every task, but the real value is rescue and always, which give you actual error handling instead of ignore_errors just papering over a failure. rescue only runs if a task inside the block fails, and gets access to special variables, ansible_failed_task and ansible_failed_result, describing what went wrong, so you can branch on the actual failure instead of blindly continuing.
always runs regardless of whether the block succeeded, failed, or was rescued, similar to a finally clause, which is what you want for cleanup tasks like removing a lock file or deregistering a host from a load balancer. ignore_errors just suppresses the failure and moves to the next task with no way to react differently based on what actually broke, block/rescue/always gives you that branching plus guaranteed cleanup in one structure.
- block:
- name: Deploy new release
command: /opt/app/deploy.sh
rescue:
- name: Roll back on failure
command: /opt/app/rollback.sh
always:
- name: Remove deploy lock
file:
path: /tmp/deploy.lock
state: absentignore_errors just tells Ansible to keep going past a task that failed, the failure still gets recorded and shown in the recap, it just doesn't stop the run. failed_when lets you redefine what "failure" even means for that task, based on rc, stdout, or any registered value, which matters a lot for command and shell tasks where a non-zero exit code doesn't actually indicate a real problem, like grep returning 1 when it finds no matches.
changed_when does the same thing for the changed status specifically. By default, command and shell always report changed: true because Ansible has no idea whether the underlying command actually changed anything, so if you're running something idempotent by nature, like a status check, you set changed_when: false so the play recap and --check mode diffs stay accurate. Getting this wrong is one of the most common reasons a playbook that did nothing still shows a wall of changed lines.
delegate_to reroutes where a single task actually executes, while the loop variable, when condition, and item are still evaluated in the context of the original host. The classic case is updating a load balancer's pool membership from a task that's logically part of a web server's play, you delegate that one task to the load balancer host instead of running it on the web server itself.
run_once is different, it makes a task execute on exactly one host out of the group, by default the first host in the batch, and skips it everywhere else, useful for things like running a database migration once instead of once per app server. The two are often combined: run_once: true with delegate_to: localhost, so a single notification or API call fires exactly once and runs on the control node rather than whichever host happened to be first in inventory order.
serial controls how many hosts Ansible works through per batch instead of hitting the entire play's host list at once. serial: 2 processes two hosts fully (all tasks) before starting the next two, serial: "25%" does the same math against the group size. Without it, a play with a bad task can take down every host in the group simultaneously since Ansible runs a task against all hosts before moving to the next task, by default.
This is what makes a rolling deploy actually rolling, you pull a batch out of the load balancer, deploy, health check, put it back, then move to the next batch, and if something's badly wrong you catch it after two hosts instead of after fifty. You can also pass serial a list like [1, 5, "20%"] to start with a canary batch of one host, then widen once that succeeds.
import_playbook lets one top-level playbook pull in other complete playbooks as a sequence, each with their own hosts, vars, and pre/post tasks, which a role dependency can't do because roles operate inside a single play's host pattern, not across plays. It's how you compose something like site.yml out of separate db.yml, web.yml, and cache.yml playbooks, each targeting a different inventory group, run in a defined order from one entry point.
It's a static, parse-time include, meaning the referenced playbook's content gets pulled in before any conditionals or loops are evaluated, so you can't put a when clause on import_playbook itself and expect it to behave the way it would on include_tasks. If you need runtime conditional logic around which playbook runs, import_playbook isn't the tool for that.
import_tasks is static, Ansible processes it at parse time before the play even starts running, which means tags, loops over the imported file, and conditionals applied at the import line get pushed down into every task inside the file individually. include_tasks is dynamic, resolved at runtime as the play executes, so a variable can determine which file gets included, and it plays better with loops since each iteration is properly scoped.
The practical difference that bites people: with import_tasks, a when condition on the import line gets applied to every single task in the included file, which can look right in simple cases but breaks down if some of those tasks have their own conditions that should be evaluated independently. Also, --list-tasks won't show you what's inside an include_tasks file since it's not resolved until runtime, but it will show everything inside an import_tasks file because that's already flattened out before the run starts.
The old model was a flat namespace of individual roles on Galaxy, mostly community-maintained, no consistent versioning story across roles from the same vendor, and no good way to ship modules, plugins, and roles together as one unit. Collections package modules, plugins, roles, and docs together under a namespace.name structure, like community.general or amazon.aws, with their own version number and changelog, installed with ansible-galaxy collection install.
The move also happened because Ansible core itself got smaller, a lot of modules that used to ship in ansible-base got split out into collections maintained by their own teams, cloud vendor modules especially, which is why a fresh Ansible install sometimes can't find a module you expect until you explicitly install the collection it lives in and reference it with the full namespace.collection.module name, or set collections: in your playbook to shorten that.
Fact caching solves the problem of gather_facts being slow and repetitive across separate playbook runs against the same hosts, gathering facts means SSHing in and running a setup module that inspects the whole system before any real work starts, every single time, even if nothing about the host changed since the last run five minutes ago. With caching turned on, jsonfile, redis, or memcached backend, set in ansible.cfg, Ansible stores gathered facts and reuses them within the cache timeout window instead of re-gathering.
The gotcha is exactly that timeout. If you turn caching on but leave fact_caching_timeout unset or too generous, you can end up running a playbook against a host that was reimaged or had its IP changed an hour ago, and Ansible happily uses stale cached facts instead of the real current state, which causes tasks to make decisions based on wrong data with no obvious error, since nothing failed, it just used the wrong ansible_default_ipv4 or wrong OS version silently.
async: N sets a timeout in seconds for how long a task is allowed to run in the background, and poll: N tells Ansible how often to check back on it. With both set to something like async: 1800, poll: 15, Ansible kicks the task off, then checks every 15 seconds whether it's finished, up to 30 minutes, without holding the SSH connection open synchronously the whole time.
Setting poll: 0 turns it fire-and-forget, Ansible starts the task and immediately moves on without waiting or checking status at all, useful for something like kicking off a long backup job you don't need to block the rest of the playbook on. The tradeoff is you get no result back in that run, you'd need a separate async_status task later, referencing the job ID that got registered, if you actually need to confirm it finished and check its outcome.
--limit restricts which hosts from the playbook's target pattern actually get touched during this run, without editing the playbook or inventory. --limit webservers narrows it to an inventory group by exact name. --limit '~web.*' uses the tilde prefix to treat the pattern as a regex instead of a literal group or hostname match, so it'll match anything starting with "web" across groups or hostnames, whatever fits the pattern.
A real use for this is re-running a big playbook after a partial failure, Ansible writes a .retry file listing exactly which hosts failed, and you can pass that file to --limit @retry-file.retry to rerun against only the hosts that didn't succeed, instead of re-touching the ones that already completed fine.
no_log: true suppresses a task's output from the console and from any log files or callback plugins, specifically to keep things like passwords, API tokens, or decrypted secrets from ending up in plaintext in your CI logs or Ansible's own log file. It replaces the task's result with a generic message rather than showing stdout, stderr, or the module's returned data.
Where it still leaks is if you're not careful about where the sensitive value shows up before that task, if you registered a secret in an earlier task without no_log, or if a debug task later prints the variable directly, the value is still sitting there in output from a different task. no_log only protects the exact task it's set on, it doesn't retroactively scrub a variable everywhere it might be referenced or displayed elsewhere in the run.
set_fact sits very high in Ansible's variable precedence, higher than role defaults, higher than inventory vars, and it takes effect immediately for the rest of the play, including later tasks and any roles that run after it. If you're seeing a role default win instead, the most common cause is that set_fact ran in a task that itself got skipped by a when condition, or ran inside a role that executes after the one whose default you expected to override, since order of execution matters, not just precedence ranking.
The other common cause is scope: a fact set with set_fact on one host doesn't automatically exist on other hosts, and a fact set inside one role's tasks is still visible in later roles within the same play, but if you're checking the variable's value from a separate play block in the same playbook, set_fact values don't carry over unless you're using a fact cache or explicitly passing the value forward.
The environment keyword sets environment variables for the specific module invocation on the remote host, at the process level Ansible uses to run that module, it doesn't touch the user's shell profile or persist beyond that one task. It's the right way to pass something like PATH additions or an HTTP_PROXY value to a task that needs it, without polluting the actual login shell environment on the target machine.
Exporting a variable inside a shell task, by contrast, only affects that one shell invocation and any subshells it spawns, it doesn't persist to the next task either, each task in Ansible runs as its own separate connection and process, so people who expect an export in one shell task to carry over to the next task get surprised when it's gone. If you need the same environment variables across several tasks, setting environment: at the play level applies it to every task in that play instead of repeating it per task.
linear is the default: every host in the batch runs the current task before any host moves on to the next task, so the whole group stays in lockstep task by task. free lets each host run through the entire play at its own pace, a fast host can finish all its tasks while a slow one is still on task three, no waiting for the group to sync up between tasks.
The obvious appeal of free is speed on a fleet with mixed hardware or network latency, nobody waits on the slowest host at every step. The tradeoff is you lose the guarantee that all hosts are at the same point at the same time, which matters if a later task assumes an earlier task already completed everywhere, like a task that depends on every host having registered with a load balancer before the next task fires, that ordering guarantee is gone with free.
Hard questions
12Switch to a dynamic inventory plugin instead of a file, one that queries the cloud provider directly at run time and builds the host list fresh on every invocation, so it can never go stale between runs.
For AWS specifically that's the amazon.aws.aws_ec2 plugin from the amazon.aws collection, pointed at an inventory source file that ends in aws_ec2.yml so Ansible knows to treat it as a plugin config, not a literal host list.
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
filters:
tag:Environment: production
instance-state-name: running
keyed_groups:
- key: tags.Role
prefix: roleThe keyed_groups block auto-creates groups from EC2 tags, so a host tagged Role: webserver lands in a role_webserver group automatically without anyone touching an inventory file, ever.
The shell module has no way to check whether /opt/myapp already exists before running the command. On the second run, mkdir errors out because the directory's already there, and the task fails, or it reports "changed" every single time regardless of whether anything actually needed to change.
# Not idempotent
- name: Create app directory
shell: mkdir /opt/myappTwo real fixes. The clean one: swap to the file module, which checks state first and only acts if something's actually missing.
# Idempotent
- name: Create app directory
file:
path: /opt/myapp
state: directory
mode: '0755'The second fix, if you genuinely need shell for something the file module can't do, is a creates: argument that tells Ansible to skip the command entirely once the target path exists. Interviewers want to hear "no state check" named as the actual mechanism. Saying "shell is bad" without explaining why gets you half credit at best.
Roughly, from lowest to highest: role defaults sit at the bottom, then inventory group_vars and host_vars, then play-level vars, then role vars, then block vars, then task vars, and extra vars passed with -e on the command line beat everything, always, no exceptions.
The bug that shows up constantly in real code review: someone sets a value in a role's defaults/main.yml, expects it to apply, and it silently doesn't, because an inventory group_var they forgot existed is quietly overriding it three levels up. -e is the one worth memorizing exactly, since it's the escape hatch that wins regardless of anything else in the play (Ansible Documentation, variable precedence).
Dependencies listed in a role's meta/main.yml run before that role's own tasks, automatically, without you calling them explicitly anywhere in the playbook. Ansible also deduplicates by default: if two separate roles in the same play both depend on the same shared role, it only actually runs once.
The gotcha: that dedup happens by role name, not by the parameters it was called with. If role A depends on common with one set of variables and role B depends on common with different ones, whichever gets resolved first quietly wins, and the second set of parameters never applies. allow_duplicates: true in meta overrides that behavior if you actually need the role to run more than once.
Most often it's a string mismatch: the notify: value doesn't match the handler's name: field exactly, character for character, including case. Ansible matches handlers by name string, not by any deeper reference, so a stray extra space or a capitalization difference silently breaks the link with zero error message.
The other two usual suspects: the handler lives in a role that this specific play never actually imported, or the notifying task sits inside a block whose failure path (a rescue, or an early failure before the notify line even gets reached) meant the notify statement itself never executed.
ansible-vault encrypt turns an entire YAML file into an AES256-encrypted blob that's unreadable without the vault password or vault-id, fine for something like a group_vars/all/vault.yml file dedicated purely to secrets.
ansible-vault encrypt_string 'supersecret' --name 'db_password'encrypt_string is the more surgical option: it encrypts one value inline, so the surrounding YAML, variable names, structure, everything but that one secret, stays readable in a git diff (Ansible Documentation, Vault guide). Vaulting the whole file is simpler to set up. Encrypting individual strings is the one that keeps code review actually useful once secrets are involved.
First check forks, the default is 5, meaning only 5 hosts are actually being worked on at once regardless of how many are in the play, so what looks like a hang against 300 hosts might just be normal behavior grinding through 60 batches of 5 sequentially, not actually stuck. Bump forks in ansible.cfg or with -f and see if throughput scales the way you'd expect for that host count.
If it's genuinely stuck and not just slow, the next suspects are SSH-level: a handful of hosts with a stale host key, a firewall silently dropping packets instead of rejecting the connection, or DNS resolution timing out on specific hostnames, since Ansible waits for the full SSH connection timeout on each one before failing, and with the default strategy it can appear the whole run is frozen while it's actually just one bad host in the current batch holding things up for everyone else in lockstep. Running with -vvvv shows the raw SSH command and where exactly it's stalling, and setting a shorter ConnectTimeout in ssh_args, plus ANSIBLE_TIMEOUT for the module response timeout, turns a silent multi-minute hang per bad host into a fast, visible failure you can then fix or exclude with --limit.
The key tool is vault IDs. Instead of one implicit vault password for the whole project, you encrypt with ansible-vault encrypt --vault-id new@prompt secrets.yml, tagging which password label was used, and multiple vault-id labels can coexist in the same repo, each file tagged with which password it needs, and you point ansible-playbook at multiple --vault-id arguments at once so it can decrypt files under either the old or new label in the same run.
The actual rotation, without a flag day where everything breaks, looks like this: introduce the new vault-id alongside the old one, re-encrypt secrets files one at a time with the new password under the new label while the old label is still accepted, ship both passwords to CI during the transition window, then once every file has been migrated, drop the old vault-id from CI and delete the old password. ansible-vault rekey can also re-encrypt a single file in place with a new password without needing a separate vault-id label at all, if you're rotating one file at a time rather than doing a project-wide vault-id migration.
A module's contract, regardless of language, is: read arguments as JSON, do the work, and write exactly one JSON object to stdout with at minimum a changed boolean, plus failed if something went wrong, and any other data the module wants to return for later tasks to register. Anything else printed to stdout that isn't valid JSON breaks the whole task, since Ansible parses stdout directly as the result.
What people get wrong the first time: not supporting check mode, so the module makes real changes even when someone runs --check expecting a dry run, when it should inspect supports_check_mode and skip the actual mutation while still reporting what would have changed. The other common miss is not being idempotent, the module should check current state before acting and only report changed: true when it actually altered something, not unconditionally, otherwise every run of that task shows changed regardless of whether anything happened, which breaks changed_when logic and handler notifications downstream. Reusing shared code via module_utils instead of copy-pasting helper functions into every module is also something first-timers skip, then have to retrofit once they've got five modules with the same duplicated auth or parsing logic.
def main():
module = AnsibleModule(argument_spec=dict(
name=dict(type="str", required=True),
), supports_check_mode=True)
changed = False
if module.check_mode:
module.exit_json(changed=changed)
# ... do the real work here ...
module.exit_json(changed=changed, msg="ok")forks controls parallelism across hosts, but per-host overhead is often the bigger cost at real scale. Pipelining (ANSIBLE_PIPELINING=True in ansible.cfg) cuts the number of SSH operations per task from several down to one by executing the module over stdin instead of copying it to a temp file, running it, then cleaning up as three separate SSH round trips, which adds up fast across thousands of hosts and hundreds of tasks. It requires requiretty be off in sudoers on the targets, which is the usual reason people try it, see no improvement, and give up without realizing it silently didn't activate.
Beyond pipelining, gather_facts overhead per host adds up linearly, disable it or narrow it with gather_subset if you don't need the full fact set every run. Mitogen for Ansible is the other real lever, it replaces the SSH and module-copy execution model with a persistent Python interpreter per host that Ansible pushes work to over a single connection, cutting round trips dramatically, teams running Mitogen against thousands of hosts routinely see multi-times speedups over stock Ansible, though it's an unofficial third-party strategy plugin so you're taking on extra risk around plugin compatibility and keeping it updated against new Ansible releases.
By default, when a host fails during a serial batch, Ansible finishes the current batch, the other hosts in that same batch keep going since they were already dispatched, then stops before starting the next batch. It doesn't automatically touch or roll back hosts from earlier batches that already succeeded and got fully deployed, they're left in their new, already-updated state.
max_fail_percentage changes the threshold for when Ansible gives up mid-batch instead of just failing at the batch boundary, if you set max_fail_percentage: 30 on a batch of 10 and 4 hosts fail, that's 40%, over the threshold, so Ansible stops immediately rather than finishing that batch's remaining hosts. any_errors_fatal is more aggressive still, a single failure anywhere immediately stops the entire play across every host and batch, including ones mid-task in a different batch under a free strategy, useful when a failure means something is fundamentally wrong and continuing to touch more hosts would make things worse rather than better. None of these three roll back already-updated hosts automatically, that's still on you to handle, usually via a separate rollback playbook targeting the hosts recorded in the retry file or captured from the play recap.
Teams move off ansible-vault to something like the hashi_vault lookup plugin or an AWS Secrets Manager lookup mainly for centralized secret rotation and access control that a flat encrypted YAML file can't give you, one vault password shared across a team or baked into CI is a single point of compromise, and there's no per-secret audit trail of who read what and when, whereas HashiCorp Vault gives leased, revocable credentials, fine-grained policies per secret path, and an access log.
What you give up is offline simplicity, ansible-vault works with zero external dependencies, you can run a playbook fully air-gapped as long as you have the password. A lookup against an external secrets store means the control node needs live network access to that store at runtime, plus its own auth setup, a Vault token, an AppRole, an IAM role, which is one more moving part that can be down or misconfigured exactly when you need to run an emergency playbook. Most teams that make the move already have that secrets infrastructure for other reasons and are just extending it to cover Ansible rather than adopting it purely for this.
How to prepare for Ansible interview questions in 2026
Skip memorizing module names in isolation and build one small end-to-end thing instead: a role or two with the correct directory layout, an inventory that groups at least three environments, a handler that actually gets notified, and one variable deliberately overridden at three different precedence levels so you can watch which one wins. Break your own idempotency assumption on purpose, write a shell task that isn't idempotent, run it twice, watch it fail or falsely report changed, then fix it with the file module. Fixing a bug you created yourself teaches the mental model faster than reading about it secondhand.
Across mock interview sessions run through LastRoundAI tagged DevOps, SRE, or platform engineering, the question that catches the most candidates isn't a module syntax question at all. It's "what happens if you run this twice," asked cold, right after a candidate finishes explaining a playbook they're clearly comfortable with. We don't have a clean percentage to attach to that pattern, only that reviewers flag it often enough across sessions to be worth naming here explicitly.
Worth knowing going in: network and computer systems administrator roles, the job title Ansible historically got tied to, are actually projected to shrink 4 percent from 2024 to 2034, even as around 14,300 openings a year still show up from turnover and retirements (BLS Occupational Outlook Handbook). That's not the same as automation skills mattering less. It reads more like the title itself splitting into DevOps engineer, SRE, and platform engineer roles that all still lean on configuration management daily, just under a different name on the job posting.
Practice the follow-up, not just the first answer
Reading the fix for a non-idempotent shell task is not the same as defending it out loud once an interviewer changes one detail on you mid-answer. LastRoundAI's mock interview mode runs DevOps and infrastructure-focused rounds with real-time follow-ups instead of a static question bank, and the free plan includes 15 credits a month that reset monthly. Starter is $19/mo if fifteen sessions a month isn't enough.
If the slower part of the job hunt right now is finding enough DevOps, SRE, or platform engineering openings rather than passing the interview once you're in front of one, Auto-Apply matches and queues applications for your review, 10 a month free, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it yourself.
Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

