HA Terraform Template for Azure
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:
- Create
storagelink-ha-terraform.tfandterraform.tfvars - Configure
terraform.tfvarswith your settings - Run:
terraform init && terraform apply
Key Variables:
admin_ip_range: Your IP +/32for SSH accessssh_public_key: SSH public key for VM accessweb_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
| Resource | Description |
|---|---|
| Resource Group | Container for all resources |
| Virtual Network | Isolated network with public and private subnets |
| Network Security Group | Allows web ports (80/443) publicly; restricts SSH (22) to admin IP only |
| Load Balancer | Distributes traffic across VM instances with health probes |
| Public IP | Static IP address for external access |
| Key Vault | Securely stores database passwords and configuration |
| Managed Identity | Provides secure access to Azure services |
| PostgreSQL Flexible Server | Managed database for configuration persistence (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 | VM instances distributed across availability zones |
| Storage Account | Boot diagnostics and StorageLink data storage |
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, 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). 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
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
| Variable | Default | Description |
|---|---|---|
resource_group_name | — | Resource group to create |
location | East US | Azure region |
admin_ip_range | — | Workstation public IP with /32 suffix |
linux_admin_username | azureuser | Linux admin username for SSH |
ssh_public_key | — | SSH public key for VM access |
vm_size | Standard_D2s_v3 | VM size (2 vCPU, 8 GB RAM) |
instance_count | 2 | Initial number of VM instances |
db_size | B_Standard_B2s | PostgreSQL instance SKU |
web_admin_username | — | Web admin username (optional) |
web_admin_password | — | Web admin password (optional, min 12 chars) |
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 = "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"
