This post was originally published on the Oh Dear blog on 20 February 2024. I moved it to my own blog in October 2026 and rewrote it to cover every LTS hop up to 26.04.
In early 2024 we upgraded the older uptime satellite servers that power Oh Dear’s Uptime Monitoring from Ubuntu 20.04 to Ubuntu 22.04 LTS. It was mostly a smooth process, and I wrote down the steps in case anyone’s ever in a similar position.
Since then I’ve done a lot more of these, and boxes that started on 20.04 have gone all the way to 26.04. So this is the same guide, extended to every LTS hop, with what changes in each one and what broke for me along the way.
The short version: do-release-upgrade moves you one LTS at a time. Going from 20.04 to 26.04 is three upgrades and three reboots, and you check the box in between each one.
In-place upgrade or a new server?#
There’s a lot of ways to upgrade servers, and the safest approach is to set up a brand new machine, move over your data, test things and then point your DNS records to the new server.
At Oh Dear, that wasn’t an option. Our cloud providers don’t allow the existing public IP addresses to move from one server to another. Since a lot of clients have whitelisted our IPs to get unrestricted, un-ratelimited access, we have to preserve them. So we prefer in-place upgrades, where the IPs stay the same.
If you can build a fresh box instead, do that. If you can’t, read on.
Where each LTS stands in October 2026#
| 20.04 Focal Fossa | 22.04 Jammy Jellyfish | 24.04 Noble Numbat | 26.04 Resolute Raccoon | |
|---|---|---|---|---|
| Standard support ends | May 2025 | May 2027 | May 2029 | May 2031 |
| ESM (Ubuntu Pro) ends | May 2030 | May 2032 | May 2034 | May 2036 |
| Kernel | 5.4 | 5.15 | 6.8 | 7.0 |
| PHP | 7.4 | 8.1 | 8.3 | 8.5 |
| Python | 3.8 | 3.10 | 3.12 | 3.14 |
| MySQL | 8.0 | 8.0 | 8.0 | 8.4 |
| PostgreSQL | 12 | 14 | 16 | 18 |
| OpenSSL | 1.1.1 | 3.0 | 3.0 | 3.5 |
| OpenSSH | 8.2 | 8.9 | 9.6 | 10.2 |
| nginx | 1.18 | 1.18 | 1.24 | 1.28 |
The support dates come from Canonical’s Ubuntu release cycle page. The package versions are what the Ubuntu archive publishes for each release, checked on Launchpad on 15 October 2026.
If you’re still on 20.04 without Ubuntu Pro, your server hasn’t had a security update since May 2025. Ubuntu Pro is free for personal use on up to 5 machines , which buys you time, but it doesn’t change the destination. And 22.04 leaves standard support in May 2027, so that’s next.
You can’t skip an LTS#
There’s no way to go from 20.04 straight to 26.04 with do-release-upgrade. Here’s why.
The upgrader downloads a list of releases from changelogs.ubuntu.com and offers the first release that’s newer than yours and still marked as supported. Servers default to Prompt=lts in /etc/update-manager/release-upgrades, which makes it read the LTS-only list:
$ curl -s https://changelogs.ubuntu.com/meta-release-lts | grep -E '^(Dist|Version|Supported):' | tail -12
Dist: focal
Version: 20.04.6 LTS
Supported: 1
Dist: jammy
Version: 22.04.5 LTS
Supported: 1
Dist: noble
Version: 24.04.5 LTS
Supported: 1
Dist: resolute
Version: 26.04.1 LTS
Supported: 1
From focal, the first newer entry is jammy. Always. So 20.04 goes to 22.04, then 22.04 to 24.04, then 24.04 to 26.04. Each hop installs a new kernel, replaces just about every package on the system and wants a reboot before the next one.
A new LTS doesn’t show up in that list on release day. Canonical adds it once the first point release is out: 24.04.1 and 26.04.1 both shipped in late August. Until then, a plain do-release-upgrade on the previous LTS prints this rather confusing message:
Checking for a new Ubuntu release
There is no development version of an LTS available.
To upgrade to the latest non-LTS development release
set Prompt=normal in /etc/update-manager/release-upgrades.
Don’t follow that advice on a server. -d (--devel-release) is the switch that lets you upgrade to the new LTS before its .1 is out, and Canonical’s 26.04.1 announcement
even mentions it for the eager. For servers, I wait.
As of October 2026 you don’t need -d for any of the three hops. 26.04.1 came out on 27 August 2026, and the 24.04 to 26.04 path opened about a month later, at the end of September, after some backports for regressions in rust-coreutils. When the next LTS lands in 2028, the same waiting game starts again.
Before the first hop#
Most of the hard outages I’ve had during these upgrades came from two things: SSH dying in the middle of a hop, and a box that doesn’t get its network back after the reboot. Everything in this section exists to make those two survivable.
Backup the server first#
A lot of things can go wrong with in-place upgrades, so it’s best to be safe. You’ll be installing a new Linux kernel and replacing just about every package on the system (including glibc and openssl), and if anything goes wrong, your machine won’t boot.
Make sure you have a backup or a snapshot in place that you can restore from. Take one per hop, not one for the whole trip, and check in your provider’s UI that it actually finished. This is also a good opportunity to test some of your disaster recovery practices, since you’ll be taking snapshots and possibly rolling back to them.
If the box runs a database, take a logical dump on top of the snapshot. A snapshot restores the whole machine, a dump restores your data on any machine:
$ sudo mysqldump --single-transaction --quick --routines --triggers --events --all-databases \
| gzip > /root/all-databases-$(date +%Y%m%d).sql.gz
Make sure you can get in without SSH#
The upgrade is safest through a console screen or VNC display. Ideally, you head over to your cloud provider of choice, open up the console there and log in to the system as the local root user. This way, even if your SSH session(s) disconnect, you still have server access.
That console is only useful if you can log in on it. Most cloud boxes only accept SSH keys, so set a root password and write it down somewhere that isn’t the server:
$ sudo passwd root
Then open the console once and confirm you get a login prompt before you start. Finding out the console doesn’t work while the box is half-upgraded is no fun.
Back in 2024 we opted for the SSH approach instead of the console, because it went incredibly smooth on our servers and we had the snapshots as a fallback. I still run the upgrades over SSH. But SSH is the one service that’s guaranteed to wobble during an upgrade, because it’s being upgraded too. So before every hop I start a backup SSH daemon on port 1022 and test it from my laptop. That post explains why the upgrader’s own spare sshd doesn’t start when you run it inside tmux, and how to set up your own. Start it after the test reboot below, because a manually started sshd doesn’t survive a reboot.
Updates first#
I always like to ensure that the machine I’m upgrading is in the best state possible before the release upgrade. That’s why I update and reboot the machine first, to get a fresh system:
$ sudo apt update
$ sudo apt full-upgrade
$ sudo reboot
After that, you’re running the latest kernel of your current release.
I use full-upgrade rather than upgrade on purpose. do-release-upgrade refuses to start while any package is still upgradable, with Please install all available updates for your release before upgrading., and a plain apt upgrade can leave packages “kept back”. It also refuses when a kernel or libc update is waiting for a reboot. The reboot fixes that, and it proves the box boots fine today, so a boot problem after the upgrade comes from the upgrade, not from something that was already broken.
You can ask the upgrader what it would offer without starting anything:
$ do-release-upgrade -c
Check the network config#
The most common way a box doesn’t come back after the reboot is the network. A newer kernel or systemd can name the network interface differently, or a netplan key that used to work now gets ignored, and you end up with a machine that boots fine and has no IP address.
$ grep -nE 'gateway4|gateway6|set-name' /etc/netplan/*.yaml
$ sudo netplan generate
gateway4 and gateway6 are deprecated in favour of a routes: block with to: default (the static IP post
has an example). Renames with set-name are what bit me on 26.04: the rename silently didn’t happen, and the config for the old interface name went nowhere. Matching the interface on its MAC address with match: macaddress: and keeping the name the kernel gives it is the safer setup. netplan generate should print nothing at all.
On cloud boxes, cloud-init may regenerate the netplan file on boot and undo your fix. Check for that by rebooting once and reading the file again.
Check the disks and boot#
$ cat /etc/fstab
$ sudo blkid
$ df -h / /boot
$ dkms status
What I look for:
- Every UUID in
/etc/fstabmatches a real device fromblkid. A stale one drops the box into emergency mode at boot. - Mounts that aren’t
/or/boothave thenofailoption, so a missing data disk doesn’t block the boot. - Enough free space. The upgrader checks
/usr,/varand/bootitself and refuses to start without room, but a separate, small/bootwith three old kernels on it is a classic. I want 10GB free on/before I start. dkms statusshows every out-of-tree kernel module as installed. Those have to rebuild against the new kernel, and that’s where a box with a custom driver fails to boot.
Know your third-party repositories#
$ ls /etc/apt/sources.list.d/
$ grep Prompt /etc/update-manager/release-upgrades
The upgrader disables every repository that isn’t an official Ubuntu mirror (more on that below). Note what’s there now, so you know what to switch back on afterwards. Prompt should be lts. If it says never, the upgrader won’t do anything.
Take an inventory#
Write down what runs on the box now, so you have something to compare with after each hop:
$ systemctl list-units --type=service --state=running --no-pager > ~/before-services.txt
$ sudo ss -tlnp > ~/before-ports.txt
Running one hop#
The steps are the same for every hop. Then I’ll go over what changes in each one.
Step 1: open a screen/tmux via SSH#
Your SSH session might disconnect throughout the upgrade, so start the process in a separate shell that you can (try to) reconnect to later, should it disconnect. I’ve been preaching screen for SSH work since 2008 :
$ screen -S ubuntu_upgrade
You can detach yourself from the shell by hitting CTRL+A and then D. With tmux, that’s tmux new -s upgrade and CTRL+B then D.
The interactive upgrader also starts its own GNU screen session behind the scenes, called ubuntu-release-upgrade-screen-window. If you lose your session anyway, sudo screen -ls shows it and sudo screen -r ubuntu-release-upgrade-screen-window gets you back in.
Step 2: start the upgrade#
In your shell, type:
$ cat /etc/issue
Ubuntu 20.04.6 LTS \n \l
This will confirm which release you’re on. Next, start the upgrade:
$ sudo do-release-upgrade
In this process, you’ll be prompted to make several decisions. I’ll mark some of these below, with a little explainer to each.
Third party sources disabled
Some third party entries in your sources.list were disabled. You can
re-enable them after the upgrade with the 'software-properties' tool
or your package manager.
To continue please press [ENTER]
You likely have third-party repositories, for instance to get the latest PHP or Python versions. To avoid any interference of packages, the upgrader disables those during the upgrade. This is fine. The 22.04 upgrader comments the lines out with a disabled on upgrade to jammy note. The 24.04 and 26.04 upgraders move everything to the newer deb822 .sources files, where a disabled repository gets Enabled: no.
You might, as one of the first steps, encounter this message:
Invalid package information
After updating your package information, the essential package
'ubuntu-minimal' could not be located. This may be because you have
no official mirrors listed in your software sources, or because of
excessive load on the mirror you are using. See /etc/apt/sources.list
for the current list of configured software sources.
In the case of an overloaded mirror, you may want to try the upgrade
again later.
This happened to us on the DigitalOcean VMs. DigitalOcean points the Ubuntu sources at its own mirror, mirrors.digitalocean.com, and that mirror isn’t on the upgrader’s list of known Ubuntu mirrors. So the upgrader treats it as third-party and disables it, and then there’s no Ubuntu archive left to upgrade from.
Back then we fixed it by restarting the command, as root, with:
$ sudo -i
# RELEASE_UPGRADER_ALLOW_THIRD_PARTY=1 do-release-upgrade
sudo do-release-upgrade --allow-third-party does exactly the same thing: the flag sets that environment variable. Be warned though, it skips the mirror check entirely, so your PPAs and other third-party repositories stay switched on during the upgrade too. These days I’d rather point the sources at archive.ubuntu.com before starting the hop, so only the mirror changes and everything else still gets disabled. The upgrader for 26.04 handles this better: it asks Required apt sources are missing and offers to continue with the default Ubuntu sources.
Once done, the upgrade will restart.
14 packages are going to be removed. 145 new packages are going to be
installed. 893 packages are going to be upgraded.
You have to download a total of 826 M. This download will take about
2 minutes with your connection.
Installing the upgrade can take several hours. Once the download has
finished, the process cannot be canceled.
Continue [yN] Details [d]
Obviously, we’ll install new packages, so hit y to continue.
Restart services during package upgrades without asking?
<Yes> <no>
In our case, each server we upgraded got taken out of rotation temporarily for the maintenance. We’re fine having the upgrader auto-restart services when needed, so we choose <Yes>.
Throughout the process you’re going to get a lot of questions like these:
Configuration file '/etc/crontab'
==> Modified (by you or by a script) since installation.
==> Package distributor has shipped an updated version.
What would you like to do about it ? Your options are:
Y or I : install the package maintainer's version
N or O : keep your currently-installed version
D : show the differences between the versions
Z : start a shell to examine the situation
The default action is to keep your current version.
*** crontab (Y/I/N/O/D/Z) [default=N] ?
You made modifications to a file that the system upgrade would like to overwrite. These have to be decided on a case-by-case basis. After all, you know your system best. In the example above, we have custom cronjobs per server, so we choose N (the default value) to keep our version of the file.
Whenever possible, I’ll answer I so the package maintainer’s version gets used, but you need to know the scenarios in which you can do that. One of those scenarios is sysctl.conf:
Configuration file '/etc/sysctl.conf'
==> Modified (by you or by a script) since installation.
==> Package distributor has shipped an updated version.
What would you like to do about it ? Your options are:
Y or I : install the package maintainer's version
N or O : keep your currently-installed version
D : show the differences between the versions
Z : start a shell to examine the situation
The default action is to keep your current version.
*** sysctl.conf (Y/I/N/O/D/Z) [default=N] ?
If you’ve not made any changes there (and it’s unlikely you did), it’s best to pick I for the package defaults. This is the file that overrides default kernel parameter values. Since you’re upgrading several kernel versions higher, I find it safest to take whatever the package maintainer opted to include. They probably know kernel tuning better than I do. If you did tune something, put it in its own file under /etc/sysctl.d/ and the question goes away next time.
Note: some of these prompts will be pure text/CLI, some will be in an ncurses-like UI. The UI might be different, but the questions, answers and the logic to deal with them are the same in all scenarios.
System upgrade is complete.
Restart required
To finish the upgrade, a restart is required.
If you select 'y' the system will be restarted.
Continue [yN]
Now’s the moment of truth: hit y to reboot!
Or: run it without the questions#
Once you’ve done a hop by hand and know which config files you care about, the non-interactive frontend is a lot less babysitting. This is how I run it on boxes I’ve already done once:
$ sudo -i
# tmux new -s upgrade
# do-release-upgrade -f DistUpgradeViewNonInteractive; echo "RC=$?" > /root/upgrade.rc
DistUpgradeViewNonInteractive answers yes to every question, keeps your version of every changed config file (the N from above), and does not reboot at the end. Writing the exit code to a file means I don’t have to keep an SSH session glued to it: RC=0 means it finished, anything else means read the logs before you do anything else. The logs are in /var/log/dist-upgrade/: apt-term.log is the best way to follow progress, main.log has the errors.
Because it keeps your config files, the maintainer’s versions get written next to them as .dpkg-dist files. Review those after the hop:
$ sudo find /etc -name '*.dpkg-dist' -o -name '*.dpkg-new'
On a modest 4GB VM, a hop took me 15 to 25 minutes from start to RC=0. With the reboot, the checks and getting the applications back, budget about 45 minutes per hop.
Step 3: reboot and verify the upgrade#
The interactive upgrader asks to reboot, the non-interactive one leaves it to you. Either way, reboot before you do anything else on the box. Until you do, the old kernel and long-running processes are still running against the new libraries.
Once the server has rebooted, log in and verify the upgrade is completed:
$ cat /etc/issue
Ubuntu 22.04.5 LTS \n \l
$ uname -r
$ uptime
uptime should be a minute or two. If it isn’t, the reboot never happened. Then check nothing is broken or half-configured:
$ systemctl --failed
$ sudo dpkg --audit
$ sudo ss -tlnp
dpkg --audit must print nothing. Compare the running services and listening ports with the inventory you took before the hop. A service that ran before and doesn’t now is your next job.
Step 4: re-enable your repositories#
Find what the upgrader switched off:
$ grep -rn -e 'disabled on upgrade' -e 'Enabled: no' /etc/apt/sources.list /etc/apt/sources.list.d/
A repository the upgrader couldn’t convert ends up in a file with a .disabled suffix, so check ls /etc/apt/sources.list.d/ too.
Check the codename in each line before you switch it back on, and check the repository actually publishes the new release. Some don’t care about the codename at all (Caddy’s apt repo uses any-version, NodeSource uses nodistro). Those you can just re-enable. For a PPA, sudo add-apt-repository ppa:<name> writes a fresh entry for the release you’re on now. Then sudo apt update and reinstall whatever got removed because its repository was off.
Step 5: cleanup obsolete packages#
There might still be a few packages on the system that you can now clean up.
$ sudo apt autoremove
This will prompt you to uninstall packages that seem obsolete, but the decision is ultimately yours. Read the list. On a database server it can include the binaries of the PostgreSQL version your data still lives on.
Have a look at the installed packages and see if any of these are no longer needed (think: old PHP versions).
$ apt list --installed
Then, and only then, the next hop.
Hop 1: 20.04 to 22.04#
This is the hop from the original version of this post. The changes that need attention:
- PHP 7.4 becomes PHP 8.1 if you use Ubuntu’s own PHP packages. PHP 8 removed a lot of deprecated functionality, so test your code first. The FPM socket moves too, from
/run/php/php7.4-fpm.sockto/run/php/php8.1-fpm.sock, and so does the config: your pool settings in/etc/php/7.4/fpm/pool.d/don’t get copied to/etc/php/8.1/. Afastcgi_passin nginx that still points at the 7.4 socket gives you a 502 on every PHP page. - OpenSSL 1.1.1 becomes OpenSSL 3.0.
libssl1.1is gone, so any third-party binary linked against it stops working. Per the 22.04 release notes , certificates signed with SHA1 or MD5 are now invalid at the default security level, and TLS below 1.2 is disabled. That mostly hits internal CAs and old appliances. - OpenSSH disables
ssh-rsa(RSA with SHA-1 signatures) by default. The release notes put it plainly: “If you are upgrading a system remotely over SSH, you should check that you are not relying on this”. Modern clients negotiatersa-sha2-256orrsa-sha2-512with the same RSA key and won’t notice. Very old clients and SSH libraries in deploy tools do. - Python 3.8 becomes 3.10, and every virtualenv built on the old interpreter breaks. See the Python section below, it applies to every hop.
- PostgreSQL 12 becomes 14. MySQL stays on 8.0.
- The kernel goes from 5.4 to 5.15.
Right after this hop on the Oh Dear servers, we measured a drop in PHP’s CPU usage . That post has the graphs, and the caveats.
Hop 2: 22.04 to 24.04#
- APT sources move to the deb822 format. On 24.04, the Ubuntu sources live in
/etc/apt/sources.list.d/ubuntu.sourcesinstead of/etc/apt/sources.list. The upgrader converts your.listfiles to.sourcesfiles during the hop, so look insources.list.d/afterwards, not in the old file. APT 2.7 also starts warning about repositories signed with RSA keys smaller than 2048 bits. - Python 3.12, and
piprefuses to install system-wide. 24.04 marks the system Python as externally managed (PEP 668), so asudo pip installthat worked on 22.04 now fails withexternally-managed-environment. Use a virtualenv,pipxor thepython3-*packages. Deployment scripts that pip-install into the system Python break on this hop. - SSH can be socket-activated. A fresh 24.04 install lets systemd’s
ssh.socketlisten on the port and start sshd per connection. A generator readsPortandListenAddressfrom/etc/ssh/sshd_config, so with socket activation, changing the port needssudo systemctl daemon-reloadandsudo systemctl restart ssh.socket. After the hop,systemctl is-enabled ssh.socket ssh.servicetells you which setup your box ended up with, andss -tlnpshows sshd still listens where you expect it. - PHP 8.3 with Ubuntu’s packages, so the FPM socket and config path move again, to
php8.3. - PostgreSQL 14 becomes 16. MySQL stays on 8.0 one last time.
- The kernel goes from 5.15 to 6.8.
- The 24.04 release notes
list
freeradiusas a package the upgrader accidentally removes. If you run it, reinstall it after the hop.
Hop 3: 24.04 to 26.04#
This is the newest hop and the one with the most breaking changes. Canonical’s summary for LTS users is worth reading start to finish. These are the ones that matter on a typical web server:
- MySQL 8.0 becomes 8.4, and two things break. First, options that MySQL 8.0 only warned about are now gone, and mysqld refuses to start with them in a config file.
expire_logs_dayshas to becomebinlog_expire_logs_seconds,default_authentication_pluginhas to becomeauthentication_policy. Fix your*.cnffiles before the hop. Second, themysql_native_passwordplugin is disabled by default, so every user still on it is locked out and your app throwsPlugin 'mysql_native_password' is not loaded. More on that below. - The ondrej/php PPA doesn’t publish 26.04 packages yet. I checked on 15 October 2026:
nobleis there,resolutereturns a 404. If your sites depend on a PHP version you get from that PPA, stay on 24.04 for now. Ubuntu’s own 26.04 ships PHP 8.5. - Python 3.14, so rebuild every virtualenv again.
sudoand the coreutils are now the Rust versions:sudo-rsandrust-coreutils.cp,mvandrmstill come from GNU. If a script depends on an obscure GNU flag, test it. The GNU versions are still there if you need them (coreutils-from-gnu,sudo.ws).- OpenSSH 10.2 drops DSA keys entirely. During the hop, the package also splits the old single sshd binary into
sshdandsshd-session, and its install script says right in the comments that this “starts a brief window where new connections will fail”. This is the hop where I’m happiest about the backup sshd . - APT 3.1 removes
apt-key. Provisioning scripts that add repository keys withapt-key addstop working. Put the key in/etc/apt/keyrings/and reference it withSigned-Byinstead. - cgroup v1 is gone from systemd 259. If your kernel command line still has
systemd.unified_cgroup_hierarchy=0(check/proc/cmdline), remove it before the hop. - The 26.04 upgrader warns about foreign packages. It lists every installed package that doesn’t come from the Ubuntu archive and asks if you want to continue anyway. That’s a good moment to see what you’ve collected over the years.
- PostgreSQL 16 becomes 18. The kernel goes from 6.8 to 7.0.
- chrony replaces systemd-timesyncd, but only on new installs. An upgraded box keeps timesyncd. Canonical’s notes give
sudo apt-mark auto systemd-timesyncdfollowed bysudo apt install chronyto switch. - 26.04 is the last release with System V init script compatibility. If anything on your box still starts from
/etc/init.d/, now’s the time to write a systemd unit for it, before the hop after this one.
Once you’re on 26.04, check Prompt=lts in /etc/update-manager/release-upgrades one more time. With Prompt=normal, the next do-release-upgrade offers 26.10 as soon as that’s out, and you don’t want a server on a 9-month release by accident.
Application gotchas that come back every hop#
MySQL users on mysql_native_password#
List which plugin each user authenticates with, before the 24.04 to 26.04 hop:
mysql> SELECT user, host, plugin FROM mysql.user;
For every application user on mysql_native_password, move it to caching_sha2_password. This resets the password, so use the same one the app already has:
mysql> ALTER USER 'app'@'localhost' IDENTIFIED WITH caching_sha2_password BY 'the-current-password';
Leave the mysql.* system accounts alone. Old client libraries that don’t speak caching_sha2_password are the reason you’d want the old plugin back. MySQL’s docs and Canonical’s release notes both say mysql_native_password=ON in the [mysqld] section turns it back on. On one of my boxes that line didn’t take, and only --mysql-native-password=ON on the command line (through a systemd override) did. Whichever you use, check it’s loaded:
mysql> SELECT plugin_name, plugin_status FROM information_schema.plugins
-> WHERE plugin_name = 'mysql_native_password';
PHP from the ondrej PPA#
If your PHP comes from the ondrej/php PPA
, the upgrader disables it, and those PHP packages get removed or left behind during the hop. Your config in /etc/php/<version>/ survives, the binaries and the FPM service don’t. Before every hop, check the PPA already publishes the release you’re going to:
$ curl -sI https://ppa.launchpadcontent.net/ondrej/php/ubuntu/dists/resolute/InRelease | head -1
A 200 means it’s there, a 404 means not yet. After the hop, re-add the PPA and reinstall the same php8.x-* packages. The list of extensions you had is in /etc/php/<version>/mods-available/.
Python virtualenvs#
Every hop replaces the system Python, and a virtualenv points at the interpreter it was built with. After the hop, the service crashes with a ModuleNotFoundError or won’t start at all. Before the hop, find them and save what’s in them:
$ sudo find / -xdev -name pyvenv.cfg 2>/dev/null
$ /srv/app/venv/bin/pip freeze > ~/app-requirements.txt
After the hop, rebuild each one:
$ mv venv venv.old
$ python3 -m venv venv
$ venv/bin/pip install -r ~/app-requirements.txt
PostgreSQL#
Every hop installs a new PostgreSQL major, but your data stays on the old one until you move it. I don’t run PostgreSQL on the boxes I upgraded, so this part comes from the Debian packaging tools rather than my own scars. pg_lsclusters shows what you have. If the hop created an empty main cluster for the new version, drop that one first, then upgrade the old cluster:
$ pg_lsclusters
$ sudo pg_dropcluster --stop 16 main
$ sudo pg_upgradecluster 14 main
pg_upgradecluster leaves the old cluster in place, on a different port and set to manual start, until you remove it with pg_dropcluster. And again: don’t let apt autoremove take the old version’s binaries before you’ve done this.
Caddy and other static binaries#
A Caddy v2 binary or a Go binary you dropped in /usr/local/bin doesn’t care about the Ubuntu release and survives every hop. With Caddy, I keep an eye on the logs for the first minute after the reboot, while it renews or fetches certificates.
When a box doesn’t come back#
| What you see | What I do |
|---|---|
New SSH connections get Connection reset by peer mid-upgrade | Nothing. It’s sshd upgrading itself. Use the backup sshd, don’t restart ssh, wait for the upgrade to finish and reboot. |
| Both SSH ports dark, but the box answers ping and serves HTTPS | Still nothing. Don’t kill the upgrade. It comes back once openssh is done. |
do-release-upgrade exits with an error | Read the bottom of /var/log/dist-upgrade/main.log, fix the cause, then sudo dpkg --configure -a and sudo apt -f install, then reboot. |
| Box boots without network | Log in on the console with the root password. ip link and ip addr show what the interface is called now. Bring it up by hand, SSH in, fix netplan. |
| Box doesn’t boot at all | Console, stop at the GRUB menu, e on the Ubuntu entry, add init=/bin/bash to the linux line and boot with CTRL+X. mount -o remount,rw /, fix /etc/fstab or netplan, reboot -f. |
| None of the above works | Restore the snapshot you took before the hop. That’s what it’s for. |
You’re good!#
If all went well, you’ve now upgraded your machine to the latest LTS version. When you’re done with all hops, stop the backup sshd, close port 1022 in your firewall again, and clear the package cache with sudo apt clean.
Now it’s up to you to validate that your application(s) are still functional. At Oh Dear, after the reboot, application health checks confirm that queues, Redis and scheduled jobs came back with the OS. Beyond the OS itself, the application side (queues, cron, the app itself) is covered by monitoring for PHP applications .
The upgrader prompts and messages in this post come from the source of ubuntu-release-upgrader: the jammy and noble branches, and version 1:26.04.18 on a 26.04 box. I checked the commands against that source and the man pages, but I didn’t run a fresh upgrade to write this rewrite. The package versions are from the Ubuntu archive on 15 October 2026.