HA Terraform Template for Azure
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:
- Create
storagelink-ha-terraform.tfandterraform.tfvarswith your admin IP, SSH key and web admin credentials - Run
terraform initandterraform apply(about 10–15 minutes) - 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.
Before you begin: accept the StorageLink BYOL terms
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.
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
| Resource | Description |
|---|---|
| Resource Group | Container for all resources |
| Virtual Network | Isolated network with a VM subnet and a private database subnet |
| Network Security Group | Allows web ports (80/443) publicly; restricts SSH (22) to your admin IP |
| Load Balancer | Standard 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 IP | Static IP address for external access |
| Key Vault | Securely stores the database password |
| Managed Identity | Lets the VMs read the Key Vault and create StorageLink's storage account |
| PostgreSQL Flexible Server | Managed database shared by all VMs for configuration and licensing (single-zone by default; add high_availability for zone-redundant failover) |
| Private DNS Zone | Enables private network access to the database |
| Virtual Machine Scale Set | StorageLink BYOL VMs distributed across availability zones |
| Storage Account | Boot 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.tfterraform.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.
Accessing your StorageLink
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
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.
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
| Variable | Default | Description |
|---|---|---|
resource_group_name | — | Resource group to create |
location | East US | Azure region |
admin_ip_range | — | Workstation public IP with /32 suffix, for SSH access |
ssh_public_key | — | SSH public key for VM access |
linux_admin_username | azureuser | Linux admin username for SSH |
vm_size | Standard_D2s_v3 | VM size (2 vCPU, 8 GB RAM) |
instance_count | 2 | Number of VM instances |
availability_zones | ["1", "2", "3"] | Zones the scale set spreads the VMs across |
db_size | Standard_B2s | PostgreSQL compute size; the template adds the db_tier prefix (for example B_Standard_B2s) |
db_tier | Burstable | PostgreSQL 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-ha-terraform.tf
# 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"
