Homelab
Proof

U2 / Networking and remote access

wg-easy 15.5.0: what a WireGuard tunnel costs in throughput

Tested on wg-easy 15.5.0

Test result

wg-easy 15.5.0 stood up a working WireGuard tunnel from a scripted setup, with its API answering 3.8 s after the container started (one measurement, on a warm second start). On this 4 vCPU test VM, iperf3 through the tunnel reached 2.35 Gbit/s against 27.54 Gbit/s between the same containers without it, and whole-VM CPU rose from 41% to 81%.

Line drawing: two small servers connected by one straight armored tube, with a gauge dial mounted on the tube.

Result

This test brought up wg-easy 15.5.0, created one peer through its HTTP API, connected that peer from a second container in its own network namespace, and ran iperf3 over the tunnel and next to it. The tunnel worked on the first try. It was also the slowest link in the chain by a wide margin on this box.

iperf3 TCP throughput with and without a wg-easy WireGuard tunnel
PathThroughput, Gbit/s, median (range)TCP retransmits per run, median (range)Whole-VM CPU busy during transfer, %, median (range)Ping avg RTT, msPing loss
Container to container, no tunnel27.54 (27.26 to 27.73)0 (0 to 0)41 (41 to 42)0.0720%
Through the wg-easy tunnel (10.8.0.1)2.35 (2.27 to 2.37)0 (0 to 0)81 (81 to 81)0.3140%

iperf3 -t 15, 3 runs per row, client in its own container and network namespace, server in the wg-easy network namespace. Tunnel throughput is 9% of the no-tunnel median. Whole-VM CPU is 100 minus mpstat idle on the 4 vCPU VM, seconds above 5% busy only. Ping: 20 packets at 0.2 s intervals. VM 4 vCPU, 3 GB.

Read the table as a relative cost, not as a speed rating. Both paths run inside one VM, so the no-tunnel number (container to container over a Docker bridge) is far above what any physical network would offer. The part that carries over is the shape: with the tunnel up, the VM needed roughly twice the CPU for about one tenth of the throughput. A plausible reading is that encryption and the extra UDP hop all run on the same four vCPUs, but this test did not profile where the time went.

The VM host was not idle during the run (load average 4.11 at the start, recorded in env.txt), so treat the spread between runs as a floor.

What wg-easy costs when nothing is happening

wg-easy 15.5.0 idle footprint (separate from the throughput test)
StateRAM, MiB, median of 12CPU, %, median of 12
30 s after ready, no clients180.40.00
About 2 minutes after ready, no clients42.10.00
One connected peer, no traffic42.00.00

Container start to first authenticated API 200: 3.8 s (one measurement, taken on a second start after a throwaway start, image already pulled). Image size reported by Docker: 321 MB.

The first row is the surprising one. Thirty seconds after the API answered, the container reported 180.4 MiB. About two minutes later, with nothing changed, it reported 42.1 MiB. The raw samples are in wg-easy-idle.txt and wg-easy-idle-2min.txt. This test did not look into why. If you size a box from a single early reading, you would overestimate by a factor of four.

Steps that were tested

wg-easy 15 normally opens a setup wizard in the browser. The unattended setup variables skip it: INIT_ENABLED, plus the admin user, password, host and port. The docs say they apply only on the first start and recommend removing them afterwards. This compose file sets them, and INIT_HOST is the container name so the peer can reach the server over the Docker network:

lab/tests/wireguard/files/compose.yml

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
# wg-easy 15.5.0 with unattended setup (INIT_*). INIT_HOST is the container name, so the peer reaches it over the Docker network.
services:
  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:15.5.0
    container_name: wg-easy
    restart: unless-stopped
    environment:
      INSECURE: "true"
      INIT_ENABLED: "true"
      INIT_USERNAME: lab
      INIT_PASSWORD: lab-only-not-a-secret
      INIT_HOST: wg-easy
      INIT_PORT: "51820"
    ports:
      - "127.0.0.1:8080:51821/tcp"
    volumes:
      - etc_wireguard:/etc/wireguard
      - /lib/modules:/lib/modules:ro
    cap_add: [NET_ADMIN, SYS_MODULE]
    sysctls:
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.src_valid_mark=1
volumes:
  etc_wireguard:

The API page says requests use Basic authentication, that two-factor must be off, and that the endpoints are not yet documented and may change. Two calls were enough here, and both worked on 15.5.0: POST /api/client with {"name":"peer1","expiresAt":null} created the peer, and GET /api/client/<id>/configuration returned a standard WireGuard config. The responses are in api-create-client.json and api-list-clients.json; the config with its keys removed is peer1.conf.redacted.

The peer is an Alpine container with wireguard-tools, started with NET_ADMIN. The config wg-easy generated needed two edits before wg-quick up would work there:

  • The DNS line was removed, because the Alpine image has no resolvconf.
  • AllowedIPs was changed from 0.0.0.0/0, ::/0 to 10.8.0.0/24, so only tunnel traffic used the interface.

Proof that the traffic used the tunnel: after the runs, wg show on the server (wg-show.server.after.txt) lists the peer with 12.95 GiB received, and the no-tunnel runs were done before the interface existed. The iperf3 server runs in the wg-easy network namespace, so the same process answered both paths: its Docker address for the baseline, 10.8.0.1 for the tunnel.

The full script that produced every number on this page:

lab/tests/wireguard/wg.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# Runs inside the VM from ~/wg. Raw outputs go to ~/wg/raw.
set -u
cd "$HOME/wg"; mkdir -p raw
export COMPOSE_PROJECT_NAME=wg
log() { echo "== $* $(date -u +%T)"; }

log build tools image
docker build -q -t tools -f Dockerfile.tools . > raw/build.txt 2>&1
docker run --rm tools sh -c 'wg --version; iperf3 --version | head -1; cat /etc/alpine-release' > raw/tools-versions.txt 2>&1
docker pull -q ghcr.io/wg-easy/wg-easy:15.5.0 > /dev/null 2>&1

