IaC Azure

Terraform Module: Azure Bastion Host — Secure RDP/SSH Without Public IPs

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

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 configlive/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 configlive/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

TerraformAzureBastion HostModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading