Build the lab yourself
The lab runs on any machine with Docker, including an Apple Silicon Mac. The firewall runs real systemd, so nftables.service, systemd-run and journalctl behave exactly as on a Debian server.
- Put the files from this page in one directory, with the configuration files from earlier in the lesson (
nftables-host.conf,new.conf,nftables.conf,99-forwarding.conf,web-flood.nftandforwards.nft). docker compose -f lab-compose.yaml up -d --build, then./lab-setup.shto address every node.- For the SSH tests, make a key on
pc(ssh-keygen -t ed25519) and add its public key to/root/.ssh/authorized_keysonfw. - Copy each configuration into place with
docker compose cpas you reach it in the lesson, and usedocker compose exec fw bashas your console when you lock yourself out.
lab-setup.sh does two things a real server wouldn't need. It turns IP forwarding off on the firewall, because Docker Desktop starts every container with it on, which would hide the forwarding step. And it switches on nf_log_all_netns in Docker's VM, so log rules in a container reach the kernel log (Docker Desktop resets it when it restarts).
# nftables and iptables on Debian: the lab. docker compose -f lab-compose.yaml up -d --build && ./lab-setup.sh
name: nft-lab
x-host: &host
build: { context: ., dockerfile: Dockerfile.host }
image: ttr-nft-host:13
privileged: true # ip and sysctl
services:
fw:
build: { context: ., dockerfile: Dockerfile.fw }
image: ttr-nft-fw:13
hostname: fw
privileged: true
cgroup: host # let systemd run as PID 1
volumes: ["/sys/fs/cgroup:/sys/fs/cgroup:rw"]
tmpfs: ["/run", "/run/lock"]
networks: { wan: { interface_name: eth0, priority: 100 }, lan: { interface_name: eth1 } }
inet: { <<: *host, hostname: inet, networks: { wan: { interface_name: eth0 } } }
pc: { <<: *host, hostname: pc, networks: { lan: { interface_name: eth0 } } }
srv: { <<: *host, hostname: srv, networks: { lan: { interface_name: eth0 } } }
# Layer-2-only networks: every address and route comes from lab-setup.sh.
networks:
wan: { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:a1::/64" }] } }
lan: { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:a2::/64" }] } }- Lines 8–16: The firewall runs systemd as PID 1: privileged, the host's cgroups, tmpfs for /run.
- Lines 21–23: Layer-2-only networks; addresses come from 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 fw inet pc srv; do x $n 'sysctl -qw net.ipv6.conf.all.disable_ipv6=1'; done
# Docker Desktop turns forwarding on in every container. A fresh Debian server has it off,
# so put the firewall back to that: the lesson turns it on with a sysctl.d file.
x fw 'sysctl -qw net.ipv4.ip_forward=0'
# Packet logging from a container: the kernel only logs for other network namespaces
# when this host-wide switch is on (Docker Desktop's VM resets it on restart).
docker run --rm --privileged --net=host alpine sysctl -qw net.netfilter.nf_log_all_netns=1
# The firewall: 203.0.113.10 on the internet side, 10.10.0.1 for the office LAN.
x fw 'ip addr add 203.0.113.10/24 dev eth0; ip addr add 10.10.0.1/24 dev eth1; ip route add default via 203.0.113.1'
# A machine on the internet, which also plays the ISP's gateway (.1). Like the real
# internet, it has no route to the office's private LAN.
x inet 'ip addr add 203.0.113.50/24 dev eth0; ip addr add 203.0.113.1/24 dev eth0'
x pc 'ip addr add 10.10.0.20/24 dev eth0; ip route add default via 10.10.0.1'
x srv 'ip addr add 10.10.0.80/24 dev eth0; ip route add default via 10.10.0.1'
# Web servers that log who connects: one on the internet, one in the office.
x inet 'cd /tmp && nohup python3 -m http.server 80 > /tmp/web.log 2>&1 &'
x srv 'cd /tmp && nohup python3 -m http.server 80 > /tmp/web.log 2>&1 &'
x srv 'cd /tmp && nohup python3 -m http.server 8000 > /tmp/web8000.log 2>&1 &' # for the map example- Lines 6–8: Forwarding off on the firewall, as on a real Debian server.
- Lines 9–11: Let containers log to the kernel log.
# The firewall/router: Debian 13 with systemd, so nftables.service and sshd run as on a real server.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
systemd systemd-sysv nftables iptables openssh-server iproute2 iputils-ping \
tcpdump procps less curl netcat-openbsd conntrack \
&& rm -rf /var/lib/apt/lists/* \
&& systemctl mask systemd-modules-load.service
CMD ["/sbin/init"]# The other machines: 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 netcat-openbsd openssh-client procps \
&& rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]Where next
- Two internet connections on DebianBuilds on this lesson · Intermediate · 45 min
Give a Debian office router a second ISP: a routing table per ISP, source rules and connection marks so replies leave the way they came, per-ISP NAT, and a systemd health check that fails over and back, measured with a real outage.
- Setting up VyOS with NATAlso covers firewall, NAT · Beginner · 45 min
A VyOS router with a NAT interface for a private LAN and a public interface for a routed public subnet: plain routing, source NAT, port forwarding and the forward firewall that NAT isn't. Every output comes from a real VyOS lab.
- VyOS firewall in depthAlso covers firewall, nftables · Intermediate · 45 min
Build a complete VyOS firewall step by step: the input, forward and output hooks, a global state policy, groups, custom chains, protecting the router with rate-limited SSH, egress filtering, rule order, the ICMPv6 that IPv6 can't live without, and the nftables underneath.
- Zone-based firewall on VyOSAlso covers firewall, NAT · Intermediate · 35 min
Rebuild the NAT lesson's router with a zone-based firewall: WAN, LAN, a DMZ and the router itself, with one ruleset per direction. Includes the missing-return-traffic mistake, post-NAT matching, and a full test matrix.