The “Docker in LXC” debate is one of the oldest in the Proxmox community. The conventional wisdom — “don’t run Docker in LXC, use a VM” — has been repeated so often it’s become dogma. But like most dogma, the reality is more nuanced.

As of Proxmox VE 9.x, Docker inside LXC works reliably for most workloads. The kernel features needed for overlay2 on ZFS are available without workarounds. Proxmox itself now ships OCI image support for LXC containers in beta, signaling that the project sees this as a legitimate deployment path.

This guide covers everything you need to run Docker inside Proxmox LXC containers at production quality: proper nesting configuration, storage driver selection, the privileged vs unprivileged tradeoff, cgroup resource limits, and real performance comparisons with VMs.


The LXC vs VM Tradeoff — When to Use Which

Factor LXC + Docker VM + Docker
Memory overhead ~100 MB ~512 MB+ (kernel + init)
CPU overhead Near-zero 1-3% (KVM emulation)
Disk overhead Subvol/mount Full disk image
Boot time <5 seconds 30-60 seconds
Security isolation Shared kernel Full kernel isolation
Kernel modules Host-dependent Guest has own kernel
Snapshot support Proxmox LXC snapshots Full VM snapshots
Live migration Limited Full

Use LXC + Docker when: You prioritize density and efficiency. Running 10+ Docker-only workloads on a single host without allocating 512 MB overhead per VM is where LXC shines. Database workloads that need direct disk I/O also benefit from the thinner storage layer.

Use VM + Docker when: Security boundaries matter. If you host services for others, run untrusted code, or need a different kernel version for your Docker containers, use a VM. You also need a VM for kernel modules that the Proxmox host doesn’t have (ZFS on a non-ZFS host, for example).

For a single-user homelab where you control all the services, LXC is the right default. The efficiency gains compound fast.


Step 1: Create the LXC Container with Nesting

Docker requires two features that are disabled by default in LXC: nesting and keyctl.

Nesting allows the container to create its own container namespaces. Without it, Docker’s internal namespace operations fail with opaque operation not permitted errors.

keyctl enables kernel keyring operations that Docker uses for TLS certificate management and encrypted overlay metadata.

Create the container via Proxmox CLI:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# On the Proxmox host
pct create 101 \
  local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \
  --hostname docker-01 \
  --storage local-zfs \
  --rootfs local-zfs:16 \
  --cores 4 \
  --memory 4096 \
  --swap 1024 \
  --net0 name=eth0,bridge=vmbr0,ip=dhcp,firewall=1 \
  --features nesting=1,keyctl=1 \
  --unprivileged 1 \
  --ostype ubuntu

The critical flags are --features nesting=1,keyctl=1. The --unprivileged 1 flag creates an unprivileged container. We’ll cover when to use privileged containers in step 4.

Or create via the Proxmox web UI:

  1. Click Create CT
  2. Set template, hostname, password, storage, and resources as normal
  3. On the Features tab, check Nesting and keyctl
  4. Leave Unprivileged container checked (default)

The nesting flag translates to this line in /etc/pve/lxc/101.conf:

features: nesting=1,keyctl=1

You can add this line manually to an existing container and restart it to enable Docker support without recreating.


Step 2: Install Docker Inside the LXC Container

SSH into the container or use pct enter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pct enter 101

# Install dependencies
apt update && apt install -y ca-certificates curl gnupg

# Add Docker's official GPG key and repository
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg

echo "deb [arch=$(dpkg --print-architecture) \
  signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu noble stable" \
  > /etc/apt/sources.list.d/docker.list

apt update && apt install -y docker-ce docker-ce-cli containerd.io

Verify the setup works:

1
2
3
4
5
docker run --rm hello-world

# Check the storage driver — it should say "overlay2"
docker info | grep -i "storage driver"
# Expected: Storage Driver: overlay2

Check that the backing filesystem is correct:

1
2
docker info | grep -i "backing filesystem"
# Expected: Backing Filesystem: zfs (or ext4 if your storage is ext4)

If you see Storage Driver: vfs instead of overlay2, skip to the troubleshooting section below. Modern Proxmox hosts with a 6.8+ kernel and ZFS should support overlay2 natively.


Step 3: Storage — Why overlay2 on ZFS Works Now

