Networking 📖 13 min read

Linux Router Setup

A Linux machine with two network interfaces can do everything a home router does: hand out addresses, translate traffic to your public IP, filter what comes in, and forward ports. A consumer router gets firmware updates only for as long as its vendor provides them, and it exposes only the settings the vendor chose. A Linux router is updated with the rest of the distribution and every rule is yours to read. This guide builds one on Ubuntu Server: netplan for the interfaces, a single nftables file for the firewall and NAT that you apply with an automatic rollback in case it locks you out, and dnsmasq for DHCP and DNS.

What a Linux router gives you

Every firewall rule lives in one text file you can read top to bottom, and the connection table, DHCP leases and DNS queries are all visible with standard tools. Security fixes arrive with the distribution's normal updates for as long as the release is supported, there are no vendor cloud features unless you add them, and VLANs work with any network card and switch that handle 802.1Q tags.

Versions: the commands target Ubuntu Server 24.04 LTS and work the same on 26.04 LTS and 22.04 LTS. All three configure networking with netplan and ship nftables. The Ubuntu 26.04 release notes list an update to Netplan 1.2; the network: version: 2 file format used below is unchanged. Ubuntu's package archive lists nftables, dnsmasq and hostapd for 26.04 as well. On Debian 12 and 13 the nftables, dnsmasq and hostapd parts are the same, but interfaces are usually configured in /etc/network/interfaces instead of netplan.

The part that trips people up

A router needs two things before any traffic flows: IP forwarding in the kernel, and a NAT (masquerade) rule for traffic leaving through the WAN interface. If forwarding is on but the masquerade rule is missing, clients still get addresses from DHCP, but nothing reaches the internet, because replies can't find their way back to private addresses. If either piece is unfamiliar, read the NAT section of Networking Fundamentals first.

Requirements

  • Two network interfaces: one for WAN (to the modem) and one for LAN (to a switch or access point). A second NIC in a spare PC or a small PC with two ports both work. Check that your distribution supports the NIC's driver before you buy one.
  • Ubuntu Server 22.04, 24.04 or 26.04 LTS with sudo access, and a keyboard and screen on the machine for the first setup. Network and firewall changes can cut off an SSH session, and the local console still works when they do.
  • No other firewall manager on the machine. If sudo ufw status says active, run sudo ufw disable; the nftables file below replaces it, and two tools writing rules at once conflict.
  • About an afternoon, plus time to test from a client device.

Install the packages first

Install everything while the machine still has a normal internet connection:

sudo apt update
sudo apt install nftables dnsmasq

On Ubuntu the dnsmasq service may fail to start right after installation with "address already in use", because systemd-resolved already answers DNS on 127.0.0.53. That's expected; the configuration later fixes it.

Network Interface Setup

Identify your interfaces:

Editor window showing netplan from this section
eth0 gets its WAN address from the ISP; eth1 is the LAN, with this box as the gateway at 192.168.1.1.
ip link show

This guide calls them eth0 (WAN, to the ISP modem) and eth1 (LAN, to your devices). Ubuntu normally uses predictable names such as enp1s0, so substitute the real names everywhere below.

Configure Interfaces

Edit the netplan file in /etc/netplan/ (the installer creates one; its name varies, and ls /etc/netplan/ shows it. Keep a single definition per interface). If the file's header says it was generated by cloud-init (usually 50-cloud-init.yaml), your edits won't survive a reboot until you disable cloud-init's network configuration as that header comment explains; see the netplan tutorial:

network:
  version: 2
  ethernets:
    eth0:
      dhcp4: true          # WAN gets its address from the ISP
    eth1:
      addresses:
        - 192.168.1.1/24   # LAN: this box is the gateway

The version: 2 line is required by netplan. Current netplan versions warn if the file is readable by other users, so restrict it with sudo chmod 600 and the file name. Apply the change with netplan try first, which rolls back automatically unless you confirm within the countdown, so a mistake doesn't leave you without network access:

sudo netplan try
sudo netplan apply

Check: ip -br addr shows an address from the modem on eth0 and 192.168.1.1/24 on eth1, and ip route show default points out through eth0.

Enable IP Forwarding

echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-router.conf
sudo sysctl --system

Linux doesn't route packets between interfaces until this is on. A file in /etc/sysctl.d/ is read at every boot, so the setting persists. Check: cat /proc/sys/net/ipv4/ip_forward prints 1.

From this moment the machine forwards anything it receives, so write and load the firewall in the next step before you connect the LAN side to other devices.

Firewall and NAT in one nftables file

An earlier version of this guide built the firewall from individual iptables commands. The Debian nftables wiki discourages building new firewalls on iptables and advises against mixing the two, so this version uses nftables directly. The whole policy lives in /etc/nftables.conf, and nft -f loads a file as a single transaction: either every rule applies or none does, so the router is never left with half a rule set.

