IMPORTANT

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:

StrategyWhen to use itProContro
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 scratchImmutable 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:

  1. 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.

  2. 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.

Patch first, then upgrade the release: check out linux-patch-management.

3. Tell dpkg to keep your existing configs

By default, the release upgrade will prompt you for every customised config file it finds.

We want it to silently keep what we have, except where conflicts genuinely need a human.

cat > /etc/apt/apt.conf.d/local-keep-configs <<'EOF'
DPkg::options { "--force-confdef"; "--force-confold"; }
EOF

What these do:

  • --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).

4. Run the release upgrade

Non-interactive mode answers default-yes on any prompt the tool considers safe:

export DEBIAN_FRONTEND=noninteractive
do-release-upgrade -f DistUpgradeViewNonInteractive

This will:

  1. Disable third-party PPAs known to be incompatible.
  2. Rewrite sources.list to the new release (e.g. jammynoble).
  3. apt update against the new repos.
  4. Calculate the upgrade plan and run apt dist-upgrade.
  5. 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.log
journalctl -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 release
uname -r                       # confirms the new kernel
systemctl is-system-running    # "running" or "degraded" (if degraded → systemctl --failed)

Then walk through the full post-upgrade checks.

B: manual sources editing

The “expert” path.

Works on Debian (where do-release-upgrade doesn’t exist) or when you want full visibility into what runs.

You’re doing manually what do-release-upgrade automates: rewrite sources.list, then upgrade.

The risk is missing cleanup steps (deprecated PPAs, cloud-archive, Snap-only packages on Ubuntu…) that the tool would have caught.

1. Same as Option A

Start inside a screen

screen -S upgrade
# (later: detach with Ctrl+A then D; reattach with `screen -r upgrade`)

And patch before upgrading.

2. Rewrite sources.list to the new release

Replace the codename everywhere you need.

Example for Ubuntu 20.04 (focal) → 22.04 (jammy):

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=noninteractive
apt -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 -y
apt clean
reboot
 
# After reboot:
lsb_release -a
uname -r
systemctl is-system-running

Then run the post-upgrade checks.

C: redeploy from scratch

The pragmatic path when the infra is automated.

Instead of mutating the live machine, you provision a fresh VM on the target release and migrate workload + data over.

This is the modern infra-as-code mindset: if something needs a major upgrade, you rebuild it.

No half-old, half-new state ever exists.

You don’t even need to shut down the old one in the meantime, unless the new one needs to use the same IP address or hostname.

Unfortunately, many times its the case: internal config files, or even external ones of other VMs that target the current server).

When this makes sense

  • The server is stateless or its state lives elsewhere (database on a managed service, files on object storage, config in Git).
  • You have IaC (MAAS, Terraform, Ansible, cloud-init…) that can reproduce the box from a single command.
  • You can tolerate a short cutover window (DNS TTL, load balancer switch…).

The general flow

  1. Provision a new VM on the target release with your IaC tool:
    terraform apply  # or: ansible-playbook site.yml --limit new-host
  2. 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).
  3. Migrate the data, if any: rsync -av /srv/data/ new-host:/srv/data/.
  4. Cut over: switch the DNS record, the load balancer target, or the reverse-proxy backend to point at the new VM.
  5. 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.