A WireGuard tunnel between two Fedora VMs ran at under a tenth of the bare link: 3.67 Gbit/s against 37.9. That gap is CPU, since the server spent 69 to 77% of its time in the kernel encrypting packets. The real problems were three firewalld and NetworkManager defaults that leave a fresh WireGuard VPN on Fedora silent, cut off from the internet or in the wrong firewall zone.
This guide builds the server and a Fedora client with wg-quick, gives wg0 its own firewalld zone and adds the policy full-tunnel clients need. It then moves the tunnel under NetworkManager with nmcli and renders a phone config as a QR code. The server sequence ran on Fedora 45 and Fedora 44 with SELinux enforcing, and the tunnel figure above (wireguard-tools 1.0.20260223, measured October 2026 on Fedora 45) is the mean of seven 15-second runs.
WireGuard on Fedora: measured throughput, latency and MTU
Our lab is two VMs on one Proxmox host: bench01 is the WireGuard server at 10.0.1.50 and bench02 is a Fedora client at 10.0.1.51. The tunnel network is 10.99.99.0/24, with the server on .1, the laptop client on .2 and a phone peer on .3. Besides the default ssh service, the only things added to the public zone of the server are UDP 51820 and masquerade for full-tunnel clients, and everything that arrives on wg0 is filtered by a separate zone:

Each throughput figure in the table is the mean of 15-second iperf3 runs from the client to the server, three per row unless the row says otherwise. The bare link is the same pair of VMs talking over eth0, so it shows the ceiling of the virtual network rather than of any physical NIC:
| Path | MTU | Client to server | Server to client | Ping RTT (avg, 20 pings) |
|---|---|---|---|---|
| Bare link, eth0 | 1500 | 37.9 Gbit/s | 33.4 Gbit/s | 0.173 ms |
| WireGuard wg0, 1 stream (7 runs) | 1420 | 3.67 Gbit/s (3.07 to 4.06) | 3.84 Gbit/s | 0.401 ms |
| WireGuard wg0, 4 streams | 1420 | 4.11 Gbit/s | not run | same tunnel |
| WireGuard wg0, 1 stream | 1280 | 3.51 Gbit/s | not run | same tunnel |
| WireGuard wg0, 1 stream, Fedora 44 server | 1420 | 3.64 Gbit/s | not run | 0.665 ms |
Here is how each number was taken, so you can repeat it on your own hardware:
- Throughput:
iperf3 -c <server> -t 15 -O 2, with the first 2 seconds omitted so TCP slow start does not drag the mean. The reverse column adds-R, the 4-stream row adds-P 4. - Latency:
ping -c 20 -qfrom the client, once against 10.0.1.50 and once against 10.99.99.1. WireGuard added 0.23 ms per round trip on the test box. - CPU:
vmstat 3on both VMs during a tunnel run. The server sat at 69 to 77% system time with 18 to 22% idle and 4 to 9% steal, the client at 55 to 62% system time. - MTU:
ping -M do -swith the DF (do not fragment) bit set. The largest payload throughwg0was 1392 bytes, against 1472 oneth0.
Read the 9.7% ratio for what it is. Both VMs share one host, so the bare link is a memory copy that no real network matches, and the tunnel is limited by how fast two vCPUs can run ChaCha20-Poly1305. The 4 to 9% steal time says other guests on the same host were competing for the cores, which is also why single tunnel runs spread from 3.07 to 4.06 Gbit/s.
On a WAN uplink of 1 Gbit/s or less, the uplink runs out long before a server like this one does, so you will see your line rate through the tunnel. Four parallel streams came in at 4.11 Gbit/s, 12% above the single-stream mean, because the bottleneck stayed on the same two cores. Dropping the MTU to 1280, which some mobile and PPPoE paths force, measured 3.51 Gbit/s, inside the spread of the 1420 runs, so on this box the smaller MTU had no cost we could separate from the noise.
Below is one 10-second run on each path plus the tunnel latency. Every 15-second single-stream tunnel run logged 35 to 163 TCP retransmits, where the bare link logged 0 or 1, which is the CPU saturating rather than packet loss on the wire:

If you need more than about 4 Gbit/s through one tunnel, give the server more cores before you touch any WireGuard setting. Nothing in wg0.conf moved these numbers outside the run-to-run spread.
Prerequisites
WireGuard is cheap to run, so the server spec matters far less than the network path in front of it. You need the following before Step 1:
- A Fedora server: Fedora 45 or 44 with sudo access. Our VMs had 2 vCPU and 2 GB RAM, which is a floor for following along. RAM barely matters; CPU sets the throughput ceiling, at about 3.7 Gbit/s for 2 vCPU on the test box.
- A Fedora client: a second machine or VM for the laptop peer. Any Linux client with
wireguard-toolsfollows the same steps. - UDP 51820 reachable on the server: open in firewalld (Step 4) and, if the server sits behind a home router, forwarded to it.
- A stable address for clients to dial: a static public IP or a DNS name. The examples use 10.0.1.50; replace it with the address your clients will actually reach.
If there is no machine you can leave running behind the router, the VPN endpoint can live on the router side instead. A small box such as the GL.iNet Brume 2 running as a WireGuard gateway terminates the tunnel at the edge, and the Fedora client steps below still apply to any Linux machine behind it.
Step 1: Install WireGuard tools and firewalld
The WireGuard module ships with the Fedora kernel, so there is nothing to build: you only need the userspace tools (wg and wg-quick). The Fedora Cloud images we cloned had no firewalld at all, so it goes in the same transaction, along with qrencode for the phone step and iperf3 for measuring. Run this on the server and on the client:
sudo dnf install -y wireguard-tools qrencode firewalld iperf3
If your cloud-init runs a package upgrade on first boot, as ours does, let it finish first: cloud-init status --wait returns when it is done. On our Fedora 45 client the install failed twice with Transaction failed: Rpm transaction failed. while that dnf -y upgrade was still running, and the same command succeeded once it had finished. Confirm the tools and the in-kernel module before writing any config:
wg --version
modinfo wireguard | head -1
A filename: line that points into drivers/net/wireguard means the module is there, even though no interface uses it yet:
wireguard-tools v1.0.20260223 - https://git.zx2c4.com/wireguard-tools/
filename: /lib/modules/7.2.7-300.fc45.x86_64/kernel/drivers/net/wireguard/wireguard.ko.xz
We ran the full server sequence on Fedora 45 and Fedora 44, with the same client connecting to each. That covers the install, the keys, the dedicated zone, the policy, the NetworkManager variant and a reboot. For Fedora 43 we only checked the repository with dnf repoquery --releasever=43. It carries the same upstream wireguard-tools, and firewalld stays on the 2.x line that has the policy objects Step 7 uses:
== Fedora 45 repos
NetworkManager-1.58.1-3.fc45.x86_64
firewalld-2.5.2-1.fc45.noarch
qrencode-4.1.1-13.fc45.x86_64
wireguard-tools-1.0.20260223-2.fc45.x86_64
== Fedora 44 repos
NetworkManager-1.56.1-2.fc44.x86_64
firewalld-2.4.4-1.fc44.noarch
qrencode-4.1.1-12.fc44.x86_64
wireguard-tools-1.0.20260223-1.fc44.x86_64
== Fedora 43 repos
NetworkManager-1.54.3-3.fc43.x86_64
firewalld-2.3.2-1.fc43.noarch
qrencode-4.1.1-11.fc43.x86_64
wireguard-tools-1.0.20260223-1.fc43.x86_64
Start firewalld before going further. On both servers it came up with eth0 in the public zone, allowing only dhcpv6-client, mdns and ssh:
sudo systemctl enable --now firewalld
sudo firewall-cmd --get-active-zones
Starting firewalld over SSH is safe here because the ssh service is already in the default zone. If sudo firewall-cmd --list-all shows a default zone without ssh on your machine, add it before you log out.
Step 2: Generate the server and client key pairs
Each peer needs its own Curve25519 key pair. Create the private key on the machine that will use it, so it never travels. /etc/wireguard is mode 700 and owned by root, so the whole pipeline has to run inside one root shell. A plain cd /etc/wireguard as your user fails with Permission denied on both releases. On the server:
sudo sh -c "umask 077; wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub"
sudo ls -l /etc/wireguard
sudo cat /etc/wireguard/server.pub
Both files should be mode 600 and 45 bytes long. The last line prints the public key of the server, which the client config needs in Step 6:
total 8
-rw-------. 1 root root 45 Oct 26 00:28 server.key
-rw-------. 1 root root 45 Oct 26 00:28 server.pub
Zn3pcUiS+L8JlkIFsV0a1xeTKPAKdmTBIdMlhGCmiDo=
Repeat it on the client with its own file names. Only the public key it prints leaves the client; you will paste it into the server config in the next step:
sudo sh -c "umask 077; wg genkey | tee /etc/wireguard/client.key | wg pubkey > /etc/wireguard/client.pub"
sudo cat /etc/wireguard/client.pub
Treat the .key files like SSH private keys. Anyone holding one can join the tunnel as that peer, and there is no certificate to revoke: the only way to cut them off is to remove the matching public key from the server.
Step 3: Write the server wg0.conf
On the server, the config defines the tunnel address, the listening port and one [Peer] block per client. The keys go in as placeholders and get filled by sed afterwards, so the private key is copied from its file instead of through your clipboard. Open the file:
sudo vim /etc/wireguard/wg0.conf
Paste this exactly; it is the file both lab servers ran:
[Interface]
Address = 10.99.99.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY_HERE
[Peer]
# bench02, Fedora laptop client
PublicKey = CLIENT_PUBLIC_KEY_HERE
AllowedIPs = 10.99.99.2/32
Now replace both placeholders. Set CLIENT_PUB to the key the client printed in Step 2; the value below is the one from our lab:
export CLIENT_PUB="HSnyyT9uXUCDFQPwSdMFyvLp4p2nhf4ZSuzD5FmDzTM="
sudo sed -i "s|SERVER_PRIVATE_KEY_HERE|$(sudo cat /etc/wireguard/server.key)|" /etc/wireguard/wg0.conf
sudo sed -i "s|CLIENT_PUBLIC_KEY_HERE|${CLIENT_PUB}|" /etc/wireguard/wg0.conf
sudo grep -c HERE /etc/wireguard/wg0.conf
The final grep must print 0; any other number means a placeholder survived and wg-quick will refuse the key. AllowedIPs on the server side works as a source filter and a route at once: packets from this peer are only accepted from 10.99.99.2, and packets for 10.99.99.2 are sent to this peer. One /32 per client keeps that mapping unambiguous.
There are no PostUp firewall lines in this file on purpose. Older guides, including the previous version of this one, ran firewall-cmd and sysctl from PostUp. On Fedora the permanent firewalld config in Step 4 and Step 7 survives reboots on its own, and firewalld switched net.ipv4.ip_forward from 0 to 1 by itself when masquerade was enabled. The file also works unchanged as the source for the NetworkManager import in Step 8.
Step 4: Put wg0 in its own firewalld zone
UDP 51820 has to be open on the zone that holds eth0, or the handshake packets from the client are dropped and the peer stays silent. wg0 should also get a zone of its own. firewalld reported no zone for the interface that wg-quick created, so peers were filtered by public, which allows only ssh, mdns, dhcpv6-client and ICMP. Ping across the tunnel worked while an iperf3 test failed with No route to host, because public does not allow its port. A separate zone does not make the tunnel work. It decides which services peers can reach without touching what the LAN side can reach, and the forwarding policy in Step 7 needs it as its ingress zone.
Open the port, then create a wireguard zone that allows only what VPN peers should reach on the server itself (ssh here):
sudo firewall-cmd --permanent --add-port=51820/udp
sudo firewall-cmd --permanent --new-zone=wireguard
sudo firewall-cmd --permanent --zone=wireguard --add-interface=wg0
sudo firewall-cmd --permanent --zone=wireguard --add-service=ssh
sudo firewall-cmd --reload
sudo firewall-cmd --get-active-zones
Ping needs no extra rule, because a zone with the default target accepts ICMP. Binding wg0 also works before the interface exists: on the Fedora 44 server, --get-active-zones printed the same two zones before Step 5 created the interface and after it:
public (default)
interfaces: eth0
wireguard
interfaces: wg0
We would not use the built-in trusted zone for this, even though it makes everything work at once. It accepts every port from every peer, so one lost phone exposes every service on the server. A dedicated zone means each service a peer may reach is a line you added on purpose. The firewalld zones and rich rules guide covers narrowing it further, for example ssh from one peer only.
Step 5: Start the WireGuard server and enable it at boot
The wg-quick@wg0 systemd unit reads /etc/wireguard/wg0.conf, creates the interface, sets the address and the MTU, and loads the peer list. Enabling it with --now starts the tunnel and makes it return after a reboot:
sudo systemctl enable --now wg-quick@wg0
sudo wg show wg0
If you already brought the interface up by hand with wg-quick up wg0, the unit fails, because it tries to create a wg0 that already exists. That is what our first attempt on the Fedora 45 server logged:
Starting [email protected] - WireGuard via wg-quick(8) for wg0...
wg-quick: `wg0' already exists
[email protected]: Main process exited, code=exited, status=1/FAILURE
[email protected]: Failed with result 'exit-code'.
Take the manual interface down with sudo wg-quick down wg0, then sudo systemctl restart wg-quick@wg0. Enabling the unit created two symlinks on both releases, one under multi-user.target.wants and one under wg-quick.target.wants. After a reboot of the Fedora 45 server, the unit had wg0 up 9.0 seconds after the kernel started, with the zone binding and ip_forward = 1 intact. During a second reboot, a client pinging once a second lost 21 pings and got its first reply 2.8 seconds after the server logged the unit as finished (each timestamp from its own VM clock), since WireGuard retries a failed handshake every 5 seconds.
You can see the interface address and the 1420-byte MTU that wg-quick picks for a 1500-byte uplink with ip. The capture below was taken after the client in Step 6 connected and the phone peer from Step 9 was added, which is why one peer shows an endpoint and a handshake and the other does not:

A peer that has never connected shows only its public key and allowed IPs. An endpoint: line appears when the first valid handshake arrives, because the server learns where a client lives from its packets, not from its config.
Step 6: Connect a Fedora client
On the client, the config mirrors the one on the server. It names the server as its only peer and points Endpoint at the address the server is reachable on. It also starts as a split tunnel: only the 10.99.99.0/24 network goes through wg0, so normal traffic from the client, and your SSH session to it, stay on the LAN. Open the file on the client:
sudo vim /etc/wireguard/wg0.conf
Paste the client side, with the address of your server in place of 10.0.1.50:
[Interface]
Address = 10.99.99.2/32
PrivateKey = CLIENT_PRIVATE_KEY_HERE
[Peer]
PublicKey = SERVER_PUBLIC_KEY_HERE
Endpoint = 10.0.1.50:51820
AllowedIPs = 10.99.99.0/24
PersistentKeepalive = 25
PersistentKeepalive = 25 sends a packet every 25 seconds so a NAT router between client and server keeps the UDP mapping open. A client on the same LAN does not need it, but a laptop that moves between networks does. Fill in the keys, with SERVER_PUB set to what the server printed in Step 2, then start the tunnel:
export SERVER_PUB="Zn3pcUiS+L8JlkIFsV0a1xeTKPAKdmTBIdMlhGCmiDo="
sudo sed -i "s|CLIENT_PRIVATE_KEY_HERE|$(sudo cat /etc/wireguard/client.key)|" /etc/wireguard/wg0.conf
sudo sed -i "s|SERVER_PUBLIC_KEY_HERE|${SERVER_PUB}|" /etc/wireguard/wg0.conf
sudo wg-quick up wg0
ping -c 5 10.99.99.1
With the port open, all five pings came back at 0.431 ms on average. Then check the peer on the client:
sudo wg show wg0
Two lines prove the tunnel works. latest handshake must exist and be under two minutes old, and the received counter must be above zero:
interface: wg0
public key: HSnyyT9uXUCDFQPwSdMFyvLp4p2nhf4ZSuzD5FmDzTM=
private key: (hidden)
listening port: 40405
peer: Zn3pcUiS+L8JlkIFsV0a1xeTKPAKdmTBIdMlhGCmiDo=
endpoint: 10.0.1.50:51820
allowed ips: 10.99.99.0/24
latest handshake: 14 seconds ago
transfer: 1.34 KiB received, 2.18 KiB sent
persistent keepalive: every 25 seconds
When the handshake has never completed, wg show does not print a handshake line at all, and received stays at 0 B while sent grows. That combination almost always means UDP 51820 is not reaching the server. Make the client tunnel permanent with sudo systemctl enable wg-quick@wg0 once it works, or hand it to NetworkManager as in Step 8. For Debian or RHEL clients, the steps are the same as in the WireGuard setup for Ubuntu, Debian and Rocky, which differs only in package names and the firewall tool.
Step 7: Route all client traffic through the server
A full tunnel needs NAT on the server and permission to forward from the wireguard zone out of public. Masquerade alone was not enough on either server. With only masquerade enabled, a full-tunnel client reached 10.99.99.1 and then stopped. tracepath showed !H from the server, and curl gave up at its 8-second limit. firewalld does not forward traffic between two different zones until a policy says so. On the server:
sudo firewall-cmd --permanent --zone=public --add-masquerade
sudo firewall-cmd --permanent --new-policy=wg-to-internet
sudo firewall-cmd --permanent --policy=wg-to-internet --add-ingress-zone=wireguard
sudo firewall-cmd --permanent --policy=wg-to-internet --add-egress-zone=public
sudo firewall-cmd --permanent --policy=wg-to-internet --set-target=ACCEPT
sudo firewall-cmd --reload
sudo firewall-cmd --info-policy=wg-to-internet
The policy should list wireguard as the ingress zone and public as the egress zone, with target ACCEPT. The firewalld builds on both releases print an extra disable: no line at the top:
wg-to-internet (active)
disable: no
priority: -1
target: ACCEPT
ingress-zones: wireguard
egress-zones: public
services:
ports:
protocols:
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:
It only allows traffic that enters on wg0 and leaves on eth0, so nothing on the LAN side gains a path into the tunnel. The masquerade: no in the policy is expected, because masquerade lives on the public zone. The firewalld policies man page documents the other directions if you later want peers to reach a second internal zone.
On the client, switch AllowedIPs to the default route and restart the tunnel. An SSH session from a machine on the same LAN segment as the client survives this, because the suppress_prefixlength 0 rule that wg-quick adds only overrides the default route, and the on-link /24 route still wins. Our lab sessions arrived through a subnet router that NATs onto the LAN, so they came from an on-link address and stayed up. A session that reaches the client through a router without that NAT follows the default route into the tunnel and can drop:
sudo wg-quick down wg0
sudo sed -i 's|^AllowedIPs = 10.99.99.0/24|AllowedIPs = 0.0.0.0/0|' /etc/wireguard/wg0.conf
sudo wg-quick up wg0
tracepath -n -m 3 1.1.1.1
wg-quick handles the default route with a policy rule and its own routing table 51820, so the encrypted packets to the endpoint still leave through eth0. With the policy in place, hop 1 is the tunnel address of the server and hop 2 is the LAN gateway behind it:
1?: [LOCALHOST] pmtu 1420
1: 10.99.99.1 0.532ms
1: 10.99.99.1 0.379ms
2: 10.0.1.1 0.498ms
3: 203.0.113.1 4.158ms
Too many hops: pmtu 1420
Resume: pmtu 1420
A curl to fedoraproject.org then returned HTTP 200 in 2.8 seconds on the Fedora 45 server and 1.9 seconds on the Fedora 44 one. The received counter on the client reached 281 KiB, against 3.1 KiB in the failed run. If hop 2 shows !H instead, the policy is missing or was added without --permanent and lost on reload. If you do lose an off-link session, the client is still reachable over the tunnel: from the server, SSH to 10.99.99.2 answered with the OpenSSH banner while the full tunnel was up, so you can log in that way and run sudo wg-quick down wg0.
Step 8: Run the tunnel with NetworkManager instead of wg-quick
NetworkManager already managed eth0 on the Fedora Cloud images we used, and it can own a WireGuard interface directly. On a laptop that is the better fit: the tunnel shows up in nmcli next to Wi-Fi, comes up on login and survives reboots without a separate systemd unit. NetworkManager imports the same wg0.conf you already wrote. Import the split-tunnel file, because a full tunnel under NetworkManager is the one combination we did not test. The sequence below disables the wg-quick unit so the two tools do not compete for wg0 after a reboot. It also takes the running interface down and reverses the Step 7 edit. It worked on our client with the unit enabled and the tunnel started by hand, which is where Steps 6 and 7 leave you:
sudo systemctl disable wg-quick@wg0
sudo wg-quick down wg0
sudo sed -i 's|^AllowedIPs = 0.0.0.0/0|AllowedIPs = 10.99.99.0/24|' /etc/wireguard/wg0.conf
sudo nmcli connection import type wireguard file /etc/wireguard/wg0.conf
nmcli -f connection.id,connection.autoconnect,connection.zone,wireguard.listen-port,ipv4.addresses connection show wg0
NetworkManager names the connection after the file, starts at once, and is set to autoconnect. A listen-port of 0 on the client means a random source port, the same as wg-quick picks:
connection.id: wg0
connection.autoconnect: yes
connection.zone: --
wireguard.listen-port: 0
ipv4.addresses: 10.99.99.2/32
NetworkManager copies the private key into /etc/NetworkManager/system-connections/wg0.nmconnection, a root-only file with mode 600, so /etc/wireguard/wg0.conf is no longer read after the import. Keep the server’s copy, because adding a peer later means editing that file and importing it again; on the client it is only a backup. The client had a fresh handshake with the server within seconds of the import, and wg show keeps working because the kernel interface is the same.
The server works the same way, with one catch that matters. When NetworkManager brought up wg0 on both servers, firewalld put it in public, even though the permanent config from Step 4 still listed wg0 under the wireguard zone. NetworkManager hands every interface it manages to firewalld with the zone stored in its own connection profile, and an empty zone means the default. Ping and the handshake keep working in public, so the move looks clean, while TCP services reached over the tunnel are now filtered by the wrong zone. Set the zone on the connection itself:
sudo systemctl disable --now wg-quick@wg0
sudo nmcli connection import type wireguard file /etc/wireguard/wg0.conf
sudo firewall-cmd --get-zone-of-interface=wg0
sudo nmcli connection modify wg0 connection.zone wireguard
sudo nmcli connection up wg0
sudo firewall-cmd --get-zone-of-interface=wg0
On both releases the first zone check printed public and the second wireguard (Fedora 45 with NetworkManager 1.58, Fedora 44 with 1.56). The server kept ListenPort = 51820 from the file, the client reconnected on its next handshake retry, and SSH from the client to 10.99.99.1 answered with its banner. Both servers came back after a reboot with wg0 in wireguard and ip_forward = 1, and the Fedora 45 one also listed the wg-to-internet policy as active.
Pick one owner per interface. With the wg-quick@wg0 unit running, nmcli device lists wg0 as connected (externally) and leaves it alone, so the two can coexist on a machine only if they manage different interfaces. To go back to wg-quick, run sudo nmcli connection delete wg0 and enable the unit again; the firewalld binding from Step 4 then applies as before.
Step 9: Add a phone peer with a QR code
The WireGuard apps for Android and iOS import a config by scanning a QR code, which means the private key for the phone has to be created on the server and shown on its screen once. That is the one case where a private key is made away from its device, so the files are destroyed as soon as the phone has imported them. The commands below are for a wg-quick server, which is how our server ran for this step after we moved it back from NetworkManager. On a NetworkManager server, wg syncconf only lasts until the next reconnect. Append the peer to wg0.conf the same way and run sudo nmcli connection delete wg0. Then repeat the import, connection.zone and connection up commands from Step 8. A fresh import starts without the zone again. Generate the key pair and add the peer to the running server:
sudo sh -c "umask 077; wg genkey | tee /etc/wireguard/phone.key | wg pubkey > /etc/wireguard/phone.pub"
PHONE_PUB=$(sudo cat /etc/wireguard/phone.pub)
printf "\n[Peer]\n# phone\nPublicKey = %s\nAllowedIPs = 10.99.99.3/32\n" "$PHONE_PUB" | sudo tee -a /etc/wireguard/wg0.conf
sudo bash -c "wg syncconf wg0 <(wg-quick strip wg0)"
sudo wg show wg0 allowed-ips
wg syncconf applies the file to the live interface without taking it down. We ran it to add the phone while the laptop pinged the server every 200 ms, and all 50 pings came back. Both peers are then listed:
HSnyyT9uXUCDFQPwSdMFyvLp4p2nhf4ZSuzD5FmDzTM= 10.99.99.2/32
s8atkdLKJJCdWOD4ANKQ/BU1+ZWDs1LCf1nKSIaUcTg= 10.99.99.3/32
The tempting alternative is wg set followed by wg-quick save wg0, which rewrites wg0.conf from the runtime state. We backed up wg0.conf, ran the save, diffed the result against the backup and then restored it. Both # comments that named the peers are gone, and the current source port of the laptop is written in as a fixed Endpoint:
7d6
< # bench02, Fedora laptop client
9a9
> Endpoint = 10.0.1.51:59400
12d11
< # phone
That pinned endpoint goes stale the next time the laptop changes networks. Edit the file and sync it instead. Next, write the phone config on the server. It uses a full tunnel and sets DNS servers, because the app applies the DNS line itself:
sudo vim /etc/wireguard/phone.conf
For a phone on mobile data, Endpoint has to be the public address or DNS name of the server, not a LAN IP:
[Interface]
Address = 10.99.99.3/32
PrivateKey = PHONE_PRIVATE_KEY_HERE
DNS = 1.1.1.1, 9.9.9.9
[Peer]
PublicKey = SERVER_PUBLIC_KEY_HERE
Endpoint = 10.0.1.50:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
The server in this guide has no IPv6 address on wg0, so ::/0 sends IPv6 traffic from the phone into a tunnel that drops it. IPv6 is blackholed while the tunnel is up, which also stops it from leaking around the VPN. Fill both keys from their files and print the QR code in the terminal. The ansiutf8 type sets its own colours, so it scans on light and dark terminal themes alike:
sudo sed -i "s|PHONE_PRIVATE_KEY_HERE|$(sudo cat /etc/wireguard/phone.key)|; s|SERVER_PUBLIC_KEY_HERE|$(sudo cat /etc/wireguard/server.pub)|" /etc/wireguard/phone.conf
sudo qrencode -t ansiutf8 -r /etc/wireguard/phone.conf
In our terminal the code came out 33 lines tall. The image below came from qrencode -o on a copy of our phone config with a throwaway private key, so it is an example and will not connect anywhere. zbarimg decoded it back to the same config text, apart from one trailing blank line:

In the app, choose to add a tunnel from a QR code and scan the terminal. We did not import it on a handset in this lab, so the phone half of the flow is the documented path of the app, not something we measured. Once the phone shows the tunnel, delete the copies of its secrets on the server; the server only needs phone.pub, and that line is already in wg0.conf:
sudo shred -u /etc/wireguard/phone.key /etc/wireguard/phone.conf
Phones and laptops that roam between hotel and conference networks are where the WireGuard UDP port gets blocked most often. If you travel with several devices, a travel router that runs the WireGuard client itself holds one tunnel back to this server for everything behind it, so each device does not need its own peer.
Step 10: Measure your own tunnel with iperf3
Your hardware will not match our test box, so take your own numbers before deciding whether the server is big enough. iperf3 listens on TCP 5201, which the wireguard zone does not allow. Open it for the test and close it afterwards. On the server:
sudo firewall-cmd --zone=wireguard --add-port=5201/tcp
iperf3 -s -D
From the client, run the same test against the tunnel address in both directions. Run each at least three times, because single runs on our VMs spread by about a quarter while other guests shared the host:
iperf3 -c 10.99.99.1 -t 15 -O 2
iperf3 -c 10.99.99.1 -t 15 -O 2 -R
The plain-address run (iperf3 -c 10.0.1.50 -t 15 -O 2) needs 5201 open on public too, so add it there with the same command and the zone name changed. When you are done, a sudo firewall-cmd --reload drops both runtime openings, because they were added without --permanent, and pkill iperf3 stops the server. A reboot drops both as well, which is how our post-reboot iperf3 run met No route to host again. If the tunnel number is close to your WAN speed, the CPU is not your problem.
Troubleshooting WireGuard on Fedora
Every error below came up on the lab VMs while this guide was being built, in the order a reader is likely to meet them.
Error: “cd: /etc/wireguard: Permission denied”
wireguard-tools creates /etc/wireguard as root with mode 700, so a normal user cannot enter it, and any key recipe that starts with cd /etc/wireguard stops there or writes the keys wherever the shell happens to be. Run the whole key pipeline inside one sudo sh -c with absolute paths, as in Step 2, and delete any stray .key files from your home directory.
No “latest handshake” line and “0 B received”
Our client sent 444 bytes of handshake attempts and received nothing, and 5 pings to 10.99.99.1 were all lost. The server had no 51820/udp in firewall-cmd --list-ports. Opening the port and reloading fixed it within one handshake retry, which WireGuard sends every 5 seconds. If the port is open and it still happens, compare the PublicKey in each [Peer] block with the key the other side prints in wg show, because a wrong key fails in exactly the same silent way.
Error: “iperf3: error – unable to connect to server … No route to host”
Ping over the tunnel worked, but iperf3 on the server returned No route to host. firewalld refuses a TCP port that the zone filtering wg0 does not allow with exactly that message. With wg-quick and no binding, firewall-cmd --get-zone-of-interface=wg0 printed no zone and public did the filtering. After Step 4 it is the wireguard zone, which allows only ssh. Add the port to the zone that holds wg0, as Step 10 does for 5201.
firewall-cmd shows wg0 in “public” after moving to NetworkManager
sudo firewall-cmd --get-zone-of-interface=wg0 printed public on both servers once NetworkManager owned the interface, while --permanent --zone=wireguard --list-interfaces still printed wg0. The zone stored in the NetworkManager connection wins. Set it with sudo nmcli connection modify wg0 connection.zone wireguard and bring the connection up again, as in Step 8.
tracepath shows “!H” at 10.99.99.1 on a full tunnel
The client reached the server and nothing beyond it, with 2: 10.99.99.1 0.182ms !H in the output, even though masquerade was on and ip_forward was 1. firewalld was refusing to forward between the wireguard and public zones. The wg-to-internet policy in Step 7 is the fix; check it with sudo firewall-cmd --get-policies after a reload. On both releases that list also carries six policies firewalld ships itself, such as allow-host-ipv6, so look for the name rather than counting entries.
Error: “ping: sendmsg: Message too long”
This is the DF probe hitting the tunnel MTU: a 1393-byte payload becomes a 1421-byte packet, one byte over the 1420 that wg-quick set. It is not a fault, but it tells you where the limit is. If large transfers stall over a real WAN while small requests work, find the path limit with the same probe and set MTU = 1280 in the [Interface] block of both peers. On the test box that setting measured within the normal run-to-run spread.
What to watch in wg show after go-live
WireGuard keeps no log of who connected, so sudo wg show wg0 on the server is the monitoring surface. Three fields carry most of the signal:
- latest handshake: a peer that sends traffic or uses
PersistentKeepalivere-handshakes every 2 minutes, so for those peers a value above about 3 minutes means the peer is gone or blocked. An idle peer without keepalive simply shows an old value.wg show wg0 latest-handshakesprints Unix timestamps that a monitoring script can compare withdate +%s. - transfer: received bytes that never grow while sent bytes do is the one-way failure from the troubleshooting section, usually a closed port or a wrong key.
- endpoint: the address and port the peer last sent from. The endpoint of a laptop changes each time it moves networks, which is normal; an endpoint you do not recognise on a peer means someone else may hold its private key, so replace the key pair of that peer.
On the firewall side, sudo firewall-cmd --get-active-zones should keep showing wg0 under wireguard after every reboot and update, whichever of wg-quick or NetworkManager owns it. If it ever lands back in public, TCP services over the tunnel break first. For the rest of the server, the Fedora hardening checklist covers SSH and update policy. If you would rather not manage keys and peers by hand across many devices, a self-hosted control plane such as Headscale builds on the same protocol, and the server side of this guide maps directly to Rocky Linux and AlmaLinux, which use the same firewalld.