Home DevOps Ansible Best Practices: Project Structure That Survives Production
Intermediate 8 min · July 11, 2026

Ansible Best Practices: Project Structure That Survives Production

Ansible best practices and project structure for production.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
July 11, 2026
last updated
1,727
articles · all by Naren
Before you start⏱ 30 min
  • Working Ansible installation. Basic playbook authoring experience. Familiarity with roles and inventory concepts.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Organize your Ansible project using the official 'best practices' layout: a top-level directory with inventories/, roles/, playbooks/, group_vars/, host_vars/, and ansible.cfg. Each role should be self-contained with its own tasks, handlers, templates, and defaults. Use group_vars and host_vars for environment-specific data, never hardcode values in roles.

✦ Definition~90s read
What is Ansible Best Practices and Project Structure?

Ansible best practices and project structure is a set of conventions for organizing Ansible automation code into reusable, maintainable, and scalable components. It covers directory layout, role design, variable management, inventory organization, and workflow patterns that prevent the chaos of monolithic playbooks.

Think of an Ansible project like a restaurant kitchen.
Plain-English First

Think of an Ansible project like a restaurant kitchen. A flat playbook is like a single cook trying to make every dish from scratch using one giant recipe book — chaos when orders pile up. Roles are like specialized stations: one for grilling, one for salads, one for desserts. Each station has its own tools, ingredients, and prep steps. The inventory is the waitstaff's order pad telling which table gets what. Variables are the daily specials board — they change per shift (environment) but the recipes stay the same. Without this structure, you end up with a kitchen where nobody can find the salt and every dish tastes different.

You've been there. A 2000-line playbook that 'works on my machine' but takes 45 minutes to run in prod and nobody dares touch. The variables are scattered across five files, half of them unused. The inventory is a flat list of IPs that someone updates by hand. This is the Ansible equivalent of a Jenga tower — one wrong move and everything collapses. I've seen this bring down a deployment pipeline when a junior dev accidentally overwrote a critical variable because it was defined in three places with no precedence rules understood.

This isn't about writing Ansible that works. It's about writing Ansible that survives. Survives onboarding new team members. Survives scaling from 10 servers to 1000. Survives the 3am pager when a config change breaks production. The problem with most Ansible code is that it's written like a shell script — linear, fragile, and impossible to reuse. You need a structure that enforces separation of concerns, makes variables predictable, and lets you reason about what a playbook does without reading every line.

By the end of this, you'll be able to design an Ansible project that a new hire can understand in 10 minutes, that CI can validate automatically, and that you can confidently run against production without holding your breath. You'll know exactly where to put variables, how to structure roles, and when to break the rules. This is the structure I've used across three companies and hundreds of servers — it's not theoretical, it's battle-tested.

Why Flat Playbooks Are a Production Liability

Every Ansible project starts innocent. A single playbook, a few tasks. Then you add a role. Then another. Before you know it, you have a 500-line playbook with include statements pointing to files in three different directories. The problem isn't the lines of code — it's the lack of boundaries. When everything is in one file, you can't test a single component without running the whole thing. You can't reuse a configuration across projects. You can't even understand what the playbook does without scrolling through every task.

Flat playbooks also encourage hardcoding. You write hosts: webservers directly in the playbook instead of using an inventory. You embed apt: name=nginx state=present without considering that different environments might need different versions. This works until you need to deploy to a second environment. Then you copy the playbook, change a few lines, and now you have two files to maintain. Multiply by ten environments and you're in maintenance hell.

The fix is simple: use roles. Roles are self-contained units of automation. Each role has its own tasks, handlers, templates, files, and variables. A role should do one thing and do it well. For example, an nginx role installs and configures nginx. It doesn't also install PHP. That's a separate role. This separation lets you compose playbooks like building blocks: webserver.yml includes the nginx, php, and firewall roles. You can test each role independently, reuse them across projects, and swap out implementations without rewriting everything.

bad_playbook.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# io.thecodeforge — DevOps tutorial
# Bad: flat playbook with hardcoded values
---
- hosts: webservers
  become: yes
  tasks:
    - name: Install nginx
      apt:
        name: nginx
        state: present
    - name: Copy config
      copy:
        src: /home/user/nginx.conf
        dest: /etc/nginx/nginx.conf
    - name: Restart nginx
      service:
        name: nginx
        state: restarted
