Azure Private Endpoints With Terraform

In my case, I needed to reach Azure Database for PostgreSQL from my laptop without punching temporary holes in the firewall every time my IP changed.

This is the exact Terraform setup I used: private endpoint + private DNS + point-to-site VPN, with Azure VPN Client.

1. Add dedicated private networking Terraform

I kept this in its own file (private-networking.tf) so the core deployment files stay clean.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
locals {
vnet_address_space = ["10.50.0.0/16"]
private_endpoint_subnet = "10.50.1.0/24"
gateway_subnet = "10.50.255.0/27"
vpn_gateway_sku = "VpnGw1AZ"
vpn_client_address_space = ["172.16.201.0/24"]
}

resource "azurerm_virtual_network" "resolv-vnet" {
name = "resolv-${var.env_id}-vnet"
location = azurerm_resource_group.resolv-rg.location
resource_group_name = azurerm_resource_group.resolv-rg.name
address_space = local.vnet_address_space
}

resource "azurerm_subnet" "private-endpoints" {
name = "private-endpoints"
resource_group_name = azurerm_resource_group.resolv-rg.name
virtual_network_name = azurerm_virtual_network.resolv-vnet.name
address_prefixes = [local.private_endpoint_subnet]

private_endpoint_network_policies = "Disabled"
}

resource "azurerm_subnet" "gateway" {
name = "GatewaySubnet"
resource_group_name = azurerm_resource_group.resolv-rg.name
virtual_network_name = azurerm_virtual_network.resolv-vnet.name
address_prefixes = [local.gateway_subnet]
}

The key bits here are:

  1. A subnet for private endpoints.
  2. A GatewaySubnet for VPN gateway.
  3. AZ-compatible VPN SKU (VpnGw1AZ) to avoid deprecated SKU errors.

Docs:

2. Add PostgreSQL private endpoint and private DNS

