Running Docker Inside LXC Containers on Proxmox
Learn how to run Docker inside Proxmox LXC containers with proper nesting configuration. Step-by-step setup for both privileged and unprivileged containers.
On this page
Why Docker in LXC Instead of a Full VM?
I run about 30 services across my Proxmox cluster, and if every one of them needed a full VM, I'd be burning through RAM like nobody's business. A typical Ubuntu 22.04 VM idles at around 500-700MB of RAM before you even install anything useful. An LXC container running the same OS? About 40-80MB. When you're working with a 64GB node, that difference adds up fast.
LXC containers share the host kernel, so there's no hypervisor overhead, no duplicate kernel memory, and near-native I/O performance. For most Docker workloads -- web apps, databases, monitoring stacks -- you genuinely don't need hardware virtualization. The container-in-a-container approach (Docker inside LXC) gives you the deployment convenience of Docker Compose with the resource efficiency of LXC.
That said, there are real limitations. You're sharing a kernel, so anything that needs custom kernel modules, specific kernel versions, or direct hardware access should go in a VM. I learned that the hard way trying to run a Wireguard Docker container inside an unprivileged LXC -- it was two days of my life I won't get back.
Creating the LXC Container
From the Proxmox GUI
The most important setting is buried under Options when creating the container. You need to check Nesting under the Features section. This enables the nesting=1 flag that allows the container to run its own namespaced sub-containers, which is exactly what Docker does.
Here's what I typically configure for a Docker-capable LXC:
- Template: debian-12-standard or ubuntu-22.04-standard
- Disk: 32GB minimum (Docker images eat space)
- CPU: 2-4 cores depending on workload
- Memory: 2048MB minimum, 4096MB for anything serious
- Network: vmbr0 with a static IP
- Features: Nesting checked, keyctl checked
From the CLI
For those who prefer scripting their infrastructure:
pct create 110 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst \
--hostname docker-host \
--memory 4096 \
--swap 512 \
--cores 4 \
--rootfs local-zfs:32 \
--net0 name=eth0,bridge=vmbr0,ip=10.10.1.110/24,gw=10.10.1.1 \
--features nesting=1,keyctl=1 \
--unprivileged 1 \
--start 1
The --features nesting=1,keyctl=1 is the critical bit. Without keyctl, you'll hit issues with certain Docker operations that need kernel keyring access, particularly anything involving encrypted volumes or certain authentication mechanisms.
The Configuration File: /etc/pve/lxc/110.conf
After creation, your config should look something like this:
arch: amd64
cores: 4
features: keyctl=1,nesting=1
hostname: docker-host
memory: 4096
net0: name=eth0,bridge=vmbr0,hwaddr=BC:24:11:A3:45:67,ip=10.10.1.110/24,gw=10.10.1.1
ostype: debian
rootfs: local-zfs:subvol-110-disk-0,size=32G
swap: 512
unprivileged: 1
If you're running an unprivileged container and need Docker with overlay2 (which you do -- it's the default and best-performing storage driver), you'll also want to add fuse support. Edit the config directly:
features: keyctl=1,nesting=1,fuse=1
lxc.apparmor.profile: unconfined
lxc.cap.drop:
lxc.mount.auto: proc:rw sys:rw
That lxc.apparmor.profile: unconfined line makes security folks twitch, and rightfully so. I'll cover the implications in a moment.
Privileged vs Unprivileged: Which One for Docker?
Here's the honest truth: privileged containers are significantly easier to get Docker running in. If this is a homelab and the container isn't exposed to the internet, a privileged container with nesting is the path of least resistance.
For a privileged container, the config is simpler:
arch: amd64
cores: 4
features: keyctl=1,nesting=1
hostname: docker-priv
memory: 4096
net0: name=eth0,bridge=vmbr0,hwaddr=BC:24:11:A3:45:68,ip=10.10.1.111/24,gw=10.10.1.1
ostype: debian
rootfs: local-zfs:subvol-111-disk-0,size=32G
swap: 512
No AppArmor tweaks, no fuse mount, no cap.drop overrides. Docker just works.
For unprivileged containers -- which you should use in production or any multi-tenant setup -- you need the extra config lines I showed above, plus you need to ensure the container has the right ID mappings. More on that in a dedicated article.
Installing Docker CE Inside the Container
First, start the container and get a shell:
pct start 110
pct enter 110
Then install Docker using the official repository. Don't use the distro packages -- they're ancient.
apt update && apt install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/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/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Verifying the Installation
root@docker-host:~# docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
e6590344b1a5: Pull complete
Digest: sha256:d715f14f9eca81473d9112df50457893aa7a0a5e3b3c4f4b1e7f3b6c7d8e0a1f
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
If that works, you're golden. If it doesn't, keep reading.
Checking the Storage Driver
root@docker-host:~# docker info | grep -i storage
Storage Driver: overlay2
You want to see overlay2 here. If you see vfs, something is wrong with your container config -- vfs has no copy-on-write support and will absolutely destroy your disk I/O and eat storage like it's going out of style. Go back and verify the nesting and fuse features are enabled.
Docker Compose Setup
Docker Compose v2 comes bundled with the docker-compose-plugin package, so you use it as docker compose (no hyphen). Here's a quick test with a basic stack:
# /opt/stacks/monitoring/docker-compose.yml
services:
uptime-kuma:
image: louislam/uptime-kuma:1.23
container_name: uptime-kuma
restart: unless-stopped
ports:
- "3001:3001"
volumes:
- uptime-data:/app/data
nginx:
image: nginx:1.25-alpine
container_name: nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
volumes:
uptime-data:
cd /opt/stacks/monitoring
docker compose up -d
docker compose ps
NAME IMAGE STATUS PORTS
nginx-proxy nginx:1.25-alpine Up 3 seconds 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
uptime-kuma louislam/uptime-kuma:1.23 Up 4 seconds 0.0.0.0:3001->3001/tcp
Common Errors and How to Fix Them
"Operation not permitted" on Container Start
docker: Error response from daemon: OCI runtime create failed:
runc create failed: unable to start container process:
error during container init: error mounting "proc" to rootfs at "/proc":
mount proc:/proc (via /proc/self/fd/7), flags: 0xe: operation not permitted:
unknown.
This almost always means nesting isn't enabled. Shut down the container, verify features: nesting=1 in the config, and start it again. I've seen cases where editing the config through the GUI doesn't actually save -- always double-check by reading /etc/pve/lxc/110.conf directly.
overlay2 Storage Driver Fails
If Docker falls back to vfs instead of overlay2, you need to verify two things:
- The
fusefeature is enabled in your container config - AppArmor isn't blocking the overlay mount
For unprivileged containers, adding these lines to the config usually resolves it:
lxc.apparmor.profile: unconfined
lxc.cap.drop:
After changing the config, restart the container. You may also need to wipe Docker's storage and let it reinitialize:
systemctl stop docker
rm -rf /var/lib/docker
systemctl start docker
AppArmor Denials
Check the host's kernel log for AppArmor denials:
# Run this on the Proxmox host, not inside the container
dmesg | grep -i apparmor | tail -20
If you see DENIED entries related to your container, the lxc.apparmor.profile: unconfined fix is what you need. Yes, it weakens the security boundary. If that bothers you (and it should in production), use a VM instead.
cgroup v2 Issues
Proxmox 8.x uses cgroup v2 by default. Most modern Docker images handle this fine, but some older images that try to interact with cgroups directly will fail. You'll see errors like:
Failed to connect to bus: No such file or directory
Make sure your container's init system is properly set up for cgroup v2. In Debian/Ubuntu containers, systemd handles this automatically. Alpine containers sometimes need extra work.
Mounting Host Directories
This is where things get interesting. You can bind-mount directories from the Proxmox host into the LXC container, and then mount those into Docker containers. It's bind mounts all the way down.
Step 1: Mount Host Path into LXC
Add a mount point to /etc/pve/lxc/110.conf:
mp0: /mnt/storage/media,mp=/mnt/media
For unprivileged containers, you also need to handle the UID/GID mapping. The files on the host are owned by real UIDs, but the container sees mapped UIDs. Add to the config:
mp0: /mnt/storage/media,mp=/mnt/media,backup=0
lxc.idmap: u 0 100000 65536
lxc.idmap: g 0 100000 65536
Step 2: Mount LXC Path into Docker
In your docker-compose.yml:
services:
jellyfin:
image: jellyfin/jellyfin:10.8
volumes:
- /mnt/media:/media:ro
- jellyfin-config:/config
ports:
- "8096:8096"
The /mnt/media path inside the Docker container ultimately maps back to /mnt/storage/media on the Proxmox host. Works beautifully for media servers, file sharing, and backup targets.
Watch Out for Permission Issues
With unprivileged containers, the UID mapping means that root (UID 0) inside the container is actually UID 100000 on the host. If you're bind-mounting a directory that's owned by UID 1000 on the host, nobody inside the container can write to it unless you set up matching ID maps or use ACLs.
The quick fix for homelab use:
# On the Proxmox host
chmod -R o+rw /mnt/storage/media
The proper fix involves setting up sub-UID mappings, which is a whole topic on its own.
When to Just Use a VM Instead
I've spent enough time wrestling with Docker-in-LXC edge cases to know when to cut my losses. Use a VM when:
- You need GPU passthrough for the Docker container (transcoding, ML inference). GPU passthrough to LXC is possible but fragile. Passing it through to a VM and then into Docker is much more predictable.
- The workload needs custom kernel modules. Your host kernel is what the LXC container uses, full stop.
- You're running untrusted images. The reduced isolation of LXC means a container escape from Docker also escapes the LXC. With a VM, there's a much stronger boundary.
- You need a different OS. Want to run Docker on a different kernel version or a non-Linux OS? VM is your only option.
For everything else -- Portainer, Home Assistant, monitoring stacks, reverse proxies, database containers -- Docker-in-LXC is perfectly fine and saves a significant amount of resources.
Resource Monitoring
One thing I always set up is proper resource monitoring for my Docker-in-LXC hosts. The container sees the host's total CPU and memory by default, so htop inside the container shows all 32 cores even if you only allocated 4. Docker's own resource stats are accurate though:
docker stats --no-stream
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O
a1b2c3d4e5f6 uptime-kuma 0.45% 128.3MiB / 3.8GiB 3.29% 1.2MB / 456KB 12.3MB / 8.9MB
f6e5d4c3b2a1 nginx-proxy 0.02% 12.5MiB / 3.8GiB 0.32% 890KB / 1.1MB 4.5MB / 0B
Set memory limits in your compose files. Docker containers inside an LXC that's limited to 4GB will get OOM-killed with no warning if they exceed the LXC's allocation. The kernel's OOM killer doesn't care about your container hierarchy -- it'll kill whatever uses the most memory, which might be your database instead of the runaway process.
Wrapping Up
Docker inside LXC is one of those setups that's either trivially easy or maddeningly difficult, depending entirely on whether you get the initial container configuration right. The critical settings are nesting=1 and keyctl=1 in the features, and for unprivileged containers, the AppArmor and capability adjustments.
Start with a privileged container to verify everything works, then move to unprivileged if your security requirements demand it. Get one container working perfectly before you try to template it out to dozens. And keep /etc/pve/lxc/ backed up -- I've had cluster filesystem hiccups eat container configs, and recreating them from memory is no fun at all.