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
Related Articles
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.