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:

  1. The kernel starts
  2. /etc/sysctl.conf is applied
  3. Networking starts
  4. br0 and nord0 are created
  5. 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 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.

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

Install dnsmasq:

$ apk add dnsmasq

Configure dnsmasq

Create the /etc/dnsmasq.d/dnsmasq.conf file with the following configuration:

config /etc/dnsmasq.d/dnsmasq.conf

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.toml config 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 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

Steps:

  1. Log into your Nord account
  2. Go to NordVPN → Set up manually
  3. Open Service credentials
  4. 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:

  1. Go to Server recommendation
  2. Select IKEv2/IPSec
  3. Copy hostname (example: us5783.nordvpn.com)

Resolve it:

bash

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

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

Further Reading