Output
Runs but is not reusable. Config path hardcoded. No separation of concerns.
Production Trap:
Flat playbooks scale linearly in complexity. Every new environment doubles the maintenance burden. If you have more than 50 lines in a single playbook, it's time to refactor into roles.

The Official Best Practices Directory Layout

Ansible's official documentation recommends a specific directory layout. Don't ignore it. It's not perfect, but it's the closest thing to a standard that exists. Here's the layout I use in production:

`` production/ ├── ansible.cfg ├── inventories/ │ ├── production/ │ │ ├── hosts │ │ └── group_vars/ │ │ ├── all.yml │ │ ├── webservers.yml │ │ └── databases.yml │ └── staging/ │ ├── hosts │ └── group_vars/ │ ├── all.yml │ └── webservers.yml ├── roles/ │ ├── common/ │ │ ├── tasks/ │ │ │ └── main.yml │ │ ├── handlers/ │ │ │ └── main.yml │ │ ├── templates/ │ │ ├── files/ │ │ ├── vars/ │ │ │ └── main.yml │ │ └── defaults/ │ │ └── main.yml │ ├── nginx/ │ └── postgres/ ├── playbooks/ │ ├── site.yml │ ├── webservers.yml │ └── databases.yml └── group_vars/ └── all.yml ``

Key points: Each environment gets its own inventory directory with its own group_vars. The top-level group_vars/ is for variables shared across all environments — use it sparingly. Roles are in roles/, playbooks in playbooks/. The ansible.cfg at the root sets project-wide defaults. This layout makes it clear where to find anything. When a new team member asks 'where's the nginx config template?', the answer is always 'in the nginx role's templates directory'.

ansible.cfgYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# io.thecodeforge — DevOps tutorial
# ansible.cfg - project-level configuration
[defaults]
# Use project-specific roles path
roles_path = roles
# Inventory per environment - specify with -i
inventory = inventories/production/hosts
# Enable pipelining for speed (requires SSH pipelining enabled on target)
pipelining = True
# Number of parallel forks
forks = 20
# Host key checking off for dynamic environments
host_key_checking = False
# Log to file for debugging
log_path = ansible.log
# Retry files for failed hosts
retry_files_enabled = True
retry_files_save_path = .retry
# Don't gather facts unless needed
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 3600
Output
Configuration file that speeds up execution and enables debugging.
Senior Shortcut:
Use gathering = smart and fact caching. It skips fact gathering for hosts that already have cached facts, cutting playbook runtime by 30-50% on subsequent runs.

Variable Precedence: The Only Rule That Matters

Ansible has a variable precedence order that determines which value wins when the same variable is defined in multiple places. Ignore this at your peril. The order from lowest to highest priority is:

  1. Role defaults (roles/role_name/defaults/main.yml)
  2. Inventory vars (inventories/environment/group_vars/all.yml)
  3. Inventory group_vars per group
  4. Inventory host_vars
  5. Playbook vars (vars: in playbook)
  6. Playbook vars_files
  7. Role vars (roles/role_name/vars/main.yml)
  8. Block vars (via vars: on a block)
  9. Task vars (via vars: on a task)
  10. Extra vars (-e on command line)

The critical insight: role defaults have the LOWEST precedence. That means they are meant to be overridden. Use them for sensible defaults that work in development. Then override them in inventory group_vars for each environment. Never put environment-specific values in role vars — that's what group_vars is for. And never, ever use -e to pass secrets in production (they end up in logs). Use Ansible Vault instead.

I've seen a team spend three days debugging a variable that was defined in role defaults, group_vars, and playbook vars — each with a different value. The playbook vars won, but nobody knew because they assumed group_vars was the source of truth. The fix was to delete the playbook vars and move the value to group_vars, then add a CI check that fails if any variable is defined in more than two places.

roles/nginx/defaults/main.ymlYAML
1
2
3
4
5
6
7
8
9
# io.thecodeforge — DevOps tutorial
# Role defaults - lowest precedence, meant to be overridden
---
nginx_port: 80
nginx_user: www-data
nginx_worker_processes: 4
nginx_ssl_enabled: false
nginx_ssl_cert_path: /etc/nginx/ssl/cert.pem
nginx_ssl_key_path: /etc/nginx/ssl/key.pem
Output
Defaults that work for development. Override in group_vars for production.
The Classic Bug:

Designing Roles That Don't Suck

