TTR Networks

Intermediate · nftables and iptables on Debian: host and router firewalls · section 10 of 10

Build the lab yourself

✓ Lab-tested 21 October 2026 on Debian 13.7, nftables 1.1.3, iptables 1.8.11

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.

  1. 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.nft and forwards.nft).
  2. docker compose -f lab-compose.yaml up -d --build, then ./lab-setup.sh to address every node.
  3. For the SSH tests, make a key on pc (ssh-keygen -t ed25519) and add its public key to /root/.ssh/authorized_keys on fw.
  4. Copy each configuration into place with docker compose cp as you reach it in the lesson, and use docker compose exec fw bash as 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).

lab-compose.yaml
# 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.
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.
Dockerfile.fw
# 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"]
Dockerfile.host
# 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.