Skip to main content
  1. Posts/

Automatic Updates on Rocky and Fedora with dnf-automatic and Ansible

Most people set up a server once and never think about it again, which means package updates never happen either - apt/dnf update isn’t something anyone reliably runs by hand for years on end. dnf-automatic closes that gap: it applies updates unattended on a schedule, the same idea as Debian’s unattended-upgrades. Installing it is a one-liner; getting the config decisions and verification right is where this is actually worth writing down.

Fedora and Rocky don’t use the same plugin
#

Fedora moved to DNF5 (Fedora 41+) and renamed the automatic-update plugin along with it - dnf5-plugin-automatic, timer unit dnf5-automatic.timer1. Rocky and RHEL are still on DNF4, so they use the older package name, dnf-automatic, with its own timer unit, dnf-automatic-install.timer2. Both read the same /etc/dnf/automatic.conf format, so the config itself is portable - only the package name and timer unit need to branch per distro:

- name: Set dnf-automatic timer unit name
  ansible.builtin.set_fact:
    dnf_automatic_timer_unit: >-
      {{ 'dnf5-automatic.timer'
         if ansible_facts['distribution'] == 'Fedora'
         else 'dnf-automatic-install.timer' }}

- name: Install dnf-automatic package
  ansible.builtin.package:
    name: "{{ 'dnf5-plugin-automatic' if ansible_facts['distribution'] == 'Fedora' else 'dnf-automatic' }}"
    state: present
  become: true

If your fleet mixes Fedora and Rocky/RHEL hosts (mine does), this branch is the difference between the role working everywhere and silently doing nothing on half your hosts.

The config decisions that actually matter
#

The interesting part of /etc/dnf/automatic.conf isn’t the file format - it’s picking sane values for a handful of settings3:

[commands]
upgrade_type = default
download_updates = true
apply_updates = true
reboot = when-needed
reboot_command = shutdown -r +10 'Rebooting after applying package updates'

[emitters]
emit_via = command_email
send_error_messages = true

[email]
email_from = root@myhost.example.com
email_to = admin@example.com
email_host = localhost

[command_email]
email_from = root@myhost.example.com
email_to = admin@example.com
  • upgrade_type = default applies all available updates, not just security ones. The security-only alternative exists (upgrade_type = security) if you’d rather patch selectively, but on a homelab box where nobody’s staging non-security updates separately anyway, “default” means you’re not quietly drifting away from what a fresh install would give you.
  • apply_updates = true actually applies what gets downloaded - set it to false and this becomes a notify-only setup (you get emailed that updates exist, nothing installs). Useful on something you want to eyeball first; not what “automatic” usually means.
  • reboot = when-needed only reboots if an applied update actually requires it (a new kernel, glibc, systemd itself) - not after every run. reboot_command gives a 10-minute delayed shutdown -r, not an immediate one, so a reboot on a box you’re actively SSH’d into doesn’t yank the session out from under you without warning.
  • emit_via = command_email pipes the run’s output through email_to/a local mail command rather than just writing to stdout/the journal - the point of automating this is not having to go looking for whether it worked.

Don’t override the vendor’s timer unit directly
#

The schedule itself - when the timer fires - isn’t in automatic.conf at all, it’s a property of the systemd timer unit. The tempting shortcut is to just drop a full replacement dnf-automatic-install.timer file into /etc/systemd/system/. Don’t: the next package update ships a new vendor unit file, and now you’ve either got a stale copy silently overriding it forever, or a conflict Ansible has to actively manage.

A systemd drop-in avoids this - it overrides just the one setting you care about (OnCalendar) while everything else still comes from the vendor unit4:

# /etc/systemd/system/dnf-automatic-install.timer.d/schedule.conf
[Timer]
OnCalendar=
OnCalendar=Sat *-*-* 04:30:00
RandomizedDelaySec=0

The empty OnCalendar= line first clears any calendar expressions the vendor unit already set, before the second line sets the real one - OnCalendar= is additive by default in systemd, so skipping the reset line here would leave both schedules active.

Wiring it up with Ansible
#

The role puts this together: install the right package, deploy automatic.conf, clean up any leftover full-unit override from an earlier, less careful version of this role, then deploy the drop-in and start the timer.

- name: Deploy dnf-automatic configuration file
  ansible.builtin.template:
    src: automatic.conf.j2
    dest: /etc/dnf/automatic.conf
    owner: root
    group: root
    mode: '0644'
  become: true
  notify:
    - Restart dnf-automatic timer

- name: Remove legacy full-unit override (replaced by drop-in)
  ansible.builtin.file:
    path: "/etc/systemd/system/{{ dnf_automatic_timer_unit }}"
    state: absent
  become: true
  notify:
    - Reload systemd daemon
    - Restart dnf-automatic timer

- name: Create drop-in directory for timer override
  ansible.builtin.file:
    path: "/etc/systemd/system/{{ dnf_automatic_timer_unit }}.d"
    state: directory
    owner: root
    group: root
    mode: '0755'
  become: true

- name: Deploy timer schedule drop-in override
  ansible.builtin.template:
    src: dnf5-automatic.timer.j2
    dest: "/etc/systemd/system/{{ dnf_automatic_timer_unit }}.d/schedule.conf"
    owner: root
    group: root
    mode: '0644'
  become: true
  notify:
    - Reload systemd daemon
    - Restart dnf-automatic timer

- name: Flush handlers before starting service
  ansible.builtin.meta: flush_handlers

- name: Start and enable systemd timer for dnf-automatic
  when: not ansible_check_mode
  ansible.builtin.service:
    name: "{{ dnf_automatic_timer_unit }}"
    state: started
    enabled: true
  become: true

In the playbook, this role runs against every RHEL-family host, paired with unattended-upgrades for Debian ones - one hosts: all play, the OS family picks which role actually does anything:

- name: Configure common settings across all hosts
  hosts: all
  roles:
    - role: dnf-automatic
      tags: [dnf-automatic, security]
      when: "ansible_facts['os_family'] == 'RedHat'"
    - role: unattended-upgrades
      tags: [unattended-upgrades, security]
      when: "ansible_facts['os_family'] == 'Debian'"

Every host in the fleet gets unattended patching from the same play, without needing to know or care which OS any given host runs.

Verifying it’s actually going to run
#

Deploying the config is half the job - confirming the timer is live and knows when it’ll next fire is the other half, and it’s a 30-second check:

systemctl status dnf-automatic-install.timer   # Rocky/RHEL
systemctl status dnf5-automatic.timer          # Fedora
systemctl list-timers --all | grep automatic

list-timers shows the actual next-scheduled run, computed from the drop-in - if that doesn’t match what you configured, the drop-in didn’t take, and the fix is almost always systemctl daemon-reload followed by restarting the timer, not editing the file again.

Resources
#

Author
Andreas Müller
Platform Engineer at B&W Handelsgesellschaft mbH focusing on infrastructure management, container orchestration, and developer enablement. Mostly living in the terminal. Tech stack: Ansible, Kubernetes, IaC, and CheckMK for observability.