Skip to content

Instantly share code, notes, and snippets.

@maskati
Last active February 3, 2025 12:52
Show Gist options
  • Save maskati/446d72d751f90c4539db3adc4a7a664e to your computer and use it in GitHub Desktop.
Save maskati/446d72d751f90c4539db3adc4a7a664e to your computer and use it in GitHub Desktop.
Host your own global VPN on Azure PaaS using Tailscale

Host your own global VPN on Azure PaaS using Tailscale

This example shows setting up a Tailscale exit node running as a container on Azure Container Instances to provide global Internet egress. You can also use a similar setup to configure a Tailscale subnet router which would allow access to Azure private Virtual Networks, private endpoints, private DNS zone resolution as well as Azure service endpoints.

You can use exit nodes on several platforms including Android, iOS, Linux, macOS, tvOS and Windows.

Warning

Using an exit node will tunnel all your traffic through the selected Azure region. This might trigger certain security controls such as Entra ID protection impossible travel.

Note

This deployment uses a Microsoft Artifact Registry published Azure Linux image with a scripted installation of Tailscale. This is done instead of using the ready Tailscale image published on Docker Hub due to Docker anonymous uage limits.

Deployment

Step 1: Sign-up for Tailscale

Sign-up for a free Tailscale personal plan. The free plan supports up to 3 users and 100 devices.

Step 2: Configure your Tailscale policy

Define a Tailscale ACL policy in the access controls section of the admin portal:

{
  "tagOwners": {
    "tag:exitnode": [],
  },
  "autoApprovers": {
    "exitNode": ["tag:exitnode"],
  },
  "acls": [
    {
      "action": "accept",
      "src":    ["*"],
      "dst":    ["*:*"],
    },
  ],
}

Step 3: Create an OAuth client

Create a Tailscale OAuth client in the OAuth clients settings section of the admin portal. Configure the OAuth client as follows:

  • Description a descriptive name e.g. Azure VPN
  • Scope Keys -> Auth Keys -> Write (auth_keys) with the assigned tag tag:exitnode. This allows the OAuth client to exchange the client secret for an authentication key to register the node with using OAuth credentials.
  • Scope Devices -> Core -> Write (devices:core) with the assigned tag tag:exitnode. This allows the OAuth client to register and auto approve itself as a device.

After creation of the client you will be shown a client ID and client secret. The client secret is of the form tskey-client-<clientid>-<secret>. You will need the client secret for the tailscaleClientSecret deployment parameter in the next step.

Step 4: Deploy to Azure

Deploy the Bicep azure-vpn.bicep or click the button below to deploy the compiled ARM template azure-vpn.json. Configure parameters:

  • Region not really relevant, this is the region for your resource group metadata
  • Location which region to deploy the Azure Container Instance to serve as the VPN exit node
  • Tailscale Client Secret the OAuth client secret from the previous step
  • Tailscale Tag can be left as tag:exitnode if you did not change this in the earlier configuration steps

Deploy to Azure

Tip

Repeat with different Location values to deploy exit nodes at different Azure regions around the world.

An example with various regions deployed:

image

Using the VPN

You can use the exit node by selecting the Tailscale icon and navigating to Use exit node then selecting the name of the exit node device.

image

Performing an IP lookup when connected to the Azure East Japan region:

image

Clean up

  1. Stop and delete the Azure Container Instances.
  2. Ensure exit node devices are deregistered in the Tailscale admin machines listing.
  3. If desired revoke the OAuth client.
