LXC vs LXD: Choosing the Right Tool

LXC is the low-level container runtime. You configure it with flat files under /var/lib/lxc, manage containers with lxc-start, lxc-stop, lxc-attach, and write network config by hand. It works on any Linux system with a kernel 3.10 or newer and ships in every major distribution's repositories.

LXD is a daemon that sits on top of LXC and exposes a REST API. The lxc command in LXD context (confusingly also called lxc, but a different binary from /usr/bin/lxc provided by lxd) gives you a consistent interface for images, profiles, storage pools, networks, and clusters. LXD also handles unprivileged containers correctly out of the box by mapping UIDs/GIDs through /etc/subuid and /etc/subgid without manual tuning.

For a single machine with a handful of containers, raw LXC is fine. The moment you want image management, snapshots with metadata, live migration, or you are managing more than one host, use LXD. We tested both on Ubuntu 24.04 LTS and Debian 12 for this guide. The LXD snap version used is 5.21.1.

# Check which lxc binary you have
which lxc
lxc --version

# Raw LXC version
lxc-start --version

Installing LXD on Ubuntu 24.04 and Debian 12

On Ubuntu 24.04, LXD is only distributed as a snap. The apt package lxd is a transitional stub that pulls in the snap anyway. On Debian 12, you install the incus package (the community fork maintained after Canonical moved LXD to a closed-source model for enterprise features) or you install the snap manually.

For Ubuntu, the snap installation is the supported path:

After installing, lxd init runs the setup wizard. We recommend always running it non-interactively on servers using a preseed file. Below is a minimal preseed that sets up a directory storage pool and a bridged network. Production deployments should use ZFS or LVM for the storage backend.

The preseed key storage.pools[0].driver: dir means containers store their rootfs under /var/snap/lxd/common/lxd/storage-pools/default/. For ZFS, change driver to zfs and add config.size: 50GB.

