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.
| 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 |
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
…
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.
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.
WebHorizon (or any provider that hands you a routed prefix in this shape) does two things with your /48:
- Routes the /48 to your VPS at the upstream gateway. ✓
- Configures one address from it on the VPS's public NIC at /48 mask — e.g.
2401:4520:110b::a/48onenp3s0. 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
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.
# 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"]
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 wg0On 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>"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.
| 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 |
| 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). |
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/64Reload: 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/bird3. 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 bird4. 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)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
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.
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:
- Tunnel breaks. MikroTik's BFD declares Down, BGP drops, route is withdrawn. ✓
- Tunnel restored. MikroTik attempts BGP, but its connection has
use-bfd=yes, so it waits for BFD to come up first. - 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.
| 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.
| 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 |
| 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 |