Thorn Tech Marketing Ad
Skip to main content
Version: Next

HA Terraform Template for Azure

TLDR

Purpose: Deploy StorageLink BYOL in a highly available configuration on Azure using Terraform

Architecture: VM Scale Set across availability zones + Standard Load Balancer with an HTTPS health probe + PostgreSQL Flexible Server + VNet + Key Vault

Prerequisites: Accept the StorageLink BYOL image terms once per subscription: az vm image terms accept --publisher thorntechnologiesllc --offer storagelink-byol --plan storagelink-byol

Deployment:

  1. Create storagelink-ha-terraform.tf and terraform.tfvars with your admin IP, SSH key and web admin credentials
  2. Run terraform init and terraform apply (about 10–15 minutes)
  3. Sign in at https://<public_ip_address> and start the free 30-day trial in the admin UI — one trial licenses every VM

Product: StorageLink by Thorn Technologies — cloud storage gateway for secure file sharing

Overview​

You can deploy StorageLink in a High Availability (HA) configuration using Terraform.

This article covers deploying a Highly Available setup of StorageLink version 1.3.0 on Azure from the StorageLink BYOL listing. The template creates multiple VM instances behind a load balancer, with a managed PostgreSQL database that all instances share for their configuration and license.

The BYOL listing has no software charge — you pay only for the Azure resources the template creates. After you deploy, you start a free 30-day trial from the admin UI. When you're ready, book a 15-minute call to purchase a license.

Azure requires each subscription to accept a Marketplace image's terms before Terraform can create VMs from it. Until you do, the scale set fails with ResourcePurchaseValidationFailed: User failed validation to purchase resources. ... You have not accepted the legal terms on this subscription. Accept the StorageLink BYOL terms once with the Azure CLI:

az vm image terms accept --publisher thorntechnologiesllc --offer storagelink-byol --plan storagelink-byol

To confirm, run az vm image terms show --publisher thorntechnologiesllc --offer storagelink-byol --plan storagelink-byol and check for "accepted": true.

Deploying the pay-as-you-go listing instead

The template can also deploy the Standard (pay-as-you-go) listing. In the scale set, set source_image_reference to offer = "storage-link" and sku = "storagelink", set plan to name = "storagelink" and product = "storage-link", and accept that plan's terms (--offer storage-link --plan storagelink). The Standard listing bills a $0.08 USD/hour software fee per VM through Azure, licenses itself automatically, and supports up to 10 StorageLink users. See StorageLink Standard vs BYOL.

Resources Created​

ResourceDescription
Resource GroupContainer for all resources
Virtual NetworkIsolated network with a VM subnet and a private database subnet
Network Security GroupAllows web ports (80/443) publicly; restricts SSH (22) to your admin IP
Load BalancerStandard Load Balancer that spreads web traffic across the VMs. Its HTTPS health probe checks StorageLink's /backend/actuator/health, so a VM whose application is down is taken out of rotation
Public IPStatic IP address for external access
Key VaultSecurely stores the database password
Managed IdentityLets the VMs read the Key Vault and create StorageLink's storage account
PostgreSQL Flexible ServerManaged database shared by all VMs for configuration and licensing (single-zone by default; add high_availability for zone-redundant failover)
Private DNS ZoneEnables private network access to the database
Virtual Machine Scale SetStorageLink BYOL VMs distributed across availability zones
Storage AccountBoot diagnostics for the VMs

When StorageLink first starts, it also creates a storage account in the resource group (its name begins with storagelink) with a swiftgateway-default container, and maps the root folder / to it using the VMs' managed identity.

Running the Template​

This article contains two files:

  • storagelink-ha-terraform.tf
  • terraform.tfvars

Create these files using the contents at the bottom of this page. Make adjustments to terraform.tfvars to fit your environment — at a minimum resource_group_name, admin_ip_range, ssh_public_key, and the web admin credentials — then run:

terraform init
terraform plan

When you are ready to deploy, run:

terraform apply

Deployment typically takes 10–15 minutes, with the PostgreSQL Flexible Server taking the longest.

When deployment completes, Terraform outputs the public IP address of the Load Balancer (public_ip_address). The VMs finish configuring StorageLink a few minutes after Terraform returns. Then open the StorageLink web admin interface:

https://<public_ip_address>

Sign in with the web_admin_username / web_admin_password you set in terraform.tfvars — the first VM to start creates this admin account. If you left them empty, create the admin account on the Welcome page instead. Run terraform output to see all deployment values.

Start your free trial​

A StorageLink BYOL deployment starts unlicensed: administrators can sign in and configure it, but non-administrator users cannot sign in and file transfers are blocked until a trial or license is active. After you sign in, use the Start a 30-day free trial card at the top of the admin UI (see First Launch Experience). The VMs share one database, so a single trial — and later a single license — covers every VM in the scale set; the other VMs pick it up within about a minute.

When you are ready to purchase a license, see Purchasing a License.

SSH access​

The VMs have no public IP addresses. The load balancer's inbound NAT pool maps ports 2222–2322 on its public IP to port 22 on each VM: port 2222 reaches the first instance, 2223 the second, and so on. To list the current mapping, run:

az vmss list-instance-connection-info --resource-group <resource_group_name> --name <scale_set_name>

Then connect with ssh -i <private-key> -p 2222 azureuser@<public_ip_address>.

Deleting the Stack​

To remove all resources:

terraform destroy
danger

terraform destroy deletes the whole resource group — including the storage account StorageLink created for the root folder / and every file in it. Copy anything you want to keep first.

note

terraform destroy also purges the Key Vault (the template sets purge_soft_delete_on_destroy). If you delete the resource group another way, the Key Vault stays soft-deleted for 7 days; to remove it right away, run az keyvault purge --name <key-vault-name>.

Variable Reference​

VariableDefaultDescription
resource_group_name—Resource group to create
locationEast USAzure region
admin_ip_range—Workstation public IP with /32 suffix, for SSH access
ssh_public_key—SSH public key for VM access
linux_admin_usernameazureuserLinux admin username for SSH
vm_sizeStandard_D2s_v3VM size (2 vCPU, 8 GB RAM)
instance_count2Number of VM instances
availability_zones["1", "2", "3"]Zones the scale set spreads the VMs across
db_sizeStandard_B2sPostgreSQL compute size; the template adds the db_tier prefix (for example B_Standard_B2s)
db_tierBurstablePostgreSQL tier: Burstable, GeneralPurpose, or MemoryOptimized
web_admin_username—Web admin username (optional)
web_admin_password—Web admin password (optional; at least 12 characters; avoid $, &, ", \ and backticks)

Terraform file contents​

# StorageLink High Availability - Terraform Template
# Production-ready template for customer deployment

terraform {
required_version = ">= 1.9"

required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}

provider "azurerm" {
features {
key_vault {
purge_soft_delete_on_destroy = true
recover_soft_deleted_key_vaults = true
}
resource_group {
prevent_deletion_if_contains_resources = false
}
}

}

# Generate unique suffix for resource names
resource "random_uuid" "deployment_seed" {}

locals {
resource_suffix = substr(md5("${var.resource_group_name}-${random_uuid.deployment_seed.result}"), 0, 8)
common_tags = {
Environment = var.environment
Project = "storagelink"
DeployedBy = "terraform"
Template = "customer-template"
}
}

# Create Resource Group
resource "azurerm_resource_group" "main" {
name = var.resource_group_name
location = var.location
tags = local.common_tags
}

# ============================================================
# StorageLink Template - Variables
# All configuration variables for the deployment

# =======================
# REQUIRED VARIABLES
# =======================

variable "resource_group_name" {
description = "Name of the Azure Resource Group"
type = string
}

variable "admin_ip_range" {
description = "IP address CIDR for allowed admin SSH access (e.g., your.ip.address/32)"
type = string

validation {
condition = can(cidrhost(var.admin_ip_range, 0))
error_message = "Admin IP range must be a valid CIDR notation (e.g., 192.168.1.1/32)."
}
}

variable "ssh_public_key" {
description = "SSH public key for VM access"
type = string
sensitive = true
}

# =======================
# OPTIONAL VARIABLES
# =======================

variable "location" {
description = "Azure region for resources"
type = string
default = "East US"
}

variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
default = "production"
}

# Compute Configuration
variable "linux_admin_username" {
description = "Linux admin username"
type = string
default = "azureuser"
}

variable "vm_size" {
description = "Size of the virtual machines"
type = string
default = "Standard_D2s_v3"
}

variable "instance_count" {
description = "Number of VM instances in the scale set"
type = number
default = 2
}

variable "availability_zones" {
description = "Availability zones for VM deployment"
type = list(string)
default = ["1", "2", "3"]
}

# Database Configuration
variable "db_size" {
description = "Size of the PostgreSQL instance"
type = string
default = "Standard_B2s"
}

variable "db_tier" {
description = "Tier of DB instance (Burstable, GeneralPurpose, MemoryOptimized)"
type = string
default = "Burstable"

validation {
condition = contains(["Burstable", "GeneralPurpose", "MemoryOptimized"], var.db_tier)
error_message = "Database tier must be one of: Burstable, GeneralPurpose, MemoryOptimized."
}
}

variable "db_availability_zone" {
description = "Availability zone for the PostgreSQL database"
type = number
default = 1

validation {
condition = var.db_availability_zone >= 1 && var.db_availability_zone <= 3
error_message = "Database availability zone must be between 1 and 3."
}
}

# Web Admin Configuration (Optional)
variable "web_admin_username" {
description = "Username for web admin interface (optional)"
type = string
default = ""
}

variable "web_admin_password" {
description = "Password for web admin interface (optional, minimum 12 characters)"
type = string
default = ""
sensitive = true

validation {
condition = var.web_admin_password == "" || length(var.web_admin_password) >= 12
error_message = "Web admin password must be at least 12 characters long if provided."
}
}

# Network Configuration
variable "vnet_address_space" {
description = "Address space for the virtual network"
type = string
default = "10.0.0.0/16"
}

variable "default_subnet_prefix" {
description = "Address prefix for the default subnet"
type = string
default = "10.0.0.0/24"
}

variable "private_subnet_prefix" {
description = "Address prefix for the private subnet"
type = string
default = "10.0.1.0/24"
}

# Infrastructure Tiers
variable "ip_tier" {
description = "Tier of public IP (Basic, Standard)"
type = string
default = "Standard"

validation {
condition = contains(["Basic", "Standard"], var.ip_tier)
error_message = "IP tier must be either Basic or Standard."
}
}

variable "load_balancer_tier" {
description = "Tier of load balancer (Basic, Standard)"
type = string
default = "Standard"

validation {
condition = contains(["Basic", "Standard"], var.load_balancer_tier)
error_message = "Load balancer tier must be either Basic or Standard."
}
}

# ============================================================
# outputs.tf
# All deployment outputs for customer reference

# Access Information
output "public_ip_address" {
description = "Public IP address of the StorageLink"
value = azurerm_public_ip.storagelink.ip_address
}

output "access_urls" {
description = "URLs for accessing the StorageLink"
value = {
web_interface = "https://${azurerm_public_ip.storagelink.ip_address}"
ssh_admin = "ssh://${azurerm_public_ip.storagelink.ip_address}:2222-2322"
}
}

# Resource Information
output "resource_group_name" {
description = "Name of the created resource group"
value = azurerm_resource_group.main.name
}

output "database_endpoint" {
description = "FQDN of the PostgreSQL server"
value = azurerm_postgresql_flexible_server.storagelink.fqdn
}

output "scale_set_name" {
description = "Name of the VM scale set"
value = azurerm_linux_virtual_machine_scale_set.storagelink.name
}

