WireGuard P2P VPN

Updated May 12, 2025 15 minutes

WireGuard is a super fast and simple VPN that makes it easy to set up secure, ad-hoc, private connections using the latest encryption tech. WireGuard’s design makes no distinction between “server” and “client” — every node is simply a peer. You could designate a particular node as a “server”, and build a hub-and-spoke architecture, or you can design a full mesh network where every node can talk to every other node. This post will focus on the latter.

The following Bash script sets up a p2p VPN between two or more Linux (systemd) machines (also Windows, macOS, Android, iOS, etc. via provisioned keys). The only network requirement is that each host has the ability to make outbound UDP connections and where the router doesn’t change the source port (i.e. Full Cone or Restricted Cone NAT). For most residential ISP connections, this will work out of the box. If your connection uses Symmetric NAT or CGNAT (typical in corporate, hotel, and mobile networks) this might not work so well.

The magic of this setup is that it works without needing to make any modifications to your home router – you don’t need to open any static ports, and you don’t need to pay for an external VPN server or provider!

Note: this script will create VPN routes only between the hosts that you specify. It will not modify your normal Internet connection to any other hosts.

Example

Lets say you have three Linux hosts, with the following hostnames and public IP addresses, all on different networks:

  • defiant - 45.67.89.10
  • enterprise - 156.123.98.34
  • voyager - 23.47.88.14

If your hosts don’t have static IP addresses, you might want to set up dynamic DNS and use fully qualified domain names instead. For this example, we’ll just use the public IP addresses (most residential IP addresses tend to stay the same for long periods).

You must download the script onto each Linux host you want to join the VPN. The script will handle installing WireGuard if it’s not already installed (if your OS is unsupported, try installing WireGuard manually first).

On defiant, run:

wget https://raw.githubusercontent.com/EnigmaCurry/blog.rymcg.tech/master/src/wireguard/wireguard_p2p.sh
chmod +x wireguard_p2p.sh

./wireguard_p2p.sh install 10.15.0.1/24

This will give defiant the VPN address of 10.15.0.1.

It will print out the add-peer command you need to run on the other hosts, which includes the public endpoint and key for defiant:

------------------------------------------------------------
To add THIS node as a peer on another WireGuard server using this script, run:

./wireguard_p2p.sh add-peer defiant 45.67.89.10:51820 du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI= 10.15.0.1

(Replace 'defiant' with your desired label for this peer.)
------------------------------------------------------------

Notes:

  • If you need to print the add-peer information again, run ./wireguard_p2p.sh add-peer with no other arguments.

  • If you don’t have public static IP addresses, simply replace the IP address with the domain name (FQDN). Keep the :51820 port the same. (e.g. host.example.com:51820)

Don’t run the add-peer command on the other hosts yet. You must install the script on enterprise and voyager the same way as you did on defiant, except with different (sequential) VPN addresses:

  • On enterprise: ./wireguard_p2p.sh install 10.15.0.2/24
  • On voyager: ./wireguard_p2p.sh install 10.15.0.3/24

These commands will print out a similar add-peer command with their own endpoint and key. Gather all three add-peer commands, and then run them on each other host:

(Note: all of the IP addresses and public keys listed here are examples for demonstration purposes. You should use the real add-peer command for your actual hosts instead!)

On defiant: add enterprise and voyager:

./wireguard_p2p.sh add-peer enterprise 156.123.98.34:51820 Tx+JOAaZmGZCsE8qqy5AYFnXI7zksC4C2GOjfRlb8lk= 10.15.0.2
./wireguard_p2p.sh add-peer voyager 23.47.88.14:51820 xpW2S5aJaEj2JSbmRdUMBt12y1lhz003m5WKi70YOj4= 10.15.0.3

On enterprise: add defiant and voyager:

./wireguard_p2p.sh add-peer defiant 45.67.89.10:51820 du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI= 10.15.0.1
./wireguard_p2p.sh add-peer voyager 23.47.88.14:51820 xpW2S5aJaEj2JSbmRdUMBt12y1lhz003m5WKi70YOj4= 10.15.0.3

On voyager: add defiant and enterprise:

./wireguard_p2p.sh add-peer defiant 45.67.89.10:51820 du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI= 10.15.0.1
./wireguard_p2p.sh add-peer enterprise 156.123.98.34:51820 Tx+JOAaZmGZCsE8qqy5AYFnXI7zksC4C2GOjfRlb8lk= 10.15.0.2

Now that all three hosts have been installed, and have added each other peer in full mesh, the VPN should be up and fully functional!

Check the status on each peer. Just run wg. For example, on defiant, it will list two peers:

root@defiant:~# wg
interface: wg0
  public key: du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI=
  private key: (hidden)
  listening port: 51820

peer: xpW2S5aJaEj2JSbmRdUMBt12y1lhz003m5WKi70YOj4=
  endpoint: 23.47.88.14:51820
  allowed ips: 10.15.0.3/32
  latest handshake: 16 seconds ago
  transfer: 92 B received, 180 B sent
  persistent keepalive: every 25 seconds

peer: Tx+JOAaZmGZCsE8qqy5AYFnXI7zksC4C2GOjfRlb8lk=
  endpoint: 156.123.98.34:51820
  allowed ips: 10.15.0.2/32
  latest handshake: 16 seconds ago
  transfer: 124 B received, 180 B sent
  persistent keepalive: every 25 seconds

This shows that both of the other peers (enterprise and voyager) are added. Unfortunately, it won’t show their hostnames, but it does show their public keys.

The most important thing to look for is: latest handshake: X seconds ago. If you don’t see latest handshake, or if it shows a time thats larger than a few minutes, then something is wrong, and preventing p2p connection between the machines.

If all three machines show a good handshake, you should now be able to ping each other host, e.g. on defiant:

ping -c1 10.15.0.2
ping -c1 10.15.0.3

Thanks for reading, now you know how to setup a peer-to-peer VPN, between any number of hosts, all without needing to pay for any third party service!

Usage

Usage: ./wireguard_p2p.sh <command>

Commands:
  dependencies                       Install required packages.
  install <address-cidr>             Install and configure WireGuard. Required first time.
  uninstall                          Remove WireGuard configuration and keys.
  status                             Show the WireGuard service status.
  start                              Start the WireGuard service.
  stop                               Stop the WireGuard service.
  import-key PRIVATE_KEY             Import a private key instead of generating one.
  add-peer NAME ENDPOINT PUBLIC_KEY  Add peer live and auto-save into config.
  remove-peer PUBLIC_KEY             Remove peer live and auto-save into config.
  help                               Show this help message.

Outbound NAT issues

Even if you have verified that your ISP has given you a public, static IP address, with full Internet connectivity, you might still find that your router prevents this scenario from working as described.

pfSense

Here is one example with a pfSense router.

pfSense has four possible Outbound NAT settings, (but you only need to be concerned with the first two):

  • Automatic Outbound NAT - this is the default setting, and is most useful for home Internet services. This will allow high levels of TCP and UDP traffic, from many clients, without interference. All outbound connections will be assigned a temporary random source port mapping between the LAN client and the WAN interface. The router will use this unique port mapping to create the bi-directional route between your client and destination for the duration of the call.

    This setting cannot be used with the WireGuard scenario we’ve described so far. This is because the outbound source UDP port must be static.

  • Hybrid Outbound NAT - this setting is just like Automatic Outbound NAT, except that it also lets you create exceptional rules for certain traffic routes. This lets you use the automatic mode for most traffic (random source ports), but will also set up a specific rule to let WireGuard use a static source port from a specific host.

To create a custom rule for WireGuard traffic, make sure to select Hybrid Outbound NAT.

Create a Host Alias for both peers:

  • For the local WireGuard peer, enter the private LAN IP address.

  • For the destination peer, enter the public FQDN or IP address.

