LXC Container Patterns
When to Test (Proactive Testing Triggers)
Test IMMEDIATELY when:
- Creating new LXC container services
- Adding Docker-in-LXC functionality
- Modifying container networking or provisioning
- Using
pct execcommands in configuration - Encountering variable scoping issues with containers
- Any container-related Ansible code changes
Previous lesson: We should have tested after creating the homeassistant_configure role instead of debugging blind. Always test after container provisioning code changes.
Environment validation for LXC work:
set -a && source test.env && set +a
ssh -o StrictHostKeyChecking=no root@$PRIMARY_HOST "pct list"
ansible home -m ping
Load Relevant Skills Proactively
When working with LXC containers, load these skills IMMEDIATELY:
lxc-container-patterns(this skill)molecule-testingfor test patternsansible-conventionsfor coding standardsproxmox-safety-rulesfor Proxmox operations
LXC Provisioning Pattern
LXC containers use the shared
proxmox_lxcrole viainclude_role. Each service's<type>_lxcrole is a thin wrapper:# roles/pihole_lxc/tasks/main.yml --- - name: Provision Pi-hole container ansible.builtin.include_role: name: proxmox_lxc vars: lxc_ct_id: "{{ pihole_ct_id }}" lxc_ct_hostname: pihole lxc_ct_dynamic_group: pihole lxc_ct_memory: 256 lxc_ct_cores: 1 lxc_ct_disk: "4" lxc_ct_onboot: true lxc_ct_startup_order: 3The
proxmox_lxcrole handles: template upload,pct create, networking, device bind mounts, auto-start, container start, readiness wait, andadd_hostregistration.The "Start container" task MUST start containers that exist but are stopped, not only newly created containers. Use
'stopped' in (_lxc_status.stdout | default(''))as an additional condition alongsidenot lxc_exists. Previous bug:molecule converge(idempotent re-run) found kiosk containers in "stopped" state on ai/mesh2. The start task was gated only onnot lxc_exists, so it skipped the start. "Wait for container init" then failed with "container '401' not running!".
LXC Readiness and Connection
For readiness: use
ls /nothostname(BusyBox containers may lack it). For OpenWrt LXC: use--ostype unmanaged.Configure plays use
ansible.builtin.urito push config via the NodeManager API (/api/container/{id}/exec,/api/config/push) over VPN. This is the standard path for 9/10 configure roles. NEVER usepct execorpct_remotein configure roles when an API endpoint exists. The only exceptions arekiosk_configure(bootstrap — deploys the NM itself) andopenwrt_configure(no HTTP API on OpenWrt VM). Legacypct_remotereferences remain in provisioning roles for hypervisor-side operations (pct config,pct status).For OpenWrt containers (no Python), use
ansible.builtin.rawwith commands wrapped in/bin/sh -c '...'.
Container Networking Requirements
LXC container networking MUST match the host's actual topology. Hosts behind OpenWrt (
router_nodes,lan_hosts) use the OpenWrt LAN subnet. Hosts directly on WAN useansible_default_ipv4.gateway/prefix.NEVER hardcode all containers to the OpenWrt LAN subnet.
Bridge Allocation Patterns
- Different VM/container types consume bridges differently:
- Router VMs (OpenWrt): ALL bridges — WAN on
net0, remaining as LAN ports - Service VMs: typically ONE LAN bridge —
proxmox_all_bridges[1](first non-WAN) - LXC containers on LAN hosts:
proxmox_all_bridges[1](LAN bridge) - LXC containers on WAN hosts:
proxmox_wan_bridge— NEVER useproxmox_all_bridges[1]
- Router VMs (OpenWrt): ALL bridges — WAN on
Template Management
Templates are stored locally in
images/and uploaded to the Proxmox host during provisioning. NEVER usepveam download— the host may not have internet access.Previous bug:
pveam downloadfailed with template name mismatch. Switching to local hosting eliminated the dependency.
Upload Pattern for LXC Templates
- name: Upload LXC template to Proxmox ansible.builtin.copy: src: "{{ role_path }}/../../{{ lxc_ct_template_path }}" dest: "/var/lib/vz/template/cache/{{ lxc_ct_template }}" mode: "0644"ALWAYS use
role_pathfor the source path. Relative paths like../../images/...break when Molecule runs from non-default scenarios.
LXC Package Management
ALWAYS use
install_recommends: falsewhen installing packages in LXC containers. Many packages Recommend kernel-related metapackages that pull in 70+ MB kernel images.Previous bug:
wireguard-toolsRecommendswireguardmetapackage which depends onlinux-image-rt-amd64. Withoutinstall_recommends: false, apt pulled in a 70MB kernel image that filled the 1GB container disk.
LXC Disk Sizing Requirements
LXC templates compress well (~5:1 for Debian). ALWAYS verify that the rootfs disk is large enough for the EXTRACTED template, not just the compressed size.
Rule of thumb: Set disk to at least 2× the compressed template size, minimum 2 GB for any Debian-based container with monitoring or database services.
Previous bug: Netdata template was 314MB compressed but expanded to ~1013MB. 1GB rootfs caused
pct createto fail mid-extraction.
Per-Host IP Indexing
When a service deploys to multiple LAN hosts, each container needs a unique IP on the shared OpenWrt LAN. Use the host's index in its flavor group:
offset + groups['<flavor>'].index(inventory_hostname).WAN hosts use private NAT subnets (10.99.{container_subnet_id}.0/24) via
vmbr_ctbridge. Container IPs use just the service offset (no group indexing). Collisions are impossible — each host has its own /24.Previous bug: WAN containers shared the household subnet (192.168.86.x). The offset+base+index formula produced IP collisions with host IPs and household devices. Replaced with per-host NAT bridges.
DNAT for Inbound Connections on NAT Containers
22a. WAN host containers live on private NAT subnets (10.99.x.x) that are
unreachable from the LAN or other hosts. When a container needs to accept
inbound connections from outside the host, add DNAT rules in the
provisioning role (<type>_lxc/tasks/main.yml):
- name: Add DNAT rule for service port (WAN hosts)
ansible.builtin.shell:
cmd: |
set -o pipefail
WAN_IF="{{ proxmox_wan_bridge }}"
CT_IP="{{ _lxc_net_ip }}"
if ! iptables -t nat -C PREROUTING -i "$WAN_IF" -p tcp --dport 9001 \
-j DNAT --to-destination "$CT_IP:9001" 2>/dev/null; then
iptables -t nat -A PREROUTING -i "$WAN_IF" -p tcp --dport 9001 \
-j DNAT --to-destination "$CT_IP:9001"
fi
executable: /bin/bash
when: not (_lxc_net_on_lan | bool)
changed_when: true
- name: Add FORWARD rule for DNAT traffic (WAN hosts)
ansible.builtin.shell:
cmd: |
set -o pipefail
CT_IP="{{ _lxc_net_ip }}"
if ! iptables -C FORWARD -d "$CT_IP" -p tcp --dport 9001 \
-j ACCEPT 2>/dev/null; then
iptables -A FORWARD -d "$CT_IP" -p tcp --dport 9001 -j ACCEPT
fi
executable: /bin/bash
when: not (_lxc_net_on_lan | bool)
changed_when: true
Key points:
- Gate on
not (_lxc_net_on_lan | bool)— LAN containers are directly reachable and don't need DNAT. - Use
-C(check) before-A(append) for idempotency. - BOTH
PREROUTING DNATandFORWARD ACCEPTrules are needed. - Services currently using DNAT: Manager API (port 9001, kiosk_lxc), WireGuard (UDP 51820, wireguard_lxc).
Previous bug (2026-04-09): Cluster Manager on home (LAN) couldn't reach child Managers on WAN hosts (ai, mesh2, bridge-1, bridge-2). Their kiosk containers were on 10.99.x.19 — unreachable from the LAN. DNAT rules forwarding port 9001 from the host IP to the container IP fixed it.
Dynamic Inventory and Failed Hosts
ALWAYS use
ansible_play_hosts(notansible_play_hosts_all) inadd_hostloops that register containers.ansible_play_hosts_allincludes hosts that failed in earlier plays, leading to phantom container registrations for containers that were never created.Previous bug:
aifailed in infrastructure (clock skew). Theadd_hostloop usedansible_play_hosts_all, registeringwireguard-aieven though no container existed. The configure play then failed with UNREACHABLE.
Container PID Retrieval
NEVER use
pct pid— it does not exist in Proxmox VE. Uselxc-info -n <vmid>and parse the PID line withawk '/^PID:/{print $2}'.Previous bug:
openwrt_mesh_lxcusedpct pid 103to get the container PID for WiFi PHY namespace move. It failed with "ERROR: unknown command 'pct pid'".
pct_remote Connection Requirements
The
community.proxmox.proxmox_pct_remoteconnection plugin requires theparamikoPython package on the controller. ALWAYS addparamikotorequirements.txtwhen usingpct_remote.Previous bug:
ModuleNotFoundError: No module named 'paramiko'at runtime because the dependency wasn't inrequirements.txt.
pct_remote gather_facts Buffer Overflow
NEVER use
gather_facts: trueon plays targetingpct_remotedynamic groups unless the configure role actually referencesansible_*facts. Thepct_remoteconnection plugin has a limited JSON output buffer. Hosts with many block devices (LVM thin pools, loop devices, device-mapper entries) produceansible_devicesoutput that exceeds the buffer, causing "Module result deserialization failed: No end of json char found".ALWAYS set
gather_facts: falseforpct_remoteconfigure plays. None of the current configure roles (wireguard, pihole, rsyslog, netdata) use gathered facts — they only use role variables and facts fromset_fact.If a future role needs specific facts, use
gather_subset: ['!hardware']to skip the device-heavy hardware facts instead of gathering all facts.Previous bug:
wireguard-aifailed during fact gathering viapct_remotebecause theaihost (AMD Ryzen, 128GB SSD with LVM thin pool) had 15+ block devices. The full setup module JSON exceeded thepct_remotebuffer. Other hosts with fewer devices succeeded. Fix: setgather_facts: falsefor all pct_remote configure plays.
SSH Connection Exhaustion with Parallel pct_remote
When multiple
pct_remotecontainers run in parallel, each task opens a NEW SSH connection to the Proxmox host. With 4+ containers × N tasks, this causes rapid SSH churn that overwhelms the SSH server, resulting in "Connection reset by peer", "Broken pipe", and "SSH session not active" errors.ALWAYS add
serial: 2to configure plays that target 4+pct_remotecontainers. This processes containers in batches of 2, reducing simultaneous SSH connections.Previous bug: WireGuard configure play ran 4 containers in parallel (wireguard-home, wireguard-mesh1, wireguard-ai, wireguard-mesh2). Tasks like
wg genkey | wg pubkeyopened 4+ SSH connections throughhome's SSH server simultaneously. Two connections dropped with "Connection reset by peer (104)" and "Broken pipe". Fix: addedserial: 2to the WireGuard configure play.
pct_remote Intermittent Hangs
The
community.proxmox.proxmox_pct_remoteconnection plugin uses paramiko for SSH, which can hang indefinitely during connection setup — even withserial: 1. Addingtimeout = 60toansible.cfgandhost_key_auto_add = Trueunder[paramiko_connection]does NOT prevent the hang.NEVER use
pct exec ... systemctl is-activeto check service health. The heartbeat system (4-tier callhome chain) monitors all services continuously. Use the fleet readiness API (/api/fleet/ready?services=netdata,/api/container/netdata/ready) for health verification. If the fleet API reports a service as not ready, the service IS not ready. If the fleet API is unreachable, the heartbeat watchdog kills the Ansible run within 5 seconds.Previous bug:
Configure Netdataplay targetednetdatadynamic group withpct_remote. Hung intermittently onDetect netdata config directorytask (0% CPU, blocked on paramiko SSH handshake). The fleet API would have returned the health status in <1 second without any SSH overhead.
Device Passthrough in LXC Containers
For device passthrough (
/dev/dri,/dev/snd,/dev/input) to LXC containers, uselxc.mount.entrydirectives directly in the container config file (/etc/pve/lxc/<CTID>.conf), NOT Proxmox's-mpmount option. The-mppre-start hook rejects device directories in unprivileged containers (exit code 32).Use privileged containers (
unprivileged: false) withnesting=1for reliable device access. Add bothlxc.mount.entrybind mounts ANDlxc.cgroup2.devices.allowrules.Apply device config atomically: stop container → append config → start container. Gate on whether the entries already exist to make tasks idempotent.
NEVER use
echo "..." >> /etc/pve/lxc/<CTID>.confto append to LXC config files. pmxcfs (Proxmox cluster filesystem) rejects in-place append with "File exists" error. ALWAYS use read-modify-write:cpto/tmp, modify there,cpback.Previous bug:
lxc_device_passthrough.ymlusedecho >> $lxc_confto append mount entries. This worked on some hosts but failed on ai and mesh2 with "File exists" because pmxcfs doesn't support POSIX append. Fix: copy to /tmp, append there, copy back.Previous bug: Jellyfin and Kodi containers failed with
lxc.hook.pre-startexit code 32 when using Proxmox-mpmounts for/dev/dri. Fix: switched to privileged containers withlxc.mount.entrydirectives appended directly to the LXC config file.
VA-API in Headless LXC Containers
ALWAYS use
vainfo --display drmwhen running vainfo inside headless LXC containers. Plainvainfotries to connect to an X server (DISPLAY env var), which doesn't exist in headless containers. The--display drmflag uses the DRM render node directly.Previous bug:
jellyfin_configureranpct exec {{ ct_id }} -- vainfowithout--display drm. Failed with "error: can't connect to X server!" on every run. Fix: added--display drmandfailed_when: false.
File Deployment via copy + pct push
NEVER use heredocs inside
pct exec -- bash -c 'cat > file << EOF ... EOF'in Ansiblecommandorshellmodules. YAML string folding destroys heredoc structure, causing bash syntax errors ("unexpected token `<'").ALWAYS use the
copy+pct pushpattern for deploying config files into containers:- name: Write config to host temp ansible.builtin.copy: content: | <?xml version="1.0" encoding="UTF-8"?> <Config> <Setting>{{ variable }}</Setting> </Config> dest: /tmp/service_config.xml mode: "0644" - name: Push config into container ansible.builtin.command: cmd: pct push {{ ct_id }} /tmp/service_config.xml /etc/service/config.xml changed_when: true - name: Clean up host temp ansible.builtin.file: path: /tmp/service_config.xml state: absentPrevious bug:
pct exec {{ ct_id }} -- bash -c 'cat > /etc/jellyfin/network.xml << "NETWORK_EOF" ...'viaansible.builtin.commandwith>-folded scalar collapsed newlines. Bash receivedcat > file << "EOF" content EOFon a single line and failed withsyntax error near unexpected token '<'. Fix: useansible.builtin.copyto write to host temp, thenpct pushinto container.
Configure Play Target Rules
Non-Docker LXC configure plays MUST target the dynamic group (e.g.,
netdata,pihole,rsyslog) withpct_remoteconnection. Thepct_remoteplugin handles all commands inside the container transparently.Previous bug:
netdata_configuretargetedmonitoring_nodes(Proxmox hosts) instead of thenetdatadynamic group. All commands (command,template,systemd) ran on the HOST, not inside the container. The role checked/etc/netdataon the Proxmox host (doesn't exist) and failed. Fix: changedsite.ymltohosts: netdata.
Docker-in-LXC Configuration Patterns
Docker-in-LXC configure play target: Configure plays for Docker-in-LXC services MUST target the Proxmox HOST group (e.g.,
service_nodes), NOT the container dynamic group (e.g.,homeassistant). The role usespct execcommands which only exist on the Proxmox host.Container ID handling: Use
homeassistant_ct_idfromgroup_vars/all.yml. NEVER reference undefinedproxmox_vmidvariable in configure roles that run on the host.File deployment in containers: Use
pct exec -- bash -c 'cat > file << "EOF" ... EOF'(heredoc) instead of chainedechocommands. This preserves YAML structure and avoids quoting nightmares:- name: Create config file in container ansible.builtin.shell: cmd: | set -o pipefail pct exec {{ ct_id }} -- bash -c 'cat > /path/config.yml << "CONFIG_EOF" key: value nested: item: data CONFIG_EOF chmod 0644 /path/config.yml' executable: /bin/bash changed_when: trueLXC nesting requirements: Docker-in-LXC requires
nesting=1feature. Pass vialxc_ct_features: ["nesting=1"]toproxmox_lxc. Configure Docker daemon withcgroupfsdriver for cgroup v2 compatibility (bake into image).Jinja2 conflicts with Docker templates: Docker Go template syntax (
{{.Repository}}) uses the same delimiters as Jinja2. In Ansible tasks, escape with{{ "{{" }}and{{ "}}" }}, or avoid Docker--formatflags entirely (usedocker image inspectinstead).Previous bug: Configure play targeted
homeassistantgroup (pct_remote connection), but the role usedpct execcommands.pctonly exists on the Proxmox host, not inside the container. Fix: change site.yml to targetservice_nodes(host).Previous bug: Docker
--format "{{.Repository}}:{{.Tag}}"in verify.yml was interpreted by Ansible's Jinja2 engine as undefined variables. Fix: usedocker image inspectinstead of--format.
Dynamic Host Fact Detection in Per-Feature Scenarios
Configure roles that need host-level facts (e.g., render device GID for iGPU) MUST detect them dynamically from the host instead of relying on cached facts from infrastructure roles. Per-feature molecule scenarios don't run infrastructure plays, so facts like
igpu_render_gidare undefined.Pattern: use
stat -c '%g' /dev/dri/renderD*to detect the render GID at runtime:- name: Detect render device GID from host ansible.builtin.shell: cmd: | set -o pipefail stat -c '%g' /dev/dri/renderD* 2>/dev/null | head -1 executable: /bin/bash register: _render_gid changed_when: false failed_when: _render_gid.stdout | trim | length == 0Previous bug:
moonlight_configurereferencedigpu_render_gid(exported byproxmox_igpu). The per-feature scenario didn't run infrastructure plays, so the variable was undefined. Fix: detect GID dynamically withstat.
Configure Role Connection Decision
When a configure role needs facts from the Proxmox host (e.g.,
igpu_render_gid,igpu_render_devicefromproxmox_igpu), it MUST target the Proxmox host group (media_nodes,service_nodes) and usepct execcommands. Thepct_remoteconnection cannot access host-side facts because the dynamic group hosts are separate inventory entries.When a configure role only needs container-internal state (e.g., pihole, netdata, rsyslog), it should target the dynamic group with
pct_remoteand use direct commands withoutpct execwrapping.Previous bug: Jellyfin configure play targeted
hosts: jellyfin(dynamic group withpct_remote) but the role usedpct exec {{ jellyfin_ct_id }} --wrapping on every command. Thepct_remoteplugin already wraps commands inpct exec, causing double wrapping:pct exec 300 -- pct exec 300 -- systemctl .... Fix: change site.yml to targetmedia_nodes(Proxmox hosts) since the role needs host-side igpu facts.
Pi-hole v6 Configuration
Pi-hole v6 uses
/etc/pihole/pihole.toml(NOTsetupVars.conforpihole-FTL.conf). Use/usr/bin/pihole-FTL --config <key> <value>for programmatic configuration.Pi-hole v6 uses FTL's embedded web server (NOT lighttpd). Do not manage
lighttpd.service.ALWAYS run
pihole -g(gravity update) BEFORE switching the container'sresolv.confto127.0.0.1. Gravity downloads blocklists from the internet, which needs working DNS.For unattended install, pre-seed
/etc/pihole/pihole.tomlwith at leastdns.upstreamsbefore running the installer. UsePIHOLE_SKIP_OS_CHECK=truein LXC containers.Previous bug: Configure role set
resolv.confto127.0.0.1, then ranpihole -g. Gravity hung because FTL's DNS wasn't fully initialized.
Display-Exclusive Hookscript and DRI-Sharing Containers
Desktop LXC shares the iGPU DRI render node via bind mount (not exclusive PCI passthrough). The display-exclusive hookscript manages conflicts — when Desktop LXC starts, it stops other DRI-sharing containers (Kodi, Kiosk, Moonlight) and vice versa.
Configure roles for DRI-sharing containers MUST include a defensive "ensure container is running" guard: check
pct status, start if stopped, wait for readiness viapct exec -- ls /. This handles re-runs where hookscripts have already stopped the container.Verify assertions for DRI-sharing services MUST skip
systemctl is-activechecks when a competing container (Desktop LXC) has the DRI render node exclusively. The service legitimately can't start without GPU access.When detecting render group GID from
/dev/dri/renderD*, usefailed_when: falseand fall back to a cachedigpu_render_gidfact fromproxmox_igpu. The DRI device may be temporarily unavailable when a competing container has exclusive use.Previous bug:
kiosk_configureranstat -c '%g' /dev/dri/renderD*but a competing container held exclusive DRI access. No DRI devices existed on the host. Fix: made detection non-fatal with fallback to cached fact.
Callhome Config Rewrite Pattern (CRITICAL)
When rewriting
/etc/default/callhomeon sibling containers (to point them at the local NodeManager after kiosk deployment), NEVER overwrite the entire file. Use targetedsed -ito update ONLYCALLHOME_SERVERandCALLHOME_PUBLIC_KEY. This preserves baked-in variables likeCALLHOME_HOSTNAMEwhich determine fleet identity.The
kiosk_configurerole's "Rewrite callhome config on sibling containers" task iterates all running containers on the host and updates their callhome config. Two paths:
- Debian containers (have
/opt/callhome/):bash -c "sed -i ..."+ restartcallhome.service - OpenWrt containers (have
/usr/sbin/callhome.sh, no/opt/callhome):sh -c "sed -i ..."+ restart cron
OpenWrt containers use BusyBox
sed, which supports-iwithout a backup extension. ALWAYS usesh -c(notbash -c) for OpenWrt containers.Previous bug (2026-04-17):
kiosk_configureusedprintfto overwrite the entire/etc/default/callhomeon OpenWrt mesh/bridge containers. This destroyedCALLHOME_HOSTNAME=openwrt-meshandCALLHOME_HOSTNAME=openwrt-bridge. The containers then heartbeated with their CT hostname instead of the expected fleet service name. The fleet readiness API couldn't find them, causing verify failures. Fix:sed -ito update only the changed values.
Host-Level Socat Proxy Port Conflicts (CRITICAL)
When an LXC provisioning role deploys a host-level socat proxy (e.g.,
kiosk-display-proxyon port 6080), a PREVIOUSLY DEPLOYED service with a different name on the SAME port will prevent the new service from starting. The new service crash-loops withResult=exit-codeandNRestarts=<hundreds>while appearing as "activating" tosystemctl is-active.ALWAYS stop and remove the legacy service BEFORE deploying the replacement in the provisioning role. Include
pkill -f 'socat.*TCP-LISTEN:<port>'as a belt-and-suspenders cleanup.cleanup_lan_host.ymlMUST clean up ALL host-level systemd units deployed by provisioning roles. LAN hosts are excluded from the main cleanup play (proxmox:!lan_hosts), so without explicit LAN cleanup, stale units survive indefinitely.Previous catastrophe (2026-04-14): KasmVNC migration renamed
kiosk-vnc-proxy→kiosk-display-proxy. mesh1 (LAN host) was excluded from main cleanup. Oldkiosk-vnc-proxyheld port 6080. New service crash-looped 957 times. Masked by adding 100s of verify retries instead of diagnosing the port conflict. The root cause took multiple test cycles to surface because the retries delayed the failure without ever succeeding.