output "current_instance_count" {
description = "Current number of VM instances"
value = azurerm_linux_virtual_machine_scale_set.storagelink.instances
}

# Deployment Summary
output "deployment_summary" {
description = "Complete deployment information"
value = {
public_ip_address = azurerm_public_ip.storagelink.ip_address
database_endpoint = azurerm_postgresql_flexible_server.storagelink.fqdn
instance_count = azurerm_linux_virtual_machine_scale_set.storagelink.instances
vm_size = azurerm_linux_virtual_machine_scale_set.storagelink.sku
database_sku = azurerm_postgresql_flexible_server.storagelink.sku_name
deployment_time = timestamp()

access_info = {
web_interface = "https://${azurerm_public_ip.storagelink.ip_address}"
ssh_admin = "ssh ${var.linux_admin_username}@${azurerm_public_ip.storagelink.ip_address} -p 2222"
}

next_steps = [
"1. Access web interface at the provided URL",
"2. Configure users and settings",
"3. Test connectivity using the provided access information"
]
}
}

# ============================================================
# 01-infrastructure.tf
# Core networking, security, and identity resources

data "azurerm_client_config" "current" {}

# Managed Identity for VM access to Key Vault
resource "azurerm_user_assigned_identity" "storagelink" {
name = "storagelink-identity-${local.resource_suffix}"
location = var.location
resource_group_name = azurerm_resource_group.main.name
tags = local.common_tags
}

# Role assignment for the managed identity
resource "azurerm_role_assignment" "storagelink_contributor" {
scope = azurerm_resource_group.main.id
role_definition_name = "Contributor"
principal_id = azurerm_user_assigned_identity.storagelink.principal_id
}

# Key Vault for storing sensitive data
resource "azurerm_key_vault" "storagelink" {
name = "storagelink-kv-${local.resource_suffix}"
location = var.location
resource_group_name = azurerm_resource_group.main.name
tenant_id = data.azurerm_client_config.current.tenant_id
soft_delete_retention_days = 7
purge_protection_enabled = false
sku_name = "standard"

enabled_for_deployment = true
enabled_for_template_deployment = true
enabled_for_disk_encryption = true

access_policy {
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = azurerm_user_assigned_identity.storagelink.principal_id

secret_permissions = [
"Get",
"List",
]
}

# Allow current user to manage secrets during deployment
access_policy {
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = data.azurerm_client_config.current.object_id

secret_permissions = [
"Get",
"List",
"Set",
"Delete",
"Purge",
"Recover"
]
}

network_acls {
default_action = "Allow"
bypass = "AzureServices"
}

tags = local.common_tags
}

# Virtual Network
resource "azurerm_virtual_network" "storagelink" {
name = "storagelink-vnet-${local.resource_suffix}"
location = var.location
resource_group_name = azurerm_resource_group.main.name
address_space = [var.vnet_address_space]
tags = local.common_tags
}

# Default subnet for VMs
resource "azurerm_subnet" "default" {
name = "default"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.storagelink.name
address_prefixes = [var.default_subnet_prefix]
}

# Private subnet for database
resource "azurerm_subnet" "private" {
name = "private-1"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.storagelink.name
address_prefixes = [var.private_subnet_prefix]

service_endpoints = ["Microsoft.Storage"]

delegation {
name = "Microsoft.DBforPostgreSQL.flexibleServers"
service_delegation {
name = "Microsoft.DBforPostgreSQL/flexibleServers"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"] # added by Azure; declared to avoid a perpetual diff
}
}
}

# Network Security Group
resource "azurerm_network_security_group" "storagelink" {
name = "storagelink-nsg-${local.resource_suffix}"
location = var.location
resource_group_name = azurerm_resource_group.main.name

security_rule {
name = "admin-ssh"
priority = 1010
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.admin_ip_range
destination_address_prefix = "*"
}

security_rule {
name = "public-web"
priority = 1000
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_ranges = ["80", "443"]
source_address_prefix = "*"
destination_address_prefix = "*"
}

tags = local.common_tags
}

