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.


Series Navigation: Building a VPS VPN Chain

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.

  1. Part 1: VPS Baseline: Install, Harden, and Disable Logs
  2. Part 2: Encrypted Vault for VPN Secrets and Residual Logs
  3. Part 3: Telegram Alerts Without Leaking the Exit IP
  4. Part 4: Ansible Playbook for Edge Hop Installation
  5. Part 5: VPN Chain and End-to-End Encryption Without Persisted Logs
  6. Part 6: VPN Failover Between VPS Nodes
  7. Part 7: Operational Tasks: Heal, Add, Remove, and Replace Hops

Inventory Structure

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).

Playbook Module Architecture

Organize provisioning into modular playbooks:

Playbook FilePurpose and Responsibilities
playbooks/vpn-node.yamlCore OS hardening, custom SSH port, journald volatile config, fail2ban, Docker installation
playbooks/vpn-node-vault.yamlLUKS2 file container creation, crypttab keyfile configuration, /vault bind mounts
playbooks/vpn-node-security.yamlTelegram login notifier, daily health cron job, optional dead-man wipe timer
playbooks/vpn-node-traffic.yamlvnstat daemon configuration and monthly quota alert enforcement
playbooks/vpn-node-vpn.yamlWireGuard host configuration and OpenVPN Docker server initialization

Execution Sequence for a Fresh VPS Node

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

Core Baseline Playbook Implementation

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

Tunnel Provisioning Notes

  • Client Bundle Generation: The OpenVPN container (kylemanna/openvpn:2.4) generates client .ovpn configuration 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.

SSH Port Migration Bootstrap Sequence

Because Ubuntu uses socket-activated SSH, changing ports requires precise ordering during initial bootstrapping:

  1. Copy your operator SSH key to the target server’s default bootstrap account (root or ubuntu).
  2. Add the ssh.socket drop-in configuration for custom port 28422, reload systemd, and confirm SSH access on 28422.
  3. Add the node to Ansible hosts.yaml referencing ansible_port: 28422.
  4. Run the Ansible playbooks.

Warning: Re-ordering step 2 and 3 can lock you out of the server when UFW activates.

Verifying Idempotence

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.