How to Migrate LXD 6.x Containers to Incus (When the Official Tool Fails)
If you’re running Ubuntu 22.04 or 24.04 with LXD and planning to migrate to Incus, you’re likely in for an unpleasant surprise. The official lxd-to-incus migration tool only supports LXD up to version 5.21, but Ubuntu’s snap package manager has silently auto-updated your LXD installation to version 6.7, making the official migration path impossible.
This article documents a proven, step-by-step procedure for migrating LXD 6.x containers to Incus manually. It was developed through extensive real-world testing on production servers and addresses every pitfall you’re likely to encounter.
Why the Official Migration Tool is Broken
The lxd-to-incus tool was written when LXD and Incus shared a common codebase under the Apache 2.0 license. After Canonical took full control of LXD and relicensed it under AGPLv3 with a Contributor License Agreement, the Incus development team could no longer legally examine the LXD 6.x source code without risking their project’s legal standing.
This means the migration tool will never be updated to support LXD 6.x. Running lxd-to-incus against LXD 6.7 produces:
Error: LXD version is newer than maximum version "5.21.99"Using --ignore-version-check bypasses this, but fails at a deeper level because the LXD 6.7 database schema (version 80) has diverged beyond what the migration tool’s SQL conversion patch can handle:
Error: Failed to start the daemon: Failed to initialize global database:
failed to ensure schema: schema version '80' is more recent than expected '77'You Cannot Downgrade LXD
If your first instinct is to downgrade the LXD snap to 5.21 and then migrate, don’t. LXD database migrations are one-way. Version 5.21 expects schema version 73, but your database is already at schema 80. The downgrade attempt will fail:
Error: Failed to initialize global database: Failed to ensure schema:
schema version '80' is more recent than expected '73'Worse, the downgrade/upgrade cycle can corrupt the LXD database state entirely, leaving you with a “First LXD execution on this system” message and an empty container list even though your data is still intact on the storage backend.
The Snap Auto-Update Trap
Ubuntu’s snap daemon automatically keeps LXD on the latest/stable channel. Every Ubuntu 22.04 and 24.04 server that’s been online has been silently upgraded from LXD 5.0 (which shipped with 22.04) to LXD 6.7. There is no warning, no opt-in, and no way back.
If you’re reading this before your LXD has been updated, pin it immediately:
snap refresh lxd --channel=5.0/stableFor everyone else, read on.
The Manual Migration Procedure
The procedure below has been tested on Ubuntu 24.04 with LXD 6.7, migrating containers to Incus 7.0 (from the Zabbly repository), using ZFS as the storage backend. The same approach works with other storage backends; only the mount commands differ.
Prerequisites
Before starting, make sure you have:
- A working LXD installation with containers you want to migrate
- Root access to the host
- Enough disk space for the Incus storage pool (can share the same physical device)
- A host-level snapshot or backup as a safety net (Hetzner Cloud snapshot, etc.)
Step 1: Fix the UID/GID Mapping (Ubuntu Only)
This is the most critical step for Ubuntu hosts, and the one most likely to cause mysterious permission errors if skipped.
LXD uses a default unprivileged container UID mapping starting at host UID 1000000 with a range of 1000000000. Incus uses the same defaults if /etc/subuid and /etc/subgid don’t exist. However, Ubuntu’s shadow package pre-populates these files with root:100000:65536, a completely different and much smaller range.
On Ubuntu, replace the contents of both files:
cat > /etc/subuid << 'EOF'
root:1000000:1000000000
EOF
cat > /etc/subgid << 'EOF'
root:1000000:1000000000
EOFOn Debian, these files either don’t exist or are empty, so Incus automatically falls back to 1000000:1000000000. No action needed. This is one of several reasons Debian provides a smoother migration experience.
Step 2: Install Incus
Add the Zabbly repository and install Incus. Do not initialize it yet.
mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
sh -c 'cat <<EOF > /etc/apt/sources.list.d/zabbly-incus-stable.sources
Enabled: yes
Types: deb
URIs: https://pkgs.zabbly.com/incus/stable
Suites: $(. /etc/os-release && echo ${VERSION_CODENAME})
Components: main
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/zabbly.asc
EOF'
apt update
apt install incusNote: the lxd-to-incus tool is in the incus-extra package, not incus-tools (which conflicts with the Zabbly packages). You won’t need it for this procedure, but it’s worth knowing.
Step 3: Create the Incus Storage Pool
If you’re using ZFS, create a new dataset within your existing pool to avoid conflicts with the LXD data:
zfs create <your-pool>/incusThen initialize Incus:
incus admin initWhen prompted:
- Clustering: no
- Storage pool name: default
- Storage backend: zfs
- Create new ZFS pool: no
- Existing ZFS pool or dataset: your-pool/incus
- Network bridge: yes, name it incusbr0 (use a different subnet than LXD’s
lxdbr0, e.g. 10.0.1.1/24) - Defaults for everything else
Step 4: Stop LXD Containers
lxc stop --all
lxc listVerify all containers show as STOPPED.
Step 5: Migrate Each Container
For each container, perform the following four commands. This is the core of the procedure.
Create an empty container:
incus create <container-name> --emptySet the idmap state (critical step):
incus config set <container-name> volatile.last_state.idmap='[{"Isuid":true,"Isgid":false,"Hostid":1000000,"Nsid":0,"Maprange":1000000000},{"Isuid":false,"Isgid":true,"Hostid":1000000,"Nsid":0,"Maprange":1000000000}]'This tells Incus that the filesystem already has the correct UID mapping applied. Without this, Incus assumes the filesystem is unmapped and corrupts file ownership on first start, causing services like MariaDB and Nginx to fail with permission errors.
Copy the rootfs:
mkdir -p /tmp/old /tmp/new
mount -t zfs <your-pool>/containers/<container-name> /tmp/old
mount -t zfs <your-pool>/incus/containers/<container-name> /tmp/new
rsync -aAX --numeric-ids /tmp/old/rootfs/ /tmp/new/rootfs/
umount /tmp/old /tmp/newThe --numeric-ids flag ensures UIDs and GIDs are preserved exactly as they are on disk, without any name-based translation.
Start the container:
incus start <container-name>The container should come up with full networking (via DHCP from the Incus bridge) and all services running correctly.
Step 6: Configure Networking and Firewall
Incus manages its own nftables rules for the bridge network, handling NAT, forwarding, DNS, and DHCP automatically. If you had manual iptables or nftables rules for LXD, you need to:
- Flush the old iptables/nftables rules
- Update any remaining firewall rules to reference
incusbr0instead oflxdbr0and the new container IP addresses - If you need incoming traffic forwarded to containers, set up DNAT rules or use Incus proxy devices
For fixed container IPs:
incus config device override <container-name> eth0 ipv4.address=10.0.1.10
incus restart <container-name>Step 7: Disable LXD
Once everything is verified:
snap disable lxdExporting for Portable Migration
Once your containers are running on Incus, you can export them as portable tarballs:
incus stop <container-name>
incus export <container-name> /tmp/<container-name>.tar.gzThese tarballs contain the complete container rootfs and configuration with all file ownership preserved. You can import them on any Incus server:
incus import /tmp/<container-name>.tar.gz
incus start <container-name>This is particularly useful if you want to migrate to a completely new server with a different OS (e.g., moving from Ubuntu to Debian) or a different storage backend (e.g., ZFS to Btrfs).
Key Findings and Gotchas
The volatile.last_state.idmap setting is everything. Without it, the container starts with corrupted file ownership. Services fail with cryptic permission errors (Can't create/write to file, Operation not permitted), and incus shell fails with initgroups: Operation not permitted. With it set correctly before the first start, everything works perfectly, including incus shell.
Ubuntu’s /etc/subuid defaults conflict with LXD’s UID mapping. LXD uses UID base 1000000, Ubuntu defaults to 100000. If Incus reads Ubuntu’s default, containers get the wrong UID range and nothing maps correctly. On Debian, this problem doesn’t exist because the files aren’t pre-populated.
Old LXD ZFS snapshots are not migrated. The rsync copies only the current state of the rootfs. Any ZFS snapshots on the old LXD datasets remain on the original pool but are not connected to the new Incus containers. For most migrations, this is acceptable since the old snapshots predate the migration anyway.
The incus admin recover tool doesn’t work with LXD backup.yaml files. The LXD 6.x backup.yaml format uses an instance: key where Incus expects container:, and the volume metadata structure differs. Even after patching the key name, the recovery tool fails on volume validation.
The lxd-to-incus --ignore-version-check flag is not a reliable workaround. While it bypasses the version check, the LXD 6.7 database schema has diverged too far for the migration SQL to convert correctly. The migrated database is rejected by Incus with schema version errors.
Debian vs Ubuntu: A Smoother Migration
If you’re planning new infrastructure or rebuilding servers as part of the migration, consider using Debian instead of Ubuntu. The migration experience is significantly cleaner on Debian:
- No
/etc/subuidconflict (Incus defaults to the correct range) - No snap package manager (no auto-update trap)
- No LXD/Canonical baggage
- Incus installs cleanly from the Zabbly repository with no package conflicts
This is somewhat ironic: Ubuntu is where most LXD users are migrating from, yet Debian provides the better destination.
Summary
The LXD 6.x to Incus migration gap is a real problem affecting thousands of administrators. The official tool is permanently broken for LXD 6.x, and the snap auto-update mechanism ensures that nearly every Ubuntu LXD installation is affected.
The manual migration procedure documented here provides a reliable, tested alternative. The six steps are straightforward, and the entire process can be completed in under an hour for most setups. The key insight is that setting volatile.last_state.idmap before the first container start prevents all the permission-related issues that make this migration seem more difficult than it actually is.
This article is based on a real migration performed on production infrastructure in May 2026. All commands and outputs were verified on Ubuntu 24.04 with LXD 6.7 migrating to Incus 7.0 from the Zabbly repository, using ZFS as the storage backend.
