Infrastructure engineering for East Africa's operators, platforms and regulators See the work →
Upgrade Ubuntu 24.04 to 26.04: What Broke on a Server
Linux 8 min read

Upgrade Ubuntu 24.04 to 26.04: What Broke on a Server

We ran do-release-upgrade from Ubuntu 24.04 to 26.04 on a test server. PHP returned 502, a Python venv died at the reboot and PostgreSQL stayed on 16.

CE
CloudSpinx Engineering
Infrastructure Engineering, Virtualization, Networking, Automation
10 November 2026
ubuntu 26.04do-release-upgradeubuntu upgradephp-fpm

The first thing to break was PHP, under three minutes into the package run of our Ubuntu 24.04 to 26.04 upgrade. It was still broken when do-release-upgrade exited with status 0, and still broken after the reboot, because the nginx site pointed at a socket that no longer existed. The upgrader's log had listed php8.3-fpm among ten packages to remove, as a debug line. It said nothing about the site that depended on it.

The server was a clone of a 24.04 cloud-image template carrying a common single-box stack: nginx in front of PHP-FPM 8.3, PostgreSQL 16, a Python app in a virtualenv under systemd, a container from Docker's own apt repository and fail2ban, plus the edits admins make to packaged files (a PHP memory_limit, MaxAuthTries in sshd_config). We ran the upgrade unattended with the non-interactive frontend, probed each endpoint from a second machine about every three seconds, and kept the upgrader's logs.

Nothing forces the move yet. Ubuntu's release cycle gives 24.04 standard security maintenance until May 2029, and the 26.04 release notes allow the jump only from 24.04 or 25.10, so a 22.04 box has two hops to make. The right time is when you have a clone to practice on. CloudSpinx does that rehearsal for organizations in Kenya that hand us their Linux servers, and this is what came out of ours.

What do-release-upgrade did, minute by minute

Leave out the three minutes it spent stuck fetching the upgrader itself (two of the nine addresses archive.ubuntu.com resolves to dropped connections from our lab, until we pinned a working one in /etc/hosts) and the run took 13 minutes 12 seconds. About two minutes of that was the download. The package run took 9 minutes 33 seconds: 662 packages upgraded and 125 new ones installed, then a cleanup that purged 64 more, Python 3.12 among them.

Upgrading Ubuntu 24.04 to 26.04: six endpoints, minute by minute
Upgrading Ubuntu 24.04 to 26.04: six endpoints, minute by minuteSix endpoints on one Ubuntu 24.04 server plotted against minutes after do-release-upgrade started, probed from another host from minute 2.5. During the 9 minute 33 second package run, PHP behind the generic php-fpm.sock returned 502 for about 5 minutes 39 seconds and new SSH logins were refused for 3 minutes 45 seconds, and both recovered on their own. PHP behind the versioned php8.3-fpm.sock stayed at 502 from minute 5.8 until it was edited by hand. The upgrader finished at 13 minutes 12 seconds without rebooting; the server answered again within 20 seconds of the reboot command, and the Python virtualenv app then failed on every start.UPGRADERfetchpackage run, 9 min 33 sdone, rc=0, no rebootStatic filePHP, php-fpm.sock502 for 5 min 39 sPHP, php8.3-fpm.sock502 from here on, until edited by handNew SSH logins3 min 45 sPython venv appstill on Python 3.12, in memoryfailsDocker container0246810121416 minlanes start at minute 2.5, when the 5 s probe didreboot, answering again within 20 s
The 502 on the generic socket and the SSH gap healed themselves before the package run ended. The two that mattered did not: the site on php8.3-fpm.sock stayed at 502, and the venv app ran fine until the reboot, then never started again on its own.

PHP went first, at the second the package run stopped php8.3-fpm. New SSH connections were refused for 3 minutes 45 seconds in the middle of the run, from the moment systemd closed ssh.socket to the moment it listened again, while OpenSSH went from 9.6p1 to 10.2p1. It is the kind of gap the upgrader's own code hedges against: started from an SSH login, it asks "Continue running under SSH?" and starts a spare daemon on port 1022. Ours ran detached, so that check never fired and no spare daemon started.

