Deploy a cluster with Terraform
This page deploys the DTM cluster itself with Terraform, from the Azure Marketplace image, into a VNet you already own.
Which deployment path is yours
Section titled “Which deployment path is yours”| You want | Use |
|---|---|
| Azure to create the resource group and subnet for you | The Marketplace wizard, whose ARM templates create them |
| To deploy into a subnet your network team already owns, as code | This page |
| Bicep rather than Terraform | Deploy a cluster with Bicep |
The samples here deliberately do not create VNets, subnets or peerings. They take the resource id of a subnet that already exists, because in the estates that reach for Terraform the network is owned by someone else, and because a sample that creates network topology is a sample that fights your landing zone. The Marketplace ARM templates remain the path that can create the subnet for you (the wizard does not create VNet peerings either).
Before the first apply
Section titled “Before the first apply”Accept the Marketplace terms once per subscription. Azure refuses to create a VM from a Marketplace image until the offer’s terms are accepted for that subscription:
az vm image terms accept \ --publisher hayami --offer dtm --plan dtm-payg-v1Without it the apply fails at VM create with
MarketplacePurchaseEligibilityFailed or VMMarketplaceInvalidInput.
You will also need a subnet resource id, a free static private IP inside it, and an SSH public key for break-glass access.
The two blocks that matter
Section titled “The two blocks that matter”A Marketplace VM needs both an image reference and a matching purchase
plan block. Azure refuses the create without the plan, and the plan is also
what puts the offer into the attested instance document DTM checks at startup,
so a VM built from the image without one is created successfully and then never
becomes ready:
locals { marketplace_publisher = "hayami" marketplace_offer = "dtm" marketplace_sku = "dtm-payg-v1"
use_marketplace_image = var.image_id == "" attach_plan = local.use_marketplace_image || var.image_id_purchase_plan}
resource "azurerm_linux_virtual_machine" "dtm" { # ... size, identity, os_disk, network_interface_ids ...
# Shared Image Gallery or managed image, for estates that mirror the image # into their own gallery. Exactly one of this and source_image_reference. source_image_id = local.use_marketplace_image ? null : var.image_id
dynamic "source_image_reference" { for_each = local.use_marketplace_image ? [1] : [] content { publisher = local.marketplace_publisher offer = local.marketplace_offer sku = local.marketplace_sku version = var.marketplace_image_version } }
dynamic "plan" { for_each = local.attach_plan ? [1] : [] content { name = local.marketplace_sku publisher = local.marketplace_publisher product = local.marketplace_offer } }}Both blocks are dynamic on purpose. If you point image_id at your own
Azure Compute Gallery copy of the Marketplace image, the VM keeps the plan
block: a gallery copy carries the purchase plan on its image definition, and a
VM created from it must pass it
(Microsoft Learn).
That is the default, image_id_purchase_plan = true, and the Marketplace terms
above still apply. Set it false only for an image that carries no purchase
plan at all: Azure rejects a plan on such an image.
The samples
Section titled “The samples”Each is a self-contained directory: one main.tf and the cloud-init.yaml
beside it, no modules and nothing else to fetch. Download both files into an
empty directory and run terraform init && terraform apply.
| Topology | What it deploys | Files |
|---|---|---|
| Single region | One node, the cluster primary, with its own Key Vault | main.tf · cloud-init.yaml |
| Dual region | Two nodes, one per region, sharing one Key Vault | main.tf · cloud-init.yaml |
| Add a node | One more node joining a cluster you already have | main.tf · cloud-init.yaml |
mkdir dtm && cd dtmcurl -O https://docs.hayami.io/samples/terraform/single-region/main.tfcurl -O https://docs.hayami.io/samples/terraform/single-region/cloud-init.yaml
terraform initterraform apply \ -var 'resource_group_name=rg-dtm' \ -var 'subnet_id=<subnet-resource-id>' \ -var 'private_ip=10.50.250.4' \ -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)"cloud-init.yaml is the node’s first-boot configuration. main.tf renders it
with templatefile() and passes it as custom_data, so the two files must sit
in the same directory. It is the same payload the Marketplace ARM and Bicep
deployments use.
Dual region
Section titled “Dual region”Two nodes, one per region, sharing a single Key Vault in the primary region. Node 1 generates the cluster secrets and node 2 reads them, so both join one encrypted cluster.
Adding a node later
Section titled “Adding a node later”add-node references a cluster you already have (its subnet, its Key Vault,
its current node IPs) and creates only the new node, as a read-only secondary
that reads the shared secrets and joins the cluster over the replication port (7946).
Customer usage attribution
Section titled “Customer usage attribution”The single-region and dual-region samples pass Hayami’s customer usage
attribution tracking id to the azurerm provider (partner_id, variable
partner_id). Microsoft uses it to attribute the Azure usage the deployment
creates to Hayami. It collects nothing from your deployment and creates no
resources. The add-node sample carries none, because it grows a cluster whose
own deployment was already attributed. Microsoft’s notice for this kind of
deployment:
When you deploy Hayami Ltd software, Microsoft can identify the installation of Hayami Ltd software with the deployed Azure resources. Microsoft can correlate these resources used to support the software. Microsoft collects this information to provide the best experiences with their products and to operate their business. The data is collected and governed by Microsoft’s privacy policies, located at https://www.microsoft.com/trustcenter.
To opt out, add -var 'partner_id=' to terraform apply, or set
partner_id = "" in a terraform.tfvars. Nothing else in the deployment
changes.
What the samples create
Section titled “What the samples create”A node, and the things only that node needs:
- the VM, with a system-assigned managed identity and no public IP
- a dedicated Premium SSD data disk for
/var/lib/dtm, attached separately so replacing the VM leaves the database in place - a NIC with a static private IP, which becomes the stable resolver address
- an NSG attached to the NIC, not the subnet, so the sample never takes over
the security group on a subnet you own. It denies SSH (
22) from in-VNet and peered sources (deny-ssh-vnet, priority 4010); for break-glass SSH, setssh_breakglass_source_cidrsfor the task, apply, then empty it and apply again. The OS login is SSH key only - a per-cluster Key Vault (single and dual region), and the four role assignments the node needs
The admin password and the replication encryption key are generated on the VM at first boot and written to Key Vault. Neither is ever a Terraform variable, an output, or state. Read the initial password with the command the sample outputs, then change it. That command runs on the node and reads the vault with the node’s own identity, so it works however the vault is exposed. You need permission to run commands on the VM (for example Virtual Machine Contributor), and no role on the vault. A read of a private vault from your own machine is refused. Before the node’s first boot has stored the password, the command fails with HTTP 404; wait and run it again.
The Key Vault is private by default. The single- and dual-region samples
take key_vault_network_access, which sets how the node reaches the vault at
its first boot:
Private(the default): public network access is disabled. A private endpoint in the node subnet is resolved through aprivatelink.vaultcore.azure.netprivate DNS zone, which the sample creates and links to the subnet’s VNet.ServiceEndpoint: the public endpoint, behind a firewall that admits only the node subnet. The subnet must already have theMicrosoft.KeyVaultservice endpoint; the sample does not configure one.Public: the public endpoint is open.
Access is role-based in every mode. If your landing zone already owns a
privatelink.vaultcore.azure.net zone centrally, pass its resource ID in
key_vault_private_dns_zone_id, with your VNet already linked to it: Azure
cannot link a VNet to two zones of the same name. The same modes apply on
every deployment path; see
Key Vault network access.
Query log to Log Analytics
Section titled “Query log to Log Analytics”Set logs_ingestion_enabled = true and pass the nodes’ managed identities in
node_principals, a map of a stable node name to the identity’s object ID. The
module then creates the DTMLogs_CL custom table, a data collection endpoint
and a data collection rule in the workspace’s region, and one Monitoring Metrics
Publisher assignment per identity, scoped to the rule only. For a workspace
behind Azure Monitor Private Link, also set
dce_public_network_access_enabled = false and add the endpoint to your
Private Link Scope.
Its outputs logs_ingestion_endpoint, logs_ingestion_dcr_immutable_id and
logs_ingestion_stream are the three attributes of the same names on the
provider’s dtm_azure_monitor_config resource. Set them on each node, restart
the nodes one at a time, and turn the query log on
(Ship the query log to Log Analytics).
Once on, the custom table is prevent_destroy: turning
logs_ingestion_enabled off, removing the module, or terraform destroy fails
the plan with Instance cannot be destroyed rather than delete DTMLogs_CL
and its rows. To go ahead and keep the table, first run
terraform state rm 'module.<name>.azurerm_log_analytics_workspace_table_custom_log.dtm_logs_cl[0]'.
Each VM deployed this way bills the DTM software charge per VM-hour on top of the Azure compute, storage and networking it uses: US$0.12 per node per VM-hour at any VM size, with 1-, 2- and 3-year reservations. See Pricing and billing.
Day two
Section titled “Day two”- Upgrades replace the VM and keep its private IP and data disk. Set
marketplace_image_version(orimage_id) to the new version, then apply one node at a time. The data disk is a separate resource attached to the VM, so a targeted apply has to include the attachment as well as the VM: Terraform’s-targettakes a resource and its dependencies, not the resources that depend on it. Single region:terraform apply -target=azurerm_linux_virtual_machine.dtm -target=azurerm_virtual_machine_data_disk_attachment.dtm_data. Dual region:terraform apply -target='azurerm_linux_virtual_machine.dtm["n1"]' -target='azurerm_virtual_machine_data_disk_attachment.dtm_data["n1"]', wait for/readyzon that node to return200, then run the same command for["n2"]. On the dual-region sample, do not run a plainterraform applyafter bumping the version: both nodes would be replaced at once. The module-based commands in the rolling upgrades guide do not apply to these single-file samples. - Scaling out is the add-node sample above, or adding nodes.
- Managing DNS moves to the Terraform provider once the cluster is up: provider setup.
Verify the cluster
Section titled “Verify the cluster”curl -sk https://<node-ip>:8443/readyz -o /dev/null -w '%{http_code}\n'# 200 = ready to serve; 503 = still starting or joiningThen follow the quickstart to create your first zone.
Where next
Section titled “Where next”- Deploy a cluster with Bicep: the same samples for Bicep-first estates.
- Deploy from the Azure Marketplace: the wizard, and what it creates for you.
- Network access (NSG): which ports the cluster needs and from where.
Need a hand? Email [email protected].
Last validated: 2026-09-15