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.
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.
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:
hello—nginx-unprivilegedserving a static "It works!" page on port8080.cloudflared— the Cloudflare Tunnel client, dialing outbound to Cloudflare's edge and reverse-proxying the public hostname tohttp://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-ups —
make aca-downkeeps the tunnel, so the DNS record never churns.
Installed on your machine
- Cloudflared
- AZ CLI
- jq
- gettext
Active Subscriptions:
- Azure subscription
- 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"
doneThen 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 CLIWhat 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 withAuthorizationFailed. See the guide.
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-upReal 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=devEach 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 |
make aca-upreuses (or creates) a Cloudflare tunnel namedcloudflared-aca-demo, routesaca-demo.<zone>as a CNAME to<tunnel-id>.cfargotunnel.com, and ensures the Azure resource group.- It renders
aca/cloudflared-config.yamlfrom the template and deploysaca/main.bicep, passing the config, the tunnelcredentials.jsonandindex.htmlas secure parameters. - The
cloudflaredcontainer opens an outbound, encrypted QUIC connection to Cloudflare's edge — no ports are exposed on the app. - Public traffic to your hostname is routed down the tunnel;
cloudflaredmatches it against its ingress rules and reverse-proxies tohttp://localhost:8080→ thenginxcontainer.
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 ».
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.
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.
| 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 »