TTR Networks

Intermediate · Debian as an office router: interfaces, VLANs and a firewall · section 8 of 8

Build the lab yourself

✓ Lab-tested 5 November 2026 on Debian 13.7, systemd 257, nftables 1.1.3

The lab runs on any machine with Docker, including an Apple Silicon Mac, in about 300 MB of memory. The router is a Debian 13 container running systemd, udev and D-Bus, so systemctl, networkctl and journalctl behave as on a real install; the other machines are plain Debian containers.

  1. Put the files from this page in one directory, with the router's configuration files from earlier in the lesson in etc/systemd/network/, etc/sysctl.d/ and etc/nftables.conf.
  2. docker compose -f lab-compose.yaml up -d --build, then ./lab-setup.sh. It addresses the ISP, the web server and the office hosts (each host creates its own VLAN interface on the trunk), and turns forwarding off on the router, like a fresh install.
  3. Copy the networkd files in with docker compose cp, and follow the lesson from Interfaces with systemd-networkd: docker compose -f lab-compose.yaml exec router bash.

The lab is also the starting point for the later lessons on the Build a router in a box with Debian path, which add to the same router.

Two things the lab needed that a real Debian install already has: udev (without it, networkd leaves every link pending) and D-Bus (without it, networkctl reload fails with Failed to connect to system bus). The router's Dockerfile installs both, and masks the console logins a container can't have.

lab-compose.yaml
# Debian as an office router: the lab.  docker compose -f lab-compose.yaml up -d --build && ./lab-setup.sh
name: drt-lab
x-host: &host
  build: { context: ., dockerfile: Dockerfile.host }
  image: ttr-drt-host:13
  privileged: true                      # ip, sysctl and VLAN interfaces
services:
  router:
    build: { context: ., dockerfile: Dockerfile.router }
    image: ttr-drt-router:13
    hostname: router
    privileged: true
    cgroup: host                        # let systemd run as PID 1
    volumes: ["/sys/fs/cgroup:/sys/fs/cgroup:rw"]
    tmpfs: ["/run", "/run/lock"]
    # Fixed MACs, like real network cards: a restart is then like a reboot, not a new machine.
    networks: { wan: { interface_name: eth0, priority: 100, mac_address: "02:00:00:00:01:00" }, trunk: { interface_name: eth1, mac_address: "02:00:00:00:01:01" } }
  isp:
    build: { context: ., dockerfile: Dockerfile.isp }
    image: ttr-drt-isp:13
    hostname: isp
    privileged: true
    networks: { wan: { interface_name: eth0, priority: 100 }, internet: { interface_name: eth1 } }
  web:    { <<: *host, hostname: web,    networks: { internet: { interface_name: eth0 } } }
  # Fixed MACs, so a DHCP reservation can name the server's.
  staff:  { <<: *host, hostname: staff,  networks: { trunk: { interface_name: eth0, mac_address: "02:00:00:00:10:20" } } }
  guest:  { <<: *host, hostname: guest,  networks: { trunk: { interface_name: eth0, mac_address: "02:00:00:00:20:20" } } }
  server: { <<: *host, hostname: server, networks: { trunk: { interface_name: eth0, mac_address: "02:00:00:00:30:10" } } }