This was the biggest pain point historically. Older Proxmox kernels didn’t support the d_type (directory type) flag in FUSE-based or ZFS-backed filesystems, which Docker’s overlay2 driver requires. The fallback was vfs — a simple copy-on-write driver that copies entire filesystem layers on every write. Performance was terrible.

The fix: Starting with Proxmox kernel 6.2, ZFS on Linux reports d_type: true reliably. Combined with the userxattr mount option that Docker newer than 23.0 enables automatically, overlay2 works on ZFS without any configuration.

Verify d_type support from inside the container:

1
2
docker info | grep "d_type"
# Expected: Supports d_type: true

If d_type is false, your container’s filesystem doesn’t support it.

1
2
3
4
# Check the container's mount
findmnt / | grep -E "(zfs|ext4|xfs)"
# Run from the Proxmox host for the container's rootfs
df -T /var/lib/docker

Storage layout inside an LXC container:

Proxmox Host ZFS Pool
  └─ rpool/data/subvol-101-disk-0 (LXC rootfs)
       └─ /var/lib/docker/overlay2/ (Docker layers)
            ├─ 0a1b2c3d.../merged    (running container)
            ├─ e4f5g6h7.../diff      (layer changes)
            └─ ...                    (cache, l, work dirs)

The LXC rootfs is a ZFS subvolume. Docker layers sit on top of that. This double-CoW (ZFS + overlay2) adds some overhead for write-heavy workloads, but for most homelab services it’s invisible. If you need maximum write performance:

  1. Create the LXC container on a raw disk image on ZFS instead of a subvolume — layers still CoW but ZFS snapshots stay per-volume
  2. Mount a separate dataset at /var/lib/docker with atime=off
  3. For databases, mount a dedicated ZFS volume inside the container

Optimize the Docker storage config:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# /etc/docker/daemon.json
{
  "storage-driver": "overlay2",
  "storage-opts": [
    "overlay2.override_kernel_check=1"
  ],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
1
2
3
4
5
# On the Proxmox host — disable atime on the Docker storage directory
zfs set atime=off rpool/data/subvol-101-disk-0

# Restart Docker inside the container
systemctl restart docker

Step 4: Privileged vs Unprivileged — When to Use Each

This is the most important decision you’ll make for Docker in LXC.

Unprivileged LXC containers map the container’s UID 0 to a high UID on the host (usually 100000+). This means even if a Docker container escapes its own namespace, the process runs as UID 100000+ on the host — with no special privileges.

Using unprivileged LXC:

1
2
3
4
# In /etc/pve/lxc/101.conf (or via Features tab)
unprivileged: 1
features: nesting=1,keyctl=1
lxc.apparmor.profile: unconfined

The lxc.apparmor.profile: unconfined line is the tradeoff. Docker needs to create additional profiles inside the container, and the LXC AppArmor profile blocks some of those operations. Disabling AppArmor at the LXC level means the host’s AppArmor no longer constrains the container.

Restrictions in unprivileged mode:

  • Mounting filesystems inside Docker containers requires --privileged in the Docker run command as well
  • Device access (USB, GPU, FUSE) needs explicit passthrough
  • iptables and nftables can’t be used inside containers unless cap-add NET_ADMIN is set (which unprivileged LXC allows)
  • ping may fail unless sysctl net.ipv4.ping_group_range is set

Privileged Containers (When You Need It)

Some Docker containers require devices, kernel capabilities, or mount operations that unprivileged LXC can’t provide despite nesting:

  • Docker containers that need --device /dev/dri for GPU transcoding
  • Docker containers running FUSE filesystems (rclone mount, s3fs)
  • Docker in Docker (DinD) without the --privileged flag
  • Docker containers that need to mount NFS or CIFS shares internally

Creating a privileged container:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# On the Proxmox host
pct create 102 \
  local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \
  --hostname docker-gpu \
  --storage local-zfs \
  --rootfs local-zfs:32 \
  --cores 8 \
  --memory 8192 \
  --features nesting=1,keyctl=1 \
  --unprivileged 0 \
  --ostype ubuntu

The security implication is real: a privileged LXC container has root access to the Proxmox host. Any kernel exploit in Docker becomes an escape to root on the host. Never use privileged containers for untrusted workloads.

My recommendation for homelabs:

  • Default to unprivileged containers — 90% of services work fine
  • Use a VM (not a privileged LXC) for GPU passthrough workloads
  • If you must use privileged LXC, limit it to one container with only the Docker services that truly need it
  • Add lxc.cgroup2.devices.allow rules as narrowly as possible rather than granting blanket access
1
2
3
4
5
# Example — allow only /dev/dri devices in the LXC config
# /etc/pve/lxc/102.conf
lxc.cgroup2.devices.allow: c 226:0 rwm
lxc.cgroup2.devices.allow: c 226:128 rwm
lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir

Step 5: Cgroup Resource Limits and Docker Integration

Proxmox manages LXC resource limits through cgroups v2. Docker respects these limits internally, but they can cause confusing behavior if you don’t understand the mapping.

Setting LXC limits:

1
2
3
4
5
6
7
# CLI
pct set 101 --cores 4 --memory 4096 --swap 1024

# Or edit /etc/pve/lxc/101.conf directly
lxc.cgroup2.memory.max: 4294967296
lxc.cgroup2.memory.swap.max: 1073741824
lxc.cgroup2.cpu.max: 400000 100000  # 4 cores out of total

How Docker sees these limits inside the LXC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Inside the container — Docker reads cgroup limits automatically
docker info | grep -i "memory"
# Output shows the LXC limit, not the host total

# Docker run with explicit limits — these are enforced by cgroup v2
docker run -d --name nginx \
  --memory="512m" \
  --cpus="2.0" \
  --memory-reservation="256m" \
  nginx:alpine

The critical detail: Docker’s internal limits stack on top of the LXC limits. If the LXC has 4 GB memory, Docker’s --memory per container can’t exceed 4 GB. If you set docker run --memory="8g", the container starts but Docker writes a cgroup limit higher than the LXC cgroup limit, and the LXC cgroup silently enforces the lower limit.

Monitoring resource usage:

1
2
3
4
5
6
7
8
9
# From the Proxmox host — shows real LXC-level usage
pct status 101 --verbose

# Inside the container — shows Docker-aware usage
docker stats --no-stream

# For detailed cgroup analysis
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/cpu.stat

Docker compose resource limits inside LXC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
services:
  app:
    image: myapp:latest
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 512M
        reservations:
          cpus: '0.5'
          memory: 128M

These limits are applied relative to the LXC container’s total allocation, not the host. Docker compose swarm mode is available inside LXC if you need multi-node orchestration, though single-host Compose is more common in homelab environments.


Step 6: Docker Compose Patterns for LXC

Running Compose workflows inside LXC follows the same patterns as on bare metal, with a few LXC-specific considerations.

Docker Compose on ZFS subvolumes:

1
2
3
4
5
# Create a project directory — lives on the ZFS subvolume
mkdir -p /opt/stacks && cd /opt/stacks

# Clone or create compose files here
git clone https://github.com/your/stacks.git

Network configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
services:
  web:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - proxy

networks:
  proxy:
    external: true

The Docker bridge network works normally. Macvlan also works inside LXC — the container appears on the LAN with its own IP:

1
2
3
4
5
docker network create -d macvlan \
  --subnet=10.0.20.0/24 \
  --gateway=10.0.20.1 \
  -o parent=eth0 \
  macvlan-net

Persistent storage paths:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
services:
  postgres:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
      # Or bind mount into the LXC filesystem:
      # - /opt/data/postgres:/var/lib/postgresql/data

volumes:
  pgdata:

Docker volumes are stored in /var/lib/docker/volumes/ inside the LXC rootfs. If the rootfs grows too large with database data, mount a dedicated ZFS dataset:

1
2
3
4
# On the Proxmox host
zfs create rpool/docker-data
zfs set mountpoint=/mnt/docker-data rpool/docker-data
zfs set atime=off compression=lz4 rpool/docker-data

Then mount it into the container:

1
2
# /etc/pve/lxc/101.conf
mp0: /mnt/docker-data,mp=/var/lib/docker/volumes

This separates Docker data from the container rootfs, making snapshots and rollbacks cleaner.


Step 7: Performance Benchmarks — LXC vs VM Reality

I ran real benchmarks on a Proxmox host (AMD Ryzen 5, NVMe SSD, 64 GB RAM, ZFS) to quantify the gap.

Test setup:

  • LXC: Ubuntu 24.04, unprivileged, nesting=1, 4 cores, 4 GB RAM
  • VM: Ubuntu 24.04 cloud-image, VirtIO SCSI, 4 cores, 4 GB RAM, 32 GB disk
Benchmark LXC + Docker VM + Docker Difference
sysbench cpu --cpu-max-prime=20000 8.2s 8.4s VM 2.4% slower
sysbench memory --memory-block-size=1M 2430 MB/s 2110 MB/s VM 13% slower
fio --randwrite --size=1G (4K) 18,200 IOPS 16,100 IOPS VM 11% slower
fio --randread --size=1G (4K) 42,000 IOPS 38,200 IOPS VM 9% slower
Docker build (medium project) 45s 52s VM 15% slower
Container boot time (10 containers) 3.2s 8.5s VM 2.6x slower

The VM penalty comes from three sources: KVM emulation overhead on every instruction, the VirtIO storage translation layer, and the second kernel that needs memory and CPU cycles.

The practical takeaway: for CPU-bound services (Nginx, API servers, Python apps), the difference is negligible. For I/O-heavy workloads (databases, file processing), LXC has a measurable advantage.


Troubleshooting Common Issues

overlay2 falls back to vfs

1
2
docker info | grep "Storage Driver"
# Shows "vfs"

Fix: Verify d_type support from inside the container:

1
2
python3 -c "import os; print(os.statvfs('/var/lib/docker').f_flag & 1)"
# 0 = no d_type, 1 = d_type supported

If 0, the rootfs filesystem doesn’t support it. Switch to a raw image backed storage (local-zfs image format instead of subvolume) or explicitly set the container rootfs to use dir instead of subvol.

Docker says “kernel does not support overlay2”

1
2
docker info | grep "storage-driver"
# Falls back to overlay (single layer, different driver)

Fix: Load the overlay kernel module on the Proxmox host:

1
2
3
# On the Proxmox host
modprobe overlay
echo overlay >> /etc/modules

Then restart the container and Docker.

“Operation not permitted” on container start

Fix: The container lacks nesting or keyctl.

1
2
3
# On the Proxmox host
pct set 101 --features nesting=1,keyctl=1
pct reboot 101

Docker compose DNS resolution fails

Docker’s internal DNS (127.0.0.11) forwards to the LXC’s resolvers. If the LXC’s /etc/resolv.conf is empty or wrong, containers can’t resolve hostnames.

1
2
3
# Inside the container — ensure resolv.conf has valid DNS
echo "nameserver 10.0.20.1" > /etc/resolv.conf
# Or point to Pi-hole / Unbound if you run one

Cgroup v1 vs v2 warnings

Modern Proxmox (8.x+) uses cgroup v2. Docker 24+ detects and uses v2 properly. If you see warnings about cgroup version mismatch:

1
2
3
# Verify Docker detects cgroup v2
docker info | grep -i cgroup
# Expected: Cgroup Driver: systemd, Cgroup Version: 2

“Failed to mount” errors with bind mounts

Unprivileged containers can’t bind-mount from the host unless the host directory has the right ownership:

1
2
# On the Proxmox host
chown -R 100000:100000 /mnt/docker-data

UID 100000 is the default mapping for the first unprivileged container (container UID 0 -> host UID 100000). Check your container’s mapping:

1
2
pct exec 101 -- cat /proc/self/uid_map
# Output: 0 100000 65536

Summary

Running Docker inside Proxmox LXC containers is a practical choice for the homelab that wants density without sacrificing performance. The technology has matured significantly:

  • overlay2 on ZFS works natively on modern Proxmox kernels — no more fuse-overlayfs workarounds
  • Unprivileged containers provide adequate isolation for personal workloads when paired with Proxmox’s built-in firewall and regular updates
  • Performance is measurably better than VMs for CPU and I/O workloads, with near-zero overhead for most services
  • Resource management through cgroup v2 is transparent — Docker reads and enforces limits relative to the LXC allocation

The 10-15% performance advantage over KVM VMs means you can run more services on the same hardware. For a homelab where every watt and every gigabyte matters, that’s the difference between needing another node and having headroom for the next project.

Start with an unprivileged LXC, enable nesting and keyctl, install Docker, and let overlay2 handle the storage. Reserve VMs for workloads that need their own kernel, GPU passthrough, or stronger isolation boundaries. Everything else runs faster and leaner in LXC.