WireGuard VPN on DigitalOcean with Pulumi Typescript
- Authors
- Name
- upvpn LLC
- @upvpnapp
Setting up WireGuard VPN with Pulumi on DigitalOcean
This guide walks through setting up a WireGuard VPN server on DigitalOcean 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-digitalocean && cd wireguard-pulumi-digitalocean
pulumi new typescript # keep default selection; dev for stack
# update tsconfig.json, set:
# "target": "es2021",
Install required dependencies:
npm install @pulumi/digitalocean @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 digitalocean from '@pulumi/digitalocean'
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 region = config.get('region') || 'sfo3'
const dropletSize = config.get('dropletSize') || 's-1vcpu-1gb'
const interfaceName = 'eth0'
const useIpv6Endpoint = config.getBoolean('useIpv6Endpoint') || false
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 doSshKey = new digitalocean.SshKey('wireguard-ssh-key', {
name: 'wireguard-ssh-key',
publicKey: 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 contents of cloud init for user data and use it to create Droplet. And create firewall for the new droplet.
// 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 Droplet
const droplet = new digitalocean.Droplet('wireguard-vpn', {
image: 'ubuntu-24-04-x64',
region: region,
size: dropletSize,
ipv6: true,
sshKeys: [doSshKey.id],
userData: cloudConfig,
tags: ['vpn', 'wireguard'],
})
// Create firewall
const _firewall = new digitalocean.Firewall('wireguard-firewall', {
name: 'wireguard-firewall',
dropletIds: [droplet.id.apply((id) => Number(id))],
inboundRules: [
{
protocol: 'udp',
portRange: '51820',
sourceAddresses: ['0.0.0.0/0', '::/0'],
},
{
protocol: 'tcp',
portRange: '22',
sourceAddresses: ['0.0.0.0/0', '::/0'],
},
],
outboundRules: [
{
protocol: 'udp',
portRange: '1-65535',
destinationAddresses: ['0.0.0.0/0', '::/0'],
},
{
protocol: 'tcp',
portRange: '1-65535',
destinationAddresses: ['0.0.0.0/0', '::/0'],
},
{
protocol: 'icmp',
destinationAddresses: ['0.0.0.0/0', '::/0'],
},
],
})
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([droplet.ipv4Address, droplet.ipv6Address])
.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 = droplet.ipv4Address
export const serverIPv6 = droplet.ipv6Address
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 digitalocean from '@pulumi/digitalocean'
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 region = config.get('region') || 'sfo3'
const dropletSize = config.get('dropletSize') || 's-1vcpu-1gb'
const interfaceName = 'eth0'
const useIpv6Endpoint = config.getBoolean('useIpv6Endpoint') || false
// SSH Key Generation
// Generate SSH key
const sshKey = new tls.PrivateKey('ssh-key', {
algorithm: 'RSA',
rsaBits: 4096,
})
// Register SSH key with DigitalOcean
const doSshKey = new digitalocean.SshKey('wireguard-ssh-key', {
name: 'wireguard-ssh-key',
publicKey: 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 Droplet
const droplet = new digitalocean.Droplet('wireguard-vpn', {
image: 'ubuntu-24-04-x64',
region: region,
size: dropletSize,
ipv6: true,
sshKeys: [doSshKey.id],
userData: cloudConfig,
tags: ['vpn', 'wireguard'],
})
// Create firewall
const _firewall = new digitalocean.Firewall('wireguard-firewall', {
name: 'wireguard-firewall',
dropletIds: [droplet.id.apply((id) => Number(id))],
inboundRules: [
{
protocol: 'udp',
portRange: '51820',
sourceAddresses: ['0.0.0.0/0', '::/0'],
},
{
protocol: 'tcp',
portRange: '22',
sourceAddresses: ['0.0.0.0/0', '::/0'],
},
],
outboundRules: [
{
protocol: 'udp',
portRange: '1-65535',
destinationAddresses: ['0.0.0.0/0', '::/0'],
},
{
protocol: 'tcp',
portRange: '1-65535',
destinationAddresses: ['0.0.0.0/0', '::/0'],
},
{
protocol: 'icmp',
destinationAddresses: ['0.0.0.0/0', '::/0'],
},
],
})
// Generate client configurations
const clientConfigs = clientKeyPairs.map((clientKey, index) => {
const clientConfigTemplate = fs.readFileSync('client-config.tpl', 'utf8')
return pulumi
.all([droplet.ipv4Address, droplet.ipv6Address])
.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 = droplet.ipv4Address
export const serverIPv6 = droplet.ipv6Address
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 DigitalOcean token via env var:
Token must have following custom scopes:
- droplet (4): delete, update, read, create
- firewall (4): delete, update, read, create
- ssh_key (4): delete, update, read, create
export DIGITALOCEAN_TOKEN="your-token"
- 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
This will remove all created resources from DigitalOcean.