Quick take — A reusable hashicorp/azurerm ~> 4.0 module for Azure Bastion Host: SKU-aware features, native client tunneling, IP-based connect and a correctly named AzureBastionSubnet, all var-driven. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "bastion" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-bastion?ref=v1.0.0"
name = "..." # Name of the Azure Bastion host (3-80 chars, alphanumeri…
resource_group_name = "..." # Resource group for the Bastion, public IP and subnet.
location = "..." # Azure region; must match the VNet's region.
virtual_network_name = "..." # Existing VNet into which `AzureBastionSubnet` is create…
bastion_subnet_prefix = "..." # CIDR for `AzureBastionSubnet`; must be `/26` or larger.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Bastion is a fully managed PaaS jump host that you deploy inside a virtual network. Once it exists, your engineers reach VM RDP/SSH over TLS through the Azure portal — or, on the Standard/Premium SKUs, through a native az network bastion tunnel — without ever assigning a public IP to the target VMs and without opening 3389/22 to the internet. The Bastion itself terminates the session and proxies it privately over the VNet, so the blast radius of a leaked credential or an exposed management port collapses to “an authenticated user who already has portal/RBAC access.”
The catch is that a correct Bastion deployment has several fiddly, easy-to-get-wrong requirements that the provider will happily let you violate until apply time: the subnet must be named exactly AzureBastionSubnet, it must be at least a /26 (a /27 is only accepted on Basic and blocks scale-out), the public IP must be Standard SKU and Static allocation, and most of the interesting features (IP-based connection, native client, shareable links, tunneling) are SKU-gated and silently invalid on Basic. Wrapping all of that in a module means every team consumes a Bastion that is named to your convention, sized correctly, and feature-flagged to its SKU — instead of re-discovering the subnet-name footgun in code review every quarter.
When to use it
- You run VMs (Windows or Linux) that need interactive admin access but you have a hard “no public IPs on workloads” policy.
- You want to retire a self-managed jump-box VM (and its patching, NSG, and standing public IP) in favour of managed PaaS.
- You need native-client SSH/RDP tunneling for power users (e.g.
scp/file copy, or VS Code Remote-SSH) and shareable links for occasional external operators — both require the Standard SKU or higher, which this module exposes via flags. - You are deploying a hub-and-spoke topology and want one Bastion in the hub reachable from spoke VMs (pair this module’s Standard SKU with VNet peering).
- Skip it for fully private CI/CD or app-to-app traffic — Bastion is for human interactive sessions, not service-to-service connectivity.
Module structure
terraform-module-azure-bastion/
├── versions.tf # provider + Terraform version pins
├── main.tf # AzureBastionSubnet, Standard public IP, bastion host
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/name/dns + the public IP + subnet id
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Bastion requires a dedicated subnet named EXACTLY "AzureBastionSubnet".
bastion_subnet_name = "AzureBastionSubnet"
# Tunneling, IP-connect, shareable links and scale units are Standard/Premium only.
is_standard_or_higher = contains(["Standard", "Premium"], var.sku)
}
resource "azurerm_subnet" "bastion" {
name = local.bastion_subnet_name
resource_group_name = var.resource_group_name
virtual_network_name = var.virtual_network_name
address_prefixes = [var.bastion_subnet_prefix]
}
resource "azurerm_public_ip" "bastion" {
name = "${var.name}-pip"
resource_group_name = var.resource_group_name
location = var.location
# Bastion mandates a Standard SKU, statically allocated public IP.
allocation_method = "Static"
sku = "Standard"
zones = var.zones
tags = var.tags
}
resource "azurerm_bastion_host" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
sku = var.sku
# scale_units is only honoured on Standard/Premium; Basic is fixed at 2.
scale_units = local.is_standard_or_higher ? var.scale_units : 2
# All of these capabilities require Standard or higher. Force them off on Basic
# so a Basic deployment never sends an invalid request to the API.
copy_paste_enabled = var.copy_paste_enabled
file_copy_enabled = local.is_standard_or_higher ? var.file_copy_enabled : false
ip_connect_enabled = local.is_standard_or_higher ? var.ip_connect_enabled : false
shareable_link_enabled = local.is_standard_or_higher ? var.shareable_link_enabled : false
tunneling_enabled = local.is_standard_or_higher ? var.tunneling_enabled : false
# Session recording is a Premium-only feature.
session_recording_enabled = var.sku == "Premium" ? var.session_recording_enabled : false
ip_configuration {
name = "configuration"
subnet_id = azurerm_subnet.bastion.id
public_ip_address_id = azurerm_public_ip.bastion.id
}
tags = var.tags
}
variables.tf
variable "name" {
description = "Name of the Azure Bastion host."
type = string
validation {
condition = can(regex("^[A-Za-z0-9][A-Za-z0-9-]{1,78}[A-Za-z0-9]$", var.name))
error_message = "name must be 3-80 chars, alphanumeric or hyphen, and start/end with alphanumeric."
}
}
variable "resource_group_name" {
description = "Resource group that holds the Bastion, its public IP and subnet."
type = string
}
variable "location" {
description = "Azure region (e.g. centralindia). Must match the VNet's region."
type = string
}
variable "virtual_network_name" {
description = "Name of the existing VNet into which the AzureBastionSubnet is created."
type = string
}
variable "bastion_subnet_prefix" {
description = "CIDR for AzureBastionSubnet. Must be /26 or larger (a /27 only works on Basic)."
type = string
validation {
condition = tonumber(split("/", var.bastion_subnet_prefix)[1]) <= 26
error_message = "bastion_subnet_prefix must be /26 or larger (smaller prefix length number)."
}
}
variable "sku" {
description = "Bastion SKU: Basic, Standard or Premium. Many features require Standard+."
type = string
default = "Standard"
validation {
condition = contains(["Basic", "Standard", "Premium"], var.sku)
error_message = "sku must be one of: Basic, Standard, Premium."
}
}
variable "scale_units" {
description = "Number of scale units (2-50). Standard/Premium only; ignored on Basic."
type = number
default = 2
validation {
condition = var.scale_units >= 2 && var.scale_units <= 50
error_message = "scale_units must be between 2 and 50."
}
}
variable "zones" {
description = "Availability Zones for the Bastion public IP (e.g. [\"1\",\"2\",\"3\"]). Empty for regions without zones."
type = list(string)
default = []
}
variable "copy_paste_enabled" {
description = "Allow copy/paste in the session. Supported on all SKUs."
type = bool
default = true
}
variable "file_copy_enabled" {
description = "Allow file copy/upload/download. Standard/Premium only."
type = bool
default = false
}
variable "ip_connect_enabled" {
description = "Allow connecting to a VM by private IP rather than resource ID. Standard/Premium only."
type = bool
default = false
}
variable "shareable_link_enabled" {
description = "Allow generating shareable links to VMs. Standard/Premium only."
type = bool
default = false
}
variable "tunneling_enabled" {
description = "Allow native-client (az network bastion tunnel) connections. Standard/Premium only."
type = bool
default = false
}
variable "session_recording_enabled" {
description = "Record graphical sessions. Premium SKU only."
type = bool
default = false
}
variable "tags" {
description = "Tags applied to the Bastion host and its public IP."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the Bastion host."
value = azurerm_bastion_host.this.id
}
output "name" {
description = "Name of the Bastion host."
value = azurerm_bastion_host.this.name
}
output "dns_name" {
description = "FQDN used to connect to the Bastion host."
value = azurerm_bastion_host.this.dns_name
}
output "sku" {
description = "Effective SKU of the deployed Bastion host."
value = azurerm_bastion_host.this.sku
}
output "public_ip_id" {
description = "Resource ID of the Bastion's Standard public IP."
value = azurerm_public_ip.bastion.id
}
output "public_ip_address" {
description = "Allocated public IP address of the Bastion frontend."
value = azurerm_public_ip.bastion.ip_address
}
output "subnet_id" {
description = "Resource ID of the AzureBastionSubnet created by this module."
value = azurerm_subnet.bastion.id
}
How to use it
module "bastion_host" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-bastion?ref=v1.0.0"
name = "bst-hub-prod-cin"
resource_group_name = azurerm_resource_group.hub.name
location = "centralindia"
virtual_network_name = azurerm_virtual_network.hub.name
# /26 leaves room for Bastion scale-out; /27 would cap you on Basic only.
bastion_subnet_prefix = "10.10.250.0/26"
sku = "Standard"
scale_units = 4
zones = ["1", "2", "3"]
# Standard-SKU power features for the platform team.
tunneling_enabled = true
ip_connect_enabled = true
file_copy_enabled = true
tags = {
environment = "prod"
owner = "platform-team"
costcenter = "CC-1042"
}
}
# Downstream: surface the Bastion FQDN so the runbook/output bundle can show
# operators exactly which host to connect through.
output "bastion_connect_fqdn" {
description = "FQDN engineers tunnel through to reach private VMs."
value = module.bastion_host.dns_name
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/bastion/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-bastion?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
virtual_network_name = "..."
bastion_subnet_prefix = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/bastion && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the Azure Bastion host (3-80 chars, alphanumeric/hyphen). |
resource_group_name |
string |
— | Yes | Resource group for the Bastion, public IP and subnet. |
location |
string |
— | Yes | Azure region; must match the VNet’s region. |
virtual_network_name |
string |
— | Yes | Existing VNet into which AzureBastionSubnet is created. |
bastion_subnet_prefix |
string |
— | Yes | CIDR for AzureBastionSubnet; must be /26 or larger. |
sku |
string |
"Standard" |
No | Basic, Standard, or Premium. |
scale_units |
number |
2 |
No | Scale units (2-50); honoured on Standard/Premium only. |
zones |
list(string) |
[] |
No | Availability Zones for the public IP. |
copy_paste_enabled |
bool |
true |
No | Allow clipboard copy/paste in sessions (all SKUs). |
file_copy_enabled |
bool |
false |
No | Allow file copy; Standard/Premium only. |
ip_connect_enabled |
bool |
false |
No | Connect to VMs by private IP; Standard/Premium only. |
shareable_link_enabled |
bool |
false |
No | Generate shareable VM links; Standard/Premium only. |
tunneling_enabled |
bool |
false |
No | Native-client tunneling; Standard/Premium only. |
session_recording_enabled |
bool |
false |
No | Record graphical sessions; Premium only. |
tags |
map(string) |
{} |
No | Tags applied to the Bastion host and public IP. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Bastion host. |
name |
Name of the Bastion host. |
dns_name |
FQDN used to connect to the Bastion host. |
sku |
Effective SKU of the deployed Bastion host. |
public_ip_id |
Resource ID of the Bastion’s Standard public IP. |
public_ip_address |
Allocated public IP address of the Bastion frontend. |
subnet_id |
Resource ID of the AzureBastionSubnet created by this module. |
Enterprise scenario
A financial-services platform team runs a hub-and-spoke landing zone in centralindia where a hard guardrail (Azure Policy) denies public IPs on any workload VM. They deploy one Standard-SKU Bastion in the hub VNet via this module with tunneling_enabled = true and ip_connect_enabled = true, then peer all spoke VNets to the hub. Operators use az network bastion tunnel for VS Code Remote-SSH and secure file copy into private spoke VMs, while session audit flows to Log Analytics — eliminating roughly forty standing jump-box VMs and their patch/NSG overhead, and shrinking the externally reachable management surface to zero open 3389/22 ports.
Best practices
- Never shrink the subnet below
/26. A/27only works on Basic and permanently blocks scale-out; the module’s validation enforces this so a Standard Bastion always has room to grow its scale units. - Right-size the SKU to actual usage. Bastion is billed hourly per host plus per scale unit and outbound data — start at
Standardwithscale_units = 2, raise scale units only when concurrent sessions saturate, and reservePremiumfor when you genuinely need session recording. - Lock down the subnet, not the Bastion. Azure manages the platform NSG implicitly; if you attach your own NSG to
AzureBastionSubnet, keep the documented inbound (443 from Internet/GatewayManager) and inter-Bastion rules intact or the host will fail to provision. - Centralize one Bastion per hub. Deploy a single Bastion in the hub and reach spokes over peering instead of one-per-VNet — fewer hosts means lower standing cost and a single audit chokepoint.
- Name to convention and tag for chargeback. Use a CAF-style name like
bst-<scope>-<env>-<region>and propagatetagsto both the host and its public IP so cost and ownership reports stay coherent. - Pair native tunneling with RBAC and Just-In-Time intent. Enabling
tunneling_enabled/ip_connect_enabledis powerful, so gate who can reach the Bastion with Reader+Bastion UserRBAC and route session logs to Log Analytics for an auditable record of every connection.