Terminal window with the commands from this section
Save a known-good ruleset, schedule an automatic rollback in five minutes, then load the new rules.

Replace the contents of /etc/nftables.conf with this, changing the two interface names to yours:

#!/usr/sbin/nft -f
# Router firewall: WAN = eth0 (modem), LAN = eth1 (your devices)
flush ruleset

define WAN = "eth0"
define LAN = "eth1"

table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;

        ct state established,related accept
        ct state invalid drop
        iifname "lo" accept

        # IPv6 neighbour discovery and router adverts need ICMPv6
        meta l4proto ipv6-icmp accept

        # Management and services, LAN side only
        iifname $LAN tcp dport 22 accept
        # Permit DHCP replies to the WAN DHCP client, including broadcasts
        iifname $WAN udp sport 67 udp dport 68 accept
        iifname $LAN udp dport { 53, 67 } accept
        iifname $LAN tcp dport 53 accept
        iifname $LAN icmp type echo-request accept
    }

    chain forward {
        type filter hook forward priority filter; policy drop;

        ct state established,related accept
        ct state invalid drop

        # LAN devices may open connections to the internet
        iifname $LAN oifname $WAN accept
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}

table ip nat {
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        oifname $WAN masquerade
    }
}
  • The input chain protects the router itself. Only replies to its own connections, loopback, ICMPv6 and the services the LAN needs (SSH, DNS and DHCP) get in. Nothing from the WAN side can reach SSH.
  • The forward chain covers traffic passing through. LAN devices can connect out, replies come back through the established,related rule, and everything else from the internet is dropped.
  • masquerade rewrites the source address of everything leaving through the WAN interface to the router's address on that interface, and conntrack maps the replies back.
  • flush ruleset at the top removes every existing rule, including rules other software added. If you run Docker on the same machine, Docker's rules are flushed too; restart Docker after loading the file, or keep Docker off the router.

Apply it with an automatic rollback

Check the syntax first; -c parses the file without changing anything:

sudo nft -c -f /etc/nftables.conf

If you're connected over SSH, schedule a rollback before loading the rules. Use a console or provider console and save the known-good rules first. This timer restores that saved ruleset after five minutes unless cancelled. If the saved ruleset is empty, rollback restores an unfiltered host; disconnect the WAN while testing in that case.

sudo sh -c 'umask 077; { printf "flush ruleset\n"; nft list ruleset; } > /root/nft-known-good.nft'
sudo nft -c -f /root/nft-known-good.nft
sudo systemd-run --on-active=300 --unit=nft-rollback /usr/sbin/nft -f /root/nft-known-good.nft
sudo nft -f /etc/nftables.conf

Open a new SSH session from a LAN machine. If it works, cancel the rollback and make the rules load at boot:

sudo systemctl stop nft-rollback.timer
sudo systemctl enable nftables

If the new session doesn't connect, wait for the timer, which removes all rules, fix the file and try again. A flushed rule set leaves the router unfiltered, so don't leave it that way with the WAN cable plugged in.

Check: sudo nft list ruleset shows both tables, and systemctl is-enabled nftables prints enabled. The nftables service loads /etc/nftables.conf at boot.

DHCP and DNS with dnsmasq

dnsmasq, installed earlier, handles both DHCP and DNS in one small service. Add these settings to /etc/dnsmasq.conf:

Terminal window with the commands from the section DHCP and DNS with dnsmasq
Test the dnsmasq config, restart it, and enable it at boot.
# Serve only the LAN interface (dnsmasq adds loopback automatically)
interface=eth1
# Bind each interface's own address, not the wildcard address
bind-dynamic

# DHCP range and lease time
dhcp-range=192.168.1.100,192.168.1.200,24h

# Gateway handed to clients
dhcp-option=option:router,192.168.1.1

# Upstream DNS servers
server=1.1.1.1
server=8.8.8.8

# Local domain
domain=home.lan

# Don't read /etc/resolv.conf for upstream servers
no-resolv

Ubuntu's systemd-resolved already listens on port 53 on 127.0.0.53, which is why dnsmasq failed with "address already in use" after installation. The dnsmasq man page describes binding only specific addresses as the way to run next to another name server. It offers bind-interfaces for that, and bind-dynamic, a Linux-only variant that also picks up addresses that appear later, so dnsmasq still starts if eth1 has no cable plugged in at boot. If you'd rather filter ads for the whole network, run Pi-hole on another machine and use it as dnsmasq's upstream server.

Test the configuration, then restart and enable the service:

sudo dnsmasq --test
sudo systemctl restart dnsmasq
sudo systemctl enable dnsmasq

Check: sudo ss -ulnp | grep dnsmasq shows it on 192.168.1.1:53 and port 67. Connect a laptop to the LAN side: it should get an address between .100 and .200, and nslookup example.com 192.168.1.1 should answer.

Port Forwarding

