Building a Router with Alpine Linux
Requirements
You will need:
- An Alpine Linux system with at least 2 network interfaces (WAN + LAN)
- Basic familiarity with the Linux command line
- Root or sudo/doas access
Optional components used in this guide:
- Wireless access point (hostapd)
- VPN routing (strongSwan)
- DNS/DHCP via dnsmasq or Pi-hole
Network Layout
This setup uses a single WAN interface (eth0) connected to the internet, and a LAN bridge (br0) that combines an Ethernet interface (eth1) and a wireless interface (wlan0).
Devices can connect to the LAN either through Ethernet (via a switch) or Wi-Fi. The router provides services such as DHCP, DNS, and VPN routing.
Traffic can either go directly to the internet or be routed through a VPN (nord0) depending on firewall and routing rules.
flowchart TD
Internet((Internet))
WAN["eth0 - WAN"]
Router["Alpine Router: nftables, dnsmasq, pihole, hostapd"]
VPN["nord0 - IPSec VPN"]
Bridge["br0 - LAN Bridge"]
ETH["eth1 - Ethernet"]
WIFI["wlan0 - WiFi AP"]
Clients["LAN Clients"]
Internet <--> WAN
WAN <--> Router
Router <--> VPN
VPN <--> Internet
Router <--> Bridge
Bridge <--> ETH
Bridge <--> WIFI
ETH <--> Clients
WIFI <--> ClientsNote: Interface names and topology may differ depending on your hardware. Replace interface names (e.g.,
eth0,eth1,wlan0) with those used on your system.
You can list your interfaces with:ip link
Configuration Values
192.168.2.1192.168.2.0/24fd42:6c61:6e00:1::1421318fd42:6c61:6e00:1::us5783.nordvpn.com84.17.45.205Initial Network Setup
This section sets up basic routing, network interfaces, and NAT so your system can function as a router.
Enable IP Forwarding
Check if IP forwarding is enabled:
$ cat /proc/sys/net/ipv4/ip_forward
- 1 → enabled
- 0 → disabled
To enable it permanently, edit /etc/sysctl.conf and add or uncomment the following:
net.ipv4.ip_forward = 1
Apply the changes:
doas sysctl -p
Configure Network Interfaces
Edit the /etc/network/interfaces file with the following configuration
Notes on Configuration:
eth1andwlan0are attached tobr0, allowing LAN clients to connect via Ethernet or Wi-Fi.nord0is an XFRM interface used by strongSwan for IPSec/VPN routing.- IPv6 is disabled on
nord0since nordvpn doesn’t support and to remove unecessary addresses. - IPv6 router advertisements are disabled on
br0to prevent it from auto-configuring itself via SLAAC.
sysctl kernel parameters are applied in post-up because of the following boot order:
- The kernel starts
/etc/sysctl.confis applied- Networking starts
br0andnord0are created- Default behavior is applied such as
accept_ra = 1forbr0
Since br0 and nord0 do not exist when sysctl runs, it’s overrwritten using post-up.
How to get pmtu_value
The IPsec vpn connection adds extra overhead to network packets because of the Encapsulating Security Payload (ESP). This can cause packets to be bigger than the MTU value in a network path and because of this, the larger packets would be dropped. This is called an MTU black hole. To fix this, you need to set the MTU value on nord0 to reasonably low value such as around 1300. Or to get a more specific MTU value, you can continue this guide and come back here once you have mostly everything setup.
First, comment out the line with [[pmtu_value]] in the /etc/network/interfaces file. Then to get a specific MTU value for nord0, connect a computer/client to the router and run the following ping command.
$ ping -c 1 -M do -s 1472 1.1.1.1
PING 1.1.1.1 (1.1.1.1) 1472(1500) bytes of data.
From 192.168.2.1 icmp_seq=1 Frag needed and DF set (mtu = 1358)
You should see something like mtu = 1358 in the output. Grab that value and subract 40 from it 1358 - 40 = 1318. 40 is subracted here because of the IP header (20 bytes) + TCP header (20 bytes) that is added after the original packet is encapsulated and stored as the outer packet payload. This value is also known as the MSS value of outer packet. Uncomment and replace [[pmtu_value]] in the /etc/network/interfaces file with the new value you calculated.
Disable IPv6 on Bridge Slave Interfaces
Add the following configuration to /etc/sysctl.conf.
# Disable ipv6 to remove link local ipv6 addresses on br0 slave interfaces
net.ipv6.conf.eth1.disable_ipv6 = 1
net.ipv6.conf.wlan0.disable_ipv6 = 1
IPv6 is disabled on eth1 and wlan0 because they are slave interfaces of the br0 bridge. Network clients connect to br0 so eth1 and wlan0 don’t need ip addresses. This is not necessary but it helps remove unecessary ip addresses and clutter.
Restart Networking:
$ doas rc-service networking restart
Set Up Filtering and NAT with nftables
Install nftables (if not already installed):
$ apk add nftables
Create /etc/nftables.d/filter.nft with the following rules.
Create /etc/nftables.d/nat.nft with the following rules.
Note: There is configuration in the
nat.nftfile that is ment for the following sections
Ensure the nft files are included in /etc/nftables.nft
You can add, edit, or remove whatever you want/need from the nftable files.
The following link from the nftables wiki has a nice diagram that shows how packets traverse different hooks (prerouting, input, forward, postrouting, etc.). I suggest using it as a reference when adding or making changes to the nftables files.
Then start nftables:
$ doas rc-service nftables start
verify by running the following command. If you see your ruleset as output, then it worked. If there no output, it likely failed.
$ doas nft list ruleset
Enable nftables boot:
$ doas rc-update add nftables
Test Connectivity From a client device connected to the LAN:
$ ping -4c 3 8.8.8.8
If this works, your router is successfully forwarding traffic to the internet.
DHCP and DNS
Note: Only run one DHCP server on your network at a time.
Your router needs to provide:
- DHCP → assigns IP addresses to clients
- DNS → resolves domain names
There are two main approaches:
- dnsmasq → lightweight, simple, CLI-based
- Pi-hole → feature-rich, web UI, ad-blocking
Option A: dnsmasq (Recommended for simplicity)
Install dnsmasq:
$ apk add dnsmasq
Configure dnsmasq
Create the /etc/dnsmasq.d/dnsmasq.conf file with the following configuration:
Start dnsmasq
$ doas rc-service dnsmasq start
$ doas rc-update add dnsmasq
Option B: Pi-hole
Note: Pi-hole is available in Alpine’s edge/testing repositories at the time of this writting.
Install Pi-hole
If using edge:
$ apk add pihole
If using stable edit /etc/apk/repositories and add the following text
@testing http://dl-cdn.alpinelinux.org/alpine/edge/testing
Then update the system packages and install pihole:
$ apk update
$ apk add pihole@testing
Start Pi-hole
$ doas rc-service pihole start
$ doas rc-update add pihole
Configure Pi-hole
Note: If you change the pihole.toml file while pihole is not running and then start pihole, your changes will be reset.
You can use:
- Web UI (recommended)
- Or the
/etc/pihole/pihole.tomlconfig file
Example of changes made on pihole.toml:
[dns]
upstreams = [
"1.1.1.1",
"8.8.8.8"
] # cloudflare and google dns servers
domainNeeded = true
interface = "br0"
[dhcp]
active = true
start = "192.168.X.2"
end = "192.168.X.253"
router = "192.168.X.1"
netmask = "255.255.255.0"
leaseTime = "24h"
ipv6 = true
rapidCommit = true
hosts = [
"00:00:00:00:00:00,192.168.X.X,HOSTNAME"
] # Static DHCP leases to mac address (OPTIONAL)
[webserver]
[webserver.session]
timeout = 2592000 # 30 days
Access pihole Web UI
To access the pi-hole webui, go into the /etc/nftables.d/filter.nft file and uncomment the rule
allowing pi-hole webui access from LAN, port 80
By default web url to pihole is at http://<router-ip>/
Set pihole webui password
$ doas pihole setpassword
Remove password:
$ doas pihole setpassword ""
Extra DHCP and DNS Configuration
Local hostname resolution
Note: dnsmasq and pihole read the /etc/hosts file and makes them resolvable to your LAN. If you haven’t modified this file, it likely has default examples such as 127.0.0.1 for [[gateway_hostname]].my.domain from the first installation. This would resolve things such as the router hostname to localhost for clients which we don’t want. I suggest removing the defaults and having something simple such as in the following step.
Edit /etc/hosts with the following config
127.0.0.1 localhost
::1 localhost
Keep this minimal to avoid incorrect resolution. You can also add domains to be resolved for clients and the router itself here.
(Optional) Make router use its own DNS
Note: We prevent resolv.conf from being overwritten because when DHCP runs, it rewrites “/etc/resolv.conf” to use the DNS servers given by an upstream router. Making the file immutable prevents DHCP from rewriting resolv.conf.
Edit /etc/resolv.conf with the following config
nameserver 127.0.0.1
Prevent overwriting of /etc/resolv.conf by editing /etc/udhcpc/udhcpc.conf with the following
RESOLV_CONF="no"
Alternatively make /etc/resolv.conf immutability:
$ chattr +i /etc/resolv.conf
Remove immutability when needed with:
$ chattr -i /etc/resolv.conf
Routing Traffic Through NordVPN (IPSec + strongSwan)
This section shows how to route selected traffic through NordVPN using IKEv2/IPSec with strongSwan.
Instead of sending all traffic through the VPN, this setup allows you to selectively route traffic using packet marking and custom routing tables.
Overview
strongSwan→ establishes the VPN tunnelnord0→ XFRM interface used for VPN trafficnftables→ marks packets for VPN routingiproute2→ routes marked packets through a custom table
Get NordVPN Service Credentials
⚠️ These are NOT your normal Nord account credentials.
Steps:
- Log into your Nord account
- Go to NordVPN → Set up manually
- Open Service credentials
- Verify your email
Save:
- Username → [[nord_service_username]]
- Password → [[nord_service_password]]
These are used for EAP-MSCHAPv2 authentication
Install NordVPN CA Certificate
$ wget https://downloads.nordcdn.com/certificates/root.pem \
$ -O /etc/ipsec.d/cacerts/NordVPN.pem
Choose a NordVPN Server
From the Nord dashboard:
- Go to Server recommendation
- Select IKEv2/IPSec
- Copy hostname (example:
us5783.nordvpn.com)
Resolve it:
Save:
- Hostname → [[nord_hostname]]
- IP → [[nord_ip]]
Create a Routing Table
Create a custom routing table for VPN traffic:
$ mkdir -p /etc/iproute2
$ echo "200 nordvpn" | sudo tee -a /etc/iproute2/rt_tables
Configure strongSwan
Install strongSwan:
$ apk add strongswan
Create the /etc/swanctl/conf.d/swanctl.conf file with the following configuration
Create nordvpn-route init script
Create an openrc script at /usr/local/etc/init.d/nordvpn-route with the following script.
#!/sbin/openrc-run
description="NordVPN Routing and SNAT Setup"
depend() {
need strongswan
need nftables
}
start() {
ebegin "Starting NordVPN routing"
# Wait for charon's VICI socket to be ready before talking to it
local i
for i in $(seq 1 15); do
if [ -S /run/charon.vici ] || [ -S /var/run/charon.vici ]; then
break
fi
sleep 1
done
if [ ! -S /run/charon.vici ] && [ ! -S /var/run/charon.vici ]; then
eerror "charon VICI socket never appeared, strongSwan may not be running"
eend 1
return 1
fi
# Ensure swanctl config is loaded and connection is initiated.
# Alpine's strongswan init script may not call --load-all automatically,
# so we do it here explicitly. This is safe to call even if already loaded.
swanctl --load-all >/dev/null 2>&1
# Wait up to 40 seconds for the CHILD_SA to reach INSTALLED state.
local i
for i in $(seq 1 40); do
if swanctl --list-sas 2>/dev/null | grep -q "INSTALLED"; then
break
fi
sleep 1
done
if ! swanctl --list-sas 2>/dev/null | grep -q "INSTALLED"; then
eerror "NordVPN tunnel was not established after 40 seconds"
eend 1
return 1
fi
# Extract the virtual IP NordVPN assigned us from the established SA.
local VPN_IP
VPN_IP=$(swanctl --list-sas | awk '/local [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\/32/ {print $2}' | cut -d/ -f1)
if [ -z "$VPN_IP" ]; then
eerror "Could not determine NordVPN virtual IP from established SA"
eend 1
return 1
fi
einfo "Using VPN IP: $VPN_IP"
# Add policy routing: packets marked 0x1 go through the nordvpn table.
ip rule add fwmark 0x1 table nordvpn 2>/dev/null || true
# Add nord0 as the default route in the nordvpn table.
ip route add default dev nord0 table nordvpn 2>/dev/null || true
# Flush the SNAT chain and install the rule using the actual VPN VIP.
nft flush chain inet nat nordvpn_snat
nft add rule inet nat nordvpn_snat snat ip to "$VPN_IP"
eend $?
}
stop() {
ebegin "Removing NordVPN routing"
ip rule del fwmark 0x1 table nordvpn 2>/dev/null || true
ip route flush table nordvpn 2>/dev/null || true
nft flush chain inet nat nordvpn_snat
eend $?
}
The nordvpn-route script:
- Waits for strongSwans charon’s VICI socket to be ready
- Ensures swanctl config is loaded and connection is initiated
- Extracts the virtual IP NordVPN assigned us
- Adds a routing rule to route marked traffic through the nordvpn table
- Adds an nftable rule on the
nattable,nordvpn_snatchain that changes the source address (snat) to the extracted vpn ip.
Configure Routing + nftables
nftables (packet marking)
Create the /etc/nftables.d/mangle.nft file with the following rules.
Enable vpn services
$ doas rc-update add strongswan
$ doas rc-update add nordvpn-route
Reboot and the vpn should automatically connect
Verify Connection
Check tunnel:
$ doas swanctl --list-sas
Check routing decision on a marked packet:
$ ip route get 8.8.8.8 mark 0x1
Useful Commands
Terminate vpn connection:
$ swanctl --terminate --ike nordvpn
Reconnect to the vpn:
$ sudo swanctl --load-all
$ sudo swanctl --initiate --child net
Setting Up a Wireless Access Point
This section configures your router to act as a Wi-Fi access point using wlan0, bridged into your LAN (br0).
Clients connecting over Wi-Fi will behave the same as wired LAN clients.
Wi-Fi hardware support
Not all Wi-Fi adapters support access point (AP) mode.
Check with (may need to install iw first):
$ iw list | grep -A 10 "Supported interface modes"
Look for:
* AP
Configure hostapd
Install hostapd
$ apk add hostapd
Open the /etc/hostapd/hostapd.conf file and update the following rules. I have some notes about what each config option does.
# Wifi interface device to use as an access point
interface=wlan0
# Bridge interface that connects both eth1 and wlan0
bridge=br0
# nl80211 is used with all modern Linux mac80211 drivers.
driver=nl80211
# Network Name
ssid=YourSSIDHere
# a = 802.11a (5GHz), g = 802.11g (2.4GHz)
hw_mode=g
# 1–13 for 2.4GHz, 36-165 for 5GHz
channel=6
# OR acs_survey = Automatic Channel Selection with ACS survey based algorithm. Use this instead if it's supported
#channel=acs_survey
# enables Quality of Service (QoS) features, ensuring, for instance, that a video call gets priority over a file download. 0 = disable
wmm_enabled=1
# authentication algorithms, 1 = WPA/WPA2/WPA3
auth_algs=1
# Send empty SSID in beacons and ignore probe request frames that do not specify full SSID, i.e., require stations to know SSID. 0=disabled
ignore_broadcast_ssid=0
# wpa auth (Wi-Fi Protected Access) 0=None, 1=WPA, 2=WPA2, 3=WPA3
## When using WPA/WPA2, a passphrase is required (wpa_passphrase)
wpa=2
# The wifi ASCII password
wpa_passphrase=YourSecurePassphrase
# key management algorithms (authentication method)
# WPA-PSK = WPA-Personal / WPA2-Personal
wpa_key_mgmt=WPA-PSK
# encryption method clients must use to encrypt traffic
# WPA2=CCMP, WPA3=GCMP
rsn_pairwise=CCMP
Enable and Start hostapd
$ sudo rc-service hostapd start
$ sudo rc-update add hostapd
Verify
$ ip addr
You should see:
- wlan0 active
- part of bridge br0
Then try connecting from a device using your SSID (Network Name).
Important Notes
Channel selection
- 2.4GHz (hw_mode=g): channels 1–11 (US)
- 5GHz (hw_mode=a): channels vary
You can also use automatic selection (if supported):
channel=acs_survey
WPA2 vs WPA3
This guide uses WPA2 for compatibility.
- Works on nearly all devices
- More reliable across hardware
You can upgrade to WPA3 later if your hardware supports it.
Bridge behavior
Because wlan0 is attached to br0:
- Wi-Fi clients receive DHCP from your configured server
- They are on the same subnet as Ethernet clients
- No additional routing is needed
Final Notes
At this point, your Alpine Linux system should be functioning as:
- A router (IPv4 NAT + forwarding)
- A DHCP/DNS server
- A wireless access point
- A VPN gateway
You can now expand this setup with:
- Port forwarding
- Firewall rules
- VLANs
- Monitoring and logging