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-bridgeandlxc-templatesare NOT needed — we configure the bridge manually in/etc/network/interfaces.lxcfsmust 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
dev3instead ofeth1due to udev predictable naming. Check withip linkafter boot. To forceeth1naming, addnet.ifnames=0to 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 thebridgenetwork type. For multi-interface setups, usephysfor 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,
tcpdumpshows SYN → RST: the SYN reaches uhttpd but the nftablesinput_wanpolicy 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 downloadmay needlxc-templatesinstalled: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:
- lxcfs not running — start it:
rc-service lxcfs start - FORWARD policy DROP —
iptables -P FORWARD ACCEPT - 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.lxcbr0 → DISABLE_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'