WireGuard VPN on Vultr with Pulumi Typescript
- Authors
- Name
- upvpn LLC
- @upvpnapp
Setting up WireGuard VPN with Pulumi on Vultr
This guide walks through setting up a WireGuard VPN server on Vultr using Pulumi Typescript for infrastructure as code. We'll create a secure VPN server with support for both IPv4 and IPv6, along with automated client configuration generation.
Project Setup
First, create a new Pulumi project:
mkdir wireguard-pulumi-vultr && cd wireguard-pulumi-vultr
pulumi new typescript # keep default selection; dev for stack
# update tsconfig.json, set:
# "target": "es2021",
Install required dependencies:
npm install @ediri/vultr @pulumi/tls ip-address
Project Structure
Create the other files not created by Pulumi in your project directory:
.
├── index.ts # Infrastructure
├── ip.ts # Utilities to compute IP addresses
├── client-config.tpl # Template for client wg configuration
├── cloud-init.yaml.tpl # Template for cloud init as user data
├── Pulumi.yaml
├── Pulumi.dev.yaml
├── package.json
├── package-lock.json
└── tsconfig.json
Configuration
Let's define our configuration variables in index.ts
:
import * as pulumi from '@pulumi/pulumi'
import * as vultr from '@ediri/vultr'
import * as tls from '@pulumi/tls'
import * as fs from 'fs'
import * as crypto from 'crypto'
import { cidrhost4, cidrhost6 } from './ip'
const config = new pulumi.Config()
const numClients = config.getNumber('numClients') || 1
const vpnCidrIPv4 = config.get('vpnCidrIPv4') || '10.8.0.0/24'
const vpnCidrIPv6 = config.get('vpnCidrIPv6') || 'fd00:10:10::/64'
const interfaceName = 'enp1s0'
const useIPv6Endpoint = config.getBoolean('useIPv6Endpoint') || false
const region = config.get('region') || 'sea'
const plan = config.get('plan') || 'vc2-1c-1gb'
// OS ID: 2284 Ubuntu 24.04 LTS x64
const osId = config.getNumber('osId') || 2284
SSH Key Generation
Generate a new SSH key pair for secure access:
// Generate SSH key
const sshKey = new tls.PrivateKey('ssh-key', {
algorithm: 'RSA',
rsaBits: 4096,
})
// Register SSH key with DigitalOcean
const vultrSshKey = new vultr.SSHKey('wireguard-ssh-key', {
name: 'wireguard-ssh-key',
sshKey: sshKey.publicKeyOpenssh,
})
WireGuard Key Generation
Generate one WireGuard key pair for server, and numClients
key pairs for clients:
const generateWireGuardKeyPair = () => {
let key = crypto.generateKeyPairSync('x25519', {
publicKeyEncoding: { format: 'der', type: 'spki' },
privateKeyEncoding: { format: 'der', type: 'pkcs8' },
})
return {
publicKey: key.publicKey.slice(12).toString('base64'),
privateKey: key.privateKey.slice(16).toString('base64'),
}
}
const serverKeyPair = generateWireGuardKeyPair()
const clientKeyPairs = []
for (let i = 0; i < numClients; i++) {
clientKeyPairs.push(generateWireGuardKeyPair())
}
Cloud-Init Template
Create 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 Configuration Template
Create 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
Utilities to compute IP addresses
Create ip.ts
, it contains functions to compute IPv4 and IPv6 addresses for WireGuard client and server from user provided CIDRs for VPN:
# ip.ts
import { Address4, Address6 } from 'ip-address';
function cidrhost4(network: string, hostNum: number): string {
const addr = new Address4(network);
const networkBigInt = BigInt(addr.bigInt());
const prefix = parseInt(addr.subnet.substring(1));
const maxHosts = BigInt(Math.pow(2, 32 - prefix)) - 1n;
if (hostNum < 0 || BigInt(hostNum) > maxHosts) {
throw new Error(`Host number ${hostNum} is out of range for network ${network}`);
}
const hostAddress = networkBigInt + BigInt(hostNum);
const newAddr = Address4.fromBigInt(hostAddress);
return `${newAddr.address}`;
}
function cidrhost6(network: string, hostNum: number): string {
const addr = new Address6(network);
const networkBigInt = BigInt(addr.bigInt());
const prefix = parseInt(addr.subnet.substring(1));
const maxHosts = BigInt(Math.pow(2, 128 - prefix)) - 1n;
if (hostNum < 0 || BigInt(hostNum) > maxHosts) {
throw new Error(`Host number ${hostNum} is out of range for network ${network}`);
}
const hostAddress = networkBigInt + BigInt(hostNum);
const newAddr = Address6.fromBigInt(hostAddress);
return `${newAddr.address}`;
}
export { cidrhost4, cidrhost6 };
Create Infrastructure
Create firewall. Create contents of cloud init for user data and use it to create instance.
// Generate variables to interpolate in
// cloud config template and client config template
const peerConfigs = clientKeyPairs
.map((clientKey, index) => {
let clientIpv4 = cidrhost4(vpnCidrIPv4, 2 + index)
let clientIpv6 = cidrhost6(vpnCidrIPv6, 2 + index)
// proper indentation so that cloud init yaml is well formed
return `
[Peer]
PublicKey = ${clientKey.publicKey}
AllowedIPs = ${clientIpv4}/32,${clientIpv6}/128`
})
.join('\n')
// Create cloud-init config
const cloudConfigTemplate = fs.readFileSync('cloud-init.yaml.tpl', 'utf8')
const cloudConfig = cloudConfigTemplate
.replace('${server_ipv4_address}', cidrhost4(vpnCidrIPv4, 1))
.replace('${server_ipv6_address}', cidrhost6(vpnCidrIPv6, 1))
.replace('${server_private_key}', serverKeyPair.privateKey)
.replaceAll('${interface}', interfaceName)
.replace('${peer_configs}', peerConfigs)
// Create firewall
const firewallGroup = new vultr.FirewallGroup('wireguard', {
description: 'WireGuard VPN Firewall',
})
const _firewallRules = [
new vultr.FirewallRule('ssh', {
firewallGroupId: firewallGroup.id,
protocol: 'tcp',
ipType: 'v4',
subnet: '0.0.0.0',
subnetSize: 0,
port: '22',
}),
new vultr.FirewallRule('wireguard', {
firewallGroupId: firewallGroup.id,
protocol: 'udp',
ipType: 'v4',
subnet: '0.0.0.0',
subnetSize: 0,
port: '51820',
}),
new vultr.FirewallRule('wireguard-ipv6', {
firewallGroupId: firewallGroup.id,
protocol: 'udp',
ipType: 'v6',
subnet: '::',
subnetSize: 0,
port: '51820',
}),
]
// Create instance
const instance = new vultr.Instance('wireguard', {
plan: plan,
region: region,
osId: osId,
enableIpv6: true,
firewallGroupId: firewallGroup.id,
sshKeyIds: [vultrSshKey.id],
userData: cloudConfig,
tags: ['wireguard', 'vpn'],
})
Client Configuration Generation
Interpolate contents of client-config.tpl
to generate each client configuration:
// Generate client configurations
const clientConfigs = clientKeyPairs.map((clientKey, index) => {
const clientConfigTemplate = fs.readFileSync('client-config.tpl', 'utf8')
return pulumi.all([instance.mainIp, instance.v6MainIp]).apply(([serverIPv4, serverIPv6]) => {
return clientConfigTemplate
.replace('${client_private_key}', clientKey.privateKey)
.replace('${client_ipv4_address}', cidrhost4(vpnCidrIPv4, index + 2))
.replace('${client_ipv6_address}', cidrhost6(vpnCidrIPv6, index + 2))
.replace('${server_public_key}', serverKeyPair.publicKey)
.replace('${server_public_ip}', useIPv6Endpoint ? `[${serverIPv6}]` : serverIPv4)
})
})
Export Outputs
// Export outputs
export const serverIPv4 = instance.mainIp
export const serverIPv6 = instance.v6MainIp
export const sshPrivateKey = sshKey.privateKeyOpenssh
export const clientConfigFiles = pulumi.secret(clientConfigs)
export const cloudInitConfig = pulumi.secret(cloudConfig)
Complete Code
Here's the complete code for the project:
// index.ts
import * as pulumi from '@pulumi/pulumi'
import * as vultr from '@ediri/vultr'
import * as tls from '@pulumi/tls'
import * as fs from 'fs'
import * as crypto from 'crypto'
import { cidrhost4, cidrhost6 } from './ip'
const config = new pulumi.Config()
const numClients = config.getNumber('numClients') || 1
const vpnCidrIPv4 = config.get('vpnCidrIPv4') || '10.8.0.0/24'
const vpnCidrIPv6 = config.get('vpnCidrIPv6') || 'fd00:10:10::/64'
const interfaceName = 'enp1s0'
const useIPv6Endpoint = config.getBoolean('useIPv6Endpoint') || false
const region = config.get('region') || 'sea'
const plan = config.get('plan') || 'vc2-1c-1gb'
// OS ID: 2284 Ubuntu 24.04 LTS x64
const osId = config.getNumber('osId') || 2284
// SSH Key Generation
// Generate SSH key
const sshKey = new tls.PrivateKey('ssh-key', {
algorithm: 'RSA',
rsaBits: 4096,
})
// Register SSH key with DigitalOcean
const vultrSshKey = new vultr.SSHKey('wireguard-ssh-key', {
name: 'wireguard-ssh-key',
sshKey: sshKey.publicKeyOpenssh,
})
// Generate WireGuard keys
const generateWireGuardKeyPair = () => {
let key = crypto.generateKeyPairSync('x25519', {
publicKeyEncoding: { format: 'der', type: 'spki' },
privateKeyEncoding: { format: 'der', type: 'pkcs8' },
})
return {
publicKey: key.publicKey.slice(12).toString('base64'),
privateKey: key.privateKey.slice(16).toString('base64'),
}
}
const serverKeyPair = generateWireGuardKeyPair()
const clientKeyPairs = []
for (let i = 0; i < numClients; i++) {
clientKeyPairs.push(generateWireGuardKeyPair())
}
// Generate variables to interpolate in
// cloud config template and client config template
const peerConfigs = clientKeyPairs
.map((clientKey, index) => {
let clientIpv4 = cidrhost4(vpnCidrIPv4, 2 + index)
let clientIpv6 = cidrhost6(vpnCidrIPv6, 2 + index)
// proper indentation so that cloud init yaml is well formed
return `
[Peer]
PublicKey = ${clientKey.publicKey}
AllowedIPs = ${clientIpv4}/32,${clientIpv6}/128`
})
.join('\n')
// Create cloud-init config
const cloudConfigTemplate = fs.readFileSync('cloud-init.yaml.tpl', 'utf8')
const cloudConfig = cloudConfigTemplate
.replace('${server_ipv4_address}', cidrhost4(vpnCidrIPv4, 1))
.replace('${server_ipv6_address}', cidrhost6(vpnCidrIPv6, 1))
.replace('${server_private_key}', serverKeyPair.privateKey)
.replaceAll('${interface}', interfaceName)
.replace('${peer_configs}', peerConfigs)
// Create firewall
const firewallGroup = new vultr.FirewallGroup('wireguard', {
description: 'WireGuard VPN Firewall',
})
const _firewallRules = [
new vultr.FirewallRule('ssh', {
firewallGroupId: firewallGroup.id,
protocol: 'tcp',
ipType: 'v4',
subnet: '0.0.0.0',
subnetSize: 0,
port: '22',
}),
new vultr.FirewallRule('wireguard', {
firewallGroupId: firewallGroup.id,
protocol: 'udp',
ipType: 'v4',
subnet: '0.0.0.0',
subnetSize: 0,
port: '51820',
}),
new vultr.FirewallRule('wireguard-ipv6', {
firewallGroupId: firewallGroup.id,
protocol: 'udp',
ipType: 'v6',
subnet: '::',
subnetSize: 0,
port: '51820',
}),
]
// Create instance
const instance = new vultr.Instance('wireguard', {
plan: plan,
region: region,
osId: osId,
enableIpv6: true,
firewallGroupId: firewallGroup.id,
sshKeyIds: [vultrSshKey.id],
userData: cloudConfig,
tags: ['wireguard', 'vpn'],
})
// Generate client configurations
const clientConfigs = clientKeyPairs.map((clientKey, index) => {
const clientConfigTemplate = fs.readFileSync('client-config.tpl', 'utf8')
return pulumi.all([instance.mainIp, instance.v6MainIp]).apply(([serverIPv4, serverIPv6]) => {
return clientConfigTemplate
.replace('${client_private_key}', clientKey.privateKey)
.replace('${client_ipv4_address}', cidrhost4(vpnCidrIPv4, index + 2))
.replace('${client_ipv6_address}', cidrhost6(vpnCidrIPv6, index + 2))
.replace('${server_public_key}', serverKeyPair.publicKey)
.replace('${server_public_ip}', useIPv6Endpoint ? `[${serverIPv6}]` : serverIPv4)
})
})
// Export outputs
export const serverIPv4 = instance.mainIp
export const serverIPv6 = instance.v6MainIp
export const sshPrivateKey = sshKey.privateKeyOpenssh
export const clientConfigFiles = pulumi.secret(clientConfigs)
export const cloudInitConfig = pulumi.secret(cloudConfig)
Usage Instructions
Setup Pulumi
- Install Pulumi
- Use local file to store Pulumi state:
# Local filesystem for state
pulumi login --local
## To initialize new typescript pulumi project:
# pulumi new typescript
- Provide Vultr API key via env var:
export VULTR_API_KEY="your-api-key"
- Configure the number of clients:
pulumi config set numClients 3
- Deploy the infrastructure:
export PULUMI_CONFIG_PASSPHRASE="" # or your passphrase
pulumi up
- Connect Client
Client configuration file contents are stored as list of strings in a secret, extract them individually:
# Change the index in jq to get other client config
pulumi stack output clientConfigFiles --show-secrets | jq -r '.[0]' > client1.conf
# or second client, and so on.
# pulumi stack output clientConfigFiles --show-secrets | jq -r '.[1]' > client2.conf
For mobile devices, create QR code:
cat client1.conf | qrencode -t ansiutf8
- SSH into the server:
# Save the SSH private key
pulumi stack output sshPrivateKey --show-secrets > wireguard-ssh-key
chmod 600 wireguard-ssh-key
# SSH into the server
ssh -i wireguard-ssh-key root@$(pulumi stack output serverIPv4)
- Cleanup
To destroy the infrastructure
pulumi destroy