# Public IP for Load Balancer
resource "azurerm_public_ip" "storagelink" {
name = "storagelink-ip-${local.resource_suffix}"
location = var.location
resource_group_name = azurerm_resource_group.main.name
allocation_method = "Static"
sku = var.ip_tier
ip_version = "IPv4"
idle_timeout_in_minutes = 4
tags = local.common_tags
}

# Load Balancer
resource "azurerm_lb" "storagelink" {
name = "storagelink-lb-${local.resource_suffix}"
location = var.location
resource_group_name = azurerm_resource_group.main.name
sku = var.load_balancer_tier

frontend_ip_configuration {
name = "storagelink-ip"
public_ip_address_id = azurerm_public_ip.storagelink.id
}

tags = local.common_tags
}

# Backend Pool
resource "azurerm_lb_backend_address_pool" "storagelink" {
name = "${azurerm_lb.storagelink.name}-backend-pool"
loadbalancer_id = azurerm_lb.storagelink.id
}

# Health Probe — checks the StorageLink application itself (through nginx), not just the
# nginx port, so an instance whose application is down is taken out of rotation
resource "azurerm_lb_probe" "health" {
name = "healthProbe"
loadbalancer_id = azurerm_lb.storagelink.id
protocol = "Https"
port = 443
request_path = "/backend/actuator/health"
interval_in_seconds = 15
number_of_probes = 2
}

# Load Balancer Rules
resource "azurerm_lb_rule" "http" {
name = "LBRuleHttp"
loadbalancer_id = azurerm_lb.storagelink.id
protocol = "Tcp"
frontend_port = 80
backend_port = 80
frontend_ip_configuration_name = "storagelink-ip"
backend_address_pool_ids = [azurerm_lb_backend_address_pool.storagelink.id]
probe_id = azurerm_lb_probe.health.id
enable_floating_ip = false
disable_outbound_snat = true
idle_timeout_in_minutes = 30
}

resource "azurerm_lb_rule" "https" {
name = "LBRuleHttps"
loadbalancer_id = azurerm_lb.storagelink.id
protocol = "Tcp"
frontend_port = 443
backend_port = 443
frontend_ip_configuration_name = "storagelink-ip"
backend_address_pool_ids = [azurerm_lb_backend_address_pool.storagelink.id]
probe_id = azurerm_lb_probe.health.id
enable_floating_ip = false
disable_outbound_snat = true
idle_timeout_in_minutes = 30
}

# Outbound Rule for VM internet access
resource "azurerm_lb_outbound_rule" "network_out" {
name = "networkOut"
loadbalancer_id = azurerm_lb.storagelink.id
protocol = "All"
enable_tcp_reset = true
allocated_outbound_ports = 2400
idle_timeout_in_minutes = 15
backend_address_pool_id = azurerm_lb_backend_address_pool.storagelink.id

frontend_ip_configuration {
name = "storagelink-ip"
}
}

# NAT Pool for SSH admin access
resource "azurerm_lb_nat_pool" "ssh_admin" {
name = "storagelink-natpool"
loadbalancer_id = azurerm_lb.storagelink.id
resource_group_name = azurerm_resource_group.main.name
frontend_ip_configuration_name = "storagelink-ip"
protocol = "Tcp"
frontend_port_start = 2222
frontend_port_end = 2322
backend_port = 22
}

# Storage Account for Diagnostics
resource "azurerm_storage_account" "diagnostics" {
name = "storagelinkdiag${local.resource_suffix}"
resource_group_name = azurerm_resource_group.main.name
location = var.location
account_tier = "Standard"
account_replication_type = "LRS"
account_kind = "StorageV2"
tags = local.common_tags
}

# ============================================================
# 02-database.tf
# PostgreSQL Flexible Server and related resources

