Create an Ubuntu Pro VM with infrastructure in Azure using Terraform

Terraform is an open-source infrastructure as code software tool that provides a consistent CLI workflow to manage hundreds of cloud services. Terraform codifies cloud APIs into declarative configuration files. This article shows you how to create an Ubuntu Pro 18.04 VM and supporting resources in Azure with Terraform.

If you don’t know already, Ubuntu Pro is a premium image designed by Canonical to provide additional coverage for production environments running in the cloud. It includes security and compliance services, enabled by default, suitable for small to largescale Linux enterprise operations — no contract needed.

Key features include live kernel patching, which provides instant security and longer uptimes, broad security patching of major open source workloads for production use, and certified components for FedRAMP, HIPAA, PCI and ISO use cases. Ubuntu Pro (18.04 and above) is backed by a 10-year maintenance commitment by Canonical.

TL;DR, here is what you need to change in your Terraform config to use Ubuntu Pro instead of Ubuntu LTS:

     source_image_reference {
         publisher = "Canonical"
-        offer     = "UbuntuServer"
-        sku       = "18.04-LTS"
+        offer     = "0001-com-ubuntu-pro-bionic"
+        sku       = "pro-18_04-lts"
         version   = "latest"
     }
 
+    plan {
+        name = "pro-18_04-lts"
+        product = "0001-com-ubuntu-pro-bionic"
+        publisher = "canonical"
+    }

There is an excellent article in the Microsoft documentation that already explains how to create a Linux VM with infrastructure in Azure using Terraform that uses a regular Ubuntu 18.04 LTS VM. I’m going to use the same example from that page but point out the few things that need to be changed to use Ubuntu Pro 18.04 instead of regular Ubuntu 18.04.

It’s necessary to add the plan block because the Ubuntu Pro image comes from the Marketplace and requires a subscription. This plan block is how you do that.

The offer and the sku change too. Here’s how you can see the different offers from Canonical:

$ az vm image list-offers -p canonical -l eastus | grep name

    [...]
    "name": "0001-com-ubuntu-pro-bionic",
    "name": "0001-com-ubuntu-pro-bionic-fips",
    "name": "0001-com-ubuntu-pro-focal",
    "name": "0001-com-ubuntu-pro-trusty",
    "name": "0001-com-ubuntu-pro-xenial",
    "name": "0001-com-ubuntu-pro-xenial-fips",
    [...]
    "name": "UbuntuServer",
    "name": "Ubuntu_Core",

And then you can list the images inside one of those offers like this:

$ az vm image list -p canonical -l eastus --offer 0001-com-ubuntu-pro-bionic --all | grep sku | sort -u

    "sku": "pro-18_04-lts",
    "sku": "pro-18_04-lts-gen2",
    "sku": "pro-fips-18_04",
    "sku": "pro-fips-18_04-gen2",

If you want you can look for the specific URN of an image in one of these skus with this command:

$ az vm image list -p canonical -l eastus --offer 0001-com-ubuntu-pro-bionic --all --sku pro-18_04-lts

[
  {
    "offer": "0001-com-ubuntu-pro-bionic",
    "publisher": "Canonical",
    "sku": "pro-18_04-lts",
    "urn": "Canonical:0001-com-ubuntu-pro-bionic:pro-18_04-lts:18.04.20200318",
    "version": "18.04.20200318"
  },
[...]
  {
    "offer": "0001-com-ubuntu-pro-bionic",
    "publisher": "Canonical",
    "sku": "pro-18_04-lts-gen2",
    "urn": "Canonical:0001-com-ubuntu-pro-bionic:pro-18_04-lts-gen2:18.04.202103250",
    "version": "18.04.202103250"
  }
]

But for our example, we just use the latest image available in the pro-18_04-lts sku.

In my Terraform config, I also changed the admin username to ubuntu instead of azureuser and I use my own local SSH key instead of creating a new one in Azure. After doing terraform init, here’s what the terraform apply looks like:

