WireGuard VPN on Hetzner with Pulumi Typescript
- Authors
- Name
- upvpn LLC
- @upvpnapp
Setting up WireGuard VPN with Pulumi on Hetzner
This guide walks through setting up a WireGuard VPN server on Hetzner Cloud 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-hetzner && cd wireguard-pulumi-hetzner
pulumi new typescript # keep default selection; dev for stack
# update tsconfig.json, set:
# "target": "es2021",
Install required dependencies:
npm install @pulumi/hcloud @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 hcloud from '@pulumi/hcloud'
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 = 'eth0'
const useIPv6Endpoint = config.getBoolean('useIPv6Endpoint') || false
// Hetzner Datacenter location https://docs.hetzner.com/cloud/general/locations/#what-locations-are-there
const location = config.get('location') || 'fsn1'
// Server Type https://docs.hetzner.com/cloud/servers/overview#pricing
const serverType = config.get('serverType') || 'cax11'
// Name or ID of the image the server is created from
const image = config.get('image') || 'ubuntu-24.04'
SSH Key Generation
Generate a new SSH key pair for secure access:
// Generate SSH key
const privateKeySSH = new tls.PrivateKey('ssh-key', {
algorithm: 'RSA',
rsaBits: 4096,
})
// Register SSH key
const sshKey = new hcloud.SshKey('wireguard-ssh-key', {
name: 'wireguard-ssh-key',
publicKey: privateKeySSH.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 firewall = new hcloud.Firewall('wireguard-firewall', {
rules: [
{
direction: 'in',
protocol: 'tcp',
port: '22',
sourceIps: ['0.0.0.0/0', '::/0'],
},
{
direction: 'in',
protocol: 'udp',
port: '51820',
sourceIps: ['0.0.0.0/0', '::/0'],
},
],
})
// Create server
const server = new hcloud.Server('wireguard-server', {
serverType: serverType,
image: image,
location: location,
sshKeys: [sshKey.id],
publicNets: [
{
ipv4Enabled: true,
ipv6Enabled: true,
},
],
firewallIds: [firewall.id.apply((id) => Number(id))],
userData: cloudConfig,
})
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([server.ipv4Address, server.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 = server.ipv4Address
export const serverIPv6 = server.ipv6Address
export const sshPrivateKey = privateKeySSH.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 hcloud from '@pulumi/hcloud'
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 = 'eth0'
const useIPv6Endpoint = config.getBoolean('useIPv6Endpoint') || false
// Hetzner Datacenter location https://docs.hetzner.com/cloud/general/locations/#what-locations-are-there
const location = config.get('location') || 'fsn1'
// Server Type https://docs.hetzner.com/cloud/servers/overview#pricing
const serverType = config.get('serverType') || 'cax11'
// Name or ID of the image the server is created from
const image = config.get('image') || 'ubuntu-24.04'
// SSH Key Generation
// Generate SSH key
const privateKeySSH = new tls.PrivateKey('ssh-key', {
algorithm: 'RSA',
rsaBits: 4096,
})
// Register SSH key
const sshKey = new hcloud.SshKey('wireguard-ssh-key', {
name: 'wireguard-ssh-key',
publicKey: privateKeySSH.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 firewall = new hcloud.Firewall('wireguard-firewall', {
rules: [
{
direction: 'in',
protocol: 'tcp',
port: '22',
sourceIps: ['0.0.0.0/0', '::/0'],
},
{
direction: 'in',
protocol: 'udp',
port: '51820',
sourceIps: ['0.0.0.0/0', '::/0'],
},
],
})
// Create server
const server = new hcloud.Server('wireguard-server', {
serverType: serverType,
image: image,
location: location,
sshKeys: [sshKey.id],
publicNets: [
{
ipv4Enabled: true,
ipv6Enabled: true,
},
],
firewallIds: [firewall.id.apply((id) => Number(id))],
userData: cloudConfig,
})
// Generate client configurations
const clientConfigs = clientKeyPairs.map((clientKey, index) => {
const clientConfigTemplate = fs.readFileSync('client-config.tpl', 'utf8')
return pulumi.all([server.ipv4Address, server.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 = server.ipv4Address
export const serverIPv6 = server.ipv6Address
export const sshPrivateKey = privateKeySSH.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 Hetzner Cloud token via env var:
export HCLOUD_TOKEN="token-with-read-write-permissions"
- 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