← Back

LXC Container Deployment on Alpine Linux — Full Guide (N3160 Verified)

📅 2026-06-15 | 🏷️

⚠️ Critical: Firewall before first web access
OpenWrt's nftables input_wan blocks port 80 by default. After starting the LXC for the first time, run the firewall rule immediately — or uhttpd will be unreachable from the host despite responding to tcpdump. See Step 7 — Known Issues for the fix.

LXC Container Deployment on Alpine Linux — Full Guide (N3160 Verified)

Tested on: Intel N3160 / 4GB RAM / 32GB mSATA / dual RTL8168 NICs / Alpine 3.23 edge


1. Alpine Host Setup

Install LXC tools

apk add lxc bridge lxcfs lxc-download xz iptables
rc-update add cgroups boot
rc-service cgroups start

Note: lxc-bridge and lxc-templates are NOT needed — we configure the bridge manually in /etc/network/interfaces. lxcfs must be running for LXC to work correctly.

Enable and start lxcfs

rc-update add lxcfs default
rc-service lxcfs start

Create bridge (br0 = LAN)

Edit /etc/network/interfaces:

auto lo
iface lo inet loopback
iface lo inet6 loopback

auto eth0
iface eth0 inet manual

auto br0
iface br0 inet static
    address 192.168.2.88/24
    gateway 192.168.2.254
iface br0 inet6 auto
    bridge_ports eth0
    bridge-stp off
    bridge-fd 0

# WAN:留给 OpenWrt LXC (eth1 stays UP but has no IP on host)
auto eth1
iface eth1 inet manual
    pre-up ip link set dev eth1 up

N3160 NIC naming: RTL8168 NICs may rename to dev3 instead of eth1 due to udev predictable naming. Check with ip link after boot. To force eth1 naming, add net.ifnames=0 to kernel cmdline in GRUB.

Then:

service networking restart

Enable IP forwarding (persist)

echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.d/local.conf
echo 'net.bridge.bridge-nf-call-iptables = 1' >> /etc/sysctl.d/local.conf
echo 1 > /proc/sys/net/ipv4/ip_forward

Fix iptables FORWARD policy

iptables -P FORWARD ACCEPT

(Docker may have set it to DROP — fix it before starting containers)


2. Privileged vs Unprivileged

Privileged Unprivileged
Root inside container = root on host Mapped UID (not real root)
/dev/net/tun ✅ works ❌ denied
Physical NIC passthrough (phys) ✅ works ❌ denied
/dev/ppp device ✅ works ❌ denied
Security Lower Higher

Use privileged if you need physical NIC passthrough, PPPoE, or VPN inside container. Omit lxc.idmap = privileged by default on Alpine.


3. Pick a Distro

lxc-create -t download -- --list

Select distribution and create:

lxc-create -n openwrt-ct -t download -- -d alpine -r 3.23 -a amd64

4. Extract Custom Rootfs

For ImmortalWrt (router use case):

cd /var/lib/lxc/<name>/rootfs
rm -rf *
wget -q https://downloads.immortalwrt.org/releases/25.12.0/targets/x86/64/immortalwrt-25.12.0-x86-64-rootfs.tar.gz
tar -xzf immortalwrt-25.12.0-x86-64-rootfs.tar.gz -C . --strip-components=1
rm immortalwrt-25.12.0-x86-64-rootfs.tar.gz
chown -R root:root .

5. LXC Config

/var/lib/lxc/<name>/config:

lxc.include = /usr/share/lxc/config/common.conf
lxc.arch = linux64
lxc.rootfs.path = btrfs:/var/lib/lxc/<name>/rootfs
lxc.uts.name = <name>

# Memory cap (tune for your host RAM)
lxc.cgroup2.memory.max = 256M
lxc.cgroup2.memory.high = 200M

# Resource limits
lxc.cgroup2.cpu.weight = 102
lxc.cgroup2.cpu.max = "1 4"
lxc.cgroup2.memory.swap.max = 128M
lxc.pid.max = 1024

# Autostart on boot
lxc.start.auto = 1
lxc.start.delay = 0

# Network: veth → br0
# OpenWrt treats eth0 as routed WAN (NOT bridged into br-lan)
lxc.net.0.type = veth
lxc.net.0.link = br0
lxc.net.0.flags = up
lxc.net.0.hwaddr = <MAC_ADDRESS>

