unattended-upgrades on Debian that installs nothing: Ubuntu-style Allowed-Origins
A Debian 13 server had automatic security updates. Ansible had installed unattended-upgrades, enabled it in 20auto-upgrades, and apt-daily-upgrade.timer had run that morning. Then a routine check:
$ apt list --upgradable 2>/dev/null | grep -c security
61
Nothing had failed, and nothing had warned. unattended-upgrades had never found anything it was allowed to install.
If your 50unattended-upgrades on Debian has Allowed-Origins with ${distro_codename}-security, yours does the same.
The config #
This is what the Ansible role wrote to /etc/apt/apt.conf.d/50unattended-upgrades:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
The ESM lines give it away: Extended Security Maintenance is Ubuntu Pro. This is Ubuntu’s stock file, copied into a Debian role by hand or by an LLM. On Debian it expands to “Debian:trixie” and “Debian:trixie-security”, which looks right and matches nothing.
Why it matches nothing #
Allowed-Origins is the legacy syntax. unattended-upgrades (2.12 on Debian 13) converts each entry to the newer pattern format in get_allowed_origins_legacy():
allowed_origins.append("o=%s,a=%s" % (substitute(distro_id),
substitute(distro_codename)))
The part after the colon becomes a=, and the matcher compares a with the archive, which is the Suite: field of the Release file:
elif what in ("a", "suite", "archive"):
match = fnmatch.fnmatch(origin.archive, value)
On Ubuntu, suite and codename are the same string:
# security.ubuntu.com/ubuntu/dists/noble-security/InRelease
Suite: noble-security
Codename: noble
On Debian, they are not:
# security.debian.org/debian-security/dists/trixie-security/InRelease
Origin: Debian
Label: Debian-Security
Suite: stable-security
Codename: trixie-security
apt-cache policy shows both, as a= and n=:
release v=13,o=Debian,a=stable-security,n=trixie-security,l=Debian-Security,c=main,b=amd64
So a=trixie-security matches nothing, and neither does a=trixie, because the main archive’s suite is stable. Run through unattended-upgrades’ own matcher against a trixie host’s package lists, the legacy entries come up empty, and Debian’s patterns each hit their archive:
'o=Debian,a=trixie' -> []
'o=Debian,a=trixie-security' -> []
'origin=Debian,codename=${distro_codename},label=Debian' -> ['stable/trixie']
'origin=Debian,codename=${distro_codename}-security,label=Debian-Security' -> ['stable-security/trixie-security']
With no allowed origin, there is nothing to upgrade, and that’s not an error. Each run logs No packages found that can be upgraded unattended and no pending auto-removals and exits cleanly.
The fix #
Use Origins-Pattern with codename=, as Debian’s own file does:
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename},label=Debian";
"origin=Debian,codename=${distro_codename}-updates,label=Debian";
"origin=Debian,codename=${distro_codename},label=Debian-Security";
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
};
Debian’s stock file has the -updates line commented out. Keep it or drop it as you like.
a=stable-security would match too, but don’t use it. On release day, stable moves to the next Debian release, and a suite-based pattern would follow it. A codename stays with the release you installed.
Then check what unattended-upgrades actually picks:
sudo unattended-upgrade --dry-run -d
The Allowed origins are: line shows the patterns after conversion. Below it, the security packages from apt list --upgradable should show up as upgrade candidates. If the run ends with “No packages found” while apt list --upgradable lists stable-security packages, the origins are still wrong.
With Automatic-Reboot "false", a new kernel or libc still waits for you once updates flow again, so check for /var/run/reboot-required.
Takeaway #
On Debian, Allowed-Origins "${distro_id}:${distro_codename}-security" is a no-op that looks like a security policy, because it matches the suite (stable-security), not the codename. Use Origins-Pattern with codename=, and confirm it with --dry-run -d.
Run grep -rn Allowed-Origins /etc/apt/apt.conf.d/ on your Debian hosts, and run the same grep over your configuration management repos, where the copy is most likely to live.