WireGuard VPN on Vultr with Terraform

Authors

Introduction

This guide will walk you through setting up a WireGuard VPN server on Vultr using Terraform. We'll create a complete infrastructure with IPv4 and IPv6 support, automated client configuration generation, and proper security settings.

You can also obtain Complete Terraform Configuration

Overview

We'll create:

  • A Vultr instance with public IPv4 and IPv6 running WireGuard
  • SSH key pair for secure access
  • Firewall rules for VPN and SSH traffic
  • WireGuard server and client configurations
  • Configurable number of clients.
  • Cloud-init template for automated server setup

Project Structure

.
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── cloud-init.yaml.tpl
└── client-config.tpl

Implementation Steps

1. Provider and Version Configuration

First, let's set up the required providers and versions:

# versions.tf
terraform {
  required_providers {
    vultr = {
      source  = "vultr/vultr"
      version = "~> 2.22"
    }
    // For generating WireGuard key pairs
    wireguard = {
      source  = "OJFord/wireguard"
      version = "0.3.1"
    }
    // For generating SSH key
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }
  required_version = ">= 1.0.0"
}

provider "vultr" {
  api_key = var.vultr_api_key
}

provider "wireguard" {}

2. Variables Definition

Define the necessary variables:

# variables.tf
variable "vultr_api_key" {
  description = "Vultr API Key"
  type        = string
  sensitive   = true
}

variable "region" {
  description = "Vultr region"
  type        = string
  default     = "sea"
}

variable "plan" {
  description = "Vultr plan ID"
  type        = string
  default     = "vc2-1c-1gb"
}

variable "os_id" {
  description = "Vultr OS ID"
  # 2284	Ubuntu 24.04 LTS x64			x64	ubuntu
  default = "2284"
}

variable "client_count" {
  description = "Number of WireGuard clients to create"
  type        = number
  default     = 1
}

variable "vpn_ipv4_cidr" {
  description = "IPv4 CIDR for WireGuard VPN"
  type        = string
  default     = "10.10.0.0/24"
}

variable "vpn_ipv6_cidr" {
  description = "IPv6 CIDR for WireGuard VPN"
  type        = string
  default     = "fd00:10:10::/64"
}

variable "use_ipv6_endpoint" {
  description = "Use IPv6 endpoint in client config, otherwise use IPv4"
  default     = true
}

variable "interface" {
  description = "Default network interface on the instance"
  default     = "enp1s0"
}

3. SSH Key Generation

Generate an SSH key pair for the instance:

# main.tf
resource "tls_private_key" "ssh" {
  algorithm = "ED25519"
}

resource "vultr_ssh_key" "vpn" {
  name    = "wireguard-vpn-key"
  ssh_key = tls_private_key.ssh.public_key_openssh
}

4. WireGuard Key Generation

Generate server and client keys:

# main.tf
resource "wireguard_asymmetric_key" "server" {}

resource "wireguard_asymmetric_key" "clients" {
  count = var.client_count
}

5. Cloud-init Template

Create a cloud-init template for automated server setup:

# cloud-init.yaml.tpl
#cloud-config
package_update: true
package_upgrade: false

packages:
  - wireguard
  - ufw

write_files:
  - path: /etc/wireguard/wg0.conf
    content: |
      [Interface]
      Address = ${server_ipv4_address},${server_ipv6_address}
      PrivateKey = ${server_private_key}
      ListenPort = 51820

      PostUp = ufw route allow in on wg0 out on ${interface}
      PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
      PostUp = iptables -t nat -A POSTROUTING -o ${interface} -j MASQUERADE
      PostUp = ip6tables -A FORWARD -i wg0 -j ACCEPT
      PostUp = ip6tables -t nat -A POSTROUTING -o ${interface} -j MASQUERADE
      PostDown = ufw route delete allow in on wg0 out on ${interface}
      PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
      PostDown = iptables -t nat -D POSTROUTING -o ${interface} -j MASQUERADE
      PostDown = ip6tables -D FORWARD -i wg0 -j ACCEPT
      PostDown = ip6tables -t nat -D POSTROUTING -o ${interface} -j MASQUERADE

      ${peer_configs}
    owner: root:root
    permissions: '0600'

runcmd:
  - ufw allow 51820/udp
  - ufw allow OpenSSH
  - echo "y" | ufw enable
  - echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
  - echo "net.ipv6.conf.all.forwarding=1" >> /etc/sysctl.conf
  - sysctl -p
  - systemctl enable wg-quick@wg0
  - systemctl start wg-quick@wg0

