Package updates, NTP synchronization, and HTML email notifications on Ubuntu 24.04
Manually managing multiple Linux servers quickly becomes tedious and prone to errors: forgotten updates, undetected time zone differences, inconsistent NTP configurations, etc. Ansible solves this problem by allowing you to automate system administration in a reproducible way, without having to install an agent on the target machines.
We will set up an Ansible server on Ubuntu 24.04 LTS, configure a server inventory, and deploy a complete playbook that performs the following in a single execution:
- Configuring the time zone (Europe/Paris) on all servers
- Deploying Chrony and NTP synchronization on an internal server
- Fully updating packages (
apt upgrade) - Sending an HTML summary email with the status of each server
An important point about this configuration: the default SSH port (22) has been replaced by a custom port on all servers, which we will integrate into the Ansible configuration.
To reproduce this configuration, you will need:
- An Ansible server running Ubuntu 24.04 LTS (the "controller")
- Several Linux Ubuntu 24.04 servers to manage (the "nodes")
- An internal NTP server accessible on the network (UDP port 123)
- An internal SMTP server for sending emails (Postfix or equivalent)
- A common user with sudo access on all servers
SSH key authentication
Ansible uses SSH to connect to nodes. It is essential to configure public/private key authentication to avoid entering passwords each time you run it.
On the Ansible server, generate an SSH key pair if you haven't already done so:
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa
Then deploy the public key on each node. If your SSH port is custom (here port 12345 will be used as an example in this article), specify it explicitly:
ssh-copy-id -p 12345 -i ~/.ssh/id_rsa.pub utilisateur@192.168.1.10
Verify that the connection works without a password:
ssh -p 12345 utilisateur@192.168.1.10 'echo Connection OK'
Installing Ansible
On Ubuntu 24.04, Ansible is available in the official repositories:
sudo apt update
sudo apt install ansible -y
Check the installation:
ansible --version
You should get an output similar to:
ansible [core 2.16.3]
config file = /etc/ansible/ansible.cfg
python version = 3.12.3
jinja version = 3.1.2
Also install the community.general collection needed for time zone management:
ansible-galaxy collection install community.general
Inventory configuration
The Ansible inventory lists the machines to be managed. Edit the /etc/ansible/hosts file:
sudo nano /etc/ansible/hosts
Here is an example of an inventory with several servers spread across different subnets, using a custom SSH port:
[servers]
srv-ansible ansible_host=192.168.1.5 ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
srv-ntp ansible_host=192.168.1.10 ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
srv-web ansible_host=192.168.2.20 ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
srv-mail ansible_host=192.168.2.35 ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
srv-backup ansible_host=192.168.3.50 ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
| Parameter | Description |
|---|---|
ansible_host |
IP address or DNS name of the target server |
ansible_port |
SSH port (here 12345 instead of the default port 22) |
ansible_user |
SSH user with sudo privileges |
ansible_ssh_private_key_file |
Path to the SSH private key |
ansible_python_interpreter |
Forces the use of Python 3 |
Test connectivity to all servers:
ansible servers -m ping
A pong response for each server confirms that everything is in order.
The maintenance playbook
Create the playbook file:
sudo nano /etc/ansible/update_servers.yml
General structure
The playbook begins by declaring its metadata and the target server group:
---
- name: NTP configuration, time zone, and package update
hosts: servers
become: true
become_user: root
tasks:
The become: true parameter allows privilege escalation via sudo, which is necessary for system administration operations.
Task 1: Time zone configuration
- name: Set time zone to Europe/Paris
community.general.timezone:
name: Europe/Paris
register: timezone_set
when: inventory_hostname != 'srv-ntp'
The community.general.timezone module configures the time zone idempotently: it will only modify the system if necessary. The result is stored in timezone_set for use in the final report.
UPDATE:
I am adding the parameter when: inventory_hostname != 'srv-ntp' because my NTP server is itself a Chrony and is part of the list of servers to be updated, so if I overwrite the server configuration with the client configuration, it will not work.
Task 2: Installing Chrony
- name: Ensure Chrony is installed
ansible.builtin.apt:
name: chrony
state: present
register: chrony_installed
when: inventory_hostname != 'srv-ntp'
The state: present parameter ensures that the package is installed without reinstalling it if it is already installed. This is Ansible's idempotence principle.
Task 3: Deploying the NTP configuration
This is a key task: instead of simply adding a line to the existing configuration file (which would leave the public Ubuntu servers active), we completely replace the chrony.conf file with a streamlined configuration that points only to our internal NTP server:
- name: Deploy chrony.conf configuration
ansible.builtin.copy:
dest: /etc/chrony/chrony.conf
content: |
server 192.168.1.10 iburst
keyfile /etc/chrony/chrony.keys
driftfile /var/lib/chrony/chrony.drift
ntsdumpdir /var/lib/chrony
logdir /var/log/chrony
maxupdateskew 100.0
rtcsync
makestep 1 3
leapsectz right/UTC
backup: yes
register: chrony_config_changed
when: inventory_hostname != 'srv-ntp'
The backup: yes parameter automatically creates a backup of the old file before overwriting it. The iburst parameter speeds up the initial synchronization by sending several requests in quick succession at startup.
> Why replace the entire file? By default, Ubuntu 24.04 configures Chrony with several pools of public servers. If we simply add our internal server at the end of the file, Chrony will continue to use the public servers as a priority. By replacing the file, we ensure that only our internal NTP is used.
Task 4: Restart Chrony
- name: Restart the Chrony service
ansible.builtin.systemd:
name: chronyd
state: restarted
enabled: yes
register: chrony_service_restarted
when: inventory_hostname != 'srv-ntp'
The enabled: yes parameter also ensures that Chrony will start automatically the next time the server is rebooted.
Task 5: Checking NTP synchronization
- name: Check NTP synchronization
ansible.builtin.command:
cmd: chronyc tracking
register: ntp_status
changed_when: false
The changed_when: false parameter tells Ansible that this task never changes the state of the system. Without this parameter, Ansible would display a CHANGED on each execution, which would skew the statistics.
Tasks 6 and 7: Updating packages
- name: Update the package list
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
register: apt_update_result
- name: Perform a full package update
ansible.builtin.apt:
upgrade: full
autoremove: yes
autoclean: yes
register: apt_upgrade_result
The cache_valid_time: 3600 parameter prevents package lists from being re-downloaded if they have already been updated within the last hour. The upgrade: full parameter is equivalent to apt full-upgrade, which also handles dependency changes. The autoremove and autoclean options clean up orphaned packages and the APT cache.
Task 8: Detecting the need to reboot
- name: Check if a reboot is necessary
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required
After a kernel or critical library update, Ubuntu creates the /var/run/reboot-required file. This module checks for its existence and reports it in the report. Ansible does not automatically reboot the server—this is a deliberate decision to avoid any unplanned service interruptions.
Task 9: Building the HTML report
This is the most complex task. It uses the Jinja2 template engine built into Ansible to dynamically build an HTML email with the status of each server:
- name: Build the global summary message
ansible.builtin.set_fact:
recap_message: |
<html>
<body style="font-family: Arial, sans-serif; background-color: #f4f4f4; padding: 20px;">
<div style="max-width: 800px; margin: auto; background: white; border-radius: 8px; padding: 20px;">
<h2 style="color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px;">
??? Ansible update report
</h2>
<p style="color: #7f8c8d;">Run on {{ ansible_date_time.date }} at {{ ansible_date_time.time }}</p>
{% for host in ansible_play_hosts %}
<div style="margin-top: 20px; border: 1px solid #ddd; border-radius: 6px; overflow: hidden;">
<div style="background-color: #3498db; color: white; padding: 10px 15px;">
<h3 style="margin: 0;">{{ host | upper }}</h3>
</div>
<table style="width: 100%; border-collapse: collapse;">
<tr style="background-color: #f9f9f9;">
<td style="padding: 8px 15px; border-bottom: 1px solid #eee; width: 60%;">Time zone (Europe/Paris)</td>
<td style="padding: 8px 15px; border-bottom: 1px solid #eee;">
{% if hostvars[host].timezone_set.changed %}
<span style="color: #e67e22;">?? CHANGED</span>
{% else %}
<span style="color: #27ae60;">? OK</span>
{% endif %}
</td>
</tr>
<tr>
<td style="padding: 8px 15px; border-bottom: 1px solid #eee;">Chrony</td>
<td style="padding: 8px 15px; border-bottom: 1px solid #eee;">
{% if hostvars[host].chrony_installed.changed %}
<span style="color: #e67e22;">?? INSTALLED</span>
{% else %}
<span style="color: #27ae60;">? PRESENT</span>
{% endif %}
</td>
</tr>
<tr style="background-color: #f9f9f9;">
<td style="padding: 8px 15px; border-bottom: 1px solid #eee;">NTP configuration</td>
<td style="padding: 8px 15px; border-bottom: 1px solid #eee;">
{% if hostvars[host].chrony_config_changed.changed %}
<span style="color: #e67e22;">?? CHANGED</span>
{% else %}
<span style="color: #27ae60;">? OK</span>
{% endif %}
</td>
</tr>
<tr>
<td style="padding: 8px 15px; border-bottom: 1px solid #eee;">Package update</td>
<td style="padding: 8px 15px; border-bottom: 1px solid #eee;">
{% if hostvars[host].apt_upgrade_result.changed %}
<span style="color: #3498db;">?? DONE</span>
{% else %}
<span style="color: #27ae60;">? UP TO DATE</span>
{% endif %}
</td>
</tr>
<tr style="background-color: #f9f9f9;">
<td style="padding: 8px 15px;">Reboot required</td>
<td style="padding: 8px 15px;">
{% if hostvars[host].reboot_required.stat.exists %}
<span style="color: #e74c3c;">?? YES</span>
{% else %}
<span style="color: #27ae60;">? NO</span>
{% endif %}
</td>
</tr>
</table>
</div>
{% endfor %}
</div>
</body>
</html>run_once: true
Important points about this task:
run_once: true: the task only runs once but accesses data from all servers viahostvarsansible_play_hosts: Ansible magic variable containing the list of all hosts in the current playhostvars[host]: allows access to the variables of a specific host from any taskansible_date_time: Ansible automatic variable containing the date and time of execution
Task 10: Sending the summary email
- name: Send a single summary email
ansible.builtin.mail:
host: smtp.mydomain.local
port: 25
to: "admin@mondomaine.local"
subject: "Ansible update report - {{ ansible_date_time.date }}"
body: "{{ recap_message }}"
from: "ansible@srv-ansible.mondomaine.local"
secure: never
subtype: html
run_once: true
delegate_to: localhost
Two parameters deserve explanation:
secure: never: disables TLS for SMTP sending. Necessary if your internal SMTP server does not have a TLS certificate configured. Never use this option for an external SMTP server.delegate_to: localhost: delegates email sending to the Ansible server itself rather than to one of the managed nodes. This makes sense since it is the controller that must send the report.
Scheduling with Cron
To automate weekly execution, add a cron job on the Ansible server. The example below schedules execution every Monday at 3:00 a.m.:
sudo crontab -e
Add the line:
0 3 * * 1 ansible-playbook /etc/ansible/update_servers.yml >> /var/log/ansible-update.log 2>&1
The redirection >> /var/log/ansible-update.log 2>&1 saves the complete output (stdout and stderr) in a log file so that any problems can be diagnosed.
Result: the email report
Each time the playbook is executed, you receive a structured HTML email with a table for each server:
| Indicator | Meaning |
|---|---|
| ? OK / PRESENT / UP TO DATE | The check passed, no changes necessary |
| ?? MODIFIED / INSTALLED | A change was made during this execution |
| ?? PERFORMED | Packages were updated |
| ?? YES (restart) | A restart is required following the updates |
The email subject automatically includes the execution date, making it easy to distinguish reports in your inbox.
Frequent troubleshooting
NTP is not synchronizing
If chronyc sources displays ? in front of your internal NTP server, check that UDP port 123 is accessible:
nc -uz 192.168.1.10 123 && echo 'Port 123 OK' || echo 'Port 123 BLOCKED'
If you are using a Windows server as your NTP reference, verify that the service is active:
w32tm /query /status
Email not arriving
Test SMTP sending from the Ansible server:
echo 'Ansible test' | mail -s 'Test' admin@mondomaine.local
Check the SMTP server logs and make sure that port 25 is accessible from the Ansible server.
StartTLS error
If you get the error StartTLS is not offered on server, it means that your internal SMTP server does not support TLS. Check that you have secure: never in the email sending task of the playbook.
That's it! We have implemented a complete automation solution with Ansible that manages NTP configuration, time zone, package updates, and email notifications across the entire Linux server fleet with a single command.
A few key points to remember:
- Idempotence: each Ansible task checks the current status before acting. You can run the playbook as many times as you want without risk.
- Completely replacing
chrony.confis essential to ensure that only your internal NTP is used. - The HTML report with Jinja2 and
hostvarsallows you to centralize information from all servers in a single readable email. - The custom SSH port is a simple security measure that reduces noise from automatic scans and integrates easily into the Ansible inventory.
Enjoy!
Appendix: Complete playbook
---
- name: NTP configuration, time zone, and package update
hosts: servers
become: true
become_user: root
tasks:
- name: Set time zone to Europe/Paris
community.general.timezone:
name: Europe/Paris
register: timezone_set
- name: Ensure Chrony is installed
ansible.builtin.apt:
name: chrony
state: present
register: chrony_installed
- name: Deploy chrony.conf configuration
ansible.builtin.copy:
dest: /etc/chrony/chrony.conf
content: |
server 192.168.1.10 iburst
keyfile /etc/chrony/chrony.keys
driftfile /var/lib/chrony/chrony.drift
ntsdumpdir /var/lib/chrony
logdir /var/log/chrony
maxupdateskew 100.0
rtcsync
makestep 1 3
leapsectz right/UTC
backup: yes
register: chrony_config_changed
- name: Restart the Chrony service
ansible.builtin.systemd:
name: chronyd
state: restarted
enabled: yes
register: chrony_service_restarted
- name: Check NTP synchronization
ansible.builtin.command:
cmd: chronyc tracking
register: ntp_status
changed_when: false
- name: Update the package list
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
register: apt_update_result
- name: Perform a full package update
ansible.builtin.apt:
upgrade: full
autoremove: yes
autoclean: yes
register: apt_upgrade_result
- name: Check if a reboot is required
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required
- name: Build the global summary message
ansible.builtin.set_fact:
recap_message: |
[... see Task 9 section for complete HTML content ...]
run_once: true
- name: Send a single summary email
ansible.builtin.mail:
host: smtp.mydomain.local
port: 25
to: "admin@mondomaine.local"
subject: "Ansible Report - {{ ansible_date_time.date }}"
body: "{{ recap_message }}"
from: "ansible@srv-ansible.mondomaine.local"
secure: never
subtype: html
run_once: true
delegate_to: localhost