Ansible Roles — Empty defaults/main.yml Prevents Reuse
Hardcoded paths in tasks/main.yml caused 8 months of fork drift and a 2-week merge estimate.
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
- ✓Solid grasp of DevOps fundamentals
- ✓Comfortable with command-line tools
- ✓Basic Linux administration knowledge
- Ansible Role = packaged unit of automation with standard directory structure (tasks, handlers, defaults, vars, templates, files, meta)
- Convention over configuration: Ansible auto-loads main.yml from each directory when the role is called — missing that file means the directory silently does nothing
- defaults/ for overridable variables (lowest precedence in all of Ansible), vars/ for internal constants (higher precedence — overrides from inventory won't reach them)
- Performance: role lookups add ~100ms per role call — flatten deeply nested role dependency chains for large inventories running under time pressure
- Production trap: hardcoding paths in tasks/ instead of using defaults/ — the role works for one team and is useless for everyone else without forking it
- Biggest mistake: creating 'God roles' that configure databases AND web servers AND monitoring — break into separate focused roles, compose them in the playbook
Ansible Roles are the standard mechanism for organizing playbook content into reusable, self-contained units. They solve the fundamental problem of configuration management at scale: how to package tasks, variables, templates, and handlers so they can be shared across projects, teams, and the Ansible Galaxy community without copy-paste spaghetti.
A role enforces a directory convention — tasks/, handlers/, templates/, files/, vars/, defaults/, and meta/ — that lets Ansible resolve dependencies and variable precedence predictably. Without this structure, you're writing scripts that rot; with it, you build composable infrastructure components that can be tested, versioned, and published.
The critical insight most engineers miss is that defaults/main.yml is not optional decoration — it's the contract for role reuse. When you leave it empty, every variable your role consumes becomes a hard requirement that must be set externally, making the role brittle and context-dependent.
A well-designed role uses defaults/ to provide sensible fallbacks for every tunable parameter, so consumers can override only what they need. This is the difference between a role that works out of the box and one that requires reading the source code to understand what variables are expected.
In production, roles solve two opposing problems: preventing the 'god role' that tries to configure everything (a 2000-line monster that's impossible to test or compose) and enabling role composition through meta/main.yml dependencies. The best roles are small, focused on a single concern (e.g., nginx configures nginx, not the whole web stack), and declare their dependencies explicitly.
Testing with Molecule against actual container or VM instances is what separates production-grade roles from ad-hoc scripts — it forces you to handle idempotency, edge cases, and variable precedence in a repeatable way. The first time you write a role, you should start with mkdir my_role/{tasks,defaults,meta} and a molecule init scenario, not a script file.
Think of Ansible Roles as a professional toolbox with dedicated, labeled drawers. Instead of throwing every tool — hammers, screwdrivers, drills — into one big pile (a single massive playbook), you organize them. One drawer holds Web Server tools. Another holds Database tools. A third holds Monitoring tools. When you need to build a new system, you grab exactly the drawers you need and leave the rest on the shelf.
The labels on the drawers matter too. Some tools have adjustable settings — the drill's speed, the torque on the wrench. Those settings go on a sticky note on the outside of the drawer so whoever borrows it can change them without opening the drawer and modifying the tool itself. That's what defaults/ is in an Ansible role: the sticky note that says 'this is what we assume, but you can change it.' vars/ is the weld that holds the drawer together — it should not be touched.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Ansible Roles are how you turn automation from scripting into software engineering. A single playbook that works for one team and one environment is a script. A role that any team can pull from Galaxy, override with their own variable values, and deploy to any environment without touching a line of task code — that's reusable infrastructure.
Most tutorials show you how to initialize a role and move on. What they skip is the operational detail that determines whether a role becomes an asset or a liability six months after it's written. The defaults-versus-vars distinction trips up engineers who understand the concept but haven't felt the pain of getting it wrong in production. God roles are written by people who know roles exist but haven't internalized why the single-responsibility principle applies to infrastructure automation as much as it does to application code. And role dependency chains in meta/main.yml can fail in ways that produce no errors and leave your fleet silently misconfigured.
By the end of this article you'll know how to structure roles that teams outside your own can actually use, how to test them with Molecule so regressions surface in CI rather than production, why the variable precedence hierarchy determines whether your role is overridable or effectively hardcoded, and how to recognize the God role pattern early enough to fix it before it becomes entrenched technical debt.
Why Empty defaults/main.yml Breaks Role Reuse
Ansible roles are self-contained units of automation that package tasks, variables, files, and templates into a reusable structure. The core mechanic is variable precedence: defaults/main.yml defines the lowest-priority variables, intended to be overridden by playbook vars, inventory vars, or command-line extra vars. When defaults/main.yml is empty, the role loses its default contract — any variable the role expects must be supplied externally, or the role fails. This turns a reusable component into a fragile, context-dependent script.
In practice, a well-designed role declares every tunable parameter in defaults/main.yml with a sensible default. This allows the role to run standalone with no external variable injection. It also enables role idempotency: running the role with the same defaults produces the same result. Teams that skip this step create hidden dependencies — variables assumed to exist from inventory or group_vars — which break when the role is reused in a different project or CI pipeline.
Use this practice for any role that will be shared across playbooks, teams, or repositories. The cost is minimal (a few lines of YAML), but the payoff is dramatic: roles become testable in isolation, composable in larger workflows, and safe to version and distribute. An empty defaults/main.yml is the single most common smell that a role is not yet production-ready.
--- # Empty defaults/main.yml – no variables defined # This prevents role reuse because other roles or playbooks # cannot override any default values. # Instead, always define at least one variable, even if empty: # my_var: ""
The Architecture of a Role: Convention Over Configuration
Ansible Roles exist to move infrastructure automation from scripting to software engineering. The distinction matters operationally: a script works for its author in their environment. A role works for any team, in any environment, without modification to its internals — only variable overrides at the boundary.
The mechanism that makes this possible is convention over configuration. Every role follows the same directory structure. When Ansible calls a role, it knows exactly where to look for each type of content without being told: tasks/main.yml for the primary logic, handlers/main.yml for service restart definitions, defaults/main.yml for overridable variables, vars/main.yml for internal constants, templates/ for Jinja2 config files, files/ for static assets, and meta/main.yml for dependencies and Galaxy metadata. None of these require explicit loading in your tasks — Ansible finds and loads them automatically based on their location.
This predictability is the entire point. When a new engineer opens a role they've never seen before, they know immediately where the task logic lives, where the variables are defined, and where the templates are. That shared mental model is what allows roles to be shared across teams and organizations via Ansible Galaxy.
The structure isn't optional and it isn't decoration. Missing tasks/main.yml means the role does nothing and produces no error. A template referenced in tasks that doesn't exist in templates/ fails at runtime with a file not found error that points to a path that looks correct. The ansible-galaxy role init command generates the full structure in one command — use it every time rather than creating directories manually and risking missing one.
One aspect of the structure that teams often underuse: the tests/ directory. Ansible generates it but leaves it empty. This is where your Molecule configuration lives — the test scenarios that verify the role works with default variables, with non-default variables, and that it's idempotent on a second run. A role without tests in tests/ is a role that breaks silently and gets discovered in production.
#!/usr/bin/env bash # io.thecodeforge: Initialize a new role with the full standard structure # Always use ansible-galaxy role init — never create directories manually. # The tool generates every required directory and file, including stubs # for meta/main.yml with Galaxy metadata fields that must be present # for publishing to Galaxy or an internal Automation Hub. ansible-galaxy role init io.thecodeforge.webserver # Generated structure: # io.thecodeforge.webserver/ # ├── defaults/ # │ └── main.yml <- LOWEST precedence. Everything here is overridable. # │ Use for: ports, paths, package versions, feature flags. # │ If a value might differ between teams or environments, it goes here. # ├── files/ # │ <- Static assets. No variable substitution. # │ Use for: scripts, static certs, binary configs, SSH keys. # │ Loaded by: ansible.builtin.copy with src: filename.ext # ├── handlers/ # │ └── main.yml <- Service lifecycle tasks. Only run when notified. # │ Use for: reload, restart, enable. Never unconditional tasks. # ├── meta/ # │ └── main.yml <- Role dependencies, Galaxy metadata, supported platforms. # │ Dependencies here run before this role, automatically. # ├── tasks/ # │ └── main.yml <- Primary execution logic. Loaded first automatically. # │ Should contain NO hardcoded values — only variable references. # ├── templates/ # │ <- Jinja2 templates. Rendered at runtime with variable substitution. # │ Use for: nginx.conf, postgresql.conf, systemd unit files. # │ Loaded by: ansible.builtin.template with src: filename.j2 # ├── vars/ # │ └── main.yml <- HIGH precedence. Inventory and group_vars cannot override these. # │ Use for: internal package names, service names, OS-specific constants. # │ NOT for values users should change — use defaults/ for those. # └── tests/ # └── molecule/ <- Molecule test scenarios. Never leave this empty in production roles. # ├── default/ (tests with default variable values) # └── custom/ (tests with non-default values — catches hardcoding) # After init, immediately set up Molecule: cd io.thecodeforge.webserver molecule init scenario default --driver-name docker molecule init scenario custom --driver-name docker # Write converge.yml and verify.yml for both scenarios before writing a single task.
Production Patterns: Reusability, Composition, and the God Role Problem
The single most important design principle for Ansible roles is the same one that applies to microservices, library functions, and Unix commands: do one thing well. A role that installs and configures Nginx is useful to every team that runs Nginx. A role that installs Nginx, PostgreSQL, Redis, and a monitoring agent is useful to exactly one team — the team that chose that exact combination — and becomes a maintenance burden the moment any team's requirements diverge.
This is the God role problem. It emerges gradually. Someone writes a server_setup role that installs the web server and the database because both are needed on the first server they're automating. A few weeks later they add log rotation. A few weeks after that, monitoring. By the time the role has 600 lines across tasks/main.yml, it's impossible to use partially. A team that only needs the web server configuration must accept the database configuration too, or fork the role.
The fix is decomposition: one role per service, composed in the playbook. A playbook that calls roles: [common, nginx, postgresql, prometheus_node_exporter, log_rotation] is instantly readable. You know exactly what the role list configures. You can remove prometheus_node_exporter from the list for a server where you don't want monitoring. You can test each role independently with Molecule. You can update the nginx role without touching the postgresql role.
The second pattern that determines whether roles scale is the variable boundary. The calling playbook is where environment-specific values should live — not inside the role. A role's defaults/main.yml provides the fallback values that work for the most common case. The playbook's vars block or the inventory's group_vars override those defaults for specific environments. This separation means the role itself is environment-agnostic — it works for dev, staging, and production, with the differences expressed entirely in the calling context.
When a value is passed in the vars: block at role call time in a playbook, it has higher precedence than defaults/ but lower than host_vars. This is the right level for environment-specific overrides when you want the role to receive a value without the calling team having to set it in inventory. It's also where you declare which environment-specific values are expected — a well-documented vars block at the role call site is self-documenting infrastructure.
--- # io.thecodeforge: Production orchestration playbook # This file composes roles. It contains no task logic of its own. # Each role does one thing. This playbook decides which things to do and in what order. # # Variable precedence at role call time (from the vars: block): # Higher than: defaults/main.yml, group_vars, inventory vars # Lower than: host_vars, extra vars (-e) # Use the vars: block for environment-specific overrides you want visible in the playbook. # Use group_vars for overrides that should apply to all plays targeting that group. # ── Play 1: Load Balancer configuration ─────────────────────────────────────── - name: Configure Load Balancer Tier hosts: load_balancers become: true roles: # Common role runs first on every host — sets up SSH hardening, NTP, logging standards - role: io.thecodeforge.common vars: common_ntp_servers: - 169.254.169.123 # AWS time sync service — lower latency than pool.ntp.org common_ssh_allow_groups: ['deploy', 'sre'] # HAProxy role — focused exclusively on load balancer configuration - role: io.thecodeforge.haproxy vars: haproxy_max_connections: 10000 # Overrides defaults/main.yml value of 2000 haproxy_timeout_connect: '5s' haproxy_timeout_client: '30s' haproxy_timeout_server: '30s' haproxy_backend_servers: # Dynamically populated from inventory - { name: 'web01', addr: '{{ hostvars["web-01"]["ansible_host"] }}', port: 8080 } - { name: 'web02', addr: '{{ hostvars["web-02"]["ansible_host"] }}', port: 8080 } # TLS termination role — manages certificates and nginx-based TLS offloading - role: io.thecodeforge.tls_termination vars: tls_domain: 'api.thecodeforge.io' tls_cert_source: 'acme' # 'acme', 'vault', or 'file' tls_acme_email: 'ops@thecodeforge.io' # ── Play 2: Application Server configuration ────────────────────────────────── - name: Configure Application Server Tier hosts: web_servers become: true roles: - role: io.thecodeforge.common # Same role, same defaults — common runs identically on all tiers - role: io.thecodeforge.nginx vars: nginx_worker_processes: auto nginx_worker_connections: 4096 nginx_vhosts: - server_name: 'api.thecodeforge.io' listen_port: 8080 root: '/var/www/api' access_log: '/var/log/nginx/api_access.log' - role: io.thecodeforge.app_deploy vars: app_repo: 'https://github.com/thecodeforge/api.git' app_version: '{{ release_version | default("main") }}' app_user: 'www-data' app_env: 'production' # ── Play 3: Database configuration ──────────────────────────────────────────── - name: Configure Database Tier hosts: database_servers become: true serial: 1 # One database server at a time — never parallel for Postgres max_fail_percentage: 0 # Any database failure stops the entire play roles: - role: io.thecodeforge.common - role: io.thecodeforge.postgresql vars: postgres_version: 16 postgres_data_dir: '/data/pg_production' # Overrides default /var/lib/postgresql postgres_max_connections: 200 postgres_shared_buffers: '4GB' postgres_effective_cache_size: '12GB' # Passwords come from Vault — never hardcoded here postgres_app_password: '{{ vault_postgres_app_password }}' - role: io.thecodeforge.prometheus_node_exporter # No vars override — defaults work for all servers # Port 9100, /metrics endpoint, standard collectors
Testing Roles with Molecule — The Practice That Separates Good Roles from Great Ones
A role without tests is a role that breaks silently in production. You find out when a deployment fails, when a new engineer makes a change that looked harmless, or when a Galaxy role dependency updates and changes behavior. Molecule gives you a way to find out in CI instead.
Molecule is the standard testing framework for Ansible roles. It spins up disposable infrastructure — Docker containers for most roles, cloud instances for roles that need real hardware — runs your role against that infrastructure, verifies the resulting state with Testinfra assertions, runs the role a second time to verify idempotency, and then tears everything down. The entire cycle takes 2-5 minutes for a Docker-based test.
The most valuable test you can write is the idempotency check: run the role twice and assert that the second run shows zero 'changed' tasks. This is Molecule's default behavior — it runs the role, checks idempotency automatically, and fails if the second run shows any changes. If your role isn't idempotent, Molecule tells you which task is the problem.
The second most valuable test is the non-default variable scenario: create a Molecule scenario that sets every variable in defaults/main.yml to a non-default value and runs the role. If any task contains a hardcoded value instead of a variable reference, this test surfaces it. The production incident in this article would have been caught by this test on the first day the role was written.
For roles that will be shared via Galaxy or an internal Automation Hub, add platform-specific scenarios: test on Ubuntu 22.04 LTS, on Ubuntu 24.04, and on RHEL 9 if your organization runs Red Hat. Platform divergence in package names, service names, and file paths is a major source of 'works on my machine' failures in shared roles.
--- # io.thecodeforge: Molecule converge playbook — default scenario # This runs the role with default variable values. # Molecule automatically runs this twice and fails if second run shows 'changed'. - name: Converge — test nginx role with default variables hosts: all become: true roles: - role: io.thecodeforge.nginx # No vars: block here — testing that defaults/main.yml values work correctly --- # io.thecodeforge: Molecule verify playbook — Testinfra assertions # File: molecule/default/verify.yml # These assertions run after converge and confirm the role achieved its intended state. - name: Verify — confirm nginx role achieved correct state hosts: all gather_facts: false tasks: - name: Confirm Nginx package is installed ansible.builtin.package_facts: manager: apt - name: Assert Nginx is installed at the pinned version ansible.builtin.assert: that: - "'nginx' in ansible_facts.packages" fail_msg: "Nginx is not installed — role task failed silently" - name: Confirm Nginx service is running and enabled ansible.builtin.service_facts: - name: Assert Nginx service state ansible.builtin.assert: that: - "ansible_facts.services['nginx.service'].state == 'running'" - "ansible_facts.services['nginx.service'].status == 'enabled'" fail_msg: "Nginx is not running or not enabled — handler or service task failed" - name: Confirm Nginx is listening on the default port ansible.builtin.wait_for: port: "{{ nginx_port | default(80) }}" timeout: 5 msg: "Nginx is not listening on port {{ nginx_port | default(80) }}" - name: Verify Nginx config is valid ansible.builtin.command: nginx -t register: nginx_test changed_when: false failed_when: nginx_test.rc != 0 --- # io.thecodeforge: Molecule converge playbook — custom_paths scenario # File: molecule/custom_paths/converge.yml # This scenario runs the role with NON-DEFAULT variable values. # It catches hardcoded paths and values in tasks/main.yml. # If this scenario fails where default/ passes, a path is hardcoded. - name: Converge — test nginx role with non-default variable values hosts: all become: true roles: - role: io.thecodeforge.nginx vars: nginx_port: 8080 # Non-default: catches port hardcoding nginx_worker_processes: 2 # Non-default: catches proc count hardcoding nginx_log_dir: /var/log/nginx_custom # Non-default: catches path hardcoding nginx_config_dir: /etc/nginx_custom # Non-default: catches config path hardcoding # Every variable in defaults/main.yml should appear here with a non-default value. # If the role fails this scenario, find the hardcoded value and move it to defaults/. --- # io.thecodeforge: CI pipeline configuration for Molecule testing # File: .gitlab-ci.yml excerpt # This runs both Molecule scenarios on every merge request. # molecule_test: # stage: test # image: quay.io/ansible/community-ansible-dev-tools:latest # before_script: # - pip install molecule molecule-plugins[docker] ansible-lint # script: # - cd roles/io.thecodeforge.nginx # - ansible-lint . # Lint first — fast failure # - molecule test --scenario-name default # Default values + idempotency check # - molecule test --scenario-name custom_paths # Non-default values + hardcoding check # rules: # - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # artifacts: # when: on_failure # paths: # - roles/io.thecodeforge.nginx/.molecule/ # expire_in: 3 days
Why Your First Role Should Be a Directory, Not a Script
Competitor pages will walk you through drilling directories and creating files. They treat it like assembling IKEA furniture—step one, step two, done. That's how you get a pile of YAML that looks like a role but behaves like a tangled script.
The structure matters because Ansible's loader uses it as a contract. tasks/main.yml is the entry point. defaults/main.yml provides fallback variables. handlers/main.yml holds notifications. Respect the contract or watch your role silently skip a handler because you put it in handlers/nginx.yml instead of handlers/main.yml.
Here's the cold truth: a role is a namespace for automation logic. Every directory under roles/ is a boundary for variables, tasks, and templates. Break that boundary arbitrarily and you're back to monolithic playbooks with extra steps.
Start with this skeleton. Don't add files you don't need. A role with three files that works is better than one with twelve that doesn't.
// io.thecodeforge — devops tutorial // Minimal production role structure // roles/nginx/ // defaults/ // main.yml // tasks/ // main.yml // handlers/ // main.yml // templates/ // nginx.conf.j2 // vars/ // main.yml // meta/ // main.yml // // Example: roles/nginx/tasks/main.yml --- - name: Install nginx ansible.builtin.apt: name: nginx state: present become: true - name: Configure nginx ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf owner: root group: root mode: '0644' notify: Restart nginx - name: Ensure nginx is running ansible.builtin.systemd: name: nginx state: started enabled: true become: true
tasks/main.yml with 80 lines is fine. tasks/install.yml, tasks/configure.yml, tasks/restart.yml with 10 lines each is premature abstraction that makes debugging harder.Managing Dependencies: meta/main.yml Is Your Dependency Lock File
Competitors mention that roles can depend on other roles. They do not tell you that unmanaged dependencies are the #1 cause of 'works on my machine' failures. A role that installs PostgreSQL should not guess whether geerlingguy.postgresql is installed.
meta/main.yml is your dependency manifest. Declare every role you rely on, with a version constraint. This is not optional—it's how you stop your automation from breaking when someone updates a community role.
Here's the rule: if you call import_role or include_role and the target role isn't in meta/main.yml, you've introduced a hidden dependency. Production playbooks will fail silently or, worse, run with the wrong version.
Use ansible-galaxy install -r roles/requirements.yml to freeze versions. Pin to a tag or commit hash, not *. Your future self will thank you when the upstream role removes a variable you relied on.
// io.thecodeforge — devops tutorial
// roles/postgresql/meta/main.yml
---
dependencies:
- role: geerlingguy.postgresql
version: 3.5.0
- role: nginx
- role: monitoring
// roles/requirements.yml (project-level)
---
roles:
- name: geerlingguy.postgresql
src: https://github.com/geerlingguy/ansible-role-postgresql.git
version: 3.5.0
- name: nginx
src: git+https://git.internal.company.io/ansible-roles/nginx.git
version: v2.1.0
// Install command:
// ansible-galaxy install -r roles/requirements.yml -p roles/version: 3.5.0 not version: '*'. If a role doesn't have tags, mirror it to your internal git server and pin to your own tag. You control the lock file.meta/main.yml like a lock file, always versioned.The Role That Couldn't Be Reused
- If a value could possibly differ between environments, teams, or PostgreSQL versions, it belongs in defaults/main.yml — not as a literal string in tasks/main.yml. When in doubt, make it a variable.
- A role with zero entries in defaults/main.yml is almost certainly hiding hardcoded site-specific assumptions somewhere in its tasks. Treat an empty defaults/main.yml as a code smell during role review.
- A reusable role has no hardcoded site-specific values anywhere in its tasks. Everything that varies belongs in defaults/ with a sensible value that works for the most common case.
- Test roles with non-default variable values using a dedicated Molecule scenario. If making the test pass requires editing tasks/ rather than just setting different variables, the role isn't reusable yet.
ansible-inventory -i inventory.ini --host $HOST --vars | jq '.'ansible -m debug -a 'var=postgres_data_dir' -i inventory.ini $HOSTgrep -A 10 'dependencies:' roles/role_name/meta/main.ymlansible-galaxy role list --roles-path ./rolesansible-playbook playbook.yml --check -v | grep -B5 'undefined variable'grep -rn 'variable_name' roles/role_name/ --include='*.yml'ansible-playbook playbook.yml --check --diff > /tmp/role_diff.txtgrep -B 3 -A 15 'changed:' /tmp/role_diff.txtls -la roles/role_name/{files,templates}/grep -n 'src:' roles/role_name/tasks/main.yml| Aspect | Single Playbook | Ansible Roles |
|---|---|---|
| Appropriate scale | 1-5 tasks on a single host group, one-off operations, scripts you run once. A single playbook is the right tool for small, focused, non-repeating automation. | Multi-tier infrastructure, automation shared across teams, anything that runs on a schedule or in CI/CD. Roles pay for their structure the moment a second team needs the same automation. |
| Reusability | None — reusing a playbook requires copy-pasting blocks of YAML and maintaining multiple copies. Any bug fix must be applied to every copy manually. | First-class — roles are versioned units with defined interfaces (defaults/). Published to Galaxy or internal Automation Hub. Bug fixes propagate to all consumers via requirements.yml version bumps. |
| Variable management | Global namespace — all variables are visible to all tasks. Name collisions between sections are invisible until they cause wrong behavior at runtime. | Structured and separated — defaults/ for overridable config, vars/ for internal constants, clear precedence hierarchy. Prefix variables with role name to prevent global namespace collisions. |
| Testing | Manual — run the playbook in staging and verify by hand. Regressions are caught by the next human who notices something is wrong. | Automated with Molecule — idempotency check, non-default variable scenario, platform scenarios. Regressions are caught in CI on the merge request. |
| Team collaboration | Difficult at scale — multiple people editing one file creates merge conflicts and unclear ownership. Who is responsible for which section? | Parallel ownership — each role has a clear owner and a clear boundary. The nginx team owns the nginx role. The postgres team owns the postgres role. Changes don't conflict. |
| Maintenance over time | Degrades — a 600-line playbook becomes impossible to read or modify without risk. Engineers avoid changing it, leading to workarounds layered on top of workarounds. | Stable — each role stays focused on one service. A postgres role doesn't grow because someone added monitoring. Roles evolve independently at their own pace. |
| File | Command / Code | Purpose |
|---|---|---|
| io | ansible-galaxy role init io.thecodeforge.webserver | The Architecture of a Role |
| io | - name: Configure Load Balancer Tier | Production Patterns |
| io | - name: Converge — test nginx role with default variables | Testing Roles with Molecule |
| ProductionSkeleton.yml | - name: Install nginx | Why Your First Role Should Be a Directory, Not a Script |
| DependencyManagement.yml | dependencies: | Managing Dependencies |
Key takeaways
Common mistakes to avoid
6 patternsCreating God Roles that configure multiple unrelated services
Hardcoding environment-specific values in tasks/main.yml instead of defaults/main.yml
Confusing defaults/ with vars/ and wondering why inventory overrides have no effect
Missing role dependencies in meta/main.yml and relying on playbook ordering
Not namespacing role variables — using generic names that collide across roles
Skipping Molecule tests because 'the role is simple' or 'it works in staging'
Interview Questions on This Topic
Describe the Ansible variable precedence hierarchy. If a variable is defined in both defaults/main.yml and vars/main.yml within a role, which one wins? What about group_vars?
What is the specific use case for meta/main.yml in an Ansible Role? Provide a real example including a conditional dependency.
yaml
dependencies:
- role: io.thecodeforge.common
vars:
common_ntp_servers:
- 169.254.169.123
- role: io.thecodeforge.firewall
vars:
firewall_allow_ports: [5432]
when: ansible_os_family == 'RedHat'
# firewall managed differently on Debian — ufw is handled by common role
``
The operational risk to know: circular dependencies are detected and silently broken. Ansible stops the cycle without error and continues. This means a circular dependency can silently omit required configuration. Test dependency graphs with ansible-playbook -vvv to see the resolution order. Avoid circular dependencies entirely — they're always a sign the role boundaries are wrong.How does import_role differ from include_role? Describe a production bug caused by choosing the wrong one.
Explain the DRY principle in the context of Ansible. How do roles facilitate it better than include_tasks?
How would you design a CI/CD pipeline to test an Ansible role independently? Walk through the Molecule configuration and what each stage verifies.
yaml
ansible_role_test:
stage: test
image: quay.io/ansible/community-ansible-dev-tools:latest
script:
- ansible-lint roles/io.thecodeforge.postgresql/
- molecule test --scenario-name default
- molecule test --scenario-name custom_paths
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
artifacts:
when: on_failure
paths: ['.molecule/']
expire_in: 3 days
``
No role change merges without all four stages green. This is the hard gate.When would you use vars_prompt in a playbook instead of defining variables in a role's defaults directory?
Frequently Asked Questions
defaults/main.yml has the lowest variable precedence in all of Ansible. Every other variable source — inventory file variables, group_vars, host_vars, playbook vars block, extra vars (-e) — overrides it. This makes it the right place for values you expect callers to customize: ports, file paths, package versions, feature flags, usernames. vars/main.yml has much higher precedence — only host_vars and -e can override it. Inventory group_vars cannot. This makes vars/ appropriate only for internal role constants that must not change: OS-specific package names, internal service identifiers, fixed file permissions. Putting overridable values in vars/ is the most common reason roles are inflexible — operators set the value in group_vars and nothing happens because vars/ is silently winning.
Dependencies are declared in meta/main.yml under the dependencies key. When a playbook calls a role, Ansible reads its meta/main.yml, resolves all dependencies, and runs them before the dependent role — automatically, without any explicit task in the playbook. Dependencies are deduplicated across the entire playbook: if multiple roles depend on the same role, it runs once. For circular dependencies (Role A depends on Role B, Role B depends on Role A), Ansible detects the cycle and breaks it silently by skipping one dependency. No error is produced, no warning appears — required configuration may simply not run. Test dependency graphs with ansible-playbook -vvv to see the resolution order. Avoid circular dependencies entirely by redesigning the role boundaries.
Three patterns, in order of organizational maturity: First, requirements.yml with Git source — list roles by Git URL and tag in requirements.yml, run ansible-galaxy install -r requirements.yml in CI before running playbooks. Each project pins the version it needs. Second, private Automation Hub or Pulp — an internal Galaxy server where teams publish versioned roles. Callers install via ansible-galaxy with the internal server URL. Provides access control, download metrics, and deprecation management. Third, public Ansible Galaxy — appropriate for generic infrastructure roles (common OS hardening, standard monitoring agents) that have no proprietary configuration. The non-negotiable practice across all three: version every role with Git tags and pin specific versions in requirements.yml. Never reference a Git branch or 'latest' — a breaking change in the role will silently break every consumer on the next CI run.
A God role is a single Ansible role that configures multiple unrelated services — a server_setup role that installs Docker, PostgreSQL, Nginx, and a monitoring agent in one role. The problems compound over time: teams that need only PostgreSQL must accept Docker and Nginx anyway. Testing requires a full-stack environment. A change to the Nginx section risks breaking the PostgreSQL section. New engineers are afraid to modify it. Upstream consumers can't use partial functionality. The role accumulates when: conditions to skip sections that don't apply to certain callers — which is the signal that it's really multiple roles pretending to be one. The fix is always decomposition: one role per service, composed in the playbook. The playbook becomes readable documentation of which services a host runs. Each role becomes independently testable, independently versioned, and independently useful.
Molecule runs the role twice by default. The first run (converge) applies the role and verifies state with assertions in verify.yml. The second run (idempotency check) runs the exact same role again and fails the test if any task reports 'changed'. A fully idempotent role shows zero 'changed' tasks on the second run. If any task reports 'changed' on the second run, Molecule prints the task name and fails the CI job before the role reaches any environment. The most common causes of idempotency failures that Molecule catches: Jinja2 templates containing timestamps or dynamic values that differ between renders, shell module tasks running unconditionally, and file tasks that don't properly check existing content. This automated check catches idempotency bugs at code review time rather than weeks later when a production cron job starts reporting unexpected changes.
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
That's Ansible. Mark it forged?
6 min read · try the examples if you haven't