WireGuard VPN on Akamai Linode with Terraform
- Authors
- Name
- upvpn LLC
- @upvpnapp
Introduction
This guide will walk you through setting up a WireGuard VPN server on Akamai Linode 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:
- Linode 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 {
linode = {
source = "linode/linode"
version = "~> 2.31.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 "linode" {
token = var.linode_token
}
provider "wireguard" {}
2. Variables Definition
Define the necessary variables:
# variables.tf
variable "linode_token" {
description = "Linode API token"
type = string
sensitive = true
}
variable "region" {
description = "Linode region"
type = string
default = "us-southeast"
}
variable "instance_type" {
description = "Linode instance type"
type = string
default = "g6-nanode-1"
}
variable "image" {
description = "Linode image the server is created from"
default = "linode/ubuntu22.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 = "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. 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
]))
}
# Firewall
resource "linode_firewall" "vpn" {
label = "wireguard-vpn"
inbound {
label = "allow-wireguard"
action = "ACCEPT"
protocol = "UDP"
ports = "51820"
ipv4 = ["0.0.0.0/0"]
ipv6 = ["::/0"]
}
inbound {
label = "allow-ssh"
action = "ACCEPT"
protocol = "TCP"
ports = "22"
ipv4 = ["0.0.0.0/0"]
ipv6 = ["::/0"]
}
inbound_policy = "DROP"
outbound_policy = "ACCEPT"
linodes = [linode_instance.vpn.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
}
}
# Linode instance
resource "linode_instance" "vpn" {
label = "wireguard-vpn"
image = var.image
region = var.region
type = var.instance_type
authorized_keys = [trimspace(tls_private_key.ssh.public_key_openssh)]
metadata {
user_data = base64encode(data.template_file.cloud_init.rendered)
}
}
8. Output Configurations
Generate output for client configurations:
# outputs.tf
locals {
ipv6_address = element(split("/", linode_instance.vpn.ipv6), 0)
ipv4_address = element(linode_instance.vpn.ipv4[*], 0)
}
# 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 ?
"[${local.ipv6_address}]" :
local.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 = local.ipv4_address
}
output "server_public_ipv6" {
value = local.ipv6_address
}
Usage Instructions
- Create a
terraform.tfvars
file with your Linode personal access token with following access:
- Firewalls: Read/Write
- Linodes: Read/Write
- Events: Read (Required during
terraform destroy
)
linode_token = "your-token-here"
- Initialize and apply the Terraform configuration:
terraform init
terraform apply
- 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
- 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 {
linode = {
source = "linode/linode"
version = "~> 2.31.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 "linode" {
token = var.linode_token
}
provider "wireguard" {}
# variables.tf
variable "linode_token" {
description = "Linode API token"
type = string
sensitive = true
}
variable "region" {
description = "Linode region"
type = string
default = "us-southeast"
}
variable "instance_type" {
description = "Linode instance type"
type = string
default = "g6-nanode-1"
}
variable "image" {
description = "Linode image the server is created from"
default = "linode/ubuntu22.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 = "RSA"
rsa_bits = 4096
}
# 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
]))
}
# Firewall
resource "linode_firewall" "vpn" {
label = "wireguard-vpn"
inbound {
label = "allow-wireguard"
action = "ACCEPT"
protocol = "UDP"
ports = "51820"
ipv4 = ["0.0.0.0/0"]
ipv6 = ["::/0"]
}
inbound {
label = "allow-ssh"
action = "ACCEPT"
protocol = "TCP"
ports = "22"
ipv4 = ["0.0.0.0/0"]
ipv6 = ["::/0"]
}
inbound_policy = "DROP"
outbound_policy = "ACCEPT"
linodes = [linode_instance.vpn.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
}
}
# Linode instance
resource "linode_instance" "vpn" {
label = "wireguard-vpn"
image = var.image
region = var.region
type = var.instance_type
authorized_keys = [trimspace(tls_private_key.ssh.public_key_openssh)]
metadata {
user_data = base64encode(data.template_file.cloud_init.rendered)
}
}
# outputs.tf
locals {
ipv6_address = element(split("/", linode_instance.vpn.ipv6), 0)
ipv4_address = element(linode_instance.vpn.ipv4[*], 0)
}
# 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 ?
"[${local.ipv6_address}]" :
local.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 = 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