$ terraform apply

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # azurerm_linux_virtual_machine.myterraformvm will be created
  + resource "azurerm_linux_virtual_machine" "myterraformvm" {
      + admin_username                  = "ubuntu"
      + allow_extension_operations      = true
      + computer_name                   = "myvm"
      + disable_password_authentication = true
      + extensions_time_budget          = "PT1H30M"
      + id                              = (known after apply)
      + location                        = "eastus"
      + max_bid_price                   = -1
      + name                            = "myVM"
      + network_interface_ids           = (known after apply)
      + platform_fault_domain           = -1
      + priority                        = "Regular"
      + private_ip_address              = (known after apply)
      + private_ip_addresses            = (known after apply)
      + provision_vm_agent              = true
      + public_ip_address               = (known after apply)
      + public_ip_addresses             = (known after apply)
      + resource_group_name             = "myResourceGroup"
      + size                            = "Standard_DS1_v2"
      + tags                            = {
          + "environment" = "Terraform Demo"
        }
      + virtual_machine_id              = (known after apply)
      + zone                            = (known after apply)

      + admin_ssh_key {
          + public_key = <<-EOT
                ssh-rsa <redacted>
            EOT
          + username   = "ubuntu"
        }

      + boot_diagnostics {
          + storage_account_uri = (known after apply)
        }

      + os_disk {
          + caching                   = "ReadWrite"
          + disk_size_gb              = (known after apply)
          + name                      = "myOsDisk"
          + storage_account_type      = "Premium_LRS"
          + write_accelerator_enabled = false
        }

      + plan {
          + name      = "pro-18_04-lts"
          + product   = "0001-com-ubuntu-pro-bionic"
          + publisher = "canonical"
        }

      + source_image_reference {
          + offer     = "0001-com-ubuntu-pro-bionic"
          + publisher = "Canonical"
          + sku       = "pro-18_04-lts"
          + version   = "latest"
        }
    }

  # azurerm_network_interface.myterraformnic will be created
  + resource "azurerm_network_interface" "myterraformnic" {
      + applied_dns_servers           = (known after apply)
      + dns_servers                   = (known after apply)
      + enable_accelerated_networking = false
      + enable_ip_forwarding          = false
      + id                            = (known after apply)
      + internal_dns_name_label       = (known after apply)
      + internal_domain_name_suffix   = (known after apply)
      + location                      = "eastus"
      + mac_address                   = (known after apply)
      + name                          = "myNIC"
      + private_ip_address            = (known after apply)
      + private_ip_addresses          = (known after apply)
      + resource_group_name           = "myResourceGroup"
      + tags                          = {
          + "environment" = "Terraform Demo"
        }
      + virtual_machine_id            = (known after apply)

      + ip_configuration {
          + name                          = "myNicConfiguration"
          + primary                       = (known after apply)
          + private_ip_address            = (known after apply)
          + private_ip_address_allocation = "dynamic"
          + private_ip_address_version    = "IPv4"
          + public_ip_address_id          = (known after apply)
          + subnet_id                     = (known after apply)
        }
    }

  # azurerm_network_interface_security_group_association.example will be created
  + resource "azurerm_network_interface_security_group_association" "example" {
      + id                        = (known after apply)
      + network_interface_id      = (known after apply)
      + network_security_group_id = (known after apply)
    }

  # azurerm_network_security_group.myterraformnsg will be created
  + resource "azurerm_network_security_group" "myterraformnsg" {
      + id                  = (known after apply)
      + location            = "eastus"
      + name                = "myNetworkSecurityGroup"
      + resource_group_name = "myResourceGroup"
      + security_rule       = [
          + {
              + access                                     = "Allow"
              + description                                = ""
              + destination_address_prefix                 = "*"
              + destination_address_prefixes               = []
              + destination_application_security_group_ids = []
              + destination_port_range                     = "22"
              + destination_port_ranges                    = []
              + direction                                  = "Inbound"
              + name                                       = "SSH"
              + priority                                   = 1001
              + protocol                                   = "Tcp"
              + source_address_prefix                      = "*"
              + source_address_prefixes                    = []
              + source_application_security_group_ids      = []
              + source_port_range                          = "*"
              + source_port_ranges                         = []
            },
        ]
      + tags                = {
          + "environment" = "Terraform Demo"
        }
    }

  # azurerm_public_ip.myterraformpublicip will be created
  + resource "azurerm_public_ip" "myterraformpublicip" {
      + allocation_method       = "Dynamic"
      + fqdn                    = (known after apply)
      + id                      = (known after apply)
      + idle_timeout_in_minutes = 4
      + ip_address              = (known after apply)
      + ip_version              = "IPv4"
      + location                = "eastus"
      + name                    = "myPublicIP"
      + resource_group_name     = "myResourceGroup"
      + sku                     = "Basic"
      + tags                    = {
          + "environment" = "Terraform Demo"
        }
    }

  # azurerm_resource_group.myterraformgroup will be created
  + resource "azurerm_resource_group" "myterraformgroup" {
      + id       = (known after apply)
      + location = "eastus"
      + name     = "myResourceGroup"
      + tags     = {
          + "environment" = "Terraform Demo"
        }
    }

  # azurerm_storage_account.mystorageaccount will be created
  + resource "azurerm_storage_account" "mystorageaccount" {
      + access_tier                      = (known after apply)
      + account_kind                     = "StorageV2"
      + account_replication_type         = "LRS"
      + account_tier                     = "Standard"
      + allow_blob_public_access         = false
      + enable_https_traffic_only        = true
      + id                               = (known after apply)
      + is_hns_enabled                   = false
      + large_file_share_enabled         = (known after apply)
      + location                         = "eastus"
      + min_tls_version                  = "TLS1_0"
      + name                             = (known after apply)
      + primary_access_key               = (sensitive value)
      + primary_blob_connection_string   = (sensitive value)
      + primary_blob_endpoint            = (known after apply)
      + primary_blob_host                = (known after apply)
      + primary_connection_string        = (sensitive value)
      + primary_dfs_endpoint             = (known after apply)
      + primary_dfs_host                 = (known after apply)
      + primary_file_endpoint            = (known after apply)
      + primary_file_host                = (known after apply)
      + primary_location                 = (known after apply)
      + primary_queue_endpoint           = (known after apply)
      + primary_queue_host               = (known after apply)
      + primary_table_endpoint           = (known after apply)
      + primary_table_host               = (known after apply)
      + primary_web_endpoint             = (known after apply)
      + primary_web_host                 = (known after apply)
      + resource_group_name              = "myResourceGroup"
      + secondary_access_key             = (sensitive value)
      + secondary_blob_connection_string = (sensitive value)
      + secondary_blob_endpoint          = (known after apply)
      + secondary_blob_host              = (known after apply)
      + secondary_connection_string      = (sensitive value)
      + secondary_dfs_endpoint           = (known after apply)
      + secondary_dfs_host               = (known after apply)
      + secondary_file_endpoint          = (known after apply)
      + secondary_file_host              = (known after apply)
      + secondary_location               = (known after apply)
      + secondary_queue_endpoint         = (known after apply)
      + secondary_queue_host             = (known after apply)
      + secondary_table_endpoint         = (known after apply)
      + secondary_table_host             = (known after apply)
      + secondary_web_endpoint           = (known after apply)
      + secondary_web_host               = (known after apply)
      + tags                             = {
          + "environment" = "Terraform Demo"
        }

      + blob_properties {
          + container_delete_retention_policy {
              + days = (known after apply)
            }

          + cors_rule {
              + allowed_headers    = (known after apply)
              + allowed_methods    = (known after apply)
              + allowed_origins    = (known after apply)
              + exposed_headers    = (known after apply)
              + max_age_in_seconds = (known after apply)
            }

          + delete_retention_policy {
              + days = (known after apply)
            }
        }

      + identity {
          + principal_id = (known after apply)
          + tenant_id    = (known after apply)
          + type         = (known after apply)
        }

      + network_rules {
          + bypass                     = (known after apply)
          + default_action             = (known after apply)
          + ip_rules                   = (known after apply)
          + virtual_network_subnet_ids = (known after apply)
        }

      + queue_properties {
          + cors_rule {
              + allowed_headers    = (known after apply)
              + allowed_methods    = (known after apply)
              + allowed_origins    = (known after apply)
              + exposed_headers    = (known after apply)
              + max_age_in_seconds = (known after apply)
            }

          + hour_metrics {
              + enabled               = (known after apply)
              + include_apis          = (known after apply)
              + retention_policy_days = (known after apply)
              + version               = (known after apply)
            }

          + logging {
              + delete                = (known after apply)
              + read                  = (known after apply)
              + retention_policy_days = (known after apply)
              + version               = (known after apply)
              + write                 = (known after apply)
            }

          + minute_metrics {
              + enabled               = (known after apply)
              + include_apis          = (known after apply)
              + retention_policy_days = (known after apply)
              + version               = (known after apply)
            }
        }
    }

  # azurerm_subnet.myterraformsubnet will be created
  + resource "azurerm_subnet" "myterraformsubnet" {
      + address_prefix                                 = (known after apply)
      + address_prefixes                               = [
          + "10.0.1.0/24",
        ]
      + enforce_private_link_endpoint_network_policies = false
      + enforce_private_link_service_network_policies  = false
      + id                                             = (known after apply)
      + name                                           = "mySubnet"
      + resource_group_name                            = "myResourceGroup"
      + virtual_network_name                           = "myVnet"
    }

  # azurerm_virtual_network.myterraformnetwork will be created
  + resource "azurerm_virtual_network" "myterraformnetwork" {
      + address_space         = [
          + "10.0.0.0/16",
        ]
      + guid                  = (known after apply)
      + id                    = (known after apply)
      + location              = "eastus"
      + name                  = "myVnet"
      + resource_group_name   = "myResourceGroup"
      + subnet                = (known after apply)
      + tags                  = {
          + "environment" = "Terraform Demo"
        }
      + vm_protection_enabled = false
    }

  # random_id.randomId will be created
  + resource "random_id" "randomId" {
      + b64_std     = (known after apply)
      + b64_url     = (known after apply)
      + byte_length = 8
      + dec         = (known after apply)
      + hex         = (known after apply)
      + id          = (known after apply)
      + keepers     = {
          + "resource_group" = "myResourceGroup"
        }
    }

