WireGuard P2P VPN

Updated April 21, 2025 9 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. The only network requirement is that each host has the ability to make outbound UDP connections (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 greater than 25 seconds, 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

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.

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.
  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."
}

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 "$@"
            ;;
        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. ❤️