# Layer-2-only networks: every address and route comes from the router's
# configuration or lab-setup.sh. "trunk" is the office switch: the hosts tag
# their own frames (eth0.10, eth0.20, eth0.30), like ports on a VLAN switch.
networks:
  wan:      { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:c1::/64" }] } }
  trunk:    { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:c2::/64" }] } }
  internet: { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:c3::/64" }] } }
  • Lines 8–17: The router: systemd as PID 1, privileged, with fixed MAC addresses so a restart behaves like a reboot.
  • Lines 25–28: The office hosts all share the trunk network and tag their own frames. Their fixed MACs matter in the DHCP lesson, where the server's is reserved.
  • Lines 33–35: Layer-2-only networks: no Docker addresses or routes to get in the way.
lab-setup.sh
#!/bin/sh
# Address everything except the router (its configuration is the lesson). Run after `docker compose up`.
x() { docker compose -f lab-compose.yaml exec -T "$1" sh -c "$2"; }
# IPv4 only, for clean output. (The IPv6 lesson turns it back on where it needs it.)
for n in router isp web staff guest server; do x $n 'sysctl -qw net.ipv6.conf.all.disable_ipv6=1'; done
# Docker Desktop turns forwarding on in every container. A fresh Debian install has it off,
# so put the router back to that: the lesson turns it on with a sysctl.d file.
x router 'sysctl -qw net.ipv4.ip_forward=0'
# The ISP: the router's upstream (203.0.113.1) and the way to the "internet".
x isp    'ip addr add 203.0.113.1/24 dev eth0; ip addr add 198.51.100.1/24 dev eth1; sysctl -qw net.ipv4.ip_forward=1'
x web    'ip addr add 198.51.100.10/24 dev eth0; ip route add default via 198.51.100.1'
x web    'cd /tmp && nohup python3 -m http.server 80 > /tmp/web.log 2>&1 &'
# The internet's DNS, for the lab: the ISP's resolver at 198.51.100.53 knows www.example.com.
x isp    'ip addr add 198.51.100.53/24 dev eth1; dnsmasq --no-resolv --no-hosts --listen-address=198.51.100.53 --bind-interfaces --address=/www.example.com/198.51.100.10 --address=/ads.example.com/198.51.100.66 --local-ttl=300 --log-queries --log-facility=/tmp/dns.log'
# Office machines on the trunk: each tags its own frames, like a switch port in that VLAN.
x staff  'ip link add link eth0 name eth0.10 type vlan id 10 && ip link set eth0.10 up && ip addr add 10.10.10.20/24 dev eth0.10 && ip route add default via 10.10.10.1'
x guest  'ip link add link eth0 name eth0.20 type vlan id 20 && ip link set eth0.20 up && ip addr add 10.10.20.20/24 dev eth0.20 && ip route add default via 10.10.20.1'
x server 'ip link add link eth0 name eth0.30 type vlan id 30 && ip link set eth0.30 up && ip addr add 10.10.30.10/24 dev eth0.30 && ip route add default via 10.10.30.1'
x server 'cd /tmp && nohup python3 -m http.server 80 > /tmp/web.log 2>&1 &'
  • Line 8: Forwarding off on the router, like a fresh Debian install (Docker Desktop turns it on).
  • Lines 13–14: A resolver standing in for the internet's DNS. The router uses it from the DHCP and DNS lesson onwards.
  • Lines 16–18: Each office host creates its VLAN interface and a fixed address. The next lesson replaces the addresses with DHCP.
Dockerfile.router
# The router: Debian 13 with systemd, udev and D-Bus, so systemd-networkd, nftables and dnsmasq run as on
# real hardware (networkd waits for udev before it configures a link).
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      systemd systemd-sysv udev dbus nftables dnsmasq openssh-server iproute2 iputils-ping \
      tcpdump procps less curl netcat-openbsd conntrack \
 && rm -rf /var/lib/apt/lists/* \
 && systemctl mask systemd-modules-load.service getty@.service autovt@.service getty-static.service \
 && systemctl disable dnsmasq
CMD ["/sbin/init"]
Dockerfile.host
# Office PCs, servers and the internet: plain Debian 13 with network tools and a DHCP client.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      iproute2 iputils-ping tcpdump curl python3 netcat-openbsd procps dnsutils dhcpcd-base \
 && rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]
Dockerfile.isp (used by the IPv6 lesson)
# The ISP's router: Debian 13 with Kea (IPv6 prefixes by DHCPv6 prefix delegation) and a
# small DNS resolver for the lab's "internet".
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      iproute2 iputils-ping tcpdump procps kea-dhcp6-server dnsmasq-base \
 && rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]

Where next

  • IPv6 on the Debian routerBuilds on this lesson · Intermediate · 40 min

    Add IPv6 to a Debian router with systemd-networkd: ask the ISP for a /56 by DHCPv6 prefix delegation, give each VLAN a /64 and announce it with router advertisements, firewall it with nftables (including the ICMPv6 IPv6 can't work without), and see what happens when the ISP changes the prefix.

  • 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.

  • DHCP and DNS on the Debian routerBuilds on this lesson · Intermediate · 35 min

    Add DHCP and DNS to a Debian router with dnsmasq: a range and lease time per VLAN, a reservation, local names from DHCP, a caching forwarder, blocking a domain, the firewall rules it needs, and reading its log when a VLAN gets no address.

  • Two Debian routers: VRRP and connection syncBuilds on this lesson · Intermediate · 45 min

    Pair two Debian 13 routers so the office survives one failing: keepalived VRRP for the gateway and public addresses, conntrackd so open connections survive, NAT to a shared address, health checks, and split brain, all measured in a lab.