Automating SSL/TLS certificates with Let's Encrypt and Azure Key Vault is a solved problem. Tools like Azure Acmebot make deployment incredibly simple.
In corporate environments targeting ISO 27001, KRITIS, or NIS2 compliance, "simple" is rarely sufficient. The standard Acmebot deployment relies on public endpoints — and a Storage Account or Key Vault reachable from the public internet will immediately trigger findings during a security audit.
Here's how to transition from a standard deployment to a fully network-isolated, Zero-Trust architecture — entirely managed with Terraform.
The Compliance Gap
Three components expose a public attack surface out of the box:
- Storage Account — accepts traffic from all networks by default
- Key Vault — operates with a public endpoint unless explicitly restricted
- Function App — the Acmebot dashboard is publicly reachable with no network-layer restriction
Target principle: Default-Deny at the network layer, not just the identity layer.
Step 1: VNet Integration
The Function App needs a dedicated subnet with a delegation to Microsoft.Web/serverFarms:
resource "azurerm_subnet" "acmebot_integration" {
name = "snet-acmebot-integration"
resource_group_name = var.existing_vnet_rg
virtual_network_name = var.existing_vnet_name
address_prefixes = ["10.0.1.0/27"]
delegation {
name = "delegation-acmebot"
service_delegation {
name = "Microsoft.Web/serverFarms"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
}
Two settings that will cost you hours if you miss them:
site_config {
vnet_route_all_enabled = true # Forces ALL outbound through VNet
}
app_settings = {
"WEBSITE_DNS_SERVER" = "168.63.129.16" # Azure internal DNS — required
"WEBSITE_CONTENTOVERVNET" = "1"
}
Without vnet_route_all_enabled = true, the Function still tries to reach Storage over the public internet and fails silently once you lock it down.
Step 2: Private Endpoints & DNS
The Storage Account requires four separate Private Endpoints — one each for blob, table, queue, and file. Azure Functions uses all four internally. Skip any one and the Function App deploys successfully but fails at runtime with cryptic storage errors:
resource "azurerm_private_endpoint" "storage_pe" {
for_each = toset(["blob", "table", "queue", "file"])
name = "pe-acmebot-st-${each.key}"
subnet_id = azurerm_subnet.acmebot_endpoints.id
private_service_connection {
name = "psc-acmebot-st-${each.key}"
private_connection_resource_id = azurerm_storage_account.acmebot_storage.id
subresource_names = [each.key]
is_manual_connection = false
}
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [var.private_dns_zone_ids[each.key]]
}
}
Step 3: Default-Deny Firewall Rules
resource "azurerm_storage_account" "acmebot_storage" {
public_network_access_enabled = false
allow_nested_items_to_be_public = false
network_rules {
default_action = "Deny"
bypass = ["AzureServices"]
virtual_network_subnet_ids = [azurerm_subnet.acmebot_integration.id]
}
}
bypass = "AzureServices" is required — it allows Azure's internal control plane operations to continue working. Without it, diagnostic log shipping and other platform features silently break.
After applying this configuration, your Acmebot deployment meets the network isolation requirements of ISO 27001 Annex A.8, NIS2 Article 21, and KRITIS baseline controls.
The complete tested architecture including Entra ID automation and full Private Link configuration is on my blog and GitHub.
Top comments (0)