From the start of the package run the static file never failed. nginx was restarted once by its package, inside a single second, and one probe of the PHP page caught it. The Docker container and the Python app answered every probe through the package run, which turned out to be the misleading part. (Our first probe, with a 2-second timeout, missed a few answers from the static file, the versioned PHP page, the app and SSH during the download, so we restarted it with 5 seconds; the figure starts where that one did.)

The upgrader did not reboot. Its non-interactive frontend reads a RealReboot setting that defaults to off, so it exits and leaves a 26.04 userspace running on the 24.04 kernel until someone reboots. After the reboot command the server was answering again within 20 seconds.

PHP-FPM 502 after the upgrade: php8.3-fpm.sock is gone

26.04 ships PHP 8.5. The php-fpm metapackage moved to php8.5-fpm, the run removed php8.3-fpm (removed, not purged, so /etc/php/8.3 stays on disk) and started PHP 8.5 on /run/php/php8.5-fpm.sock five and a half minutes later. Our test site had two locations. The one using the generic /run/php/php-fpm.sock, an alternatives symlink, came back by itself when 8.5 started. The one naming the version, /run/php/php8.3-fpm.sock, returned 502 until we edited it:

$ sudo sed -i 's#php8\.3-fpm\.sock#php8.5-fpm.sock#' /etc/nginx/sites-available/default
$ sudo nginx -t && sudo systemctl reload nginx

The page that recovered on its own had lost something. PHP 8.5 got a fresh php.ini ("Creating config file /etc/php/8.5/fpm/php.ini with new version" in the log), so our memory_limit = 512M sat in the 8.3 file while the live pool ran at the default 128M. Nothing errors until a request needs the memory. Carry every setting you changed under /etc/php/8.3/ across to /etc/php/8.5/ before calling it done, pool files included.

The virtualenv that died at the reboot, not the upgrade

The Python app answered every probe through the package run and for the 34 seconds between the upgrader finishing and the reboot. It was still running the Python 3.12 it had loaded at boot, though the cleanup had already purged python3.12. The upgrader suspends needrestart on purpose, its code reasoning that a reboot comes at the end anyway, so the service restart needrestart would normally do after a library update, as it does under unattended-upgrades, never happened ("packages have been installed but needrestart is suspended"). After the reboot the app never came back:

  File "/opt/app/app.py", line 1, in <module>
    import http.server, requests, sys
ModuleNotFoundError: No module named 'requests'

A venv made with python3 -m venv links bin/python3 to /usr/bin/python3, which is now 3.14, while its packages sit in lib/python3.12/site-packages, which 3.14 does not read. Our unit had Restart=always with RestartSec=2, slow enough to stay under systemd's default limit of five starts in ten seconds, so systemd kept relaunching it (12 restarts by the time we looked) and systemctl --failed listed nothing. Anything that only watches for failed units would have missed it. Rebuild the environment from its requirements:

$ sudo python3 -m venv --clear /opt/app/venv
$ sudo /opt/app/venv/bin/pip install -r /opt/app/requirements.txt
$ sudo systemctl restart venvapp

Do the groundwork before you start. Every pyvenv.cfg whose home is /usr/bin was built on the system Python and will need this, and its version line says which one. A venv built on an interpreter apt does not manage, from pyenv or uv, does not break this way. Where an app has no requirements file, run pip freeze from its venv before the upgrade and save the output: the venv's own pip sits in the same lib/python3.12 directory that 3.14 ignores, and --clear deletes it.

PostgreSQL 16 and Docker: still running, no longer updated

PostgreSQL never stopped during the upgrade, and that is the trap. The upgrader's deny list refuses to remove postgresql-16, with a comment that purging the package can drop the cluster, so the 16 cluster kept serving on port 5432. The 26.04 postgresql metapackage installed PostgreSQL 18 beside it and created no 18 cluster. After the reboot our PHP page was still reading from 16.15, out of a package whose only source is the local dpkg database:

$ apt-cache policy postgresql-16
  Installed: 16.15-0ubuntu0.24.04.1
  Candidate: 16.15-0ubuntu0.24.04.1
 *** 16.15-0ubuntu0.24.04.1 100
        100 /var/lib/dpkg/status

No repository the server now uses carries that package, so no more security fixes reach it. pg_upgradecluster 16 main moved our three-row test database to 18 and left 16 stopped on port 5433 until dropped. On a real database the method matters more than the command, because its default is a dump, and we timed dump against link mode in the PostgreSQL 14 upgrade.

