Terraform, Azure VPN Gateway and OpenVPN Config

Recently I needed a quick way to spin-up a VPN Gateway and generate the openvpn config clients could use to connect.

There is a good guide to generating the necessary certificates and manually editing the openvpn config you can download from the portal in the official docs.

Being a sucker for punishment I wondered if I could automate the process (mainly because I always forget the openssl commands) and secondly it was to be run by someone else (not me) so I wanted it to be as simple as possible.

Warning: Please take time to understand the limitations of this certificate generation before production use (TF State file containing CA private keys) and review the limitations in the TLS provider for Terraform.

So how would this work? First choice is Terraform for the automation. Luckily that has a provider for generating certs! So the flow goes like this:

1. Create the Root CA
2. Use that to generate a client cert
3. Output the client cert for use in the openvpn config file
4. Inject the CA into the Azure VPN configuration and create it
5. Run a script to fetch the Azure VPN OpenVPN configuration file (as this contains the Pre-shared key we don’t set) then inject the client cert we outputted from the Terraform.
6. Connect

One gotcha – The TLS provider only outputs standard pems. Azure VPN requires the CA to be in a specific format outputted by the following command.

openssl x509 -in caCert.pem -outform der | base64 -w0 > caCert.der

As a result there is a null_resource and local_file resource to handle this translation.

So all you should need is Terraform, Bash, OpenSSL and Azure CLI – not perfect but it’s the best I could do! (This is currently a working draft – please use with caution).

resource "random_string" "random" {
length = 8
special = false
upper = false
number = false
resource "azurerm_public_ip" "vpn_ip" {
name = "vpn-ip"
location = var.region
resource_group_name = var.resource_group_name
domain_name_label = random_string.random.result
allocation_method = "Dynamic"
tags = var.tags
resource "tls_private_key" "example" {
algorithm = "RSA"
rsa_bits = "2048"
# Create the root certificate
resource "tls_self_signed_cert" "ca" {
key_algorithm = tls_private_key.example.algorithm
private_key_pem = tls_private_key.example.private_key_pem
# Certificate expires after 1 year
validity_period_hours = 8766
# Generate a new certificate if Terraform is run within three
# hours of the certificate's expiration time.
early_renewal_hours = 200
# Allow to be used as a CA
is_ca_certificate = true
allowed_uses = [
dns_names = [ azurerm_public_ip.vpn_ip.domain_name_label ]
subject {
common_name = "CAOpenVPN"
organization = "dev env"
resource "local_file" "ca_pem" {
filename = "caCert.pem"
content = tls_self_signed_cert.ca.cert_pem
resource "null_resource" "cert_encode" {
provisioner "local-exec" {
# Bootstrap script called with private_ip of each node in the clutser
command = "openssl x509 -in caCert.pem -outform der | base64 -w0 > caCert.der"
depends_on = [ local_file.ca_pem ]
data "local_file" "ca_der" {
filename = "caCert.der"
depends_on = [
resource "tls_private_key" "client_cert" {
algorithm = "RSA"
rsa_bits = "2048"
resource "tls_cert_request" "client_cert" {
key_algorithm = tls_private_key.client_cert.algorithm
private_key_pem = tls_private_key.client_cert.private_key_pem
# dns_names = [ azurerm_public_ip.vpn_ip.domain_name_label ]
subject {
common_name = "ClientOpenVPN"
organization = "dev env"
resource "tls_locally_signed_cert" "client_cert" {
cert_request_pem = tls_cert_request.client_cert.cert_request_pem
ca_key_algorithm = tls_private_key.client_cert.algorithm
ca_private_key_pem = tls_private_key.client_cert.private_key_pem
ca_cert_pem = tls_self_signed_cert.ca.cert_pem
validity_period_hours = 43800
allowed_uses = [
resource "azurerm_virtual_network_gateway" "vpn-gateway" {
name = "vpn-gateway"
location = var.region
resource_group_name = var.resource_group_name
type = "Vpn"
active_active = false
enable_bgp = false
sku = "VpnGw1"
ip_configuration {
name = "vnetGatewayConfig"
public_ip_address_id = azurerm_public_ip.vpn_ip.id
private_ip_address_allocation = "Dynamic"
subnet_id = azurerm_subnet.yoursubnethere.id
vpn_client_configuration {
address_space = [""]
vpn_client_protocols = ["OpenVPN"]
root_certificate {
name = "terraformselfsignedder"
public_cert_data = data.local_file.ca_der.content
output "client_cert" {
value = tls_locally_signed_cert.client_cert.cert_pem
output "client_key" {
value = tls_private_key.client_cert.private_key_pem
output "vpn_id" {
value = azurerm_virtual_network_gateway.vpn-gateway.id
view raw main.tf hosted with ❤ by GitHub
set -e
# Get vars from TF State
VPN_ID=`terraform output vpn_id`
VPN_CLIENT_CERT=`terraform output client_cert`
VPN_CLIENT_KEY=`terraform output client_key`
# Replace newlines with \n so sed doesn't break
CONFIG_URL=`az network vnet-gateway vpn-client generate –ids $VPN_ID -o tsv`
wget $CONFIG_URL -O "vpnconfig.zip"
# Ignore complaint about backslash in filepaths
unzip -o "vpnconfig.zip" -d "./vpnconftemp"|| true
echo "Updating file $OPENVPN_CONFIG_FILE"
cp $OPENVPN_CONFIG_FILE openvpn.ovpn
rm -r ./vpnconftemp
rm vpnconfig.zip
view raw openvpn_gen.sh hosted with ❤ by GitHub

Cleanup in Bash Scripts

[Brain dump so I don’t forget this one]

So you want your bash script to exit on an error but you’d like it to clean some stuff up before it closes after the error occurs.

No problem a TRAP can do this for you (read detailed docs for caveats).

In a very simple form it looks like this:

set -e
function cleanup()
echo -e "—-> Trap caught! Do cleanup here"
trap cleanup EXIT
# imagine some stuff happens here
# and it exists
exit 1
view raw trap.sh hosted with ❤ by GitHub
Using Trap to fire cleanup on exit

Learn more here: https://www.linuxjournal.com/content/bash-trap-command


Generate docker images of specific size

For some testing I’m doing I need a set of images of a specific size to simulate pulling larger vs smaller image.

Here is a quick script I put together for generating a 200mb, 600mb, 1000mb and 2000mb image (tiny bit larger as alpine included). Took a while to work out best to use /dev/urandom not /dev/zero as with zero the images got compressed for transfer.

set -e
set -x
# Push 200mb image
dd if=/dev/urandom of=./file.bin bs=1M count=200
docker build -t lawrencegripper/big:200mb .
docker push lawrencegripper/big:200mb
rm ./file.bin
# Push 600mb image
dd if=/dev/urandom of=./file.bin bs=1M count=600
docker build -t lawrencegripper/big:600mb .
docker push lawrencegripper/big:600mb
rm ./file.bin
# Push 1000mb image
dd if=/dev/urandom of=./file.bin bs=1M count=1000
docker build -t lawrencegripper/big:1000mb .
docker push lawrencegripper/big:1000mb
rm ./file.bin
# Push 2000mb image
dd if=/dev/urandom of=./file.bin bs=1M count=2000
docker build -t lawrencegripper/big:2000mb .
docker push lawrencegripper/big:2000mb
rm ./file.bin
view raw 1_gen_image_sizes.sh hosted with ❤ by GitHub
FROM alpine
COPY ./file.bin .
view raw 2_Dockerfile hosted with ❤ by GitHub