WireGuard VPN on Hetzner Cloud with Terraform

Authors

Introduction

This guide will walk you through setting up a WireGuard VPN server on Hetzner Cloud 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:

  • Hetzner server 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 {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.49.0"
    }
    // 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 "hcloud" {
  token = var.hcloud_token
}

provider "wireguard" {}

2. Variables Definition

Define the necessary variables:

# variables.tf
variable "hcloud_token" {
  description = "Hetzner Cloud token"
  type        = string
  sensitive   = true
}

variable "location" {
  description = "Hetzner Datacenter location https://docs.hetzner.com/cloud/general/locations/#what-locations-are-there"
  type        = string
  default     = "fsn1"
}

variable "server_type" {
  description = "Server Type https://docs.hetzner.com/cloud/servers/overview#pricing"
  type        = string
  default     = "cax11"
}

variable "image" {
  description = "Name or ID of the image the server is created from"
  default     = "ubuntu-20.04"
}

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     = "eth0"
}

3. SSH Key Generation

Generate an SSH key pair for the instance:

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

resource "hcloud_ssh_key" "vpn" {
  name       = "wireguard-vpn-key"
  public_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 server 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
resource "hcloud_firewall" "vpn" {
  name = "wireguard-vpn"
  rule {
    direction = "in"
    protocol  = "udp"
    port      = "51820"
    source_ips = [
      "0.0.0.0/0",
      "::/0"
    ]
  }

  rule {
    direction = "in"
    protocol  = "tcp"
    port      = "22"
    source_ips = [
      "0.0.0.0/0",
      "::/0"
    ]
  }

  apply_to {
    server = hcloud_server.wireguard.id
  }
}

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 server
resource "hcloud_server" "wireguard" {
  name        = "wireguard-vpn"
  server_type = var.server_type
  image       = var.image
  location    = var.location
  ssh_keys    = [hcloud_ssh_key.vpn.id]
  public_net {
    ipv4_enabled = true
    ipv6_enabled = true
  }

  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 ?
      "[${hcloud_server.wireguard.ipv6_address}]" :
    hcloud_server.wireguard.ipv4_address)
  })

  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 = hcloud_server.wireguard.ipv4_address
}

output "server_public_ipv6" {
  value = hcloud_server.wireguard.ipv6_address
}

Usage Instructions

  1. Create a terraform.tfvars file with your Hetzner Cloud token:
hcloud_token = "your-token-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 {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.49.0"
    }
    // 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 "hcloud" {
  token = var.hcloud_token
}

provider "wireguard" {}


# variables.tf
variable "hcloud_token" {
  description = "Hetzner Cloud token"
  type        = string
  sensitive   = true
}

variable "location" {
  description = "Hetzner Datacenter location https://docs.hetzner.com/cloud/general/locations/#what-locations-are-there"
  type        = string
  default     = "fsn1"
}

variable "server_type" {
  description = "Server Type https://docs.hetzner.com/cloud/servers/overview#pricing"
  type        = string
  default     = "cax11"
}

variable "image" {
  description = "Name or ID of the image the server is created from"
  default     = "ubuntu-20.04"
}

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     = "eth0"
}



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

resource "hcloud_ssh_key" "vpn" {
  name       = "wireguard-vpn-key"
  public_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
resource "hcloud_firewall" "vpn" {
  name = "wireguard-vpn"
  rule {
    direction = "in"
    protocol  = "udp"
    port      = "51820"
    source_ips = [
      "0.0.0.0/0",
      "::/0"
    ]
  }

  rule {
    direction = "in"
    protocol  = "tcp"
    port      = "22"
    source_ips = [
      "0.0.0.0/0",
      "::/0"
    ]
  }

  apply_to {
    server = hcloud_server.wireguard.id
  }
}

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 server
resource "hcloud_server" "wireguard" {
  name        = "wireguard-vpn"
  server_type = var.server_type
  image       = var.image
  location    = var.location
  ssh_keys    = [hcloud_ssh_key.vpn.id]
  public_net {
    ipv4_enabled = true
    ipv6_enabled = true
  }

  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 ?
      "[${hcloud_server.wireguard.ipv6_address}]" :
    hcloud_server.wireguard.ipv4_address)
  })

  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 = hcloud_server.wireguard.ipv4_address
}

output "server_public_ipv6" {
  value = hcloud_server.wireguard.ipv6_address
}



# 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