To make a service on the LAN reachable from outside, two pieces go into /etc/nftables.conf: a destination NAT rule that rewrites incoming packets, and a forward rule that lets them through. This example forwards TCP 8080 on the WAN side to port 80 on 192.168.1.150. Add a prerouting chain to the existing table ip nat:

Editor window showing nftables prerouting chain from this section
A port forward: TCP 8080 arriving on WAN goes to port 80 on 192.168.1.150.
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        iifname $WAN tcp dport 8080 dnat to 192.168.1.150:80
    }

and this line to the forward chain, after the ct state invalid drop line:

        iifname $WAN oifname $LAN ip daddr 192.168.1.150 tcp dport 80 ct state new accept

The forward rule matches the rewritten destination (port 80), not the outside port. Apply the change the same way as before: nft -c -f, schedule the rollback timer if you're on SSH, load, test, cancel the timer. Test from outside your network, for example from a phone on mobile data. Every forward exposes that service to the internet, so forward only what you need and keep the service patched.

Traffic Shaping (Optional)

Bufferbloat, the latency spike you get when the line is full, is handled well by the CAKE queue discipline (tc-cake(8)):

# Shape traffic leaving the WAN interface (your upload) slightly below the measured line rate
sudo tc qdisc replace dev eth0 root cake bandwidth 20mbit

A qdisc on eth0 only shapes traffic leaving that interface, which on the WAN side is your upload. Shaping downloads needs ingress shaping, usually through an IFB device; the tc-cake(8) man page covers it. These tc settings don't survive a reboot, so add them to a systemd unit or a networkd hook once they work.

Wireless Access Point (Optional)

If the machine has a Wi-Fi card that supports AP mode, hostapd can broadcast a network. Many people instead keep an old router as a plain access point in bridge mode, or use a Pi with RaspAP.

sudo apt install hostapd

Edit /etc/hostapd/hostapd.conf:

interface=wlan0
driver=nl80211
# Set your own two-letter country code
country_code=US
ssid=MyNetwork
hw_mode=g
channel=6
wpa=2
wpa_passphrase=YourSecurePassword
wpa_key_mgmt=WPA-PSK
rsn_pairwise=CCMP

Enable it:

sudo systemctl unmask hostapd
sudo systemctl enable --now hostapd

Then either bridge wlan0 with eth1, or give it its own subnet and add it to dnsmasq and the firewall rules.

Monitoring

# Live bandwidth per connection on the WAN interface
sudo apt install iftop
sudo iftop -i eth0

# Tracked connections (from the conntrack package)
sudo apt install conntrack
sudo conntrack -L | head

# DHCP leases handed out by dnsmasq
cat /var/lib/misc/dnsmasq.leases

Connection Tracking Limit

Every forwarded connection uses an entry in the kernel's connection tracking table. If the kernel log shows nf_conntrack: table full, dropping packet, raise the limit:

echo "net.netfilter.nf_conntrack_max=131072" | sudo tee /etc/sysctl.d/98-conntrack.conf
sudo sysctl --system

The setting only exists once the conntrack module is loaded, which happens as soon as the NAT rules are in place.

If clients get an address but no internet

  1. cat /proc/sys/net/ipv4/ip_forward must print 1.
  2. sudo nft list chain ip nat postrouting must show masquerade on the WAN interface name. A wrong name loads without any error; the rule simply never matches.
  3. sudo nft list chain inet filter forward must show the LAN-to-WAN accept rule, with the interface names the right way round.
  4. From the router itself, ping -c 3 1.1.1.1 checks the WAN link, and nslookup example.com 192.168.1.1 from a client checks dnsmasq.

To see what the firewall drops, add log prefix "nft-drop: " counter as the last line of the input or forward chain, reload, and watch sudo journalctl -k -f. Remove the line again when you're done, because it logs every dropped packet.

If clients get no address

  • sudo systemctl status dnsmasq and sudo journalctl -u dnsmasq -b show whether it started and why not.
  • The input chain must accept UDP 67 on the LAN interface; without it DHCP requests never reach dnsmasq.
  • Another DHCP server on the same LAN (an old router not yet switched to access-point mode, for example) hands out conflicting addresses. Turn its DHCP off.

Undoing the setup

To turn the machine back into an ordinary host: sudo systemctl disable --now nftables dnsmasq, run sudo nft flush ruleset, delete /etc/sysctl.d/99-router.conf (and 98-conntrack.conf if you added it), and put back the netplan file you started with. Restore your old router before you unplug this one, so the network isn't left without DHCP.

What about OPNsense, pfSense or OpenWrt?

They're good choices if you want a web interface and a purpose-built system. OPNsense and pfSense are FreeBSD-based firewall distributions, and OpenWrt replaces the firmware on consumer routers. A plain Linux router makes sense when you already know Linux and want to run other services, such as a WireGuard server or a DNS filter, on the same machine with the tools you already use.

Whichever you pick, keep your old consumer router around as a plain access point; that's often the simplest way to provide Wi-Fi.