# Mount auto — let lxcfs handle cgroup/proc/sys automatically
lxc.mount.auto = proc:mixed sys:mixed cgroup:rw:force

Critical: Do NOT use lxc.net.1.type = bridge — LXC on Alpine doesn't support the bridge network type. For multi-interface setups, use phys for direct NIC passthrough.


6. ImmortalWrt Network Config (inside container)

Edit /var/lib/lxc/<name>/rootfs/etc/config/network:

config interface 'loopback'
    option device 'lo'
    option proto 'static'
    list ipaddr '127.0.0.1/8'

config globals 'globals'
    option dhcp_default_duid '00042ae6d4160cef43d8839efb2795422fc4'
    option ula_prefix 'fdcc:b58a:b43b::/48'

config device
    option name 'br-lan'
    option type 'bridge'
    list ports 'eth1'

config interface 'lan'
    option device 'br-lan'
    option proto 'static'
    list ipaddr '<LAN_NETWORK>/24'
    option ip6assign '60'
    option defaultroute '0'

config interface 'wan'
    option device 'eth0'
    option proto 'static'
    list ipaddr '<WAN_IP>/24'
    option gateway '<HOST_GATEWAY>'
    option defaultroute '1'

Key concept: eth0 (the LXC veth) is OpenWrt's WAN/routed interface — it must NOT be bridged into br-lan. <HOST_GATEWAY> is your LAN gateway (e.g. 192.168.2.254). The defaultroute on the WAN interface creates the default route on boot.

Firewall — Allow HTTP on WAN

OpenWrt's nftables input_wan chain blocks port 80 by default. If you run uhttpd (web admin), add this rule or all HTTP requests from the host will be rejected with TCP RST:

uci add firewall rule
uci set firewall.@rule[-1].name='Allow-HTTP-WAN'
uci set firewall.@rule[-1].src='wan'
uci set firewall.@rule[-1].dest_port='80'
uci set firewall.@rule[-1].proto='tcp'
uci set firewall.@rule[-1].target='ACCEPT'
uci commit firewall
/etc/init.d/firewall reload

Verify: nft list chain inet fw4 input_wan should show tcp dport 80 counter accept.

Without this, tcpdump shows SYN → RST: the SYN reaches uhttpd but the nftables input_wan policy drops the packet and triggers a reject response.


Install Other Distros via lxc-create

If you want Debian, Ubuntu, Alpine, or Arch instead of OpenWrt, use the built-in download template:

# Alpine
lxc-create -n alpine -t download -- -d alpine -r 3.20 -a $(uname -m)

# Debian
lxc-create -n debian -t download -- -d debian -r bookworm -a amd64

# Ubuntu
lxc-create -n ubuntu -t download -- -d ubuntu -r jammy -a amd64

# Arch
lxc-create -n arch -t download -- -d archlinux -r rolling -a $(uname -m)

After creation, configure the LXC to use your bridge. Edit /etc/lxc/default.conf or the container's config:

lxc.net.0.type = veth
lxc.net.0.link = br0
lxc.net.0.flags = up
lxc.net.0.hwaddr = 00:11:22:33:44:55

Then start: lxc-start -n <name>

Note: On Alpine host, lxc-create -t download may need lxc-templates installed: apk add lxc-templates.

7. Known Issues & Fixes

Issue: "/usr/lib/lxc/rootfs: No such file or directory"

The Alpine lxc package creates /usr/lib/lxc/rootfs (a README file). LXC tries to bind-mount there instead of the container's rootfs. Fix: This doesn't actually break LXC — it's a red herring. The real issues are usually:

  1. lxcfs not running — start it: rc-service lxcfs start
  2. FORWARD policy DROPiptables -P FORWARD ACCEPT
  3. Wrong OpenWrt network config — eth0 must be routed, not bridged

Issue: OpenWrt "Network unreachable" on ping

OpenWrt's /etc/config/network must have option gateway '192.168.2.254' on the wan interface. Without this, no default route is created and all outbound traffic fails.

Issue: Host can't ping OpenWrt

