Build the lab yourself
The lab runs on any machine with Docker, including an Apple Silicon Mac, in a few hundred megabytes of memory. The VPN server runs real systemd, so every systemctl and journalctl command in this lesson works exactly as it would on a Debian server.
- Put the files from this page in one directory, with
server.conf, the threeccdfiles,nftables.conf,99-openvpn.conf,client-template.ovpnandmake-client.shfrom earlier in the lesson. docker compose -f lab-compose.yaml up -d --build, then./lab-setup.shto address every node.- On the server (
docker compose -f lab-compose.yaml exec vpn bash), follow the lesson: build the PKI, copy the configuration files into/etc/openvpn/server/, enable forwarding and the firewall, and start the service. - Build each user's file with
make-client.sh, copy it to that user's container (docker compose cp), and connect withopenvpn --config alice.ovpn. - Add the return routes on
lanrtr, and work through the scenarios.
Two Docker details caught this lab out. They're in the files, but they're worth knowing for your own labs:
- Don't disable IPv6 when creating containers on IPv6-only networks (
sysctls: net.ipv6.conf.all.disable_ipv6). Docker can then give the container no address on its network, and quietly attaches an extra bridge with its own default route. Disable IPv6 after start instead, aslab-setup.shdoes. - For a container with two networks, give its
eth0network a higherpriority. In this lab, Docker attached the networks in alphabetical order and named the first oneeth0whatever was asked for, so the container failed to start when a later network asked foreth0.
One deliberate difference from a plain Docker container: Docker Desktop starts every container with IP forwarding already on, which would hide a missing 99-openvpn.conf. lab-setup.sh turns it off on the VPN server, so it behaves like a fresh Debian install and the lesson's sysctl file is what turns it on.
# OpenVPN on Debian: the lab. docker compose -f lab-compose.yaml up -d --build && ./lab-setup.sh
name: ovpn-lab
x-host: &host
build: { context: ., dockerfile: Dockerfile.host }
image: ttr-deb-host:13
privileged: true # ip, sysctl, tun devices
services:
vpn:
build: { context: ., dockerfile: Dockerfile.vpn }
image: ttr-deb-vpn:13
hostname: vpn
privileged: true
cgroup: host # let systemd run as PID 1
volumes: ["/sys/fs/cgroup:/sys/fs/cgroup:rw"]
tmpfs: ["/run", "/run/lock"]
networks: { server: { interface_name: eth0, priority: 100 }, office: { interface_name: eth1 } }
isp: { <<: *host, hostname: isp, networks: { users: { interface_name: eth0, priority: 100 }, server: { interface_name: eth1 } } }
web: { <<: *host, hostname: web, networks: { server: { interface_name: eth0 } } }
lanrtr: { <<: *host, hostname: lanrtr, networks: { office: { interface_name: eth0 } } }
office: { <<: *host, hostname: office, networks: { office: { interface_name: eth0 } } }
alice: { <<: *host, hostname: alice, networks: { users: { interface_name: eth0 } } }
bob: { <<: *host, hostname: bob, networks: { users: { interface_name: eth0 } } }
branch: { <<: *host, hostname: branch, networks: { users: { interface_name: eth0, priority: 100 }, branchlan: { interface_name: eth1 } } }
branchpc: { <<: *host, hostname: branchpc, networks: { branchlan: { interface_name: eth0 } } }
# Layer-2-only networks: every address and route comes from lab-setup.sh.
# Docker names the first network it attaches eth0, attaching in alphabetical order,
# so each two-network node gives its eth0 network the highest priority.
networks:
users: { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:61::/64" }] } }
server: { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:62::/64" }] } }
office: { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:63::/64" }] } }
branchlan: { enable_ipv4: false, enable_ipv6: true, ipam: { config: [{ subnet: "fd00:64::/64" }] } }- Lines 12–15: The VPN server runs systemd as PID 1: privileged, with the host's cgroups and tmpfs mounts for /run.
- Line 16: Its eth0 network gets the highest priority, so Docker names it eth0.
#!/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. (Disabling IPv6 at container creation instead makes
# Docker attach an extra bridge, because the lab networks themselves are IPv6-only.)
for n in vpn isp web lanrtr office alice bob branch branchpc; 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 VPN server back to that: the lesson turns it on with a sysctl.d file.
x vpn 'sysctl -qw net.ipv4.ip_forward=0'
# The "internet": the users' side (198.51.100.0/24) and the server's side (203.0.113.0/24), joined by isp.
x isp 'ip addr add 198.51.100.1/24 dev eth0; ip addr add 203.0.113.1/24 dev eth1; sysctl -qw net.ipv4.ip_forward=1'
x web 'ip addr add 203.0.113.80/24 dev eth0; ip route add default via 203.0.113.1'
x vpn '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'
x alice 'ip addr add 198.51.100.21/24 dev eth0; ip route add default via 198.51.100.1'
x bob 'ip addr add 198.51.100.22/24 dev eth0; ip route add default via 198.51.100.1'
x branch 'ip addr add 198.51.100.23/24 dev eth0; ip route add default via 198.51.100.1; ip addr add 10.20.0.1/24 dev eth1; sysctl -qw net.ipv4.ip_forward=1'
x branchpc 'ip addr add 10.20.0.10/24 dev eth0; ip route add default via 10.20.0.1'
# The office: its own router (lanrtr) is every office machine's default gateway, as in most offices.
x lanrtr 'ip addr add 10.10.0.254/24 dev eth0; sysctl -qw net.ipv4.ip_forward=1'
x office 'ip addr add 10.10.0.20/24 dev eth0; ip route add default via 10.10.0.254'
# A tiny web server on the internet that logs who connects.
x web 'cd /tmp && nohup python3 -m http.server 80 > /tmp/web.log 2>&1 &'# The VPN server: a small Debian 13 box with systemd, so OpenVPN runs as a
# normal service (openvpn-server@server), exactly as on a real server.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
systemd systemd-sysv openvpn easy-rsa nftables iproute2 iputils-ping tcpdump procps less \
&& rm -rf /var/lib/apt/lists/* \
&& systemctl mask systemd-modules-load.service
CMD ["/sbin/init"]# Every other node: users, routers and servers. Plain Debian 13 with network tools.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
openvpn iproute2 iputils-ping tcpdump nftables curl python3 procps \
&& rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]Where next
- WireGuard on Debian: users, routing and keysAlso covers VPN, 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.
- Linux Network ConnectivityPrerequisite · Beginner · 80 min
Find out why a Linux machine can't reach something, layer by layer: interfaces and addresses, routes and ip route get, ARP and the neighbour table, ping and traceroute, DNS, and listening sockets — with real output from a three-host lab.
- Site-to-site VPN with WireGuard on VyOSAlso covers VPN · Intermediate · 35 min
Join two sites' private LANs across the internet with WireGuard on VyOS: keys and peers, the AllowedIPs mistake and what it teaches, what the internet actually sees, and the tunnel's smaller MTU.
- PowerDNS on Debian: authoritative, secondary, DNSSEC and recursorAlso covers debian · Intermediate · 60 min
Build a complete DNS service on Debian with PowerDNS: authoritative primary and secondary servers with NOTIFY and AXFR, zones by pdnsutil and the HTTP API, DNSSEC signed all the way from a lab root, and a validating recursor for the office.