Creating private endpoint for Azure storage account using Terraform

According to Microsoft, An Azure storage account contains all of your Azure Storage data objects: blobs, file shares, queues, tables, and disks. The storage account provides a unique namespace for your Azure Storage data that's accessible from anywhere in the world over HTTP or HTTPS. Data in your storage account is durable and highly available, secure, and massively scalable. Read more...

For this blog, we are concerned with only blob storage. We will create a storage account, and one container in it. And also we will create one virtual machine using which we can access the storage account (container). Storage account can't be accessible from the public internet and also we are not whitelisting the subnet in which the virtual machine resides.

All code used in this blog is available at https://github.com/lets-learn-it/terraform-learning/tree/azure/05-private-endpoint

We will create all infrastructure in 4 steps.

  1. resource group, virtual network, and subnets
  2. a virtual machine in the public subnet
  3. storage account with one container
  4. DNS zone and private endpoint

Directory structure

Resource group, V-net, and subnets

We need one variable, i.e. resource group name. let's create it first. I am hardcoding the value of it.

variable "resource_group_name" {
  type = string
  default = "qwerty12344321"
}

All resources will be created in a single resource group for sake of simplisity. You can create different resource groups if you want.

resource "azurerm_resource_group" "example" {
  name     = var.resource_group_name
  location = "East US"
}

Now we need a virtual network and 2 subnets inside it. For one of the subnets, set flag enforce_private_link_endpoint_network_policies to true. It is necessary to create a private endpoint in that subnet.

resource "azurerm_virtual_network" "example" {
  name                = "example-network"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
}

# we will create VM in this subnet
resource "azurerm_subnet" "public_subnet" {
  name                 = "public_subnet"
  resource_group_name  = azurerm_resource_group.example.name
  virtual_network_name = azurerm_virtual_network.example.name
  address_prefixes     = ["10.0.1.0/24"]
}

# we will create private endpoint in this subnet
resource "azurerm_subnet" "endpoint_subnet" {
  name                 = "endpoint_subnet"
  resource_group_name  = azurerm_resource_group.example.name
  virtual_network_name = azurerm_virtual_network.example.name
  address_prefixes     = ["10.0.2.0/24"]

  enforce_private_link_endpoint_network_policies = true
}

The virtual machine in the public subnet

Now, we will write a module to create a virtual machine. We will place it in the public subnet and give it public IP so that we can connect it using SSH. for creating a module, create a directory named vm and start creating files in it.

Create public IP first, so that we can attach it to the network interface.

resource "azurerm_public_ip" "public_ip" {
  name                = format("%s_%s", var.name, "ip")
  resource_group_name = var.resource_group_name
  location            = var.location
  allocation_method   = "Dynamic"
}

Now we can create a network interface and attach public IP (previously created) to it.

resource "azurerm_network_interface" "example" {
  name                = format("%s_%s", var.name, "network_interface")
  location            = var.location
  resource_group_name = var.resource_group_name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = var.subnet_id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id = azurerm_public_ip.public_ip.id
  }
}

As the network interface is available to use, we will create an ubuntu server VM.

resource "azurerm_linux_virtual_machine" "example" {
  name                = format("%s%s", var.name, "vm")
  resource_group_name = var.resource_group_name
  location            = var.location
  size                = "Standard_B1s"
  admin_username      = "adminuser"

  network_interface_ids = [
    azurerm_network_interface.example.id,
  ]

  admin_ssh_key {
    username   = "adminuser"
    public_key = file(var.public_key_path)
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "UbuntuServer"
    sku       = "16.04-LTS"
    version   = "latest"
  }

}

We want to access this VM using SSH. For that, we need to add the network security group to the network interface. Let's create NSG and attach it to the already created network interface.