Create the Static Port outbound NAT rule.

  • Interface: WAN
  • Address Family: IPv4+IPv6
  • Protocol: UDP
  • Source: Choose Network or Alias and select the LAN host alias ({host}/32) and choose the source port that WireGuard is listening to.
  • Destination: Choose Network or Alias and select the WAN host alias of your remote peer ({remote}/32) and choose the destination port that WireGuard peer is listening to.

With this rule active, the source port on the receiving end will now always match the WireGuard listening port.

Troubleshooting NAT

You can verify the UDP source ports using tcpdump:

tcpdump -n -i any udp

The output of tcpdump shows packets both being sent and received, with the source and destintations hosts and ports. To deduce whether NAT is being (mis)applied, you must run this on both peers to get their own perspective. Critically, for this scenario to work, both the sender and receiver must see the same source ports. Whereas the IP address will be translated public<->private, the ports are static.

Provisioning new peers

WireGuard supports many platforms beyond just Linux. However, this Bash script is only designed for Linux. No problem though, because you can use this script to create keys and config files using the standard WireGuard .conf file format, which is supported on Windows, macOS, Linux, Android, and iOS.

For the most secure VPN, you should have each peer generate their own key. In the context of this script, “provisioning” a peer means that we are taking a shortcut, and generating a key on behalf of that peer and providing it to them ahead of time, embedded into a .conf file. This is convenient to hand to your your trusted friend, moreso than having to exchange keys. Another advantage is that it means we don’t have to port the script to any other platforms – the provisioned machines just need to install WireGuard and import the .conf file.

Usage: ./wireguard_p2p.sh provision-peer <name> <endpoint> <address/CIDR>

Running the provision-peer subcommand, on the Linux host, will:

  • Generate a fresh keypair for the other (provisioned) peer.

  • Emit a ready-to-import .conf file (complete with their new private key, VPN address, and the Linux peer’s public key and endpoint to connect to).

  • Print the exact add-peer command to run on the Linux host to add the new peer connection.

Install the .conf file using WireGuard (GUI) on the other side.

Example of provisioning

For example, your friend has a Windows computer (or mac, android, ios, etc.):

  • borg - 34.56.78.90

Generate a new key and config on their behalf:

./wireguard_p2p.sh provision-peer borg 34.56.78.90:51820 10.15.0.4/24
  • Make sure to enter borg’s public IP address (e.g. 34.56.78.90) - Your friend should double check via ifconfig.me.
  • The endpoint port (e.g. 51820) can be customized for their machine. You may need to experiment with different ports especially if its already being used for something else on their end.
  • Enter an unused private IP address/CIDR for use on the VPN subnet.

The provision-peer will output an add-peer command that you must copy, paste, and run, to add the peer connection:

./wireguard_p2p.sh add-peer borg 34.56.78.90:51820 o7iXZVberLzQclBG+9U4+BJozKVhOl3Mqgaj9MCLST8=  10.15.0.4/24

Grab the contents of the peer’s config file:

cat /etc/wireguard/provisioned_peers/borg.conf
### Example borg.conf connects to defiant
[Interface]
PrivateKey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=
Address    = 10.15.0.2/24
ListenPort = 51820

[Peer]
PublicKey           = du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI=
Endpoint            = 45.67.89.10:51820
AllowedIPs          = 10.15.0.1/24
PersistentKeepalive = 25
  • Copy the text from the config file and give it to your friend in an email or chat message.

  • Your friend needs to download WireGuard and install it.

  • They should save the config text into a file named defiant.conf (they should use the name of your peer, as this will be the name of the connection from their perspective).

  • The config file can be imported via the WireGuard GUI. Activate the connection.

  • Tell your friend to look for the handshake, or how to ping your peer’s VPN address to check that everything is working.

The script

#!/bin/bash

######################################################
#                   WireGuard P2P                    #
#  https://blog.rymcg.tech/blog/linux/wireguard_p2p  #
######################################################

set -euo pipefail

###################################
# Configurable Environment Vars  #
###################################

WG_INTERFACE="wg0"
WG_PORT="51820"
WG_CONFIG_DIR="/etc/wireguard"

###################################
# Internal Globals                #
###################################