This is what let my resolv-psql-uat.postgres.database.azure.com resolve and route privately once connected over VPN.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
resource "azurerm_private_dns_zone" "pgsql-privatelink" {
name = "privatelink.postgres.database.azure.com"
resource_group_name = azurerm_resource_group.resolv-rg.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "pgsql-privatelink-vnet-link" {
name = "resolv-${var.env_id}-pgsql-privatelink-link"
resource_group_name = azurerm_resource_group.resolv-rg.name
private_dns_zone_name = azurerm_private_dns_zone.pgsql-privatelink.name
virtual_network_id = azurerm_virtual_network.resolv-vnet.id
}

resource "azurerm_private_endpoint" "resolv-psql-pe" {
name = "resolv-psql-${var.env_id}-pe"
location = azurerm_resource_group.resolv-rg.location
resource_group_name = azurerm_resource_group.resolv-rg.name
subnet_id = azurerm_subnet.private-endpoints.id

private_service_connection {
name = "resolv-psql-${var.env_id}-psc"
private_connection_resource_id = azurerm_postgresql_flexible_server.resolv-psql.id
is_manual_connection = false
subresource_names = ["postgresqlServer"]
}

private_dns_zone_group {
name = "default"
private_dns_zone_ids = [azurerm_private_dns_zone.pgsql-privatelink.id]
}
}

Without the private DNS zone and VNet link, name resolution is usually where things break.

Docs: Private Endpoint for Azure Database for PostgreSQL flexible serverAzure Private DNS

3. Configure P2S VPN for Azure VPN Client

I used Azure AD (Entra ID) auth with OpenVPN so sign-in is with the same tenant account used in the Azure portal.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
resource "azurerm_public_ip" "resolv-vpngw-pip" {
name = "resolv-${var.env_id}-vpngw-pip"
location = azurerm_resource_group.resolv-rg.location
resource_group_name = azurerm_resource_group.resolv-rg.name
allocation_method = "Static"
sku = "Standard"
zones = ["1"]
domain_name_label = "resolv-${var.env_id}-vpngw"
}

resource "azurerm_virtual_network_gateway" "resolv-vpngw" {
name = "resolv-${var.env_id}-vpngw"
location = azurerm_resource_group.resolv-rg.location
resource_group_name = azurerm_resource_group.resolv-rg.name

type = "Vpn"
vpn_type = "RouteBased"
sku = local.vpn_gateway_sku

ip_configuration {
name = "vpngw-ipconfig"
public_ip_address_id = azurerm_public_ip.resolv-vpngw-pip.id
private_ip_address_allocation = "Dynamic"
subnet_id = azurerm_subnet.gateway.id
}

vpn_client_configuration {
address_space = local.vpn_client_address_space
vpn_client_protocols = ["OpenVPN"]
vpn_auth_types = ["AAD"]
aad_tenant = "https://login.microsoftonline.com/${var.tenant_id}/"
aad_audience = "41b23e61-6c1e-4545-b367-cd054e0ed4b4"
aad_issuer = "https://sts.windows.net/${var.tenant_id}/"
}
}

Two important gotchas here:

  1. AZ gateway SKUs require zoned Standard public IPs.
  2. domain_name_label is required on the public IP. The Azure VPN Client only accepts an FQDN as the VPN Server address — a bare IP will fail with “Azure VPN Client can only connect to Azure VPN Servers”. Setting domain_name_label gives the IP a stable DNS name like resolv-uat-vpngw.<region>.cloudapp.azure.com.

Docs: About Point-to-Site VPNConfigure Microsoft Entra authentication for P2S

4. Get the VPN gateway FQDN after deployment

Once applied, retrieve the exact FQDN to use in the Azure VPN Client:

1
2
3
4
5
az network public-ip show \
--resource-group resolv-uat-rg \
--name resolv-uat-vpngw-pip \
--query dnsSettings.fqdn \
--output tsv

This returns something like resolv-uat-vpngw.australiaeast.cloudapp.azure.com. That value goes in the VPN Server field when importing the VPN profile in Azure VPN Client.

5. Run Terraform plan and apply

This is the sequence I used in UAT.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cd ./terraform/web/uat

# Login to the correct tenant/subscription
az login --tenant 00000000-0000-0000-0000-000000000001
az account set --subscription 00000000-0000-0000-0000-000000000002

# If backend config changed or your .terraform folder was reset
terraform init -reconfigure

# Review proposed infra changes
terraform plan

# Apply once the plan looks correct
terraform apply -auto-approve

What to expect while applying:

  1. Private DNS zone, VNet, and private endpoint create relatively quickly.
  2. VPN gateway creation is slow and can take 30 to 60 minutes depending on region load.
  3. Seeing repeated Still creating... for the VPN gateway is normal, so do not cancel as long as status updates keep appearing.
  4. If apply fails midway, fix the issue and run terraform plan then terraform apply again. Terraform will continue from current state.

Docs: Terraform planTerraform applyTerraform init

6. Test end-to-end

These were my exact verification steps:

  1. Install Azure VPN Client.
  2. Download and import the VPN profile:
  • In Azure Portal, open your virtual network gateway (resolv-uat-vpngw).
  • Go to Settings -> Point-to-site configuration and click Download VPN client.
  • Extract the downloaded zip.
  • Open Azure VPN Client, choose + then Import, and select the azurevpnconfig.xml file.
  1. Connect and sign in with the same Microsoft account in the target tenant.
  2. Confirm DNS resolution and connectivity:
1
2
nslookup <DEPLOYMENT-NAME>.postgres.database.azure.com
psql "host=<DEPLOYMENT-NAME>.postgres.database.azure.com port=5432 dbname=<db> user=<user> sslmode=require"

If DNS resolves to a private RFC1918 address and psql connects, the path is working as intended.

Docs:

7. Troubleshooting I hit during rollout

I ran into both of these when applying in UAT:

  1. NonAzSkusNotAllowedForVPNGateway.
    Use VpnGw1AZ (or another *AZ SKU), not VpnGw1.
  2. VmssVpnGatewayPublicIpsMustHaveZonesConfigured.
    Set zones on the Standard public IP used by the VPN gateway.
  3. “Azure VPN Client can only connect to Azure VPN Servers”.
    The VPN Server field in the client must be an FQDN, not a raw IP. Add domain_name_label to the azurerm_public_ip resource (see section 3), redeploy, then use the FQDN from section 4 in the client.

Also, if terraform plan suddenly says backend init is required, run:

1
terraform init -reconfigure

That rebinds the working directory to backend config; it does not redeploy resources by itself.

Docs: Terraform init commandAzure VPN gateway SKU changes