Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Cloudflared on Azure Container Apps

Expose an Azure Container Apps workload to the public internet through a Cloudflare Tunnel — no inbound ports, no public IP, no load balancer. Provisioned end-to-end with Bicep.

Cloudflare Azure Container Apps Bicep NGINX Automation


Architecture diagram

Why Cloudflare Tunnel?

Exposing a service the traditional way means a public IP, open firewall ports, and TLS certificates to manage — every one of them an attack surface to defend. Cloudflare Tunnel (cloudflared) flips this around: your workload opens a single outbound connection to Cloudflare's edge, so anything behind NAT, a firewall, or a private network can be reached from the internet with zero inbound ports and no public IP. Cloudflare fronts the traffic with automatic TLS, DDoS protection, and optional identity-aware access (SSO/WAF), all without changing the app.

What this demo shows

A single Azure Container App runs two containers in one replica — deployed with one az deployment group create of a single Bicep file, no container image built or pushed:

  • hellonginx-unprivileged serving a static "It works!" page on port 8080.
  • cloudflared — the Cloudflare Tunnel client, dialing outbound to Cloudflare's edge and reverse-proxying the public hostname to http://localhost:8080.

The app has no ingress block, so nothing is reachable from outside except through the tunnel.

  • 🔒 No attack surface — nothing listens for inbound connections; the tunnel dials out.
  • 🧩 Declarative routing — the reverse-proxy rules live in a versioned config, not the dashboard.
  • 🧱 Pure IaC — Log Analytics, the environment and the app are all in aca/main.bicep.
  • 📦 No image build — official, pinned, non-root images; config/creds/HTML injected as secrets.
  • ♻️ Stable re-upsmake aca-down keeps the tunnel, so the DNS record never churns.

Table of contents


Prerequisites

Before you start you need this:

Installed on your machine

  1. Cloudflared
  2. AZ CLI
  3. jq
  4. gettext

Active Subscriptions:

  1. Azure subscription
  2. Cloudflare account Free Plan is sufficent, but you need a Credit Card to activate it.

On macOS everything installs with Homebrew. First, check what you already have:

for t in az cloudflared jq envsubst; do
  printf '%-12s ' "$t"; command -v "$t" >/dev/null && echo "✓ installed" || echo "✗ missing"
done

Then install whatever is missing:

# No Homebrew yet? Install it from https://brew.sh first.
brew install azure-cli cloudflared jq gettext   # envsubst ships inside gettext
az extension add -n containerapp                 # ACA commands for the az CLI
What each tool is for
Tool Why it's needed Install
Azure CLI + containerapp ext deploy the Bicep / manage the app brew install azure-cli then az extension add -n containerapp
cloudflared the tunnel client + CLI to create tunnels/DNS brew install cloudflared
jq parses cloudflared JSON output (tunnel id lookup) brew install jq
envsubst renders the config template (from gettext) brew install gettext

You also need an Azure subscription (az login) and a Cloudflare account + a domain on Cloudflare's nameservers, so Cloudflare is the authoritative DNS and the tunnel can create the aca-demo.* record. Free tier is fine (dash.cloudflare.com).

Using Azure AD PIM? Activate your eligible Owner/Contributor role before make aca-up, or resource-group creation fails with AuthorizationFailed. See the guide.


Quick start

az login -t <your-tenant-id>
cloudflared tunnel login                  # one-time browser auth
export DEMO_HOSTNAME=aca-demo.example.com     # a subdomain of a zone in your Cloudflare account

make aca-up                               # tunnel + DNS + resource group + Bicep deploy
# → open https://$DEMO_HOSTNAME  (first hit can take ~20-30s while the tunnel registers)

make aca-down                             # delete the resource group (keeps tunnel + DNS)

Override any default with an env var (defaults: SUBSCRIPTION="Microsoft Azure Sponsorship", LOCATION=northeurope, RESOURCE_GROUP=cloudflared-aca-demo, TUNNEL_NAME=cloudflared-aca-demo):

LOCATION=uksouth RESOURCE_GROUP=my-rg SUBSCRIPTION="My Sub" make aca-up

Multiple environments (dev / test / acc / prod)

Real projects run several layers — development, test, acceptance, production. They all live under one Cloudflare account and one DNS zone; each environment differs only by its hostname, its tunnel, and its Azure resource group. That separation is captured in the envs/ directory — one small file per environment:

make aca-up   ENV=dev      # → aca-dev.<zone>   tunnel+rg: cloudflared-aca-dev
make aca-up   ENV=test     # → aca-test.<zone>
make aca-up   ENV=acc      # → aca-acc.<zone>
make aca-up   ENV=prod     # → aca-prod.<zone>  (typically its own Azure subscription)

