Ansible Best Practices: Project Structure That Survives Production
Ansible best practices and project structure for production.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- ✓Working Ansible installation. Basic playbook authoring experience. Familiarity with roles and inventory concepts.
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.
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.
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'.
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:
- Role defaults (
roles/role_name/defaults/main.yml) - Inventory vars (
inventories/environment/group_vars/all.yml) - Inventory group_vars per group
- Inventory host_vars
- Playbook vars (
vars:in playbook) - Playbook vars_files
- Role vars (
roles/role_name/vars/main.yml) - Block vars (via
vars:on a block) - Task vars (via
vars:on a task) - Extra vars (
-eon 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.
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.
Here's how I structure a role:
tasks/main.yml: The main list of tasks. Keep it short. Useinclude_tasksto 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).
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.
Here's a pattern I use for multi-environment inventory:
``` [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.
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.
--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.
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:
- Syntax check:
ansible-playbook --syntax-check playbooks/site.yml— catches YAML errors and missing variables. - 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. - Linting: Use
ansible-lintto enforce best practices. It catches things like hardcoded IPs, missing tags, and privilege escalation issues. - 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.
- 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.
When to Break the Rules
Every best practice has exceptions. Here's when to deviate:
- 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
ansiblecommand directly, not a playbook. - Tight coupling: Sometimes two roles are so tightly coupled that separating them adds complexity. For example, an
approle that depends on a specificnginxconfig. 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-pullfor 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.
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.The Variable That Wiped Out Config
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.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.- 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.
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.ansible-inventory --graph to see group membership. 4. Ensure the variable name is spelled correctly.allow_duplicates: false in meta/main.yml if appropriate.ansible-playbook --syntax-check playbooks/site.ymlgrep -rn 'include:' playbooks/include: with import_playbook: in playbooks. For tasks, use include_tasks or import_tasks.| File | Command / Code | Purpose |
|---|---|---|
| bad_playbook.yml | - hosts: webservers | Why Flat Playbooks Are a Production Liability |
| ansible.cfg | [defaults] | The Official Best Practices Directory Layout |
| roles | nginx_port: 80 | Variable Precedence |
| roles | - name: Include OS-specific variables | Designing Roles That Don't Suck |
| inventories | [prod-webservers] | Inventory Organization |
| playbooks | - name: Apply common configuration to all hosts | Playbook Design |
| group_vars | vault_db_password: !vault | | Secrets Management |
| .github | name: Ansible CI | Testing and CI |
Key takeaways
Common mistakes to avoid
4 patternsPutting all tasks in a single playbook
roles: [nginx, postgresql, monitoring]. Each role has a single responsibility.Hardcoding values in tasks
Not testing playbooks before deployment
--syntax-check, --check --diff, and ansible-lint before every merge. Use Molecule for role-level testing.Committing secrets to Git
Interview Questions on This Topic
How does Ansible's variable precedence work, and what's a common mistake that causes production incidents?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's Ansible. Mark it forged?
8 min read · try the examples if you haven't