How to Use Pulumi with Azure and Twingate

This guide provides step-by-step instructions on automating Twingate deployments with Pulumi on Microsoft Azure.

Prerequisites

This guide assumes the following (on top of prerequisites for all Pulumi guides):

  • You have a Azure account setup and an account with relevant access to create and delete Azure resources
  • You are using an operating system that supports Bash

1. Set up the environment

First, create a new folder for our Pulumi code to reside:

mkdir twingate_pulumi_azure_demo
cd twingate_pulumi_azure_demo

Next, we can use a template to set up the environment:

pulumi new typescript

You will be prompted to enter some values, which can be whatever you want:

project name: demo
project description: Deploying Twingate using Pulumi
stack name: azure

Once the project is set up, open the folder you created in your favorite code editor.

2. Install Node Modules

As we are using Typescript, Pulumi will use node.js modules to build the infrastructure for both Azure and Twingate. Install the following node.js modules:

npm install @pulumi/azure-native
npm install @pulumi/azure
npm install @twingate/pulumi-twingate

3. Set up Azure Access for Pulumi

Connect Pulumi to your Azure environment:

az login
az account list
az account set --subscription=<id>

4. Configuration

Pulumi requires a Twingate API key along with the name of the Twingate tenant we will be configuring.

You can generate a new API key from your Twingate account by following these instructions.

The name of your Twingate tenant is the initial portion of your Twingate URL, e.g. mycorp in mycorp.twingate.com.

Let’s store these two values in the Pulumi configuration:

pulumi config set twingate:network <younetwork>
pulumi config set twingate:apiToken <yourToken> --secret

Configure variables for our test virtual machine

pulumi config set twingate_pulumi_azure_demo:username tgadmin
pulumi config set twingate_pulumi_azure_demo:password --secret <superSecurePassword>

Configure Azure

You can change the region used for your Azure configuration, if desired:

pulumi config set azure-native:location uksouth

Write the configuration

Open the index.ts file and import the modules we will be using:

import * as pulumi from "@pulumi/pulumi";
import * as azure from "@pulumi/azure";
import * as azure_native from "@pulumi/azure-native";
import * as twingate from "@twingate/pulumi-twingate";

Let’s also set some variables we will use when spinning up the VMs:

const config = new pulumi.Config();
const username = config.require("username");
const password = config.requireSecret("password");

Create Twingate Resources

Next, let’s create the components corresponding to our Twingate Connector and Twingate Group:

const tgazureNetwork = new twingate.TwingateRemoteNetwork("twingate-azure-demo", {
name: "twingate-azure-demo",
});
const tgazureConnector = new twingate.TwingateConnector("twingateConnector", { remoteNetworkId: tgazureNetwork.id });
const tgazureConnectorTokens = new twingate.TwingateConnectorTokens("twingateConnectorTokens", {
connectorId: tgazureConnector.id,
});
const tggroup = new twingate.TwingateGroup("twingateGroup", {
name: "azure demo group",
});

Create an Azure Resource Group

Next, let’s create an Azure Resource Group which will be made up of a Connector VM and test VM:

const ResourceGroup = new azure.core.ResourceGroup("ResourceGroup", { location: "UK South" });
const resourceGroupName = ResourceGroup.name;

Set up an Azure Public IP

Next, let’s create the networking components in Azure by setting up a public IP address used by the Twingate Connector VM:

const PublicIp = new azure.network.PublicIp("PublicIp", {
location: ResourceGroup.location,
resourceGroupName: ResourceGroup.name,
allocationMethod: "Static",
sku: "Standard",
zones: ["1"],
});

Define an Azure Virtual Network

Next, let’s define a virtual network in Azure to host all components:

const virtualNetwork = new azure_native.network.VirtualNetwork("server-network", {
resourceGroupName,
addressSpace: { addressPrefixes: ["10.0.0.0/16"] },
subnets: [
{
name: "default",
addressPrefix: "10.0.1.0/24",
},
],
});