PRIVATE_KEY_FILE="$WG_CONFIG_DIR/privatekey"
PUBLIC_KEY_FILE="$WG_CONFIG_DIR/publickey"
CONFIG_FILE="$WG_CONFIG_DIR/${WG_INTERFACE}.conf"
SERVICE_NAME="wg-quick@${WG_INTERFACE}"
ADDRESS_FILE="$WG_CONFIG_DIR/address"

###################################
# Functions                       #
###################################

load_address() {
    if [[ ! -f "$ADDRESS_FILE" ]]; then
        echo "Error: Address file not found at $ADDRESS_FILE. Run install with an address." >&2
        exit 1
    fi
    WG_ADDRESS=$(<"$ADDRESS_FILE")
}

dependencies() {
    if command -v wg >/dev/null 2>&1; then
        echo "WireGuard already installed. Skipping dependency installation."
        return
    fi

    echo "Installing dependencies..."
    if [ -f /etc/debian_version ]; then
        apt update
        apt install -y wireguard-tools wireguard curl
    elif [ -f /etc/fedora-release ]; then
        dnf install -y wireguard-tools curl
    elif [ -f /etc/arch-release ]; then
        pacman -Sy --noconfirm wireguard-tools curl
    else
        echo "Cannot detect platform. Please manually install the wireguard and curl packages for your system." >&2
        exit 1
    fi
}

generate_keys() {
    echo "Generating WireGuard keys..."
    mkdir -p "$WG_CONFIG_DIR"
    chmod 700 "$WG_CONFIG_DIR"
    cd "$WG_CONFIG_DIR"

    if [[ ! -f "$PRIVATE_KEY_FILE" ]]; then
        umask 077
        wg genkey | tee privatekey | wg pubkey > publickey
        echo "Keys generated."
    else
        echo "Keys already exist."
    fi
}

create_base_config() {
    echo "Creating base WireGuard config file..."

    tee "$CONFIG_FILE" > /dev/null <<EOF
[Interface]
Address = $WG_ADDRESS
PrivateKey = $(<"$PRIVATE_KEY_FILE")
ListenPort = $WG_PORT
SaveConfig = true
EOF

    echo "Base config created at $CONFIG_FILE"
}

get_add_peer_command() {
        echo ""
    echo "------------------------------------------------------------"
    echo "To add THIS node as a peer on another WireGuard server using this script, run:"
    echo ""
    local public_key
    public_key=$(<"$PUBLIC_KEY_FILE")

    local public_ip
    public_ip=$(curl -s ifconfig.me)
    load_address

    echo "./wireguard_p2p.sh add-peer $(hostname) ${public_ip}:${WG_PORT} ${public_key}" "${WG_ADDRESS%%/*}"
    echo ""
    echo "(Replace '$(hostname)' with your desired label for this peer.)"
    echo "------------------------------------------------------------"
    echo ""
}

add_peer() {
    load_address

    local name="$2"
    local endpoint="$3"
    local public_key="$4"
    local peer_ip="$5"

    if [[ -z "$name" || -z "$endpoint" || -z "$public_key" || -z "$peer_ip" ]]; then
        echo "Error: name, endpoint, public_key, and peer_ip cannot be blank." >&2
        exit 1
    fi

    echo "Adding peer to WireGuard interface..."

    wg set "$WG_INTERFACE" peer "$public_key" \
        allowed-ips "$peer_ip" \
        endpoint "$endpoint" \
        persistent-keepalive 25

    echo "Peer added live. Restarting interface to save into config..."
    wg-quick down "$WG_INTERFACE" || true
    wg-quick up "$WG_INTERFACE"

    echo "Peer added and saved: $name ($endpoint)"
}

remove_peer() {
    local public_key="$2"

    if [[ -z "$public_key" ]]; then
        echo "Error: public_key cannot be blank." >&2
        exit 1
    fi

    echo "Removing peer from WireGuard interface..."

    wg set "$WG_INTERFACE" peer "$public_key" remove

    echo "Peer removed live. Restarting interface to save into config..."
    wg-quick down "$WG_INTERFACE" || true
    wg-quick up "$WG_INTERFACE"

    echo "Peer removed and config saved."
}