# Local values for database configuration
locals {
# PostgreSQL Flexible Server requires tier prefix: B_ (Burstable), GP_ (GeneralPurpose), MO_ (MemoryOptimized)
tier_prefix = var.db_tier == "Burstable" ? "B" : (var.db_tier == "GeneralPurpose" ? "GP" : "MO")
db_sku_name = "${local.tier_prefix}_${var.db_size}"
}

# Generate secure database password
resource "random_password" "db_password" {
length = 32
special = false # the StorageLink image writes this password into .pgpass, where ':' is a field separator
upper = true
lower = true
numeric = true
}

# Store database password in Key Vault
resource "azurerm_key_vault_secret" "db_password" {
name = "dbPassword"
value = random_password.db_password.result
key_vault_id = azurerm_key_vault.storagelink.id

depends_on = [
azurerm_key_vault.storagelink,
random_password.db_password
]

tags = local.common_tags
}

# Private DNS Zone for PostgreSQL
resource "azurerm_private_dns_zone" "postgres" {
name = "private.postgres.database.azure.com"
resource_group_name = azurerm_resource_group.main.name
tags = local.common_tags
}

# Link private DNS zone to virtual network
resource "azurerm_private_dns_zone_virtual_network_link" "postgres" {
name = "${azurerm_virtual_network.storagelink.name}-link"
resource_group_name = azurerm_resource_group.main.name
private_dns_zone_name = azurerm_private_dns_zone.postgres.name
virtual_network_id = azurerm_virtual_network.storagelink.id
registration_enabled = false
tags = local.common_tags
}

# PostgreSQL Flexible Server
resource "azurerm_postgresql_flexible_server" "storagelink" {
name = "storagelink-db-${local.resource_suffix}"
resource_group_name = azurerm_resource_group.main.name
location = var.location
version = "16"

administrator_login = "swiftgw"
administrator_password = random_password.db_password.result

# zone = var.db_availability_zone

# Let Azure choose the fastest available zone

storage_mb = 32768
storage_tier = "P4"

sku_name = local.db_sku_name

backup_retention_days = 7
geo_redundant_backup_enabled = false

public_network_access_enabled = false

maintenance_window {
day_of_week = 0
start_hour = 8
start_minute = 0
}

delegated_subnet_id = azurerm_subnet.private.id
private_dns_zone_id = azurerm_private_dns_zone.postgres.id

depends_on = [azurerm_private_dns_zone_virtual_network_link.postgres]

tags = local.common_tags

lifecycle {
ignore_changes = [
zone,
]
}
}

# Configure PostgreSQL extensions
resource "azurerm_postgresql_flexible_server_configuration" "extensions" {
name = "azure.extensions"
server_id = azurerm_postgresql_flexible_server.storagelink.id
value = "LTREE"
}

# ============================================================
# 03-compute.tf
# Virtual Machine Scale Set