A good role is like a function: it takes inputs (variables), does one thing, and produces a predictable state. A bad role is a kitchen sink that installs nginx, configures PHP, sets up a firewall, and deploys the application code. Don't do that. Each role should have a single responsibility.

  • tasks/main.yml: The main list of tasks. Keep it short. Use include_tasks to break up complex logic into separate files (e.g., install.yml, configure.yml, service.yml).
  • handlers/main.yml: Handlers for restarting services. Only define handlers that are notified by tasks in this role.
  • templates/: Jinja2 templates for config files. Name them after the destination file (e.g., nginx.conf.j2).
  • files/: Static files to copy. Avoid if possible — use templates instead.
  • vars/main.yml: Internal variables that should NOT be overridden. Use sparingly.
  • defaults/main.yml: Overridable defaults. This is where most variables go.
  • meta/main.yml: Role metadata including dependencies. Use dependencies sparingly — they create coupling.

A common mistake is putting too much logic in tasks/main.yml. If your role has more than 20 tasks, split it. For example, an nginx role might have install.yml, configure.yml, vhosts.yml, and ssl.yml. Then main.yml just includes them in order. This makes it easy to skip parts by overriding variables (e.g., nginx_install: false).

roles/nginx/tasks/main.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# io.thecodeforge — DevOps tutorial
# Main tasks file for nginx role
---
- name: Include OS-specific variables
  include_vars: "{{ ansible_os_family }}.yml"

- name: Install nginx
  include_tasks: install.yml
  when: nginx_install | default(true)

- name: Configure nginx
  include_tasks: configure.yml
  when: nginx_configure | default(true)

- name: Manage vhosts
  include_tasks: vhosts.yml
  when: nginx_vhosts | length > 0

- name: Setup SSL
  include_tasks: ssl.yml
  when: nginx_ssl_enabled

- name: Ensure nginx is running
  service:
    name: nginx
    state: started
    enabled: yes
Output
Modular tasks file that can be partially skipped via variables.
Interview Gold:
Q: 'How do you handle OS-specific differences in a role?' A: Use include_vars with {{ ansible_os_family }} to load different variable files per OS family (e.g., Debian.yml, RedHat.yml). Then tasks use those variables instead of hardcoding package names.

Inventory Organization: Beyond Flat Files

The inventory is your source of truth for what servers exist and what groups they belong to. For small projects, a flat hosts file works. But once you have more than 50 servers, you need dynamic inventory. Ansible supports dynamic inventory scripts that query cloud APIs (AWS EC2, GCP, Azure) or CMDBs. This eliminates the manual step of updating a file every time a server is added or removed.

But even with static inventory, structure matters. Use groups to represent both function (e.g., webservers, databases) and environment (e.g., production, staging). Then use group_vars to set environment-specific variables. Never put IP addresses in playbooks — always reference groups.

``` [production:children] prod-webservers prod-databases

[prod-webservers] web01.example.com web02.example.com

[prod-databases] db01.example.com

[staging:children] staging-webservers staging-databases

[staging-webservers] staging-web01.example.com

[staging-databases] staging-db01.example.com ```

Then in group_vars/production.yml, you set variables that apply to all production hosts. In group_vars/prod-webservers.yml, you set variables specific to production web servers. This hierarchy mirrors the group structure and makes variable lookup predictable.

inventories/production/hostsINI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# io.thecodeforge — DevOps tutorial
# Production inventory with group hierarchy
[prod-webservers]
web01 ansible_host=10.0.1.10
web02 ansible_host=10.0.1.11

[prod-databases]
db01 ansible_host=10.0.2.10
db02 ansible_host=10.0.2.11

[production:children]
prod-webservers
prod-databases

[all:vars]
ansible_user=deploy
ansible_ssh_private_key_file=/path/to/prod-key.pem
Output
Inventory file with groups and children for environment separation.
Never Do This:
Don't put ansible_user or ansible_ssh_private_key_file in a playbook or role. Put them in inventory group_vars for the environment. Otherwise, you risk using production credentials against staging servers.

Playbook Design: Orchestration, Not Implementation

A playbook should be a thin orchestrator that applies roles to groups. It should not contain tasks directly. Think of playbooks as the 'conductor' and roles as the 'musicians'. The playbook decides which roles run on which hosts and in what order.