Create an Azure Security Group (optional)

For this guide, we will create a group allowing inbound communication on the default ssh port (22) to test connectivity.

const networkSecurityGroup = new azure_native.network.NetworkSecurityGroup("networkSecurityGroup", {
location: ResourceGroup.location,
networkSecurityGroupName: "testnsg",
resourceGroupName: ResourceGroup.name,
securityRules: [
{
access: "Allow",
destinationAddressPrefix: "*",
direction: "Inbound",
protocol: "*",
destinationPortRanges: ["22", "80"],
name: "SSHandHTTP",
sourceAddressPrefix: "88.98.90.108/32",
sourcePortRange: "*",
priority: 100,
},
],
});

Configure the Web Server test virtual machine

Declare a new network interface

Let’s declare a new network interface that will be used for the test VM that we will be accessing via Twingate:

const networkInterface_vm = new azure_native.network.NetworkInterface("server-nic-vm", {
resourceGroupName,
ipConfigurations: [
{
name: "webserveripcfg",
subnet: virtualNetwork.subnets.apply((subnet) => subnet![0]),
privateIPAllocationMethod: azure_native.network.IPAllocationMethod.Dynamic,
},
],
});

Create a startup script for web server

The startup script for our test VM is very simple - it listens to port 80 and returns a Hello, World! message:

const initScript = pulumi.interpolate`#!/bin/bash\n
echo "Hello, World!" > index.html
nohup python -m SimpleHTTPServer 80 &`;

Create the web server virtual machine

const vm = new azure_native.compute.VirtualMachine("server-vm", {
resourceGroupName,
networkProfile: {
networkInterfaces: [{ id: networkInterface_vm.id }],
},
hardwareProfile: {
vmSize: azure_native.compute.VirtualMachineSizeTypes.Standard_B1ms,
},
osProfile: {
computerName: "VM",
customData: initScript.apply((x) => Buffer.from(x).toString("base64")),
adminUsername: username,
adminPassword: password,
linuxConfiguration: {
disablePasswordAuthentication: false,
},
},
storageProfile: {
osDisk: {
createOption: azure_native.compute.DiskCreateOption.FromImage,
name: "server-vm-myosdisk",
deleteOption: "Delete",
},
imageReference: {
publisher: "canonical",
offer: "UbuntuServer",
sku: "16.04-LTS",
version: "latest",
},
},
});

Configure the Twingate Connector Virtual Machine

The machine we will configure below will be hosting the Twingate Connector.

Configure the network interface

const networkInterface_tg = new azure_native.network.NetworkInterface("server-nic-tg", {
resourceGroupName,
ipConfigurations: [
{
name: "tgserveripcfg",
subnet: virtualNetwork.subnets.apply((subnet) => subnet![0]),
privateIPAllocationMethod: azure_native.network.IPAllocationMethod.Dynamic,
publicIPAddress: { id: PublicIp.id },
},
],
networkSecurityGroup: {
id: networkSecurityGroup.id,
},
});

Create a startup script

Our startup script will deploy a Connector and use the Connector tokens defined earlier.

const startupScript = pulumi.interpolate`#!/bin/bash
curl "https://binaries.twingate.com/connector/setup.sh" | sudo TWINGATE_ACCESS_TOKEN="${tgazureConnectorTokens.accessToken}" TWINGATE_REFRESH_TOKEN="${tgazureConnectorTokens.refreshToken}" TWINGATE_URL="https://${twingate.config.network}.twingate.com" bash
`;

Create the Twingate Connector virtual machine

Let’s now bundle it all together to create our Twingate Connector VM:

const tgconnector = new azure_native.compute.VirtualMachine("connector-vm", {
resourceGroupName,
networkProfile: {
networkInterfaces: [{ id: networkInterface_tg.id }],
},
hardwareProfile: {
vmSize: azure_native.compute.VirtualMachineSizeTypes.Standard_B1ms,
},
osProfile: {
computerName: "TG-CONNECTOR",
adminUsername: username,
adminPassword: password,
linuxConfiguration: {
disablePasswordAuthentication: false,
},
},
storageProfile: {
osDisk: {
createOption: azure_native.compute.DiskCreateOption.FromImage,
name: "connector-vm-myosdisk1",
deleteOption: "Delete",
},
imageReference: {
offer: "0001-com-ubuntu-server-jammy",
publisher: "Canonical",
sku: "22_04-lts-gen2",
version: "latest",
},
},
userData: startupScript.apply((x) => Buffer.from(x).toString("base64")),
});

Configure Twingate to allow access to the test web server

Let’s get the new private IP address of the web server VM. We will need it to automatically create a corresponding Twingate Resource:

const privIP = networkInterface_vm.ipConfigurations.apply((x) => {
return x != undefined ? x[0].privateIPAddress : null;
});

Create a new Twingate Resource

Let’s now create a Resource in Twingate for our test VM. Don’t forget to allow ports 22 and 80:

const tgresource = new twingate.TwingateResource("resource", {
name: "Azure demo server",
address: pulumi.interpolate`${privIP}`,
remoteNetworkId: tgazureNetwork.id,
accessGroups: [
{
groupId: tggroup.id,
},
],
protocols: {
allowIcmp: true,
tcp: {
policy: "RESTRICTED",
ports: ["22", "80"],
},
udp: {
policy: "ALLOW_ALL",
},
},
});

Final Code

We are now done with writing code! The final version of your index.ts should look like the following:

import * as pulumi from "@pulumi/pulumi";
import * as azure from "@pulumi/azure";
import * as azure_native from "@pulumi/azure-native";
import * as twingate from "@twingate/pulumi-twingate";
const config = new pulumi.Config();
const username = config.require("username");
const password = config.requireSecret("password");
const tgazureNetwork = new twingate.TwingateRemoteNetwork("twingate-azure-demo", {
name: "twingate-azure-demo",
});
const tgazureConnector = new twingate.TwingateConnector("twingateConnector", { remoteNetworkId: tgazureNetwork.id });
const tgazureConnectorTokens = new twingate.TwingateConnectorTokens("twingateConnectorTokens", {
connectorId: tgazureConnector.id,
});
const tggroup = new twingate.TwingateGroup("twingateGroup", {
name: "azure demo group",
});
const ResourceGroup = new azure.core.ResourceGroup("ResourceGroup", { location: "UK South" });
const resourceGroupName = ResourceGroup.name;
const PublicIp = new azure.network.PublicIp("PublicIp", {
location: ResourceGroup.location,
resourceGroupName: ResourceGroup.name,
allocationMethod: "Static",
sku: "Standard",
zones: ["1"],
});
const virtualNetwork = new azure_native.network.VirtualNetwork("server-network", {
resourceGroupName,
addressSpace: { addressPrefixes: ["10.0.0.0/16"] },
subnets: [
{
name: "default",
addressPrefix: "10.0.1.0/24",
},
],
});
const networkSecurityGroup = new azure_native.network.NetworkSecurityGroup("networkSecurityGroup", {
location: ResourceGroup.location,
networkSecurityGroupName: "testnsg",
resourceGroupName: ResourceGroup.name,
securityRules: [
{
access: "Allow",
destinationAddressPrefix: "*",
direction: "Inbound",
protocol: "*",
destinationPortRanges: ["22", "80"],
name: "SSHandHTTP",
sourceAddressPrefix: "88.98.90.108/32",
sourcePortRange: "*",
priority: 100,
},
],
});
const networkInterface_vm = new azure_native.network.NetworkInterface("server-nic-vm", {
resourceGroupName,
ipConfigurations: [
{
name: "webserveripcfg",
subnet: virtualNetwork.subnets.apply((subnet) => subnet![0]),
privateIPAllocationMethod: azure_native.network.IPAllocationMethod.Dynamic,
},
],
});
const initScript = pulumi.interpolate`#!/bin/bash\n
echo "Hello, World!" > index.html
nohup python -m SimpleHTTPServer 80 &`;
const vm = new azure_native.compute.VirtualMachine("server-vm", {
resourceGroupName,
networkProfile: {
networkInterfaces: [{ id: networkInterface_vm.id }],
},
hardwareProfile: {
vmSize: azure_native.compute.VirtualMachineSizeTypes.Standard_B1ms,
},
osProfile: {
computerName: "VM",
customData: initScript.apply((x) => Buffer.from(x).toString("base64")),
adminUsername: username,
adminPassword: password,
linuxConfiguration: {
disablePasswordAuthentication: false,
},
},
storageProfile: {
osDisk: {
createOption: azure_native.compute.DiskCreateOption.FromImage,
name: "server-vm-myosdisk",
deleteOption: "Delete",
},
imageReference: {
publisher: "canonical",
offer: "UbuntuServer",
sku: "16.04-LTS",
version: "latest",
},
},
});
const networkInterface_tg = new azure_native.network.NetworkInterface("server-nic-tg", {
resourceGroupName,
ipConfigurations: [
{
name: "tgserveripcfg",
subnet: virtualNetwork.subnets.apply((subnet) => subnet![0]),
privateIPAllocationMethod: azure_native.network.IPAllocationMethod.Dynamic,
publicIPAddress: { id: PublicIp.id },
},
],
networkSecurityGroup: {
id: networkSecurityGroup.id,
},
});
const startupScript = pulumi.interpolate`#!/bin/bash
curl "https://binaries.twingate.com/connector/setup.sh" | sudo TWINGATE_ACCESS_TOKEN="${tgazureConnectorTokens.accessToken}" TWINGATE_REFRESH_TOKEN="${tgazureConnectorTokens.refreshToken}" TWINGATE_URL="https://${twingate.config.network}.twingate.com" bash
`;
const tgconnector = new azure_native.compute.VirtualMachine("connector-vm", {
resourceGroupName,
networkProfile: {
networkInterfaces: [{ id: networkInterface_tg.id }],
},
hardwareProfile: {
vmSize: azure_native.compute.VirtualMachineSizeTypes.Standard_B1ms,
},
osProfile: {
computerName: "TG-CONNECTOR",
adminUsername: username,
adminPassword: password,
linuxConfiguration: {
disablePasswordAuthentication: false,
},
},
storageProfile: {
osDisk: {
createOption: azure_native.compute.DiskCreateOption.FromImage,
name: "connector-vm-myosdisk1",
deleteOption: "Delete",
},
imageReference: {
offer: "0001-com-ubuntu-server-jammy",
publisher: "Canonical",
sku: "22_04-lts-gen2",
version: "latest",
},
},
userData: startupScript.apply((x) => Buffer.from(x).toString("base64")),
});
const privIP = networkInterface_vm.ipConfigurations.apply((x) => {
return x != undefined ? x[0].privateIPAddress : null;
});
const tgresource = new twingate.TwingateResource("resource", {
name: "Azure demo server",
address: pulumi.interpolate`${privIP}`,
remoteNetworkId: tgazureNetwork.id,
accessGroups: [
{
groupId: tggroup.id,
},
],
protocols: {
allowIcmp: true,
tcp: {
policy: "RESTRICTED",
ports: ["22", "80"],
},
udp: {
policy: "ALLOW_ALL",
},
},
});

5. Run and apply the configuration

You can run the following to check your Pulumi config:

pulumi preview

Once you are happy with it, you can then run:

pulumi up

Once you select “YES”, you should see all the infrastructure being built!

You will see the resources in both Twingate and Azure being created. This will take a few minutes so now is a good time to reward yourself with a ☕️

The only thing left to do is give your Twingate user access to the new Group that has been created.

6. Test access

Once you are done, you should be able to connect your browser to the IP address of our test web server VM and see the Hello, World! message.

We can now destroy the infrastructure by using the following command:

pulumi down

Last updated 2 months ago