From zero to a reusable Chrony role
Ansible is an automation tool for describing the desired state of systems as code and applying that state repeatedly. This article builds a small Chrony lab step by step: installation, inventory, first connection, mini playbook, role, variables, template, handler and execution.
Chrony is a good learning example: the purpose is easy to understand, the service exists across many Linux distributions, and it naturally includes package installation, service management, configuration files, templates, defaults, facts, conditions, handlers and inventory overrides. The real local ForgeOne chronyd role was used as a practical source. For this article, it was turned into a neutral teaching version: no internal NTP servers, no customer hosts, no internal repository URLs and no secrets.
What is Ansible?
Ansible runs on a Control Node. That is the machine where you execute ansible or ansible-playbook. From there, Ansible connects to Managed Nodes via SSH. Managed Nodes are the target systems being configured. Usually no agent is installed on the targets; Ansible uses SSH and executes modules. Many Linux modules need a usable Python environment on the Managed Node.
CONTROL NODE|| SSHvMANAGED NODE
- Inventory: describes where automation runs, meaning hosts and groups.
- Playbook: describes what should happen and on which hosts.
- Task: one step in a playbook.
- Module: the technical unit of work, for example package, template or systemd_service.
- Role: reusable structure of tasks, defaults, templates, handlers and metadata.
- Collection: package format for modules, roles, plugins and documentation.
- FQCN: Fully Qualified Collection Name, for example ansible.builtin.package.
During a run such as ansible-playbook playbooks/site.yml, Ansible roughly reads configuration, loads inventory, determines hosts and groups, combines variables, opens SSH connections, gathers facts, evaluates the playbook, loads roles, executes tasks, runs modules on managed nodes, triggers handlers when changes occur and prints a result. Understanding that flow matters more than seeing a lot of YAML immediately.
Install Ansible
Choose exactly one installation method for your control node. Distribution packages are simple for first tests. pipx is useful for admin workstations because Ansible is isolated in the user context. A Python venv is useful for project repositories and CI because versions are explicit.
Debian and Ubuntu
Debian and Ubuntu use APT. Run this block on the control node when your workstation or admin VM uses Debian or Ubuntu.
# Debian / Ubuntu: Install Ansiblesudo apt updatesudo apt install ansible# Checkansible --version
Red Hat, Rocky Linux and AlmaLinux
RHEL-compatible systems use DNF. Run this block on the control node when your workstation uses Red Hat Enterprise Linux, Rocky Linux or AlmaLinux.
# Red Hat / Rocky Linux / AlmaLinux: Install Ansible Coresudo dnf install ansible-core# Optional, if the community package is available in enabled repositoriessudo dnf install ansible# Checkansible --version
SUSE Linux Enterprise and openSUSE
SUSE systems use zypper. Run this block on the control node when your workstation uses SUSE Linux Enterprise or openSUSE.
# SUSE Linux Enterprise / openSUSE: Install Ansiblesudo zypper refreshsudo zypper install ansible# Checkansible --version
pipx and Python venv
pipx installs Ansible isolated in the user context. This is useful when the operating system ships conservative package versions but you do not want to activate a project-local virtual environment.
# Install pipx and install Ansible isolated in the user contextpython3 -m pip install --user pipxpython3 -m pipx ensurepathpipx install --include-deps ansible# Open a new shell or reload PATH, then checkansible --versionansible-community --version
A project-local venv is useful when the repository, CI and team should use the same Ansible version. Run this block inside the project directory.
# Project-local Python venvpython3 -m venv .venv. .venv/bin/activatepython -m pip install --upgrade pippython -m pip install ansible ansible-lint# Checkansible --version
Build the lab
Now create a small test project. The initial structure is intentionally minimal. It grows with every step in the article.
mkdir -p ansible-chrony-labcd ansible-chrony-labmkdir -p inventories/lab/group_vars inventories/lab/host_vars playbooks roles
Create ansible.cfg next. This file makes project behavior reproducible: Ansible knows which inventory and role path to use.
[defaults]inventory = inventories/lab/hosts.ymlroles_path = rolesinterpreter_python = auto_silent[ssh_connection]pipelining = Truetimeout = 30
ansible-config viewansible-config dump --only-changed
Create inventory
Inventory primarily says: where does automation run? The playbook and role say: what should happen? For the lab, we use two example hosts in a group named linux. Replace hostnames and SSH user with your real targets.
all:children:linux:hosts:node01.example.org:ansible_user: adminnode02.example.org:ansible_user: admin
all is the root of the inventory. children contains groups. linux is a group in this example. hosts contains the concrete managed nodes. ungrouped is the automatic group for hosts that are not assigned to another group. Parent and child groups organize groups hierarchically, but that is not classical object-oriented inheritance.
Test the first connection
Before configuring Chrony, test connectivity. The Ansible ping module is not ICMP ping. It mainly tests SSH, Python and whether Ansible can execute a module on the managed node.
ansible all -m ansible.builtin.ping
If this fails, check DNS/hostname, SSH key, user, sudo permissions and Python on the target first. Only continue with the playbook when this step works.
First mini playbook
The first playbook does one thing: it ensures the chrony package is installed. This teaches play, hosts, become, tasks, name, module parameters and state without role structure.
---- name: Install Chrony for first contact with Ansiblehosts: linuxbecome: truetasks:- name: Install Chrony packageansible.builtin.package:name: chronystate: present
Run the playbook in check mode first, then for real and then a second time. The first real run may report changed. The second run should report ok for the package task when the package is already installed. This is idempotence: Ansible describes state, not just commands.
ansible-playbook playbooks/mini.yml --check --diffansible-playbook playbooks/mini.ymlansible-playbook playbooks/mini.yml
Why this becomes a role
The mini playbook is not enough once Debian uses different paths than Red Hat, SUSE uses a different service name, time sources should be configurable, configuration changes need a service restart or the same logic should be reused in ten projects. That is why we turn it into a role.
A role structure is not a ForgeOne-specific convention. ansible-galaxy creates a standard structure that you can simplify deliberately afterwards.
ansible-galaxy role init --init-path roles chrony
roles/chrony/├── defaults/├── files/├── handlers/├── meta/├── tasks/├── templates/├── tests/└── vars/
Our teaching role needs defaults, tasks, handlers, templates, meta and README. files stays empty because configuration is rendered from variables. vars stays empty because user options should not live in vars: role vars have much higher precedence than defaults and are harder to override. The next complete files live in the lab under roles/chrony/defaults/main.yml, roles/chrony/tasks/main.yml, roles/chrony/tasks/Debian.yml, roles/chrony/tasks/RedHat.yml, roles/chrony/tasks/Suse.yml, roles/chrony/templates/chrony.conf.j2 and roles/chrony/handlers/main.yml.
Chrony role step by step
defaults/main.yml is the public configurable interface of the role. Users can override these values through group_vars, host_vars or other sources. Package names, service names, paths, server list and service state belong here.
---chrony_package_name_map:Debian: chronyRedHat: chronySuse: chronychrony_service_name_map:Debian: chronyRedHat: chronydSuse: chronydchrony_config_path_map:Debian: /etc/chrony/chrony.confRedHat: /etc/chrony.confSuse: /etc/chrony.confchrony_package_state: presentchrony_service_enabled: truechrony_service_state: startedchrony_servers:- address: 0.pool.ntp.orgoptions: iburst- address: 1.pool.ntp.orgoptions: iburstchrony_driftfile: /var/lib/chrony/driftchrony_makestep: "1.0 3"chrony_rtcsync: truechrony_logdir: /var/log/chrony
tasks/main.yml contains the logic. First it computes OS-specific values from ansible_facts["os_family"]. Then it fails cleanly for unsupported operating system families, includes the matching OS file, renders the template and enables/starts the service.
---- name: Resolve OS-specific Chrony valuesansible.builtin.set_fact:chrony_package_name: "{{ chrony_package_name_map.get(ansible_facts['os_family']) }}"chrony_service_name: "{{ chrony_service_name_map.get(ansible_facts['os_family']) }}"chrony_config_path: "{{ chrony_config_path_map.get(ansible_facts['os_family']) }}"changed_when: false- name: Stop when the operating system family is unsupportedansible.builtin.fail:msg: "Unsupported operating system family: {{ ansible_facts['os_family'] }}"when: chrony_package_name is not defined or chrony_package_name is none- name: Run OS-specific Chrony tasksansible.builtin.include_tasks: "{{ ansible_facts['os_family'] }}.yml"- name: Render Chrony configurationansible.builtin.template:src: chrony.conf.j2dest: "{{ chrony_config_path }}"owner: rootgroup: rootmode: "0644"notify: Restart chrony- name: Ensure Chrony service stateansible.builtin.systemd_service:name: "{{ chrony_service_name }}"enabled: "{{ chrony_service_enabled }}"state: "{{ chrony_service_state }}"
How does Ansible know os_family? Through gather_facts. For Debian/Ubuntu the value is typically Debian, for RHEL-compatible systems RedHat, and for SUSE Suse. You can inspect that with the setup module.
ansible all -m ansible.builtin.setup -a 'filter=ansible_os_family'ansible all -m ansible.builtin.setup -a 'filter=ansible_distribution'
include_tasks is used here because the file is chosen at runtime from facts. import_tasks is static and processed during parsing. For this learning role, include_tasks makes the relation to gathered facts visible.
tasks/Debian.yml
---- name: Install Chrony on Debian familyansible.builtin.package:name: "{{ chrony_package_name }}"state: "{{ chrony_package_state }}"
tasks/RedHat.yml
---- name: Install Chrony on Red Hat familyansible.builtin.package:name: "{{ chrony_package_name }}"state: "{{ chrony_package_state }}"
tasks/Suse.yml
---- name: Install Chrony on SUSE familyansible.builtin.package:name: "{{ chrony_package_name }}"state: "{{ chrony_package_state }}"
The template generates Chrony configuration from variables. {{ variable }} inserts values, for iterates over chrony_servers, and if renders optional directives. template is correct here because the file is built from variables. copy would be correct for an unchanged static file.
# Ansible managed{% for server in chrony_servers %}server {{ server.address }} {{ server.options | default('iburst') }}{% endfor %}driftfile {{ chrony_driftfile }}{% if chrony_makestep | length > 0 %}makestep {{ chrony_makestep }}{% endif %}{% if chrony_rtcsync | bool %}rtcsync{% endif %}logdir {{ chrony_logdir }}
The handler restarts Chrony only when the template actually changed. notify marks the change in the task, and the handler runs once at the end. This avoids unnecessary restarts during a run.
---- name: Restart chronyansible.builtin.systemd_service:name: "{{ chrony_service_name }}"state: restarted
Run the playbook with the role
Now the playbook becomes small and readable. Reusable logic lives in the role; the playbook only selects target group, privileges and role.
---- name: Configure time synchronizationhosts: linuxbecome: truegather_facts: trueroles:- role: chrony
The linux group comes from the inventory. become: true means tasks run with elevated privileges, usually through sudo. gather_facts: true is important because the role uses os_family.
Understand variables and precedence
Precedence now comes from a real problem: the role has defaults, the lab overrides time sources for all hosts, and node01 gets a host-specific exception.
inventories/lab/group_vars/all.yml
chrony_servers:- address: 0.pool.ntp.orgoptions: iburst- address: 1.pool.ntp.orgoptions: iburstchrony_makestep: "1.0 3"chrony_rtcsync: true
inventories/lab/host_vars/node01.example.org.yml
chrony_servers:- address: time.cloudflare.comoptions: iburst
node02 uses the servers from group_vars/all.yml. node01 uses the servers from host_vars/node01.example.org.yml because host_vars are more specific in this practical case. Technically, this is not classical object-oriented inheritance. Ansible combines variables from several sources according to defined precedence rules. The inventory hierarchy all -> parent group -> child group -> host is only one part of that.
Source | Example | Role in the example------------------------------ | ------------------------ | -----------------------------Role defaults | chrony_servers: pool A | Role defaultgroup_vars/all | chrony_servers: pool B | Lab-wide configurationhost_vars/node01 | chrony_servers: source C | Exception for one hostplay vars | vars: ... | Value directly in the playrole vars | roles/chrony/vars | internal strong role valuesset_fact | chrony_service_name | Runtime value from factsrole params | role: chrony, vars: ... | Explicit role callextra vars | -e key=value | Strongest deliberate override
This table is practical orientation. The complete list is more detailed and includes additional sources and special cases. See the official Ansible variable precedence documentation for details.
Deploy and verify the result
The project is now executable. Check syntax first, then dry run, then a single host and only then the whole group.
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --syntax-checkansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --check --diffansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --limit node01.example.org --diffansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml
Do not stop at PLAY RECAP. Verify on a managed node that the service is running and that Chrony sees sources and tracking data. The service is called chrony or chronyd depending on the distribution, so the systemctl command includes both variants.
ssh admin@node01.example.org 'systemctl status chrony || systemctl status chronyd'ssh admin@node01.example.org 'chronyc sources'ssh admin@node01.example.org 'chronyc tracking'
Run the role again afterwards. If everything is already correct, changed should ideally be 0. If changed keeps increasing, look for non-idempotent tasks or templates that create different content on every run.
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --diff
The complete example structure
All files from the lab are included here completely. There is no download gate and no login. Copy each file into the path shown.
ansible-chrony-lab/├── ansible.cfg├── requirements.yml├── inventories/│ └── lab/│ ├── hosts.yml│ ├── group_vars/│ │ └── all.yml│ └── host_vars/│ └── node01.example.org.yml├── playbooks/│ ├── mini.yml│ └── site.yml└── roles/└── chrony/├── defaults/│ └── main.yml├── handlers/│ └── main.yml├── meta/│ └── main.yml├── tasks/│ ├── Debian.yml│ ├── main.yml│ ├── RedHat.yml│ └── Suse.yml├── templates/│ └── chrony.conf.j2└── README.md
requirements.yml
---collections: []roles: []
This example does not need an external collection. All modules used here come from ansible.builtin. In real projects, external collections and roles can be versioned in requirements.yml.
Collections, roles and Git submodules
A role is reusable configuration logic. A collection is a package format for roles, modules, plugins, playbooks and documentation. FQCNs such as ansible.builtin.package show which collection a module comes from.
Git submodules are different: they are a Git and repository strategy. ForgeOne uses separately versioned role repositories in some projects and includes them via submodule. This is not an Ansible requirement and not a universal best-practice mandate; it is an option for teams that want to version roles independently from inventories and playbooks.
git submodule add git@example.org:platform/ansible-roles.git roles/sharedgit clone --recurse-submodules git@example.org:platform/ansible-project.gitgit submodule update --init --recursivegit submodule status
Alternatives include standalone roles through requirements.yml, collections, private Automation Hub or Galaxy-like repositories. The right choice depends on team size, release process, reuse and governance.
Best practices from the example
- defaults are the public interface of the role.
- group_vars describe shared values for an environment or group.
- host_vars remain real exceptions.
- Handlers avoid unnecessary restarts.
- Templates are correct when files are generated from variables.
- FQCN reduces name conflicts and shows origin.
- Modules are preferable to shell commands when a fitting module exists.
- Git makes changes reviewable.
- Dependency pinning makes builds more reproducible.
- --check, --diff and --limit reduce risk before production runs.
Common beginner mistakes are: putting everything into site.yml, hard-coding values in tasks, using shell instead of modules, storing secrets in inventory, putting user options into vars instead of defaults, overriding everything with extra-vars, binding roles to individual hosts, not versioning dependencies, running directly against production without --limit and --check.
Molecule is useful for role testing, but it is not required for this first article. The local ForgeOne Chrony role has a minimal Molecule structure with syntax testing. Full Molecule tests deserve their own article.
AWX and AAP
For this lab, ansible-playbook is enough. When multiple teams, credentials, RBAC, inventories, job templates, scheduling, workflows, audit, API and execution environments matter, AWX or Red Hat Ansible Automation Platform become relevant. Installing AWX is outside the scope of this fundamentals article.
Conceptually, this fits into larger Automation & GitOps architectures: OpenTofu can describe infrastructure while Ansible configures systems, services and operational workflows.
Conclusion
We started from zero: installed Ansible, built an inventory, checked SSH/Python with ansible.builtin.ping, installed Chrony first with a mini playbook and then developed a reusable role. The role uses defaults, facts, OS-specific includes, templates, handlers and inventory overrides. After that, we covered execution, verification, idempotence, collections, roles and Git submodules.
Professional support
Anyone who wants to rebuild the example has all files and steps in this article. If this should become a larger automation environment, central role library, CI/CD path or AWX/AAP platform, ForgeOne can support architecture, implementation, migration and operations.
Plan automation with ForgeOne
We help turn individual playbooks into a maintainable role library, CI/CD path or AWX/AAP platform.