6. Client Configuration Template

Create a template for client configurations:

# client-config.tpl
[Interface]
PrivateKey = ${client_private_key}
Address = ${client_ipv4_address},${client_ipv6_address}
DNS = 1.1.1.1, 2606:4700:4700::1111

[Peer]
PublicKey = ${server_public_key}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = ${server_public_ip}:51820
PersistentKeepalive = 25

7. Main Infrastructure

Create the Instance and Firewall:

# main.tf

# Calculate IP addresses for server and clients
locals {
  server_ipv4_address = cidrhost(var.vpn_ipv4_cidr, 1)
  server_ipv6_address = cidrhost(var.vpn_ipv6_cidr, 1)

  client_configs = [
    for idx in range(var.client_count) : {
      ipv4_address = cidrhost(var.vpn_ipv4_cidr, idx + 2)
      ipv6_address = cidrhost(var.vpn_ipv6_cidr, idx + 2)
      private_key  = wireguard_asymmetric_key.clients[idx].private_key
      public_key   = wireguard_asymmetric_key.clients[idx].public_key
    }
  ]

  # Indent string to keep cloud-init.yaml well formatted after interpolation
  peer_configs = indent(6, join("\n", [
    for client in local.client_configs : <<-EOT
    [Peer]
    PublicKey = ${client.public_key}
    AllowedIPs = ${client.ipv4_address}/32,${client.ipv6_address}/128
    EOT
  ]))
}

# Create firewall group
resource "vultr_firewall_group" "vpn" {
  description = "WireGuard VPN Firewall"
}

# Allow SSH
resource "vultr_firewall_rule" "ssh" {
  firewall_group_id = vultr_firewall_group.vpn.id
  protocol          = "tcp"
  ip_type           = "v4"
  subnet            = "0.0.0.0"
  subnet_size       = 0
  port              = "22"
}

# Allow WireGuard
resource "vultr_firewall_rule" "wireguard" {
  firewall_group_id = vultr_firewall_group.vpn.id
  protocol          = "udp"
  ip_type           = "v4"
  subnet            = "0.0.0.0"
  subnet_size       = 0
  port              = "51820"
}

# Allow IPv6 WireGuard
resource "vultr_firewall_rule" "wireguard_ipv6" {
  firewall_group_id = vultr_firewall_group.vpn.id
  protocol          = "udp"
  ip_type           = "v6"
  subnet            = "::"
  subnet_size       = 0
  port              = "51820"
}

data "template_file" "cloud_init" {
  template = file("cloud-init.yaml.tpl")

  vars = {
    server_private_key  = wireguard_asymmetric_key.server.private_key
    server_ipv4_address = "${local.server_ipv4_address}/32"
    server_ipv6_address = "${local.server_ipv6_address}/128"
    peer_configs        = local.peer_configs
    interface           = var.interface
  }
}

# Create Vultr instance
resource "vultr_instance" "wireguard" {
  plan              = var.plan
  region            = var.region
  os_id             = var.os_id
  tags              = ["wireguard", "vpn"]
  enable_ipv6       = true
  firewall_group_id = vultr_firewall_group.vpn.id
  ssh_key_ids       = [vultr_ssh_key.vpn.id]
  user_data         = data.template_file.cloud_init.rendered
}

8. Output Configurations

Generate output for client configurations:

# outputs.tf
# Creates ready to use local files for client configurations.
# Generate QR code on Linux:
# cat clientN.conf | qrencode -t ansiutf8
resource "local_file" "client_configs" {
  count = var.client_count

  content = templatefile("client-config.tpl", {
    client_private_key  = wireguard_asymmetric_key.clients[count.index].private_key
    client_ipv4_address = local.client_configs[count.index].ipv4_address
    client_ipv6_address = local.client_configs[count.index].ipv6_address
    server_public_key   = wireguard_asymmetric_key.server.public_key
    server_public_ip = (var.use_ipv6_endpoint ?
      "[${vultr_instance.wireguard.v6_main_ip}]" :
    vultr_instance.wireguard.main_ip)
  })

  filename = "client${count.index + 1}.conf"
}

# Get ssh private key `terraform output -raw ssh_private_key > wireguard_ssh_key`
# chmod 400 wireguard_ssh_key
# ssh -i wireguard_ssh_key root@<ip-address>
output "ssh_private_key" {
  value     = tls_private_key.ssh.private_key_openssh
  sensitive = true
}

output "server_public_ipv4" {
  value = vultr_instance.wireguard.main_ip
}

output "server_public_ipv6" {
  value = vultr_instance.wireguard.v6_main_ip
}

Usage Instructions

  1. Create a terraform.tfvars file with your Vultr API Key:
vultr_api_key = "your-api-key-here"
  1. Initialize and apply the Terraform configuration:
terraform init
terraform apply
  1. Connect Client

Client configurations will be generated in the current directory as client1.conf, client2.conf, etc. For mobile devices, create QR code:

cat client1.conf | qrencode -t ansiutf8
  1. SSH into the server:
# Save the SSH private key
terraform output -raw ssh_private_key > wireguard-ssh-key
chmod 400 wireguard-ssh-key

# Connect to the server
ssh -i wireguard-ssh-key root@$(terraform output -raw server_public_ipv4)

Complete Terraform Configuration

Here's all the code in a single block:

# versions.tf
terraform {
  required_providers {
    vultr = {
      source  = "vultr/vultr"
      version = "~> 2.22"
    }
    // For generating WireGuard key pairs
    wireguard = {
      source  = "OJFord/wireguard"
      version = "0.3.1"
    }
    // For generating SSH key
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }
  required_version = ">= 1.0.0"
}

provider "vultr" {
  api_key = var.vultr_api_key
}

provider "wireguard" {}


# variables.tf
variable "vultr_api_key" {
  description = "Vultr API Key"
  type        = string
  sensitive   = true
}

variable "region" {
  description = "Vultr region"
  type        = string
  default     = "sea"
}

variable "plan" {
  description = "Vultr plan ID"
  type        = string
  default     = "vc2-1c-1gb"
}

variable "os_id" {
  description = "Vultr OS ID"
  # 2284	Ubuntu 24.04 LTS x64			x64	ubuntu
  default = "2284"
}

variable "client_count" {
  description = "Number of WireGuard clients to create"
  type        = number
  default     = 1
}

variable "vpn_ipv4_cidr" {
  description = "IPv4 CIDR for WireGuard VPN"
  type        = string
  default     = "10.10.0.0/24"
}

variable "vpn_ipv6_cidr" {
  description = "IPv6 CIDR for WireGuard VPN"
  type        = string
  default     = "fd00:10:10::/64"
}

variable "use_ipv6_endpoint" {
  description = "Use IPv6 endpoint in client config, otherwise use IPv4"
  default     = true
}

variable "interface" {
  description = "Default network interface on the instance"
  default     = "enp1s0"
}


# main.tf
# SSH Key Generation
resource "tls_private_key" "ssh" {
  algorithm = "ED25519"
}

resource "vultr_ssh_key" "vpn" {
  name    = "wireguard-vpn-key"
  ssh_key = tls_private_key.ssh.public_key_openssh
}

# WireGuard Key Generation
resource "wireguard_asymmetric_key" "server" {}

resource "wireguard_asymmetric_key" "clients" {
  count = var.client_count
}

# Calculate IP addresses for server and clients
locals {
  server_ipv4_address = cidrhost(var.vpn_ipv4_cidr, 1)
  server_ipv6_address = cidrhost(var.vpn_ipv6_cidr, 1)

  client_configs = [
    for idx in range(var.client_count) : {
      ipv4_address = cidrhost(var.vpn_ipv4_cidr, idx + 2)
      ipv6_address = cidrhost(var.vpn_ipv6_cidr, idx + 2)
      private_key  = wireguard_asymmetric_key.clients[idx].private_key
      public_key   = wireguard_asymmetric_key.clients[idx].public_key
    }
  ]

  # Indent string to keep cloud-init.yaml well formatted after interpolation
  peer_configs = indent(6, join("\n", [
    for client in local.client_configs : <<-EOT
    [Peer]
    PublicKey = ${client.public_key}
    AllowedIPs = ${client.ipv4_address}/32,${client.ipv6_address}/128
    EOT
  ]))
}

# Create firewall group
resource "vultr_firewall_group" "vpn" {
  description = "WireGuard VPN Firewall"
}

# Allow SSH
resource "vultr_firewall_rule" "ssh" {
  firewall_group_id = vultr_firewall_group.vpn.id
  protocol          = "tcp"
  ip_type           = "v4"
  subnet            = "0.0.0.0"
  subnet_size       = 0
  port              = "22"
}

