Provision and configure on Azure
In this tutorial one configuration creates two Azure VMs running Ubuntu 24.04 and sets each one up: a deploy account with your public key. The resource group, the network, the VMs, their SSH host keys and the steps on each VM are planned and applied together, and live in one state file.
Time: 15 minutes.
Prerequisites
Section titled “Prerequisites”- Conn3x v0.6.12 installed.
- An Azure subscription and the
azCLI. You need to be able to create resource groups, networks and VMs:Contributoron the subscription is enough. - An SSH key at
~/.ssh/id_ed25519, with its public half at~/.ssh/id_ed25519.pub. Conn3x logs in with it. - Room for four vCPUs in
westus2. A new subscription’s policies can limit sizes and locations. If yours rejects these, changesizeandlocationinmain.tf.
Write the configuration
Section titled “Write the configuration”Make an empty directory and save this as main.tf:
terraform { required_providers { azurerm = { source = "hashicorp/azurerm", version = "~> 5.0" } tls = { source = "hashicorp/tls", version = "~> 4.0" } }}
variable "admin_cidr" { type = string description = "The address allowed to reach SSH, such as 203.0.113.10/32."}
locals { servers = toset(["app-1", "app-2"])}
provider "azurerm" { features {}}
resource "azurerm_resource_group" "tutorial" { name = "conn3x-tutorial" location = "westus2"}
resource "azurerm_virtual_network" "tutorial" { name = "conn3x-tutorial" location = azurerm_resource_group.tutorial.location resource_group_name = azurerm_resource_group.tutorial.name address_space = ["10.10.0.0/16"]}
resource "azurerm_subnet" "tutorial" { name = "conn3x-tutorial" resource_group_name = azurerm_resource_group.tutorial.name virtual_network_name = azurerm_virtual_network.tutorial.name address_prefixes = ["10.10.0.0/24"]}
resource "azurerm_network_security_group" "tutorial" { name = "conn3x-tutorial" location = azurerm_resource_group.tutorial.location resource_group_name = azurerm_resource_group.tutorial.name
security_rule { name = "ssh" priority = 100 direction = "Inbound" access = "Allow" protocol = "Tcp" source_port_range = "*" destination_port_range = "22" source_address_prefix = var.admin_cidr destination_address_prefix = "*" }}
resource "azurerm_subnet_network_security_group_association" "tutorial" { subnet_id = azurerm_subnet.tutorial.id network_security_group_id = azurerm_network_security_group.tutorial.id}
resource "azurerm_public_ip" "app" { for_each = local.servers name = each.key location = azurerm_resource_group.tutorial.location resource_group_name = azurerm_resource_group.tutorial.name allocation_method = "Static" sku = "Standard"}
resource "azurerm_network_interface" "app" { for_each = local.servers name = each.key location = azurerm_resource_group.tutorial.location resource_group_name = azurerm_resource_group.tutorial.name
ip_configuration { name = "conn3x" subnet_id = azurerm_subnet.tutorial.id private_ip_address_allocation = "Dynamic" public_ip_address_id = azurerm_public_ip.app[each.key].id }}
# Each server's SSH host key, made here and installed by cloud-init,# so Conn3x can check it on the very first connection.resource "tls_private_key" "host" { for_each = local.servers algorithm = "ED25519"}
resource "azurerm_linux_virtual_machine" "app" { for_each = local.servers name = each.key location = azurerm_resource_group.tutorial.location resource_group_name = azurerm_resource_group.tutorial.name size = "Standard_B2s_v2" admin_username = "conn3x" disable_password_authentication = true network_interface_ids = [azurerm_network_interface.app[each.key].id]
admin_ssh_key { username = "conn3x" public_key = trimspace(file(pathexpand("~/.ssh/id_ed25519.pub"))) }
os_disk { caching = "ReadWrite" storage_account_type = "StandardSSD_LRS" }
source_image_reference { publisher = "Canonical" offer = "ubuntu-24_04-lts" sku = "server" version = "latest" }
custom_data = base64encode("#cloud-config\n${yamlencode({ ssh_deletekeys = true ssh_keys = { ed25519_private = tls_private_key.host[each.key].private_key_openssh ed25519_public = tls_private_key.host[each.key].public_key_openssh } })}")
# The firewall goes on before anything can listen. depends_on = [azurerm_subnet_network_security_group_association.tutorial]}
circuit "app" { hosts = { for name, vm in azurerm_linux_virtual_machine.app : name => { address = vm.public_ip_address host_key = tls_private_key.host[name].public_key_openssh } }
ssh { user = "conn3x" private_key_file = "~/.ssh/id_ed25519" become = true timeout = "5m" }
step "deploy_user" { user { name = "deploy" shell = "/bin/bash" } }
step "deploy_key" { authorized_key { user = "deploy" key = file("${path.module}/deploy.pub") } }}
output "addresses" { value = { for name, vm in azurerm_linux_virtual_machine.app : name => vm.public_ip_address }}
output "known_hosts" { value = join("", [for name, vm in azurerm_linux_virtual_machine.app : "${vm.public_ip_address} ${tls_private_key.host[name].public_key_openssh}"])}The configuration does five things:
- Creates one resource group holding everything else, so
az group deleteis your way out ifconn3x destroyever can’t finish. - Opens SSH to one address with a network security group, attached to the subnet before any VM starts.
- Generates a host key for each VM with the
tlsprovider, and hands it to the VM through cloud-init. Cloud-init writes the key before SSH starts on this image, so there’s no wait before connecting. - Builds the circuit’s
hostsfrom the VMs. Each host is a host object with the VM’s address and its pinnedhost_key. Conn3x accepts only that key, so even the first connection to a new VM is verified, and you don’t need aknown_hostsentry. - Configures each VM with two steps: a
userand anauthorized_key. Azure makes theconn3xadmin user with your public key and sudo, so Conn3x logs in as that user withbecome = true.
timeout = "5m" keeps Conn3x retrying while a new VM boots.
Set your credentials
Section titled “Set your credentials”Sign in, and make sure your subscription can use the two services. The azurerm provider uses your active subscription:
az loginaz provider register --namespace Microsoft.Computeaz provider register --namespace Microsoft.NetworkPass your own address to the configuration:
export TF_VAR_admin_cidr="$(curl -4 -s https://ifconfig.me)/32"The deploy user gets the public key in deploy.pub, next to main.tf. Copy yours there, or any public key you want the account to accept:
cp ~/.ssh/id_ed25519.pub deploy.pubInitialize
Section titled “Initialize”conn3x initinit downloads two providers:
Initializing provider plugins...- terraform.io/builtin/conn3x is built in to Conn3x- Finding hashicorp/tls versions matching "~> 4.0"...- Finding hashicorp/azurerm versions matching "~> 5.0"...- Installing hashicorp/tls v4.4.2...- Installing hashicorp/azurerm v5.12.0...Your versions may be newer. init writes them to .terraform.lock.hcl.
conn3x planThe plan covers the cloud and the hosts at once:
# azurerm_linux_virtual_machine.app["app-1"] will be created # azurerm_linux_virtual_machine.app["app-2"] will be created # azurerm_network_interface.app["app-1"] will be created # azurerm_network_interface.app["app-2"] will be created # azurerm_network_security_group.tutorial will be created # azurerm_public_ip.app["app-1"] will be created # azurerm_public_ip.app["app-2"] will be created # azurerm_resource_group.tutorial will be created # azurerm_subnet.tutorial will be created # azurerm_subnet_network_security_group_association.tutorial will be created # azurerm_virtual_network.tutorial will be created # conn3x_authorized_key.app__deploy_key["app-1"] will be created # conn3x_authorized_key.app__deploy_key["app-2"] will be created # conn3x_user.app__deploy_user["app-1"] will be created # conn3x_user.app__deploy_user["app-2"] will be created # tls_private_key.host["app-1"] will be created # tls_private_key.host["app-2"] will be created
Plan: 17 to add, 0 to change, 0 to destroy.The VMs don’t exist yet, so each step’s address and host key are unknown until apply:
+ ssh { + become = true + host = (known after apply) + host_key = (known after apply) + private_key_file = "~/.ssh/id_ed25519" + timeout = "5m" + user = "conn3x" }conn3x applyType yes when asked. It took under two minutes when we ran it:
Apply complete! Resources: 17 added, 0 changed, 0 destroyed.List the addresses:
conn3x output addresses{ "app-1" = "203.0.113.10" "app-2" = "203.0.113.11"}The addresses on this page are examples. Yours are real, public ones.
Check the host keys yourself
Section titled “Check the host keys yourself”ssh on your machine doesn’t know these VMs yet. Answer yes at its first prompt and you’ve trusted whatever answered, which is the one thing Conn3x refused to do. You don’t have to: the configuration made the keys, so it can tell ssh what to expect.
Write them to a file next to main.tf:
conn3x output -raw known_hosts > known_hostsssh-keygen -lf known_hostsssh-keygen prints one fingerprint for each VM:
256 SHA256:Q+hD+puZ+h9eH5sp8ecjm0UoFHpGOToWBJzGcfGozYw 203.0.113.10 (ED25519)256 SHA256:6k9h22xldln3q2bky1aiG6mO7EiVnGBsiUWpSXLIURk 203.0.113.11 (ED25519)Log in as deploy, with ssh told to use only that file and to refuse any other key. Use an address from conn3x output addresses:
ssh -o UserKnownHostsFile=known_hosts -o StrictHostKeyChecking=yes \ -i ~/.ssh/id_ed25519 deploy@203.0.113.10 \ 'whoami; . /etc/os-release; echo $PRETTY_NAME'There’s no prompt. If the VM presents a different key, ssh stops with Host key verification failed.. When it matches:
deployUbuntu 24.04.5 LTSRun conn3x plan again. It reads the VMs from Azure and the steps from each VM, and reports no changes:
No changes. Your infrastructure matches the configuration.Remove one VM
Section titled “Remove one VM”Take app-2 out of local.servers in main.tf:
locals { servers = toset(["app-1"])}Run conn3x plan, then conn3x apply. The same plan destroys the app-2 VM, its network interface, public address and host key, and removes its two steps. The authorized_key step defaults to on_remove = "delete", so Conn3x deletes the deploy key from app-2 before the VM goes. See Removal for each module’s default.
Plan: 0 to add, 0 to change, 6 to destroy.Destroy everything
Section titled “Destroy everything”Destroy everything the configuration created, and type yes:
conn3x destroyThis deletes the resource group and everything in it, which stops the charges. Check that it’s gone:
az group exists -n conn3x-tutorialThat prints false. The first network in a region makes Azure create a free NetworkWatcherRG group of its own, outside this configuration, and destroy leaves it behind.
The state file holds each VM’s private host key, so delete it as well, with your known_hosts file, which no longer points at anything:
rm -f terraform.tfstate terraform.tfstate.backup known_hostsFor a configuration you keep, turn on state encryption or store state where you’d store a secret. The private host key also sits in each VM’s custom_data: Azure’s API won’t hand it back, but root on the VM can read it.
Next steps
Section titled “Next steps”- Reach hosts through a bastion, for servers without a public address.
- Create and configure a server in one plan adds nginx and a firewall to a single server.
- Hosts covers building
hostsfrom resources.