Host and OpenWrt must be on the same subnet. If host is 192.168.2.88 and OpenWrt WAN is 192.168.2.11, they can ping each other directly. If OpenWrt's eth0 is bridged into br-lan with a different subnet, traffic can't route.

Issue: "/dev/ppp: Not a directory"

/dev/ppp must be a directory for bind mount, not a file. Create it on the host:

mkdir -p /dev/ppp
chmod 0666 /dev/ppp
mkdir -p /dev/net
chmod 0666 /dev/net

Issue: NAT / PPPoE passthrough

For PPPoE passthrough to the container, use phys type instead of veth:

lxc.net.1.type = phys
lxc.net.1.link = eth1
lxc.net.1.name = eth0
lxc.net.1.flags = up

Requires privileged container and eth1 must be UP on the host.


8. Verified Working Stack (N3160)

Host (Alpine 3.23):
  br0: 192.168.2.88/24 → gateway 192.168.2.254 (WAN)
  eth0: slave to br0
  eth1: UP, no IP (reserved for future PPPoE passthrough)
  zRAM: 2GB, zstd compression
  Docker: btrfs storage driver
  lxcfs: running
  IP forward: enabled

OpenWrt LXC (ImmortalWrt 25.12):
  eth0: 192.168.2.11/24 → gateway 192.168.2.254 (WAN/routed via veth)
  br-lan: 192.168.1.1/24 (eth1 bridged in, no cable yet)
  Memory: 256M max / 200M high
  Internet: ✅ (ping 8.8.8.8 works)
  DNS: ✅ (google.com resolves)
  Reboot persistence: ✅ (survives LXC reboot)

9. zRAM Setup (Native OpenRC)

Load zram kernel module

echo 'zram num_devices=1' > /etc/modules-load.d/zram.conf

Create OpenRC init script

cat > /etc/init.d/zram-swap << 'EOF'
#!/sbin/openrc-run

depend() {
    after modules
    before localmount
}

start() {
    ebegin "Initializing native ZRAM swap"
    echo zstd > /sys/block/zram0/comp_algorithm
    echo 2048M > /sys/block/zram0/disksize
    mkswap /dev/zram0 >/dev/null
    swapon -p 100 /dev/zram0
    eend $?
}

stop() {
    ebegin "Deactivating ZRAM swap"
    swapoff /dev/zram0
    echo 1 > /sys/block/zram0/reset
    eend $?
}
EOF

chmod +x /etc/init.d/zram-swap
rc-update add zram-swap boot
rc-service zram-swap start

Tune swappiness

echo 'vm.swappiness = 80' >> /etc/sysctl.d/local.conf
sysctl --system

10. Docker on Alpine Host

apk add docker docker-cli-compose
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'EOF'
{
  "storage-driver": "btrfs"
}
EOF
rc-update add docker default
service docker start

Verify: docker info | grep "Storage Driver" → must return btrfs.


Alternative: Alpine LXC NAT Bridge (lxcbr0)

Alpine provides lxc-bridge which sets up a NAT'd bridge (lxcbr0) with dnsmasq automatically. Cleaner than manual iptables.

Host Setup

apk add lxc-bridge iptables
rc-update add dnsmasq.lxcbr0 boot
service dnsmasq.lxcbr0 start

Static IP via dnsmasq.conf

cat >> /etc/lxc/dnsmasq.conf << 'EOF'
dhcp-host=guest1,10.0.3.4
dhcp-host=guest2,10.0.3.5
EOF
service dnsmasq.lxcbr0 restart

Container Config (veth → lxcbr0)

lxc.net.0.type = veth
lxc.net.0.link = lxcbr0
lxc.net.0.flags = up

Container interface stays on DHCP — dnsmasq assigns the static IP automatically.

Note: Container must be on the same host as the bridge. For cross-host NAT, use routed approach (eth0 as WAN, covered above).


LXC NAT Network on VPS (Alpine Wiki Method)

When your VPS has one public IP and containers share it via NAT. Based on Alpine Wiki LXC.

1. Install Packages

apk add lxc bridge lxcfs lxc-download xz
apk add iptables

2. Enable Bridge + Dnsmasq (Alpine's lxc-bridge)

# Use Alpine's built-in lxcbr0 bridge managed by dnsmasq.lxcbr0
apk add lxc-bridge iptables

# Enable dnsmasq service for DHCP on lxcbr0
rc-update add dnsmasq.lxcbr0 boot
service dnsmasq.lxcbr0 start

To disable NAT (bridge-only, no DHCP): edit /etc/conf.d/dnsmasq.lxcbr0DISABLE_IPTABLES="yes"

3. Assign Static IPs via DHCP Reservations

Edit /etc/lxc/dnsmasq.conf:

dhcp-host=guest1,10.0.3.4
dhcp-host=guest2,10.0.3.5

Then restart: service dnsmasq.lxcbr0 restart

4. Create Container

lxc-create -n guest1 -f /etc/lxc/default.conf -t download
# Interactively select distribution, release, and architecture

The lxcbr0 bridge is pre-configured by lxc-bridge — containers auto-connect via /etc/lxc/default.conf.

For static IP inside container (Debian Bullseye 11.3+, systemd):

Inside container (lxc-attach -n guest1):

systemctl stop systemd-networkd
systemctl disable systemd-networkd

Or configure /etc/network/interfaces directly.

For Alpine containers, set networking in /etc/init.d/net:

ln -s /etc/init.d/net.lo /etc/init.d/net.eth0
rc-update add net.eth0 default

5. Enable Forwarding + NAT (host)

# Enable IP forwarding
sysctl -w net.ipv4.ip_forward=1
echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf

# NAT container traffic via host WAN (e.g. eth0)
iptables -t nat -A POSTROUTING -s 10.0.3.0/24 ! -d 10.0.3.0/24 -j MASQUERADE

6. Port Forwarding (host → container)

# e.g. host:8080 -> container:80
iptables -t nat -A PREROUTING -p tcp -d $(public_ip) --dport 8080 \
    -j DNAT --to-destination 10.0.3.4:80
iptables -A FORWARD -d 10.0.3.4 -p tcp --dport 80 -j ACCEPT

7. Start/Stop Container

# Enable cgroups (required on Alpine)
rc-update add cgroups
rc-service cgroups start

# Create service symlink
ln -s /etc/init.d/lxc /etc/init.d/lxc.guest1

# Start / stop / autostart
rc-service lxc.guest1 start
rc-service lxc.guest1 stop
rc-update add lxc.guest1

Quick Reference — N3160 Final Config

Host /etc/network/interfaces

auto lo
iface lo inet loopback
iface lo inet6 loopback

auto eth0
iface eth0 inet manual

auto br0
iface br0 inet static
    address 192.168.2.88/24
    gateway 192.168.2.254
iface br0 inet6 auto
    bridge_ports eth0
    bridge_stp off
    bridge-fd 0

auto eth1
iface eth1 inet manual
    pre-up ip link set dev eth1 up

LXC /var/lib/lxc/openwrt-ct/config

lxc.include = /usr/share/lxc/config/common.conf
lxc.arch = linux64
lxc.rootfs.path = btrfs:/var/lib/lxc/openwrt-ct/rootfs
lxc.uts.name = openwrt-ct
lxc.cgroup2.memory.max = 256M
lxc.cgroup2.memory.high = 200M
lxc.start.auto = 1
lxc.start.delay = 0
lxc.net.0.type = veth
lxc.net.0.link = br0
lxc.net.0.flags = up
lxc.net.0.hwaddr = BC:24:11:DE:ED:AB
lxc.mount.auto = proc:mixed sys:mixed cgroup:rw:force

ImmortalWrt /etc/config/network (inside container rootfs)

config interface 'loopback'
    option device 'lo'
    option proto 'static'
    list ipaddr '127.0.0.1/8'

config globals 'globals'
    option dhcp_default_duid '00042ae6d4160cef43d8839efb2795422fc4'
    option ula_prefix 'fdcc:b58a:b43b::/48'

config device
    option name 'br-lan'
    option type 'bridge'
    list ports 'eth1'

config interface 'lan'
    option device 'br-lan'
    option proto 'static'
    list ipaddr '192.168.1.1/24'
    option ip6assign '60'
    option defaultroute '0'

config interface 'wan'
    option device 'eth0'
    option proto 'static'
    list ipaddr '192.168.2.11/24'
    option gateway '192.168.2.254'
    option defaultroute '1'