WireGuard VPN on AWS with Terraform
- Authors
- Name
- upvpn LLC
- @upvpnapp
Table of Contents
Introduction
This guide will walk you through setting up a WireGuard VPN server on Amazon Web Services 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:
- A VPC with IPv6 support
- EC2 instance with public IPv4 and IPv6 running WireGuard
- SSH key pair for secure access
- Security Group 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 {
aws = {
source = "hashicorp/aws"
version = "~> 5.78"
}
// 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 "aws" {
region = var.region
}
provider "wireguard" {}
2. Variables Definition
Define the necessary variables:
# variables.tf
variable "region" {
description = "AWS region"
type = string
default = "us-west-2"
}
variable "instance_type" {
description = "AWS instance type"
type = string
default = "t3.micro"
}
variable "client_count" {
description = "Number of WireGuard clients to create"
type = number
default = 1
}
variable "aws_vpc_cidr" {
description = "IPv4 CIDR for VPC"
default = "10.240.0.0/16"
}
variable "aws_vpc_subnet_cidr" {
description = "IPv4 CIDR, must be subnet of aws_vpc_cidr"
default = "10.240.1.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 = "ens5"
}
3. SSH Key Generation
Generate an SSH key pair for the instance:
# main.tf
resource "tls_private_key" "ssh" {
algorithm = "ED25519"
}
resource "aws_key_pair" "vpn" {
key_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. Create Network Infrastructure
Create VPC and Subnet with both IPv4 and IPv6 capabilities:
# main.tf
# Create Network Infrastructure
# VPC Network with IPv6 support
resource "aws_vpc" "vpn" {
cidr_block = var.aws_vpc_cidr
assign_generated_ipv6_cidr_block = true
enable_dns_hostnames = true
tags = {
Name = "wireguard-vpn-vpc"
}
}
resource "aws_internet_gateway" "vpn" {
vpc_id = aws_vpc.vpn.id
tags = {
Name = "wireguard-vpn-igw"
}
}
resource "aws_subnet" "vpn" {
vpc_id = aws_vpc.vpn.id
cidr_block = var.aws_vpc_subnet_cidr
ipv6_cidr_block = cidrsubnet(aws_vpc.vpn.ipv6_cidr_block, 8, 1)
map_public_ip_on_launch = true
assign_ipv6_address_on_creation = true
tags = {
Name = "wireguard-vpn-subnet"
}
}
resource "aws_route_table" "vpn" {
vpc_id = aws_vpc.vpn.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.vpn.id
}
route {
ipv6_cidr_block = "::/0"
gateway_id = aws_internet_gateway.vpn.id
}
tags = {
Name = "wireguard-vpn-rt"
}
}
resource "aws_route_table_association" "vpn" {
subnet_id = aws_subnet.vpn.id
route_table_id = aws_route_table.vpn.id
}
8. Create Security Group
Setup firewall rules using Security Group:
# main.tf
# Create Security Group | Firewall
resource "aws_security_group" "vpn" {
name = "wireguard-vpn-sg"
description = "Security group for WireGuard VPN server"
vpc_id = aws_vpc.vpn.id
ingress {
from_port = 51820
to_port = 51820
protocol = "udp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
description = "WireGuard VPN"
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
description = "SSH access"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = {
Name = "wireguard-vpn-sg"
}
}
9. Create Instance
Create the instance using latest Ubuntu 24.04 AMI:
# 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
}
}
data "aws_ami" "ubuntu" {
most_recent = true
// https://documentation.ubuntu.com/aws/en/latest/aws-how-to/instances/find-ubuntu-images/#ownership-verification
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server*"]
}
}
# Create EC2 instance
resource "aws_instance" "vpn" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.vpn.id
vpc_security_group_ids = [aws_security_group.vpn.id]
key_name = aws_key_pair.vpn.key_name
associate_public_ip_address = true
ipv6_address_count = 1
user_data = data.template_file.cloud_init.rendered
root_block_device {
volume_type = "gp3"
volume_size = 8
}
tags = {
Name = "wireguard-vpn-server"
}
}
10. Output Configurations
Generate output for client configurations and SSH key:
# outputs.tf
locals {
ipv6_address = element(aws_instance.vpn.ipv6_addresses, 0)
ipv4_address = aws_instance.vpn.public_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
Setup
aws
cli (or use CloudShell) so thathashicorp/aws
Terraform provider can use the same credential source.Initialize and apply the Terraform configuration:
terraform init
terraform apply
After instance creation, wait for cloud init to complete the setup.
- 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 ubuntu@$(terraform output -raw server_public_ipv4)
Complete Terraform Configuration
Here's all the code in a single block:
# versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.78"
}
// 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 "aws" {
region = var.region
}
provider "wireguard" {}
# variables.tf
variable "region" {
description = "AWS region"
type = string
default = "us-west-2"
}
variable "instance_type" {
description = "AWS instance type"
type = string
default = "t3.micro"
}
variable "client_count" {
description = "Number of WireGuard clients to create"
type = number
default = 1
}
variable "aws_vpc_cidr" {
description = "IPv4 CIDR for VPC"
default = "10.240.0.0/16"
}
variable "aws_vpc_subnet_cidr" {
description = "IPv4 CIDR, must be subnet of aws_vpc_cidr"
default = "10.240.1.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 = "ens5"
}
# main.tf
# SSH Key Generation
resource "tls_private_key" "ssh" {
algorithm = "ED25519"
}
resource "aws_key_pair" "vpn" {
key_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
}
# Create Network Infrastructure
# VPC Network with IPv6 support
resource "aws_vpc" "vpn" {
cidr_block = var.aws_vpc_cidr
assign_generated_ipv6_cidr_block = true
enable_dns_hostnames = true
tags = {
Name = "wireguard-vpn-vpc"
}
}
resource "aws_internet_gateway" "vpn" {
vpc_id = aws_vpc.vpn.id
tags = {
Name = "wireguard-vpn-igw"
}
}
resource "aws_subnet" "vpn" {
vpc_id = aws_vpc.vpn.id
cidr_block = var.aws_vpc_subnet_cidr
ipv6_cidr_block = cidrsubnet(aws_vpc.vpn.ipv6_cidr_block, 8, 1)
map_public_ip_on_launch = true
assign_ipv6_address_on_creation = true
tags = {
Name = "wireguard-vpn-subnet"
}
}
resource "aws_route_table" "vpn" {
vpc_id = aws_vpc.vpn.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.vpn.id
}
route {
ipv6_cidr_block = "::/0"
gateway_id = aws_internet_gateway.vpn.id
}
tags = {
Name = "wireguard-vpn-rt"
}
}
resource "aws_route_table_association" "vpn" {
subnet_id = aws_subnet.vpn.id
route_table_id = aws_route_table.vpn.id
}
# Create Security Group | Firewall
resource "aws_security_group" "vpn" {
name = "wireguard-vpn-sg"
description = "Security group for WireGuard VPN server"
vpc_id = aws_vpc.vpn.id
ingress {
from_port = 51820
to_port = 51820
protocol = "udp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
description = "WireGuard VPN"
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
description = "SSH access"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = {
Name = "wireguard-vpn-sg"
}
}
# 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
}
}
data "aws_ami" "ubuntu" {
most_recent = true
// https://documentation.ubuntu.com/aws/en/latest/aws-how-to/instances/find-ubuntu-images/#ownership-verification
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server*"]
}
}
# Create EC2 instance
resource "aws_instance" "vpn" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.vpn.id
vpc_security_group_ids = [aws_security_group.vpn.id]
key_name = aws_key_pair.vpn.key_name
associate_public_ip_address = true
ipv6_address_count = 1
user_data = data.template_file.cloud_init.rendered
root_block_device {
volume_type = "gp3"
volume_size = 8
}
tags = {
Name = "wireguard-vpn-server"
}
}
# outputs.tf
locals {
ipv6_address = element(aws_instance.vpn.ipv6_addresses, 0)
ipv4_address = aws_instance.vpn.public_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