TTR Networks

Intermediate · Two internet connections on Debian · section 8 of 8

Build the lab yourself

✓ Lab-tested 21 October 2026 on Debian 13.7, iproute2 6.15.0, nftables 1.1.3, systemd 257

The lab runs on any machine with Docker, including an Apple Silicon Mac, in a few hundred megabytes. The router runs real systemd, so the services and timer work as on a Debian server.

  1. Put the files from this page in one directory, with the router's files from earlier in the lesson.
  2. docker compose -f lab-compose.yaml up -d --build, then ./lab-setup.sh to address every node and set up the ISPs' filters.
  3. Copy the router's files into place on gw with docker compose cp, then follow the lesson: forwarding and nftables, the first default route, the routing tables, the marks and the health check.
  4. To fail ISP 1 the way the lesson does, run this on isp1, and delete the table again to repair it:
nft add table ip outage
nft add chain ip outage upstream '{ type filter hook forward priority 0; policy drop; }'

As in the other Debian labs, lab-setup.sh turns IP forwarding off on the router, because Docker Desktop starts every container with it on.

lab-compose.yaml
# Two internet connections on Debian: the lab.  docker compose -f lab-compose.yaml up -d --build && ./lab-setup.sh
name: dwan-lab
x-host: &host
  build: { context: ., dockerfile: Dockerfile.host }
  image: ttr-dwan-host:13
  privileged: true                      # ip, sysctl, nft
services:
  gw:
    build: { context: ., dockerfile: Dockerfile.gw }
    image: ttr-dwan-gw:13
    hostname: gw
    privileged: true
    cgroup: host                        # let systemd run as PID 1
    volumes: ["/sys/fs/cgroup:/sys/fs/cgroup:rw"]
    tmpfs: ["/run", "/run/lock"]
    networks:
      wan1: { interface_name: eth0, priority: 300 }
      wan2: { interface_name: eth1, priority: 200 }
      lan:  { interface_name: eth2, priority: 100 }
  isp1: { <<: *host, hostname: isp1, networks: { wan1: { interface_name: eth0, priority: 200 }, up1: { interface_name: eth1, priority: 100 } } }
  isp2: { <<: *host, hostname: isp2, networks: { wan2: { interface_name: eth0, priority: 200 }, up2: { interface_name: eth1, priority: 100 } } }
  core:
    <<: *host
    hostname: core
    networks:
      up1: { interface_name: eth0, priority: 300 }
      up2: { interface_name: eth1, priority: 200 }
      net: { interface_name: eth2, priority: 100 }
  web:  { <<: *host, hostname: web, networks: { net: { interface_name: eth0 } } }
  pc:   { <<: *host, hostname: pc,  networks: { lan: { interface_name: eth0 } } }
