Skip to content

Instantly share code, notes, and snippets.

@marfillaster
Last active May 16, 2026 12:55
Show Gist options
  • Select an option

  • Save marfillaster/7580167fb1721b7a43002daf3382e6e5 to your computer and use it in GitHub Desktop.

Select an option

Save marfillaster/7580167fb1721b7a43002daf3382e6e5 to your computer and use it in GitHub Desktop.
MikroTik RouterOS v7 — IPv6-over-WireGuard Relay via Routed-/48 VPS

IPv6-over-WireGuard relay — VPS with a routed /48 + MikroTik

Part of a larger home-network build — full write-up with topology, VLANs, UniFi on the router, encrypted DNS, and the rationale behind every choice: https://marfillaster.github.io/mikrotik-home-network/

This is the simpler counterpart of the Vultr/on-link /64 recipe. Use this gist when your VPS provider routes a real prefix to your instance (e.g. WebHorizon SG, Hetzner, Linode-on-request, Oracle). Use the older gist when your provider only assigns on-link IPv6 (e.g. Vultr) and you have to NDP-proxy.

When the prefix is routed, ndppd disappears, the reserved-IP fee disappears, and the address plan opens up — you have 65k /64s to carve up however you want.

Cost vs the Vultr variant

Item Vultr (on-link /64) This (routed /48)
VPS plan $5/mo $3/mo (e.g. WebHorizon SG, 1 TB)
Reserved IPv6 $3/mo $0 (already routed)
Bandwidth overage $0.01/GB $0.0025/GB (4× cheaper)
LAN address plan one /64 one /48 — 65k /64s
Extra daemon on VPS ndppd none
Total fixed $8/mo $3/mo

Resulting layout

                        Internet
                           │
                ┌──────────┴──────────┐
                │   VPS (Ubuntu)      │
                │   <VPS_IP>          │
                │ enp3s0:             │
                │   <UPSTREAM /128>    ← provider link addr (untouched)
                │   default v6 via provider gateway
                │ wg0: <LAN_PREFIX>:0::1/64    ← WG transit /64 from your /48
                └──────────┬──────────┘
                           │ WireGuard, UDP/51820
                           │
                ┌──────────┴──────────┐
                │  MikroTik v7        │
                │ wg-host:            │
                │   <LAN_PREFIX>:0::2/64
                │ bridge (LAN):       │
                │   <LAN_PREFIX>:1::1/64  (SLAAC to clients)
                │   <ULA_PREFIX>::1/64    (ULA, SLAAC + DNS)
                └──────────┬──────────┘
                           │
                        LAN clients
                        (SLAAC <LAN_PREFIX>:1::/64 + ULA + RDNSS)

For multiple VLANs/segments, allocate further /64s from the same /48:

<LAN_PREFIX>:0::/64  — WG transport
<LAN_PREFIX>:1::/64  — main LAN
<LAN_PREFIX>:10::/64 — guest VLAN
<LAN_PREFIX>:11::/64 — IoT VLAN
…

Placeholders

Substitute these before pasting:

Placeholder Where to get it
<VPS_IP> VPS provider panel → instance public IPv4
<VPS_NIC> Provider's NIC name (enp3s0, ens3, etc. — ip -o link to find)
<UPSTREAM_ADDR> / <UPSTREAM_GW> The /128 link address + gateway the provider configured (if not auto-configured via RA)
<LAN_PREFIX> The /48 (or /56) routed to your VPS — e.g. 2001:db8 if /48 is 2001:db8::/48
<ULA_PREFIX> A locally-generated ULA /64, e.g. fdXX:XXXX:XXXX:1 — generate with python3 -c 'import secrets; print(f"fd{secrets.token_hex(5)}".lower())'
<VPS_PRIVKEY> / <VPS_PUBKEY> wg genkey | tee server.key | wg pubkey on the VPS
<MT_PUBKEY> After creating the wireguard interface on MikroTik, /interface/wireguard/print shows it
<LAN_BRIDGE> MikroTik LAN bridge name — bridge in defconf

Examples below use <LAN_PREFIX>=2001:db8, <ULA_PREFIX>=fd00:dead:beef:1. These are documentation prefixes from RFC 3849 / RFC 4193 — substitute your own.

Provider note: choose a routed-prefix VPS

This recipe assumes the provider routes a prefix to your instance. Confirm in the panel that your IPv6 allocation says "routed /48" or "routed /56" (or that the prefix is a different /48 than the on-link transport subnet). If it's a single on-link /64 you can't subnet, you're in Vultr-land — see the other gist.

How return traffic finds its way: list each /64 in AllowedIPs

WebHorizon (or any provider that hands you a routed prefix in this shape) does two things with your /48:

  1. Routes the /48 to your VPS at the upstream gateway. ✓
  2. Configures one address from it on the VPS's public NIC at /48 mask — e.g. 2401:4520:110b::a/48 on enp3s0. This creates a kernel connected route for the entire /48 on the public interface.

That second route competes with wg-quick's tunnel route for the same prefix, and the public interface wins on metric. Egress is fine (LAN client → MikroTik → tunnel → VPS → public NIC → internet), but return traffic for any LAN /64 inside the /48 would land on the public NIC, fail ND, and disappear.

The fix is built into the VPS paste below: AllowedIPs lists each LAN /64 you actually serve. Wg-quick installs one dev wg0 route per entry, and longest-prefix match (/64 over /48) puts the return path back through the tunnel where it belongs.

The shape of the routing table on the VPS ends up like this:

2401:4520:110b::/48      dev enp3s0  metric 256   ← provider-installed, harmless
2401:4520:110b:1::/64    dev wg0                  ← from AllowedIPs, wins by prefix length
2401:4520:110b:10::/64   dev wg0                  ← from AllowedIPs
2401:4520:110b:20::/64   dev wg0                  ← from AllowedIPs

Practical consequence: every LAN /64 you SLAAC on the MikroTik must be in AllowedIPs on the VPS. Adding a VLAN later means adding the /64 there and wg-quick down wg0 && wg-quick up wg0. Listing <LAN_PREFIX>::/48 as a single entry doesn't work — it's the same prefix length as the public-NIC route, so metric breaks the tie the wrong way.

Providers known to route prefixes (verify on your specific plan):

  • WebHorizon SG — routed /48, $3/mo, 1 TB, $2.50/TB overage
  • Hetzner Cloud — /64 per server effectively routed (NDP-proxied at gateway, transparent)
  • Linode/Akamai — /64 default, request routed /64 or /56 via support, free
  • Oracle Cloud — /56 routed to your VCN; "Always-Free" tier viable
  • OVH — varies by product; verify

1. VPS paste (run as root on Ubuntu)

set -e
apt-get update -qq
apt-get install -y -qq wireguard

cat >/etc/sysctl.d/99-wg-relay.conf <<'EOF'
net.ipv6.conf.all.forwarding = 1
net.ipv6.conf.default.forwarding = 1
net.ipv6.conf.<VPS_NIC>.accept_ra = 2
EOF
sysctl --system >/dev/null

umask 077
mkdir -p /etc/wireguard
wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub
VPS_PRIVKEY=$(cat /etc/wireguard/server.key)

cat >/etc/wireguard/wg0.conf <<EOF
[Interface]
PrivateKey = ${VPS_PRIVKEY}
Address    = <LAN_PREFIX>:0::1/64
ListenPort = 51820
MTU        = 1420

[Peer]
# MikroTik
PublicKey  = <MT_PUBKEY>
# One entry per LAN /64 so wg-quick installs more-specific routes that beat
# the provider's /48 connected route on the public NIC. Section above explains.
AllowedIPs = <LAN_PREFIX>:0::2/128, <LAN_PREFIX>:1::/64, <LAN_PREFIX>:10::/64, <LAN_PREFIX>:20::/64
PersistentKeepalive = 25
EOF

# Firewall: allow WG, allow forwarding for the LAN prefix
ufw allow 51820/udp comment "WireGuard"
ufw route allow in on wg0
ufw route allow out on wg0
ufw reload || true

systemctl enable --now wg-quick@wg0

# Print VPS public key — you need it on the MikroTik side
echo "VPS public key: $(cat /etc/wireguard/server.pub)"

No ndppd. Because the /48 is routed, the provider's gateway sends packets for any address in it directly to your VPS. The kernel then forwards them through wg0 based on AllowedIPs. Done.

2. MikroTik paste (RouterOS v7)

# Create WG interface (RouterOS auto-generates its private key)
/interface/wireguard add name=wg-host listen-port=51820 mtu=1420

# Get the MikroTik's public key, then add the VPS as a peer
/interface/wireguard/peers add interface=wg-host name=vps \
    public-key="<VPS_PUBKEY>" \
    endpoint-address=<VPS_IP> endpoint-port=51820 \
    allowed-address=::/0 \
    persistent-keepalive=25s

# Address on WG transport /64
/ipv6/address add address=<LAN_PREFIX>:0::2/64 interface=wg-host advertise=no

# Address on LAN bridge — RA will pick this up automatically
/ipv6/address add address=<LAN_PREFIX>:1::1/64 interface=<LAN_BRIDGE> advertise=yes
/ipv6/address add address=<ULA_PREFIX>::1/64    interface=<LAN_BRIDGE> advertise=yes comment="ULA RFC 4193"

# Default v6 route via the VPS. check-gateway=ping detects a dead tunnel in
# ~30 s; Section 4 (optional) replaces this with BGP+BFD for ~600 ms detection.
/ipv6/route add dst-address=::/0 gateway=<LAN_PREFIX>:0::1%wg-host \
    check-gateway=ping comment="webhorizon primary"

# RA on bridge (skip if defconf RA is already covering bridge).
# advertise-dns=self: the router advertises whatever address it holds on the
# interface as the RDNSS, so it can never point at a stale or wrong address
# and it survives a prefix renumber with nothing to keep in sync.
/ipv6/nd add interface=<LAN_BRIDGE> advertise-dns=self \
    managed-address-configuration=no other-configuration=no

# Allow WG-side inbound on input chain (defconf drops non-LAN input)
/ipv6/firewall/filter add chain=input action=accept in-interface=wg-host \
    comment="accept input from VPS WG peer" \
    place-before=[find where chain=input and comment="defconf: drop everything else not coming from LAN"]

3. Smoke test

On the VPS:

wg show                                       # latest handshake should be < 3 min
ping6 -c 2 <LAN_PREFIX>:0::2                  # WG transit reachable (MT side)
ping6 -c 2 <LAN_PREFIX>:1::1                  # MT LAN gateway reachable via wg0

On the MikroTik:

/ping 2606:4700:4700::1111 count=3            # Cloudflare via VPS

On a LAN client:

ip -6 addr | grep <LAN_PREFIX>                # confirm SLAAC picked up the /64
ping6 -c 3 2606:4700:4700::1111
curl -6 -s https://test-ipv6.com/json/        # expect "score":"10/10"

A useful sanity check on the VPS to confirm the routing table is shaped correctly:

ip -6 route get <client-IPv6-addr>     # expect "dev wg0", not "dev <VPS_NIC>"

4. Optional: sub-second failover with BGP + BFD

What this section trades

Section 2's default route uses RouterOS's check-gateway=ping, which detects a dead tunnel in ~30 s (10 s interval × 3 misses, hardcoded). During that window the MikroTik keeps offering an IPv6 default route, AAAA-targeted connections sit in SYN-timeout, and Happy Eyeballs eventually retries on IPv4 — typical user-visible effect is half a minute of "internet seems off."

This section replaces the static route with a BGP-advertised one on a BFD-monitored session. Detection becomes ~600 ms: the route is withdrawn the moment BFD declares the path dead, the kernel returns "no route" immediately, and clients fall back to IPv4 within one connection attempt.

