If you run services on Azure Container Apps (ACA), you probably put Azure Front Door (AFD) in front of them. This is the standard pattern in Microsoft's own documentation.
The problem: to use a WAF, you need the AFD Premium plan, and it costs $330/month. That is a lot for a startup or a small team. Many of us downgraded to the Standard plan and gave up on the WAF (I did too).
But running a production service without a WAF did not feel right. I looked for a way to get a WAF without paying $330/month — and Cloudflare Tunnel solved it.
This article shows how to move the edge layer (CDN / WAF) to Cloudflare, and how to connect a fully private (VNet-integrated) ACA environment to it with Cloudflare Tunnel.
TL;DR
- The WAF that needed AFD Premium ($330/mo) now runs on Cloudflare Pro ($20/mo)
- The origin only accepts Tunnel connections from Cloudflare, so zero inbound ports are open on Azure
- No custom domain registration and no certificate management on the Azure side
- Everything from the Tunnel to DNS is written in Terraform
Sample repository
A working sample is available here:
https://github.com/AranSeshita/aca-cloudflare-tunnel
Cost comparison: AFD vs Cloudflare
Let's look at the options and costs for running a WAF.
| AFD Standard | AFD Premium | App Gateway WAF v2 | Cloudflare Pro | |
|---|---|---|---|---|
| Base price | $35/mo | $330/mo | from $323/mo | $20/mo |
| Managed Rules | Not available | Included | Included | Included (OWASP) |
| Custom rules | Limited | Full | Full | Full |
If you want WAF Managed Rules / Bot Manager on Azure, Premium is the only real option. With three environments (PROD / SBX / QA), you pay almost $1,000/month.
Cloudflare is different: the Tunnel itself is free, and the only fixed cost is the plan. This article uses the Pro plan ($20/mo) because we want the full WAF: OWASP Core Ruleset, custom rules, Super Bot Fight Mode, and Rate Limiting.
- You use AFD Premium ($330/mo) → switch to Pro ($20/mo) and cut the cost a lot
- You use AFD Standard ($35/mo) and gave up on the WAF → for almost the same money, Pro gives you the full WAF
- You want the lowest cost → the Free plan ($0) also supports the private + zero-inbound design. The only difference from Pro is the WAF ruleset
Architecture
Cloudflare Tunnel works like this: you run a small connector called cloudflared next to your origin. The connector opens outbound connections to Cloudflare's edge, and all traffic goes through them. In this setup, cloudflared runs as a container inside ACA.
The connection is outbound only, so you never open an inbound port on Azure. The ACA Environment is Internal (VNet-integrated): each Container App only has an internal FQDN that works inside the VNet. There is no public IP and no public FQDN. The only way in from the outside is the Tunnel. Nice and simple.
Building it with Terraform
The code below uses plain resources so it is easy to read. The sample repository has the same setup as reusable modules (one for the Tunnel, one for Container Apps). Use the repository if you want to add this to a real project.
Prerequisites
- A custom domain managed on Cloudflare
- An Azure subscription (Owner, or Contributor + User Access Administrator, because the sample assigns the AcrPull role to a Managed Identity)
- Azure CLI (
az) and Terraform 1.5+ (hashicorp/azurerm ~> 4.0,cloudflare/cloudflare ~> 5.19)
The VNet, the internal ACA Environment, and the frontend / backend Container Apps are all built in the sample repository. This article focuses on the Cloudflare Tunnel parts.
0. Create a Cloudflare API token (dashboard)
Create a Terraform-only API token under [My Profile → API Tokens → Create Custom Token]. It needs exactly three permissions:
| Scope | Permission | Access |
|---|---|---|
| Account | Cloudflare Tunnel | Edit |
| Zone | DNS | Edit |
| Zone | Zone | Read |
Notes:
- WAF permissions are left out on purpose. The WAF is managed in the dashboard (see step 5 for why).
- A token limited to Tunnel + DNS causes less damage if it ever leaks.
Provider
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.19"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id # required since azurerm v4
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
1. The Cloudflare Tunnel resource
# config_src = "cloudflare" means the ingress routing is managed remotely on the Cloudflare side
resource "cloudflare_zero_trust_tunnel_cloudflared" "main" {
account_id = var.cloudflare_account_id
name = "${var.project_name}-${var.environment}-tunnel"
config_src = "cloudflare"
}
# Connector token, needed to start cloudflared.
# In provider v5 the tunnel resource does not expose it - use this data source.
data "cloudflare_zero_trust_tunnel_cloudflared_token" "main" {
account_id = var.cloudflare_account_id
tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.main.id
}
2. Deploy cloudflared to ACA
# ACA Environment (Internal / VNet-integrated)
resource "azurerm_container_app_environment" "main" {
name = "cae-${var.project_name}-${var.environment}"
location = var.location
resource_group_name = var.resource_group_name
log_analytics_workspace_id = var.log_analytics_workspace_id
# Subnet delegated to Microsoft.App/environments (min /23 for Consumption-only)
infrastructure_subnet_id = var.aca_subnet_id
internal_load_balancer_enabled = true # Internal environment with no public IP
# Consumption-only environments get a "Consumption" workload profile
# added by Azure automatically - ignore it to avoid a permanent diff
lifecycle {
ignore_changes = [workload_profile]
}
}
# cloudflared (Tunnel connector)
resource "azurerm_container_app" "cloudflared" {
name = "ca-${var.project_name}-${var.environment}-cfd"
resource_group_name = var.resource_group_name
container_app_environment_id = azurerm_container_app_environment.main.id
revision_mode = "Single"
# SystemAssigned because it never accesses Azure resources
identity {
type = "SystemAssigned"
}
secret {
name = "tunnel-token"
value = data.cloudflare_zero_trust_tunnel_cloudflared_token.main.token
}
template {
min_replicas = 2 # run 2+ connectors (avoid a single point of failure)
max_replicas = 3
container {
name = "cloudflared"
image = "docker.io/cloudflare/cloudflared:2026.5.0" # pin the version
cpu = 0.25
memory = "0.5Gi"
args = ["tunnel", "--no-autoupdate", "run", "--token", "$(TUNNEL_TOKEN)"]
env {
name = "TUNNEL_TOKEN"
secret_name = "tunnel-token"
}
}
}
# No ingress block - this is an outbound-only, headless worker
}
Notes:
- With
internal_load_balancer_enabled = true, the environment has no public IP and every app only gets an internal FQDN. The only way in from outside is the Tunnel.infrastructure_subnet_idmust be a subnet delegated to Microsoft.App/environments -
min_replicas = 2keeps the connector redundant. With a single instance, the whole service goes down during deploys or node failures - cloudflared is outbound-only, so the
ingressblock is not needed. It is a public image, so noregistryblock either - Pin the
imageversion (do not uselatest)
3. Tunnel ingress rules (the Host header rewrite is the key)
locals {
# The internal ingress FQDN of the app we publish. ingress[0].fqdn has
# no revision suffix, and it includes the ".internal." part that
# internal environments use.
frontend_origin = azurerm_container_app.frontend.ingress[0].fqdn
}
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "main" {
account_id = var.cloudflare_account_id
tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.main.id
source = "cloudflare"
config = {
ingress = [
{
hostname = var.public_hostname # e.g. app.example.com
service = "https://${local.frontend_origin}"
origin_request = {
http_host_header = local.frontend_origin
origin_server_name = local.frontend_origin
}
},
{ service = "http_status:404" } # catch-all (required, must be last)
]
}
}
This is the most important part of the setup.
ACA's ingress only accepts HTTP requests sent to its own FQDN. To use a custom domain, something must rewrite the Host header before the request reaches the origin. AFD did this internally. Here we do it with http_host_header.
If you forget this, the Host does not match: ACA returns 404 (or a TLS validation error if the SNI does not match), and cloudflared cannot reach the origin. In practice, this is the biggest pitfall in this whole build.
-
http_host_headersets the Host to the app's internal FQDN, andorigin_server_namesets the TLS SNI to match (if the SNI is wrong, TLS validation fails) - Use
ingress[0].fqdnfor the origin FQDN.latest_revision_fqdnhas a revision suffix and breaks on every new revision. Building<app>.<default_domain>by hand drops the.internal.part on internal environments, so the origin becomes unreachable - The catch-all rule at the end and
source = "cloudflare"are both required (if you forget the latter, provider v5 shows a confusing "a number is required" error)
What you get: no custom domain registration and no certificate management on ACA (between Cloudflare and the origin, ACA's automatic certificate is used). To publish another hostname, just add one more ingress rule.
The azurerm_container_app.frontend used here is defined elsewhere (see the sample repository).
4. DNS record
Create a CNAME that points to the Tunnel (<tunnel-id>.cfargotunnel.com).
resource "cloudflare_dns_record" "main" {
zone_id = var.cloudflare_zone_id
name = var.public_hostname
content = "${cloudflare_zero_trust_tunnel_cloudflared.main.id}.cfargotunnel.com"
type = "CNAME"
proxied = true # required - a DNS-only record does not route
ttl = 1 # 1 = automatic when proxied
}
5. WAF / Geo / Rate Limit (set up in the dashboard)
The WAF is not managed by Terraform. This is on purpose, for two reasons:
- During an incident, you often need to change rules right away, without waiting for a plan / apply cycle
- WAF resources (
cloudflare_rulesetand others) have broken several times across major provider versions, so managing them in Terraform adds maintenance work
This is also why the API token in step 0 has no WAF permissions. The boundary (Terraform = Tunnel/DNS, dashboard = WAF) is enforced at the permission level too.
The setup itself is under Security → WAF in the dashboard:
- Managed Rules: enable the Cloudflare Managed Ruleset and the OWASP Core Ruleset (Pro plan)
-
Custom rules: for example, if you only serve one country, a geo rule like
Country does not equal Japan → Block - Rate limiting rules: add per-endpoint limits as needed
Trade-offs, and when not to use this
It is not all upside. Things to know before you adopt this:
- You operate part of the edge yourself: with AFD, the whole edge layer was managed. Here, cloudflared is a workload running on your ACA. Capacity, health monitoring, and image updates are now your job.
- One more vendor in the path: an AFD setup depends only on Azure. This setup puts Azure and Cloudflare in series. If either one goes down, your service goes down, and during incidents you check two status pages.
- Observability changes: AFD's diagnostic logs and metrics are gone. Plan to use Cloudflare Analytics instead (Logpush needs Enterprise).
There are also cases where this simply does not fit: if audit or compliance rules require the edge layer to stay inside Azure (traffic must not pass through a third-party edge), you cannot use this design.
On the other hand, if you are a startup, running a PoC, or hosting internal tools — you want a WAF and a CDN, but not the fixed cost — this design fits well.
Summary
ACA + Cloudflare Tunnel is a simple design that cuts your edge-layer cost without giving up security.
- Cost: the WAF that needed AFD Premium ($330/mo) now costs $20/mo on Cloudflare Pro
- Security: ACA stays Internal (fully private) and is published only through cloudflared's outbound connections. Zero inbound ports on Azure
- Operations: no custom domain registration or certificate management on ACA, and everything is managed with Terraform (the WAF stays in the dashboard for quick tuning)
Once you drop the idea that "everything on Azure must be an Azure resource", you get more options for cost and flexibility — especially as a startup or a small team.
Closing
Your own requirements and compliance rules come first, of course. But when the budget is small and you do not want to give up on security, I hope this design is a useful option.
The Tunnel also works on the Free plan, so clone the sample repo and try running cloudflared yourself:


Top comments (0)