Ansible Playbook for VPN Hop Installation
TL;DR: Provisioning VPN nodes manually is prone to configuration drift. This article presents a structured, idempotent Ansible architecture that deploys hardened baselines, LUKS vaults, Telegram alerts, WireGuard, and OpenVPN. Re-running the playbooks on a healthy node reports changed=0.
This article is Part 4 of a 7-part series on building a multi-hop, fail-closed VPS VPN chain with Ansible, LUKS encrypted vaults, Telegram monitoring, and zero-log policy routing.
- Part 1: VPS Baseline: Install, Harden, and Disable Logs
- Part 2: Encrypted Vault for VPN Secrets and Residual Logs
- Part 3: Telegram Alerts Without Leaking the Exit IP
- Part 4: Ansible Playbook for Edge Hop Installation
- Part 5: VPN Chain and End-to-End Encryption Without Persisted Logs
- Part 6: VPN Failover Between VPS Nodes
- Part 7: Operational Tasks: Heal, Add, Remove, and Replace Hops
Define VPN nodes with distinct non-standard SSH ports and dedicated private subnets to prevent network collisions when chaining hops:
# hosts.yaml
vpn-nodes:
vars:
ansible_user: vpnop
ansible_ssh_private_key_file: keys/vpn-operator
ansible_ssh_common_args: "-o IdentitiesOnly=yes"
hosts:
vpn-1:
ansible_host: 203.0.113.10
ansible_port: 28422
openvpn_subnet: "10.100.0.0/24"
wireguard_port: 51820
vpn-2:
ansible_host: 203.0.113.20
ansible_port: 41922
openvpn_subnet: "10.200.0.0/24"
wireguard_port: 51821
Rule: Each hop in the chain must use an independent internal VPN subnet (10.100.0.0/24, 10.200.0.0/24).
Organize provisioning into modular playbooks:
| Playbook File | Purpose and Responsibilities |
|---|---|
playbooks/vpn-node.yaml | Core OS hardening, custom SSH port, journald volatile config, fail2ban, Docker installation |
playbooks/vpn-node-vault.yaml | LUKS2 file container creation, crypttab keyfile configuration, /vault bind mounts |
playbooks/vpn-node-security.yaml | Telegram login notifier, daily health cron job, optional dead-man wipe timer |
playbooks/vpn-node-traffic.yaml | vnstat daemon configuration and monthly quota alert enforcement |
playbooks/vpn-node-vpn.yaml | WireGuard host configuration and OpenVPN Docker server initialization |
Execute playbooks sequentially when bootstrapping a new node:
# Step 1: Deploy baseline OS hardening & non-standard SSH port
ansible-playbook -i hosts.yaml playbooks/vpn-node.yaml \
-e target_vpn=vpn-1
# Step 2: Initialize encrypted LUKS vault and bind mounts
ansible-playbook -i hosts.yaml playbooks/vpn-node-vault.yaml \
-e target_vpn=vpn-1
# Step 3: Deploy Telegram alerting hooks (suspend dead-man wipe during setup)
ansible-playbook -i hosts.yaml playbooks/vpn-node-security.yaml \
-e target_vpn=vpn-1 \
-e deadman_enabled=false
# Step 4: Configure bandwidth monitoring
ansible-playbook -i hosts.yaml playbooks/vpn-node-traffic.yaml \
-e target_vpn=vpn-1
# Step 5: Configure WireGuard and OpenVPN services
ansible-playbook -i hosts.yaml playbooks/vpn-node-vpn.yaml \
-e target_vpn=vpn-1
The main baseline playbook enforces system lock-down and zero-log directives:
# playbooks/vpn-node.yaml
- hosts: "{{ target_vpn | default('vpn-nodes') }}"
become: true
vars:
vpn_ssh_port: "{{ ansible_port | default(28422) }}"
vpn_reduce_logs: true
tasks:
- name: Ensure custom SSH socket drop-in directory exists
file:
path: /etc/systemd/system/ssh.socket.d
state: directory
mode: '0755'
- name: Configure systemd SSH socket to listen on non-standard port
copy:
dest: /etc/systemd/system/ssh.socket.d/zz-vpn-port.conf
content: |
[Socket]
ListenStream=
ListenStream={{ vpn_ssh_port }}
notify: restart ssh socket
- name: Deploy fail2ban configuration for systemd backend
copy:
dest: /etc/fail2ban/jail.d/sshd-vpn.conf
content: |
[sshd]
enabled = true
backend = systemd
maxretry = 5
port = {{ vpn_ssh_port }}
notify: restart fail2ban
- name: Disable UFW firewall packet logging
command: ufw logging off
when: vpn_reduce_logs | bool
changed_when: false
- name: Configure systemd-journald to volatile RAM storage
copy:
dest: /etc/systemd/journald.conf.d/volatile.conf
content: |
[Journal]
Storage=volatile
ForwardToSyslog=no
RuntimeMaxUse=64M
notify: restart journald
when: vpn_reduce_logs | bool
- name: Disable and stop rsyslog service
service:
name: rsyslog
state: stopped
enabled: false
when: vpn_reduce_logs | bool
ignore_errors: true
handlers:
- name: restart ssh socket
systemd:
name: ssh.socket
state: restarted
daemon_reload: true
- name: restart fail2ban
service:
name: fail2ban
state: restarted
- name: restart journald
service:
name: systemd-journald
state: restarted
- Client Bundle Generation: The OpenVPN container (
kylemanna/openvpn:2.4) generates client.ovpnconfiguration profiles on first run. - Client Configuration Retrieval: Fetch generated client profiles over SSH specifying custom ports:
# Download client bundles using custom SSH ports
scp -P 28422 [email protected]:/opt/openvpn-vpn/clients/client1.ovpn ./configs/vpn1-client.ovpn
scp -P 41922 [email protected]:/opt/openvpn-vpn/clients/client1.ovpn ./configs/vpn2-client.ovpn
- OpenVPN 2.4 Container Choice: We use OpenVPN 2.4 in Docker because OpenVPN 2.6 DCO (Data Channel Offload) can experience TCP packet drop bugs when encapsulating TCP-in-TCP connections.
Because Ubuntu uses socket-activated SSH, changing ports requires precise ordering during initial bootstrapping:
- Copy your operator SSH key to the target server’s default bootstrap account (
rootorubuntu). - Add the
ssh.socketdrop-in configuration for custom port28422, reload systemd, and confirm SSH access on28422. - Add the node to Ansible
hosts.yamlreferencingansible_port: 28422. - Run the Ansible playbooks.
Warning: Re-ordering step 2 and 3 can lock you out of the server when UFW activates.
Run the entire playbook suite twice against the same host:
ansible-playbook -i hosts.yaml playbooks/vpn-node.yaml -e target_vpn=vpn-1
The second run must return changed=0, failed=0.
Next in the series: Part 5: VPN Chain and End-to-End Encryption Without Persisted Logs - Linking edge nodes into a multi-hop policy-routed tunnel with fail-closed blackhole routing.