diff --git a/scenarios/wireguard/.gitignore b/scenarios/wireguard/.gitignore
new file mode 100644
index 0000000..ce6c6cd
--- /dev/null
+++ b/scenarios/wireguard/.gitignore
@@ -0,0 +1,7 @@
+server/runtime/*
+!server/runtime/.gitkeep
+
+client/wg0.conf
+
+*.key
+*.conf.generated
diff --git a/scenarios/wireguard/README.md b/scenarios/wireguard/README.md
new file mode 100644
index 0000000..c2206ed
--- /dev/null
+++ b/scenarios/wireguard/README.md
@@ -0,0 +1,118 @@
+## WireGuard External Service Example
+
+This scenario connects one external service to NSE2 through a WireGuard tunnel.
+NSE2 runs the WireGuard server alongside the example nodes `n1` and `n2`.
+The external host runs the WireGuard client and an application that shares the client's network namespace.
+
+This example uses the [LinuxServer.io WireGuard container](https://github.com/linuxserver/docker-wireguard).
+Refer to its documentation for additional configuration options and container requirements.
+
+
+```mermaid
+flowchart LR
+ subgraph NSE2["NSE2 Host"]
+ n1["Node n1"]
+ n2["Node n2"]
+ server["WireGuard Server
Transparent Proxy"]
+
+ n1 <--> server
+ n2 <--> server
+ end
+
+ subgraph External["External Host"]
+ client["WireGuard Client"]
+ app["External Application"]
+ client --- app
+ end
+
+ server <-->|"WireGuard Tunnel"| client
+```
+
+Traffic addressed to either of the `wg-server` Docker addresses is DNATed by `server/setup-iptables.sh` to the external peer's tunnel address.
+The external application shares the WireGuard client's network namespace and therefore requires no additional routing configuration.
+
+This example supports one external WireGuard peer.
+
+### Configuration
+
+Before starting the server, review the following values in `compose.yml`:
+
+- `SERVERURL` must be an address through which the external host can reach the NSE2 host. Use `host.docker.internal` when testing both sides on the same machine.
+- `SERVERPORT` must match the UDP port published by the `wg-server` service.
+- `ALLOWEDIPS` must include every NSE2 network that the external service should be able to reach through the tunnel.
+
+
+### Setup
+
+Start the NSE2-side services:
+
+```sh
+docker compose up -d
+```
+
+The WireGuard server generates its keys and the external peer configuration
+under `server/runtime`. The generated client configuration is:
+
+```text
+server/runtime/peer_external/peer_external.conf
+```
+
+Copy this file to `client/wg0.conf` on the external host:
+
+```sh
+scp server/runtime/peer_external/peer_external.conf \
+ user@external-host:/path/to/examples/wireguard/client/wg0.conf
+```
+
+The configuration contains the external peer's private key and must not be
+committed to Git. Restrict its permissions on the external host:
+
+```sh
+chmod 600 client/wg0.conf
+```
+
+Start the WireGuard client and external application:
+
+```sh
+docker compose -f compose-client.yml up -d
+```
+
+
+### Testing
+
+Check that the tunnel has established a handshake:
+
+```sh
+docker compose exec wg-server wg show
+docker compose -f compose-client.yml exec wg-client wg show
+```
+
+The example application listens on port 80. Test it from both NSE2 nodes:
+
+```sh
+docker compose exec n1 curl --fail http://172.30.0.2
+docker compose exec n2 curl --fail http://172.31.0.2
+```
+
+Both requests should return:
+
+```text
+Hello from external WireGuard service!
+```
+
+Within the Compose networks, Docker DNS can resolve the WireGuard server by its service name.
+The application can therefore also be reached through `wg-server`:
+
+```sh
+docker compose exec n1 curl --fail http://wg-server
+docker compose exec n2 curl --fail http://wg-server
+```
+
+The generated client configuration routes the NSE2 Docker networks listed in `ALLOWEDIPS` through the WireGuard tunnel.
+It also uses the DNS server configured by the WireGuard container, allowing the external side to resolve and connect to `n1` and `n2` by name.
+
+```sh
+docker compose -f compose-client.yml exec wg-client ping n1
+docker compose -f compose-client.yml exec wg-client ping n2
+```
+
diff --git a/scenarios/wireguard/compose-client.yml b/scenarios/wireguard/compose-client.yml
new file mode 100644
index 0000000..1a85491
--- /dev/null
+++ b/scenarios/wireguard/compose-client.yml
@@ -0,0 +1,40 @@
+name: wireguard-external-service-client
+
+services:
+ wg-client:
+ image: lscr.io/linuxserver/wireguard:latest
+ container_name: external-wg-client
+ cap_add:
+ - NET_ADMIN
+ - SYS_MODULE
+ environment:
+ PUID: ${PUID:-1000}
+ PGID: ${PGID:-1000}
+ TZ: ${TZ:-Etc/UTC}
+ volumes:
+ - ./client/wg0.conf:/config/wg_confs/wg0.conf:ro
+ - /lib/modules:/lib/modules:ro
+ sysctls:
+ - net.ipv4.conf.all.src_valid_mark=1
+ restart: unless-stopped
+ # Optional. Only needed when server and client run on the same host. Then
+ # the SERVER_URL in the .env needs to be `host.docker.internal` since that
+ # resolves to localhost of the host.
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+
+ app:
+ image: alpine:latest
+ container_name: external-app
+ network_mode: service:wg-client
+ command:
+ - sh
+ - -c
+ - |
+ apk add --no-cache python3
+ mkdir -p /var/www
+ echo '
Hello from external WireGuard service!
' > /var/www/index.html
+ python3 -m http.server 80 --directory /var/www
+ depends_on:
+ - wg-client
+ restart: unless-stopped
diff --git a/scenarios/wireguard/compose.yml b/scenarios/wireguard/compose.yml
new file mode 100644
index 0000000..6f1fcde
--- /dev/null
+++ b/scenarios/wireguard/compose.yml
@@ -0,0 +1,86 @@
+name: wireguard-external-service-scenario
+
+services:
+ wg-server:
+ image: lscr.io/linuxserver/wireguard:latest
+ container_name: wg-server
+ cap_add:
+ - NET_ADMIN
+ - SYS_MODULE
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ PEERS: external
+ PERSISTENTKEEPALIVE_PEERS: external
+
+ # Address and port written to the generated client configuration.
+ # Use host.docker.internal for same-host testing; otherwise, use an
+ # address reachable from the external host.
+ SERVERURL: host.docker.internal
+ SERVERPORT: 51820
+
+ # Networks the generated client configuration routes through WireGuard.
+ # Add any NSE2 network that the external service must be able to reach.
+ ALLOWEDIPS: 10.192.122.0/24,172.30.0.0/24,172.31.0.0/24
+
+ INTERNAL_SUBNET: 10.192.122.0
+ volumes:
+ - ./server/runtime:/config
+ - ./server/templates/server.conf:/config/templates/server.conf:ro
+ - ./server/setup-iptables.sh:/config/setup-iptables.sh:ro
+ - /lib/modules:/lib/modules:ro
+ ports:
+ # The published UDP port must match SERVERPORT.
+ - "51820:51820/udp"
+ sysctls:
+ - net.ipv4.conf.all.src_valid_mark=1
+ restart: unless-stopped
+ networks:
+ wg_n1:
+ ipv4_address: 172.30.0.2
+ wg_n2:
+ ipv4_address: 172.31.0.2
+ n1:
+ container_name: n1
+ hostname: n1
+ cap_add:
+ - NET_ADMIN
+ privileged: 'true'
+ volumes:
+ - ./compose.yml:/compose.yml:ro
+ environment:
+ NODE_ID: 1
+ TYPE: Host
+ networks:
+ wg_n1:
+ ipv4_address: 172.30.0.3
+ entrypoint: sh -c "apk add iproute2 bash curl && tail -f /dev/null"
+ image: alpine
+
+ n2:
+ container_name: n2
+ hostname: n2
+ cap_add:
+ - NET_ADMIN
+ privileged: 'true'
+ volumes:
+ - ./compose.yml:/compose.yml:ro
+ environment:
+ NODE_ID: 2
+ TYPE: Host
+ networks:
+ wg_n2:
+ ipv4_address: 172.31.0.3
+ entrypoint: sh -c "apk add iproute2 bash curl && tail -f /dev/null"
+ image: alpine
+
+networks:
+ wg_n1:
+ ipam:
+ config:
+ - subnet: 172.30.0.0/24
+ wg_n2:
+ ipam:
+ config:
+ - subnet: 172.31.0.0/24
diff --git a/scenarios/wireguard/server/runtime/.gitkeep b/scenarios/wireguard/server/runtime/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/scenarios/wireguard/server/setup-iptables.sh b/scenarios/wireguard/server/setup-iptables.sh
new file mode 100755
index 0000000..3e65bd0
--- /dev/null
+++ b/scenarios/wireguard/server/setup-iptables.sh
@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+# Configures iptables forwarding and NAT for a WireGuard gateway container.
+# It automatically detects all attached Docker network interfaces, allows
+# traffic between them and WireGuard, and redirects traffic addressed to the
+# gateway's Docker IPs to its single WireGuard peer.
+# Rules are added idempotently on "up" and removed on "down".
+
+set -euo pipefail
+
+if (( $# < 1 || $# > 2 )); then
+ echo "Usage: $0 {up|down} [wireguard-interface]" >&2
+ exit 2
+fi
+
+ACTION="$1"
+WG_INTERFACE="${2:-wg0}"
+STATE_FILE="/run/iptables-${WG_INTERFACE}.state"
+
+# All container interfaces except loopback and WireGuard.
+mapfile -t LAN_INTERFACES < <(
+ find /sys/class/net -mindepth 1 -maxdepth 1 -printf '%f\n' |
+ grep -vE "^(lo|${WG_INTERFACE})$" |
+ sort
+)
+
+if [[ ${#LAN_INTERFACES[@]} -eq 0 ]]; then
+ echo "No Docker network interfaces found" >&2
+ exit 1
+fi
+
+add_rule() {
+ local table="$1"
+ shift
+
+ iptables -w -t "$table" -C "$@" 2>/dev/null ||
+ iptables -w -t "$table" -A "$@"
+}
+
+remove_rule() {
+ local table="$1"
+ shift
+
+ while iptables -w -t "$table" -C "$@" 2>/dev/null; do
+ iptables -w -t "$table" -D "$@"
+ done
+}
+
+configure_rules() {
+ # Determine the tunnel IP of the gateway's single WireGuard peer.
+ mapfile -t peer_ips < <(
+ wg show "$WG_INTERFACE" allowed-ips |
+ grep -oE '[0-9]+(\.[0-9]+){3}/32' |
+ cut -d/ -f1
+ )
+
+ if [[ ${#peer_ips[@]} -ne 1 ]]; then
+ echo "Expected exactly one peer /32 address, found ${#peer_ips[@]}" >&2
+ exit 1
+ fi
+
+ TARGET_PEER_IP="${peer_ips[0]}"
+ WIREGUARD_PORT="$(wg show "$WG_INTERFACE" listen-port)"
+
+ if [[ -z "$WIREGUARD_PORT" || "$WIREGUARD_PORT" == "0" ]]; then
+ echo "Could not determine the WireGuard listening port" >&2
+ exit 1
+ fi
+
+ # PostDown runs after the WireGuard interface is removed, so preserve the
+ # values required to remove the rules.
+ printf '%s\n%s\n' \
+ "$TARGET_PEER_IP" \
+ "$WIREGUARD_PORT" > "$STATE_FILE"
+
+ for lan_interface in "${LAN_INTERFACES[@]}"; do
+ # Allow WireGuard peers to access this Docker network.
+ add_rule filter FORWARD \
+ -i "$WG_INTERFACE" -o "$lan_interface" \
+ -j ACCEPT
+
+ # Allow response traffic from this Docker network back through WireGuard.
+ add_rule filter FORWARD \
+ -i "$lan_interface" -o "$WG_INTERFACE" \
+ -m conntrack --ctstate ESTABLISHED,RELATED \
+ -j ACCEPT
+
+ # Hide WireGuard source addresses behind this interface's address.
+ add_rule nat POSTROUTING \
+ -o "$lan_interface" \
+ -j MASQUERADE
+
+ # Keep the outer WireGuard traffic on the gateway itself.
+ # This rule must precede the catch-all DNAT rule below.
+ add_rule nat PREROUTING \
+ -i "$lan_interface" \
+ -p udp --dport "$WIREGUARD_PORT" \
+ -j ACCEPT
+
+ # Redirect all other traffic addressed to the gateway to its peer.
+ add_rule nat PREROUTING \
+ -i "$lan_interface" \
+ -m addrtype --dst-type LOCAL \
+ -j DNAT --to-destination "$TARGET_PEER_IP"
+
+ # Allow DNAT-translated traffic to reach the peer through WireGuard.
+ add_rule filter FORWARD \
+ -i "$lan_interface" -o "$WG_INTERFACE" \
+ -d "$TARGET_PEER_IP" \
+ -j ACCEPT
+ done
+
+ # Allow response traffic from the remote peer.
+ add_rule filter FORWARD \
+ -i "$WG_INTERFACE" \
+ -s "$TARGET_PEER_IP" \
+ -m conntrack --ctstate ESTABLISHED,RELATED \
+ -j ACCEPT
+
+ # Ensure the peer sends responses back through this gateway.
+ add_rule nat POSTROUTING \
+ -o "$WG_INTERFACE" \
+ -d "$TARGET_PEER_IP" \
+ -j MASQUERADE
+}
+
+remove_rules() {
+ if [[ ! -f "$STATE_FILE" ]]; then
+ echo "State file not found: $STATE_FILE" >&2
+ exit 1
+ fi
+
+ mapfile -t state < "$STATE_FILE"
+ TARGET_PEER_IP="${state[0]}"
+ WIREGUARD_PORT="${state[1]}"
+
+ # Remove shared peer rules first.
+ remove_rule nat POSTROUTING \
+ -o "$WG_INTERFACE" \
+ -d "$TARGET_PEER_IP" \
+ -j MASQUERADE
+
+ remove_rule filter FORWARD \
+ -i "$WG_INTERFACE" \
+ -s "$TARGET_PEER_IP" \
+ -m conntrack --ctstate ESTABLISHED,RELATED \
+ -j ACCEPT
+
+ # Remove interface-specific rules in reverse order.
+ for lan_interface in "${LAN_INTERFACES[@]}"; do
+ remove_rule filter FORWARD \
+ -i "$lan_interface" -o "$WG_INTERFACE" \
+ -d "$TARGET_PEER_IP" \
+ -j ACCEPT
+
+ remove_rule nat PREROUTING \
+ -i "$lan_interface" \
+ -m addrtype --dst-type LOCAL \
+ -j DNAT --to-destination "$TARGET_PEER_IP"
+
+ remove_rule nat PREROUTING \
+ -i "$lan_interface" \
+ -p udp --dport "$WIREGUARD_PORT" \
+ -j ACCEPT
+
+ remove_rule nat POSTROUTING \
+ -o "$lan_interface" \
+ -j MASQUERADE
+
+ remove_rule filter FORWARD \
+ -i "$lan_interface" -o "$WG_INTERFACE" \
+ -m conntrack --ctstate ESTABLISHED,RELATED \
+ -j ACCEPT
+
+ remove_rule filter FORWARD \
+ -i "$WG_INTERFACE" -o "$lan_interface" \
+ -j ACCEPT
+ done
+
+ rm -f "$STATE_FILE"
+}
+
+case "$ACTION" in
+ up)
+ configure_rules
+ ;;
+ down)
+ remove_rules
+ ;;
+ *)
+ echo "Usage: $0 {up|down} [wireguard-interface]" >&2
+ exit 2
+ ;;
+esac
diff --git a/scenarios/wireguard/server/templates/server.conf b/scenarios/wireguard/server/templates/server.conf
new file mode 100644
index 0000000..2c6948f
--- /dev/null
+++ b/scenarios/wireguard/server/templates/server.conf
@@ -0,0 +1,7 @@
+[Interface]
+Address = ${INTERFACE}.1
+ListenPort = 51820
+PrivateKey = $(cat /config/server/privatekey-server)
+
+PostUp = /config/setup-iptables.sh up %i
+PostDown = /config/setup-iptables.sh down %i