# How to move a DigitalOcean Droplet from Ubuntu to Debian

> Move a DigitalOcean Droplet from Ubuntu to Debian: new server, copy the data, then switch the reserved IP. In-place conversion is unsupported.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-11-03 | Topics: [Security](https://flaviocopes.com/tags/security/) | Canonical: https://flaviocopes.com/ubuntu-to-debian-digitalocean/

As of 2 November 2026, Ubuntu's tracker still lists [CVE-2026-98432](https://ubuntu.com/security/CVE-2026-98432) as Vulnerable on every current LTS, with a fix in progress and no published USN. Debian's security archive for Debian 13 already has a fixed kernel. If that Droplet is on DigitalOcean and you want the fix now, build a new Debian 13 Droplet, copy the service, and move the address.

The bug itself, the kernel check, and the MPTCP stop gap are in [CVE-2026-98432: check your Linux server and close public ports](https://flaviocopes.com/linux-tcp-cve-2026-98432/). This post is the move.

[Pieter Levels](https://x.com/levelsio/status/2117272451545514001) wrote on 2 November 2026: "So @Ubuntu still hasn't patched this open hole on any Ubuntu server on the public internet that lets anyone take it over. We might just have to switch all our servers over to Debian in this new post-AGI time of rapid cybersecurity hacks which require just as rapid patching. Slowpokes will be left behind." He linked [WTF Ubuntu?](https://chalmovsky.com/posts/wtf-ubuntu), which says: "Debian patched it on October 16. Ubuntu still doesn't have a patch more than two weeks later. Every supported release, LTS included, is listed as "Vulnerable"." Chalmovsky dated that page "as of 2026-11-02 14:48 UTC." The same post quotes Levels' earlier note on [closing every VPS port](https://x.com/levelsio/status/2116949634111115348).

Chalmovsky's October 16 date is his. The Debian tracker names [DSA-6568-1](https://security-tracker.debian.org/tracker/CVE-2026-98432) as the first fixed package for trixie (`linux` 6.12.113-1). The announce mail for that advisory was not available from debian.org, so the day stays the article's, and the package status stays the tracker's.

## Where the two trackers stood on 2 November 2026

Ubuntu's page was last updated 30 October 2026. The main `linux` package:

| Ubuntu release | Status on the tracker |
| --- | --- |
| 26.10 | Vulnerable, work in progress in 7.3.0-10.10 |
| 26.04 LTS | Vulnerable, work in progress in 7.0.0-43.43 |
| 24.04 LTS | Vulnerable, work in progress in 6.8.0-149.149 |
| 22.04 LTS | Vulnerable, work in progress in 5.15.0-203.213 |
| 20.04 LTS | Vulnerable |
| 18.04 LTS, 16.04 LTS | Vulnerable |
| 14.04 LTS | Not affected |

DigitalOcean's current Ubuntu images are 22.04, 24.04, and 26.04. All three were still Vulnerable. A 22.04 Droplet that boots the HWE kernel is a different row: `linux-hwe-6.8` on jammy was Vulnerable, work in progress in 6.8.0-149.149~22.04.1. Match `uname -r` to the row, which the [CVE post](https://flaviocopes.com/linux-tcp-cve-2026-98432/) already walks through. Cloud and OEM kernels have their own rows too.

This delay is inside standard support. Ubuntu Pro and Kernel Livepatch have nothing to apply until a fixed kernel exists. The [CVE post](https://flaviocopes.com/linux-tcp-cve-2026-98432/) says the same thing.

Debian's tracker the same day:

| Archive | Package | Status |
| --- | --- | --- |
| trixie (Debian 13) | 6.12.107-1 | vulnerable |
| trixie-security | 6.12.115-2 | fixed |
| bookworm-security | 6.1.190-1 | fixed, origin DLA-4841-1 |
| bookworm, linux-6.12 security | 6.12.113-1~deb12u1 | fixed, origin DLA-4850-1 |
| forky, sid | 7.2.12-1 and 7.2.15-1 | fixed |

The first fixed trixie version on that page is 6.12.113-1, origin DSA-6568-1. The package in trixie-security on 2 November was newer, 6.12.115-2. [DSA-6602-1](https://www.debian.org/security/), listed that day as a linux security update, does not name CVE-2026-98432. It is a later kernel. The tracker still marks that newer package fixed because it is past 6.12.113-1.

The base suite is still the vulnerable 6.12.107-1. A Debian 13 machine that never enables `trixie-security` will install the vulnerable kernel. Check the candidate before you call the box patched. The command and the output are below.

## Ubuntu and Debian, without the panic

Switching the whole fleet because one kernel is late is a real choice. It is a bad choice if you need something Debian does not ship.

[Ubuntu's release cycle](https://ubuntu.com/about/release-cycle) publishes a version every six months. Interim releases get nine months of updates. An LTS lands every two years, with five years of standard security maintenance for the Main archive. Ubuntu Pro adds ESM and the Legacy add-on, up to 15 years, and Kernel Livepatch. Pro is free for personal use on up to five machines. ESM covers Main and Universe for 10 years.

On that page, 24.04 LTS has standard support through May 2029 and ESM through May 2034. 22.04 LTS has standard support through May 2027. 26.04 LTS has standard support through May 2031. 20.04 LTS left standard support in May 2025.

[Debian's releases page](https://www.debian.org/releases/) describes about five years for a stable release: three of full support, then two of LTS. Debian 13 "trixie" was released on 9 August 2025. The latest point release listed there is 13.7, on 12 September 2026. Full support runs to 9 August 2028, LTS to 30 June 2030, and the page lists an ELTS date of 30 June 2035. Debian 12 "bookworm" left full support on 11 July 2026. Its LTS runs to 30 June 2028.

Debian stable is the quieter archive. Ubuntu's interim releases and Ubuntu Pro's long window are the other deal. Snaps are an Ubuntu feature. The release-cycle page describes them, and Ubuntu Core is an all-snap system. Debian does not install `snapd` by default. On a Debian 13.7 system installed from the Debian archive on 2 November 2026, `dpkg-query` reported `snapd` as not installed. Run `snap list` on the Ubuntu Droplet before you assume every service has a `.deb`.

A Launchpad PPA is an Ubuntu archive. Debian's FAQ is direct about derivatives: "These distributions are Debian-based, but they are not Debian." And, on migrating: "You should make a backup of all your data and reinstall the operating system from scratch. You should not attempt to "upgrade" to Debian using the package management tools as you might end up with an unusable system." ([Debian FAQ, 3.2.1 and 3.2.3](https://www.debian.org/doc/manuals/debian-faq/debian-faq.en.txt).)

Package freshness cuts both ways. Debian 13's `nodejs` candidate on 2 November 2026 was 20.19.2+dfsg-1+deb13u3 from `trixie-security`. The same day, [nodejs.org/en/download](https://nodejs.org/en/download) offered Node.js 26.13.0 LTS, 24.22.2 LTS, and 22.23.4 LTS, and listed the 20.x line as EOL (20.20.2). The distro package is the one Debian will patch. It is the old line.

## Leave the disk alone

People do write up an in-place conversion. One [Unix & Linux answer](https://unix.stackexchange.com/questions/344408/migrate-from-ubuntu-to-debian-and-take-the-application-data-along) describes adding Debian repositories, pinning the Ubuntu packages down, and running a dist-upgrade, including a claimed run from Ubuntu 22.04 to Debian 11. A [Hacker News thread](https://news.ycombinator.com/item?id=33174214) has the other stories: kernel updates and Wi-Fi that broke.

That recipe stays off this page. The FAQ already says the package tools can leave you with an unusable system. The two archives rebuild the same upstream with different patches, different kernel package names, and different `/etc` conffiles. A half-finished dist-upgrade is a box you SSH into once. A new Droplet keeps the old one bootable until you move the address.

## The route

Inventory the Ubuntu Droplet, snapshot it, create a Debian 13 Droplet in the same region, copy the data, test, then move the reserved IP or the DNS record. Power the old Droplet off. Destroy it after you have lived on the new one for a few days.

```mermaid
flowchart TD
  accTitle: Move a Droplet from Ubuntu to Debian
  accDescr: Inventory the Ubuntu server, snapshot it, create a Debian 13 Droplet, copy and test, move the reserved IP, power the old Droplet off, and destroy it after a few days.
  Inv[Inventory Ubuntu] --> Snap[Snapshot]
  Snap --> New[Create Debian 13]
  New --> Copy[Copy and test]
  Copy --> IP[Move reserved IP]
  IP --> Off[Power the old one off]
  Off --> Del[Destroy after a few days]
```

[Rebuild](https://docs.digitalocean.com/products/droplets/how-to/rebuild/) is the other button, and it is the destructive one. DigitalOcean says a rebuild wipes the disk, keeps the Droplet's IP, and only offers images from the same OS family. The docs do not say whether Ubuntu and Debian count as one family. A DigitalOcean ideas thread uses a Debian-to-Ubuntu rebuild as the example of a cross-family rebuild the panel refuses. If Debian 13 appears in that Droplet's rebuild image list, the panel will let you wipe the disk and install it. If it does not, stop. The CLI form, from the rebuild docs, is:

```bash
doctl compute droplet-action rebuild "$OLD_ID" --image debian-13-x64
```

The doctl commands in this post are the documented ones. They were not run against a DigitalOcean account while this was written. `203.0.113.25` below is DigitalOcean's documentation address. Replace it.

Use rebuild only when three things are true. The data already lives somewhere else. You need that exact public IP, because destroying a Droplet releases it and DigitalOcean says you are unlikely to get it back. And Debian 13 is actually in the list. Rebuild cannot keep the old disk online while you test. You also get a new SSH host key.

```mermaid
flowchart TD
  accTitle: Rebuild or a new Droplet
  accDescr: Rebuild wipes the disk and keeps the public IP only when Debian is in that Droplet's image list. A new Droplet leaves the old disk online until the reserved IP moves.
  Q{Debian in the rebuild list, and data already copied?}
  Q -->|Yes, and you need this IP| Re[Rebuild wipes the disk]
  Q -->|No| New[New Droplet, then move the IP]
```

[Resize](https://docs.digitalocean.com/products/droplets/how-to/resize/) changes CPU, RAM, and disk. It does not change the OS. You cannot shrink the disk. Skip it for this job. The [doctl guide](https://flaviocopes.com/doctl/) has the resize command if you need a bigger plan later.

## Inventory the Ubuntu box

SSH in and write the answers down. Copy them off the server. A snapshot is a disk image. This list is how you know what to reinstall.

Packages you installed yourself:

```bash
apt-mark showmanual
```

Running services:

```bash
systemctl list-units --type=service --state=running
```

Listening ports:

```bash
ss -tulpn
```

Cron and timers:

```bash
ls /etc/cron.* /var/spool/cron/crontabs
systemctl list-timers --all
```

Users and keys:

```bash
getent passwd
ls /root/.ssh /home/*/.ssh
```

Then the files the service actually depends on. Nginx or Caddy config under `/etc`. `ufw status verbose` if you use ufw. Compose files and `docker ps` / `docker volume ls`. Anything in `/etc/letsencrypt` and every `.env` the app reads. `snap list`, and every file under `/etc/apt/sources.list.d` that points at a PPA.

Databases get their own dump. A disk snapshot of a running database can catch a half-written page. These are the usual redirects. They were not run against a database for this post. A dump of a busy server can miss in-flight writes. Use the database's own backup page if you need a consistent online dump. PostgreSQL's is [SQL dump](https://www.postgresql.org/docs/current/backup-dump.html).

```bash
pg_dump app > app.sql
mysqldump app > app.sql
```

## Snapshot, then create Debian 13

[Snapshot pricing](https://docs.digitalocean.com/products/snapshots/details/pricing/), last verified by DigitalOcean on 26 October 2026, is $0.06 per GB per month for Droplets, with a $0.01 minimum. You pay that until you delete the snapshot. Power the Droplet off first if you want a consistent disk. That is downtime. Take the database dump either way.

```bash
doctl compute droplet-action shutdown "$OLD_ID" --wait
doctl compute droplet-action snapshot "$OLD_ID" \
  --snapshot-name ubuntu-before-debian \
  --wait
doctl compute droplet-action power-on "$OLD_ID" --wait
```

`shutdown` asks the guest to stop. The [doctl guide](https://flaviocopes.com/doctl/) notes that the action can finish without the guest actually powering off, and that `power-off` is the forced one. Forced power cuts through writes. Check the status before you trust the snapshot.

DigitalOcean's [image table](https://docs.digitalocean.com/products/droplets/details/images/), last verified 13 July 2026, lists one Debian image: Debian 13, slug `debian-13-x64`, image ID 252660345. Ubuntu on that page is `ubuntu-22-04-x64`, `ubuntu-24-04-x64`, and `ubuntu-26-04-x64`. There is no Debian 12 image in the table. Debian 13 is current stable, and it is the image DigitalOcean offers. Image IDs move. Confirm the slug before you create:

```bash
doctl compute image list-distribution
```

Copy the old Droplet's region, size, VPC, and SSH key. The size slugs below are examples from DigitalOcean's docs (`s-1vcpu-1gb`, region `nyc3`). Read the real ones with `doctl compute droplet get "$OLD_ID"`. [Droplet pricing](https://docs.digitalocean.com/products/droplets/details/pricing/) bills a Droplet from create until destroy. A powered-off Droplet is still billed, because the CPU stays reserved. Bundled plans bill per second, with a 60 second or $0.01 minimum, and a cap of 672 hours in a month. The v5 plans have no monthly cap. A public IPv4 on v5 is a separate line. The pricing page's introductory amount was $0.00 and marked as subject to change. Copy your current size. This post does not pick a new one.

The panel path is the same one in [Create a VPS on DigitalOcean](https://flaviocopes.com/digitalocean-create-vps/). Choose Debian 13 on the Distributions list. With doctl, pass the key. If you omit `--ssh-keys`, DigitalOcean emails a temporary root password.

```bash
NEW_ID=$(doctl compute droplet create debian-web \
  --region nyc3 \
  --image debian-13-x64 \
  --size s-1vcpu-1gb \
  --ssh-keys "$SSH_KEY_ID" \
  --vpc-uuid "$VPC_UUID" \
  --enable-monitoring \
  --wait \
  --format ID \
  --no-header)
```

`--vpc-uuid` is for a non-default VPC. The default VPC is `--enable-private-networking`. Both flags are on the [droplet create reference](https://docs.digitalocean.com/reference/doctl/reference/compute/droplet/create/) (generated 28 October 2026, doctl v2.1.0). `--wait` waits for the Droplet action. It does not mean cloud-init has finished. `cloud-init status --wait` on the new box does.

Put the new Droplet in the same Cloud Firewall before you open SSH to the world. Cloud Firewalls are free and stateful, and the default is deny. If the old firewall is attached by tag, pass that `--tag-name` on create, which is how the [doctl guide](https://flaviocopes.com/doctl/) does it. If it is attached to Droplet IDs, add the new one. From the [add-droplets reference](https://docs.digitalocean.com/reference/doctl/reference/compute/firewall/add-droplets/), generated the same day:

```bash
doctl compute firewall add-droplets "$FIREWALL_ID" --droplet-ids "$NEW_ID"
```

A Droplet can sit in more than one firewall. The rules are a union. An allow-everything rule plus a tight rule is allow-everything. Read the set before you call it done.

A volume can attach to one Droplet at a time. Detach it from the old one, then attach it to the new one. From the volume action docs, generated 28 October 2026:

```bash
doctl compute volume-action detach "$VOLUME_ID" "$OLD_ID"
doctl compute volume-action attach "$VOLUME_ID" "$NEW_ID"
```

DigitalOcean auto-mounts a pre-formatted volume on Debian Droplets created on or after 26 April 2018. Older guests need the [mount steps](https://docs.digitalocean.com/products/volumes/how-to/mount/). You can attach up to 15 volumes.

## Harden it before you copy the app

The long version of a locked-down VPS is [How to secure a VPS with Tailscale and Cloudflare Tunnel](https://flaviocopes.com/secure-vps/). SSH itself is [SSH for developers](https://flaviocopes.com/ssh-for-developers/). Both of those, and the free [VPS course](https://flaviocopes.com/courses/vps/) and [Linux server security course](https://flaviocopes.com/courses/linux-server-security/), are written around Ubuntu. The habits transfer. The package details below are Debian's.

[Where to host](https://flaviocopes.com/where-to-host/) is the earlier question, if a VPS is the wrong shape and this move is a good moment to stop.

On the OpenSSH package in that Debian 13.7 install (`1:10.0p1-7+deb13u4`), `sshd -T` reported:

```text
permitrootlogin without-password
pubkeyauthentication yes
passwordauthentication yes
kbdinteractiveauthentication no
```

Root can log in with a key. Password login is still on for other users. A DigitalOcean image may override this in cloud-init. Read `sshd -T` on the Droplet. Then set keys only, and create a sudo user before you close root.

```bash
adduser deploy
usermod -aG sudo deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
```

Put your public key in `/home/deploy/.ssh/authorized_keys`, mode `600`, owner `deploy`. In `/etc/ssh/sshd_config`:

```text
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
```

`sshd -t` must succeed before you reload ssh. The [SSH post](https://flaviocopes.com/ssh-for-developers/) has the rest, including what to do when the reload locks you out. Keep DigitalOcean's web console open while you test the new user.

`unattended-upgrades` 2.12 on that system wrote `/etc/apt/apt.conf.d/20auto-upgrades` as:

```text
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
```

The active lines in `50unattended-upgrades` were:

```text
Unattended-Upgrade::Origins-Pattern {
    "origin=Debian,codename=${distro_codename},label=Debian";
    "origin=Debian,codename=${distro_codename},label=Debian-Security";
    "origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
};
```

The `-updates`, `-proposed-updates`, and backports lines were commented out. So was `// "linux-";` inside the package blacklist, which means kernel packages are eligible. `Unattended-Upgrade::Automatic-Reboot` was commented out, including the `"02:00"` time. Security updates install. The new kernel does not boot until you reboot.

```bash
sudo apt install unattended-upgrades
sudo unattended-upgrades --dry-run
```

`--dry-run` is the documented simulation. It downloads and does not install. [Securing a server with AI](https://flaviocopes.com/securing-a-server-with-ai/) shows this on Ubuntu. The file above is the Debian one.

Levels' layout is still the one to end on. SSH over Tailscale. The public site through a Cloudflare Tunnel, which [the Tunnel guide](https://flaviocopes.com/cloudflare-tunnel/) covers for a named tunnel. The [VPS guide](https://flaviocopes.com/secure-vps/) builds the pair and says what to test before you delete the public SSH rule. Close port 22 only after the new path works, and only with the web console still open.

The host firewall is the same idea as on Ubuntu. `ufw` is in Debian 13 (candidate 0.36.2-9 on 2 November 2026). Follow the rules in the VPS guide. A ruleset was not applied on Debian for this post. Docker is the exception: publishing a container port bypasses ufw. Docker's Debian install page says so, and points at [Docker and ufw](https://docs.docker.com/engine/network/packet-filtering-firewalls/#docker-and-ufw). The same page says Docker wants `iptables-nft` or `iptables-legacy`. Rules you create with `nft` directly are not supported. Put Docker rules on the `DOCKER-USER` chain.

## Install the equivalents

Three traps show up immediately.

`snapd` is absent unless you install it. A snap with no Debian package is a service you have to replace, or you stay on Ubuntu for that one box.

A PPA line in `sources.list` will not resolve on Debian. Delete those files. Debian trixie prefers deb822 (`.sources` files). `apt modernize-sources` converts an old `sources.list`. Third-party archives can still break the system. The [Debian wiki page on sources](https://wiki.debian.org/SourcesList) is the warning. Add one only when you know who publishes it.

Docker's apt repo is per distro. The Ubuntu file points at `download.docker.com/linux/ubuntu`. Debian's points at `download.docker.com/linux/debian`. Copying the Ubuntu file across is how you install nothing, or the wrong packages.

Docker's [Debian install](https://docs.docker.com/engine/install/debian/) lists Debian Trixie 13 and Bookworm 12. Uninstall the distro packages first. `apt` may say none of them are installed. That is fine.

```bash
sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-doc docker-buildx podman-docker containerd runc | cut -f1)
```

Then the repository, as that page gives it:

```bash
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```

On Debian 13.7 on 2 November 2026, that install produced:

```text
docker-ce 5:29.9.2-1~debian.13~trixie
docker-ce-cli 5:29.9.2-1~debian.13~trixie
containerd.io 2.4.2-3~debian.13~trixie
docker-buildx-plugin 0.38.1-1~debian.13~trixie
docker-compose-plugin 5.7.0-1~debian.13~trixie
```

`docker info` showed client 29.9.2, Buildx v0.38.1, and Compose v5.7.0. The server line failed, because nothing was listening on `/var/run/docker.sock`. That system was not booted with systemd, so `systemctl status docker` and `docker run hello-world` were not run. On a Droplet, those two are the checks Docker's page asks for. The sample `apt list` output on Docker's page still showed a Bookworm build, `5:29.8.2-1~debian.12~bookworm`. The trixie packages above are what apt installed that day. Versions move. Install the current ones. Pin a version only when you have a reason.

Node, if you need it: `sudo apt install nodejs` installs Debian's package. The candidate that day was `20.19.2+dfsg-1+deb13u3` from security, and `20.19.2+dfsg-1+deb13u2` from trixie main. That is the 20.x line Node's own download page marks EOL. For a current LTS, use [Node's download page](https://nodejs.org/en/download). The NodeSource steps in [How to upgrade Node.js on Ubuntu](https://flaviocopes.com/ubuntu-how-to-upgrade-nodejs/) are for Ubuntu. Leave them there.

Nginx's candidate the same day was `1.26.3-3+deb13u10` from `trixie-security`, and `1.26.3-3+deb13u7` from trixie. `sudo apt install nginx` follows the candidate. It was not installed in the test.

The kernel is the package that matters for this CVE. Before `trixie-security` was configured, `apt-cache policy linux-image-amd64` offered:

```text
Candidate: 6.12.107-1
```

That version is the vulnerable one on the tracker. After this deb822 file, the candidate changed:

```text
Types: deb
URIs: http://deb.debian.org/debian-security
Suites: trixie-security
Components: main
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
```

```text
linux-image-amd64:
  Installed: (none)
  Candidate: 6.12.115-2
  Version table:
     6.12.115-2 500
        500 http://deb.debian.org/debian-security trixie-security/main amd64 Packages
     6.12.115-1 500
        500 http://deb.debian.org/debian-security trixie-security/main amd64 Packages
     6.12.107-1 500
        500 http://deb.debian.org/debian trixie/main amd64 Packages
```

A DigitalOcean image may already include a security suite, possibly on DigitalOcean's own mirror. Trust the candidate version. If it is still 6.12.107-1, the security suite is missing. Install `linux-image-amd64` once the candidate is 6.12.115-2 or newer, then reboot, then check `uname -r` against the tracker.

Those package results come from Debian 13.7 (trixie), `VERSION_ID=13`, installed from `deb.debian.org` on 2 November 2026. It was a chroot, not a DigitalOcean image, and it was not booted on its own kernel.

## Move the data

Copy service data. Do not rsync `/` from Ubuntu onto Debian. That overwrites Debian's `/etc` with Ubuntu's and puts you back in the unsupported conversion.

`rsync` 3.5.0+ds1-0+deb13u1 says `--archive` (`-a`) is `-rlptgoD`, and that set does not include ACLs, xattrs, or hard links. `-H`, `-A`, and `-X` add those. `--numeric-ids` keeps the uid and gid numbers and does not translate them by name. A file copy with these flags was not run for this post. The flags are what that `rsync --help` prints.

```bash
rsync -aHAX --numeric-ids \
  deploy@203.0.113.10:/var/www/ /var/www/
```

Run `id deploy` on both machines. If the name exists on both and the numbers differ, `--numeric-ids` preserves the Ubuntu number, and `ls -l` on Debian will show a number where you expected a name. `chown` to the Debian user after the copy when the numbers disagree. Do that on purpose. A blind `chown -R` across `/var/lib` will hand database files to the wrong user.

Restore the dumps on the new box:

```bash
psql app < app.sql
mysql app < app.sql
```

Same caveat as the dump. These were not executed here. Create the database and the user first, with the passwords from the old `.env`. PostgreSQL's data directory is a bad rsync target while the server is running. Prefer the dump.

Docker volumes live under `/var/lib/docker/volumes`. With the daemon stopped on both sides, an `rsync -aHAX --numeric-ids` of one volume's `_data` directory works. With the daemon up, `docker run` a temporary container and tar the volume. The daemon did not start in the test, so that tar copy was not run. Compose files come across with the project directory. Recreate the containers on Debian after Docker is installed. An Ubuntu image id is not a Debian install.

Certificates in `/etc/letsencrypt` can be copied, and the account key with them. Treat that directory like a password. After cutover, run your renewal tool's dry run from its own docs. Caddy and Traefik renew themselves. Read the log the next morning.

## Test before anyone else hits it

Point one machine at the new IP and leave DNS alone.

`/etc/hosts` on your laptop:

```text
203.0.113.25 example.com
```

Or the same trick from [a curl --resolve example](https://flaviocopes.com/fundamentals-ai-era/):

```bash
curl --resolve example.com:443:203.0.113.25 https://example.com/
```

Replace the name and the address. A temp subdomain, `new.example.com` with a short TTL, does the same job from a phone. Click the real paths. Log in. Write a row. Watch `journalctl -u` for the service you moved. If TLS fails, the certificate name and the Host header are the usual cause, and the old Droplet is still serving the real name, so you can take the time.

## Cut over

A reserved IP is the clean switch. [Reserved IP pricing](https://docs.digitalocean.com/products/networking/reserved-ips/details/pricing/), last verified 26 October 2026: free while it is assigned to a Droplet. An unassigned reserved IPv4 is $5.00 per month ($0.01 per hour), and it is not billed until you accrue $1. Reserved IPv6 is free even when it is unassigned.

The [doctl guide](https://flaviocopes.com/doctl/) says a reserved IP moves to another compatible Droplet in the same region. Keep the new Droplet there. The assign command, from DigitalOcean's reserved-ip-action reference (generated 28 October 2026):

```bash
doctl compute reserved-ip-action assign "$RESERVED_IP" "$NEW_ID"
```

Their example is `doctl compute reserved-ip-action assign 203.0.113.25 386734086`. That reference does not publish how long the move takes. Watch the address until it answers on the new Droplet. DNS does not change, which is the point.

If the name is an ordinary A record, lower the TTL a day or more before the move, wait out the old TTL, then change the record. [That DNS section](https://flaviocopes.com/fundamentals-ai-era/) is the one to read. The old IP keeps answering until caches expire. Leave the old service up through that window. Stopping it early is how visitors get connection refused while their resolver still has the old address. I hit that on a previous Droplet move. It is below.

## Check it from outside

On the new box:

```bash
systemctl --failed
journalctl -u nginx -u docker --since "10 min ago"
sudo unattended-upgrades --dry-run
```

Use the unit names you actually run. Confirm the renewal log, and confirm the backup job still has somewhere to write. The snapshot on the old Droplet is a rollback of the old disk. It is not a backup of the new one. Set the new backup the same day.

From a second machine, not the Droplet:

```bash
nmap -Pn -p 22,80,443 203.0.113.25
```

`nmap` was not run for this post. You want to see the ports you meant to publish, and nothing else. If 22 is still open and Tailscale already works, that is the leftover to close, using the [VPS guide](https://flaviocopes.com/secure-vps/).

## Then destroy the old one

Power it off and leave it for a few days. You can boot it if the new box is wrong. [Droplet pricing](https://docs.digitalocean.com/products/droplets/details/pricing/) is explicit: a powered-off Droplet on a bundled plan is still billed, because the resources stay reserved. Billing stops when you destroy it. The snapshot keeps billing at $0.06 per GB per month until you delete that too.

```bash
doctl compute droplet-action shutdown "$OLD_ID" --wait
```

When the new box has lived through a backup, a renewal, and a reboot:

```bash
doctl compute droplet delete "$OLD_ID"
```

`delete` asks for confirmation. The [doctl guide](https://flaviocopes.com/doctl/) has the same command.

## Stay patched

`unattended-upgrades` covers the archive. It will not reboot the machine while `Automatic-Reboot` stays commented out. After a kernel install, ask `needrestart`. Version 3.11-1 was the package on that Debian 13.7 system. Its batch output there was:

```text
NEEDRESTART-VER: 3.11
NEEDRESTART-KCUR: 6.12.94+
NEEDRESTART-KSTA: 0
NEEDRESTART-UCSTA: 0
```

`6.12.94+` was the kernel of the host that check ran under. `KSTA` 0 is "unknown", from the package's own codes: 0 unknown, 1 no upgrade, 2 ABI upgrade, 3 version upgrade. On a Droplet you booted yourself, 1 means the expected kernel is running. 3 means the running version differs from the one on disk, and you reboot. `needrestart` with no flags walks services one by one. `-b` prints the lines above. `-r a` restarts services without asking. Leave that for a window you chose.

Subscribe to [debian-security-announce](https://www.debian.org/security/). Debian's security page tells you to. Read it when a linux DSA shows up, then run:

```bash
apt-cache policy linux-image-amd64
```

Install, reboot, and check the tracker row again. The [CVE post](https://flaviocopes.com/linux-tcp-cve-2026-98432/) is the habit: the package name on disk is the only status that counts.

## How I would do this

The last time I moved a service to a new Droplet, I kept the old one answering until DNS caught up, and I destroyed it only after I was sure nothing still pointed at it. That story is [the Sendy migration](https://flaviocopes.com/devops-mistakes-ai/). I would do the same shape here, on a new Debian 13 Droplet, and I would leave the Ubuntu box powered off for a few days because the old disk is the rollback. I have not converted an Ubuntu disk to Debian, and I would not start with that.

Two cases where I would stay on Ubuntu. A snap or a PPA package with no Debian build, if that package is the product. And Ubuntu Pro, if you are paying for the long support window or Livepatch on a fleet that can wait for Ubuntu's kernel. This CVE is a reason to move a box that is exposed and still Vulnerable. It is a weak reason to rebuild a private build machine that has no public ports. Closing those ports is still the other half, in the [CVE post](https://flaviocopes.com/linux-tcp-cve-2026-98432/) and the [VPS guide](https://flaviocopes.com/secure-vps/).

On the new Debian Droplet, the first command is `apt-cache policy linux-image-amd64`. The candidate should be the security build, 6.12.115-2 or newer as of 2 November 2026. Then subscribe to debian-security-announce, so the next one does not wait on a blog post.
