Welcome! This section contains practical, hands-on guides based on things I’ve built, broken,
and learned along the way.
Most of these guides focus on Linux, servers, Coding, networking, and self-hosting, but I’ll
occasionally branch out into other topics when I find something worth documenting.
More guides will be added over time as I continue experimenting and documenting.
1 - Building a Router with Alpine Linux
This guide walks through building a functional home router using Alpine Linux. The setup includes a WAN interface, LAN (Ethernet + Wi-Fi), firewalling with nftables, DHCP/DNS options (dnsmasq or Pi-hole), and VPN routing using strongSwan.
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 <--> Clients
Note:
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
Configuration Values— set your values once; they apply to the following compatable text blocks
Default: 192.168.2.1
Default: 192.168.2.0/24
Default: fd42:6c61:6e00:1::1
Default: 42
Default: 1318
Default: fd42:6c61:6e00:1::
Default: us5783.nordvpn.com
Default: 84.17.45.205
Initial 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
config/etc/network/interfaces
Notes on Configuration:
eth1 and wlan0 are attached to br0, allowing LAN clients to connect via Ethernet or Wi-Fi.
nord0 is an XFRM interface used by strongSwan for IPSec/VPN routing.
IPv6 is disabled on nord0 since nordvpn doesn’t support and to remove unecessary addresses.
IPv6 router advertisements are disabled on br0 to 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.conf is applied
Networking starts
br0 and nord0 are created
Default behavior is applied such as accept_ra = 1 for br0
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 interfacesnet.ipv6.conf.eth1.disable_ipv6=1net.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.
config/etc/nftables.d/filter.nft
Create /etc/nftables.d/nat.nft with the following rules.
Note: There is configuration in the nat.nft file that is ment for the following sections
config/etc/nftables.d/nat.nft
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 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.toml config file
Example of changes made on pihole.toml:
[dns]upstreams=["1.1.1.1","8.8.8.8"]# cloudflare and google dns serversdomainNeeded=trueinterface="br0"[dhcp]active=truestart="192.168.X.2"end="192.168.X.253"router="192.168.X.1"netmask="255.255.255.0"leaseTime="24h"ipv6=truerapidCommit=truehosts=["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 tunnel
nord0 → XFRM interface used for VPN traffic
nftables → marks packets for VPN routing
iproute2 → routes marked packets through a custom table
Get NordVPN Service Credentials
⚠️ These are NOT your normal Nord account credentials.
$ 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
config/etc/swanctl/conf.d/swanctl.conf
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 itlocal i
for i in $(seq 1 15);doif[ -S /run/charon.vici ]||[ -S /var/run/charon.vici ];thenbreakfi sleep 1doneif[ ! -S /run/charon.vici ]&&[ ! -S /var/run/charon.vici ];then eerror "charon VICI socket never appeared, strongSwan may not be running" eend 1return1fi# 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);doif swanctl --list-sas 2>/dev/null | grep -q "INSTALLED";thenbreakfi sleep 1doneif ! swanctl --list-sas 2>/dev/null | grep -q "INSTALLED";then eerror "NordVPN tunnel was not established after 40 seconds" eend 1return1fi# 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 1return1fi 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 nat table, nordvpn_snat chain 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.
config/etc/nftables.d/mangle.nft
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 pointinterface=wlan0# Bridge interface that connects both eth1 and wlan0bridge=br0# nl80211 is used with all modern Linux mac80211 drivers.driver=nl80211# Network Namessid=YourSSIDHere# a = 802.11a (5GHz), g = 802.11g (2.4GHz)hw_mode=g# 1–13 for 2.4GHz, 36-165 for 5GHzchannel=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 = disablewmm_enabled=1# authentication algorithms, 1 = WPA/WPA2/WPA3auth_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=disabledignore_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 passwordwpa_passphrase=YourSecurePassphrase# key management algorithms (authentication method)# WPA-PSK = WPA-Personal / WPA2-Personalwpa_key_mgmt=WPA-PSK# encryption method clients must use to encrypt traffic# WPA2=CCMP, WPA3=GCMPrsn_pairwise=CCMP