# Layer-2-only networks: every address and route comes from lab-setup.sh.
networks:
  wan1: { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:a3::/64" }] } }
  wan2: { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:a4::/64" }] } }
  lan:  { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:a5::/64" }] } }
  up1:  { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:a6::/64" }] } }
  up2:  { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:a7::/64" }] } }
  net:  { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:a8::/64" }] } }
  • Lines 8–19: The router runs systemd as PID 1, with three networks named in order.
  • Lines 32–38: Layer-2-only networks; addresses come from lab-setup.sh.
lab-setup.sh
#!/bin/sh
# Address every node and give it its routes. Run after `docker compose up`.
x() { docker compose -f lab-compose.yaml exec -T "$1" sh -c "$2"; }
# IPv4 only, for clean output.
for n in gw isp1 isp2 core web pc; do x $n 'sysctl -qw net.ipv6.conf.all.disable_ipv6=1'; done
# Docker Desktop turns forwarding on in every container; a fresh Debian router has it off.
x gw   'sysctl -qw net.ipv4.ip_forward=0'
# The office router: one address from each ISP, and the office LAN. No routes yet.
x gw   'ip addr add 198.51.100.2/28 dev eth0; ip addr add 203.0.113.2/28 dev eth1; ip addr add 10.20.0.1/24 dev eth2'
x pc   'ip addr add 10.20.0.10/24 dev eth0; ip route add default via 10.20.0.1'
# Two ISPs. Each accepts only its own addresses from the customer (BCP38 source
# filtering, as real ISPs should), and routes the rest of the internet via core.
x isp1 'ip addr add 198.51.100.1/28 dev eth0; ip addr add 198.51.100.242/28 dev eth1; ip route add default via 198.51.100.241; sysctl -qw net.ipv4.ip_forward=1
        nft add table ip bcp38; nft add chain ip bcp38 customer "{ type filter hook forward priority 0; }"
        nft add rule ip bcp38 customer iifname eth0 ip saddr != 198.51.100.0/28 counter drop'
x isp2 'ip addr add 203.0.113.1/28 dev eth0; ip addr add 203.0.113.242/28 dev eth1; ip route add default via 203.0.113.241; sysctl -qw net.ipv4.ip_forward=1
        nft add table ip bcp38; nft add chain ip bcp38 customer "{ type filter hook forward priority 0; }"
        nft add rule ip bcp38 customer iifname eth0 ip saddr != 203.0.113.0/28 counter drop'
# The internet: core routes each ISP's customer range to that ISP, and hosts the web.
x core 'ip addr add 198.51.100.241/28 dev eth0; ip addr add 203.0.113.241/28 dev eth1; ip addr add 192.0.2.1/24 dev eth2; sysctl -qw net.ipv4.ip_forward=1
        ip route add 198.51.100.0/28 via 198.51.100.242; ip route add 203.0.113.0/28 via 203.0.113.242'
# A web server that logs visitors, with a second address used as a health-check target.
x web  'ip addr add 192.0.2.80/24 dev eth0; ip addr add 192.0.2.53/24 dev eth0; ip route add default via 192.0.2.1'
x web  'cd /tmp && nohup python3 -m http.server 80 > /tmp/web.log 2>&1 &'
# A web server in the office, to publish through both ISPs.
x pc   'cd /tmp && nohup python3 -m http.server 80 > /tmp/web.log 2>&1 &'
  • Lines 6–7: Forwarding off on the router, as on a fresh Debian install.
  • Lines 11–18: Each ISP drops packets from the office that don't carry its own addresses.
  • Lines 20–21: The internet core knows which ISP each office address belongs to.
Dockerfile.gw
# The office router: Debian 13 with systemd, so the health check runs as a real timer.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      systemd systemd-sysv nftables iproute2 iputils-ping tcpdump procps less curl conntrack \
 && rm -rf /var/lib/apt/lists/* \
 && systemctl mask systemd-modules-load.service
CMD ["/sbin/init"]
Dockerfile.host
# ISPs, the internet and the office PC: plain Debian 13 with network tools.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      iproute2 iputils-ping tcpdump curl python3 nftables procps \
 && rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]

Where next

  • nftables and iptables on Debian: host and router firewallsPrerequisite · Intermediate · 60 min

    Build a Debian firewall with nftables, from a host firewall to an office router with NAT and port forwards: safe changes with a timed rollback, sets and maps, logging and packet tracing, and what iptables really is on Debian 13.

  • Two internet connections on VyOSAlso covers failover, policy routing · Intermediate · 45 min

    Add a second ISP to a VyOS office router and make it actually work: why a backup default route misses most outages, health checks that test beyond the ISP, per-ISP NAT, port forwards through both ISPs, and what happens to open connections, all measured on a real lab.

  • OpenVPN on Debian: users, routing and revocationAlso covers debian, nftables · Intermediate · 60 min

    Install and run an OpenVPN server on Debian with your own certificate authority: one certificate per user, fixed addresses, routing to an office and a branch LAN, split and full tunnels, tls-crypt, revoking users, and the mistakes that lock everyone out.

  • WireGuard on Debian: users, routing and keysAlso covers debian, nftables · Intermediate · 50 min

    Run a WireGuard server on Debian for several users, an office LAN and a branch: keys, wg-quick and systemd, AllowedIPs as routing and access control, split and full tunnels, roaming and keepalives, adding and removing users live, and the silent failures.