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 via hostvars
  • ansible_play_hosts: Ansible magic variable containing the list of all hosts in the current play
  • hostvars[host]: allows access to the variables of a specific host from any task
  • ansible_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: &quot;admin@mondomaine.local&quot;
        subject: &quot;Ansible update report - {{ ansible_date_time.date }}&quot;
        body: &quot;{{ recap_message }}&quot;
        from: &quot;ansible@srv-ansible.mondomaine.local&quot;
        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 &gt;&gt; /var/log/ansible-update.log 2&gt;&amp;1

The redirection &gt;&gt; /var/log/ansible-update.log 2&gt;&amp;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 &amp;&amp; echo &#x27;Port 123 OK&#x27; || echo &#x27;Port 123 BLOCKED&#x27;

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 &#x27;Ansible test&#x27; | mail -s &#x27;Test&#x27; 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.conf is essential to ensure that only your internal NTP is used.
  • The HTML report with Jinja2 and hostvars allows 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: &quot;admin@mondomaine.local&quot;
        subject: &quot;Ansible Report - {{ ansible_date_time.date }}&quot;
        body: &quot;{{ recap_message }}&quot;
        from: &quot;ansible@srv-ansible.mondomaine.local&quot;
        secure: never
        subtype: html
      run_once: true
      delegate_to: localhost