Plan: 10 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

azurerm_resource_group.myterraformgroup: Creating...
[...]

Once the creation completes, you can list the VMs in the ressource group:

$ az vm list --resource-group myResourceGroup -o table -d
Name    ResourceGroup    PowerState    PublicIps      Fqdns    Location    Zones
------  ---------------  ------------  -------------  -------  ----------  -------
myVM    myResourceGroup  VM running    52.249.198.68           eastus

In my case, I can SSH in the VM with my SSH key and the ubuntu user:

$ ssh ubuntu@52.249.198.68

Welcome to Ubuntu 18.04.5 LTS (GNU/Linux 5.4.0-1043-azure x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Thu Apr 15 19:53:08 UTC 2021

  System load:  0.45              Processes:           121
  Usage of /:   5.5% of 28.90GB   Users logged in:     0
  Memory usage: 6%                IP address for eth0: 10.0.1.4
  Swap usage:   0%

UA Infra: Extended Security Maintenance (ESM) is enabled.

36 packages can be updated.
5 of these updates are fixed through UA Infra: ESM.
17 of these updates are security updates.
To see these additional updates run: apt list --upgradable



The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

And I can view the status of the UA services that come with Ubuntu Pro:

ubuntu@myvm:~$ ua status
SERVICE       ENTITLED  STATUS    DESCRIPTION
esm-apps      yes       enabled   UA Apps: Extended Security Maintenance (ESM)
esm-infra     yes       enabled   UA Infra: Extended Security Maintenance (ESM)
fips          yes       disabled  NIST-certified FIPS modules
fips-updates  yes       disabled  Uncertified security updates to FIPS modules
livepatch     yes       enabled   Canonical Livepatch service

Enable services with: ua enable <service>

                Account: <redacted>
           Subscription: <redacted>
            Valid until: 9999-12-31 00:00:00
Technical support level: essential

Don’t forget to destroy your Terraform environment with terraform destroy if you no longer need it.

Here is my complete config.tf file:

# Configure the Microsoft Azure Provider
provider "azurerm" {
    features {}
}

# Create a resource group if it doesn't exist
resource "azurerm_resource_group" "myterraformgroup" {
    name     = "myResourceGroup"
    location = "eastus"

    tags = {
        environment = "Terraform Demo"
    }
}

# Create virtual network
resource "azurerm_virtual_network" "myterraformnetwork" {
    name                = "myVnet"
    address_space       = ["10.0.0.0/16"]
    location            = "eastus"
    resource_group_name = azurerm_resource_group.myterraformgroup.name

    tags = {
        environment = "Terraform Demo"
    }
}

# Create subnet
resource "azurerm_subnet" "myterraformsubnet" {
    name                 = "mySubnet"
    resource_group_name  = azurerm_resource_group.myterraformgroup.name
    virtual_network_name = azurerm_virtual_network.myterraformnetwork.name
    address_prefixes       = ["10.0.1.0/24"]
}

# Create public IPs
resource "azurerm_public_ip" "myterraformpublicip" {
    name                         = "myPublicIP"
    location                     = "eastus"
    resource_group_name          = azurerm_resource_group.myterraformgroup.name
    allocation_method            = "Dynamic"

    tags = {
        environment = "Terraform Demo"
    }
}

# Create Network Security Group and rule
resource "azurerm_network_security_group" "myterraformnsg" {
    name                = "myNetworkSecurityGroup"
    location            = "eastus"
    resource_group_name = azurerm_resource_group.myterraformgroup.name

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

    tags = {
        environment = "Terraform Demo"
    }
}

# Create network interface
resource "azurerm_network_interface" "myterraformnic" {
    name                      = "myNIC"
    location                  = "eastus"
    resource_group_name       = azurerm_resource_group.myterraformgroup.name

    ip_configuration {
        name                          = "myNicConfiguration"
        subnet_id                     = azurerm_subnet.myterraformsubnet.id
        private_ip_address_allocation = "Dynamic"
        public_ip_address_id          = azurerm_public_ip.myterraformpublicip.id
    }

    tags = {
        environment = "Terraform Demo"
    }
}

# Connect the security group to the network interface
resource "azurerm_network_interface_security_group_association" "example" {
    network_interface_id      = azurerm_network_interface.myterraformnic.id
    network_security_group_id = azurerm_network_security_group.myterraformnsg.id
}

# Generate random text for a unique storage account name
resource "random_id" "randomId" {
    keepers = {
        # Generate a new ID only when a new resource group is defined
        resource_group = azurerm_resource_group.myterraformgroup.name
    }

    byte_length = 8
}

# Create storage account for boot diagnostics
resource "azurerm_storage_account" "mystorageaccount" {
    name                        = "diag${random_id.randomId.hex}"
    resource_group_name         = azurerm_resource_group.myterraformgroup.name
    location                    = "eastus"
    account_tier                = "Standard"
    account_replication_type    = "LRS"

    tags = {
        environment = "Terraform Demo"
    }
}

# Create virtual machine
resource "azurerm_linux_virtual_machine" "myterraformvm" {
    name                  = "myVM"
    location              = "eastus"
    resource_group_name   = azurerm_resource_group.myterraformgroup.name
    network_interface_ids = [azurerm_network_interface.myterraformnic.id]
    size                  = "Standard_DS1_v2"

    os_disk {
        name              = "myOsDisk"
        caching           = "ReadWrite"
        storage_account_type = "Premium_LRS"
    }

    source_image_reference {
        publisher = "Canonical"
        offer     = "0001-com-ubuntu-pro-bionic"
        sku       = "pro-18_04-lts"
        version   = "latest"
    }

    # Add plan info because Ubuntu Pro comes from the marketplace
    plan {
        name = "pro-18_04-lts"
        product = "0001-com-ubuntu-pro-bionic"
        publisher = "canonical"
    }

    computer_name  = "myvm"
    admin_username = "ubuntu"
    disable_password_authentication = true

    admin_ssh_key {
        username       = "ubuntu"
	public_key = file("~/.ssh/id_rsa.pub")
    }

    boot_diagnostics {
        storage_account_uri = azurerm_storage_account.mystorageaccount.primary_blob_endpoint
    }

    tags = {
        environment = "Terraform Demo"
    }
}
Written on April 15, 2021