~/blog/wifi-access-point-lxc-container
A WiFi Access Point Inside an LXC Container

Every guide for a home WiFi access point assumes a Raspberry Pi or a spare machine. Mine runs in a Proxmox container, which is one small configuration step away from being impossible — and that step is worth writing down, because the error you get when you skip it tells you nothing.
The goal: an isolated wireless network, separate from the wired LAN, for guests and anything I do not fully trust, with DNS filtering and a VPN underneath.
The problem: a container cannot see the radio
A container shares the host kernel, so a wireless card on the host is invisible inside it by default. iw dev returns nothing. hostapd fails with a driver error that reads like a missing package.
There are two plausible fixes and only one good one:
- macvlan — plausible, and it does not work for an AP:
hostapdneeds to set the interface to AP mode, and macvlan interfaces cannot. - A physical netdev passthrough — hand the actual card to the container. The container then owns the radio and does everything itself: AP mode, bridging, the lot.
The second one is a two-line container option:
# on the Proxmox host: /etc/pve/lxc/107.conf
lxc.net.1.type: phys
lxc.net.1.link: wlan0
lxc.net.1.name: wlan0
lxc.net.1.flags: up
That is the whole trick. net0 stays on the normal LAN bridge so the container is reachable and can reach the internet; net1 is the physical card, appearing inside as wlan0.
Two more container settings, both needed and for different reasons:
features: nesting=1
lxc.cgroup2.devices.allow: c 10:200 rwm
nesting=1 is what lets the container configure its own network stack the way hostapd expects. The c 10:200 line is /dev/net/tun, the character device c 10:200 — the container needs it to bring up a VPN tunnel, which is the point of putting this network in a container in the first place. Without it, the VPN client fails at startup with a permissions error and you go looking in entirely the wrong place.
Give the container more RAM than you would expect for the work: an access point plus DNS plus a tunnel is not heavy, but 256 MB is uncomfortably tight.
hostapd
interface=wlan0
bridge=br0
driver=nl80211
ssid=lab-wifi
hw_mode=g
channel=6
wmm_enabled=1
# WPA2-PSK
auth_algs=1
wpa=2
wpa_key_mgmt=WPA-PSK
rsn_pairwise=CCMP
# wpa_passphrase=<your passphrase>
ieee80211n=1
ht_capab=[HT40-][SHORT-GI-20][SHORT-GI-40]
ctrl_interface=/var/run/hostapd
bridge=br0 is doing real work: hostapd adds the wireless interface to the bridge itself, so the container’s br0 is the access point’s uplink — anything with an address on that bridge routes normally, and you never have to script interface juggling.
driver=nl80211 is the modern interface into the kernel’s wireless stack. hw_mode=g on channel 6 is a deliberately conservative 2.4 GHz choice; 5 GHz is faster but has far less reach through walls, which is the wrong trade for a network phones and IoT devices will use.
ctrl_interface is not just for hostapd_cli; it is what makes the client notifications further down possible.
DHCP and DNS: dnsmasq
interface=br0
bind-interfaces
dhcp-range=10.42.0.100,10.42.0.200,12h
dhcp-option=option:router,10.42.0.1
dhcp-option=option:dns-server,10.42.0.1
no-resolv
server=1.1.1.1
server=1.0.0.1
log-queries
log-facility=/var/log/dnsmasq-queries.log
log-async
bind-interfaces matters here: without it dnsmasq listens on everything, including the LAN interface, and the container quietly becomes a DNS server for your whole network.
no-resolv plus explicit server= lines means the upstream resolvers are exactly what you wrote — otherwise it also reads /etc/resolv.conf and you have an unpredictable query path.
The query log is the interesting part. log-queries with a dedicated log-facility gives you a per-device record of every lookup on the network: which device asked for what, and when. That log is the sensor for everything below.
Blocking at the DNS layer
Domain blocking belongs in the resolver, not in a browser extension that only covers one device:
conf-file=/etc/dnsmasq-ads/adblock.conf
The list itself is the OISD set, pulled by a timer and rewritten into dnsmasq’s address=/domain/ form. With no address, blocked names get an NXDOMAIN answer instead of a fake IP:
#!/bin/bash
set -euo pipefail
URL="https://big.oisd.nl/"
TMP=$(mktemp)
timeout 180 wget -q -T 90 -O "$TMP" "$URL"
# ... rewrite into address=/…/ lines, then:
dnsmasq --test -C /etc/dnsmasq-ads/adblock.conf && systemctl reload dnsmasq
Two things make this safe to run unattended. Write to a temporary file and only replace the live one if the download succeeded — a truncated list caused by a timeout should not wipe your blocking. And always dnsmasq --test before reloading: a malformed generated config fails the reload, and a failed reload means no DNS for every device on that network.
Knowing when a device joins
hostapd_cli -a runs a script on every access point event:
ExecStart=/usr/sbin/hostapd_cli -i wlan0 -a /usr/local/sbin/ap-sta-event.sh
The script fires on connect and disconnect, and the connect path resolves the new client into something human-readable:
AP-STA-CONNECTED)
mac="$2"
sleep 2 # let dnsmasq record the lease first
line=$(grep -i "$mac" /var/lib/misc/dnsmasq.leases | tail -1)
ip=$(awk '{print $3}' <<<"$line")
hostname=$(awk '{print $4}' <<<"$line")
;;
The sleep 2 is not superstition. The association event fires before DHCP completes, so reading the lease file immediately gives you nothing — you get a MAC address and no name, every time. Two seconds is enough for the lease to appear and turns an opaque MAC into “someone’s phone joined”.
Watching for malicious lookups
The query log makes a cheap sensor: tail it, match domains against a threat feed, and report. Mine is deliberately alert-only — it logs, it does not block:
syslog.openlog("dns-threat", 0, syslog.LOG_DAEMON)
syslog.syslog(syslog.LOG_INFO, f"started: {len(threats)} threat domains loaded, alert-only mode")
and the output is logfmt — client=… device=… domain=… — so the log pipeline can extract fields without a regex per line. The threat list refreshes on its own timer.
Alert-only is a choice worth defending. A blocklist that blocks silently will eventually block something you need, at the worst time, and you will have no idea why. Alerting gives you the same information with none of the failure mode, and if a device on your network really is beaconing to a known-bad domain, the notification is the useful part anyway.
Pitfalls
- macvlan will not work for an access point. Pass the physical device through.
- The card is pinned. Once the container owns it, the host cannot use it as a client — plan the hardware accordingly.
- WPA2 only, as written.
wpa=2is WPA2-PSK; if you want WPA3 you needwpa_key_mgmt=SAEandieee80211w, and older devices may stop connecting. bind-interfaces. Leave it out and you are running DNS for the entire LAN.- Reloading a broken blocklist takes the network down. Validate before reload.
- No
/dev/net/tunmeans no VPN. Add the device allow, and do not expect a clear error if you forget. - Kernel logs are unavailable in a container.
imklogcannot read/proc/kmsgwithoutCAP_SYSLOG, so wireless driver warnings on the host are not somewhere you can look from inside — debug those from the Proxmox host.