install() {
    dependencies
    generate_keys

    local given_address="${2:-}"

    if [[ -f "$ADDRESS_FILE" ]]; then
        local saved_address
        saved_address=$(<"$ADDRESS_FILE")

        if [[ -n "$given_address" ]]; then
            # Check that given address contains a "/" character
            if [[ "$given_address" != */* ]]; then
                echo "Error: Address must include a CIDR subnet (example: 10.10.0.1/24)" >&2
                exit 1
            fi

            if [[ "$given_address" != "$saved_address" ]]; then
                echo "Error: Address already exists at $ADDRESS_FILE and differs from the given one."
                echo "Saved:   $saved_address"
                echo "Given:   $given_address"
                echo ""
                echo "If you want to change the WireGuard address, you must uninstall first:"
                echo ""
                echo "    ./wireguard_p2p.sh uninstall"
                echo ""
                exit 1
            fi
            # Addresses match; continue normally
        fi

        WG_ADDRESS="$saved_address"
        echo "Loaded existing address: $WG_ADDRESS"
    else
        if [[ -z "$given_address" ]]; then
            echo "Error: No address specified and no existing address file." >&2
            echo "Usage: $0 install <your-address-in-cidr>"
            exit 1
        fi

        # Check that given address contains a "/" character
        if [[ "$given_address" != */* ]]; then
            echo "Error: Address must include a CIDR subnet (example: 10.10.0.1/24)" >&2
            exit 1
        fi

        WG_ADDRESS="$given_address"
        mkdir -p "$WG_CONFIG_DIR"
        echo "$WG_ADDRESS" | tee "$ADDRESS_FILE" > /dev/null
        echo "Saved address to $ADDRESS_FILE"
    fi

    echo "Ensuring WireGuard service is fully stopped before reinstalling..."
    wg-quick down "$WG_INTERFACE" || true
    systemctl stop "$SERVICE_NAME" || true

    create_base_config

    echo "Bringing up WireGuard interface..."
    systemctl enable "$SERVICE_NAME"
    systemctl start "$SERVICE_NAME"

    echo "WireGuard installed and service started."

    get_add_peer_command
}

uninstall() {
    echo "Stopping and disabling service..."
    systemctl stop "$SERVICE_NAME" || true
    systemctl disable "$SERVICE_NAME" || true
    wg-quick down ${WG_INTERFACE} 2>/dev/null || true

    echo "Removing config and keys..."
    rm -f "$CONFIG_FILE" "$PRIVATE_KEY_FILE" "$PUBLIC_KEY_FILE" "$ADDRESS_FILE"

    echo "WireGuard config and keys removed."
}

status() {
    systemctl status "$SERVICE_NAME"
    echo
    wg
}

start() {
    echo "Starting WireGuard service..."
    systemctl start "$SERVICE_NAME"
}

stop() {
    echo "Stopping WireGuard service..."
    systemctl stop "$SERVICE_NAME"
}

help() {
    cat <<EOF
Usage: $0 <command>

Commands:
  dependencies                       Install required packages.
  install <address-cidr>             Install and configure WireGuard. Required first time.
  uninstall                          Remove WireGuard configuration and keys.
  status                             Show the WireGuard service status.
  start                              Start the WireGuard service.
  stop                               Stop the WireGuard service.
  import-key PRIVATE_KEY             Import a private key instead of generating one.
  add-peer NAME ENDPOINT PUBLIC_KEY  Add peer live and auto-save into config.
  remove-peer PUBLIC_KEY             Remove peer live and auto-save into config.
  provision-peer NAME ENDPOINT ADDRESS    Provision keys and .conf for a new peer.
  help                               Show this help message.

Make sure required environment variables are set before running.
EOF
}