The cost is a bird2 daemon on the VPS, a BGP+BFD configuration on the MikroTik, and ~3.4 GB/month of BFD heartbeat traffic (≈ $0.0085 at WebHorizon's $2.50/TB). Worth it for metered VPS plans where quota null-routes happen often enough to be noticed; skip if 30 s of stalled IPv6 is fine and you'd rather avoid the extra operational surface.

Numbers (measured on this setup)

Event Time
WG tunnel goes silent → BFD down → BGP route withdrawn ~600 ms
WG tunnel restored → BFD up → BGP route re-installed ~3 s
Full VPS reboot → bird back, BGP+BFD up, route installed ~28 s (bounded by VPS boot, not protocol)
BFD bandwidth at 200 ms × 3, bidirectional ~3.4 GB/mo
BFD cost at WebHorizon $2.50/TB ~$0.0085/mo

Extra placeholders

Placeholder Notes
<VPS_AS> A private ASN, e.g. 65002 (RFC 6996 range 64512–65534, or 4-byte range 4200000000+)
<MT_AS> A different private ASN, e.g. 65001
<VPS_ROUTER_ID> Any IPv4 unique within your AS, e.g. 10.0.0.1. Doesn't have to be a real address.
<MT_ROUTER_ID> Same — your MikroTik LAN IP is a convenient choice (192.168.88.1).

4a. VPS additions

1. Add a link-local on wg0 — bird's next hop self requires one, and wg-quick doesn't add one by default. Append to /etc/wireguard/wg0.conf:

[Interface]
# ...existing Address line stays...
Address = fe80::1/64

Reload: wg-quick down wg0 && wg-quick up wg0.

2. Install bird2 and write its config. Run as root on the VPS:

apt-get install -y bird2

mkdir -p /etc/bird
cat >/etc/bird/bird.conf <<EOF
log syslog all;
router id <VPS_ROUTER_ID>;

protocol device { }

protocol kernel kernel6 {
  ipv6 { export none; import all; };
  learn yes;
}

protocol bfd {
  interface "wg0" {
    min rx interval 200 ms;
    min tx interval 200 ms;
    idle tx interval 1 s;
    multiplier 3;
  };
  # Explicit neighbor so bird actively probes the MikroTik instead of just
  # responding. See "Why bird needs an explicit BFD neighbor" below.
  neighbor <LAN_PREFIX>:0::2 dev "wg0";
}

protocol bgp mikrotik {
  local <LAN_PREFIX>:0::1 as <VPS_AS>;
  neighbor <LAN_PREFIX>:0::2 as <MT_AS>;
  ipv6 {
    import none;
    export where net = ::/0;
    next hop self;
  };
}
EOF
chown -R bird:bird /etc/bird

3. Tighten the systemd restart policy so bird comes back on any exit (the packaged unit uses Restart=on-abnormal, which leaves the daemon stopped after a graceful exit such as a bad reload):

mkdir -p /etc/systemd/system/bird.service.d
cat >/etc/systemd/system/bird.service.d/restart.conf <<'EOF'
[Service]
Restart=on-failure
RestartSec=2s
EOF
systemctl daemon-reload
systemctl enable --now bird

4. Verify (the BFD side will stay Down until Section 4b is done):

birdc show protocols mikrotik   # State: up,  Info: Established (after 4b)
birdc show bfd sessions         # State: Up,  Interval 0.200, Timeout 0.600 (after 4b)

4b. MikroTik additions

1. Create the BGP instance, template, and connection. RouterOS 7 splits BGP into three nested objects — instance carries AS/router-id, template carries policy (here, just use-bfd), connection is per-peer.

/routing/bgp/instance/add name=default-bgp as=<MT_AS> router-id=<MT_ROUTER_ID>

/routing/bgp/template/add name=tpl-host as=<MT_AS> use-bfd=yes

/routing/bgp/connection/add name=host-vps instance=default-bgp \
    remote.address=<LAN_PREFIX>:0::1 remote.as=<VPS_AS> \
    local.address=<LAN_PREFIX>:0::2 local.role=ebgp \
    templates=tpl-host afi=ipv6

A few things about the syntax that aren't obvious from the field names: every connection requires an instance (the instance object is what carries the AS and router-id, the connection just references it); the address-family field is afi=ipv6 (singular — RouterOS rejects address-family and address-families); the as= value appearing on both the instance and template is the local AS on the MikroTik side, not the remote.

2. BFD timer config (per-interface):

/routing/bfd/configuration/add interfaces=wg-host \
    min-rx=200ms min-tx=200ms multiplier=3

3. Allow BFD on the input firewall. The defconf input chain drops anything not coming from the LAN list, including UDP/3784 from the VPS, so add an explicit accept ahead of the catch-all drop:

/ipv6/firewall/filter add chain=input action=accept protocol=udp dst-port=3784 \
    in-interface=wg-host comment="BFD from VPS" \
    place-before=[find where chain=input and comment="defconf: drop everything else not coming from LAN"]

4. Remove the static ::/0 from Section 2 — the BGP-learned route at distance 20 will own it:

/ipv6/route/remove [find comment="webhorizon primary"]

5. Verify:

/routing/bfd/session/print
# state=up, actual-tx-interval=200ms, hold-time=600ms

/routing/bgp/session/print
# E (ESTABLISHED) flag, prefix-count=1

/ipv6/route/print where dst-address="::/0"
# DAb ::/0  fe80::1%wg-host  distance=20   (only ::/0 entry)

/ping count=3 2606:4700:4700::1111

4c. Test the failure mode

Simulate a VPS quota / null-route by blocking WireGuard UDP on the VPS:

# On the VPS:
iptables -I INPUT  -p udp --dport 51820 -j DROP
iptables -I OUTPUT -p udp --sport 51820 -j DROP

# Watch on the MikroTik:
/routing/bfd/session/print   # should go to state=down within ~600ms
/ipv6/route/print where dst-address="::/0"   # ::/0 disappears

# Undo:
iptables -D INPUT  -p udp --dport 51820 -j DROP
iptables -D OUTPUT -p udp --sport 51820 -j DROP
# Route reappears within ~3 s.

Run a continuous IPv6 ping from a LAN client during the test. With BGP+BFD you'll see a sharp transition: pings fail at the moment the block applies, and dual-stack apps Happy-Eyeballs to IPv4 immediately instead of stalling.

Why bird needs an explicit BFD neighbor

A protocol bfd block with only an interface clause is passive: bird responds to incoming probes but never initiates. That works on the first bring-up because MikroTik initiates as soon as its BGP session asks for BFD — but it's not enough for clean recovery after the tunnel goes down.

The recovery sequence with passive-only bird looks like this:

  1. Tunnel breaks. MikroTik's BFD declares Down, BGP drops, route is withdrawn. ✓
  2. Tunnel restored. MikroTik attempts BGP, but its connection has use-bfd=yes, so it waits for BFD to come up first.
  3. MikroTik doesn't send BFD probes until a protocol asks, and bird (passive) doesn't initiate either. Both sides wait for the other; recovery requires a manual birdc restart.

Adding neighbor <peer> dev "wg0" makes bird's BFD an active probe regardless of BGP state. The session stays warm whenever the tunnel is up, and BGP re-establishes seconds later without intervention.

There's no equivalent setting on the MikroTik side — its BFD probes whenever a use-bfd=yes connection exists, so a single explicit neighbor on bird closes the loop.

Cost recap

Line item Cost
VPS (routed /48, SG, 1 TB transfer) $3.00 / mo
Bandwidth overage if exceeded $0.0025/GB
Total $3.00 / mo

Compare with the Vultr variant ($8/mo) — $5/mo saved and a bigger prefix.

When to use which gist

Situation Use
Provider gives a routed /48 or /56 this gist
Provider only does on-link /64 (Vultr) Vultr gist
You need SLAAC on more than one VLAN this gist
You want the cheapest 24/7 SG relay this gist
You're stuck with Vultr for other reasons Vultr gist

What's in each section

Section Status
1 — VPS paste required
2 — MikroTik paste required
3 — Smoke test required
4 — BGP + BFD for sub-second failover optional — adds ~3 GB/mo of bandwidth in exchange for 600 ms detection (vs. 30 s with the static route). Recommended for metered VPS plans.
5 — Migration note from Vultr setup optional
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment