Thorn Tech Marketing Ad
Skip to main content
Version: Next

HA Terraform Template for Azure

TLDR

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

Architecture: VM Scale Set + Load Balancer + PostgreSQL Flexible Server + VNet + Key Vault + Auto-scaling

Deployment:

  1. Create storagelink-ha-terraform.tf and terraform.tfvars
  2. Configure terraform.tfvars with your settings
  3. Run: terraform init && terraform apply

Key Variables:

  • admin_ip_range: Your IP + /32 for SSH access
  • ssh_public_key: SSH public key for VM access
  • web_admin_username/web_admin_password: Web admin credentials

Prerequisites: Subscribe to StorageLink on Azure Marketplace first

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.2.0 on Azure. The template creates multiple VM instances behind a load balancer with a managed PostgreSQL database for configuration persistence and automatic scaling capabilities.

Note: Make sure you are subscribed to the StorageLink listing in the Azure Marketplace before deploying the Terraform template:

https://azuremarketplace.microsoft.com/en-us/marketplace/apps/thorntechnologiesllc.storage-link

Resources Created

ResourceDescription
Resource GroupContainer for all resources
Virtual NetworkIsolated network with public and private subnets
Network Security GroupAllows web ports (80/443) publicly; restricts SSH (22) to admin IP only
Load BalancerDistributes traffic across VM instances with health probes
Public IPStatic IP address for external access
Key VaultSecurely stores database passwords and configuration
Managed IdentityProvides secure access to Azure services
PostgreSQL Flexible ServerManaged database for configuration persistence (single-zone by default; add high_availability for zone-redundant failover)
Private DNS ZoneEnables private network access to the database
Virtual Machine Scale SetVM instances distributed across availability zones
Storage AccountBoot diagnostics and StorageLink data storage

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, 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). Use it to access the StorageLink web admin interface:

https://<public_ip_address>

Sign in with the web_admin_username / web_admin_password you set in terraform.tfvars. Run terraform output to see all deployment values.

Deleting the Stack

To remove all resources:

terraform destroy
note

Key Vault soft-delete is enabled by default in Azure. If you redeploy using the same resource group name, run az keyvault purge --name <key-vault-name> first to avoid a name conflict with the soft-deleted vault.

Variable Reference

VariableDefaultDescription
resource_group_nameResource group to create
locationEast USAzure region
admin_ip_rangeWorkstation public IP with /32 suffix
linux_admin_usernameazureuserLinux admin username for SSH
ssh_public_keySSH public key for VM access
vm_sizeStandard_D2s_v3VM size (2 vCPU, 8 GB RAM)
instance_count2Initial number of VM instances
db_sizeB_Standard_B2sPostgreSQL instance SKU
web_admin_usernameWeb admin username (optional)
web_admin_passwordWeb admin password (optional, min 12 chars)

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 = "ubuntu"
}

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 = "B_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"
}
}
}

# 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
resource "azurerm_lb_probe" "health" {
name = "healthProbe"
loadbalancer_id = azurerm_lb.storagelink.id
protocol = "Tcp"
port = 80
interval_in_seconds = 30
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 = true
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 = "storage-link"
sku = "storagelink"
version = "latest"
}

plan {
name = "storagelink"
product = "storage-link"
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
repo_update: true
repo_upgrade: all

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 application to be fully ready (up to 10 minutes)
for i in {1..20}; do
if curl -k -s --connect-timeout 5 http://localhost/health >/dev/null 2>&1; then
echo "Application is ready, configuring admin user..."
break
fi
echo "Waiting for application to be ready... ($i/20)"
sleep 30
done

# Additional check: verify we can actually reach the admin endpoint
for i in {1..5}; do
if curl -k -s --connect-timeout 5 http://localhost:8080/1.0.0/admin/config >/dev/null 2>&1; then
echo "Admin endpoint is accessible"
break
fi
echo "Waiting for admin endpoint... ($i/5)"
sleep 10
done

# Now try to configure
curl -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}\"}" \
2>/dev/null || echo "Web admin config failed, but will retry automatically"
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 & symbols

# 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"