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.

markdown
CONTROL NODE
|
| SSH
v
MANAGED 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.

bash
# Debian / Ubuntu: Install Ansible
sudo apt update
sudo apt install ansible
# Check
ansible --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.

bash
# Red Hat / Rocky Linux / AlmaLinux: Install Ansible Core
sudo dnf install ansible-core
# Optional, if the community package is available in enabled repositories
sudo dnf install ansible
# Check
ansible --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.

bash
# SUSE Linux Enterprise / openSUSE: Install Ansible
sudo zypper refresh
sudo zypper install ansible
# Check
ansible --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.

bash
# Install pipx and install Ansible isolated in the user context
python3 -m pip install --user pipx
python3 -m pipx ensurepath
pipx install --include-deps ansible
# Open a new shell or reload PATH, then check
ansible --version
ansible-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.

bash
# Project-local Python venv
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install ansible ansible-lint
# Check
ansible --version

Build the lab

Now create a small test project. The initial structure is intentionally minimal. It grows with every step in the article.

bash
mkdir -p ansible-chrony-lab
cd ansible-chrony-lab
mkdir -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.

ini
[defaults]
inventory = inventories/lab/hosts.yml
roles_path = roles
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
timeout = 30
bash
ansible-config view
ansible-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.

yaml
all:
children:
linux:
hosts:
node01.example.org:
ansible_user: admin
node02.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.

bash
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.

yaml
---
- name: Install Chrony for first contact with Ansible
hosts: linux
become: true
tasks:
- name: Install Chrony package
ansible.builtin.package:
name: chrony
state: 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.

bash
ansible-playbook playbooks/mini.yml --check --diff
ansible-playbook playbooks/mini.yml
ansible-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.

bash
ansible-galaxy role init --init-path roles chrony
markdown
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.

yaml
---
chrony_package_name_map:
Debian: chrony
RedHat: chrony
Suse: chrony
chrony_service_name_map:
Debian: chrony
RedHat: chronyd
Suse: chronyd
chrony_config_path_map:
Debian: /etc/chrony/chrony.conf
RedHat: /etc/chrony.conf
Suse: /etc/chrony.conf
chrony_package_state: present
chrony_service_enabled: true
chrony_service_state: started
chrony_servers:
- address: 0.pool.ntp.org
options: iburst
- address: 1.pool.ntp.org
options: iburst
chrony_driftfile: /var/lib/chrony/drift
chrony_makestep: "1.0 3"
chrony_rtcsync: true
chrony_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.

yaml
---
- name: Resolve OS-specific Chrony values
ansible.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 unsupported
ansible.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 tasks
ansible.builtin.include_tasks: "{{ ansible_facts['os_family'] }}.yml"
- name: Render Chrony configuration
ansible.builtin.template:
src: chrony.conf.j2
dest: "{{ chrony_config_path }}"
owner: root
group: root
mode: "0644"
notify: Restart chrony
- name: Ensure Chrony service state
ansible.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.

bash
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

yaml
---
- name: Install Chrony on Debian family
ansible.builtin.package:
name: "{{ chrony_package_name }}"
state: "{{ chrony_package_state }}"

tasks/RedHat.yml

yaml
---
- name: Install Chrony on Red Hat family
ansible.builtin.package:
name: "{{ chrony_package_name }}"
state: "{{ chrony_package_state }}"

tasks/Suse.yml

yaml
---
- name: Install Chrony on SUSE family
ansible.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.

jinja2
# 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.

yaml
---
- name: Restart chrony
ansible.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.

yaml
---
- name: Configure time synchronization
hosts: linux
become: true
gather_facts: true
roles:
- 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

yaml
chrony_servers:
- address: 0.pool.ntp.org
options: iburst
- address: 1.pool.ntp.org
options: iburst
chrony_makestep: "1.0 3"
chrony_rtcsync: true

inventories/lab/host_vars/node01.example.org.yml

yaml
chrony_servers:
- address: time.cloudflare.com
options: 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.

markdown
Source | Example | Role in the example
------------------------------ | ------------------------ | -----------------------------
Role defaults | chrony_servers: pool A | Role default
group_vars/all | chrony_servers: pool B | Lab-wide configuration
host_vars/node01 | chrony_servers: source C | Exception for one host
play vars | vars: ... | Value directly in the play
role vars | roles/chrony/vars | internal strong role values
set_fact | chrony_service_name | Runtime value from facts
role params | role: chrony, vars: ... | Explicit role call
extra 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.

bash
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --syntax-check
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --check --diff
ansible-playbook -i inventories/lab/hosts.yml playbooks/site.yml --limit node01.example.org --diff
ansible-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.

bash
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.

bash
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.

markdown
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

yaml
---
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.

bash
git submodule add git@example.org:platform/ansible-roles.git roles/shared
git clone --recurse-submodules git@example.org:platform/ansible-project.git
git submodule update --init --recursive
git 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.