Always check the release notes first: they list known issues, breaking changes, and recommended pre-upgrade actions for the specific package versions you’re about to install.
Unlike a patch upgrade, a release upgrade moves the system from one major release to the next (Ubuntu 22.04 → 24.04, Debian 11 → 12…).
That means kernel, libc, init system, sources.list, and dozens of configs all change in one shot.
There is no official downgrade path: if you mess this up, you either have to:
Roll back from a snapshot
Rebuild the server from scratch
Fix everything manually
For this reason, before touching anything:
Walk through the full pre-upgrade checks (recon, snapshot, system-state backup, pre-flight).
Read the release notes for the exact version jump you’re doing.
Have a clear rollback plan in mind before you start.
This page assumes the safety net is already in place.
Pick the right approach
There are three different roads for accomplishing a release upgrade.
Here you find a general overview:
Strategy
When to use it
Pro
Contro
A. Automatic: do-release-upgrade (Ubuntu)
You’re on Ubuntu and want the “Canonical-supported” path: it knows about EOL repos, third-party PPAs, deprecated packages, and post-release fixups.
Handles the most edge cases automatically. Cleans up cloud-archive, disables incompatible PPAs, prompts on real conflicts only.
Ubuntu-only. Can take 1-3h.
B. Manual: editing sources.list + apt dist-upgrade
Debian (no do-release-upgrade) or Ubuntu if you want full control over what runs.
No magic. You decide every step. Faster on small servers.
No automatic cleanup. PPAs and cloud-archive need manual care. Easier to get wrong if you miss something.
C. Redeploy from scratch
Immutable infra, IaC-managed (Ansible, Terraform…), stateless servers, or anything you can rebuild faster than you can debug.
Pristine state, no half-old half-new hybrids. No need to shut down the old VM, unless the new one needs to have the same IP or hostname.
Needs the automation already in place. Stateful data must be migrated separately.
So, by seeing this you could think:
“Option A is the best, its the default/standard for Ubuntu”
“Option C can be good and really clean, if my company has solid automation pipelines in place”
“Option B is risky, hence stupid to do in production”
But the reality is, from my personal experience, in production you have to go with option B most of the time.
Why is that?
Because with option A, do-release-upgrade updates everything: this means every custom repository that you have under /etc/apt/sources.list.d/, not just the Ubuntu ones!
For example, in my VPS I currently have Docker and Tailscale:
/etc/apt/sources.list.d$ ls docker.list tailscale.list
What if I don’t want to update their repositories to the next release? But do-release-upgrade does exactly that: it changes the release under every repository, and then upgrades.
Two reasons this hurts you in production:
State: for anything that holds state (databases like PostgreSQL, MySQL, MariaDB, MongoDB, queue brokers like RabbitMQ, storage layers like Ceph…) a “just bump it” upgrade is a recipe for unbootable schemas, missing migration steps, or silent data corruption.
Many of these have their own upgrade procedure (pg_upgrade, mysql_upgrade…) that must run between old and new versions, with the application stopped, often with a backup taken right before.
Compatibility matrices: vendor-supported and integration-heavy software often comes with an explicit compatibility matrix: which versions are certified together, which kernel/libc/glibc/etc. they support, which bugs are known on a specific combination.
Letting do-release-upgrade push everything forward at once can land you on a combo that:
isn’t certified: you lose vendor support for the whole stack
has known bugs you specifically didn’t want to inherit
isn’t supported yet: the vendor hasn’t released a build for the new OS release
silently breaks an integration you didn’t even know existed (e.g. a kernel module that no longer compiles against the new headers)
Both points have the same root cause: a release upgrade is an OS event, not an everything event.
Other software has its own release cycle and its own constraints, and should be upgraded on its own schedule.
So the rule of thumb is:
IMPORTANT
If the server in production runs anything stateful, vendor-certified, or business-critical, do the OS upgrade with Option B and upgrade those services separately, each with its dedicated procedure and against its own compatibility matrix.
Now that we’ve said that, let’s actually see the 3 procedures.
A: do-release-upgrade (Ubuntu)
The Ubuntu-supported path.
The tool handles sources.list rewrite, EOL/incompatible PPA disable, removed-package cleanup, and asks about config conflicts that really need a decision.
1. Start inside a screen
If SSH drops mid-upgrade, the process keeps running and you reconnect to it.
Never run a release upgrade without one.
screen -S upgrade# (later: detach with Ctrl+A then D; reattach with `screen -r upgrade`)
2. Bring the current release fully up to date
do-release-upgrade refuses to run if the current release has pending updates.
--force-confdef → if dpkg sees a conflict and a default action is available, take it.
--force-confold → otherwise, keep the old (your customised) version.
The new upstream version is preserved alongside as *.dpkg-dist so you can diff and merge later (see the post-upgrade checks).
Warning: remember to delete the file after the upgrade
This file is persistent: every future apt upgrade will silently keep your customised configs without even warning you if an upstream default changes. That’s behaviour you want only for this release upgrade, not forever.
Remove it right after the upgrade finishes:
rm /etc/apt/apt.conf.d/local-keep-configs
4. Run the release upgrade
Non-interactive mode answers default-yes on any prompt the tool considers safe:
Disable third-party PPAs known to be incompatible.
Rewrite sources.list to the new release (e.g. jammy → noble).
apt update against the new repos.
Calculate the upgrade plan and run apt dist-upgrade.
Remove packages obsolete in the new release.
Watch it from a second SSH session (open before you start), tailing logs:
# In the second session:tail -f /var/log/dist-upgrade/main.logjournalctl -f
5. Reboot, then verify
The tool prints System upgrade is complete. at the end.
Reboot once, then jump straight to verification.
reboot# After it comes back:lsb_release -a # confirms the new releaseuname -r # confirms the new kernelsystemctl is-system-running # "running" or "degraded" (if degraded → systemctl --failed)
find /etc/apt/ -type f -name "*.list" -print0 | xargs -0 sed -i 's/focal/jammy/g'# Same idea for *.sources (DEB822 format, used since Ubuntu 24.04)find /etc/apt/sources.list.d/ -type f -name "*.sources" -print0 2>/dev/null | xargs -0r sed -i 's/focal/jammy/g'
Note
This replaces the codename in every .list file (handles both /etc/apt/sources.list and /etc/apt/sources.list.d/.list), and you probably don’t want it if you choose this method!
WARNING
Verify the result before continuing: grep -r '<new-codename>' /etc/apt/. If you still see the old codename somewhere, the upgrade will pull a mix of old and new packages.
3. Refresh the index against the new repos
apt update
If this errors out (the usual 404 on a repo, signature mismatch…), don’t proceed: you have to fix the source first.
4. Run the non-interactive distribution upgrade
The two key flags:
DEBIAN_FRONTEND=noninteractive → tells dpkg no humans are watching.
Dpkg::Options::="--force-confold" → on config conflicts, keep your customised version (the new default goes alongside as *.dpkg-dist).
export DEBIAN_FRONTEND=noninteractiveapt -o Dpkg::Options::="--force-confold" \ -o Dpkg::Options::="--force-confdef" \ dist-upgrade -y # or full-upgrade, its just an alias
dist-upgrade (vs upgrade) accepts package removals and dependency changes, which is exactly what a release upgrade requires.
5. Cleanup, reboot, verify
apt autoremove --purge -yapt cleanreboot# After reboot:lsb_release -auname -rsystemctl is-system-running
Run the role/playbook that installs the application stack on the new VM (same one that built the old VM, just against the new image).
Migrate the data, if any: rsync -av /srv/data/ new-host:/srv/data/.
Cut over: switch the DNS record, the load balancer target, or the reverse-proxy backend to point at the new VM.
Soak for 24-48h, then decommission the old VM.
IMPORTANT
Keep the old VM powered off but not deleted for at least a few days after cutover. If something subtle regresses, the old VM can be powered back on and DNS flipped back in minutes.
After the upgrade
Whichever option you used, the system is on the new release… but you haven’t verified anything yet.
Walk through the full post-upgrade checks: kernel, services, application health, log review, and the all-important diff against the pre-upgrade system snapshot.
Instead, if something is broken and you can’t make it work, the upgrade rollback page covers the recovery options.