Episode #5: Securing Azure Virtual Machines with Bastion
In the previous episode of Azure Terraformer, we demonstrated how to provision an Azure Linux Virtual Machine (VM) with a public IP and SSH access secured through an Azure Key Vault-stored public key. While functional, exposing a VM with a public IP presents security risks. In fifth episode, we refine that setup by removing the public IP and introducing Azure Bastion as the access point.
Azure Bastion allows users to connect to VMs over SSH without assigning a public IP. This reduces attack exposure while still enabling secure remote access. To implement this, a public IP is assigned to the Bastion service rather than the VM. The VM retains only a private IP, with access routed through Bastion.
Provisioning the Public IP for Bastion
The first step in this transition is provisioning the public IP for Bastion. Unlike the VM’s previous public IP, this one serves exclusively for the Bastion service. The configuration specifies a static allocation and a standard SKU, as required by Bastion.
resource "azurerm_public_ip" "bastion" {
name = "pip-ep5-bas${random_string.main.result}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
allocation_method = "Static"
sku = "Standard"
}
Configuring the Virtual Network and Subnets
Before deploying the Azure Bastion host, a virtual network and associated subnets must be created. The virtual network (vnet-ep5) provides an address space for the environment. Two subnets are defined: one for the VM (snet-default) and one specifically for Bastion (AzureBastionSubnet).
resource "azurerm_virtual_network" "main" {
name = "vnet-ep5-${random_string.main.result}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
address_space = ["10.0.0.0/16"]
}
resource "azurerm_subnet" "bastion" {
name = "AzureBastionSubnet"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.0.0/24"]
}
resource "azurerm_subnet" "default" {
name = "snet-default"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.1.0/24"]
}
Before deploying the Azure Bastion host, a dedicated subnet named AzureBastionSubnet must be created. This subnet is required for Bastion to function properly and must follow the naming convention enforced by Azure.
resource "azurerm_subnet" "bastion" {
name = "AzureBastionSubnet"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.0.0/24"]
}
Deploying the Azure Bastion Host
With the public IP in place, the next step is deploying an Azure Bastion host. This connects to a dedicated Bastion subnet and is assigned the newly created public IP.
resource "azurerm_bastion_host" "main" {
name = "bas-${random_string.main.result}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
ip_configuration {
name = "configuration"
subnet_id = azurerm_subnet.bastion.id
public_ip_address_id = azurerm_public_ip.bastion.id
}
}
Modifying the Network Interface (NIC)
To complete the transition, the VM’s network interface is reconfigured. The previous configuration included a public IP, which is now removed. The updated configuration maintains only a private IP.
Previous Configuration (with Public IP)
resource "azurerm_network_interface" "main" {
name = "nic-vm${random_string.main.result}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
ip_configuration {
name = "public"
subnet_id = data.azurerm_subnet.default.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.main.id
}
}
Updated Configuration (Private IP Only)
resource "azurerm_network_interface" "main" {
name = "nic-vm${random_string.main.result}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.default.id
private_ip_address_allocation = "Dynamic"
}
}
Handling Deployment Issues
During deployment, the resource group was destroyed and recreated to ensure consistency. This was necessary to correct naming conventions and avoid potential misconfigurations. Azure Bastion takes time to provision, making it important to finalize configurations before deployment. An issue arose when deleting the resource group due to lingering resources, specifically a failed public IP deletion. The deletion was interrupted when Terraform was manually stopped mid-operation. This caused inconsistencies in the state file, requiring manual intervention.
Connecting to the VM via Azure Bastion
With Bastion deployed, remote access to the VM is now handled through the Azure portal. The process involves selecting the Bastion service, choosing the VM, and authenticating using the SSH key stored in Key Vault.
Conclusion
This change eliminates the need for a public IP on the VM, improving security while maintaining accessibility. Removing direct exposure reduces the risk of external attacks. Future episodes will continue refining this setup, incorporating Just-in-Time access controls and Network Security Groups to further secure cloud infrastructure.