Ansible Project Structure

How a real Ansible infrastructure repo is laid out — where every file goes and why.

Production repo layout

infra-repo/ ├── ansible.cfg # Ansible runtime configuration ├── site.yml # Main entrypoint — applies all roles ├── requirements.yml # External role/collection dependencies ├── .gitlab-ci.yml # CI/CD pipeline ├── inventories/ │ ├── production/ │ │ ├── hosts.ini # Production host list and groups │ │ ├── group_vars/ │ │ │ ├── all.yml # Variables for every host │ │ │ ├── webservers.yml # Variables for [webservers] group │ │ │ ├── mailservers.yml # Variables for [mailservers] group │ │ │ └── dbservers.yml │ │ └── host_vars/ │ │ ├── web01.yml # Variables only for web01 │ │ └── mail01.yml │ └── staging/ │ ├── hosts.ini │ └── group_vars/ │ └── all.yml # Staging-specific overrides ├── playbooks/ │ ├── deploy-web.yml # Deploy the web stack │ ├── deploy-mail.yml # Deploy the mail stack │ └── update-packages.yml # Rolling package update └── roles/ ├── nginx/ ├── postfix/ ├── dovecot/ ├── chrony/ └── common/ # Base role applied to all hosts

ansible.cfg

The Ansible configuration file. It sets defaults so you do not have to repeat flags on every command.

[defaults]
# Where to look for inventory by default
inventory = inventories/production/hosts.ini

# Where roles are found
roles_path = roles

# Remote user for SSH connections
remote_user = ansible

# Disable host key checking (use carefully)
host_key_checking = False

# How many hosts to run tasks on simultaneously
forks = 10

# Retry files clutter the repo — disable
retry_files_enabled = False

[privilege_escalation]
become = True
become_method = sudo
become_user = root

Ansible reads ansible.cfg from the current directory first, then ~/.ansible.cfg, then /etc/ansible/ansible.cfg. Keep a project-level ansible.cfg so the repo is self-contained.

site.yml

The main playbook that ties everything together. Running ansible-playbook site.yml should apply the full desired state to all hosts.

---
# site.yml — full infrastructure deployment

- name: Apply common baseline to all hosts
  hosts: all
  roles:
    - role: common

- name: Deploy web servers
  hosts: webservers
  roles:
    - role: nginx
    - role: chrony

- name: Deploy mail servers
  hosts: mailservers
  roles:
    - role: postfix
    - role: dovecot
    - role: chrony

- name: Deploy log servers
  hosts: logservers
  roles:
    - role: rsyslog_server
Keep site.yml readable. It should read like a table of contents — what roles go on which groups. The actual implementation detail lives in the roles. If site.yml is getting complex, split into separate playbooks in playbooks/.

inventories/ — multiple environments

A separate inventory directory for each environment. This lets you use the same roles and playbooks against different hosts with different variable values.

# inventories/production/hosts.ini

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

[mailservers]
mail01.example.com

[logservers]
log01.example.com

[all:vars]
# Variables here are lowest priority — prefer group_vars files
ansible_python_interpreter=/usr/bin/python3

Run against a specific environment by pointing to its inventory:

# Default (uses ansible.cfg setting)
ansible-playbook site.yml

# Staging environment explicitly
ansible-playbook -i inventories/staging/hosts.ini site.yml

# Production with a limit
ansible-playbook site.yml --limit web01.example.com

group_vars/ and host_vars/

Variable files that Ansible loads automatically based on what group or host it is running against. These live inside the inventory directory, not in the repo root.

# inventories/production/group_vars/all.yml
# Applies to EVERY host in this inventory

---
# NTP servers used by all hosts
chrony_servers:
  - ntp1.example.com
  - ntp2.example.com

# All hosts forward logs here
rsyslog_forward_server: log01.example.com
# inventories/production/group_vars/webservers.yml
# Applies only to hosts in the [webservers] group

---
nginx_worker_processes: auto
nginx_client_max_body_size: 64m
enable_tls: true
# inventories/production/host_vars/web01.yml
# Applies only to web01

---
nginx_server_name: web01.example.com
nginx_port: 8443

The load order is: all.ymlgroupname.ymlhostname.yml. More specific files override less specific ones. The same variable defined in webservers.yml overrides its value in all.yml.

playbooks/

Focused playbooks for specific operations. Unlike site.yml which deploys everything, these are for targeted actions:

---
# playbooks/update-packages.yml
# Rolling package update with serial to avoid taking down all hosts at once

- name: Update all packages
  hosts: all
  serial: 1            # update one host at a time
  become: true
  tasks:
    - name: Update all packages
      ansible.builtin.package:
        name: '*'
        state: latest
      notify: Reboot if kernel updated

  handlers:
    - name: Reboot if kernel updated
      ansible.builtin.reboot:
        reboot_timeout: 300

roles/

Each subdirectory is one role. The role name matches the directory name, which matches what you reference in playbooks.

roles/
├── common/         # Referenced as role: common
├── nginx/          # Referenced as role: nginx
├── postfix/        # Referenced as role: postfix
├── chrony/         # Referenced as role: chrony
└── rsyslog_server/ # Referenced as role: rsyslog_server

Ansible searches the roles/ directory (and the path in ansible.cfg) when resolving role names. You can also set roles_path = roles:~/.ansible/roles to search multiple locations.

requirements.yml

Declares external roles or collections your project depends on. Download them with ansible-galaxy install -r requirements.yml.

---
# requirements.yml

roles:
  - name: geerlingguy.docker
    version: "6.1.0"

collections:
  - name: community.general
    version: ">=7.0.0"
  - name: ansible.posix
    version: ">=1.5.0"

Pin versions in production repos. An unpinned geerlingguy.docker might update and break your deployment.

When you join a team and are handed an unfamiliar Ansible repo, work through it in this order:

  1. Read README.md — conventions, how to run, environment notes
  2. Read ansible.cfg — which inventory is default, any unusual settings
  3. Read site.yml — which roles run on which hosts
  4. Look at inventories/production/hosts.ini — what groups exist
  5. Check group_vars/all.yml — baseline variables for every host
  6. Browse roles/ — skim the defaults/main.yml of each role to understand what it configures

Finding what controls a specific setting

Someone says: "the NTP server pointing to the wrong host, can you fix it?" Here is how to trace it:

  1. Search for the variable name: grep -r "chrony_servers" inventories/ roles/
  2. Find where it is defined — likely in inventories/production/group_vars/all.yml
  3. Check if any group or host var overrides it for the affected host
  4. Find the template that uses it: grep -r "chrony_servers" roles/chrony/templates/
  5. Verify what the rendered config looks like on the host: cat /etc/chrony.conf
# Search for a variable across the whole repo
grep -r "chrony_servers" .

# Search only in inventory variables
grep -r "chrony_servers" inventories/

# Search only in role templates
grep -r "chrony_servers" roles/