resource "azurerm_network_security_group" "nsg" {
  name                = format("%s_%s", var.name, "nsg")
  location            = var.location
  resource_group_name = var.resource_group_name

  security_rule {
    name                       = "allow_ssh_sg"
    priority                   = 100 
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  depends_on = [
    azurerm_network_interface.example
  ]
}

resource "azurerm_network_interface_security_group_association" "association" {
  network_interface_id      = azurerm_network_interface.example.id
  network_security_group_id = azurerm_network_security_group.nsg.id
}

As vm is a module and we will be passing variables while using it. Let's create these variables so that we can create a reusable module.

variable "resource_group_name" {
  type = string
}

variable "public_key_path" {
  type = string
}

variable "name" {
  type = string
}

variable "subnet_id" {
  type = string
}

variable "location" {
  type = string
}

This vm module will output public_ip of the virtual machine.

output "public_ip" {
  value = azurerm_public_ip.public_ip.ip_address
}

Our module for the virtual machines is ready. Let's use it.

module "vm" {
	# using it from outside of vm directory
    source = "./vm/"
    
    resource_group_name = azurerm_resource_group.example.name
    location = azurerm_resource_group.example.location
    
    # make sure you have public key at this location
    public_key_path = "C:/Users/wv3cxq/.ssh/id_rsa.pub"
    name = "demo"
    subnet_id = azurerm_subnet.public_subnet.id
}

Storage account with one container

Now, we will write a module to create a storage account. for creating a module, create a directory named vm and start creating files in it.

resource "azurerm_storage_account" "storage" {
  name                     = format("%s%s", var.name, "storage9553")
  resource_group_name      = var.resource_group_name
  location                 = var.location
  account_tier             = "Standard"
  account_replication_type = "GRS"

  network_rules {
      default_action = "Deny"
      ip_rules = var.white_list_ip
  }
}

As we used variables in the virtual machine module, this module also needs some variables.

variable "resource_group_name" {
  type = string
}

variable "name" {
  type = string
}

variable "location" {
  type = string
}

variable "white_list_ip" {
  type = list(string)
  default = []
}

We will output some values using which we can connect to the storage account using Azure CLI. while outputting these values in production, please mark them as sensitive.

output "primary_connection_string" {
  value = azurerm_storage_account.storage.primary_connection_string
}

output "storage_account_id" {
    value = azurerm_storage_account.storage.id
}

output "primary_access_key" {
    value = azurerm_storage_account.storage.primary_access_key
}

output "storage_account_name" {
  value = azurerm_storage_account.storage.name
}

Our module for the storage account is now complete. Let's use it to create a storage account and then we will create the container in it.

module "storage_account" {
    source = "./storageaccount"

    resource_group_name = azurerm_resource_group.example.name
    location = azurerm_resource_group.example.location
    name = "demo"
    
    white_list_ip = []
}

resource "azurerm_storage_container" "container" {
  name                  = "demo"
  storage_account_name  = module.storage_account.storage_account_name
  container_access_type = "private"
}

Now we have a storage account with us. You can check it using a console or add your machine's public IP in white_list_ip and try to access it using Azure CLI. We will see How to access blob storage using Azure CLI? at last of this blog.

DNS zone and private endpoint

Before creating a private endpoint, we need a private DNS zone so that we can create a record of private endpoint in this zone. We also need to link this zone to the virtual network (this is necessary else name resolution will fail).

resource "azurerm_private_dns_zone" "example" {
  name                = "privatelink.blob.core.windows.net"
  resource_group_name = azurerm_resource_group.example.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "network_link" {
  name                  = "vnet_link"
  resource_group_name   = azurerm_resource_group.example.name
  private_dns_zone_name = azurerm_private_dns_zone.example.name
  virtual_network_id    = azurerm_virtual_network.example.id
}

Now the most important part will start. module creation for the private endpoint. We will create a record in this module itself, so that we can achieve reusability of the module.

resource "azurerm_private_endpoint" "endpoint" {
  name                = format("%s-%s", var.name, "private-endpoint")
  location            = var.location
  resource_group_name = var.resource_group_name
  subnet_id           = var.subnet_id

  private_service_connection {
    name                           = format("%s-%s", var.name, "privateserviceconnection")
    private_connection_resource_id = var.private_link_enabled_resource_id
    is_manual_connection           = false
    subresource_names              = var.subresource_names
  }
}

resource "azurerm_private_dns_a_record" "dns_a" {
  name                = format("%s-%s", var.name, "arecord")
  zone_name           = var.private_dns_zone_name
  resource_group_name = var.resource_group_name
  ttl                 = 300
  records             = [azurerm_private_endpoint.endpoint.private_service_connection.0.private_ip_address]
}

Let's create variables and outputs for this module also.

variable "resource_group_name" {
  type = string
}

variable "name" {
  type = string
}

variable "location" {
  type = string
}

variable "subnet_id" {
  type = string
}

variable "private_link_enabled_resource_id" {
  type = string
}

variable "private_dns_zone_name" {
  type = string
}

variable "subresource_names" {
  type = list(string)
}

We need a fully qualified domain name (FQDN) of a private endpoint to access the storage account. Let's output it.

output "dns_a_record" {
    value = azurerm_private_dns_a_record.dns_a.fqdn
}

Use this module and create a private endpoint with a record in previously created private DNS zone.

module "privateendpoint" {
    source = "./privateendpoint/"

    resource_group_name = azurerm_resource_group.example.name
    location = azurerm_resource_group.example.location
    name = "demo"

    subnet_id = azurerm_subnet.endpoint_subnet.id
    private_link_enabled_resource_id = module.storage_account.storage_account_id
    private_dns_zone_name = azurerm_private_dns_zone.example.name
    
    # you can add other subresouce also
    subresource_names = ["blob"]

    depends_on = [
      azurerm_private_dns_zone.example
    ]
}

Everything is in place except output values. We are outputting some values from the module but to get these, we need to output in the root directory also.

output "dns_a_record" {
    value = module.privateendpoint.dns_a_record
}

output "primary_connection_string" {
  value = module.storage_account.primary_connection_string
}

output "storage_account_id" {
    value = module.storage_account.storage_account_id
}

output "public_ip" {
    value = module.vm.public_ip
}

output "storage_primary_access_key" {
    value = module.storage_account.primary_access_key
}

Creating resources

First, run the following command to import all plugins and modules.

terraform init

To check what will get created? run plan. To create a plan, run the following command.

terraform plan

Check plan, if everything is OK, you are free to create all resources with a single command.

terraform apply

While creating resources using the apply command, you may get errors like below. This is coming because we are not allowing our machine which is running terraform apply to access our storage account. That's why terraform is not able to create containers. You can add your public IP in white_list_ip list.

All resources got created. after adding public IP to white_list_ip the list. A total of 15 resources is created. It is showing 1 added and 1 destroyed because I reran apply.

As you can see, I got public IP 20.119.70.83  as output. Let's connect it using SSH. but before that let me show you my storage account's networking from the console. I am allowing only access from my personal computer. Storage account should not be accessible to the virtual machine. But because of the private endpoint, It will access.

If you go to the Private endpoint connections tab, you can find the private endpoint which we created.

Connect to VM and Test access

To connect the virtual machine, run the following command,

ssh -i <private_key_path> adminuser@<public_ip>

To check access to the storage account, we need Azure CLI. But by default, it is not installed on the virtual machine. To install it first, using the following commands,

sudo apt udpate
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

check DNS name resolution is working or not. To check that run the following command. you can find FQDN in output values. We are outputting it as dns_a_record. It should be pointing to private IP in endpoint subnet

nslookup demostorage9553.privatelink.blob.core.windows.net

I tried creating other record, other than demostorage9553 but it wasn't working. If anybody knows why and give me some resource to understand why it is not working? That will be great. I think mapping is based on record. and also pls tell me if there any way to give endpoint to az cli for storage account.

Now use Azure CLI to check access to blob storage. I added one object manually to demo container.  Run the following command to access data from demo container. You can get connection string from outputs.

az storage blob list \
  --container-name demo
  --connection-string <connection_string>

Destroy all resources

terraform destroy

References

Storage account overview
What is the private endpoint?
Use private endpoints for azure storage
Install Azure CLI in an ubuntu machine