WireGuard VPN on Hetzner with Pulumi Typescript

Authors

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

  1. Setup Pulumi

# Local filesystem for state
pulumi login --local

## To initialize new typescript pulumi project:
# pulumi new typescript
  1. Provide Hetzner Cloud token via env var:
export HCLOUD_TOKEN="token-with-read-write-permissions"
  1. Configure the number of clients:
pulumi config set numClients 3
  1. Deploy the infrastructure:
export PULUMI_CONFIG_PASSPHRASE="" # or your passphrase
pulumi up
  1. 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
  1. 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)
  1. Cleanup

To destroy the infrastructure

pulumi destroy