Azure DevOps Agent deployment with Terraform
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.

The Hub-Spoke model separates shared services (the “hub”) from workloads (the “spokes”). Providing the following benefits:
This approach scales better than flat virtual networks. Especially when managing multiple business units or projects.
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.
We’ve solved this with a cost-effective, repeatable network setup that uses:
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.
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 component | Location | Public access |
| 1 | FFA Titan Hub network | FFA Titan hub network resourcegroup | Disabled |
| 2 | FFA Titan Spoke network | FFA Titan environment resourcegroup | Disabled |
To achieve secure connectivity to [1] and [2] we need to implement the following in our Infrastructure-as-Code:
| # | IaC-goal | language |
| 1 | Create FFA Titan Hub vnet and subnets | terraform |
| 2 | Create FFA Titan Hub DNS Forwarder VM | terraform |
| 3 | Create FFA Titan Hub Private DNS Zones | terraform |
| 4 | Create FFA Titan Hub VPN Gateway | terraform |
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.
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.
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
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
]
}
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.

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.
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-goal | language |
| 1 | Create FFA Titan Spoke vnet and subnets | terraform |
| 2 | Create FFA Titan Spoke DNS Forwarder VM | terraform |
| 3 | Link with FFA Titan Hub Private DNS Zones | terraform |
| 4 | Peer FFA Titan Spoke network with FFA Titan Hub network | terraform |
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.
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.
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
]
}

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.
Yes. The Terraform identity (Service Principal or user) needs:Contributor role on the resource groupPrivate DNS Zone Contributor if you're creating DNS zonesNetwork 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.
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.
ntw-alias:provider "azurerm" {
alias = "ntw" # for network subscription (as used in your code)
features = {}
subscription_id = var.subscription_id_ntw
}
defaultprovider "azurerm" {
features = {}
}
Azure DevOps Agent deployment with Terraform
Why SMBs struggle with Data & AI platforms