import_key() {
    local private_key="$2"
    if [[ -z "$private_key" ]]; then
        echo "Error: private_key cannot be blank." >&2
        exit 1
    fi

    echo "Importing provided private key..."
    mkdir -p "$WG_CONFIG_DIR"
    chmod 700 "$WG_CONFIG_DIR"
    echo "$private_key" | tee "$PRIVATE_KEY_FILE" > /dev/null
    echo "$private_key" | wg pubkey | tee "$PUBLIC_KEY_FILE" > /dev/null
    echo "Key imported."
}

provision_peer() {
    shift
    load_address
    if [[ $# -ne 3 ]]; then
      echo "Usage: $0 provision-peer <name> <endpoint> <address/CIDR>" >&2
      exit 1
    fi


    local name="$1"
    local endpoint="$2"     # e.g. alice.example.com:51820
    local peer_cidr="$3"    # e.g. 10.15.0.4/32

    if [[ "$endpoint" != *:* ]]; then
        echo "Error: endpoint must be in host:port form" >&2
        exit 1
    fi
    local peer_host="${endpoint%:*}"
    local peer_port="${endpoint##*:}"

    local public_ip=$(curl -s ifconfig.me)

    # Directory for the new peer’s artifacts
    local dir="$WG_CONFIG_DIR/provisioned-peers"
    mkdir -p "$dir"
    chmod 700 "$dir"

    # Generate a fresh keypair
    umask 077
    wg genkey | tee "$dir/${name}.priv" \
      | wg pubkey > "$dir/${name}.pub"

    local priv="$dir/${name}.priv"
    local pub="$(<"$dir/${name}.pub")"
    local conf="$dir/${name}.conf"

    # Build the turnkey .conf
    {
      echo "[Interface]"
      echo "PrivateKey = $(<"$priv")"
      echo "Address    = $peer_cidr"
      echo "ListenPort = $peer_port"
      echo
      echo "[Peer]"
      echo "PublicKey           = $(<"$PUBLIC_KEY_FILE")"
      echo "Endpoint            = $public_ip:$WG_PORT"
      echo "AllowedIPs          = $WG_ADDRESS"
      echo "PersistentKeepalive = 25"
    } > "$conf"
    chmod 600 "$conf"

    # Print the add-peer command for THIS node
    cat <<EOF

▶️  Provisioned peer bundle for '$name':
    • $conf

▶️  To stitch '$name' into this node’s mesh, run:

./wireguard_p2p.sh add-peer \\
    $name \\
    $endpoint \\
    $pub \\
    $peer_cidr

Now copy ${name}.conf to your client, import & activate, then run the above add-peer command here.
EOF
}


main() {
    if [[ $EUID -ne 0 ]]; then
        echo "Error: This script must be run as root." >&2
        exit 1
    fi

    if [[ $# -eq 0 ]]; then
        help
        exit 0
    fi

    case "$1" in
        dependencies) dependencies ;;
        install) install "$@" ;;
        uninstall) uninstall ;;
        status) status ;;
        start) start ;;
        stop) stop ;;
        import-key)
            if [[ $# -ne 2 ]]; then
                echo "Usage: $0 import-key <private_key>" >&2
                exit 1
            fi
            import_key "$@"
            ;;
        add-peer)
            if [[ $# -eq 1 ]]; then
                get_add_peer_command
                exit 0
            elif [[ $# -ne 5 ]]; then
                echo "Usage: $0 add-peer <name> <endpoint> <public_key> <peer_ip>" >&2
                exit 1
            fi
            add_peer "$@"
            ;;
        remove-peer)
            if [[ $# -ne 2 ]]; then
                echo "Usage: $0 remove-peer <public_key>" >&2
                exit 1
            fi
            remove_peer "$@"
            ;;
        provision-peer)
            provision_peer "$@"
            ;;
        help|-h|--help) help ;;
        *)
            echo "Unknown command: $1" >&2
            help
            exit 1
            ;;
    esac
}

###################################
# Entrypoint                      #
###################################

main "$@"


You can discuss this blog on Matrix (Element): #blog-rymcg-tech:enigmacurry.com

This blog is copyright EnigmaCurry and dual-licensed CC-BY-SA and MIT. The source is on github: enigmacurry/blog.rymcg.tech and PRs are welcome. ❤️