Skip to content

Build a DMZ lab with an nftables firewall in Docker

A three-zone DMZ lab in Docker: an nftables firewall between LAN, DMZ and outside, with DNAT to a web server, tested with real connection attempts.

Published on 5 min read

This tutorial builds a three-zone DMZ lab in Docker: a LAN, a DMZ with a web server, and an outside network, with an nftables firewall in the middle. The policy allows LAN to DMZ and to the internet, lets the outside reach only the web server on port 80, and blocks the DMZ from the LAN. You'll check every rule with real connection attempts. All commands and outputs come from a run on Docker 29 (Docker Desktop on macOS) with nftables 1.1.6.

What you'll build

   outside: Docker network blogtest-dmz-out 10.77.3.0/24 (NAT to the internet)
      ext 10.77.3.10                                    Docker gateway 10.77.3.1
          |                                                    |
          +--------------------- out 10.77.3.254 --------------+
                                        |
                              +-------------------+
                              |  fw (nftables)    |
                              +-------------------+
                    lan 10.77.1.254          dmz 10.77.2.254
                          |                        |
                  pc 10.77.1.10            web 10.77.2.10 (nginx)
                        LAN                       DMZ

The policy you will implement:

From \ ToLANDMZOutside / internet
LANallowallow (masquerade)
DMZdropdrop
OutsidedropTCP 80 to the web server only

What you need

  • Docker Engine 28 or later, or a recent Docker Desktop. The com.docker.network.endpoint.ifname option that names the firewall's outside interface arrived in Docker Engine 28.0.
  • The nicolaka/netshoot image (it ships nft, curl, nc and conntrack) and nginx:1.27-alpine.
  • An idea of subnets and default gateways.

Step 1: create the outside network and the containers

The outside zone is a normal Docker network, so it has NAT to the real internet. The firewall joins it with a fixed address and an interface named out. The LAN PC and the web server start with --network none: they get their cables in the next step.

docker network create --subnet 10.77.3.0/24 blogtest-dmz-out

docker run -d --name blogtest-dmz-fw --hostname fw --cap-add NET_ADMIN \
  --sysctl net.ipv4.ip_forward=1 \
  --network name=blogtest-dmz-out,ip=10.77.3.254,driver-opt=com.docker.network.endpoint.ifname=out \
  nicolaka/netshoot sleep infinity
docker run -d --name blogtest-dmz-ext --hostname ext --cap-add NET_ADMIN \
  --network name=blogtest-dmz-out,ip=10.77.3.10 nicolaka/netshoot sleep infinity
docker run -d --name blogtest-dmz-pc --hostname pc --cap-add NET_ADMIN \
  --network none --dns 1.1.1.1 nicolaka/netshoot sleep infinity
docker run -d --name blogtest-dmz-web --hostname web --cap-add NET_ADMIN \
  --network none nginx:1.27-alpine

Step 2: cable LAN and DMZ with veth pairs

A veth pair is a virtual cable between two network namespaces. This function creates one and puts each end into a container. It runs ip in a short-lived privileged container that sees the host's process IDs, so it works on Linux and on Docker Desktop.

link() {
  pa=$(docker inspect -f '{{.State.Pid}}' blogtest-dmz-$1)
  pb=$(docker inspect -f '{{.State.Pid}}' blogtest-dmz-$3)
  docker run --rm --privileged --pid host --network host nicolaka/netshoot \
    ip link add "$2" netns "$pa" type veth peer name "$4" netns "$pb"
  docker exec blogtest-dmz-$1 ip link set "$2" up
  docker exec blogtest-dmz-$3 ip link set "$4" up
}
link fw lan pc eth0
link fw dmz web eth0

Why not three Docker networks? I tried that first. Docker applies its iptables rules to traffic inside its bridges, and its masquerade rules rewrote the source of packets that crossed the firewall: the web server saw connections from the Docker gateway 10.77.2.1 instead of the real client, and replies took a path around the firewall. With veth cables, only your firewall touches the traffic between zones.

Step 3: address the zones

