WireGuard VPN on GCP with Terraform

Authors

Table of Contents

Introduction

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

Overview

We'll create:

  • GCP instance with public IPv4 and IPv6 running WireGuard
  • SSH key pair for secure access
  • Network, dual-stack Subnetwork, and 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 {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.12.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 "google" {
  project = var.project_id
  region  = var.region
  zone    = var.zone
}

provider "wireguard" {}

2. Variables Definition

Define the necessary variables:

# variables.tf
variable "project_id" {
  description = "GCP Project ID"
  type        = string
}

variable "region" {
  description = "GCP region"
  type        = string
  default     = "us-central1"
}

variable "zone" {
  description = "GCP zone"
  type        = string
  default     = "us-central1-a"
}

variable "machine_type" {
  description = "GCP machine type"
  type        = string
  default     = "e2-micro"
}

variable "image" {
  description = "OS project and family "
  default     = "ubuntu-os-cloud/ubuntu-minimal-2404-lts-amd64"
}

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

variable "gcp_subnetwork_cidr" {
  description = "IPv4 CIDR for GCP subnetwork where instance will be hosted"
  default     = "10.240.0.0/24"
}

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

3. SSH Key Generation

Generate an SSH key pair for the instance:

# main.tf
resource "tls_private_key" "ssh" {
  algorithm = "RSA"
  rsa_bits  = 4096
}

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. Create Network Infrastructure

Create Network and Subnetwork with both IPv4 and IPv6 capabilities:

# main.tf

# VPC Network
resource "google_compute_network" "vpn_network" {
  name                    = "wireguard-network"
  auto_create_subnetworks = false
}

# Subnet
resource "google_compute_subnetwork" "vpn_subnet" {
  name          = "wireguard-subnet"
  ip_cidr_range = var.gcp_subnetwork_cidr
  network       = google_compute_network.vpn_network.id
  region        = var.region

  stack_type       = "IPV4_IPV6"
  ipv6_access_type = "EXTERNAL"
}

# Firewall rules
resource "google_compute_firewall" "wireguard_udp" {
  name    = "allow-wireguard"
  network = google_compute_network.vpn_network.name

  allow {
    protocol = "udp"
    ports    = ["51820"]
  }

  source_ranges = ["0.0.0.0/0"]
}

resource "google_compute_firewall" "ssh" {
  name    = "allow-ssh"
  network = google_compute_network.vpn_network.name

  allow {
    protocol = "tcp"
    ports    = ["22"]
  }

  source_ranges = ["0.0.0.0/0"]
}

resource "google_compute_firewall" "wireguard_udp_ipv6" {
  name    = "allow-wireguard-ipv6"
  network = google_compute_network.vpn_network.name

  allow {
    protocol = "udp"
    ports    = ["51820"]
  }

  source_ranges = ["::/0"]
}

resource "google_compute_firewall" "ssh_ipv6" {
  name    = "allow-ssh-ipv6"
  network = google_compute_network.vpn_network.name

  allow {
    protocol = "tcp"
    ports    = ["22"]
  }

  source_ranges = ["::/0"]
}

8. Create Instance

Create the instance:

# 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
  ]))
}

# Cloud-init template
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
  }
}

# Compute instance
resource "google_compute_instance" "wireguard" {
  name         = "wireguard-vpn"
  machine_type = var.machine_type
  zone         = var.zone

  boot_disk {
    initialize_params {
      image = var.image
    }
  }

  network_interface {
    stack_type = "IPV4_IPV6"
    subnetwork = google_compute_subnetwork.vpn_subnet.id
    access_config {}
    ipv6_access_config {
      network_tier = "PREMIUM"
    }
  }

  metadata = {
    ssh-keys  = "ubuntu:${tls_private_key.ssh.public_key_openssh}"
    user-data = data.template_file.cloud_init.rendered
  }

  can_ip_forward = true

  tags = ["wireguard-vpn"]
}

9. Output Configurations

Generate output for client configurations and SSH key:

# outputs.tf

locals {
  ipv6_address = google_compute_instance.wireguard.network_interface[0].ipv6_access_config[0].external_ipv6
  ipv4_address = google_compute_instance.wireguard.network_interface[0].access_config[0].nat_ip
}

# Creates ready to use local files for client configurations.
# Generate QR code on Linux:
# cat clientN.conf | qrencode -t ansiutf8
resource "local_sensitive_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 ?
      "[${local.ipv6_address}]" :
    local.ipv4_address)
  })

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

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

output "server_public_ipv4" {
  value = local.ipv4_address
}

output "server_public_ipv6" {
  value = local.ipv6_address
}

Usage Instructions

  1. Setup gcloud
project_id = "your-gcp-project-id"

After this hashicorp/google Terraform provider can use the credentials file created by login.

  1. Initialize and apply the Terraform configuration:
terraform init
terraform apply

After instance creation, wait for cloud init to complete the setup.

  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 ubuntu@$(terraform output -raw server_public_ipv4)

Complete Terraform Configuration

Here's all the code in a single block:

# versions.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.12.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 "google" {
  project = var.project_id
  region  = var.region
  zone    = var.zone
}

provider "wireguard" {}




# variables.tf
variable "project_id" {
  description = "GCP Project ID"
  type        = string
}

variable "region" {
  description = "GCP region"
  type        = string
  default     = "us-central1"
}

variable "zone" {
  description = "GCP zone"
  type        = string
  default     = "us-central1-a"
}

variable "machine_type" {
  description = "GCP machine type"
  type        = string
  default     = "e2-micro"
}

variable "image" {
  description = "OS project and family "
  default     = "ubuntu-os-cloud/ubuntu-minimal-2404-lts-amd64"
}

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

variable "gcp_subnetwork_cidr" {
  description = "IPv4 CIDR for GCP subnetwork where instance will be hosted"
  default     = "10.240.0.0/24"
}

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




# main.tf
# SSH Key Generation
resource "tls_private_key" "ssh" {
  algorithm = "RSA"
  rsa_bits  = 4096
}

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

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

# Create Network Infrastructure
# VPC Network
resource "google_compute_network" "vpn_network" {
  name                    = "wireguard-network"
  auto_create_subnetworks = false
}

# Subnet
resource "google_compute_subnetwork" "vpn_subnet" {
  name          = "wireguard-subnet"
  ip_cidr_range = var.gcp_subnetwork_cidr
  network       = google_compute_network.vpn_network.id
  region        = var.region

  stack_type       = "IPV4_IPV6"
  ipv6_access_type = "EXTERNAL"
}

# Firewall rules
resource "google_compute_firewall" "wireguard_udp" {
  name    = "allow-wireguard"
  network = google_compute_network.vpn_network.name

  allow {
    protocol = "udp"
    ports    = ["51820"]
  }

  source_ranges = ["0.0.0.0/0"]
}

resource "google_compute_firewall" "ssh" {
  name    = "allow-ssh"
  network = google_compute_network.vpn_network.name

  allow {
    protocol = "tcp"
    ports    = ["22"]
  }

  source_ranges = ["0.0.0.0/0"]
}

resource "google_compute_firewall" "wireguard_udp_ipv6" {
  name    = "allow-wireguard-ipv6"
  network = google_compute_network.vpn_network.name

  allow {
    protocol = "udp"
    ports    = ["51820"]
  }

  source_ranges = ["::/0"]
}

resource "google_compute_firewall" "ssh_ipv6" {
  name    = "allow-ssh-ipv6"
  network = google_compute_network.vpn_network.name

  allow {
    protocol = "tcp"
    ports    = ["22"]
  }

  source_ranges = ["::/0"]
}

# 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
  ]))
}

# Cloud-init template
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
  }
}

# Compute instance
resource "google_compute_instance" "wireguard" {
  name         = "wireguard-vpn"
  machine_type = var.machine_type
  zone         = var.zone

  boot_disk {
    initialize_params {
      image = var.image
    }
  }

  network_interface {
    stack_type = "IPV4_IPV6"
    subnetwork = google_compute_subnetwork.vpn_subnet.id
    access_config {}
    ipv6_access_config {
      network_tier = "PREMIUM"
    }
  }

  metadata = {
    ssh-keys  = "ubuntu:${tls_private_key.ssh.public_key_openssh}"
    user-data = data.template_file.cloud_init.rendered
  }

  can_ip_forward = true

  tags = ["wireguard-vpn"]
}



# outputs.tf

locals {
  ipv6_address = google_compute_instance.wireguard.network_interface[0].ipv6_access_config[0].external_ipv6
  ipv4_address = google_compute_instance.wireguard.network_interface[0].access_config[0].nat_ip
}

# Creates ready to use local files for client configurations.
# Generate QR code on Linux:
# cat clientN.conf | qrencode -t ansiutf8
resource "local_sensitive_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 ?
      "[${local.ipv6_address}]" :
    local.ipv4_address)
  })

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

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

output "server_public_ipv4" {
  value = local.ipv4_address
}

output "server_public_ipv6" {
  value = local.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