← 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 .

⚠️ Never run cd and rm -rf * as separate commands. Shell state resets between commands in some contexts — always chain them with && as shown above.


5. LXC Config

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

# ── UPDATE THESE 3 LINES before starting ──
lxc.uts.name = openwrt-ct           # ← container name (change this)
lxc.rootfs.path = dir:/var/lib/lxc/openwrt-ct/rootfs   # ← must match
lxc.net.0.hwaddr = 00:11:22:33:44:55  # ← generate: openssl rand -hex 6 | sed 's/\(..\)/\1:/g'

lxc.include = /usr/share/lxc/config/common.conf
lxc.arch = linux64

# 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.cgroup2.pids.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

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

Storage driver: The guide uses dir: (directory-backed rootfs) for portability. If your host is btrfs, change to btrfs: — this enables instant subvolume snapshots for both LXC and Docker. If ext4/XFS, keep dir:. Do not mix storage drivers without planning the subvolume layout first.

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. Start / Stop the Container

# Start (daemonized — runs in background)
lxc-start -n openwrt-ct -d

# Check status
lxc-info -n openwrt-ct

# Stop (10s timeout before SIGKILL)
lxc-stop -n openwrt-ct -t 10

# Start in foreground (see all logs live — useful for debugging)
lxc-start -n openwrt-ct -F

# Enter the container (run commands inside OpenWrt)
lxc-attach -n openwrt-ct

-d = daemonize (background). -F = foreground (debugging). -t N = timeout seconds on stop.

7. 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 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.2.11/24'

config interface 'wan'
    option device 'eth0'
    option proto 'pppoe'

config interface 'wan6'
    option device 'eth0'
    option proto 'dhcpv6'

Key concept: eth0 (the LXC veth) is OpenWrt's WAN/routed interface — it must NOT be bridged into br-lan. PPPoE handles authentication; OpenWrt gets a public IP directly on eth0. LAN (eth1 passthrough) uses 192.168.2.11 and bridges to br-lan for your internal network. Set PPPoE username/password via LuCI at 192.168.2.11 or via UCI.

Firewall — Allow HTTP and HTTPS on WAN

OpenWrt's nftables input_wan chain blocks ports 80 and 443 by default. If you run uhttpd (web admin), add these rules or all HTTP/HTTPS requests from the host will be rejected with TCP RST:

# Run these INSIDE the container via lxc-attach
lxc-attach -n openwrt-ct << 'EOF'
# Allow HTTP
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'

# Allow HTTPS
uci add firewall rule
uci set firewall.@rule[-1].name='Allow-HTTPS-WAN'
uci set firewall.@rule[-1].src='wan'
uci set firewall.@rule[-1].dest_port='443'
uci set firewall.@rule[-1].proto='tcp'
uci set firewall.@rule[-1].target='ACCEPT'

uci commit firewall
/etc/init.d/firewall reload
EOF

Verify: nft list chain inet fw4 input_wan should show tcp dport 80 counter accept and tcp dport 443 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.

8. 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.


9. 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)

10. 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

11. 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

# ── UPDATE THESE 3 LINES before starting ──
lxc.uts.name = openwrt-ct           # ← container name (change this)
lxc.rootfs.path = dir:/var/lib/lxc/openwrt-ct/rootfs   # ← must match
lxc.net.0.hwaddr = 00:11:22:33:44:55  # ← generate: openssl rand -hex 6 | sed 's/\(..\)/\1:/g'

lxc.include = /usr/share/lxc/config/common.conf
lxc.arch = linux64

# Memory cap
lxc.cgroup2.memory.max = 256M
lxc.cgroup2.memory.high = 200M

# CPU & process limits
lxc.cgroup2.cpu.weight = 102
lxc.cgroup2.cpu.max = "1 4"
lxc.cgroup2.memory.swap.max = 128M
lxc.cgroup2.pids.max = 1024

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

# Network: veth → br0 (WAN/routed)
lxc.net.0.type = veth
lxc.net.0.link = br0
lxc.net.0.flags = up
lxc.net.0.hwaddr = BC:84:67:8C:4E:AA

# Physical pass-through for LAN bridge
lxc.net.1.type = phys
lxc.net.1.link = eth1
lxc.net.1.name = eth0
lxc.net.1.flags = up

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

⚠️ eth1 passthrough: When using lxc.net.1.type = phys, the Alpine host loses all access to eth1 — it belongs entirely to the container. Other host services (Docker, etc.) cannot use eth1 directly. Plan accordingly.

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'