docker exec blogtest-dmz-fw  ip addr add 10.77.1.254/24 dev lan
docker exec blogtest-dmz-fw  ip addr add 10.77.2.254/24 dev dmz
docker exec blogtest-dmz-pc  ip addr add 10.77.1.10/24 dev eth0
docker exec blogtest-dmz-pc  ip route add default via 10.77.1.254
docker exec blogtest-dmz-web ip addr add 10.77.2.10/24 dev eth0
docker exec blogtest-dmz-web ip route add default via 10.77.2.254
# for testing only: let the outside host try to reach the inside networks directly
docker exec blogtest-dmz-ext ip route add 10.77.1.0/24 via 10.77.3.254
docker exec blogtest-dmz-ext ip route add 10.77.2.0/24 via 10.77.3.254
docker exec -d blogtest-dmz-pc nc -lk 8080

The last line starts a listener on the LAN PC so you can test whether the DMZ and the outside can reach it. The two routes on ext play an attacker on the outside segment who knows your internal ranges. Without them, the "outside to LAN" tests would fail only for lack of a route, and prove nothing about the firewall.

Before any rules, the firewall is just a router. The DMZ reaches the LAN, and the LAN can't reach the internet yet, because nothing translates its 10.77.1.0/24 addresses:

docker exec blogtest-dmz-web nc -zv -w 3 10.77.1.10 8080
10.77.1.10 (10.77.1.10:8080) open

Step 4: write and load the nftables policy

Save this as fw.nft:

# Recreate our own table on every load; leave Docker's tables alone
table inet dmzfw
delete table inet dmzfw

define WEB = 10.77.2.10

table inet dmzfw {
  chain input {
    type filter hook input priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iifname "lo" accept
    iifname "lan" icmp type echo-request accept
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop

    # LAN may open connections to the DMZ and to the internet
    iifname "lan" oifname { "dmz", "out" } accept

    # The outside may reach the web server on port 80, nothing else
    iifname "out" oifname "dmz" ip daddr $WEB tcp dport 80 accept

    # DMZ to LAN would fall to the drop policy anyway; count it to see attempts
    iifname "dmz" oifname "lan" counter drop
  }

  chain prerouting {
    type nat hook prerouting priority dstnat;
    iifname "out" tcp dport 80 dnat ip to $WEB
  }

  chain postrouting {
    type nat hook postrouting priority srcnat;
    oifname "out" masquerade
  }
}

How it works:

  • Default deny. Both filter chains have policy drop. Only what you accept passes.
  • Stateful. ct state established,related accept lets replies through, so you write rules only for the side that opens the connection. This is why the web server can answer the LAN although nothing allows DMZ to LAN.
  • One table, filter and NAT together. The inet family covers IPv4 and IPv6 and has supported NAT since Linux 5.2. In an inet table, a NAT address needs the ip or ip6 prefix, hence dnat ip to (nftables wiki).
  • DNAT. Connections to the firewall's outside address on port 80 are sent to the web server. The forward rule then allows them, because forwarding sees the new destination.
  • Masquerade. Traffic leaving by out takes the firewall's address 10.77.3.254, which Docker then NATs to the internet.
  • No flush ruleset. Docker keeps a table ip nat inside the container for its DNS server at 127.0.0.11. When I flushed the whole ruleset, DNS inside the firewall container stopped with connection refused. The table plus delete table pair at the top recreates only dmzfw, so you can reload the file as often as you like.

Copy it in, check it, load it:

docker cp fw.nft blogtest-dmz-fw:/etc/fw.nft
docker exec blogtest-dmz-fw nft -c -f /etc/fw.nft
docker exec blogtest-dmz-fw nft -f /etc/fw.nft
docker exec blogtest-dmz-fw nft list tables
table ip nat
table inet dmzfw

nft -c only checks the file. table ip nat is Docker's; inet dmzfw is yours.

Step 5: test every rule

Run each flow, the allowed ones and the forbidden ones:

echo "== 1 LAN -> DMZ web"; docker exec blogtest-dmz-pc curl -s -o /dev/null -w '%{http_code}\n' -m 3 http://10.77.2.10/
echo "== 2 LAN -> internet"; docker exec blogtest-dmz-pc curl -sI -m 5 https://example.com | head -1
echo "== 3 outside -> fw:80"; docker exec blogtest-dmz-ext curl -s -o /dev/null -w '%{http_code}\n' -m 3 http://10.77.3.254/
echo "== 4 outside -> LAN"; docker exec blogtest-dmz-ext nc -zv -w 3 10.77.1.10 8080
echo "== 5 outside -> web:22"; docker exec blogtest-dmz-ext nc -zv -w 3 10.77.2.10 22
echo "== 6 DMZ -> LAN"; docker exec blogtest-dmz-web nc -zv -w 3 10.77.1.10 8080
echo "== 7 DMZ -> internet"; docker exec blogtest-dmz-web wget -q -T 3 -O /dev/null http://1.1.1.1; echo exit=$?
== 1 LAN -> DMZ web
200
== 2 LAN -> internet
HTTP/2 200
== 3 outside -> fw:80
200
== 4 outside -> LAN
nc: connect to 10.77.1.10 port 8080 (tcp) timed out: Operation in progress
== 5 outside -> web:22
nc: connect to 10.77.2.10 port 22 (tcp) timed out: Operation in progress
== 6 DMZ -> LAN
nc: 10.77.1.10 (10.77.1.10:8080): Operation timed out
== 7 DMZ -> internet
wget: download timed out
exit=1

Three flows pass and four time out, exactly the table above. Note the difference between test 5 here and before the rules: without a firewall, nc to port 22 on the web server got Connection refused at once, because the server answered with a reset. Now the SYN is dropped silently and nc waits for its timeout. A drop gives an attacker less information than a refusal.

The counter on the DMZ to LAN rule shows the blocked attempt from test 6 (three SYN packets: the first try and two retransmissions):

docker exec blogtest-dmz-fw nft list chain inet dmzfw forward
table inet dmzfw {
	chain forward {
		type filter hook forward priority filter; policy drop;
		ct state established,related accept
		ct state invalid drop
		iifname "lan" oifname { "out", "dmz" } accept
		iifname "out" oifname "dmz" ip daddr 10.77.2.10 tcp dport 80 accept
		iifname "dmz" oifname "lan" counter packets 3 bytes 180 drop
	}
}

The web server's log confirms that the DNAT kept the real client address, so your logs stay useful:

docker logs blogtest-dmz-web 2>/dev/null | grep 'GET /'
10.77.1.10 - - [14/Oct/2026:00:40:28 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.21.0" "-"
10.77.3.10 - - [14/Oct/2026:00:40:28 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.21.0" "-"

And the firewall's connection tracking table shows the translation, with the original destination 10.77.3.254 and the reply coming from 10.77.2.10:

docker exec blogtest-dmz-fw conntrack -L -p tcp --dport 80
tcp      6 102 TIME_WAIT src=10.77.3.10 dst=10.77.3.254 sport=37304 dport=80 src=10.77.2.10 dst=10.77.3.10 sport=80 dport=37304 [ASSURED] mark=0 use=1

Clean up

docker rm -f blogtest-dmz-fw blogtest-dmz-ext blogtest-dmz-pc blogtest-dmz-web
docker network rm blogtest-dmz-out

Going further

  • Allow DMZ updates only: iifname "dmz" oifname "out" tcp dport 443 accept, then run test 7 again with HTTPS.
  • Add a management rule: SSH to the web server from one LAN address only.
  • Log drops with a log prefix rule. Kernel netfilter logging only works outside the host's own network namespace when the host sets net.netfilter.nf_log_all_netns=1; it was 0 on my Docker Desktop VM, so in a container a log rule prints nothing by default. Counters, as above, always work.
  • Watch both sides of the DNAT with tcpdump from helper containers. Capture a Docker container's traffic shows how.
  • Split the LAN into VLANs on a trunk to the firewall, as in the VLAN and trunk lab.

Draw it in Isothrum

Isothrum's editor is a free browser editor for labs like this one, with no account; the engine runs locally as WebAssembly. You place containers from any Docker image, VMs and routers, cable the zones, and set latency, jitter, loss or bandwidth per link. It exports isoloom.yml plus Docker Compose, Vagrant, Proxmox, Kubernetes and cloud files, and you run the lab with those tools.

Related articles

Build an eBGP lab with three FRR routers in Docker, one per AS. Bring up sessions, exchange prefixes, read the AS path and filter a route.
Set up an isolated security lab with DVWA as a target, an attacker box and a packet-capture sensor in Docker. Tested, with legal and isolation rules.
Build a three-router OSPF lab with FRRouting in Docker: two LANs, a shared backbone, DR and BDR election, and real neighbor and route output.