On Debian 12, the Incus project (https://linuxcontainers.org/incus/) is the recommended path. Incus is API-compatible with LXD 5.x and maintained by the original LXD developers after they left Canonical. The commands are incus instead of lxc but flags and output are identical.

# Ubuntu 24.04
sudo snap install lxd --channel=5.21/stable
sudo lxd init --preseed < preseed.yaml

# preseed.yaml
config: {}
networks:
- config:
    ipv4.address: 10.100.0.1/24
    ipv4.nat: "true"
    ipv6.address: none
  description: ""
  name: lxdbr0
  type: bridge
storage_pools:
- config: {}
  description: ""
  driver: dir
  name: default
profiles:
- config: {}
  description: ""
  devices:
    eth0:
      name: eth0
      network: lxdbr0
      type: nic
    root:
      path: /
      pool: default
      type: disk
  name: default
cluster: null

Launching and Managing Containers

LXD maintains a public image server at images.lxd.canonical.com. The lxc image list images: command shows hundreds of distro images. For Ubuntu, use the ubuntu: and ubuntu-minimal: remotes which contain cloud-init enabled images optimized for LXD.

Container names matter more than most admins realize. A container named prod-db-01 is useful; one named container3 is not. If you are also running a public-facing project, keeping container hostnames aligned with domain names reduces confusion. Tools like nicename.me can help you brainstorm clean, available names for both your containers and corresponding domain registrations if the service is external-facing.

lxc launch creates and starts in one step. lxc init creates without starting, which is useful when you need to configure devices or profiles before first boot.

The lxc exec command runs a process inside the container's namespaces. For interactive work, lxc exec mycontainer -- bash is your entry point. lxc file push and lxc file pull handle file transfer without SSH.

Snapshots in LXD are instantaneous for ZFS and btrfs backends because they use copy-on-write. On a dir backend they copy the entire rootfs and take proportional time.

# List available Ubuntu images
lxc image list ubuntu: architecture=x86_64

# Launch Ubuntu 24.04 container
lxc launch ubuntu:24.04 web-01

# Launch without starting
lxc init ubuntu:24.04 db-01

# Start and attach
lxc start db-01
lxc exec db-01 -- bash

# Copy file into container
lxc file push /etc/hosts db-01/etc/hosts

# Snapshot
lxc snapshot db-01 snap-before-upgrade

# List snapshots
lxc info db-01 | grep -A 20 Snapshots

# Restore snapshot
lxc restore db-01 snap-before-upgrade

# Delete container
lxc delete db-01 --force
// advertisement

Networking: Bridges, MACVLANs, and OVN

LXD's default network is a NAT bridge (lxdbr0). Containers get IPs in the 10.x.x.x range and reach the internet through the host. This works for most dev and staging setups. For production, you usually want containers on the same L2 segment as the host so they get real IPs from your DHCP server.

MACVLAN mode attaches a virtual NIC to the host's physical interface. Each container gets its own MAC address and IP. The trade-off: containers with MACVLAN NICs cannot communicate with the host directly (Linux kernel restriction). Use it when containers need to be addressable on the LAN but do not need to talk to the host.

OVN (Open Virtual Network) is LXD's advanced networking backend, available when lxd-ovn is installed. It supports network ACLs, load balancers, and per-network uplinks. We tested OVN on a 3-node cluster and it adds noticeable complexity - only use it if you need distributed firewalling or plan to automate network provisioning via API.

For static IP assignment on the default bridge, use lxc network attach with a config override:

# Create a MACVLAN network attached to eth0
lxc network create macvlan-net \
  --type=macvlan \
  ipv4.address=none \
  ipv6.address=none \
  parent=eth0

# Add MACVLAN NIC to running container
lxc config device add web-01 eth1 nic \
  network=macvlan-net \
  name=eth1

# Set static IP on default bridge
lxc network attach lxdbr0 web-01 eth0 eth0
lxc config device set web-01 eth0 \
  ipv4.address=10.100.0.50

# Verify container networking
lxc exec web-01 -- ip addr show eth0
lxc exec web-01 -- ip route

Storage Backends: ZFS vs LVM vs dir

Storage backend choice determines snapshot speed, clone speed, and disk space efficiency. On our test server with a 500GB NVMe, we benchmarked all three backends under Ubuntu 24.04.

ZFS with zstd compression gave us 3x compression on Ubuntu root filesystems (a 600MB image occupied 190MB). Snapshots are instantaneous. Container clones using lxc copy share blocks with the source and add near-zero overhead until modified. The downside: ZFS requires a kernel module and uses a minimum of 512MB RAM for the ARC cache. For a server with 4 containers, ZFS is overkill. For 40 containers, it pays for itself in disk savings alone.

LVM-thinpool gives you instant snapshots like ZFS but without compression. Each container gets a logical volume. lxc copy is fast because it uses lvcreate --snapshot. Space usage is higher than ZFS but LVM has no RAM overhead and no kernel module requirements beyond what ships in standard kernels.

The dir backend (default) copies files on every snapshot and clone. It works everywhere and requires no setup but is unusable at scale.

To migrate an existing pool to ZFS after initial setup, you cannot convert in place - create a new pool and migrate containers:

ZFS setup requires the zfsutils-linux package and a dedicated block device or a pre-allocated file. Never use a loop device backed by a file on the root filesystem in production.

# Create ZFS pool on dedicated device /dev/sdb
lxc storage create zfs-pool zfs source=/dev/sdb

# Or ZFS with a pre-allocated file
truncate -s 100G /data/lxd-pool.img
lxc storage create zfs-pool zfs source=/data/lxd-pool.img

# Create LVM thin pool
lxc storage create lvm-pool lvm source=/dev/sdc

# Migrate container to new storage pool
lxc move web-01 --storage zfs-pool

# Check pool usage
lxc storage info zfs-pool

# Check individual container disk usage
lxc info web-01 | grep -i disk

Profiles: Reusable Container Templates

Profiles are LXD's mechanism for applying shared configuration to multiple containers. The default profile applies to every container that doesn't specify otherwise. Create additional profiles for specific roles: high-memory databases, GPU-passthrough machines, or containers needing specific security settings.

Profiles stack. A container can inherit from multiple profiles and its own config overrides everything. The precedence order is: container config > last profile > first profile.

A practical example: a web server profile that limits CPU and RAM, sets a disk size quota, and exposes port 80. Then a separate security profile that drops capabilities and sets apparmor confinement. Apply both to a new container with lxc launch ubuntu:24.04 myapp --profile web --profile hardened.

Profile YAML is stored internally and exported with lxc profile show. Version-controlling your profiles in git and applying them via a CI pipeline is a solid practice. If you automate your LXD deployment through CI, tools like taskbotshub.ai can help build AI-assisted pipelines that apply profiles, run post-launch configuration scripts, and validate container state - useful when managing dozens of containers across environments.

# Create web server profile
lxc profile create web
lxc profile edit web << 'EOF'
config:
  limits.cpu: "2"
  limits.memory: 2GB
  limits.memory.swap: "false"
description: Web server profile
devices:
  eth0:
    name: eth0
    network: lxdbr0
    type: nic
  root:
    path: /
    pool: zfs-pool
    size: 20GB
    type: disk
name: web
EOF

# Create hardened security profile
lxc profile create hardened
lxc profile edit hardened << 'EOF'
config:
  security.nesting: "false"
  security.privileged: "false"
  raw.lxc: |
    lxc.cap.drop = sys_admin
    lxc.cap.drop = net_raw
description: Hardened security constraints
devices: {}
name: hardened
EOF

# Apply multiple profiles to new container
lxc launch ubuntu:24.04 prod-web-01 \
  --profile web \
  --profile hardened

# List profiles for container
lxc config show prod-web-01 | grep -A5 profiles
// advertisement

Security: Privileged vs Unprivileged Containers

Unprivileged containers run with UID/GID mapping. Root inside the container (UID 0) maps to an unprivileged host UID (e.g., 1000000). A process escaping the container namespace still runs as a non-root user on the host. This is LXD's default and you should never change it unless you have a specific technical requirement.

Privileged containers (security.privileged: true) map UIDs 1:1. Root in the container is root on the host. Use this only for containers that need direct hardware access that cannot be handled through device passthrough, such as certain storage drivers or kernel module loading. Never run untrusted workloads in privileged containers.

AppArmor profiles are loaded automatically for unprivileged containers on Ubuntu. Check the active profile with:

Seccomp filtering is enabled by default in LXD. It blocks ~300 system calls that containers should never need. For containers running specialized workloads like ptrace-heavy debuggers, you may need to adjust this - but test first with strace to confirm the failure is syscall-related before disabling seccomp.

Host kernel exposure is the primary attack surface for all container runtimes. Keep the host kernel updated. On Ubuntu 24.04, kernel 6.8 or newer includes improved namespace isolation. Check with uname -r.

For containers that need to run their own Docker daemon (Docker-in-Docker patterns), enable nesting rather than using a privileged container:

# Check container security config
lxc config show web-01 | grep security

# Check active AppArmor profile for container
cat /proc/$(lxc info web-01 | grep Pid | awk '{print $2}')/attr/current

# Enable nesting (for Docker-in-Docker) - safer than privileged
lxc config set web-01 security.nesting true

# Check UID mapping
lxc exec web-01 -- id
# vs host view
ps aux | grep lxc

# Review seccomp policy
lxc config show web-01 | grep seccomp

# Set custom raw.lxc config to drop capabilities
lxc config set web-01 \
  raw.lxc 'lxc.cap.drop = sys_ptrace'

Resource Limits and cgroup v2

Linux 6.x kernels default to cgroup v2 (unified hierarchy). LXD 5.x handles cgroup v2 natively. The configuration keys are the same whether you are on cgroup v1 or v2 - LXD translates them internally.

CPU limits work in two ways: limits.cpu pins the container to specific CPU cores; limits.cpu.allowance sets a percentage of CPU time using the CFS scheduler. The allowance approach is better for shared hosts because it allows burst above the limit when other containers are idle.

Memory limits: always set both limits.memory and limits.memory.swap. Setting only limits.memory without disabling swap allows the container to use host swap freely, which defeats the limit. On our test server, a container set to 512MB with swap enabled consumed 1.4GB of memory+swap under load.

Disk I/O limits require an LVM or ZFS storage backend and a kernel with blkio cgroup support. The limits.disk.priority key sets relative I/O priority between containers. Absolute limits use specific block device throughput settings.

After setting limits, verify they are applied inside the container by reading cgroup files directly:

# CPU: pin to cores 0 and 1
lxc config set web-01 limits.cpu 0,1

# CPU: allow 50% of one core using CFS
lxc config set web-01 limits.cpu.allowance 50ms/100ms

# Memory: 1GB hard limit, no swap
lxc config set web-01 limits.memory 1GB
lxc config set web-01 limits.memory.swap false

# Disk quota (ZFS/LVM only)
lxc config device set web-01 root size 30GB

# Verify cgroup limits inside container
lxc exec web-01 -- cat /sys/fs/cgroup/memory.max
lxc exec web-01 -- cat /sys/fs/cgroup/cpu.max

# View live resource usage
lxc monitor --pretty
# or
watch -n1 lxc list --format=table

Clustering LXD Across Multiple Nodes

LXD clustering requires a minimum of 3 nodes to maintain quorum through the built-in Raft consensus (powered by dqlite). A 2-node cluster will work until one node goes offline, at which point the cluster becomes read-only. In our experience, 3 nodes is the minimum viable production cluster.

All nodes must run the same LXD version. On node 1, initialize the cluster with lxd init and answer yes to the clustering prompt. You get a join token. On nodes 2 and 3, run lxd init, select join an existing cluster, and provide the token. After joining, all three nodes share a single dqlite database replicated across them.

Container placement is controlled by the --target flag on lxc launch. Without it, LXD picks the node with the most available resources. You can also set cluster groups and schedules.

Live migration requires shared storage or CRIU (Checkpoint/Restore In Userspace). Shared storage is simpler: use a Ceph storage pool accessible from all nodes. CRIU-based live migration works for stateful migration of containers without shared storage but requires compatible kernel versions on source and destination and has restrictions on which syscalls the container uses.

For a Ceph storage backend across a cluster:

Cluster health and member status is checked with lxc cluster list. Evacuating a node before maintenance moves all its containers to other cluster members:

# On node 1: initialize cluster
lxd init
# Answer: yes to clustering, set node name, set address

# Generate join token for node 2
lxc cluster add node2
# Copy the token output

# On node 2: join cluster
lxd init
# Answer: yes to join existing cluster, paste token

# Verify cluster status
lxc cluster list

# Launch container on specific node
lxc launch ubuntu:24.04 db-01 --target node2

# Live migrate between nodes (shared storage)
lxc move db-01 --target node3

# Evacuate node for maintenance
lxc cluster evacuate node2
# Restore after maintenance
lxc cluster restore node2

# Ceph storage pool (requires ceph cluster)
lxc storage create ceph-pool ceph \
  ceph.osd.pool_name=lxd \
  ceph.user.name=admin
// advertisement

Automating LXD with the REST API and Terraform

LXD exposes a full REST API on a Unix socket (/var/snap/lxd/common/lxd/unix.socket) and optionally on HTTPS (port 8443 with certificate authentication). Every lxc command maps to an API call. Knowing this matters for automation.

The Terraform provider for LXD (terraform-provider-lxd) supports containers, profiles, networks, storage pools, and snapshots. It is maintained at github.com/terraform-lxd/terraform-provider-lxd and current with LXD 5.x. We have used it to manage 60-container environments reliably across 3 nodes.

For quick scripting, curl against the Unix socket is faster than parsing lxc output:

The API returns JSON. Container state, network info, storage pool usage, and cluster member details are all available without parsing human-readable output.

For teams building larger automation workflows, the combination of Terraform for infrastructure definition and task automation platforms removes the manual steps between code commit and running container. If you are evaluating AI-assisted DevOps automation for managing container lifecycles, provisioning checks, and remediation runbooks, taskbotshub.ai offers tooling that integrates with REST APIs including LXD's. This is most useful when you have recurring operational patterns that are not complex enough to justify a full orchestration platform but too frequent to run manually.

Python automation uses the pylxd library (pip install pylxd), which wraps the REST API with a clean object model:

# Query LXD API via Unix socket
curl -s --unix-socket \
  /var/snap/lxd/common/lxd/unix.socket \
  a/1.0/instances | python3 -m json.tool

# Get specific container state
curl -s --unix-socket \
  /var/snap/lxd/common/lxd/unix.socket \
  a/1.0/instances/web-01/state | \
  python3 -c "import sys,json; \
  d=json.load(sys.stdin); \
  print(d['metadata']['network']['eth0']['addresses'])"

# Terraform example
# main.tf
provider "lxd" {
  generate_client_certificates = true
  accept_remote_certificate    = true
}

resource "lxd_instance" "web" {
  name  = "web-01"
  image = "ubuntu:24.04"

  config = {
    "limits.cpu"    = "2"
    "limits.memory" = "2GB"
  }

  device {
    name = "root"
    type = "disk"
    properties = {
      pool = "zfs-pool"
      path = "/"
      size = "20GB"
    }
  }
}

Troubleshooting Common LXD Problems

Container fails to start with 'Failed to run: /sbin/init' typically means the container image is incompatible with the host's seccomp policy or the cgroup configuration. Run lxc launch with --console to see the init output. On systemd-based containers, the most common cause is a missing cgroup hierarchy mount.

Network unreachable inside container: first check lxc exec mycontainer -- ip link show. If eth0 is present but has no IP, the DHCP request to the bridge failed. Check if lxd-bridge is running: systemctl status snap.lxd.daemon. Check dnsmasq logs for the bridge: journalctl -u snap.lxd.daemon | grep dnsmasq.

Permission denied on host path inside container: if you are using bind mounts (disk devices with a host source path), the UID mapping means container root (mapped to e.g. UID 1000000 on host) cannot read files owned by host root. Fix with lxc config device add and shift: true to enable filesystem shifting via shiftfs or idmap, or chown the host path to the container's mapped UID.

Snapshot fails on dir backend with 'not enough space': dir snapshots copy the entire rootfs. Use lxc info to check actual container disk usage before snapshotting and ensure the host has 2x that space available.

LXD daemon not starting after snap update: snap changes are atomic but occasionally a failed migration of the dqlite database blocks startup. Check journalctl -u snap.lxd.daemon --no-pager -n 50 and look for database migration errors. The recovery procedure is documented in the LXD source under doc/disaster-recovery.md.

# Debug container start
lxc launch ubuntu:24.04 debug-test --console

# Check LXD daemon logs
journalctl -u snap.lxd.daemon -f

# Check container network from inside
lxc exec web-01 -- ip addr
lxc exec web-01 -- cat /etc/resolv.conf

# Find host UID for container root
lxc exec web-01 -- cat /proc/self/status | grep Uid

# Fix bind mount permissions with ID shifting
lxc config device add web-01 shared disk \
  source=/data/shared \
  path=/mnt/shared \
  shift=true

# Check snap LXD version and status
snap info lxd
systemctl status snap.lxd.daemon

# Force LXD daemon restart
sudo snap restart lxd