log throwaway start so the measured start does not pay for first-read page cache
docker compose up -d > /dev/null 2>&1; sleep 25; docker compose down -v > /dev/null 2>&1
log start wg-easy
T0=$(date +%s.%N)
docker compose up -d > raw/compose-up.txt 2>&1
for _ in $(seq 1 90); do
  code=$(curl -s -o /dev/null -w '%{http_code}' -m3 -u lab:lab-only-not-a-secret http://127.0.0.1:8080/api/client)
  [ "$code" = 200 ] && break; sleep 1
done
T1=$(date +%s.%N)
echo "ready_seconds $(python3 -c "print(round($T1 - $T0, 1))")  http=$code" > raw/wg-easy-ready.txt
sleep 30
for _ in $(seq 1 12); do docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' wg-easy; done > raw/wg-easy-idle.txt
sleep 90
for _ in $(seq 1 12); do docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' wg-easy; done > raw/wg-easy-idle-2min.txt
docker image inspect ghcr.io/wg-easy/wg-easy:15.5.0 --format '{{.Size}}' > raw/wg-easy-image-size.txt
docker compose logs --no-color --tail=30 wg-easy > raw/wg-easy-logs.txt 2>&1

log create client through the API
API="curl -s -m20 -u lab:lab-only-not-a-secret"
$API -XPOST http://127.0.0.1:8080/api/client -H 'content-type: application/json' -d '{"name":"peer1","expiresAt":null}' > raw/api-create-client.json
$API http://127.0.0.1:8080/api/client > raw/api-list-clients.json
CID=$(jq -r '.[0].id' raw/api-list-clients.json)
$API "http://127.0.0.1:8080/api/client/$CID/configuration" > raw/peer1.conf
sed -E 's/(PrivateKey|PresharedKey) = .*/\1 = <redacted>/' raw/peer1.conf > raw/peer1.conf.redacted
cp raw/peer1.conf peer1.conf
# alpine has no resolvconf, and a /24 route is enough for the test
sed -i -e '/^DNS/d' -e 's|^AllowedIPs.*|AllowedIPs = 10.8.0.0/24|' peer1.conf

log iperf server + baseline
docker run -d --name srv --network container:wg-easy tools iperf3 -s > /dev/null
docker run -d --name peer --network wg_default --cap-add NET_ADMIN --sysctl net.ipv4.conf.all.src_valid_mark=1 tools sleep infinity > /dev/null
SRVIP=$(docker inspect wg-easy --format '{{(index .NetworkSettings.Networks "wg_default").IPAddress}}')
echo "$SRVIP" > raw/server-docker-ip.txt
sleep 2
docker exec peer ping -c 20 -i 0.2 "$SRVIP" > raw/ping.direct.txt 2>&1

cpu_during() { # cpu_during <outfile> <command...>: run command while mpstat samples whole-VM CPU
  local f="$1"; shift
  mpstat 1 1000 > "$f" 2>&1 &
  local mp=$!
  "$@"; local rc=$?
  kill "$mp" 2>/dev/null; wait "$mp" 2>/dev/null
  return $rc
}
for i in 1 2 3; do
  cpu_during raw/direct.run$i.mpstat.txt sh -c "docker exec peer iperf3 -c $SRVIP -t 15 -J > raw/direct.run$i.json 2> raw/direct.run$i.err"
done

log bring the tunnel up
docker cp peer1.conf peer:/etc/wireguard/wg0.conf
docker exec peer wg-quick up wg0 > raw/wg-quick-up.txt 2>&1
sleep 3
docker exec peer wg show wg0 > raw/wg-show.peer.before.txt 2>&1
docker exec wg-easy wg show wg0 > raw/wg-show.server.before.txt 2>&1
docker exec peer ip link show wg0 > raw/wg0-link.txt 2>&1
docker exec peer ping -c 20 -i 0.2 10.8.0.1 > raw/ping.tunnel.txt 2>&1
for i in 1 2 3; do
  cpu_during raw/tunnel.run$i.mpstat.txt sh -c "docker exec peer iperf3 -c 10.8.0.1 -t 15 -J > raw/tunnel.run$i.json 2> raw/tunnel.run$i.err"
done
docker exec peer wg show wg0 > raw/wg-show.peer.after.txt 2>&1
docker exec wg-easy wg show wg0 > raw/wg-show.server.after.txt 2>&1
docker exec peer wg show wg0 latest-handshakes > raw/handshakes.txt 2>&1

log wg-easy RAM with the tunnel up and idle
sleep 10
for _ in $(seq 1 12); do docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' wg-easy; done > raw/wg-easy-idle-with-peer.txt
docker compose down -v > /dev/null 2>&1; docker rm -f srv peer > /dev/null 2>&1
echo done > raw/DONE

What went wrong or surprised

  • Memory: the 180.4 MiB then 42.1 MiB readings described above.
  • The compose file keeps NET_ADMIN, SYS_MODULE and two sysctls. This test did not try without them.
  • Nothing failed outright. No packets were lost in the 20-packet ping on either path.

What this test did not cover

  • Real networks: no physical NIC, no WAN, no NAT traversal, no MTU problems on a path with a smaller MTU than 1420.
  • UDP throughput, many parallel streams, and many peers. iperf3 ran one TCP stream per run.
  • The wg-easy web UI, 2FA, client expiry, and the admin password flow.
  • The kernel WireGuard module on a different CPU. Encryption speed depends on CPU features this test did not vary.
  • Upgrade and backup of the etc_wireguard volume.
  • Whether the IPv6 addresses in the generated config work end to end.

Sources and how this was made

lab/tests/wireguard/run.sh
Show the full file
#!/usr/bin/env bash
# wireguard: wg-easy 15.5.0 server, one peer in its own container (own network namespace), iperf3 with and without the tunnel.
TEST_ID=wireguard; VM_NAME=b-wireguard
source "$(dirname "$0")/../access-common.sh"
HERE="$ROOT/lab/tests/$TEST_ID"
rm -rf "$RAW"; mkdir -p "$RAW"
vm_start
capture_env
vm ssh 'rm -rf ~/wg; mkdir -p ~/wg/raw; which mpstat'
vm scp-to "$HERE/files/." wg/
vm scp-to "$HERE/wg.sh" wg/wg.sh
vm ssh 'cd ~/wg && nohup ./wg.sh > raw/wg.log 2>&1 &'
for _ in $(seq 1 120); do
  vm ssh 'test -f ~/wg/raw/DONE' 2>/dev/null && break
  sleep 10
done
vm scp-from wg/raw/. "$RAW/"
rm -f "$RAW/peer1.conf"   # holds the throwaway keys; only the redacted copy is kept
python3 -I "$HERE/summarize.py" "$RAW" "$OUT"

How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.