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: trueIf 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.comupgrade_type = defaultapplies 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 = trueactually applies what gets downloaded - set it tofalseand 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-neededonly reboots if an applied update actually requires it (a new kernel, glibc, systemd itself) - not after every run.reboot_commandgives a 10-minute delayedshutdown -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_emailpipes the run’s output throughemail_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=0The 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: trueIn 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 automaticlist-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.