@description('Container instance location to deploy as Tailscale [exit node](https://tailscale.com/kb/1103/exit-nodes).')
// az provider show -n Microsoft.ContainerInstance --query "resourceTypes[?resourceType=='containerGroups'].locations | [0]" --output tsv
@allowed(['australiacentral','australiacentral2','australiaeast','australiasoutheast','brazilsouth','canadacentral','canadaeast'
'centralindia','centralus','eastasia','eastus','eastus2','francecentral','francesouth','germanynorth','germanywestcentral'
'israelcentral','italynorth','japaneast','japanwest','koreacentral','koreasouth','mexicocentral','newzealandnorth','northcentralus'
'northeurope','norwayeast','norwaywest','polandcentral','qatarcentral','southafricanorth','southafricawest','southcentralus'
'southeastasia','southindia','spaincentral','swedencentral','switzerlandnorth','switzerlandwest','uaecentral','uaenorth','uksouth'
'ukwest','westcentralus','westeurope','westindia','westus','westus2','westus3'])
param location string
@description('''Tailscale [OAuth client](https://tailscale.com/kb/1215/oauth-clients) secret, must have `auth_keys` and `devices:core`
scopes and be assigned a tag with a value equal to the param `tailscaleTag` e.g. `tag:exitnode`.''')
@secure()
#disable-next-line secure-parameter-default
param tailscaleClientSecret string
@description('''Tailscale tag to advertise as exit node. Must be defined in Tailscale policy
[tagOwners](https://tailscale.com/kb/1337/acl-syntax#tag-owners),
[autoApprovers.exitNode](https://tailscale.com/kb/1337/acl-syntax#autoapprovers).''')
param tailscaleTag string = 'tag:exitnode'
resource aci 'Microsoft.ContainerInstance/containerGroups@2023-05-01' = {
name: 'aci-tailscale-${location}'
location: location
properties: {
sku: 'Standard'
osType: 'Linux'
restartPolicy: 'Never'
ipAddress: {
type: 'Public'
ports: [
// help Tailscale make a peer-to-peer connection rather than falling back to a relay
// https://tailscale.com/kb/1082/firewall-ports#my-devices-are-using-a-relay-what-can-i-do-to-help-them-connect-peer-to-peer
{
protocol: 'UDP'
port: 41641
}
]
}
containers: [
{
name: 'tailscale'
properties: {
// https://github.com/microsoft/azurelinux
// https://mcr.microsoft.com/en-us/artifact/mar/azurelinux/base/core/about
image: 'mcr.microsoft.com/azurelinux/base/core:3.0'
resources: {
requests: {
cpu: 1
memoryInGB: 1
}
}
ports: [
{
protocol: 'UDP'
port: 41641
}
]
environmentVariables: [
{
name: 'TAILSCALE_LOCATION'
value: location
}
{
name: 'TAILSCALE_TAG'
value: tailscaleTag
}
{
name: 'TAILSCALE_CLIENT_SECRET'
secureValue: tailscaleClientSecret
}
]
command: [
'/bin/bash'
'-c'
join([
// prerequisites to download tailscale
'tdnf update -yq && tdnf -yq install ca-certificates jq'
// download latest tailscale and extract to bin
'echo "Installing Tailscale..."'
'curl -fsSL "https://pkgs.tailscale.com/stable/$(curl -fsSL "https://pkgs.tailscale.com/stable/?mode=json" | jq -r ".Tarballs.amd64")" | bsdtar -xvzf - -C /usr/bin --strip-components 1 "*/tailscale" "*/tailscaled"'
// run tailscaled in the background with in-memory state
// and userspace networking mode https://tailscale.com/kb/1112/userspace-networking
// https://tailscale.com/kb/1278/tailscaled
'echo "Starting Tailscale service..."'
'tailscaled --state=mem: --tun=userspace-networking 2>/var/log/tailscaled.log &'
// wait for tailscaled to start
// json will make it exit with status 0 even if logged out
'tailscale status --json >/dev/null'
// report current version including daemon version
'tailscale version --daemon'
// connect to tailnet with exit node configuration
// https://tailscale.com/kb/1241/tailscale-up
'echo "Connecting to Tailscale..."'
'tailscale up --hostname="exitnode-$TAILSCALE_LOCATION" --authkey="$TAILSCALE_CLIENT_SECRET?preauthorized=true&ephemeral=true" --accept-dns="false" --advertise-exit-node --advertise-tags="$TAILSCALE_TAG"'
'echo "Connected to Tailscale"'
// report network health
'tailscale netcheck'
// follow the log file to stdout
'tail -f /var/log/tailscaled.log &'
// on termination deregister the exit node
'trap "tailscale down" SIGTERM'
// keep the container running until terminated
'sleep infinity &'
'pid=$!'
'wait $pid'
'echo "Disconnecting from Tailscale..."'
'tailscale down'
'echo "Disconnected from Tailscale"'
], '\n')
]
}
}
]
}
}
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"metadata": {
"_generator": {
"name": "bicep",
"version": "0.33.93.31351",
"templateHash": "15505456026913079018"
}
},
"parameters": {
"location": {
"type": "string",
"allowedValues": [
"australiacentral",
"australiacentral2",
"australiaeast",
"australiasoutheast",
"brazilsouth",
"canadacentral",
"canadaeast",
"centralindia",
"centralus",
"eastasia",
"eastus",
"eastus2",
"francecentral",
"francesouth",
"germanynorth",
"germanywestcentral",
"israelcentral",
"italynorth",
"japaneast",
"japanwest",
"koreacentral",
"koreasouth",
"mexicocentral",
"newzealandnorth",
"northcentralus",
"northeurope",
"norwayeast",
"norwaywest",
"polandcentral",
"qatarcentral",
"southafricanorth",
"southafricawest",
"southcentralus",
"southeastasia",
"southindia",
"spaincentral",
"swedencentral",
"switzerlandnorth",
"switzerlandwest",
"uaecentral",
"uaenorth",
"uksouth",
"ukwest",
"westcentralus",
"westeurope",
"westindia",
"westus",
"westus2",
"westus3"
],
"metadata": {
"description": "Container instance location to deploy as Tailscale [exit node](https://tailscale.com/kb/1103/exit-nodes)."
}
},
"tailscaleClientSecret": {
"type": "securestring",
"metadata": {
"description": "Tailscale [OAuth client](https://tailscale.com/kb/1215/oauth-clients) secret, must have `auth_keys` and `devices:core`\nscopes and be assigned a tag with a value equal to the param `tailscaleTag` e.g. `tag:exitnode`."
}
},
"tailscaleTag": {
"type": "string",
"defaultValue": "tag:exitnode",
"metadata": {
"description": "Tailscale tag to advertise as exit node. Must be defined in Tailscale policy \n[tagOwners](https://tailscale.com/kb/1337/acl-syntax#tag-owners), \n[autoApprovers.exitNode](https://tailscale.com/kb/1337/acl-syntax#autoapprovers)."
}
}
},
"resources": [
{
"type": "Microsoft.ContainerInstance/containerGroups",
"apiVersion": "2023-05-01",
"name": "[format('aci-tailscale-{0}', parameters('location'))]",
"location": "[parameters('location')]",
"properties": {
"sku": "Standard",
"osType": "Linux",
"restartPolicy": "Never",
"ipAddress": {
"type": "Public",
"ports": [
{
"protocol": "UDP",
"port": 41641
}
]
},
"containers": [
{
"name": "tailscale",
"properties": {
"image": "mcr.microsoft.com/azurelinux/base/core:3.0",
"resources": {
"requests": {
"cpu": 1,
"memoryInGB": 1
}
},
"ports": [
{
"protocol": "UDP",
"port": 41641
}
],
"environmentVariables": [
{
"name": "TAILSCALE_LOCATION",
"value": "[parameters('location')]"
},
{
"name": "TAILSCALE_TAG",
"value": "[parameters('tailscaleTag')]"
},
{
"name": "TAILSCALE_CLIENT_SECRET",
"secureValue": "[parameters('tailscaleClientSecret')]"
}
],
"command": [
"/bin/bash",
"-c",
"[join(createArray('tdnf update -yq && tdnf -yq install ca-certificates jq', 'echo \"Installing Tailscale...\"', 'curl -fsSL \"https://pkgs.tailscale.com/stable/$(curl -fsSL \"https://pkgs.tailscale.com/stable/?mode=json\" | jq -r \".Tarballs.amd64\")\" | bsdtar -xvzf - -C /usr/bin --strip-components 1 \"*/tailscale\" \"*/tailscaled\"', 'echo \"Starting Tailscale service...\"', 'tailscaled --state=mem: --tun=userspace-networking 2>/var/log/tailscaled.log &', 'tailscale status --json >/dev/null', 'tailscale version --daemon', 'echo \"Connecting to Tailscale...\"', 'tailscale up --hostname=\"exitnode-$TAILSCALE_LOCATION\" --authkey=\"$TAILSCALE_CLIENT_SECRET?preauthorized=true&ephemeral=true\" --accept-dns=\"false\" --advertise-exit-node --advertise-tags=\"$TAILSCALE_TAG\"', 'echo \"Connected to Tailscale\"', 'tailscale netcheck', 'tail -f /var/log/tailscaled.log &', 'trap \"tailscale down\" SIGTERM', 'sleep infinity &', 'pid=$!', 'wait $pid', 'echo \"Disconnecting from Tailscale...\"', 'tailscale down', 'echo \"Disconnected from Tailscale\"'), '\n')]"
]
}
}
]
}
}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment