FFA logo wit
FFA logo wit
FFA logo wit

Secure azure networking made simple

HomeSecure azure networking made simple
Kalender
11 juli 2025

Introduction

A secure and well-structured network is the foundation of every reliable Data & AI platform. In this blog we’ll explore how secure azure networking made simple. We configure Azure Hub-Spoke networking combined with Point-to-Site (P2S) VPN to support enterprise-grade data architectures like FFA Titan.

We use this setup in every environment to ensure scalability, security, and isolation between workloads. and it's fully automated.

Why Hub-Spoke-networking?

The Hub-Spoke model separates shared services (the “hub”) from workloads (the “spokes”). Providing the following benefits:

  • 🔐 Centralized security: Control all traffic with one central firewall.
  • 🔌 Simplified connectivity: Peering to the hub connects everything.
  • 🎯 Role separation: Separate dev/test/prod environments.

This approach scales better than flat virtual networks. Especially when managing multiple business units or projects.

The Challenge: cost-effective and repeatable network topology

One of the biggest challenges for SMBs is deploying a secure Azure network that scales without breaking the bank. Microsoft often recommends using Azure Private DNS Resolver with an inbound endpoint for name resolution across VNets. It works well, but the pricing is steep:
€168+ per environment, each month. That adds up fast when you want to separate dev/test/prod, especially for smaller teams.

The Solution: Terraform and and centralized DNS-configuration in the Hub

We’ve solved this with a cost-effective, repeatable network setup that uses:

  • Centralized Private DNS Zone configuration in the Hub-network
  • Custom DNS forwarders costing €4.32 per environment, each month.

It’s secure, scalable, and cuts out unnecessary overhead. This is built into every Titan rollout. No extra configuration needed. just secure azure networking made simple.

Terraforming FFA Titan's Hub-Spoke-network topology

We want a secure and scalable hub-spoke-network-topology that acts as the central point for VPN access, DNS resolution, and private connectivity across all environments. Or in other words, secure azure networking made simple. This hub enables spoke networks to resolve private endpoints, route traffic securely, and access shared services. All without exposing infrastructure to the public internet:

#Platform componentLocationPublic access
1FFA Titan Hub networkFFA Titan hub network resourcegroupDisabled
2FFA Titan Spoke networkFFA Titan environment resourcegroupDisabled

To achieve secure connectivity to [1] and [2] we need to implement the following in our Infrastructure-as-Code:

Hub-network

#IaC-goallanguage
1Create FFA Titan Hub vnet and subnetsterraform
2Create FFA Titan Hub DNS Forwarder VMterraform
3Create FFA Titan Hub Private DNS Zonesterraform
4Create FFA Titan Hub VPN Gatewayterraform

Step 1: Create FFA Titan Hub vnet and subnets

This step creates a private vnet with subnets in the existing resourcegroup rg_ffatitandemdev_ntw


# Create vnet
resource "azurerm_virtual_network" "ffa_titan_vpn_vnet" {
  name                = var.vnet_name
  location            = var.location
  resource_group_name = var.resourcegroup_name
  address_space       = ["10.0.0.0/16"]
}

# Create a Gateway Subnet
resource "azurerm_subnet" "ffa_titan_point_to_site_vpn_gateway_subnet" {
  name                                      = var.subnet_vpn_name
  resource_group_name                       = var.resourcegroup_name
  virtual_network_name                      = azurerm_virtual_network.ffa_titan_vpn_vnet.name
  address_prefixes                          = ["10.0.3.0/27"]
  # private_endpoint_network_policies_enabled = false
  depends_on = [
    azurerm_virtual_network.ffa_titan_vpn_vnet
  ]
}

# Create a Custom Private DNS Forwarder subnet hosted in Azure Container Instance
resource "azurerm_subnet" "ffa_titan_aci_dnsforwarder_subnet" {
  name                                      = var.subnet_aciprivatednsforwarder_name
  resource_group_name                       = var.resourcegroup_name
  virtual_network_name                      = azurerm_virtual_network.ffa_titan_vpn_vnet.name
  address_prefixes                          = ["10.0.6.0/29"]
  # private_endpoint_network_policies_enabled = true
}

# Set DNS Forwarder IP as Azure VNET Custom DNS Server
resource "azurerm_virtual_network_dns_servers" "ffa_titan_vnet_private_dns_resolver_as_dnsserver" {
  virtual_network_id = azurerm_virtual_network.ffa_titan_vpn_vnet.id
  # make these dynamic and update dns records as part of rolling out dns forwarder in repsective vnet-<env>
  dns_servers = [
    azurerm_network_interface.ffa_titan_vnet_dnsforwarder_nic.private_ip_address,
    "10.1.6.4",
    "10.2.6.4",
    "10.3.6.4"
  ]
  depends_on = [
    azurerm_virtual_network.ffa_titan_vpn_vnet,
    azurerm_virtual_machine_extension.ffa_titan_vm_linux_dnsforwarder_extension
  ]
}

please note: we set the custom DNS-servers of the HUB DNS forwarder VM and the future Spoke-network DNS forwarders VMS in the HUB-DNS-configuration. This is required to route the traffic properly originating from various sources.

Step 2: Create FFA Titan Hub DNS Forwarder VM

This step creates Private DNS Zones in the Hub-network that are used by the Hub- and Spoke-networks to resolve hostnames to the correct azure-private-endpoitns attached to the FFA TItan platform components.

# KEYVAULT COMPONENTS
# dnsforwarder password generator
resource "random_password" "ffa_titan_password" {
  length           = 16
  min_lower        = 4
  min_numeric      = 4
  min_upper        = 4
  min_special      = 4
  override_special = "!#$%_"
}

# dnsforwarder administrator username
resource "azurerm_key_vault_secret" "ffa_titan_keyvault_secret_dnsforwarder_admin_username" {
  name         = "ffatitan-dnsforwarder-admin-username-${var.environment}"
  value        = "adminuser"
  key_vault_id = data.azurerm_key_vault.ds_ffa_titan_key_vault_acr.id
}

# dnsforwarder administrator password
resource "azurerm_key_vault_secret" "ffa_titan_keyvault_secret_dnsforwarder_admin_password" {
  name         = "ffatitan-dnsforwarder-admin-password-${var.environment}"
  value        = random_password.ffa_titan_password.result
  key_vault_id = data.azurerm_key_vault.ds_ffa_titan_key_vault_acr.id
}


# VM COMPONENTS
resource "azurerm_network_interface" "ffa_titan_vnet_dnsforwarder_nic" {
  name                = "dnsforwardervm--nic"
  location            = var.location
  resource_group_name = var.resourcegroup_name

  ip_configuration {
    name                          = "ip"
    subnet_id                     = azurerm_subnet.ffa_titan_aci_dnsforwarder_subnet.id
    private_ip_address_allocation = "Static"
    private_ip_address            = "10.0.6.4"
  }
  depends_on = [
    azurerm_subnet.ffa_titan_aci_dnsforwarder_subnet
  ]
}

resource "azurerm_virtual_machine" "ffa_titan_vm_linux_dnsforwarder" {
  name                = "dnsforwardervm"
  location            = var.location
  resource_group_name = var.resourcegroup_name
  network_interface_ids = [
    azurerm_network_interface.ffa_titan_vnet_dnsforwarder_nic.id
  ]
  vm_size                          = "Standard_B1ls"
  delete_data_disks_on_termination = true
  delete_os_disk_on_termination    = true

  storage_os_disk {
    name              = "dnsforwardervm-osdisk"
    caching           = "ReadWrite"
    create_option     = "FromImage"
    managed_disk_type = "Standard_LRS"
  }

  storage_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }

  os_profile {
    computer_name  = "dnsforwardervm"
    admin_username = azurerm_key_vault_secret.ffa_titan_keyvault_secret_dnsforwarder_admin_username.value
    admin_password = random_password.ffa_titan_password.result
  }

  os_profile_linux_config {
    disable_password_authentication = false
  }
}

resource "azurerm_virtual_machine_extension" "ffa_titan_vm_linux_dnsforwarder_extension" {
  name                 = "devops-extension"
  virtual_machine_id   = azurerm_virtual_machine.ffa_titan_vm_linux_dnsforwarder.id
  publisher            = "Microsoft.Azure.Extensions"
  type                 = "CustomScript"
  type_handler_version = "2.1"

  settings = <<SETTINGS
    {
        "script": "${filebase64("${path.cwd}/${var.stack}/helper/03_dnsforwarder_setup.sh")}"
    }
SETTINGS
  depends_on = [
    azurerm_virtual_machine.ffa_titan_vm_linux_dnsforwarder
  ]
}

please note: this script sets up a small linux VM in which automatically BIND9 (dns server) is provisioned. this DNS Forwarder VM is used to route the traffic from the Hub-network to the proper Spoke-network.

Virtual machine extension shellscript '03_dnsforwarder_setup.sh'

this script is used to automatically provision the BIND9 DNS forwarder.

#!/bin/sh

## INSTALL DNS FORWARDER (BIND9) on VM

# Install bind9 (dns forwarder)
echo "=================INSTALLING BIND9=================="
sudo apt update
sudo apt install bind9 -y
echo "================= => FINISHED INSTALLING BIND9=================="

# configure bind9 to forward all requests to Azure DNS-server
echo "=================CONFIGURING BIND9=================="
echo "options {
	directory \"/var/cache/bind\";
        recursion yes;
        allow-query { any; }; # do not expose externally
        forwarders {
            168.63.129.16;
        };
        forward only;
        dnssec-validation no; # needed for private dns zones
        auth-nxdomain no; # conform to RFC1035
        listen-on { any; };
};" | sudo tee /etc/bind/named.conf.options
echo "================= => FINISHED CONFIGURING BIND9=================="

# restart bind9 with new config
echo "=================RESTARTING BIND9=================="
sudo service bind9 restart
echo "================= => FINISHED RESTARTING BIND9=================="

# # exit with success
exit 0

Step 3: Create FFA Titan Hub Private DNS Zones

This step creates Private DNS Zones in the Hub-network that are used by the Hub- and Spoke-networks to resolve hostnames to the correct azure-private-endpoitns attached to the FFA TItan platform components.

locals {
  private_dns_zones = {
    blob         = "privatelink.blob.core.windows.net"
    dfs          = "privatelink.dfs.core.windows.net"
    rdbms        = "privatelink.database.windows.net"
    keyvault     = "privatelink.vaultcore.azure.net"
    databricks   = "privatelink.azuredatabricks.net"
    adf          = "privatelink.datafactory.azure.net"
    adf_portal   = "privatelink.adf.azure.com"
    eventhub     = "privatelink.servicebus.windows.net"
  }
}

resource "azurerm_private_dns_zone" "ffa_titan_private_dns_zones" {
  for_each            = local.private_dns_zones
  name                = each.value
  resource_group_name = var.resourcegroup_name
}

resource "azurerm_private_dns_zone_virtual_network_link" "ffa_titan_private_dns_links" {
  for_each                = local.private_dns_zones
  name                    = "${each.key}-vnet-private-link"
  resource_group_name     = var.resourcegroup_name
  private_dns_zone_name   = azurerm_private_dns_zone.ffa_titan_private_dns_zones[each.key].name
  virtual_network_id      = azurerm_virtual_network.ffa_titan_vpn_vnet.id

  depends_on = [
    azurerm_virtual_network.ffa_titan_vpn_vnet,
    azurerm_private_dns_zone.ffa_titan_private_dns_zones
  ]
}

Step 4: Create FFA Titan Hub VPN Gateway

This step rolls out the Hub-network P2S VPN Gateway.

resource "azurerm_public_ip" "ffa_titan_vnet_vpn_public_ip" {
  name                = "${var.titan_prefix}-vpn-public-ip"
  location            = var.location
  resource_group_name = var.resourcegroup_name
  allocation_method   = "Dynamic" #only  temporarely 'Static' to work wwith openvpn
  sku = "Basic" #only 'Standard' temporarely  to work with openvpn
}

resource "azurerm_virtual_network_gateway" "ffa_titan_vpn_gateway" {
  name                = "${var.titan_prefix}-vpn-gw"
  location            = var.location
  resource_group_name = var.resourcegroup_name
  type                = "Vpn"
  vpn_type            = "RouteBased"
  active_active       = false
  enable_bgp          = false
  sku                 = "Basic" #"SKU 'Basic' (sstp - 28 eur p/m only windows) temporarely not used as we use 'VpnGw1' (123 eur per month) to support Linux and MacOS natively as well."

  ip_configuration {
    name                          = "ffa-titan-vnet-gateway-config"
    public_ip_address_id          = azurerm_public_ip.ffa_titan_vnet_vpn_public_ip.id
    private_ip_address_allocation = "Dynamic"
    subnet_id                     = azurerm_subnet.ffa_titan_point_to_site_vpn_gateway_subnet.id
  }

  vpn_client_configuration {
    address_space = ["192.168.76.0/24"]

    root_certificate {
      name             = "RootCert"
      public_cert_data = data.azurerm_key_vault_secret.ds_ffa_titan_vpn_root_certificate.value
    }
  }
  depends_on = [
    azurerm_public_ip.ffa_titan_vnet_vpn_public_ip,
    azurerm_subnet.ffa_titan_point_to_site_vpn_gateway_subnet,
    azurerm_virtual_network_dns_servers.ffa_titan_vnet_private_dns_resolver_as_dnsserver
  ]
}

Please note: you need to have your root certificate, client certificates already created and available in your keyvault. My tip is to create this during the bootstrap-activities of the environment.

Result

We can now successfully connect to our HUB-network with our VPN-client from our local environment. This means we now have private and secure connectivity to the FFA Titan HUB-network.

Spoke-network

We create a seperate Spoke-network for each environment, DEV- , STG, PRD respectively. please note that we have conditional statements in their to set the correct values for each respective environment.

#IaC-goallanguage
1Create FFA Titan Spoke vnet and subnets terraform
2Create FFA Titan Spoke DNS Forwarder VMterraform
3Link with FFA Titan Hub Private DNS Zonesterraform
4Peer FFA Titan Spoke network with FFA Titan Hub networkterraform

Step 1: Create FFA TitanSpoke vnet and subnets

This step creates a private vnet with subnets in the existing resourcegroup rg_ffatitan_dem_<env>

# Create vnet
resource "azurerm_virtual_network" "ffa_titan_vnet" {
  name                = var.vnet_name
  location            = var.location
  resource_group_name = var.resourcegroup_name
  address_space       = var.environment == "dev" ? ["10.1.0.0/16"] : var.environment == "stg" ? ["10.2.0.0/16"] : var.environment == "prd" ? ["10.3.0.0/16"] : ["10.0.0.0/16"]
}

# Create a private subnet
resource "azurerm_subnet" "ffa_titan_private_subnet" {
  name                                      = var.subnet_private_name
  resource_group_name                       = var.resourcegroup_name
  virtual_network_name                      = azurerm_virtual_network.ffa_titan_vnet.name
  address_prefixes                          = var.environment == "dev" ? ["10.1.2.0/24"] : var.environment == "stg" ? ["10.2.2.0/24"] : var.environment == "prd" ? ["10.3.2.0/24"] : ["10.0.2.0/24"]

  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet
  ]
}

# Create a powerplatform subnet
resource "azurerm_subnet" "ffa_titan_powerplatform_subnet" {
  name                                      = var.subnet_powerplatform_name
  resource_group_name                       = var.resourcegroup_name
  virtual_network_name                      = azurerm_virtual_network.ffa_titan_vnet.name
  address_prefixes                          = var.environment == "dev" ? ["10.1.4.0/24"] : var.environment == "stg" ? ["10.2.4.0/24"] : var.environment == "prd" ? ["10.3.4.0/24"] : ["10.0.4.0/24"]

  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet
  ]
}

# Create a Azure Devops Self hosted Build agent Subnet
resource "azurerm_subnet" "ffa_titan_ado_buildagent_subnet" {
  name                                      = var.subnet_adobuildagent_name
  resource_group_name                       = var.resourcegroup_name
  virtual_network_name                      = azurerm_virtual_network.ffa_titan_vnet.name
  address_prefixes                          = var.environment == "dev" ? ["10.1.5.0/24"] : var.environment == "stg" ? ["10.2.5.0/24"] : var.environment == "prd" ? ["10.3.5.0/24"] : ["10.0.5.0/24"]

  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet
  ]
}

# Create a Custom Private DNS Forwarder subnet 
resource "azurerm_subnet" "ffa_titan_aci_dnsforwarder_subnet" {
  name                                      = var.subnet_aciprivatednsforwarder_name
  resource_group_name                       = var.resourcegroup_name
  virtual_network_name                      = azurerm_virtual_network.ffa_titan_vnet.name
  address_prefixes                          = var.environment == "dev" ? ["10.1.6.0/24"] : var.environment == "stg" ? ["10.2.6.0/24"] : var.environment == "prd" ? ["10.3.6.0/24"] : ["10.0.6.0/24"]

  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet
  ]
}

# Create a Custom Private Databricks subnet 

# databricks private endpoints
variable "private_subnet_endpoints" {
  default = []
}

resource "azurerm_subnet" "ffa_titan_privatedatabricks_subnet" {
  name                                      = var.subnet_privatedatabricks_name
  resource_group_name                       = var.resourcegroup_name
  virtual_network_name                      = azurerm_virtual_network.ffa_titan_vnet.name
  address_prefixes                          = var.environment == "dev" ? ["10.1.7.0/24"] : var.environment == "stg" ? ["10.2.7.0/24"] : var.environment == "prd" ? ["10.3.7.0/24"] : ["10.0.7.0/24"]
  
  # delegate subnet to be used for databricks in TITAN-VNET
  delegation {
    name = "databricks"
    service_delegation {
      name = "Microsoft.Databricks/workspaces"
      actions = [
        "Microsoft.Network/virtualNetworks/subnets/join/action",
        "Microsoft.Network/virtualNetworks/subnets/prepareNetworkPolicies/action",
      "Microsoft.Network/virtualNetworks/subnets/unprepareNetworkPolicies/action"]
    }
  }

  service_endpoints = var.private_subnet_endpoints
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet
  ]
}

# Create a Custom Pulbic Databricks subnet 
resource "azurerm_subnet" "ffa_titan_publicdatabricks_subnet" {
  name                                      = var.subnet_publicdatabricks_name
  resource_group_name                       = var.resourcegroup_name
  virtual_network_name                      = azurerm_virtual_network.ffa_titan_vnet.name
  address_prefixes                          = var.environment == "dev" ? ["10.1.8.0/24"] : var.environment == "stg" ? ["10.2.8.0/24"] : var.environment == "prd" ? ["10.3.8.0/24"] : ["10.0.8.0/24"]

  # delegate subnet to be used for databricks in TITAN-VNET
  delegation {
    name = "delegation-databricks"

    service_delegation {
      name    = "Microsoft.Databricks/workspaces"
      actions = [
        "Microsoft.Network/virtualNetworks/subnets/join/action",
        "Microsoft.Network/virtualNetworks/subnets/prepareNetworkPolicies/action",
        "Microsoft.Network/virtualNetworks/subnets/unprepareNetworkPolicies/action"
        ]
    }
  }
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet
  ]
}

# create a custom privatelink databricks subsnet
resource "azurerm_subnet" "ffa_titan_privatelinkdatabricks_subnet" {
  name                                           = var.subnet_privatelinkdatabricks_name
  resource_group_name                            = var.resourcegroup_name
  virtual_network_name                           = azurerm_virtual_network.ffa_titan_vnet.name
  address_prefixes                               = var.environment == "dev" ? ["10.1.9.0/24"] : var.environment == "stg" ? ["10.2.9.0/24"] : var.environment == "prd" ? ["10.3.9.0/24"] : ["10.0.9.0/24"]

  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet
  ]
}

# Set DNS Forwarder IP as Azure VNET Custom DNS Server
resource "azurerm_virtual_network_dns_servers" "ffa_titan_vnet_private_dns_resolver_as_dnsserver" {
  virtual_network_id = azurerm_virtual_network.ffa_titan_vnet.id
  dns_servers        = azurerm_network_interface.ffa_titan_vnet_dnsforwarder_nic.private_ip_addresses
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet,
    azurerm_virtual_machine_extension.ffa_titan_vm_linux_dnsforwarder_extension
  ]
}

please note: in this Spoke-network we create seperate private subnets. each subnet has its own purpose. secondly we set the Custom DNS Server in the VNET to reflect the IP address of the Custom DNS Forwarder VM for the respective Spoke-network.

Step 2: Create FFA Titan Spoke DNS Forwarder VM

this step creates the custom DNS Forwarder VM with BIND9 extension for the respective environment.

# KEYVAULT COMPONENTS
# dnsforwarder password generator
resource "random_password" "ffa_titan_dnsforwarder_password" {
  length           = 16
  min_lower        = 4
  min_numeric      = 4
  min_upper        = 4
  min_special      = 4
  override_special = "!#$%_"
}

# dnsforwarder administrator username
resource "azurerm_key_vault_secret" "ffa_titan_keyvault_secret_dnsforwarder_admin_username" {
  name         = "ffatitan-dnsforwarder-admin-username-${var.environment}"
  value        = "adminuser"
  key_vault_id = data.azurerm_key_vault.ds_ffa_titan_key_vault_acr.id
  lifecycle {
    ignore_changes = [value, version]

  }

  provider = azurerm.ntw
}

# dnsforwarder administrator password
resource "azurerm_key_vault_secret" "ffa_titan_keyvault_secret_dnsforwarder_admin_password" {
  name         = "ffatitan-dnsforwarder-admin-password-${var.environment}"
  value        = random_password.ffa_titan_password.result
  key_vault_id = data.azurerm_key_vault.ds_ffa_titan_key_vault_acr.id
  lifecycle {
    ignore_changes = [value, version]
  }

  provider = azurerm.ntw
}


# VM COMPONENTS
resource "azurerm_network_interface" "ffa_titan_vnet_dnsforwarder_nic" {
  name                = "dnsforwardervm--nic"
  location            = var.location
  resource_group_name = var.resourcegroup_name

  ip_configuration {
    name                          = "ip"
    subnet_id                     = azurerm_subnet.ffa_titan_aci_dnsforwarder_subnet.id
    private_ip_address_allocation = "Static"
    private_ip_address            = var.environment == "dev" ? "10.1.6.4" : var.environment == "stg" ? "10.2.6.4" : var.environment == "prd" ? "10.3.6.4" : "10.0.6.4"
  }
  depends_on = [
    azurerm_subnet.ffa_titan_aci_dnsforwarder_subnet
  ]
}

resource "azurerm_virtual_machine" "ffa_titan_vm_linux_dnsforwarder" {
  name                = "dnsforwardervm"
  location            = var.location
  resource_group_name = var.resourcegroup_name
  network_interface_ids = [
    azurerm_network_interface.ffa_titan_vnet_dnsforwarder_nic.id
  ]
  vm_size                          = "Standard_B1ls"
  delete_data_disks_on_termination = true
  delete_os_disk_on_termination    = true

  storage_os_disk {
    name              = "dnsforwardervm-osdisk"
    caching           = "ReadWrite"
    create_option     = "FromImage"
    managed_disk_type = "Standard_LRS"
  }

  storage_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }

  os_profile {
    computer_name  = "dnsforwardervm"
    admin_username = azurerm_key_vault_secret.ffa_titan_keyvault_secret_dnsforwarder_admin_username.value
    admin_password = random_password.ffa_titan_dnsforwarder_password.result
  }

  os_profile_linux_config {
    disable_password_authentication = false
  }
}

resource "azurerm_virtual_machine_extension" "ffa_titan_vm_linux_dnsforwarder_extension" {
  name                 = "devops-extension"
  virtual_machine_id   = azurerm_virtual_machine.ffa_titan_vm_linux_dnsforwarder.id
  publisher            = "Microsoft.Azure.Extensions"
  type                 = "CustomScript"
  type_handler_version = "2.1"

  settings = <<SETTINGS
    {
        "script": "${filebase64("${path.cwd}/${var.stack}/helper/03_dnsforwarder_setup.sh")}"
    }
SETTINGS
  depends_on = [
    azurerm_virtual_machine.ffa_titan_vm_linux_dnsforwarder
  ]
}

please note: the script used to setup BIND9 is exactly the same as for the Custom DNS Forwarder VM in the Hub-network. you can use that.

In this step we link the FFA Titan Hub Private DNS Zones to the Spoke-network. This ensures that the Custom DNS Forwarder VM in the Spoke-network can properly resolve the private-endpoints belonging to the platform components in its network and route the traffic properly.

resource "azurerm_private_dns_zone_virtual_network_link" "ffa_titan_vnet_private_dns_to_vpnvnet_private_link_blob" {
  name                  = "blob-vnet-vpn-private-link-${var.environment}"
  resource_group_name   = "rg_${var.titan_prefix}_${var.customer_abb}_ntw"
  private_dns_zone_name = data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_blob.name
  virtual_network_id    = azurerm_virtual_network.ffa_titan_vnet.id
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet,
    data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet,
    data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_blob
  ]

  provider = azurerm.ntw
}

resource "azurerm_private_dns_zone_virtual_network_link" "ffa_titan_vnet_private_dns_to_vpnvnet_private_link_dfs" {
  name                  = "dfs-vnet-vpn-private-link-${var.environment}"
  resource_group_name   = "rg_${var.titan_prefix}_${var.customer_abb}_ntw"
  private_dns_zone_name = data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_dfs.name
  virtual_network_id    = azurerm_virtual_network.ffa_titan_vnet.id
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet,
    data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet,
    data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_dfs
  ]

  provider = azurerm.ntw
}

resource "azurerm_private_dns_zone_virtual_network_link" "ffa_titan_vnet_private_dns_to_vpnvnet_private_link_rdbms" {
  name                  = "rdbms-vnet-vpn-private-link-${var.environment}"
  resource_group_name   = "rg_${var.titan_prefix}_${var.customer_abb}_ntw"
  private_dns_zone_name = data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_rdbms.name
  virtual_network_id    = azurerm_virtual_network.ffa_titan_vnet.id
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet,
    data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet,
    data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_rdbms
  ]

  provider = azurerm.ntw
}

resource "azurerm_private_dns_zone_virtual_network_link" "ffa_titan_vnet_private_dns_to_vpnvnet_private_link_keyvault" {
  name                  = "keyvault-vnet-vpn-private-link-${var.environment}"
  resource_group_name   = "rg_${var.titan_prefix}_${var.customer_abb}_ntw"
  private_dns_zone_name = data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_keyvault.name
  virtual_network_id    = azurerm_virtual_network.ffa_titan_vnet.id
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet,
    data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet,
    data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_keyvault
  ]

  provider = azurerm.ntw
}

resource "azurerm_private_dns_zone_virtual_network_link" "ffa_titan_vnet_private_dns_to_vpnvnet_private_link_databricks" {
  name                  = "databricks-vnet-vpn-private-link-${var.environment}"
  resource_group_name   = "rg_${var.titan_prefix}_${var.customer_abb}_ntw"
  private_dns_zone_name = data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_databricks.name
  virtual_network_id    = azurerm_virtual_network.ffa_titan_vnet.id
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet,
    data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet,
    data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_databricks
  ]

  provider = azurerm.ntw
}

resource "azurerm_private_dns_zone_virtual_network_link" "ffa_titan_vnet_private_dns_to_vpnvnet_private_link_adf" {
  name                  = "datafactory-vnet-vpn-private-link-${var.environment}"
  resource_group_name   = "rg_${var.titan_prefix}_${var.customer_abb}_ntw"
  private_dns_zone_name = data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_adf.name
  virtual_network_id    = azurerm_virtual_network.ffa_titan_vnet.id
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet,
    data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet,
    data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_adf
  ]

  provider = azurerm.ntw
}

resource "azurerm_private_dns_zone_virtual_network_link" "ffa_titan_vnet_private_dns_to_vpnvnet_private_link_adf_portal" {
  name                  = "datafactoryportal-vnet-vpn-private-link-${var.environment}"
  resource_group_name   = "rg_${var.titan_prefix}_${var.customer_abb}_ntw"
  private_dns_zone_name = data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_adf_portal.name
  virtual_network_id    = azurerm_virtual_network.ffa_titan_vnet.id
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet,
    data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet,
    data.azurerm_private_dns_zone.ffa_titan_vnet_private_dns_zone_adf_portal
  ]

  provider = azurerm.ntw
}

please note: make sure you declare your data.* objects in terraform for the respective private dns zones from the HUB-network. otherwise this wont work.

Step 4: Peer FFA Titan Spoke network with FFA Titan Hub network

This step virtually peers the Spoke- and Hub-network to each other. this must be done twice in opposite directions to ensure bi-directional network traffic can flow freely between the Hub and respective Spoke (environment).

# enable global peering between the two virtual network
resource "azurerm_virtual_network_peering" "peering_vpn_vnet_to_remote_network" {
  name                         = "peering-vnet-vpn-to-vnet-ffatitan-${var.environment}"
  resource_group_name          = "rg_${var.titan_prefix}_${var.customer_abb}_ntw"
  virtual_network_name         = data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet.name
  remote_virtual_network_id    = azurerm_virtual_network.ffa_titan_vnet.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  allow_gateway_transit        = true

  #depends on
  depends_on = [
    data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet
  ]

  # need to use the ntw azurerm-provider
  provider = azurerm.ntw
}

resource "azurerm_virtual_network_peering" "peering_remote_network_to_vpn_net" {
  name                         = "peering-vnet-ffatitan-${var.environment}-to-vnet-vpn"
  resource_group_name          = var.resourcegroup_name
  virtual_network_name         = var.vnet_name
  remote_virtual_network_id    = data.azurerm_virtual_network.ds_ffa_titan_vpn_vnet.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  use_remote_gateways          = true

  #depends on
  depends_on = [
    azurerm_virtual_network.ffa_titan_vnet
  ]
}

Result

We have succesfully peered the Hub and spoke-network together. In the spoke network you will see th same peering but than in opposite directions.

Conclusion WIP

By automating the setup of the Hub- and Spoke-network using Terraform and Shellscript, we ensure a seamless, repeatable, and secure deployment process. This approach eliminates manual intervention, enhances security, and ensures consistency across different environments.

As we are now connected to the VPN gateway of the Hub-network we can reach the Custom DNS Forwarder VM in the Spoke-network (dev-environment).

This proves that we've successfully set up our Hub-Spoke-network-toplogy as we can reach the Custom DNS Forwarder VM in the spoke-network. Automating this process with Terraform ensures that our platform remains ISO27001 compliant while leveraging a fully managed SQL environment.

Truly azure secure networking made simple.

FAQ

Do I need specific Azure roles or permissions to deploy this?

Yes. The Terraform identity (Service Principal or user) needs:
Contributor role on the resource group
Private DNS Zone Contributor if you're creating DNS zones
Network Contributor for virtual networks, subnets, and links
Additionally, if you're managing DNS zones or VNets across multiple subscriptions, make sure the identity has cross-subscription access.

What Prerequisites Are Required to Execute the Code in This Blog?

Tooling
- Azure Subscription: Active subscription with necessary permissions.
- Terraform: Installed locally or in CI/CD, compatible with Azure providers.
- Azure CLI: Installed for managing Azure resources.
- Azure resourcegroups: 'rg_ffatitan_dem_ntw' and 'rg_ffatitan_dem_dev' present

Variables
- Azure Subscription & Resource Group IDs: Needed for deploying hub-spoke network resources.
- Azure Key Vault Name: Used to store the VPN-certificates and credentials for the DNS VMs
- Tenant ID, Client ID, Client Secret: Credentials for the Azure Service Principal.
- Environment Variables: Terraform authentication variables (ARM_CLIENT_ID, ARM_TENANT_ID, etc.).

Libraries
- Terraform Providers:azurerm: For managing Azure resources.

What terraform providers setup do i need to configure

ntw-alias:

provider "azurerm" {
alias = "ntw" # for network subscription (as used in your code)
features = {}
subscription_id = var.subscription_id_ntw
}


default
provider "azurerm" {
features = {}
}

Sjors Otten
Management

“Insights without action is worthless”

Sjors Otten is a pragmatic and passionate data & analytics architect. He excels in leveraging the untapped potential of your organization’s data. Sjors has a solid background in Business Informatics and Software Development.

With his years of experience on all levels of IT, Sjors is the go-to-person for breaking down business- and IT-strategies in workable and understandable data & analytics solutions for all levels within your organization whilst maintaining alignment with the defined corporate strategies.

Related blogs

Self-hosted Azure DevOps Agent Deployment with Terraform

Azure DevOps Agent deployment with Terraform

Read more
Secure azure networking made simple

Secure azure hub-spoke networking

Read more
Why SMBs Struggle with Data & AI Platforms (And How to Fix It)

Why SMBs struggle with Data & AI platforms

Read more

Ready to become a titan in the food industry?

Get your own Titan
Food For Analytics © 2025 All rights reserved
Chamber of Commerce 73586218
linkedin facebook pinterest youtube rss twitter instagram facebook-blank rss-blank linkedin-blank pinterest youtube twitter instagram