Example: site.yml might apply the common role to all hosts, then webservers.yml applies nginx and php to web servers, and databases.yml applies postgres to database servers. Each playbook is a single file that includes roles via the roles: keyword.

Use pre_tasks and post_tasks sparingly. They're useful for things like taking a host out of a load balancer before updating it, but don't put business logic there. Keep the playbook clean.

Another pattern: use import_playbook to compose larger playbooks from smaller ones. For example, site.yml might import common.yml, webservers.yml, and databases.yml. This lets you run individual playbooks for targeted updates or the full site playbook for a complete deployment.

playbooks/site.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# io.thecodeforge — DevOps tutorial
# Site-wide playbook that orchestrates all roles
---
- name: Apply common configuration to all hosts
  hosts: all
  roles:
    - common

- name: Configure web servers
  hosts: webservers
  roles:
    - nginx
    - php

- name: Configure database servers
  hosts: databases
  roles:
    - postgres

- name: Deploy application
  hosts: webservers
  roles:
    - app-deploy
Output
Clean orchestration playbook. No tasks, just roles applied to groups.
Senior Shortcut:
Use --limit to run a playbook against a subset of hosts during testing. Example: ansible-playbook site.yml --limit web01 runs only against web01. This is faster than modifying the playbook.

Secrets Management: Ansible Vault and Beyond

Hardcoding passwords in playbooks is a fireable offense. Use Ansible Vault to encrypt sensitive variables. The workflow: create a vault-encrypted file (e.g., group_vars/production/vault.yml) that contains secrets like db_password: !vault | .... Then reference db_password in your playbooks normally. Ansible decrypts it at runtime if you provide the vault password.

But vault has limitations. It's slow for large files. It doesn't integrate with secret rotation. For production, consider using a secrets manager like HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault. Ansible can fetch secrets from these at runtime using lookup plugins (e.g., hashi_vault, aws_secret). This avoids storing encrypted files in your repo and enables dynamic secret rotation.

Never use -e to pass secrets on the command line. They appear in process lists and logs. If you must use extra vars, use --extra-vars @secrets.yml where secrets.yml is vault-encrypted.

A pattern I use: store a single vault password in a CI/CD secret (e.g., GitHub Actions secret). The CI job uses that password to decrypt vault files during deployment. The vault files themselves are committed to the repo, but only the CI has the password. This is a reasonable trade-off for small teams. For larger teams, use a secrets manager.

group_vars/production/vault.ymlYAML
1
2
3
4
5
6
7
8
9
10
# io.thecodeforge — DevOps tutorial
# Vault-encrypted file containing secrets
# Encrypt with: ansible-vault encrypt group_vars/production/vault.yml
---
vault_db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          66386439653236336...
vault_api_key: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          34326634363534363...
Output
Encrypted variables. Decrypted automatically when vault password is provided.
Production Trap:
Don't put vault passwords in plain text in ansible.cfg or scripts. Use a vault password file with restricted permissions (0600) and never commit it. Better: use a CI secret.

Testing and CI: Catch Failures Before They Hit Prod

Ansible has no built-in testing framework. That's terrifying. You need to validate your playbooks before they run against production. Here's my minimum testing pipeline:

  1. Syntax check: ansible-playbook --syntax-check playbooks/site.yml — catches YAML errors and missing variables.
  2. Dry run: ansible-playbook --check playbooks/site.yml — simulates changes without applying them. Not perfect (modules may not support check mode), but catches most issues.
  3. Linting: Use ansible-lint to enforce best practices. It catches things like hardcoded IPs, missing tags, and privilege escalation issues.
  4. Unit testing with Molecule: Molecule lets you test roles in Docker containers or VMs. You write test scenarios that verify the role produces the expected state. This is the closest you get to a proper test suite.
  5. Integration testing: Run your playbooks against a staging environment that mirrors production. This catches environment-specific issues that unit tests miss.

In CI (GitHub Actions, GitLab CI, Jenkins), run syntax check, lint, and Molecule tests on every commit. Run dry-run against staging on merge to main. Run full integration tests before deployment to production.

I've seen teams skip testing and then discover that a playbook works on Ubuntu 20.04 but fails on 22.04 because a package name changed. Molecule with multiple Docker images would have caught that.

