Everything for Ansible in one place — pick a section below. 28 reviewed items across 3 content types, plus a hands-on tutorial.
Quick Answer
Ansible connects to servers over SSH without installing anything on them. It reads a list of target hosts from an inventory file and runs tasks defined in YAML playbooks.
Detailed Answer
Think of Ansible like a restaurant manager who walks to each station and gives verbal instructions to cooks, rather than installing an intercom system at every station. The manager (Ansible control node) simply walks over (SSH), tells the cook (managed node) what to do, checks the result, and moves on. No permanent communication system needs to be set up at each station.
At its core, Ansible uses a push-based approach where no software agent is needed on the target machines. The control node is where Ansible is installed and where all automation is kicked off. When you run a playbook, Ansible reads the inventory file to find out which hosts to target, opens SSH connections to those hosts, copies small Python scripts to the remote machines, runs them, grabs the output, and then cleans up the temporary files. This whole cycle happens for each task in the playbook, either one at a time or in parallel depending on how many forks you configure.
Under the hood, Ansible uses a component called the connection plugin to manage SSH sessions. The default plugin uses OpenSSH with ControlPersist, which keeps SSH connections alive across multiple tasks so it does not have to reconnect for every single task. The inventory can be a simple INI or YAML file listing hosts and groups, or it can be a dynamic script that queries cloud APIs like AWS or Azure. Playbooks are YAML files that define a series of plays, where each play maps a group of hosts to a set of tasks. Each task calls a module -- such as apt, copy, template, or service -- that performs a specific action on the remote machine.
In production, Ansible is usually run from a central server or a CI/CD pipeline. Organizations use Ansible Tower or AWX (the open-source version of Tower) to add a web interface, role-based access control, job scheduling, and audit logging. The inventory is typically dynamic, pulling host information from cloud providers like AWS, Azure, or GCP. Playbooks live in version-controlled repositories and run as part of deployment pipelines. SSH keys are managed centrally, and sudo-based privilege escalation is configured for tasks that need root access.
A common mistake is assuming Ansible leaves zero footprint on managed nodes. While it does not install a persistent agent, it does need Python on the target machine for most modules (the raw and script modules are exceptions). Another pitfall is SSH connection limits -- running against hundreds of hosts at once can use up file descriptors or trigger rate limiting on bastion hosts. You also need to be careful with the forks setting and the serial keyword to avoid overwhelming your infrastructure during rolling deployments.
Code Example
# inventory.yml - Define target hosts and groups
all:
children:
webservers: # Group for web application servers
hosts:
payments-api-01: # First web server
ansible_host: 10.0.1.10 # IP address for SSH connection
ansible_user: deploy # SSH user for this host
payments-api-02: # Second web server
ansible_host: 10.0.1.11 # IP address for SSH connection
ansible_user: deploy # SSH user for this host
databases: # Group for database servers
hosts:
prod-db-01: # Primary database server
ansible_host: 10.0.2.10 # IP address for SSH connection
ansible_user: dbadmin # SSH user for database host
---
# deploy-payments.yml - Playbook to deploy the payments API
- name: Deploy payments API to webservers # Play targeting the webservers group
hosts: webservers # Run against all hosts in webservers
become: true # Escalate to root privileges via sudo
serial: 1 # Deploy one server at a time (rolling)
vars: # Variables scoped to this play
app_version: "2.4.1" # Version of the app to deploy
app_dir: /opt/payments-api # Installation directory on remote host
tasks:
- name: Ensure app directory exists # Create the target directory if missing
file: # Use the file module
path: "{{ app_dir }}" # Path to create
state: directory # Ensure it is a directory
owner: deploy # Set ownership to deploy user
mode: '0755' # Set directory permissions
- name: Copy application artifact # Transfer the build artifact to remote
copy: # Use the copy module
src: "builds/payments-api-{{ app_version }}.tar.gz" # Local source path
dest: "{{ app_dir }}/payments-api.tar.gz" # Remote destination
owner: deploy # Set file ownership
mode: '0644' # Set file permissions
- name: Restart payments service # Restart the systemd service
systemd: # Use the systemd module
name: payments-api # Name of the systemd unit
state: restarted # Restart the service
daemon_reload: true # Reload systemd daemon firstInterview Tip
A junior engineer typically says Ansible is 'a tool that runs commands on servers' without explaining how its agentless, push-based design sets it apart from tools like Puppet or Chef. In your interview, make it clear that Ansible needs no daemon or agent on the target machines -- it just uses SSH and temporary Python scripts. Talk about the inventory as the source of truth for which hosts to target, and explain how playbooks describe the desired end state rather than step-by-step commands. Bring up connection reuse through ControlPersist and the forks setting for running tasks on multiple hosts at once. If you have time, mention that Ansible Tower or AWX adds enterprise features like role-based access control and audit logging on top of the core engine.
◈ Architecture Diagram
┌─────────────────────┐
│ Control Node │
│ (Ansible Engine) │
│ │
│ ┌───────────────┐ │
│ │ Inventory │ │
│ │ (hosts.yml) │ │
│ └───────────────┘ │
│ ┌───────────────┐ │
│ │ Playbook │ │
│ │ (deploy.yml) │ │
│ └───────────────┘ │
└────────┬────────────┘
│ SSH (Push)
┌────┴─────┐
│ │
↓ ↓
┌────────┐ ┌────────┐
│Managed │ │Managed │
│Node 01 │ │Node 02 │
│ │ │ │
│No Agent│ │No Agent│
│Needed │ │Needed │
└────────┘ └────────┘💬 Comments
Quick Answer
Playbooks are YAML files listing tasks to run on hosts. Roles package those tasks along with handlers, variables, templates, and files into a reusable, organized folder structure.
Detailed Answer
Imagine you are writing a cookbook. A playbook is like a single recipe page that lists every step to make a dish. A role is like a chapter that bundles related recipes, ingredient lists, prep notes, and techniques into one organized section you can reference from any recipe. You would not paste the 'how to make pasta dough' instructions into every pasta recipe -- you would just point to the pasta-dough chapter.
A playbook is the main entry point for Ansible automation. It is a YAML file with one or more plays, where each play picks a set of hosts and lists tasks to run on them. Playbooks can include variables, conditionals, loops, and handlers all in one file. They work great for simple, one-off tasks or small setups. But as your infrastructure grows, playbooks get long, hard to maintain, and difficult to reuse across projects.
Roles fix this by enforcing a standard folder structure that separates concerns. A role has subdirectories for tasks (tasks/main.yml), handlers (handlers/main.yml), variables (vars/main.yml and defaults/main.yml), templates (templates/), files (files/), and metadata (meta/main.yml). When Ansible loads a role, it automatically pulls in files from each of these directories by convention. The defaults directory holds low-priority variables that users of the role can easily override, while vars holds higher-priority variables meant for the role's internal use. The meta directory declares dependencies on other roles, so one role can automatically pull in others it needs.
In production, teams organize their Ansible code as a collection of roles in a shared repository or distribute them through Ansible Galaxy. A top-level playbook then becomes a simple mapping of hosts to roles -- assign the nginx role, the postgresql role, and the monitoring-agent role to different host groups. This lets different teams own different roles independently. Roles can be versioned and pinned in a requirements.yml file, so you can lock specific versions for production while testing newer ones in staging.
A common mistake is misunderstanding how variable priority works between roles and playbooks. Variables in defaults/main.yml have the lowest priority and are easy to override, but variables in vars/main.yml have much higher priority and can unexpectedly overwrite playbook-level variables. Another mistake is making roles too small or too large -- the sweet spot is one role per logical service or component. Also, forgetting to list role dependencies in meta/main.yml can cause missing prerequisites when roles are reused in different playbooks.
Code Example
# Playbook approach - everything in one file
# deploy-monolith.yml
- name: Deploy payments API (monolithic playbook) # Single play with all tasks inline
hosts: webservers # Target the webservers group
become: true # Use sudo for privilege escalation
tasks:
- name: Install nginx # Install web server package
apt: # Use apt module for Debian/Ubuntu
name: nginx # Package name to install
state: present # Ensure package is installed
- name: Copy nginx config # Deploy nginx configuration
template: # Use template module for Jinja2
src: nginx.conf.j2 # Local Jinja2 template source
dest: /etc/nginx/nginx.conf # Remote destination path
---
# Role-based approach - structured and reusable
# roles/nginx/tasks/main.yml
- name: Install nginx package # Task within the nginx role
apt: # Use apt module
name: nginx # Package to install
state: present # Ensure it is present
- name: Deploy nginx configuration # Configure nginx from template
template: # Use template module
src: nginx.conf.j2 # Template from roles/nginx/templates/
dest: /etc/nginx/nginx.conf # Destination on managed node
notify: restart nginx # Trigger handler on change
---
# roles/nginx/handlers/main.yml
- name: restart nginx # Handler triggered by notify
systemd: # Use systemd module
name: nginx # Service name
state: restarted # Restart the service
---
# roles/nginx/defaults/main.yml
nginx_worker_processes: auto # Default value, easily overridden
nginx_worker_connections: 1024 # Default connections per worker
---
# site.yml - Top-level playbook using roles
- name: Configure web tier with roles # Clean playbook referencing roles
hosts: webservers # Target host group
become: true # Escalate privileges
roles: # List of roles to apply
- role: nginx # Apply the nginx role
nginx_worker_connections: 2048 # Override default variable
- role: payments-api # Apply the payments-api role
- role: datadog-agent # Apply monitoring agent roleInterview Tip
A junior engineer typically mixes up roles and playbooks, saying something vague like 'roles are just reusable playbooks.' In your interview, clearly explain the structural difference: a playbook is an entry point that maps hosts to tasks, while a role is a folder-based package of tasks, handlers, variables, templates, and files. Mention Ansible Galaxy as the community hub for sharing roles, and explain how requirements.yml pins role versions. Talk about variable priority -- defaults/main.yml is easy to override while vars/main.yml is not -- because this shows you really understand how things work under the hood. If time allows, mention that collections in Ansible 2.10+ extend the role concept to bundle modules, plugins, and roles together.
◈ Architecture Diagram
┌──────────────────────────────────┐
│ site.yml (Playbook) │
│ hosts: webservers │
│ roles: │
│ - nginx │
│ - payments-api │
└──────────┬───────────────────────┘
│
┌─────┴──────┐
│ │
↓ ↓
┌─────────┐ ┌──────────────┐
│ nginx │ │ payments-api │
│ role/ │ │ role/ │
├─────────┤ ├──────────────┤
│tasks/ │ │tasks/ │
│handlers/│ │handlers/ │
│templates│ │templates/ │
│defaults/│ │defaults/ │
│vars/ │ │vars/ │
│meta/ │ │meta/ │
│files/ │ │files/ │
└─────────┘ └──────────────┘💬 Comments
Quick Answer
Idempotency means running the same playbook multiple times gives the same result with no unwanted side effects. Each module checks the current state before acting and only makes changes when reality differs from what you asked for.
Detailed Answer
Think of idempotency like a thermostat. You set the desired temperature to 72 degrees. If the room is already at 72, the thermostat does nothing. If it is at 68, it turns on the heater. If you press the 72-degree button ten more times, nothing extra happens because the desired state is already reached. Ansible modules work the same way -- they check the current state, compare it to what you want, and only make changes when there is a gap.
Idempotency is a concept borrowed from math: an operation is idempotent if doing it multiple times has the same effect as doing it once. In Ansible, this idea is built into every well-written module. When you write a task like 'ensure nginx is installed,' the apt module first checks whether nginx is already there. If it is, the task reports 'ok' (no change). If it is not, the module installs it and reports 'changed.' This check-first-then-act pattern is the foundation of Ansible's approach -- you describe the end state you want, not the steps to get there.
Under the hood, each module has its own logic for checking state. The file module looks at permissions, ownership, and content hashes before touching anything. The template module generates output from a Jinja2 template and compares its checksum against the existing file on the remote host. The user module queries the system's user database before creating or modifying accounts. The service module checks whether a service is running before starting or stopping it. This per-module checking is what lets Ansible produce accurate change reports -- the 'changed' count in the play recap tells you exactly how many tasks actually modified the system.
In production, idempotency is critical for several reasons. First, it makes re-runs safe -- if a playbook fails halfway through, you can re-run it from the start without worrying about duplicate package installs, duplicate config entries, or services getting restarted twice. Second, it supports drift detection -- by running playbooks in check mode (--check), you can see what would change without actually changing it, effectively auditing your infrastructure for unexpected differences. Third, it fits naturally into CI/CD pipelines where playbooks might be triggered multiple times by retries or overlapping deployments.
The biggest pitfall is the shell and command modules -- they are not idempotent by default because Ansible cannot know what a random shell command does. Running 'echo data >> /etc/config' via the shell module will add duplicate lines on every run. To fix this, use purpose-built modules (lineinfile, template, copy) instead of shell commands whenever possible. When shell is unavoidable, use the creates or removes parameter to add a condition, or use changed_when to control when Ansible marks the task as changed. Another subtle trap is using 'latest' as the package state -- while technically idempotent, it can cause surprise upgrades in production if upstream repositories are updated between runs.
Code Example
# idempotency-demo.yml - Demonstrating idempotent vs non-idempotent tasks
- name: Demonstrate Ansible idempotency patterns # Educational playbook
hosts: payments-api-servers # Target the payments servers
become: true # Elevate to root
tasks:
# IDEMPOTENT - apt checks if package exists first
- name: Ensure nginx is installed # Safe to run multiple times
apt: # Module has built-in idempotency
name: nginx # Package to ensure is installed
state: present # Desired state (not 'latest')
update_cache: true # Refresh apt cache first
cache_valid_time: 3600 # Only refresh if cache > 1hr old
# IDEMPOTENT - template compares checksums
- name: Deploy payments API config # Only writes if content differs
template: # Module compares checksums internally
src: payments-api.conf.j2 # Jinja2 template source
dest: /etc/payments-api/config.yml # Target configuration file
owner: payments # File owner
mode: '0640' # Restrictive file permissions
notify: restart payments-api # Handler fires only on change
# IDEMPOTENT - lineinfile checks line existence
- name: Ensure log rotation entry exists # Only adds if line is missing
lineinfile: # Module checks before modifying
path: /etc/logrotate.d/payments-api # Target file path
line: '/var/log/payments-api/*.log { rotate 7 }' # Line to ensure exists
create: true # Create file if it does not exist
# NOT IDEMPOTENT - shell runs every time blindly
- name: BAD - Append config line (not idempotent) # This appends on EVERY run
shell: echo 'MAX_CONNECTIONS=100' >> /etc/payments-api/env # Duplicates lines!
# FIXED - Make shell idempotent with creates param
- name: GOOD - Run migration only if not done # Conditional execution
shell: /opt/payments-api/bin/migrate --run # Run database migration script
args: # Additional arguments block
creates: /opt/payments-api/.migration_complete # Skip if this file exists
register: migration_result # Capture output for debugging
changed_when: "'Applied' in migration_result.stdout" # Custom change detectionInterview Tip
A junior engineer typically says 'idempotent means you can run it twice' without explaining the check-then-act mechanism behind it. In your interview, explain that each module has its own way of checking state -- apt queries dpkg, template compares checksums, service checks systemctl status -- and only acts when it finds a difference. Highlight the practical benefits: safe re-runs after partial failures, drift detection with --check mode, and reliable CI/CD integration. Make sure to mention that shell and command modules are the main exceptions to idempotency, and explain how to fix them with the creates parameter and changed_when directive. This shows you understand both the theory and the real-world challenges of keeping playbooks truly idempotent.
◈ Architecture Diagram
┌─────────────────────────────────────┐
│ Ansible Task Execution │
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Check Current State on Remote Host │
│ (e.g., is nginx installed?) │
└──────────────┬──────────────────────┘
│
┌─────┴──────┐
│ │
↓ ↓
┌─────────────┐ ┌──────────────┐
│ State Match │ │ State Drift │
│ (ok) │ │ (changed) │
├─────────────┤ ├──────────────┤
│ No action │ │ Apply change │
│ Report: ok │ │ Report: │
│ │ │ changed │
└─────────────┘ └──────────────┘💬 Comments
Quick Answer
Handlers are special tasks that only run when another task notifies them via the 'notify' keyword. They run once at the end of a play, no matter how many tasks notify them, and are most commonly used for restarting services after config changes.
Detailed Answer
Think of handlers like a restaurant's 'order up' bell. Multiple cooks might finish different parts of the same order, but the waiter only needs to come to the window once to pick up the complete plate. Similarly, you might update three different nginx config files in a play, and each change notifies the 'restart nginx' handler, but the handler only fires once at the end -- you do not want nginx restarting three separate times in the middle of your deployment.
Handlers are defined in the handlers section of a playbook or in a role's handlers/main.yml file. They look just like regular tasks but only run when a task with a matching notify keyword reports a 'changed' status. The notify keyword takes the handler's name as a string (or a list of names), and Ansible queues the handler internally. If a task reports 'ok' (nothing changed), the notification is not sent and the handler does not run. This tight link between change detection and handler execution is what makes handlers so useful for configuration management.
Under the hood, Ansible keeps a notification queue for each play. When a task with a notify keyword changes the remote system, the handler name gets added to this queue. Duplicate notifications are automatically removed -- if five tasks all notify 'restart nginx,' it shows up only once in the queue. At the end of all tasks in the play, Ansible processes the queue in the order handlers are defined, not the order they were notified. You can force handlers to run mid-play using the meta: flush_handlers task, which drains the current queue and runs all pending handlers immediately before moving to the next task.
In production, handlers are essential for zero-downtime deployments and efficient service management. Picture a rolling deployment where you update the application binary, the config file, and the systemd unit file. Each of these tasks notifies the restart handler, but the service restarts only once after all three changes are applied. Without handlers, you would either restart after every change (causing unnecessary downtime) or need complicated conditional logic to restart only at the end. Handlers also support listen groups, where multiple handlers subscribe to a single notification topic, letting you trigger a chain of related actions from one notify.
A critical pitfall is that handlers do not run if the play fails before reaching the handler phase. If task 3 of 5 fails and task 2 notified a handler, that handler will not run by default. You can change this with the --force-handlers flag or by setting force_handlers: true in ansible.cfg, but this can be risky if the handler depends on later tasks completing successfully. Another pitfall is handler naming -- handlers are matched by exact name string, so a typo in either the notify or handler name silently fails with no error message. Always test that your handlers actually fire by checking the play recap for handler task names.
Code Example
# handlers-demo.yml - Demonstrating handler patterns
- name: Configure payments-api with proper handlers # Production deployment play
hosts: prod-cluster # Target production servers
become: true # Escalate privileges
serial: 2 # Two servers at a time
handlers: # Define handlers section
- name: restart payments-api # Handler for full service restart
systemd: # Use systemd module
name: payments-api # Target service name
state: restarted # Perform a full restart
daemon_reload: true # Reload unit files first
- name: reload nginx # Handler for graceful nginx reload
systemd: # Use systemd module
name: nginx # Target nginx service
state: reloaded # Graceful reload (no downtime)
- name: restart rsyslog # Handler for log service restart
systemd: # Use systemd module
name: rsyslog # Target rsyslog service
state: restarted # Restart logging daemon
tasks:
- name: Deploy application binary # Copy new app binary to server
copy: # Use copy module
src: builds/payments-api-v2.4.1 # Local binary path
dest: /opt/payments-api/bin/payments-api # Remote binary destination
owner: payments # Set file ownership
mode: '0755' # Make it executable
notify: restart payments-api # Notify handler on change
- name: Update application config # Deploy new configuration
template: # Use template module
src: payments-api.conf.j2 # Jinja2 template source
dest: /etc/payments-api/config.yml # Remote config path
owner: payments # Set file ownership
mode: '0640' # Restrictive permissions
notify: restart payments-api # Same handler (runs only once)
- name: Update nginx virtual host # Deploy nginx site config
template: # Use template module
src: payments-vhost.conf.j2 # Nginx vhost template
dest: /etc/nginx/sites-enabled/payments-api # Nginx config destination
owner: root # Owned by root
mode: '0644' # Standard file permissions
notify: reload nginx # Notify nginx handler
- name: Force handlers to run now # Flush handlers mid-play
meta: flush_handlers # Execute all pending handlers
- name: Run smoke test after restart # Verify service is healthy
uri: # Use uri module for HTTP check
url: http://localhost:8080/health # Health check endpoint
status_code: 200 # Expect HTTP 200 response
retries: 5 # Retry up to 5 times
delay: 3 # Wait 3 seconds between retriesInterview Tip
A junior engineer typically describes handlers as 'tasks that restart services' without explaining the deduplication and end-of-play timing. In your interview, highlight three key behaviors: handlers only fire when notified by a task that actually changed something, duplicate notifications are merged so the handler runs just once, and handlers run at the end of the play by default. Mention meta: flush_handlers for when you need the service restarted mid-play, like before running a smoke test. Talk about the failure gotcha -- handlers do not run if the play fails before reaching them, and the --force-handlers flag changes this. Also mention the listen directive for grouping multiple handlers under one notification topic, which helps in complex multi-service deployments.
◈ Architecture Diagram
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Task 1 │ │ Task 2 │ │ Task 3 │
│ Deploy binary│ │ Update config│ │ Update vhost │
│ (changed) │ │ (changed) │ │ (ok) │
└──────┬───────┘ └──────┬───────┘ └──────────────┘
│ │
│ notify │ notify
↓ ↓
┌──────────────────────────────────┐
│ Handler Notification Queue │
│ │
│ ┌─────────────────────────┐ │
│ │ restart payments-api (1)│ │
│ └─────────────────────────┘ │
│ (deduplicated from 2 notifies) │
└──────────────┬───────────────────┘
│ End of play
↓
┌──────────────────────────────────┐
│ Execute: restart payments-api │
│ (runs exactly once) │
└──────────────────────────────────┘💬 Comments
Quick Answer
Dynamic inventory uses scripts or plugins that call cloud provider APIs at runtime to automatically find and group hosts. This removes the need to manually update a static host list as servers are added or removed.
Detailed Answer
Imagine you are a teacher taking attendance at a school where students transfer in and out every day. Instead of keeping a printed list that is outdated by lunchtime, you check the school's enrollment system each morning to get the current roster. Dynamic inventory works the same way -- instead of a static file listing hosts, Ansible queries your cloud provider's API in real time to discover what instances exist, what IPs they have, and how they should be grouped.
Dynamic inventory comes in two forms: inventory scripts and inventory plugins. The older approach uses executable scripts (usually Python) that output JSON in a specific format when called with --list or --host arguments. The newer and recommended approach uses inventory plugins, which are configured through YAML files. For AWS, you create a file like aws_ec2.yml with the plugin name, AWS region filters, and grouping rules. For Azure, you use the azure_rm plugin with subscription and resource group filters. Plugins are more powerful because they support composable host variables, tag-based grouping, and complex filtering without writing custom code.
Under the hood, when Ansible encounters a dynamic inventory source, it runs the plugin or script before any play starts. The plugin authenticates with the cloud API using credentials from environment variables (like AWS_ACCESS_KEY_ID or AZURE_SUBSCRIPTION_ID), credential files, or IAM instance profiles. It then makes API calls to list instances and their metadata -- instance IDs, private and public IPs, tags, regions, availability zones, and instance types. The plugin converts this metadata into Ansible inventory format: hosts get variables like ansible_host set to their IP, and groups are built automatically based on tags, regions, or any other attribute. For example, all EC2 instances tagged with Environment=production and Role=webserver automatically show up in groups named tag_Environment_production and tag_Role_webserver.
In production, dynamic inventory is essential for cloud-native setups where instances come and go constantly. Auto-scaling groups spin up and tear down servers, and Kubernetes nodes appear and disappear. Teams typically use tag-based grouping conventions -- for example, tagging every instance with Environment, Service, and Team tags, then building Ansible groups from those tags. The plugin config often includes filters to skip terminated instances, limit results to specific VPCs, and build custom host variables from instance metadata. You can also combine multiple inventory sources by pointing Ansible at a directory containing both static files and dynamic plugin configs.
A big gotcha is API rate limiting. If you have thousands of instances and run Ansible frequently, you can hit AWS throttling limits, causing inventory generation to fail. Use the cache plugin (cache: true with cache_plugin: jsonfile) to store inventory results locally and avoid repeated API calls. Another pitfall is credential management -- hardcoding AWS keys in inventory files is a security risk; always use IAM roles, environment variables, or HashiCorp Vault. Also watch for timing issues: if an auto-scaling event adds a new instance between inventory generation and task execution, that new host will not be targeted until the next Ansible run.
Code Example
# aws_ec2.yml - Dynamic inventory plugin configuration for AWS
plugin: amazon.aws.aws_ec2 # Use the AWS EC2 inventory plugin
regions: # List of AWS regions to query
- us-east-1 # Primary production region
- us-west-2 # DR and secondary region
filters: # EC2 API filters to narrow results
instance-state-name: running # Only include running instances
"tag:ManagedBy": ansible # Only instances tagged for Ansible
keyed_groups: # Create groups from instance attributes
- key: tags.Environment # Group by Environment tag value
prefix: env # Results in env_production, env_staging
separator: "_" # Separator between prefix and value
- key: tags.Service # Group by Service tag value
prefix: svc # Results in svc_payments_api, etc.
- key: placement.availability_zone # Group by AZ for zone-aware deploys
prefix: az # Results in az_us_east_1a, etc.
compose: # Build host variables from metadata
ansible_host: private_ip_address # Use private IP for SSH connection
instance_type: instance_type # Expose instance type as variable
ami_id: image_id # Expose AMI ID as variable
hostnames: # How to name hosts in inventory
- tag:Name # Prefer the Name tag
- private-ip-address # Fallback to private IP
cache: true # Enable inventory caching
cache_plugin: jsonfile # Store cache as JSON files
cache_timeout: 300 # Cache expires after 5 minutes
cache_connection: /tmp/ansible_inventory_cache # Cache file directory
---
# deploy-to-dynamic-hosts.yml - Playbook using dynamic inventory
- name: Deploy payments-api to dynamically discovered hosts # Production deployment
hosts: svc_payments_api:&env_production # Intersection of two dynamic groups
become: true # Escalate to root
serial: "25%" # Rolling deploy 25% at a time
tasks:
- name: Show discovered host info # Debug task to verify discovery
debug: # Print variable values
msg: "Deploying to {{ inventory_hostname }} ({{ ansible_host }}, {{ instance_type }})" # Show host details
- name: Pull latest application image # Deploy using container image
docker_image: # Use docker_image module
name: 123456789.dkr.ecr.us-east-1.amazonaws.com/payments-api # ECR image URI
tag: "v2.4.1" # Specific version tag
source: pull # Pull from remote registry
# Verify dynamic inventory: ansible-inventory -i aws_ec2.yml --graph # Visualize groupsInterview Tip
A junior engineer typically mentions dynamic inventory but cannot explain how plugins differ from scripts or how tag-based grouping actually works. In your interview, explain that inventory plugins (configured via YAML) are the modern approach that replaced legacy Python scripts. Walk through how the plugin authenticates with the cloud API, discovers instances, and creates groups from tags and metadata using keyed_groups. Mention the compose directive for building host variables like ansible_host from instance attributes. Talk about caching to avoid API rate limits, and explain the group intersection pattern (group1:&group2) for targeting hosts that belong to multiple dynamic groups at the same time. This level of detail shows you have hands-on experience with cloud-scale Ansible deployments.
◈ Architecture Diagram
┌─────────────────────┐
│ Ansible Control │
│ Node │
│ │
│ ┌─────────────────┐ │
│ │ aws_ec2.yml │ │
│ │ (plugin config)│ │
│ └────────┬────────┘ │
└──────────┼──────────┘
│ API Call
↓
┌─────────────────────┐
│ AWS EC2 API │
│ (us-east-1) │
└──────────┬──────────┘
│ Returns instances
↓
┌─────────────────────────────────┐
│ Dynamic Groups Generated │
│ │
│ ┌────────────────────┐ │
│ │ env_production │ │
│ │ ├─ payments-api-01│ │
│ │ ├─ payments-api-02│ │
│ │ └─ auth-svc-01 │ │
│ └────────────────────┘ │
│ ┌────────────────────┐ │
│ │ svc_payments_api │ │
│ │ ├─ payments-api-01│ │
│ │ └─ payments-api-02│ │
│ └────────────────────┘ │
└─────────────────────────────────┘💬 Comments
Quick Answer
Ansible playbooks are tested in layers: ansible-lint catches style and syntax issues without running anything, Molecule tests roles against real or containerized instances, and --check/--diff mode previews changes on live infrastructure.
Detailed Answer
Think of testing Ansible playbooks like quality control in a car factory. Ansible-lint is the blueprint reviewer who catches design errors before a single part is built -- wrong bolt sizes, missing safety features, non-standard parts. Molecule is the test track where a fully assembled car is driven through real-world conditions -- acceleration, braking, cornering -- to make sure everything works together. And --check mode is the final inspection where a trained eye looks at the production car and notes what would need fixing, without actually turning any wrenches.
Ansible-lint is a static analysis tool that scans playbooks, roles, and task files for best-practice violations, outdated syntax, and common mistakes without running anything. It flags issues like using the command module when a purpose-built module exists, missing name attributes on tasks, using bare variables in conditions, bad YAML formatting, and risky file permissions. It is usually the first check in a CI pipeline -- fast, needs no infrastructure, and catches the most obvious problems. You can customize its rules through a .ansible-lint config file and even write custom rules for your organization's standards.
Molecule is the full integration testing framework for Ansible roles. It creates temporary test instances (using Docker, Vagrant, Podman, or cloud providers), runs your role against those instances, checks the results, and then tears everything down. The typical Molecule lifecycle is: create (spin up test instances), converge (apply the role), idempotence (run the role again to verify nothing changes), verify (run assertions to check the result), and destroy (clean up). Each phase can be customized in the molecule.yml config. Molecule scenarios let you test the same role under different conditions -- different operating systems, different variable combinations, or different dependency setups.
In production CI/CD pipelines, teams run these tools in layers. The first stage runs ansible-lint and yamllint for fast feedback -- these finish in seconds and catch syntax errors and style issues. The second stage runs Molecule tests against Docker containers for each role individually -- these take minutes and verify the role actually works. The third stage runs playbooks against staging environments in --check --diff mode to preview what changes would hit real infrastructure. Some teams add a fourth stage using --syntax-check as a pre-commit hook for instant developer feedback. The whole pipeline lives in version control and runs on every pull request.
A common mistake is assuming Molecule Docker tests perfectly match production behavior. Docker containers do not run systemd by default, so service-related tasks may behave differently than on real VMs. You can work around this with special systemd-enabled container images, but it adds complexity. Another pitfall is leaning too hard on ansible-lint while skipping integration tests -- lint catches style issues but cannot verify that your template generates valid nginx configuration or that your database migration actually runs. Also, watch out for flaky Molecule tests caused by network issues during package installation in containers; use local package caches or mock repositories for reliable CI runs.
Code Example
# .ansible-lint - Linter configuration for the project
skip_list: # Rules to skip globally
- yaml[line-length] # Allow long lines in templates
- name[casing] # Allow flexible task naming
warn_list: # Rules that warn but do not fail
- experimental # Treat experimental rules as warnings
exclude_paths: # Paths to exclude from linting
- .cache/ # Ignore cache directory
- .github/ # Ignore CI config files
---
# molecule/default/molecule.yml - Molecule test configuration
dependency: # Dependency resolution settings
name: galaxy # Use Ansible Galaxy for dependencies
driver: # Test instance driver settings
name: docker # Use Docker for test instances
platforms: # Define test platforms
- name: payments-api-ubuntu # Test instance name
image: geerlingguy/docker-ubuntu2204-ansible # Systemd-enabled Ubuntu image
pre_build_image: true # Use image as-is (no build step)
privileged: true # Required for systemd in Docker
command: /lib/systemd/systemd # Start systemd as PID 1
volumes: # Mount cgroup for systemd
- /sys/fs/cgroup:/sys/fs/cgroup:rw # Read-write cgroup mount
- name: payments-api-rocky # Second platform for compatibility
image: geerlingguy/docker-rockylinux9-ansible # Rocky Linux 9 with Ansible support
pre_build_image: true # Use pre-built image
privileged: true # Required for systemd
command: /lib/systemd/systemd # Start systemd as PID 1
provisioner: # Provisioner configuration
name: ansible # Use Ansible as the provisioner
config_options: # Ansible config overrides
defaults: # [defaults] section options
callbacks_enabled: profile_tasks # Show task timing information
verifier: # Verification framework settings
name: ansible # Use Ansible for verification
---
# molecule/default/converge.yml - Playbook that Molecule runs
- name: Converge payments-api role # Molecule convergence playbook
hosts: all # Target all test instances
become: true # Escalate to root
roles: # Apply the role under test
- role: payments-api # The role being tested
payments_api_version: "2.4.1" # Test with a specific version
payments_api_port: 8080 # Test with custom port
---
# molecule/default/verify.yml - Verification playbook
- name: Verify payments-api deployment # Post-converge verification
hosts: all # Verify on all test instances
become: true # Need root to check services
tasks:
- name: Check payments-api service is running # Verify service state
systemd: # Query systemd service
name: payments-api # Target service name
register: svc_status # Capture service status
- name: Assert service is active # Fail if service is not running
assert: # Use assert module
that: # List of conditions to check
- svc_status.status.ActiveState == 'active' # Service must be active
fail_msg: "payments-api service is not running" # Error message on failure
- name: Verify config file was deployed # Check config file exists
stat: # Use stat module to check file
path: /etc/payments-api/config.yml # Path to config file
register: config_file # Capture file stat info
- name: Assert config file has correct perms # Validate file permissions
assert: # Use assert module
that: # Conditions to verify
- config_file.stat.exists # File must exist
- config_file.stat.mode == '0640' # Permissions must be 0640
fail_msg: "Config file missing or wrong permissions" # Error on failureInterview Tip
A junior engineer typically says 'we run the playbook and see if it works' as their testing approach, with no structured framework in mind. In your interview, describe the layered testing pyramid: ansible-lint for fast static analysis (seconds), Molecule for integration testing against throwaway instances (minutes), and --check --diff for production dry runs. Walk through Molecule's lifecycle phases -- create, converge, idempotence, verify, destroy -- and why the idempotence check matters. Mention that Docker is the most common Molecule driver but has limitations with systemd, and that cloud drivers give more realistic testing. Explain how this testing pipeline plugs into CI/CD -- lint on pre-commit, Molecule on pull request, dry-run before production deployment. This shows a mature approach to infrastructure-as-code quality.
◈ Architecture Diagram
┌─────────────────────────────────────┐
│ Ansible Testing Pyramid │
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Layer 1: Static Analysis (Fast) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ ansible-lint │ │ yamllint │ │
│ │ (best prax) │ │ (syntax) │ │
│ └──────────────┘ └──────────────┘ │
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Layer 2: Molecule (Integration) │
│ ┌────────┐ ┌──────────┐ ┌───────┐ │
│ │ Create │→│ Converge │→│Verify │ │
│ │(Docker)│ │(Run role)│ │(Assert│ │
│ └────────┘ └──────────┘ │ tests)│ │
│ └───────┘ │
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Layer 3: Dry Run (Production) │
│ ┌───────────────────────────────┐ │
│ │ ansible-playbook --check │ │
│ │ --diff site.yml │ │
│ │ (preview changes on staging) │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘💬 Comments
Quick Answer
Collections bundle modules, plugins, roles, and playbooks under a namespace.collection format. They replace the old flat Galaxy role model with versioned, namespaced packages that declare dependencies in galaxy.yml and are installed via ansible-galaxy collection install.
Detailed Answer
Think of a software package manager like npm or pip. Before collections, Ansible modules were either bundled into one massive ansible package or shared as standalone roles on Galaxy with no namespace protection -- two roles could define the same module name and clash. Collections are like scoped packages: community.aws.ec2_instance and amazon.aws.ec2_instance are unambiguous, versioned, and installed separately. This brings the same dependency management discipline that developers expect from application package managers.
Ansible Collections use the namespace.collection_name format, where the namespace is usually an organization (community, amazon, cisco) and the collection name groups related content. A collection can contain plugins (modules, filters, lookups, connection plugins), roles, playbooks, and documentation in a standard folder structure. The galaxy.yml file at the collection root defines metadata including version, dependencies on other collections with version ranges, and supported Ansible versions. Collections are packaged as .tar.gz archives and installed into COLLECTIONS_PATH, which defaults to ~/.ansible/collections.
Under the hood, when a playbook references a module like community.aws.s3_bucket, Ansible's plugin loader searches the collections path for the community/aws collection, then looks for the s3_bucket module inside its plugins/modules directory. These fully qualified collection names (FQCNs) remove all ambiguity -- the old s3_bucket shorthand could match multiple collections, but community.aws.s3_bucket is exact. The ansible-galaxy collection install command reads requirements.yml, resolves the dependency tree across collections, downloads compatible versions, and installs them. Version constraints use semantic versioning ranges like >=2.0.0,<3.0.0.
At production scale, collection management becomes a supply chain concern. Organizations should keep a requirements.yml with pinned collection versions, use a private Automation Hub (from Red Hat's Ansible Automation Platform) or Artifactory as a gateway to control which collection versions enter production, and run collection installs in CI rather than relying on developer machines. When multiple collections declare overlapping dependencies with incompatible version ranges, ansible-galaxy will fail to resolve them -- architects need to audit transitive dependencies and sometimes pin intermediate collections by hand.
The sneaky gotcha is the migration from the monolithic ansible package (which included hundreds of modules) to ansible-core plus individual collections. When Ansible 2.10 split the package, many modules moved from built-in to collections like community.general, community.aws, or ansible.posix. Playbooks that used short module names without FQCNs may silently pick up the wrong module or break after upgrading to ansible-core 2.16+ because the module is no longer included. Architects should enforce FQCN usage in all playbooks and roles through ansible-lint rules and treat collection version updates with the same care as application dependency updates.
Code Example
# requirements.yml — pinned collection versions for production
collections:
- name: amazon.aws # AWS modules for EC2, S3, IAM, and other services
version: ">=7.0.0,<8.0.0" # Pin to major version 7 for API stability
- name: community.aws # Community-maintained AWS modules
version: ">=8.0.0,<9.0.0" # Pin to major version 8
- name: ansible.posix # POSIX modules like authorized_key and sysctl
version: ">=1.5.0" # Minimum version for acl module fixes
- name: company.platform # Internal collection from private Automation Hub
version: "2.3.1" # Exact pin for internal collection stability
source: https://automationhub.company.com/api/galaxy/content/published/ # Private Hub URL
# Install all collections from requirements.yml into a project-local path
ansible-galaxy collection install \
-r requirements.yml \
-p ./collections \
--force # Reinstall even if versions already present
# ansible.cfg — configure collection paths and Galaxy server
[defaults]
collections_path = ./collections:~/.ansible/collections # Project-local path takes precedence
[galaxy]
server_list = automation_hub, galaxy # Check private hub first then public Galaxy
[galaxy_server.automation_hub]
url = https://automationhub.company.com/api/galaxy/content/published/ # Private Automation Hub
auth_url = https://sso.company.com/auth/realms/ansible/token # SSO token endpoint
token = {{ lookup('env', 'AUTOMATION_HUB_TOKEN') }} # Token from CI environment variable
[galaxy_server.galaxy]
url = https://galaxy.ansible.com/ # Public Ansible Galaxy as fallback
# Playbook using fully qualified collection names (FQCNs)
- name: Provision payments API infrastructure # Playbook description
hosts: payments_servers # Target host group from inventory
collections: # Shorthand declaration for this play
- amazon.aws # Allows using ec2_instance instead of amazon.aws.ec2_instance
tasks:
- name: Create payments API security group # Task description
amazon.aws.ec2_security_group: # FQCN for the security group module
name: payments-api-sg # Security group name in AWS
description: Payments API inbound rules # Human-readable description
region: us-east-1 # AWS region for the resource
rules:
- proto: tcp # Allow TCP traffic
from_port: 8080 # Application port
to_port: 8080 # Same port for single-port rule
cidr_ip: 10.0.0.0/16 # VPC CIDR range for internal access
register: sg_result # Store the result for later referenceInterview Tip
A junior engineer typically says collections are groups of Ansible modules, but at a senior or architect level, the interviewer wants to hear about supply chain management and namespace handling. Explain the namespace.collection format and why FQCNs prevent ambiguity, how galaxy.yml declares dependencies with semver constraints, why a private Automation Hub acts as a gateway for enterprise governance, and what broke when the monolithic ansible package split into ansible-core plus separate collections. A strong answer also covers transitive dependency conflicts, the difference between installing from requirements.yml versus ad-hoc installs, and using ansible-lint to enforce FQCN usage across your codebase.
◈ Architecture Diagram
┌──────────────────────────┐
│ requirements.yml │
│ ┌─────────┐ ┌──────────┐ │
│ │amazon │ │community │ │
│ │.aws │ │.aws │ │
│ │>=7,<8 │ │>=8,<9 │ │
│ └────┬────┘ └────┬─────┘ │
└──────┼───────────┼───────┘
↓ ↓
┌──────────────────────────┐
│ ansible-galaxy install │
│ resolve → download │
└────────────┬─────────────┘
↓
┌──────────────────────────┐
│ ./collections/ │
│ ansible_collections/ │
│ amazon/aws/ │
│ community/aws/ │
└──────────────────────────┘💬 Comments
Quick Answer
These tools work together to make playbooks safe to run repeatedly. changed_when overrides default change detection, creates/removes skip tasks when files already exist, check mode simulates runs without touching anything, and diff mode shows exactly what would change.
Detailed Answer
Think of a thermostat. Setting it to 72 degrees is idempotent -- no matter how many times you press the button, the system targets 72 and only acts if the current temperature is different. An Ansible playbook should work the same way: running it ten times should produce the same result as running it once, with no unnecessary changes on repeat runs. The tools Ansible gives you for this are like the thermostat's sensor that checks the room temperature before turning on the heater.
Most built-in Ansible modules are naturally idempotent. The copy module checks file checksums before copying, the user module checks user attributes before making changes, and the yum module checks installed package versions before installing. But the command and shell modules always report 'changed' because Ansible has no way to know whether a shell command actually modified anything. This is where changed_when, creates, and removes come in. changed_when takes a Jinja2 expression that decides whether the task really changed something, based on command output. creates tells Ansible to skip the task if a certain file already exists. removes tells Ansible to skip the task if a certain file is missing.
Under the hood, Ansible evaluates idempotency at the task level. Each module receives the current state of the target, compares it to the desired state, and reports ok (no change needed), changed (something was modified), or failed. Check mode (--check) tells modules to do the comparison but skip the actual change, reporting what would happen. Diff mode (--diff) goes further by showing the actual differences, like a unified diff for file content. Together, check mode and diff mode let you preview a playbook run against production without making any changes -- which is essential for change management workflows that need pre-approval.
At production scale, idempotency is not just nice to have -- it is a reliability requirement. Configuration management runs should be scheduled (through AWX or cron) to continuously enforce the desired state. If a playbook is not idempotent, every scheduled run creates false 'changed' notifications. This leads to alert fatigue, triggers unnecessary handler restarts, and makes it impossible to tell real drift from playbook noise. Teams should track changed task counts across runs -- a truly idempotent playbook running against an already-configured system should report zero changes.
The sneaky gotcha is that some modules are only idempotent under specific conditions. The lineinfile module with regexp matching can be idempotent, but if the regex matches multiple lines or the line content includes values that change between runs (like timestamps), it breaks idempotency. The template module is idempotent for file content but always reports changed if file permissions differ from expected -- even when the content is identical. Architects should run playbooks twice in CI and add assertion tasks that verify the second run reports zero changes, catching idempotency problems before they reach production.
Code Example
# Idempotent database initialization — only runs if the schema file does not exist
- name: Initialize payments database schema # Task description
ansible.builtin.command: # Command module requires explicit idempotency guards
cmd: psql -h payments-db.internal -U admin -d payments -f /opt/payments/schema.sql
creates: /opt/payments/.schema_initialized # Skip if this file exists
register: schema_result # Capture output for changed_when evaluation
changed_when: "'CREATE TABLE' in schema_result.stdout" # Only report changed if tables were created
notify: restart payments api # Trigger handler only on actual schema change
# Idempotent application deployment with version check
- name: Deploy payments API binary # Downloads only if version differs
ansible.builtin.get_url: # Module checks existing file checksum before downloading
url: https://artifacts.company.com/payments-api/2.9.0/payments-api
dest: /opt/payments/payments-api # Target path on the server
checksum: sha256:a3f8e2d1b4c5... # Skips download if checksum matches
mode: '0755' # Executable permissions
owner: payments # Application user
group: payments # Application group
# Using changed_when with shell to make a non-idempotent command idempotent
- name: Add payments user to docker group # Only changes if user not already in group
ansible.builtin.shell: |
if id -nG payments | grep -qw docker; then
echo "ALREADY_MEMBER"
else
usermod -aG docker payments
echo "ADDED"
fi
register: group_result # Capture script output
changed_when: "'ADDED' in group_result.stdout" # Report changed only when modification happened
# Run the playbook in check mode with diff to preview changes
ansible-playbook site.yml \
--check \
--diff \
--limit payments_servers \
-i inventory/production.yml # Preview what would change without making modifications
# CI pipeline idempotency test — second run must report zero changes
ansible-playbook site.yml -i inventory/staging.yml # First run applies configuration
ansible-playbook site.yml -i inventory/staging.yml 2>&1 | tee /tmp/second_run.log # Second run
grep -c 'changed=' /tmp/second_run.log | grep 'changed=0' # Assert zero changes on second runInterview Tip
A junior engineer typically says idempotency means running something twice gives the same result, but at a senior or architect level, the interviewer wants to hear about practical enforcement patterns. Explain how changed_when overrides false change signals from command/shell modules, why creates/removes act as file-based guards, how check mode and diff mode let you safely preview changes for change management approval, and why CI pipelines should run playbooks twice to catch idempotency violations. A solid answer also covers the downstream effects of non-idempotent playbooks -- false handler triggers, alert fatigue from scheduled runs, and the inability to spot real configuration drift when every run reports changes.
◈ Architecture Diagram
┌──────────┐
│ Task Run │
└────┬─────┘
↓
┌──────────┐
│Check │
│current │
│state │
└────┬─────┘
↓
┌─────┬────┐
│Match│Diff│
│ │ │
│ ok │chgd│
└──┬──┴──┬─┘
↓ ↓
┌────┐┌──────┐
│Skip││Apply │
│ ││+notify│
└────┘└──────┘💬 Comments
Quick Answer
AWX provides a web-based control plane with job templates that pair playbooks with inventories and credentials, workflows that chain templates with conditional logic, role-based access control through organizations and teams, encrypted credential storage, and dynamic inventory sources that sync from cloud providers before each run.
Detailed Answer
Think of an airline operations center. Individual pilots fly planes (job templates run playbooks), but the operations center coordinates which planes fly which routes, handles weather diversions (workflow conditionals), decides which pilots can fly which aircraft (RBAC), stores security codes in a vault (credential management), and tracks real-time fleet positions (dynamic inventory). AWX is that operations center for Ansible automation at enterprise scale.
AWX organizes automation into a hierarchy. Organizations are the top-level boundary for multi-tenancy. Teams within organizations group users with shared permissions. Projects sync playbook source code from Git repositories. Inventories define target hosts either statically or dynamically from cloud providers, ServiceNow CMDBs, or custom scripts. Credentials store sensitive data like SSH keys, cloud API tokens, and vault passwords using Fernet encryption at rest, with support for external credential stores like HashiCorp Vault, CyberArk, or AWS Secrets Manager. Job templates tie a project, inventory, credential, and playbook together into a reusable, parameterized unit of work.
Under the hood, when a job template is launched, AWX creates a job instance, grabs an execution environment (a container image with ansible-core, collections, and Python dependencies), syncs the project from Git if needed, resolves inventory (running dynamic inventory syncs), injects credentials as environment variables or temporary files, and runs the playbook. The job runs inside a container managed by the AWX task dispatcher, which handles concurrency limits, instance group routing (to send jobs to specific execution nodes), and capacity-based scheduling. Workflows chain multiple job templates into directed acyclic graphs (think flowcharts) with success, failure, and always paths, enabling patterns like provision-then-configure-then-test with automatic rollback on failure.
At production scale, AWX becomes the audit and governance layer for all Ansible automation. Every job execution is logged with full output, credential usage, inventory targets, and who or what triggered it. RBAC is fine-grained: a team can be given Execute permission on a job template without seeing its credentials, Read permission on an inventory without being able to change it, or Admin on one project without access to others. This separation is critical for compliance -- security teams manage credentials, platform teams manage inventories, and application teams run job templates within their allowed scope.
The non-obvious gotcha is execution environment management. AWX 21+ and Ansible Automation Platform 2.x replaced the old Python virtualenv model with container-based execution environments. Every job runs inside a container image that must have the right ansible-core version, Python libraries, and collections. If a team updates a collection in their requirements.yml but the execution environment image is not rebuilt, jobs will silently use the old collection version despite the requirements change. Architects need CI pipelines that rebuild execution environment images when collection dependencies change and version-tag these images so job templates point to specific, tested versions.
Code Example
# Create a project from a Git repository using the AWX CLI
awx projects create \
--name "payments-automation" \
--organization "Platform Engineering" \
--scm-type git \
--scm-url https://github.com/company/payments-ansible.git \
--scm-branch main \
--scm-update-on-launch true # Sync from Git before every job run
# Create a dynamic inventory source from AWS EC2
awx inventory_sources create \
--name "aws-us-east-payments" \
--inventory "Payments Production" \
--source ec2 \
--credential "AWS Production ReadOnly" \
--source-vars '{"regions": ["us-east-1"], "filters": {"tag:Service": ["payments-api"]}}' \
--update-on-launch true # Refresh inventory from AWS before each job
# Create a job template combining project, inventory, and credential
awx job_templates create \
--name "Deploy Payments API" \
--project "payments-automation" \
--playbook "deploy.yml" \
--inventory "Payments Production" \
--credential "Payments SSH Key" \
--execution-environment "platform-ee:2.4.0" \
--job-type run \
--ask-variables-on-launch true # Allow runtime variable overrides
# Create a workflow that chains deploy, test, and rollback templates
awx workflow_job_templates create \
--name "Payments Release Pipeline" \
--organization "Platform Engineering"
# Add workflow nodes with conditional paths
awx workflow_job_template_nodes create \
--workflow-job-template "Payments Release Pipeline" \
--unified-job-template "Deploy Payments API" # First node: deploy
awx workflow_job_template_nodes create \
--workflow-job-template "Payments Release Pipeline" \
--unified-job-template "Smoke Test Payments" # Second node: test on success
awx workflow_job_template_nodes create \
--workflow-job-template "Payments Release Pipeline" \
--unified-job-template "Rollback Payments API" # Third node: rollback on failure
# Grant a team execute permission on the job template
awx roles grant \
--type job_template \
--resource "Deploy Payments API" \
--team "Payments Developers" \
--role execute # Team can run but not edit or view credentialsInterview Tip
A junior engineer typically says AWX is a web UI for Ansible, but at a senior or architect level, the interviewer wants to hear about enterprise governance architecture. Explain how organizations, teams, and RBAC separate credential management from playbook execution, how workflows create flowcharts with conditional paths for deployment pipelines, how dynamic inventory sources keep target lists fresh, and why execution environments replaced virtualenvs. A strong answer also covers audit logging for compliance, instance group routing for multi-region execution, credential plugins for connecting to external vaults, and the CI pipeline needed to rebuild execution environment images when collection dependencies change.
◈ Architecture Diagram
┌─────────────────────────────┐ │ AWX / AAP │ │ ┌───────┐ ┌───────────────┐ │ │ │Project│→│Job Template │ │ │ │(Git) │ │play+inv+cred │ │ │ └───────┘ └──────┬────────┘ │ │ ┌───────┐ ↓ │ │ │Invent.│→┌─────────────┐ │ │ │(EC2) │ │ Workflow │ │ │ └───────┘ │deploy→test │ │ │ ┌───────┐ │ ✗→rollback │ │ │ │Creds │→└─────────────┘ │ │ │(vault)│ │ │ └───────┘ │ └─────────────────────────────┘
💬 Comments
Quick Answer
Strategy plugins control how tasks run across hosts -- linear waits for all hosts per task, free lets each host go at its own pace. Pipelining cuts SSH overhead by skipping file transfers, Mitogen keeps a persistent Python channel on each host, async tasks run long operations in the background, and forks set how many hosts run in parallel. Each has tradeoffs in debugging, compatibility, and resource usage.
Detailed Answer
Think of a restaurant kitchen filling a big catering order. The linear strategy is one dish at a time across all plates -- every plate gets soup before any plate gets the main course. The free strategy lets each station work independently -- one plate might be on dessert while another is still on appetizers. The host_pinned strategy assigns each plate to one cook who handles it start to finish. Ansible strategy plugins offer these same execution models, and the right choice depends on whether tasks have dependencies between hosts or can run on their own.
The default linear strategy runs each task across all hosts before moving to the next one. This keeps things in a predictable order -- useful when tasks depend on each other across hosts, like setting up a primary database before its replicas. The free strategy drops this synchronization, letting each host run through tasks as fast as it can without waiting for others. This dramatically speeds up environments where some hosts are fast and others are slow. The host_pinned strategy is similar to free but keeps each host assigned to the same worker process for the entire play, which helps with connection reuse.
Under the hood, the performance bottleneck for large inventories is SSH overhead. By default, Ansible copies a Python module to the remote host as a temporary file, runs it, grabs the output, and deletes the file -- for every single task. Pipelining (pipelining = True in ansible.cfg) skips the file transfer by sending the module code directly through SSH's standard input, which can cut task time by 30-50 percent on high-latency connections. Mitogen takes this further by setting up a persistent Python process on the remote host through an SSH tunnel, completely eliminating per-task module transfers and reusing the Python process across tasks. Mitogen can deliver 4-7x speedups for playbooks with many tasks.
At production scale, fork tuning is the broadest lever. The forks setting (default 5) controls how many hosts Ansible manages at the same time. For 500 servers, forks=50 means 50 hosts process in parallel per task round. Higher forks use more memory and file descriptors on the control node since each fork is a separate Python process. Async tasks (async and poll parameters) let individual tasks run in the background on the remote host while Ansible moves on to other work, which is essential for long-running jobs like package upgrades or database backups that would otherwise block the entire play. Combining the free strategy, pipelining, high forks, and async tasks can cut a 500-host playbook from 45 minutes to under 10.
The non-obvious gotcha is that performance optimizations can break debugging and compatibility. Pipelining requires the requiretty setting to be disabled in sudoers on target hosts. Mitogen has compatibility issues with some connection plugins, callback plugins, and newer Ansible versions -- it tends to lag behind ansible-core releases. The free strategy makes play output non-deterministic, which complicates log analysis and makes it harder to connect failures to specific task ordering. Async tasks need explicit status checking with async_status and can leave orphaned processes if the playbook is interrupted. Architects should benchmark optimizations in staging before production and keep a fallback configuration for troubleshooting where linear strategy and verbose output are needed.
Code Example
# ansible.cfg — performance tuning for large-scale playbook execution
[defaults]
forks = 50 # Run tasks on 50 hosts concurrently (up from default 5)
strategy = free # Let each host proceed independently without waiting
callback_whitelist = profile_tasks # Show per-task timing to identify bottlenecks
[ssh_connection]
pipelining = True # Pipe modules through SSH stdin instead of copying files
ssh_args = -o ControlMaster=auto -o ControlPersist=30m # Reuse SSH connections for 30 minutes
control_path_dir = /tmp/.ansible-ssh # Directory for SSH control sockets
# Playbook using async for long-running package upgrades
- name: Upgrade payments platform servers # Playbook description
hosts: payments_servers # Target all payments servers
strategy: free # Each host proceeds independently
tasks:
- name: Upgrade all system packages # Long-running apt upgrade
ansible.builtin.apt: # Package management module
upgrade: dist # Full distribution upgrade
update_cache: true # Refresh package index first
async: 600 # Allow up to 10 minutes for the upgrade to complete
poll: 0 # Do not wait — fire and forget, check later
register: upgrade_job # Store the async job ID
- name: Deploy payments API binary while packages upgrade # Run in parallel
ansible.builtin.copy: # Copy module is idempotent
src: payments-api-2.9.0 # Local binary to deploy
dest: /opt/payments/payments-api # Destination path on target
mode: '0755' # Executable permissions
owner: payments # Application user
- name: Wait for package upgrade to complete # Check async job status
ansible.builtin.async_status: # Polls the background job
jid: "{{ upgrade_job.ansible_job_id }}" # Job ID from the async task
register: upgrade_result # Capture the final result
until: upgrade_result.finished # Loop until the job completes
retries: 30 # Check up to 30 times
delay: 20 # Wait 20 seconds between checks
# Run the playbook with timing output to measure optimization impact
ansible-playbook site.yml \
-i inventory/production.yml \
--forks 50 \
-e 'strategy=free' 2>&1 | tee /tmp/run_timing.log
# Compare timing between linear and free strategies
ANSIBLE_STRATEGY=linear ansible-playbook site.yml -i inventory/staging.yml # Baseline
ANSIBLE_STRATEGY=free ansible-playbook site.yml -i inventory/staging.yml # OptimizedInterview Tip
A junior engineer typically says you can increase forks to speed up Ansible, but at a senior or architect level, the interviewer wants to hear about a layered performance optimization approach. Explain the difference between linear, free, and host_pinned strategies and when each makes sense, how pipelining eliminates SSH file transfer overhead, why Mitogen achieves 4-7x speedups through persistent Python channels, and how async tasks let long-running operations run in the background on each host. A strong answer also covers the tradeoffs -- pipelining requires sudoers configuration changes, Mitogen lags behind ansible-core releases, the free strategy makes logs harder to read, and high forks use proportional memory on the control node.
◈ Architecture Diagram
┌──────────────────────────────┐ │ Performance Layers │ ├──────────┬─────────┬─────────┤ │Strategy │SSH │Tasks │ │─ ─ ─ ─ ─│─ ─ ─ ─ ─│─ ─ ─ ─ │ │linear │pipeline │sync │ │free │mitogen │async │ │host_pin │ControlM │poll:0 │ ├──────────┴─────────┴─────────┤ │ forks = 50 │ │ (concurrent hosts) │ └──────────────────────────────┘
💬 Comments
Quick Answer
Reusable roles use meta/main.yml for dependencies, import_role for static inclusion at parse time, include_role for dynamic inclusion at runtime, Molecule for isolated testing, and a clear variable strategy using defaults and vars. Common anti-patterns include circular dependencies, overusing include_role with complex conditionals, and relying on global variables instead of explicit role parameters.
Detailed Answer
Think of building with LEGO sets. Each set (role) has its own instruction manual and parts list. Some sets need other sets as prerequisites -- you need the chassis before you can attach the engine (meta dependencies). You can either pre-assemble sub-modules before starting the main build (import_role, which is static) or build them on the fly as you reach each step (include_role, which is dynamic). Molecule is the quality inspection station where each set gets tested on its own before shipping. Variable precedence is like labeling each brick with where it came from so you know which one wins when two sets include the same part number.
In Ansible, role meta dependencies declared in meta/main.yml cause dependent roles to run automatically before the declaring role. If the payments-api role depends on the nginx role and the tls-certs role, Ansible runs both dependencies first. However, meta dependencies are deduplicated by default -- if two roles declare the same dependency, it runs only once unless you set allow_duplicates: true. This surprises architects when a shared role needs to run with different settings for different consumers.
The key design decision is import_role versus include_role. import_role is processed when Ansible first reads the playbook -- all tasks, handlers, and variables from the imported role are merged into the play before anything runs. This enables static analysis, --list-tasks, and tag filtering, but means conditional logic (when clauses on import_role) gets applied to every task inside the role rather than gating the whole role. include_role is evaluated during execution, meaning the role's tasks are loaded only when the include_role task actually runs. This supports looping over roles and true conditional gating but hides the role's contents from static analysis tools.
Molecule provides isolated role testing by creating temporary infrastructure (Docker containers, Vagrant VMs, or cloud instances), running the role against them, checking the results with verifiers (testinfra, ansible assertions, or goss), and tearing everything down. Production-grade roles include a molecule/ directory with test scenarios for each supported platform. CI pipelines should run molecule test on every role change before publishing to Galaxy or Automation Hub. For variables, Ansible has a 22-level priority hierarchy -- role defaults (lowest) are meant to be overridden by consumers, role vars are stronger than inventory variables, and extra vars (--extra-vars) override everything. Well-designed roles put all user-configurable values in defaults/main.yml and reserve vars/main.yml for internal constants.
The sneaky gotcha is variable scope leakage between roles. When a role sets a variable using set_fact, that variable sticks around in the host's scope for the rest of the play, potentially overriding a same-named variable in a later role. Architects should prefix all role variables with the role name (payments_api_port instead of port) and put configurable values in defaults/ rather than using set_fact. Another anti-pattern is deep meta dependency chains -- when role A depends on B, B depends on C, and C depends on D, debugging becomes nearly impossible and execution order surprises multiply. Flatten dependency trees by having playbooks explicitly order roles rather than relying on transitive meta dependencies.
Code Example
# roles/payments-api/meta/main.yml — role metadata and dependencies
galaxy_info: # Galaxy metadata for role publishing
role_name: payments_api # Role name on Galaxy
namespace: company_platform # Organization namespace
description: Deploy and configure the payments API service # Role description
min_ansible_version: "2.16" # Minimum supported ansible-core version
dependencies: # Roles that must run before this role
- role: company_platform.nginx # Web server configuration
vars:
nginx_vhost_name: payments-api # Pass parameters to the dependency
nginx_listen_port: 8080 # Override default port for this consumer
- role: company_platform.tls_certs # TLS certificate provisioning
vars:
tls_domain: payments.company.com # Domain for the certificate
# roles/payments-api/defaults/main.yml — user-configurable defaults (lowest precedence)
payments_api_version: "2.9.0" # Application version to deploy
payments_api_port: 8080 # Port the API listens on
payments_api_workers: 4 # Number of worker processes
payments_api_log_level: info # Logging verbosity
payments_api_heap_size: 512m # JVM heap size for the API process
# Playbook using import_role (static) and include_role (dynamic)
- name: Deploy payments platform # Play description
hosts: payments_servers # Target host group
tasks:
- name: Apply base configuration to all servers # Static inclusion
ansible.builtin.import_role: # Parsed at compile time
name: company_platform.base_config # Base OS hardening role
tags: [base] # Tags work because import is static
- name: Deploy region-specific service # Dynamic inclusion based on variable
ansible.builtin.include_role: # Evaluated at runtime
name: "company_platform.{{ regional_service }}" # Role name from variable
loop: "{{ services_for_region }}" # Loop over regional service list
loop_control:
loop_var: regional_service # Avoid variable name collision with inner role
when: regional_service is defined # Only include when the variable exists
# molecule/default/molecule.yml — Molecule test scenario for the payments-api role
dependency:
name: galaxy # Install role dependencies from Galaxy
options:
requirements-file: requirements.yml # Collection and role requirements
driver:
name: docker # Use Docker for test instances
platforms:
- name: payments-api-ubuntu # Test instance name
image: ubuntu:22.04 # Target OS image
pre_build_image: false # Build from Dockerfile if needed
provisioner:
name: ansible # Use Ansible to converge
playbooks:
converge: converge.yml # Playbook that applies the role
verify: verify.yml # Playbook that asserts expected state
verifier:
name: ansible # Use Ansible for verification assertions
# Run Molecule tests in CI before publishing the role
molecule test --scenario-name default # Create, converge, verify, destroyInterview Tip
A junior engineer typically says roles organize tasks into reusable units, but at a senior or architect level, the interviewer wants to hear about design patterns and anti-patterns that affect maintainability at scale. Explain the difference between import_role (static, tag-friendly, parsed when the playbook is loaded) and include_role (dynamic, loop-capable, evaluated during execution), why meta dependency deduplication causes surprises with parameterized dependencies, how Molecule provides isolated testing before Galaxy publishing, and why prefixing variable names with the role name prevents scope leakage. A strong answer also covers the anti-pattern of deep transitive meta dependency chains, the 22-level variable priority hierarchy, and why defaults/main.yml should hold all user-facing settings while vars/main.yml holds internal constants.
◈ Architecture Diagram
┌──────────────────────────────┐ │ Role Design │ │ ┌────────┐ ┌───────────────┐ │ │ │defaults│ │meta/main.yml │ │ │ │(user) │ │dependencies │ │ │ └────────┘ └───────┬───────┘ │ │ ┌────────┐ ↓ │ │ │tasks │ ┌───────────────┐ │ │ │main.yml│ │nginx role │ │ │ └────────┘ │tls_certs role │ │ │ ┌────────┐ └───────────────┘ │ │ │molecule│ │ │ │test │ │ │ └────────┘ │ └──────────────────────────────┘
💬 Comments
Quick Answer
Enable pipelining, increase forks, use async tasks, enable fact caching, use mitogen strategy plugin, and minimize SSH connections with ControlPersist.
Detailed Answer
Ansible's default behavior creates a new SSH connection per task per host. With 500 hosts and 50 tasks, that's 25,000 SSH connections — extremely slow.
1. Increase forks: Default is 5 (parallel hosts). Set forks = 50 in ansible.cfg for 500 hosts. 2. Enable pipelining: Reduces SSH operations per task from 5 to 2 by executing modules without transferring temporary files. Set pipelining = True. 3. SSH multiplexing: ControlPersist reuses SSH connections. Set ssh_args = -o ControlMaster=auto -o ControlPersist=60s. 4. Fact caching: Cache gather_facts results in Redis/JSON file to avoid re-gathering on every run. Facts rarely change between runs. 5. Async tasks: For long-running tasks (package installs), run async and poll later: async: 300, poll: 0. 6. Mitogen strategy: Third-party plugin that replaces Ansible's SSH+transfer approach with a persistent Python interpreter tunnel. 2-7x faster. 7. free strategy: Execute tasks independently per host instead of waiting for all hosts per task.
- gather_facts: false on playbooks that don't need facts - Use serial: 10 for rolling deployments instead of all-at-once - Avoid shell/command modules when a native module exists
Code Example
# ansible.cfg optimized for 500+ hosts
[defaults]
forks = 50
strategy = free
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 3600
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o PreferredAuthentications=publickey
# Async task example
- name: Update all packages (async)
yum:
name: '*'
state: latest
async: 600
poll: 0
register: yum_update
- name: Wait for updates to complete
async_status:
jid: "{{ yum_update.ansible_job_id }}"
register: job_result
until: job_result.finished
retries: 60
delay: 10
# Free strategy - each host proceeds independently
- hosts: all
strategy: free
tasks:
- name: Deploy application
copy:
src: app.tar.gz
dest: /opt/app/Interview Tip
Mention specific numbers: forks=50 for 500 hosts, ControlPersist=60s, pipelining=True. Quantify the improvement (pipelining alone is 2x faster). The Mitogen strategy plugin is a power-user answer.
💬 Comments
Quick Answer
Roles package tasks/handlers/templates/vars into reusable units. Galaxy is the public registry. Collections bundle roles+modules+plugins into distributable packages. Organize with a mono-repo of roles with molecule testing.
Detailed Answer
`` roles/nginx/ ├── defaults/main.yml # Default variables (lowest priority) ├── vars/main.yml # Role variables (high priority) ├── tasks/main.yml # Task list ├── handlers/main.yml # Handlers (notify triggers) ├── templates/ # Jinja2 templates ├── files/ # Static files ├── meta/main.yml # Dependencies, Galaxy metadata └── molecule/ # Test scenarios ``
Collections are the modern packaging format that bundles roles, modules, plugins, and documentation together. Namespaced: community.general, amazon.aws, kubernetes.core.
Galaxy: Public registry for sharing roles and collections. Private Galaxy alternatives: Ansible Automation Hub (Red Hat), Pulp, or simple Git repos.
- Mono-repo with all roles, playbooks, and inventories - requirements.yml pins external collection/role versions - Molecule tests for every role (Docker-based testing) - Group vars organized by environment (dev/staging/prod) - Dynamic inventory for cloud resources (AWS, GCP, Azure plugins) - Ansible Vault for encrypted secrets
Code Example
# requirements.yml - pin external dependencies
collections:
- name: amazon.aws
version: ">=6.0.0,<7.0.0"
- name: kubernetes.core
version: ">=3.0.0"
roles:
- name: geerlingguy.docker
version: 6.1.0
# Install dependencies
ansible-galaxy install -r requirements.yml
# Molecule test for nginx role
# molecule/default/molecule.yml
driver:
name: docker
platforms:
- name: ubuntu
image: ubuntu:22.04
pre_build_image: true
verifier:
name: ansible
# molecule/default/converge.yml
- hosts: all
roles:
- role: nginx
nginx_worker_processes: auto
# Run tests
cd roles/nginx && molecule test
# Playbook using roles
- hosts: webservers
roles:
- role: common
- role: nginx
vars:
nginx_vhosts:
- server_name: api.example.com
proxy_pass: http://localhost:8080Interview Tip
Mention molecule testing — it's the standard for role testing and shows you write testable infrastructure code. The Collections architecture is the modern approach; roles alone are legacy.
💬 Comments
Quick Answer
Terraform: declarative, state-managed, best for provisioning cloud infrastructure. Ansible: procedural, agentless, best for configuration management and application deployment. Together: Terraform provisions, Ansible configures.
Detailed Answer
- Declarative: describe desired state, Terraform figures out how to get there - State file tracks what exists — knows what to create, update, or destroy - Excellent for cloud resource provisioning (VPCs, instances, databases) - Plan/apply workflow with drift detection - Provider ecosystem for every cloud and SaaS service
- Procedural: step-by-step instructions executed in order - Agentless: uses SSH, no software to install on targets - Excellent for configuration management (packages, files, services) - Application deployment and orchestration - Ad-hoc commands for quick tasks - Jinja2 templating for dynamic configs
- Managing cloud infrastructure lifecycle (no state tracking) - Ensuring resources are destroyed when removed from code
- Installing packages, configuring services, managing files on servers - Running deployment scripts, health checks, rolling restarts
1. Terraform provisions: VPC, EC2 instances, RDS, load balancers 2. Terraform outputs instance IPs to a dynamic inventory file 3. Ansible configures: installs packages, deploys application, starts services 4. CI/CD: terraform apply then ansible-playbook deploy.yml
Code Example
# Terraform provisions infrastructure
resource "aws_instance" "web" {
count = 3
ami = "ami-0abcdef1234567890"
instance_type = "t3.medium"
tags = { Name = "web-${count.index}" }
}
# Output IPs for Ansible
output "web_ips" {
value = aws_instance.web[*].private_ip
}
# Generate Ansible inventory from Terraform
resource "local_file" "ansible_inventory" {
content = templatefile("inventory.tpl", {
web_servers = aws_instance.web[*].private_ip
})
filename = "../ansible/inventory/hosts"
}
# Ansible configures the servers
# deploy.yml
- hosts: webservers
become: true
roles:
- common
- nginx
- app_deploy
# CI/CD pipeline
# 1. terraform init && terraform apply -auto-approve
# 2. ansible-playbook -i inventory/hosts deploy.ymlInterview Tip
Show you understand the fundamental difference: Terraform is declarative with state, Ansible is procedural without state. The combined pattern (Terraform provisions, Ansible configures) is the pragmatic answer.
💬 Comments
Quick Answer
Configuration management is the practice of defining a server's desired state — packages, files, services, users — as code, so that state can be applied consistently and repeatedly instead of configured by hand. Ansible implements this over plain SSH with no agent installed on target machines, pushing changes out on demand, whereas Chef and Puppet run a persistent agent on every managed node that periodically pulls its configuration from a central server.
Detailed Answer
Think of configuration management like a restaurant chain's standard recipe book: instead of every location's chef improvising a burger from memory (and getting slightly different results in every city), every kitchen follows the exact same written recipe, so the burger is identical whether you're in Chicago or Miami, and updating the recipe once updates it everywhere. Before configuration management, ops teams configured servers by hand or with ad hoc shell scripts, so no two 'identical' servers were ever actually identical — a problem known as configuration drift.
Ansible was designed specifically to avoid requiring any software installed on the machines it manages, using SSH (which nearly every Linux server already has running) as its only transport. This was a deliberate reaction to Chef and Puppet's model, which requires installing and maintaining an agent daemon on every single managed server, plus a central server (a Chef server or Puppet master) that agents check in with on a schedule, typically every 30 minutes. Ansible's agentless design trades that recurring pull cycle for an explicit push: you run a playbook when you want changes applied, and nothing happens on the target machines outside of that run.
Mechanically, when you run an Ansible playbook, the control machine connects to each target over SSH, copies over small Python modules, executes them, and removes them — leaving no persistent process running afterward. Each task in a playbook describes desired state (e.g., 'nginx should be installed and running') rather than imperative steps, and Ansible's modules are idempotent: running the same playbook twice produces the same end state without re-executing changes unnecessarily, checking first whether the target already matches the desired state.
In production, this push-based model means configuration changes happen exactly when you trigger them — during a deploy, in a CI/CD pipeline, or via a scheduled cron-triggered playbook run — rather than silently drifting into place over the next 30-minute agent pull window like Chef or Puppet. This makes Ansible easier to reason about for deploy-time configuration changes, but it also means nothing enforces state between runs; if someone manually edits a config file on a server, Ansible won't notice or correct it until the next explicit run, whereas an agent-based tool's continuous pull cycle would self-heal that drift within its next check-in.
The gotcha: Ansible's lack of a persistent agent is usually pitched as pure simplicity, but it means Ansible provides no built-in continuous drift correction — teams that need servers to self-heal from manual changes between runs either need to schedule playbook runs frequently via cron/CI, or accept that Ansible is better suited to deploy-time and provisioning-time configuration than to continuous enforcement the way Puppet's agent model is.
Code Example
# site.yml — Ansible playbook to configure payments-api hosts
---
- hosts: payments_api_servers # target group from inventory, no agent needed
become: true # run tasks with sudo
tasks:
- name: Ensure nginx is installed
apt:
name: nginx
state: present # idempotent — skips if already installed
- name: Deploy app config from template
template:
src: templates/payments-api.conf.j2
dest: /etc/nginx/sites-available/payments-api.conf
notify: reload nginx # only reload if this file actually changed
- name: Ensure nginx is running
service:
name: nginx
state: started
enabled: true
handlers:
- name: reload nginx
service:
name: nginx
state: reloaded
# Run it — connects over SSH, no agent installed on the targets
ansible-playbook -i inventory/production site.yml --limit payments_api_serversInterview Tip
A junior engineer typically explains Ansible as 'a tool for automating server setup' without contrasting it against agent-based alternatives. For a senior or architect role, the interviewer is actually looking for you to explain the real operational tradeoff: agentless push-based tools give you precise control over when changes happen but no continuous drift correction, while agent-based pull tools self-heal drift automatically but require managing agent infrastructure and accept up to one pull-cycle of delay before a fix propagates. Mentioning idempotency specifically — that running a playbook twice is safe because modules check current state before acting — signals you understand why configuration management tools are different from plain shell scripts.
◈ Architecture Diagram
Ansible (agentless, push): Chef/Puppet (agent, pull): ┌─────────┐ SSH, on-demand ┌────────┐ poll every 30min │ Control │──────────►┌──────┐ │ Central│◄─────────┐ │ Machine │ │Server│ │ Server │ ┌──────┐ └─────────┘ └──────┘ └────────┘ │ Agent│ no persistent continuously (runs on process on target self-heals drift every node)
💬 Comments
Quick Answer
Ansible connects over plain SSH (or WinRM) and needs no daemon installed on managed hosts — only Python on the target.
Detailed Answer
Because there's no agent to deploy, patch, or secure, onboarding a host is just SSH access. The control node pushes modules, runs them, and removes them. This lowers operational overhead versus pull-based agents, though it means the control node must reach every host and scale is bounded by SSH fan-out (tunable with forks).
Interview Tip
Contrast push-based (Ansible) vs pull-based agents (Puppet/Chef).
💬 Comments
Quick Answer
Running a playbook repeatedly converges to the same state; a second run should report changed=0.
Detailed Answer
Modules check current state before acting — the apt module installs only if the package is absent, template writes only if content differs. Idempotency lets you run playbooks safely and repeatedly. Raw command/shell are NOT idempotent by default; add creates:, removes:, or changed_when: to make them behave.
Interview Tip
Call out that command/shell break idempotency unless guarded.
💬 Comments
Quick Answer
Inventory lists hosts and groups them; variables can be attached per host (host_vars) or per group (group_vars).
Detailed Answer
Inventory can be static (INI/YAML) or dynamic (plugins that query AWS, GCP, etc.). Groups let you target a subset (hosts: web) and compose meta-groups with :children. group_vars/<group>.yml and host_vars/<host>.yml keep variables next to inventory instead of scattered in playbooks.
Code Example
[web] web1.example.com [production:children] web
Interview Tip
Mention dynamic inventory for cloud environments.
💬 Comments
Quick Answer
Handlers are tasks triggered by notify that run once, at the end of the play, only if a notifying task reported changed.
Detailed Answer
A common pattern: a template task deploys a config and notifies 'Restart nginx'. The handler runs only if the file actually changed, and only once even if notified multiple times. Handlers run after all tasks in the play by default (use meta: flush_handlers to run them earlier).
Code Example
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restartedInterview Tip
Emphasize 'only on change, only once' — a classic interview point.
💬 Comments
Quick Answer
Encrypt sensitive files or variables with Ansible Vault; decrypt at run time with a vault password.
Detailed Answer
ansible-vault encrypt/create protects files (e.g. group_vars/prod/secrets.yml) with AES; they're decrypted in memory during the run. Provide the password via --ask-vault-pass interactively or --vault-password-file in CI backed by a secret store. Never commit plaintext credentials.
Interview Tip
Mention --vault-password-file for non-interactive CI.
💬 Comments
Quick Answer
Roles bundle tasks, handlers, templates, defaults, and vars into a reusable, shareable unit.
Detailed Answer
Once a playbook grows, split it into roles (ansible-galaxy role init). A role has a fixed layout (tasks/, handlers/, templates/, defaults/, vars/) so structure is predictable and reusable across projects and via Ansible Galaxy. Playbooks then become a thin list of roles applied to host groups.
Code Example
- hosts: web become: true roles: [nginx, app, monitoring]
Interview Tip
Know the standard role directory layout.
💬 Comments
Quick Answer
--check performs a dry run predicting changes without making them; --diff shows the exact file changes.
Detailed Answer
Check mode lets you preview impact before touching production. Not all modules fully support it, but most core ones do. Combine --check --diff to see both what would change and the line-level diff. It's the safest way to validate a playbook against real hosts.
Interview Tip
Recommend --check --diff before running against prod.
💬 Comments
Quick Answer
Use --limit to restrict to specific hosts/groups and --tags to run only tagged tasks.
Detailed Answer
--limit web1.example.com narrows the play to one host for safe testing; patterns and group intersections are supported. --tags config runs only tasks tagged config, and --skip-tags excludes them. Together they let you make small, controlled changes instead of running everything everywhere.
Interview Tip
Show a testing workflow: --limit one host, then widen.
💬 Comments
Quick Answer
List the 10 hosts in an inventory group, then run a playbook that targets that group with a package task (apt/yum) to ensure nginx is present. Ansible runs it in parallel over SSH, idempotently.
Detailed Answer
The strength here is idempotency and parallelism: state=present installs only where missing, and re-running is safe. Use the generic package module or the OS-specific one, add a handler to start/enable the service, and template the config. Scaling from 10 to 1000 servers is just a bigger inventory — the playbook does not change.
Code Example
- hosts: web
become: true
tasks:
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Ensure nginx running
ansible.builtin.service: { name: nginx, state: started, enabled: true }Interview Tip
Stress idempotency and that scaling to more hosts just means a bigger inventory — the playbook is unchanged.
💬 Comments
Quick Answer
A conventional layout: inventories/ (per-environment hosts), roles/ (reusable units), playbooks/, group_vars/ and host_vars/ (variables by scope), and ansible.cfg for configuration.
Detailed Answer
Separating inventories by environment (dev/stage/prod) prevents cross-environment mistakes; roles keep tasks/templates/handlers reusable and DRY; group_vars/host_vars scope variables cleanly. This structure scales from a few playbooks to a large automation codebase and plays nicely with Ansible Galaxy for sharing roles.
Code Example
inventories/{dev,prod}/hosts
roles/
playbooks/
group_vars/
host_vars/
ansible.cfgInterview Tip
Highlight per-environment inventories and roles for reuse — organization is what keeps Ansible maintainable at scale.
💬 Comments
Quick Answer
A YAML file that defines automation as ordered plays and tasks — mapping host groups to the modules, variables, handlers, and roles that bring them to a desired state, idempotently.
Detailed Answer
Each play targets an inventory group and runs tasks that are idempotent module calls, so re-running converges rather than duplicating work. Handlers run only on change (e.g., restart nginx when config changes), variables and templates make it environment-aware, and roles package reusable logic. Playbooks are the readable, version-controlled unit of Ansible automation.
Interview Tip
Mention idempotency, handlers-on-change, and roles — the three things that make playbooks robust rather than just scripts.
💬 Comments
Symptom
A playbook reported one failed host and nine changed hosts, but traffic still used the old NGINX upstream list on several servers.
Error Message
RUNNING HANDLER [reload nginx] skipped due to failed host
Root Cause
A task notified the reload handler, then a later task failed on some hosts. By default, handlers may not run on hosts that failed later in the play. The configuration file was updated, but the service was not reloaded, creating split behavior across the load-balancer fleet. The underlying issue was a combination of factors that individually seemed harmless but together created a cascading failure. The monitoring that should have caught this early was either missing or configured with thresholds too high to trigger before user impact. The on-call engineer initially pursued the wrong hypothesis because the symptoms resembled a different, more common failure mode. By the time the real root cause was identified, the blast radius had expanded beyond the original service to affect downstream dependencies. This incident exposed a gap in the team's runbooks and highlighted the need for better correlation between metrics, logs, and traces during diagnosis.
Diagnosis Steps
Solution
Fix the failing task, rerun the play with handler behavior understood, or use `force_handlers` for changes that must be applied even after later task failures. Validate NGINX config before reload.
Commands
ansible-playbook -i prod site.yml --limit lb --diff
ansible lb -i prod -m command -a "nginx -t"
ansible lb -i prod -m service -a "name=nginx state=reloaded"
Prevention
Use blocks and rescue for risky steps. Understand handler semantics. Add post-change verification that checks runtime state, not only file content.
◈ Architecture Diagram
┌──────────┐
│ Trigger │
└────┬─────┘
↓
┌──────────┐
│ Incident │
└────┬─────┘
↓
┌──────────┐
│ Verify │
└──────────┘💬 Comments