Docker went the same way by another route. The upgrader marked Docker's apt source Enabled: no ("disabled (unknown mirror)" in its log), so docker-ce stayed on the noble build, and the container restarting after the reboot hid it. Docker publishes a resolute suite: change Suites: noble to Suites: resolute, delete the Enabled: no line, and apt update offered upgrades for all six Docker packages.

Count the leftovers rather than guessing. Before those two fixes ours showed:

$ apt list '?obsolete' 2>/dev/null | grep -v Listing | wc -l
19

Ours were the six Docker packages, both PostgreSQL 16 packages, six packages of the old 6.8 kernel, three Python 3.12 libraries, libicu74 and libxml2. If you want this rehearsal run on a clone of your own server, tell us what runs on it or message +254 713 403 044 on WhatsApp.

sudo-rs, Rust coreutils and the files it would not overwrite

Some changes reach every shell session. After the upgrade sudo --version answered sudo-rs 0.2.13 through the alternatives system, with the classic sudo still installed, and ls --version answered uutils coreutils 0.10.0. Canonical's summary for LTS users covers both, including the switch back to GNU coreutils and the fact that cp, mv and rm still come from GNU. Time sync did not move: the upgrade kept systemd-timesyncd, and the same notes make chrony the default only for new installations, with a two-command migration for upgraded ones.

24.04, before26.04, after the upgrade
sudosudo 1.9.15p5sudo-rs 0.2.13, sudo 1.9.17p2 kept
ls and friendsGNU coreutils 9.4uutils coreutils 0.10.0
OpenSSH9.6p110.2p1, PerSourcePenalties on
PHP-FPM8.3.68.5.4 with a fresh php.ini
PostgreSQL serving16.1516.15, with 18.6 installed and no cluster
Python3.12.33.14.4
Time syncsystemd-timesyncdsystemd-timesyncd, no chrony
Kernel6.8.0-1497.0.0-43 once rebooted

No config file prompt came up in our run, and the non-interactive frontend would have answered one anyway: its code waits five seconds and replies n, keeping your file. sshd_config, where we had set MaxAuthTries 4, kept our edit, and the package's new version landed beside it as /etc/ssh/sshd_config.ucf-dist. The only other leftover was /etc/ca-certificates.conf.dpkg-old. Diff the .ucf-dist file anyway. OpenSSH 10.2 also turns PerSourcePenalties on by default, which changes how sshd answers an address that keeps failing to log in, and we measured that against fail2ban in its own lab.

What one run on one server does not tell you

We ran the non-interactive frontend, so we do not know what a person at the text prompt would have been asked, or whether the spare daemon on port 1022 actually carries you through that 3 minute 45 second socket gap. We did not test whether an SSH session already open survives it. Apache with mod_php, MySQL and PPAs were not on the box. And 13 minutes is two vCPUs pulling from a mirror that answered well once we pinned it; a server on a thin office line spends longer in the download.

The order we take from it: on a clone, grep the web server configs for versioned PHP sockets, list every pyvenv.cfg, run pg_lsclusters and pick the PostgreSQL method, note every file in /etc/apt/sources.list.d, and copy out your PHP edits. Upgrade, reboot, then rebuild the venvs, move the cluster, point vendor repositories at resolute, and run apt list '?obsolete' again to see what is still orphaned. Then do production the same way, or build it fresh on 26.04 from a setup checklist when the clone needed more fixing than the server is worth.

One server running only Ubuntu's own packages, with no PHP pool, no venv and no PostgreSQL: snapshot it, upgrade, reboot, and you do not need us. For anything more, write "Ubuntu 26.04 upgrade" in "What do you need?" on the Linux scoping form, choose Ubuntu, and paste into "Anything we should know?" what each server prints for lsb_release -ds, grep -rho 'php[0-9.]*-fpm.sock' /etc/nginx /etc/apache2 2>/dev/null, sudo find / -path /proc -prune -o -name pyvenv.cfg -print 2>/dev/null, pg_lsclusters and ls /etc/apt/sources.list.d. For more than a handful of servers, or output that will not fit, send it on WhatsApp to +254 713 403 044. You get each server marked upgrades clean, needs fixing after, or rebuild instead, the order to run them in, and one figure for the move, which we then handle with your team. The answer is free and commits you to nothing.

WhatsApp