make aca-logs ENV=dev      # logs/status/teardown all take the same ENV=…
make aca-down ENV=dev

Each envs/<name>.env sets just the few things that change between environments (envs/dev.env); everything else is derived from ENV + ZONE, so adding an environment is copying one file and changing ENV. No ENV at all keeps the original single-environment cloudflared-aca-demo defaults, so nothing below changes if you ignore this.

Because every environment gets its own tunnel and credentials, the tunnel credential stays the security boundary — dev can never serve prod traffic. For real separation, point prod at a dedicated Azure subscription (edit SUBSCRIPTION in envs/prod.env) and use a prod-scoped Cloudflare API token in CI. Lower environments are a natural fit for a Cloudflare Access (Zero-Trust SSO) policy on the hostname, so they aren't openly reachable.

Variable Differs per env? Example (dev)
ENV / ZONE the only inputs dev / example.com
DEMO_HOSTNAME ✅ derived aca-dev.example.com
TUNNEL_NAME / RESOURCE_GROUP / NAME_PREFIX ✅ derived cloudflared-aca-dev
SUBSCRIPTION / LOCATION usually shared (prod often separate) northeurope

How it works

  1. make aca-up reuses (or creates) a Cloudflare tunnel named cloudflared-aca-demo, routes aca-demo.<zone> as a CNAME to <tunnel-id>.cfargotunnel.com, and ensures the Azure resource group.
  2. It renders aca/cloudflared-config.yaml from the template and deploys aca/main.bicep, passing the config, the tunnel credentials.json and index.html as secure parameters.
  3. The cloudflared container opens an outbound, encrypted QUIC connection to Cloudflare's edge — no ports are exposed on the app.
  4. Public traffic to your hostname is routed down the tunnel; cloudflared matches it against its ingress rules and reverse-proxies to http://localhost:8080 → the nginx container.

The routing rules live in aca/cloudflared-config.yaml.template — add another hostname → service pair to expose a second app. The full breakdown (what the Bicep creates, how secrets are mounted, every command) is in the step-by-step guide ».


How Azure Container Apps fits in

Azure Container Apps (ACA) is a serverless container platform: you hand it container images and it runs them as replicas inside a managed environment (a Kubernetes-based runtime you never touch). A Container App can declare an ingress block — that's the only way ACA exposes a public FQDN, fronted by a managed envoy proxy and TLS. This demo deliberately omits it: with no ingress, the app has no public endpoint, no certificate and no load balancer — there is simply nothing inbound to reach or attack.

Egress, by contrast, is always open: replicas can freely dial out to the internet. That's exactly what cloudflared needs — it makes the lone outbound connection to Cloudflare's edge, and all public traffic arrives back down that same connection rather than through any Azure ingress. Azure never has to accept an inbound request.

The two containers run in one replica, so they share that replica's network namespace — exactly like containers in a Kubernetes Pod. cloudflared therefore reaches nginx over plain localhost:8080, with no internal ingress, no Service and no service discovery: the tunnel client and the app it proxies are lifecycle-coupled in the same unit.


Cleanup

make aca-down is intentionally not a full wipe: it deletes only the Azure resource group and keeps the Cloudflare tunnel + its DNS record. The next make aca-up reuses the same tunnel id, so the CNAME never changes and there's no DNS-propagation delay (no flaky re-ups).

Goal Command
Delete the resource group, keep tunnel + DNS (fast re-up) make aca-down
Delete the resource group and the Cloudflare tunnel make aca-destroy

cloudflared can create but cannot delete DNS records, so after make aca-destroy remove the aca-demo CNAME by hand in the Cloudflare dashboard → your zone → DNS → Records → the CNAME named aca-demo (content ends in …cfargotunnel.com) → Edit → Delete.


Repo layout

Path Purpose
aca/main.bicep Log Analytics + managed environment + the two-container app.
aca/cloudflared-config.yaml.template Tunnel ingress rules (rendered, then injected as a secret).
aca/index.html The "It works!" page served by nginx.
envs/*.env One file per environment (dev/test/acc/prod) — selected with ENV=….
scripts/resolve-env.sh Loads envs/<ENV>.env, falls back to single-env demo defaults.
scripts/aca-setup.sh / aca-teardown.sh What make aca-up / make aca-down run.
Makefile aca-up, aca-down, aca-destroy, aca-logs, aca-status.
docs/ Architecture diagram and the full step-by-step guide.

📖 Want to run it by hand and understand each step? See the detailed guide »

About

Expose an Azure Container Apps workload to the internet through a Cloudflare Tunnel — no inbound ports, provisioned with Bicep. Includes a kind/k8s variant.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages