TL-MR3020 v3 (64MB RAM) — Complete travel router setup: WireGuard + Extroot + Travelmate on 25.12.4

Sharing my experience setting up a TP-Link TL-MR3020 v3 as a dedicated travel router with VPN. This took several days of trial and error, and I hope it helps others with similar low-memory devices.

Hardware

  • Device: TP-Link TL-MR3020 v3
  • SoC: MT7628 (ramips/mt76x8)
  • RAM: 58 MB usable
  • Flash: 8 MB NOR
  • USB: 1x USB 2.0

Final working setup (OpenWrt 25.12.4 official)

  • Extroot: USB drive, ext4 without journal, 503 MB overlay
  • VPN: WireGuard (NordVPN), full tunnel
  • WiFi: AP (travel network) + STA (upstream hotel/airport WiFi)
  • Travelmate: 5 uplinks configured, captive portal support
  • DNS hotplug: Automatic VPN DNS when VPN is up, upstream DNS when VPN is down (for captive portal login)
  • LuCI: Accessible for WiFi management
  • Packages: 188 installed on extroot

Key findings and lessons

1. CONFIG_SWAP is removed from mt76x8 kernel

As reported in #21853, CONFIG_SWAP was deliberately removed from the ramips/mt76x8 subtarget. /proc/swaps does not exist on official images.

Custom build attempt: I compiled a custom image with CONFIG_SWAP=y and it worked — 289 MB swap (USB + zram). However, the larger image left only 192-256 KB for rootfs_data, and jffs2 requires a minimum of 5 erase blocks (320 KB on this device with 64 KB erase size). With fewer blocks, jffs2 fails with Too few erase blocks and the overlay falls back to tmpfs — nothing persists.

The tradeoff: On 8 MB flash, you can have either swap OR a working jffs2 overlay, but not both. I chose jffs2 (persistence) and no swap.

2. ext4 without journal is critical for extroot

On a 64 MB RAM device, ext4 journal recovery during boot will OOM-kill the system. Always format the USB partition without journal:

mkfs.ext4 -O ^has_journal -L MR3020 /dev/sda1

3. CIDR notation required in OpenWrt 25.12

This one cost me several TFTP recoveries. When changing the LAN IP via UCI:

# WRONG (becomes /32, device unreachable):
uci set network.lan.ipaddr='192.168.3.1'

# CORRECT:
uci set network.lan.ipaddr='192.168.3.1/24'

See #21370 and this forum thread.

4. WireGuard installation via manual .apk extraction

The official apk add fails on 64 MB devices (OOM during package index download). Workaround:

  1. Download .apk files on another machine
  2. Transfer via SSH pipe: cat file.apk | ssh root@router 'cat > /tmp/file.apk'
  3. Extract: apk extract --allow-untrusted --destination / /tmp/file.apk
  4. Load modules manually: insmod /lib/modules/6.12.87/wireguard.ko

Important module load order:

insmod scsi_common.ko  # MUST be first
insmod scsi_mod.ko
insmod sd_mod.ko
insmod usb-storage.ko

5. DNS hotplug for captive portal compatibility

WireGuard with AllowedIPs = 0.0.0.0/0 captures all traffic. For captive portal login to work, DNS must fall back to upstream when VPN is down:

# /etc/hotplug.d/iface/95-wireguard-dns
#!/bin/sh
case "$ACTION" in
    ifup)
        [ "$INTERFACE" = "wg_vpn" ] && {
            sleep 3
            uci set dhcp.@dnsmasq[0].noresolv="1"
            uci delete dhcp.@dnsmasq[0].resolvfile 2>/dev/null
            uci delete dhcp.@dnsmasq[0].server 2>/dev/null
            uci add_list dhcp.@dnsmasq[0].server="YOUR_VPN_DNS_1"
            uci add_list dhcp.@dnsmasq[0].server="YOUR_VPN_DNS_2"
            uci commit dhcp
            /etc/init.d/dnsmasq restart
            logger -t wg-dns "VPN UP: DNS via VPN provider"
        }
        ;;
    ifdown)
        [ "$INTERFACE" = "wg_vpn" ] && {
            uci set dhcp.@dnsmasq[0].noresolv="0"
            uci set dhcp.@dnsmasq[0].resolvfile="/tmp/resolv.conf.d/resolv.conf.auto"
            uci delete dhcp.@dnsmasq[0].server 2>/dev/null
            uci commit dhcp
            /etc/init.d/dnsmasq restart
            logger -t wg-dns "VPN DOWN: DNS upstream (captive portal mode)"
        }
        ;;
esac

6. Travelmate VPN management must be disabled

If using travelmate with an external VPN (WireGuard), set trm_vpn=0. Otherwise travelmate will try to manage the VPN interface and may tear it down repeatedly.

What didn't work

  • Swap on official image: Impossible, CONFIG_SWAP not in kernel
  • Custom build with swap + working jffs2: Image too large, not enough erase blocks
  • Custom build with swap + WireGuard built-in: Even larger, definitely not enough
  • OpenVPN on boot with 58 MB RAM: OOM boot loop. Must start VPN after WiFi is connected.
  • apk add online on 64 MB device: OOM during repo index processing

Tips for similar devices (4/64 MB flash/RAM)

  1. Use official image + extroot USB (don't fight the flash size)
  2. Format ext4 without journal (-O ^has_journal)
  3. Install packages via manual .apk extraction, not apk add
  4. Don't enable VPN on boot — use hotplug to start after connectivity
  5. Use delay_root=15 in fstab for USB detection
  6. Keep TFTP recovery image handy — you will brick it at least once
  7. Remember CIDR notation for IP changes in 25.12+

Hope this helps someone! Happy to answer questions.

Update (2026-06-09): A few corrections and additions after more testing:

DNS rebind protection must be disabled

Captive portals in hotels often use internal FQDNs that only resolve with the hotel's DNS. With rebind_protection=1 (default), dnsmasq blocks these responses. Disable it:

uci set dhcp.@dnsmasq[0].rebind_protection="0"
uci commit dhcp
/etc/init.d/dnsmasq restart

Improved DNS hotplug script

The original script only triggered on WireGuard ifup/ifdown, but WireGuard's interface stays up even when the handshake fails (e.g., behind a captive portal). The improved version checks the actual WG handshake timestamp when WiFi connects:

# /etc/hotplug.d/iface/95-wireguard-dns
#!/bin/sh
set_vpn_dns() {
    uci set dhcp.@dnsmasq[0].noresolv="1"
    uci delete dhcp.@dnsmasq[0].resolvfile 2>/dev/null
    uci delete dhcp.@dnsmasq[0].server 2>/dev/null
    uci add_list dhcp.@dnsmasq[0].server="YOUR_VPN_DNS_1"
    uci add_list dhcp.@dnsmasq[0].server="YOUR_VPN_DNS_2"
    uci commit dhcp
    /etc/init.d/dnsmasq restart
}

set_upstream_dns() {
    uci set dhcp.@dnsmasq[0].noresolv="0"
    uci set dhcp.@dnsmasq[0].resolvfile="/tmp/resolv.conf.d/resolv.conf.auto"
    uci delete dhcp.@dnsmasq[0].server 2>/dev/null
    uci commit dhcp
    /etc/init.d/dnsmasq restart
}

case "$ACTION" in
    ifup)
        if [ "$INTERFACE" = "wg_vpn" ]; then
            set_vpn_dns
        elif [ "$INTERFACE" = "trm_wwan" ]; then
            # WiFi connected — check if WG handshake is recent
            sleep 15
            HANDSHAKE=$(wg show wg_vpn latest-handshakes 2>/dev/null | awk '{print $2}')
            NOW=$(date +%s)
            if [ -n "$HANDSHAKE" ] && [ "$HANDSHAKE" -gt 0 ] && [ $((NOW - HANDSHAKE)) -lt 180 ]; then
                set_vpn_dns
            else
                set_upstream_dns  # No handshake = captive portal likely
            fi
        fi
        ;;
    ifdown)
        [ "$INTERFACE" = "wg_vpn" ] && set_upstream_dns
        ;;
esac

This way, when you connect to a hotel WiFi with a captive portal:

  1. WiFi associates → trm_wwan ifup
  2. Script waits 15s, checks WG handshake → no handshake (captive portal blocks UDP)
  3. DNS set to upstream → captive portal login page appears
  4. After login, WG handshake succeeds → wg_vpn ifup → DNS switches to VPN

USB kernel module load order

When loading USB storage modules manually via insmod, scsi_common.ko must be loaded before scsi_mod.ko:

insmod scsi_common.ko  # FIRST
insmod scsi_mod.ko
insmod sd_mod.ko
insmod usb-storage.ko

Without scsi_common.ko first, usb-storage fails with Unknown symbol scsi_* errors.