<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aran Seshita</title>
    <description>The latest articles on DEV Community by Aran Seshita (@aranseshita).</description>
    <link>https://dev.to/aranseshita</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2555091%2F5cd93861-f054-4241-be07-5661ac4ae15e.jpeg</url>
      <title>DEV Community: Aran Seshita</title>
      <link>https://dev.to/aranseshita</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aranseshita"/>
    <language>en</language>
    <item>
      <title>Replacing Azure Front Door with Cloudflare Tunnel: Zero-Inbound Azure Container Apps with a Full WAF for $20/month</title>
      <dc:creator>Aran Seshita</dc:creator>
      <pubDate>Wed, 15 Jul 2026 00:40:58 +0000</pubDate>
      <link>https://dev.to/aranseshita/replacing-azure-front-door-with-cloudflare-tunnel-zero-inbound-azure-container-apps-with-a-full-23oe</link>
      <guid>https://dev.to/aranseshita/replacing-azure-front-door-with-cloudflare-tunnel-zero-inbound-azure-container-apps-with-a-full-23oe</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The problem: to use a WAF, you need the AFD &lt;strong&gt;Premium&lt;/strong&gt; plan, and it costs &lt;strong&gt;$330/month&lt;/strong&gt;. 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).&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt; solved it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The WAF that needed AFD Premium ($330/mo) &lt;strong&gt;now runs on Cloudflare Pro ($20/mo)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The origin only accepts Tunnel connections from Cloudflare, so &lt;strong&gt;zero inbound ports are open on Azure&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No custom domain registration and no certificate management&lt;/strong&gt; on the Azure side&lt;/li&gt;
&lt;li&gt;Everything from the Tunnel to DNS is written in Terraform&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sample repository
&lt;/h2&gt;

&lt;p&gt;A working sample is available here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/AranSeshita/aca-cloudflare-tunnel" rel="noopener noreferrer"&gt;https://github.com/AranSeshita/aca-cloudflare-tunnel&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost comparison: AFD vs Cloudflare
&lt;/h2&gt;

&lt;p&gt;Let's look at the options and costs for running a WAF.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AFD Standard&lt;/th&gt;
&lt;th&gt;AFD Premium&lt;/th&gt;
&lt;th&gt;App Gateway WAF v2&lt;/th&gt;
&lt;th&gt;Cloudflare Pro&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Base price&lt;/td&gt;
&lt;td&gt;$35/mo&lt;/td&gt;
&lt;td&gt;$330/mo&lt;/td&gt;
&lt;td&gt;from $323/mo&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$20/mo&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed Rules&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Included (OWASP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom rules&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Cloudflare is different: &lt;strong&gt;the Tunnel itself is free&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You use AFD Premium ($330/mo) → switch to Pro ($20/mo) and &lt;strong&gt;cut the cost a lot&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;You use AFD Standard ($35/mo) and gave up on the WAF → for &lt;strong&gt;almost the same money&lt;/strong&gt;, Pro gives you the full WAF&lt;/li&gt;
&lt;li&gt;You want the lowest cost → &lt;strong&gt;the Free plan ($0) also supports the private + zero-inbound design&lt;/strong&gt;. The only difference from Pro is the WAF ruleset&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Cloudflare Tunnel works like this: you run a small connector called &lt;strong&gt;cloudflared&lt;/strong&gt; next to your origin. The connector opens &lt;em&gt;outbound&lt;/em&gt; connections to Cloudflare's edge, and all traffic goes through them. In this setup, cloudflared runs as a container inside ACA.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fitxk3kiwwmzjqmf31sp0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fitxk3kiwwmzjqmf31sp0.png" alt="architecture" width="800" height="879"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The connection is outbound only, so &lt;strong&gt;you never open an inbound port on Azure&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building it with Terraform
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A custom domain managed on Cloudflare&lt;/li&gt;
&lt;li&gt;An Azure subscription (&lt;strong&gt;Owner&lt;/strong&gt;, or &lt;strong&gt;Contributor + User Access Administrator&lt;/strong&gt;, because the sample assigns the AcrPull role to a Managed Identity)&lt;/li&gt;
&lt;li&gt;Azure CLI (&lt;code&gt;az&lt;/code&gt;) and Terraform 1.5+ (&lt;code&gt;hashicorp/azurerm ~&amp;gt; 4.0&lt;/code&gt;, &lt;code&gt;cloudflare/cloudflare ~&amp;gt; 5.19&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  0. Create a Cloudflare API token (dashboard)
&lt;/h3&gt;

&lt;p&gt;Create a Terraform-only API token under [My Profile → API Tokens → Create Custom Token]. It needs exactly three permissions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Access&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Account&lt;/td&gt;
&lt;td&gt;Cloudflare Tunnel&lt;/td&gt;
&lt;td&gt;Edit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zone&lt;/td&gt;
&lt;td&gt;DNS&lt;/td&gt;
&lt;td&gt;Edit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zone&lt;/td&gt;
&lt;td&gt;Zone&lt;/td&gt;
&lt;td&gt;Read&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbncsdoatdp5hzw786chc.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbncsdoatdp5hzw786chc.webp" alt="Cloudflare dashboard screenshot of the Create Custom Token screen" width="800" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WAF permissions are left out on purpose.&lt;/strong&gt; The WAF is managed in the dashboard (see step 5 for why).&lt;/li&gt;
&lt;li&gt;A token limited to Tunnel + DNS causes less damage if it ever leaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Provider
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.5"&lt;/span&gt;

  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/azurerm"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 4.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;cloudflare&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare/cloudflare"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 5.19"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;subscription_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription_id&lt;/span&gt; &lt;span class="c1"&gt;# required since azurerm v4&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;api_token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_api_token&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1. The Cloudflare Tunnel resource
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config_src = "cloudflare" means the ingress routing is managed remotely on the Cloudflare side&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_zero_trust_tunnel_cloudflared"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_account_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-${var.environment}-tunnel"&lt;/span&gt;
  &lt;span class="nx"&gt;config_src&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Connector token, needed to start cloudflared.&lt;/span&gt;
&lt;span class="c1"&gt;# In provider v5 the tunnel resource does not expose it - use this data source.&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_zero_trust_tunnel_cloudflared_token"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_account_id&lt;/span&gt;
  &lt;span class="nx"&gt;tunnel_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cloudflare_zero_trust_tunnel_cloudflared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Deploy cloudflared to ACA
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ACA Environment (Internal / VNet-integrated)&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_container_app_environment"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cae-${var.project_name}-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;log_analytics_workspace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log_analytics_workspace_id&lt;/span&gt;

  &lt;span class="c1"&gt;# Subnet delegated to Microsoft.App/environments (min /23 for Consumption-only)&lt;/span&gt;
  &lt;span class="nx"&gt;infrastructure_subnet_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aca_subnet_id&lt;/span&gt;
  &lt;span class="nx"&gt;internal_load_balancer_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# Internal environment with no public IP&lt;/span&gt;

  &lt;span class="c1"&gt;# Consumption-only environments get a "Consumption" workload profile&lt;/span&gt;
  &lt;span class="c1"&gt;# added by Azure automatically - ignore it to avoid a permanent diff&lt;/span&gt;
  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ignore_changes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;workload_profile&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# cloudflared (Tunnel connector)&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_container_app"&lt;/span&gt; &lt;span class="s2"&gt;"cloudflared"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ca-${var.project_name}-${var.environment}-cfd"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;container_app_environment_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_container_app_environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;revision_mode&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Single"&lt;/span&gt;

  &lt;span class="c1"&gt;# SystemAssigned because it never accesses Azure resources&lt;/span&gt;
  &lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SystemAssigned"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tunnel-token"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_zero_trust_tunnel_cloudflared_token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;template&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;min_replicas&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="c1"&gt;# run 2+ connectors (avoid a single point of failure)&lt;/span&gt;
    &lt;span class="nx"&gt;max_replicas&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

    &lt;span class="nx"&gt;container&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloudflared"&lt;/span&gt;
      &lt;span class="nx"&gt;image&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"docker.io/cloudflare/cloudflared:2026.5.0"&lt;/span&gt; &lt;span class="c1"&gt;# pin the version&lt;/span&gt;
      &lt;span class="nx"&gt;cpu&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;
      &lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.5Gi"&lt;/span&gt;
      &lt;span class="nx"&gt;args&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tunnel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"--no-autoupdate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"--token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"$(TUNNEL_TOKEN)"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

      &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"TUNNEL_TOKEN"&lt;/span&gt;
        &lt;span class="nx"&gt;secret_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tunnel-token"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# No ingress block - this is an outbound-only, headless worker&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With &lt;code&gt;internal_load_balancer_enabled = true&lt;/code&gt;, the environment has no public IP and every app only gets an internal FQDN. The only way in from outside is the Tunnel. &lt;code&gt;infrastructure_subnet_id&lt;/code&gt; must be a subnet delegated to Microsoft.App/environments&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;min_replicas = 2&lt;/code&gt; keeps the connector redundant. With a single instance, the whole service goes down during deploys or node failures&lt;/li&gt;
&lt;li&gt;cloudflared is outbound-only, so the &lt;code&gt;ingress&lt;/code&gt; block is not needed. It is a public image, so no &lt;code&gt;registry&lt;/code&gt; block either&lt;/li&gt;
&lt;li&gt;Pin the &lt;code&gt;image&lt;/code&gt; version (do not use &lt;code&gt;latest&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Tunnel ingress rules (the Host header rewrite is the key)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# The internal ingress FQDN of the app we publish. ingress[0].fqdn has&lt;/span&gt;
  &lt;span class="c1"&gt;# no revision suffix, and it includes the ".internal." part that&lt;/span&gt;
  &lt;span class="c1"&gt;# internal environments use.&lt;/span&gt;
  &lt;span class="nx"&gt;frontend_origin&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_container_app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ingress&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;fqdn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_zero_trust_tunnel_cloudflared_config"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_account_id&lt;/span&gt;
  &lt;span class="nx"&gt;tunnel_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cloudflare_zero_trust_tunnel_cloudflared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare"&lt;/span&gt;

  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_hostname&lt;/span&gt; &lt;span class="c1"&gt;# e.g. app.example.com&lt;/span&gt;
        &lt;span class="nx"&gt;service&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://${local.frontend_origin}"&lt;/span&gt;
        &lt;span class="nx"&gt;origin_request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;http_host_header&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontend_origin&lt;/span&gt;
          &lt;span class="nx"&gt;origin_server_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontend_origin&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http_status:404"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# catch-all (required, must be last)&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;This is the most important part of the setup.&lt;/strong&gt;&lt;br&gt;
ACA's ingress only accepts HTTP requests sent to &lt;em&gt;its own FQDN&lt;/em&gt;. 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 &lt;strong&gt;&lt;code&gt;http_host_header&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you forget this, the Host does not match: ACA returns &lt;strong&gt;404&lt;/strong&gt; (or a &lt;strong&gt;TLS validation error&lt;/strong&gt; if the SNI does not match), and cloudflared cannot reach the origin. In practice, this is the biggest pitfall in this whole build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;http_host_header&lt;/code&gt; sets the Host to the app's internal FQDN, and &lt;code&gt;origin_server_name&lt;/code&gt; sets the TLS SNI to match (if the SNI is wrong, TLS validation fails)&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;ingress[0].fqdn&lt;/code&gt; for the origin FQDN. &lt;code&gt;latest_revision_fqdn&lt;/code&gt; has a revision suffix and breaks on every new revision. Building &lt;code&gt;&amp;lt;app&amp;gt;.&amp;lt;default_domain&amp;gt;&lt;/code&gt; by hand drops the &lt;code&gt;.internal.&lt;/code&gt; part on internal environments, so the origin becomes unreachable&lt;/li&gt;
&lt;li&gt;The catch-all rule at the end and &lt;code&gt;source = "cloudflare"&lt;/code&gt; are both required (if you forget the latter, provider v5 shows a confusing "a number is required" error)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;azurerm_container_app.frontend&lt;/code&gt; used here is defined elsewhere (see the sample repository).&lt;/p&gt;
&lt;h3&gt;
  
  
  4. DNS record
&lt;/h3&gt;

&lt;p&gt;Create a CNAME that points to the Tunnel (&lt;code&gt;&amp;lt;tunnel-id&amp;gt;.cfargotunnel.com&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_dns_record"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_zone_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_hostname&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${cloudflare_zero_trust_tunnel_cloudflared.main.id}.cfargotunnel.com"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CNAME"&lt;/span&gt;
  &lt;span class="nx"&gt;proxied&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# required - a DNS-only record does not route&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;    &lt;span class="c1"&gt;# 1 = automatic when proxied&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. WAF / Geo / Rate Limit (set up in the dashboard)
&lt;/h3&gt;

&lt;p&gt;The WAF is &lt;strong&gt;not&lt;/strong&gt; managed by Terraform. This is on purpose, for two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;During an incident, you often need to change rules right away, without waiting for a plan / apply cycle&lt;/li&gt;
&lt;li&gt;WAF resources (&lt;code&gt;cloudflare_ruleset&lt;/code&gt; and others) have broken several times across major provider versions, so managing them in Terraform adds maintenance work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The setup itself is under Security → WAF in the dashboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Managed Rules&lt;/strong&gt;: enable the Cloudflare Managed Ruleset and the OWASP Core Ruleset (Pro plan)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom rules&lt;/strong&gt;: for example, if you only serve one country, a geo rule like &lt;code&gt;Country does not equal Japan → Block&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting rules&lt;/strong&gt;: add per-endpoint limits as needed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trade-offs, and when not to use this
&lt;/h2&gt;

&lt;p&gt;It is not all upside. Things to know before you adopt this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You operate part of the edge yourself&lt;/strong&gt;: with AFD, the whole edge layer was managed. Here, cloudflared is a workload running on &lt;em&gt;your&lt;/em&gt; ACA. Capacity, health monitoring, and image updates are now your job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One more vendor in the path&lt;/strong&gt;: an AFD setup depends only on Azure. This setup puts Azure &lt;em&gt;and&lt;/em&gt; Cloudflare in series. If either one goes down, your service goes down, and during incidents you check two status pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability changes&lt;/strong&gt;: AFD's diagnostic logs and metrics are gone. Plan to use Cloudflare Analytics instead (Logpush needs Enterprise).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;ACA + Cloudflare Tunnel is a simple design that cuts your edge-layer cost without giving up security.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: the WAF that needed AFD Premium ($330/mo) now costs $20/mo on Cloudflare Pro&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt;: ACA stays Internal (fully private) and is published only through cloudflared's outbound connections. Zero inbound ports on Azure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operations&lt;/strong&gt;: no custom domain registration or certificate management on ACA, and everything is managed with Terraform (the WAF stays in the dashboard for quick tuning)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The Tunnel also works on the Free plan, so clone the sample repo and try running cloudflared yourself:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/AranSeshita/aca-cloudflare-tunnel" rel="noopener noreferrer"&gt;https://github.com/AranSeshita/aca-cloudflare-tunnel&lt;/a&gt;&lt;/p&gt;

</description>
      <category>azure</category>
      <category>cloudflare</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
