TTR Networks

Intermediate · OpenVPN on Debian: users, routing and revocation · section 11 of 11

Build the lab yourself

✓ Lab-tested 21 October 2026 on Debian 13.7, OpenVPN 2.6.14, easy-rsa 3.2.2, nftables 1.1.3

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.

  1. Put the files from this page in one directory, with server.conf, the three ccd files, nftables.conf, 99-openvpn.conf, client-template.ovpn and make-client.sh from earlier in the lesson.
  2. docker compose -f lab-compose.yaml up -d --build, then ./lab-setup.sh to address every node.
  3. 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.
  4. Build each user's file with make-client.sh, copy it to that user's container (docker compose cp), and connect with openvpn --config alice.ovpn.
  5. 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, as lab-setup.sh does.
  • For a container with two networks, give its eth0 network a higher priority. In this lab, Docker attached the networks in alphabetical order and named the first one eth0 whatever was asked for, so the container failed to start when a later network asked for eth0.

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.

lab-compose.yaml
# 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.
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. (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 &'
Dockerfile.vpn
# 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"]
Dockerfile.host
# 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.