.github/workflows/ansible-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# io.thecodeforge — DevOps tutorial
# GitHub Actions workflow for Ansible CI
name: Ansible CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install dependencies
        run: |
          pip install ansible ansible-lint molecule molecule-docker
      - name: Syntax check
        run: ansible-playbook --syntax-check playbooks/site.yml
      - name: Lint
        run: ansible-lint
      - name: Molecule test
        run: molecule test
        working-directory: roles/nginx
Output
CI pipeline that catches errors early.
Interview Gold:
Q: 'How do you test Ansible roles without real servers?' A: Use Molecule with Docker drivers. It creates containers, runs the role, and verifies the state using testinfra (Python assertions). This catches idempotency issues and missing dependencies.

When to Break the Rules

  • Small teams, small infrastructure: If you have 5 servers and 2 environments, a flat playbook with a few roles is fine. The overhead of the full directory layout isn't worth it.
  • Prototyping: When you're exploring a new tool, don't worry about structure. Write a quick playbook to test the concept. Refactor later.
  • One-off tasks: For ad-hoc operations (e.g., 'restart all web servers'), use ansible command directly, not a playbook.
  • Tight coupling: Sometimes two roles are so tightly coupled that separating them adds complexity. For example, an app role that depends on a specific nginx config. In that case, consider merging them or using role dependencies.
  • Performance: If you have thousands of hosts, the overhead of fact caching and pipelining might not be enough. Consider using ansible-pull for pull-based configuration, or switch to a different tool like SaltStack or Terraform for certain tasks.

The key is to know the rules so you know when to break them. If you're breaking a rule, document why in a README or inline comment. Future you will thank you.

Senior Shortcut:
When prototyping, use ansible-playbook with -i pointing to a local hosts file and --ask-become-pass. Don't set up the full inventory structure until you're ready for production.
● Production incidentPOST-MORTEMseverity: high

The Variable That Wiped Out Config

Symptom
After a routine Ansible run, all web servers lost their SSL certificate paths. Sites went down with 'certificate not found' errors.
Assumption
Someone accidentally deleted the certificate files from the servers.
Root cause
A new role for 'certificate management' defined a variable cert_path in its defaults/main.yml with value /etc/ssl/certs. The existing group_vars/production.yml also defined cert_path but with a different value /etc/ssl/prod/certs. Due to Ansible's variable precedence, the role's defaults were overridden by group_vars, but a junior dev had also set cert_path in the playbook vars section, which has higher precedence than group_vars. The playbook var was set to /etc/ssl/certs (the wrong path for production), overriding the correct production value. The role then deployed configs pointing to the wrong directory.
Fix
Removed the cert_path definition from the playbook vars. Moved all environment-specific paths to group_vars/production.yml and group_vars/staging.yml. Added a CI check that fails if any variable is defined at more than two precedence levels.
Key lesson
  • Variable precedence is not a suggestion — it's the law.
  • Never define the same variable in more than two places, and always keep environment-specific values in group_vars, not in playbooks or role defaults.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Playbook fails with 'ERROR! 'vars' is not a valid attribute for a Play'