# Virtual Machine Scale Set
resource "azurerm_linux_virtual_machine_scale_set" "storagelink" {
name = "storagelink-scale-set-${local.resource_suffix}"
resource_group_name = azurerm_resource_group.main.name
location = var.location
sku = var.vm_size
instances = var.instance_count

# Distribution across availability zones
zones = var.availability_zones

overprovision = true
upgrade_mode = "Manual"

disable_password_authentication = true

source_image_reference {
publisher = "thorntechnologiesllc"
offer = "storagelink-byol"
sku = "storagelink-byol"
version = "latest"
}

plan {
name = "storagelink-byol"
product = "storagelink-byol"
publisher = "thorntechnologiesllc"
}

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

admin_username = var.linux_admin_username

admin_ssh_key {
username = var.linux_admin_username
public_key = var.ssh_public_key
}

network_interface {
name = "storagelink-nic"
primary = true

ip_configuration {
name = "ip-config"
primary = true
subnet_id = azurerm_subnet.default.id

load_balancer_backend_address_pool_ids = [azurerm_lb_backend_address_pool.storagelink.id]
load_balancer_inbound_nat_rules_ids = [azurerm_lb_nat_pool.ssh_admin.id]
}

network_security_group_id = azurerm_network_security_group.storagelink.id
}

# User data for cloud-init configuration
custom_data = base64encode(<<-EOT
#cloud-config

write_files:
- content: |
#!/bin/bash
export CLOUD_PROVIDER=azure
export ARCHITECTURE=HA
export LOG_GROUP_NAME=logGroup
export SECRET_ID=${azurerm_key_vault_secret.db_password.versionless_id}
export DB_HOST=${azurerm_postgresql_flexible_server.storagelink.fqdn}
export LOAD_BALANCER_ADDRESSES=${azurerm_public_ip.storagelink.ip_address}
path: /opt/swiftgw/launch_config.env
permissions: '0644'

runcmd:
# Configure web admin if credentials provided
- |
if [ -n "${var.web_admin_username}" ] && [ -n "${var.web_admin_password}" ]; then
echo "Configuring web admin user..."

# Wait for the StorageLink application to report healthy (up to 10 minutes).
# runcmd runs under /bin/sh, so use seq rather than bash brace expansion.
for i in $(seq 1 60); do
if curl -s --connect-timeout 5 http://localhost:8080/actuator/health | grep -q '"UP"'; then
echo "Application is ready, configuring admin user..."
break
fi
echo "Waiting for application to be ready... ($i/60)"
sleep 10
done

# 201 = admin created; 404 = already configured by another instance sharing the database
code=$(curl -s -o /dev/null -w '%%{http_code}' -X POST "http://localhost:8080/1.0.0/admin/config" \
-H "accept: */*" \
-H "Content-Type: application/json" \
-d "{\"password\": \"${var.web_admin_password}\", \"username\": \"${var.web_admin_username}\"}")
echo "Web admin config returned HTTP $code"
fi

final_message: "StorageLink cloud-init completed at $TIMESTAMP"
EOT
)

# Managed identity for Key Vault access
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.storagelink.id]
}

# Boot diagnostics
boot_diagnostics {
storage_account_uri = azurerm_storage_account.diagnostics.primary_blob_endpoint
}

tags = local.common_tags

depends_on = [
azurerm_lb.storagelink,
azurerm_postgresql_flexible_server.storagelink,
azurerm_key_vault_secret.db_password
]

lifecycle {
ignore_changes = [
instances,
custom_data,
]
}
}

terraform.tfvars​

# ===========================
# REQUIRED CONFIGURATION
# ===========================

# Azure Resource Group (will be created automatically)
resource_group_name = "my-storagelink-ha-deployment"

# Your IP address for admin SSH access
admin_ip_range = "203.0.113.100/32" # Replace with your actual IP/32

# SSH public key for VM access
ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDExample... your-key-here"
linux_admin_username = "azureuser"

# ===========================
# OPTIONAL CONFIGURATION
# ===========================

# Environment name
environment = "production"

# Azure region for deployment
location = "East US"

# Virtual machine settings
vm_size = "Standard_D2s_v3" # 2 vCPU, 8 GB RAM
instance_count = 2 # Number of VMs to start with

# Availability zones (distribute VMs across zones for HA)
availability_zones = ["1", "2", "3"]

# Database sizing (SKU will be auto-prefixed based on tier)
db_size = "Standard_B2s" # 2 vCPU, 4 GB RAM
db_tier = "Burstable" # Production tier

# Web admin credentials (can be configured later via web interface)
web_admin_username = "WebAdmin"
web_admin_password = "MySecurePassword123" # Minimum 12 characters; avoid $ & " \ and backticks

# Virtual network addressing (usually defaults are fine)
vnet_address_space = "10.0.0.0/16"
default_subnet_prefix = "10.0.0.0/24" # VM subnet
private_subnet_prefix = "10.0.1.0/24" # Database subnet

# Use Standard tier for production (supports availability zones)
ip_tier = "Standard"
load_balancer_tier = "Standard"