Introduction

WireGuard is a modern VPN protocol — fast, simple, and cryptographically sound. Its configuration is just a single file per interface, making it perfect for Ansible automation. Deploy site-to-site tunnels, remote access VPNs, or full mesh networks with key generation, peer configuration, and routing all managed as code.

Generate Key Pairs

---
- name: Generate WireGuard keys on all nodes
  hosts: wireguard_nodes
  become: true
  tasks:
    - name: Install WireGuard
      ansible.builtin.package:
        name: wireguard
        state: present

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

    - name: Derive public key
      ansible.builtin.shell: "echo '{{ wg_private_key.stdout }}' | wg pubkey"
      register: wg_public_key
      changed_when: false
      no_log: true

    - name: Save private key
      ansible.builtin.copy:
        content: "{{ wg_private_key.stdout }}"
        dest: /etc/wireguard/private.key
        mode: '0600'
      no_log: true

    - name: Set facts for peer config
      ansible.builtin.set_fact:
        wireguard_public_key: "{{ wg_public_key.stdout }}"
        wireguard_private_key: "{{ wg_private_key.stdout }}"
      no_log: true

Site-to-Site VPN

---
- name: Configure WireGuard site-to-site
  hosts: wireguard_nodes
  become: true
  vars:
    wg_interface: wg0
    wg_port: 51820
    wg_network: 10.100.0.0/24
  tasks:
    - name: Deploy WireGuard config
      ansible.builtin.template:
        src: wg0.conf.j2
        dest: "/etc/wireguard/{{ wg_interface }}.conf"
        mode: '0600'
      notify: restart wireguard

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

    - name: Enable WireGuard interface
      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

Config Template

# templates/wg0.conf.j2
[Interface]
PrivateKey = {{ wireguard_private_key }}
Address = {{ wg_address }}/24
ListenPort = {{ wg_port }}
{% if wg_post_up is defined %}
PostUp = {{ wg_post_up }}
PostDown = {{ wg_post_down }}
{% endif %}

{% for peer in groups['wireguard_nodes'] %}
{% if peer != inventory_hostname %}
[Peer]
PublicKey = {{ hostvars[peer].wireguard_public_key }}
Endpoint = {{ hostvars[peer].ansible_host }}:{{ wg_port }}
AllowedIPs = {{ hostvars[peer].wg_address }}/32{% if hostvars[peer].wg_routes is defined %}, {{ hostvars[peer].wg_routes | join(', ') }}{% endif %}

PersistentKeepalive = 25
{% endif %}
{% endfor %}

Inventory

# host_vars/site-a.yml
wg_address: 10.100.0.1
wg_routes:
  - 192.168.1.0/24
wg_post_up: "iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE"
wg_post_down: "iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE"

# host_vars/site-b.yml
wg_address: 10.100.0.2
wg_routes:
  - 192.168.2.0/24

Remote Access VPN (Road Warriors)

# Server configuration
- name: Configure WireGuard VPN server
  hosts: vpn_server
  become: true
  vars:
    wg_server_address: 10.100.0.1
    wg_dns: 1.1.1.1
    vpn_clients:
      - { name: laptop, address: 10.100.0.10 }
      - { name: phone, address: 10.100.0.11 }
      - { name: tablet, address: 10.100.0.12 }
  tasks:
    - name: Generate client keys
      ansible.builtin.shell: |
        priv=$(wg genkey)
        pub=$(echo "$priv" | wg pubkey)
        echo "$priv $pub"
      register: client_keys
      loop: "{{ vpn_clients }}"
      changed_when: false
      no_log: true

    - name: Deploy server config with all peers
      ansible.builtin.template:
        src: wg-server.conf.j2
        dest: /etc/wireguard/wg0.conf
        mode: '0600'
      notify: restart wireguard

    - name: Generate client configs
      ansible.builtin.template:
        src: wg-client.conf.j2
        dest: "/etc/wireguard/clients/{{ item.0.name }}.conf"
        mode: '0600'
      loop: "{{ vpn_clients | zip(client_keys.results) | list }}"

    - name: Generate QR codes for mobile clients
      ansible.builtin.command: >
        qrencode -t ansiutf8 < /etc/wireguard/clients/{{ item.name }}.conf
      loop: "{{ vpn_clients }}"
      when: "'phone' in item.name or 'tablet' in item.name"
      changed_when: false

Full Mesh Network

# Automatically connect every node to every other node
# Inventory: assign sequential addresses
wireguard_nodes:
  node1: { wg_address: 10.100.0.1 }
  node2: { wg_address: 10.100.0.2 }
  node3: { wg_address: 10.100.0.3 }
  node4: { wg_address: 10.100.0.4 }

The wg0.conf.j2 template above already handles full mesh — each node gets a [Peer] block for every other node.

Monitoring

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

- name: Check peer connectivity
  ansible.builtin.command: "ping -c 3 {{ hostvars[item].wg_address }}"
  loop: "{{ groups['wireguard_nodes'] | difference([inventory_hostname]) }}"
  register: ping_results
  changed_when: false
  ignore_errors: true

- name: Report down peers
  ansible.builtin.debug:
    msg: "⚠️ Peer {{ item.item }} is unreachable via WireGuard"
  loop: "{{ ping_results.results }}"
  when: item.rc != 0

Troubleshooting

No Handshake

- name: Check WireGuard interface
  ansible.builtin.command: wg show {{ wg_interface }}
  register: wg_show
  changed_when: false

# If "latest handshake" is missing, check:
# 1. Firewall allows UDP port 51820
# 2. Endpoint address is correct
# 3. Keys match (public key on remote = your public key)

Enable IP Forwarding

- name: Enable IP forwarding
  ansible.posix.sysctl:
    name: "{{ item }}"
    value: '1'
    sysctl_set: true
    reload: true
  loop:
    - net.ipv4.ip_forward
    - net.ipv6.conf.all.forwarding

Conclusion

WireGuard's simplicity makes it ideal for Ansible automation — each peer is a [Peer] block in a config file, and Ansible templates it from inventory. Generate keys once, define addresses in host_vars, and the template builds the full mesh automatically. Use WireGuard for site-to-site tunnels between datacenters, remote access VPN for developers, or overlay networks for container communication. Adding a node is adding a host to the inventory.