Introduction

WireGuard is the modern VPN protocol — faster, simpler, and more secure than OpenVPN or IPsec. Ansible automates WireGuard deployment across your infrastructure: generate key pairs, template configurations, manage peers, configure firewall rules, and build full mesh networks — all from a single playbook.

Prerequisites

# WireGuard is built into Linux kernel 5.6+
# For older kernels, install the module:

# Ubuntu/Debian
sudo apt install wireguard

# RHEL/CentOS
sudo dnf install wireguard-tools

Key Concepts

┌──────────────┐    WireGuard tunnel    ┌──────────────┐
│  Server       │◄─────────────────────►│  Client      │
│  10.0.0.1/24  │    UDP port 51820     │  10.0.0.2/24 │
│  PublicKey: A  │                       │  PublicKey: B │
│  ListenPort:  │                       │              │
│  51820        │                       │              │
└──────────────┘                        └──────────────┘

Each peer has:

  • Private key — kept secret, never shared
  • Public key — derived from private key, shared with peers
  • Allowed IPs — which traffic routes through this peer

Point-to-Site VPN (Road Warrior)

Generate Keys

---
- name: Generate WireGuard keys
  hosts: vpn_server
  become: true
  tasks:
    - name: Install WireGuard
      ansible.builtin.package:
        name: wireguard-tools
        state: present

    - name: Generate server private key
      ansible.builtin.shell: wg genkey
      register: server_private_key
      changed_when: false
      no_log: true

    - name: Derive server public key
      ansible.builtin.shell: "echo '{{ server_private_key.stdout }}' | wg pubkey"
      register: server_public_key
      changed_when: false

    - name: Generate client keys
      ansible.builtin.shell: |
        PRIV=$(wg genkey)
        PUB=$(echo "$PRIV" | wg pubkey)
        PSK=$(wg genpsk)
        echo "$PRIV|$PUB|$PSK"
      register: client_keys
      changed_when: false
      loop: "{{ wireguard_clients }}"
      no_log: true

Configure Server

- name: Deploy WireGuard server config
  hosts: vpn_server
  become: true
  vars:
    wg_interface: wg0
    wg_port: 51820
    wg_network: 10.100.0.0/24
    wg_server_ip: 10.100.0.1/24
    server_public_ip: vpn.example.com
    wireguard_clients:
      - { name: laptop, ip: "10.100.0.2/32" }
      - { name: phone, ip: "10.100.0.3/32" }
      - { name: tablet, ip: "10.100.0.4/32" }
  tasks:
    - name: Deploy server config
      ansible.builtin.template:
        src: wg-server.conf.j2
        dest: /etc/wireguard/{{ wg_interface }}.conf
        mode: '0600'
      notify: restart wireguard

    - name: Enable IP forwarding
      ansible.posix.sysctl:
        name: net.ipv4.ip_forward
        value: '1'
        sysctl_set: true
        reload: true

    - name: Allow WireGuard port
      ansible.posix.firewalld:
        port: "{{ wg_port }}/udp"
        permanent: true
        state: enabled
        immediate: true

    - name: Enable WireGuard service
      ansible.builtin.service:
        name: "wg-quick@{{ wg_interface }}"
        state: started
        enabled: true

  handlers:
    - name: restart wireguard
      ansible.builtin.service:
        name: "wg-quick@{{ wg_interface }}"
        state: restarted
# templates/wg-server.conf.j2
[Interface]
Address = {{ wg_server_ip }}
ListenPort = {{ wg_port }}
PrivateKey = {{ server_private_key }}
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

{% for client in wireguard_clients %}
# {{ client.name }}
[Peer]
PublicKey = {{ client.public_key }}
PresharedKey = {{ client.psk }}
AllowedIPs = {{ client.ip }}

{% endfor %}

Generate Client Configs

- name: Generate client configs
  ansible.builtin.template:
    src: wg-client.conf.j2
    dest: "/etc/wireguard/clients/{{ item.name }}.conf"
    mode: '0600'
  loop: "{{ wireguard_clients }}"
# templates/wg-client.conf.j2
[Interface]
Address = {{ item.ip }}
PrivateKey = {{ item.private_key }}
DNS = 1.1.1.1, 1.0.0.1

[Peer]
PublicKey = {{ server_public_key }}
PresharedKey = {{ item.psk }}
Endpoint = {{ server_public_ip }}:{{ wg_port }}
AllowedIPs = 0.0.0.0/0    # Route all traffic through VPN
# AllowedIPs = 10.0.0.0/8  # Split tunnel — only private networks
PersistentKeepalive = 25

Site-to-Site VPN

---
- name: Site-to-site WireGuard VPN
  hosts: vpn_gateways
  become: true
  vars:
    sites:
      site_a:
        wg_ip: 10.200.0.1/30
        public_ip: 203.0.113.1
        local_network: 10.0.1.0/24
      site_b:
        wg_ip: 10.200.0.2/30
        public_ip: 198.51.100.1
        local_network: 10.0.2.0/24
  tasks:
    - name: Deploy WireGuard config
      ansible.builtin.template:
        src: wg-site.conf.j2
        dest: /etc/wireguard/wg0.conf
        mode: '0600'
      notify: restart wireguard

    - name: Enable IP forwarding
      ansible.posix.sysctl:
        name: net.ipv4.ip_forward
        value: '1'
        sysctl_set: true
# templates/wg-site.conf.j2 (for site_a)
[Interface]
Address = {{ sites[site_name].wg_ip }}
ListenPort = 51820
PrivateKey = {{ vault_wg_private_key }}

[Peer]
PublicKey = {{ peer_public_key }}
Endpoint = {{ peer_public_ip }}:51820
AllowedIPs = {{ peer_wg_ip }}, {{ peer_local_network }}
PersistentKeepalive = 25

Full Mesh VPN

For connecting all servers directly to each other:

---
- name: Deploy WireGuard full mesh
  hosts: all
  become: true
  vars:
    wg_port: 51820
    wg_network: "10.200.0"
  tasks:
    - name: Assign mesh IP
      ansible.builtin.set_fact:
        wg_ip: "{{ wg_network }}.{{ groups['all'].index(inventory_hostname) + 1 }}/24"

    - name: Deploy mesh config
      ansible.builtin.template:
        src: wg-mesh.conf.j2
        dest: /etc/wireguard/wg0.conf
        mode: '0600'
      notify: restart wireguard
# templates/wg-mesh.conf.j2
[Interface]
Address = {{ wg_ip }}
ListenPort = {{ wg_port }}
PrivateKey = {{ vault_wg_keys[inventory_hostname].private }}

{% for host in groups['all'] if host != inventory_hostname %}
# {{ host }}
[Peer]
PublicKey = {{ vault_wg_keys[host].public }}
Endpoint = {{ hostvars[host].ansible_host }}:{{ wg_port }}
AllowedIPs = {{ wg_network }}.{{ groups['all'].index(host) + 1 }}/32
PersistentKeepalive = 25

{% endfor %}

Manage Peers Dynamically

Add a Peer

- name: Add new peer
  ansible.builtin.command: >
    wg set wg0 peer {{ new_peer_public_key }}
    allowed-ips {{ new_peer_ip }}/32
    preshared-key /tmp/psk
  notify: save wireguard config

- name: Save config
  ansible.builtin.command: wg-quick save wg0

Remove a Peer

- name: Remove peer
  ansible.builtin.command: >
    wg set wg0 peer {{ removed_peer_public_key }} remove
  notify: save wireguard config

Monitor WireGuard

- name: Show WireGuard status
  ansible.builtin.command: wg show wg0
  register: wg_status
  changed_when: false

- name: Check peer handshakes
  ansible.builtin.shell: |
    wg show wg0 latest-handshakes | while read key handshake; do
      if [ "$handshake" -lt "$(date -d '5 minutes ago' +%s)" ] 2>/dev/null; then
        echo "STALE: $key last handshake $(date -d @$handshake)"
      fi
    done
  register: stale_peers
  changed_when: false

Troubleshooting

No Handshake

  • Verify UDP port is open: ss -ulnp | grep 51820
  • Check firewall allows UDP 51820
  • Verify endpoint IP/DNS resolves correctly
  • Check public keys match on both sides

Can't Reach Remote Network

# Check routing
ip route show
# Verify forwarding
sysctl net.ipv4.ip_forward
# Check WireGuard interface
wg show wg0

Conclusion

Ansible automates WireGuard VPN deployment for any topology — point-to-site for remote access, site-to-site for office connectivity, or full mesh for server-to-server communication. Template configurations from inventory variables, manage keys with Ansible Vault, and dynamically add/remove peers. WireGuard's simplicity (one config file per interface) makes it a perfect fit for Ansible's template-based approach.