Fix
1. Check that you're not using vars at the play level incorrectly. 2. Ensure vars: is a list of dictionaries or a single dictionary. 3. If using vars_files, verify the file path is correct.
Symptom · 02
Variable defined in group_vars is not available in a role
Fix
1. Verify the group_vars file is in the correct inventory directory. 2. Check that the host is a member of the group. 3. Run ansible-inventory --graph to see group membership. 4. Ensure the variable name is spelled correctly.
Symptom · 03
Role dependencies cause circular dependency error
Fix
1. Identify the circular chain by reading the error message. 2. Break the cycle by moving shared tasks to a common role that both depend on. 3. Use allow_duplicates: false in meta/main.yml if appropriate.
★ Ansible Project Structure Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
Playbook fails with `ERROR! 'include' is not a valid attribute for a Play`
Immediate action
Check if you're using `include` instead of `import_playbook` at play level
Commands
ansible-playbook --syntax-check playbooks/site.yml
grep -rn 'include:' playbooks/
Fix now
Replace include: with import_playbook: in playbooks. For tasks, use include_tasks or import_tasks.
Variable not found error like `'nginx_port' is undefined`+
Immediate action
Check if the variable is defined in the correct precedence level
Commands
ansible-inventory --host web01 --vars
ansible-playbook playbooks/site.yml --extra-vars 'nginx_port=8080' --check
Fix now
Add the variable to the appropriate group_vars file or role defaults.
Playbook runs but no changes are made (idempotency issue)+
Immediate action
Check if the module is correctly detecting state
Commands
ansible-playbook playbooks/site.yml -v
ansible-playbook playbooks/site.yml --diff
Fix now
Use --diff to see what changes Ansible thinks it needs to make. Adjust the module parameters to correctly reflect desired state.
SSH connection timeout or 'Permission denied'+
Immediate action
Verify SSH connectivity and credentials
Commands
ansible all -i inventories/production/hosts -m ping -u deploy --private-key=/path/to/key
ssh -i /path/to/key deploy@hostname
Fix now
Ensure the SSH key is correct and the user has sudo access. Check ansible_user and ansible_ssh_private_key_file in inventory.
AspectFlat PlaybookRole-Based Structure
ReusabilityLow — copy-paste between projectsHigh — roles can be shared via Ansible Galaxy
MaintainabilityPoor — changes affect everythingGood — isolated changes per role
TestabilityDifficult — must run full playbookEasy — test roles individually with Molecule
Variable managementChaotic — hardcoded valuesPredictable — precedence rules
Learning curveLowMedium
Best forPrototypes, small infra (<10 hosts)Production, large infra (>10 hosts)
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
bad_playbook.yml- hosts: webserversWhy Flat Playbooks Are a Production Liability
ansible.cfg[defaults]The Official Best Practices Directory Layout
rolesnginxdefaultsmain.ymlnginx_port: 80Variable Precedence
rolesnginxtasksmain.yml- name: Include OS-specific variablesDesigning Roles That Don't Suck
inventoriesproductionhosts[prod-webservers]Inventory Organization
playbookssite.yml- name: Apply common configuration to all hostsPlaybook Design
group_varsproductionvault.ymlvault_db_password: !vault |Secrets Management
.githubworkflowsansible-ci.ymlname: Ansible CITesting and CI

Key takeaways

1
Use roles for everything. A playbook should be a thin wrapper mapping roles to host groups.
2
Organize variables by precedence
role defaults, group_vars, host_vars, vault.
3
Encrypt secrets with Ansible Vault. Store vault passwords in a secrets manager.
4
Test before deploy
--syntax-check, ansible-lint, Molecule, --check --diff.

Common mistakes to avoid

4 patterns
×

Putting all tasks in a single playbook

Fix
Use roles. A playbook should be a thin wrapper: roles: [nginx, postgresql, monitoring]. Each role has a single responsibility.
×

Hardcoding values in tasks

Fix
Use variables for everything that differs between environments. Define defaults in roles, overrides in group_vars.
×

Not testing playbooks before deployment

Fix
Run --syntax-check, --check --diff, and ansible-lint before every merge. Use Molecule for role-level testing.
×

Committing secrets to Git

Fix
Use Ansible Vault for secrets. Encrypt entire files or individual variables. Store vault passwords in a secrets manager.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Ansible's variable precedence work, and what's a common mistake...
Q02SENIOR
When would you choose a role-based structure over a flat playbook for a ...
Q03SENIOR
What happens if you define a variable in both `defaults/main.yml` and `v...
Q04JUNIOR
What is the purpose of `ansible.cfg` in a project, and what settings are...
Q05SENIOR
You run a playbook against production and it fails because a variable is...
Q06SENIOR
How would you design an Ansible project for a multi-region, multi-enviro...
Q01 of 06SENIOR

How does Ansible's variable precedence work, and what's a common mistake that causes production incidents?

ANSWER
Ansible has 10 levels of precedence from role defaults (lowest) to extra vars (highest). A common mistake is defining the same variable in role defaults, group_vars, and playbook vars, then expecting group_vars to win. The playbook vars actually win, which can override environment-specific values. The fix is to only define a variable in two places max: role defaults and group_vars.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is the most important Ansible best practice?
02
How should I organize Ansible variables?
03
How do I handle secrets in Ansible?
04
What testing should I do before deploying playbooks to production?
05
How do I structure environments (dev/staging/prod) in Ansible?
06
Should I use ansible-core or the full ansible package?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
July 11, 2026
last updated
1,727
articles · all by Naren
🔥

That's Ansible. Mark it forged?

8 min read · try the examples if you haven't

Previous
Ansible vs Chef vs Puppet vs SaltStack
28 / 37 · Ansible
Next
Ansible Ad-Hoc Commands Guide