# Allow WireGuard
resource "vultr_firewall_rule" "wireguard" {
  firewall_group_id = vultr_firewall_group.vpn.id
  protocol          = "udp"
  ip_type           = "v4"
  subnet            = "0.0.0.0"
  subnet_size       = 0
  port              = "51820"
}

# Allow IPv6 WireGuard
resource "vultr_firewall_rule" "wireguard_ipv6" {
  firewall_group_id = vultr_firewall_group.vpn.id
  protocol          = "udp"
  ip_type           = "v6"
  subnet            = "::"
  subnet_size       = 0
  port              = "51820"
}

data "template_file" "cloud_init" {
  template = file("cloud-init.yaml.tpl")

  vars = {
    server_private_key  = wireguard_asymmetric_key.server.private_key
    server_ipv4_address = "${local.server_ipv4_address}/32"
    server_ipv6_address = "${local.server_ipv6_address}/128"
    peer_configs        = local.peer_configs
    interface           = var.interface
  }
}

# Create Vultr instance
resource "vultr_instance" "wireguard" {
  plan              = var.plan
  region            = var.region
  os_id             = var.os_id
  tags              = ["wireguard", "vpn"]
  enable_ipv6       = true
  firewall_group_id = vultr_firewall_group.vpn.id
  ssh_key_ids       = [vultr_ssh_key.vpn.id]
  user_data         = data.template_file.cloud_init.rendered
}


# outputs.tf
# Creates ready to use local files for client configurations.
# Generate QR code on Linux:
# cat clientN.conf | qrencode -t ansiutf8
resource "local_file" "client_configs" {
  count = var.client_count

  content = templatefile("client-config.tpl", {
    client_private_key  = wireguard_asymmetric_key.clients[count.index].private_key
    client_ipv4_address = local.client_configs[count.index].ipv4_address
    client_ipv6_address = local.client_configs[count.index].ipv6_address
    server_public_key   = wireguard_asymmetric_key.server.public_key
    server_public_ip = (var.use_ipv6_endpoint ?
      "[${vultr_instance.wireguard.v6_main_ip}]" :
    vultr_instance.wireguard.main_ip)
  })

  filename = "client${count.index + 1}.conf"
}

# Get ssh private key `terraform output -raw ssh_private_key > wireguard_ssh_key`
# chmod 400 wireguard_ssh_key
# ssh -i wireguard_ssh_key root@<ip-address>
output "ssh_private_key" {
  value     = tls_private_key.ssh.private_key_openssh
  sensitive = true
}

output "server_public_ipv4" {
  value = vultr_instance.wireguard.main_ip
}

output "server_public_ipv6" {
  value = vultr_instance.wireguard.v6_main_ip
}


# cloud-init.yaml.tpl
#cloud-config
package_update: true
package_upgrade: false

packages:
  - wireguard
  - ufw

write_files:
  - path: /etc/wireguard/wg0.conf
    content: |
      [Interface]
      Address = ${server_ipv4_address},${server_ipv6_address}
      PrivateKey = ${server_private_key}
      ListenPort = 51820

      PostUp = ufw route allow in on wg0 out on ${interface}
      PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
      PostUp = iptables -t nat -A POSTROUTING -o ${interface} -j MASQUERADE
      PostUp = ip6tables -A FORWARD -i wg0 -j ACCEPT
      PostUp = ip6tables -t nat -A POSTROUTING -o ${interface} -j MASQUERADE
      PostDown = ufw route delete allow in on wg0 out on ${interface}
      PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
      PostDown = iptables -t nat -D POSTROUTING -o ${interface} -j MASQUERADE
      PostDown = ip6tables -D FORWARD -i wg0 -j ACCEPT
      PostDown = ip6tables -t nat -D POSTROUTING -o ${interface} -j MASQUERADE

      ${peer_configs}
    owner: root:root
    permissions: '0600'

runcmd:
  - ufw allow 51820/udp
  - ufw allow OpenSSH
  - echo "y" | ufw enable
  - echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
  - echo "net.ipv6.conf.all.forwarding=1" >> /etc/sysctl.conf
  - sysctl -p
  - systemctl enable wg-quick@wg0
  - systemctl start wg-quick@wg0


# client-config.tpl
[Interface]
PrivateKey = ${client_private_key}
Address = ${client_ipv4_address},${client_ipv6_address}
DNS = 1.1.1.1, 2606:4700:4700::1111

[Peer]
PublicKey = ${server_public_key}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = ${server_public_ip}:51820
PersistentKeepalive = 25