DEV Community

Aran Seshita
Aran Seshita

Posted on

Replacing Azure Front Door with Cloudflare Tunnel: Zero-Inbound Azure Container Apps with a Full WAF for $20/month

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.

architecture

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

Cloudflare dashboard screenshot of the Create Custom Token screen

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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_id must be a subnet delegated to Microsoft.App/environments
  • min_replicas = 2 keeps the connector redundant. With a single instance, the whole service goes down during deploys or node failures
  • cloudflared is outbound-only, so the ingress block is not needed. It is a public image, so no registry block either
  • Pin the image version (do not use latest)

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)
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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_header sets the Host to the app's internal FQDN, and origin_server_name sets the TLS SNI to match (if the SNI is wrong, TLS validation fails)
  • Use ingress[0].fqdn for the origin FQDN. latest_revision_fqdn has 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
}
Enter fullscreen mode Exit fullscreen mode

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_ruleset and 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:

https://github.com/AranSeshita/aca-cloudflare-tunnel

Top comments (0)