
Public ingress is easy to enable and surprisingly hard to prove you have removed. This Workshopz lab builds a Container Apps platform where the workload has an HTTP ingress listener, yet its environment cannot be reached directly from the public internet. Private endpoints and private DNS provide the internal path, managed identity provides authorization to a private Key Vault, and Azure Front Door Premium supplies the only controlled public edge.
Level: Advanced Duration: 120–150 minutes Tools: Terraform 1.15.8, AzureRM 4.79.0, Azure CLI 2.88.0, PowerShell 7.6.3 Region: West Europe
Cost warning: This lab creates Azure Front Door Premium, Container Apps private-endpoint infrastructure, Private Link, a B1s VM, a managed disk, a Standard public IP, Key Vault, and Log Analytics. Complete cleanup as soon as the evidence is captured.
What this lab proves
At the end of the live run you will have evidence for six separate controls:
The Container Apps environment has public network access disabled.
A client inside the VNet resolves and reaches the app through its private endpoint.
A managed identity reads a proof secret from a private Key Vault without exposing its value.
A second identity on the same private network receives HTTP 403 because Private Link does not replace RBAC.
Azure Front Door reaches the origin only after its managed Private Link request is approved.
WAF Prevention blocks a deterministic test marker while normal HTTPS traffic succeeds.
That sequence matters. A green Front Door request alone does not prove the origin is private, and a green Key Vault request alone does not prove least-privilege authorization. The lab captures both the expected failures and the successful recovery paths.
Architecture
Internet
├── https://<container-app-fqdn>/proof ── BLOCKED
└── https://<front-door-host>/proof
└── WAF Prevention
└── Front Door managed Private Link
└── Container Apps environment
└── proof app
└── user-assigned identity
└── private Key Vault
Workshop VNet
├── delegated Container Apps subnet
├── private endpoints: Container Apps + Key Vault
├── private DNS: Container Apps + Key Vault
└── no-inbound Ubuntu test VM
The app uses external_enabled=true because Front Door needs an HTTP ingress listener. The environment uses public_network_access="Disabled", so that listener is reachable only through approved private connectivity. This distinction is central to the workshop.
Before you begin
Use an isolated subscription context where you are Owner and User Access Administrator. The preflight refuses existing awz-aca resources and matching soft-deleted vaults; it never registers providers or creates resources.
az login
az account set --subscription "<subscription-id>"
$subscriptionId = (az account show --query id --output tsv).Trim()
pwsh ./Start-PrivateAzureContainerAppsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage Preflight
Expected evidence: zero Azure mutations, both privileged roles effective, every provider registered, West Europe supported, B1s available, no collisions, Terraform formatted and valid, and the protected baseline captured privately.
Stage 2 — Build the private foundation
pwsh ./Start-PrivateAzureContainerAppsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage Foundation
The first Terraform stage creates the VNet and three subnets, Log Analytics, a private RBAC-enabled Key Vault, its private endpoint and DNS zone, three user-assigned identities, and a short-lived Ubuntu VM. An explicit NSG rule denies every inbound packet to the VM; tests run through Azure VM Run Command.
The identities deliberately have different permissions:
Application identity: Key Vault Secrets User.
Seeder identity: Key Vault Secrets Officer so the isolated lab can create its proof value from inside the VNet.
Unauthorized identity: no Key Vault assignment.
The seeding command creates a random non-sensitive value inside the VM, stores it through the private endpoint, and retains only its SHA-256 digest under ignored work/. Tokens and the value never appear in public output. The unauthorized identity then requests the same secret and must receive HTTP 403.
This is the first Zero Trust lesson: the private endpoint proves network reachability; Key Vault RBAC still decides whether the caller is authorized.
Stage 3 — Deploy the private application
pwsh ./Start-PrivateAzureContainerAppsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage PrivateApp
The second stage creates a workload-profile Container Apps environment using its Consumption profile. The environment is integrated with the delegated subnet, public network access is disabled, and a customer-managed private endpoint is registered in privatelink.westeurope.azurecontainerapps.io.
The proof app is intentionally small. Its source ships with the workshop, and Terraform injects it into a digest-pinned Microsoft Azure CLI image. /healthz returns a non-sensitive health response. /proof requests a Key Vault token from the Container Apps managed-identity endpoint, reads the proof secret, and returns booleans plus a digest. It never returns the token or secret value.
The test now exercises two paths:
Workstation → public app FQDN → blocked
Test VM → private DNS → private endpoint → /proof → HTTP 200
The private response must also prove managedIdentity=true, secretLoaded=true, and a digest match. The unauthorized identity is tested again and must remain HTTP 403.
Stage 4 — Create Front Door but leave Private Link pending
pwsh ./Start-PrivateAzureContainerAppsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage FrontDoor
The third Terraform stage adds Azure Front Door Premium, an HTTPS-only route, a /healthz origin probe, diagnostics, a Private Link origin targeting managedEnvironments, and WAF.
WAF is enabled in Prevention with Microsoft Default Rule Set 2.1. A separate custom rule blocks the harmless query marker awz-block-me; this produces a deterministic teaching result without sending an exploit payload.
Private Link approval is deliberately not automated by Terraform. While the request is Pending, Front Door must not return a successful proof response. Capture this state before continuing: it shows that merely declaring an origin does not authorize Front Door to enter the private boundary.
Stage 5 — Approve only the workshop connection
pwsh ./Start-PrivateAzureContainerAppsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage Approve
The approval script lists connections only on the dedicated workshop environment. It requires Pending state and verifies the request is Front Door-managed before approving it. If Azure creates multiple matching requests, each is handled; an unrelated pending request causes the safety gate to stop.
Front Door health can take several minutes to converge. The stage retries a normal /proof request until it receives HTTP 200 and the digest matches the private baseline. The Container Apps public endpoint remains disabled throughout.
Stage 6 — Prove WAF and observability
pwsh ./Start-PrivateAzureContainerAppsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage Validate `
-LogWaitSeconds 2700
Two edge requests provide the final behavioral proof:
GET /healthz → HTTP 200
GET /proof?lab=awz-block-me → HTTP 403
The validation then waits for Container Apps console/system logs, Key Vault audit logs, Front Door access and health logs, and Front Door WAF logs. Public evidence contains counts only; resource IDs, request addresses, client addresses, live hostnames, and workspace IDs remain private.
Finally, terraform plan -detailed-exitcode must return 0. Private Link approval is an intentional service-side state transition and must not leave the Terraform-managed configuration drifting.
Complete live-run command
From a clean subscription you can run every build stage in order:
pwsh ./Start-PrivateAzureContainerAppsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage All `
-LogWaitSeconds 2700
Use the individual stages for teaching and screenshots; use All for an unattended validation run.
Verified cleanup
pwsh ./Remove-PrivateAzureContainerAppsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-ConfirmCleanup `
-Confirm:$false
Cleanup removes Front Door and WAF first, then waits for their managed Private Link connection to disappear before destroying the app and foundation. Azure can retain that child connection after the profile is gone, so the script fails closed unless there is exactly one non-customer connection whose child resource ID, environment, and request description match this workshop; only then can it remove the verified orphan. It preserves the customer endpoint until normal Terraform teardown, purges the known workshop Key Vault, confirms zero awz-aca artifacts, and compares resources, roles, private DNS zones, private endpoints, and subscription diagnostics with the preflight baseline.
Troubleshooting guide
| Failure | What it usually means | Safe action | |---|---|---| | Seeder HTTP 403 | RBAC propagation is incomplete | Rerun Foundation after several minutes. | | App revision unhealthy | Required MCR, Azure platform DNS, or outbound connectivity is blocked | Check delegated-subnet egress and required Azure DNS. | | VM resolves a public address | Private DNS zone group or VNet link is wrong | Inspect the Container Apps private endpoint DNS configuration. | | /proof returns 502 | Managed identity, Key Vault RBAC, or Key Vault DNS is not ready | Check Key Vault audit logs and retry PrivateApp. | | No pending Front Door request | Private Link provisioning has not completed | Wait and rerun FrontDoor; do not approve broadly. | | Edge remains non-200 after approval | Origin health or Front Door propagation is pending | Verify approval, origin host header, and /healthz. | | WAF marker returns 200 | Security-policy propagation is pending | Wait and rerun Validate without weakening WAF. | | A log table does not exist | Its first event has not been ingested | Generate traffic and retry within the bounded wait. | | Cleanup pauses after Front Door is gone | Azure retained the managed Private Link connection | Let the 20-minute bounded wait complete. The script can remove only one exact-match workshop orphan after verifying that zero Front Door profiles remain. |
Production guidance
For production, use a remote Terraform backend, workload identity federation, separate platform and workload ownership, PIM, deliberate egress through NAT Gateway or Azure Firewall, central private DNS, and dedicated workload profiles where DNS control is required. Tune WAF in Detection against representative traffic before Prevention, keep exclusions narrow, add custom domains and certificate monitoring, and route diagnostics into a central SOC workflow.
The workshop VM is a test instrument, not a production access pattern. Production administration should use private management paths. The VM's public IP exists only to make short-lived outbound agent connectivity deterministic, and its NSG denies all inbound traffic.
Download the complete workshop ZIP
The downloadable package includes Terraform, PowerShell stages, the proof service, the provider lockfile, tests, sanitized evidence, all ten screenshots, the article source, and cleanup documentation.
Microsoft references
Sanitized live evidence
The following ten images were captured during the cleaned-up live run. Subscription, tenant, object, workspace, DNS, IP, email, token, digest, SSH, and local-path values are masked.
CLI preflight and safety gate

Read-only preflight confirms permissions, provider readiness, quota, and a collision-free starting point.
Container Apps public network access disabled

Azure Portal shows the environment-level public network access control disabled.
Private endpoints and private DNS

Sanitized Portal evidence shows the Container Apps and Key Vault private endpoints and both private DNS zones.
Public denial and private HTTP 200

The workstation public path is blocked while the VM resolves private DNS and receives HTTP 200.
Managed identities and Key Vault RBAC

The application and seeder identities inherit only their scoped Key Vault data-plane roles.
Authorization remains mandatory

The unauthorized identity receives HTTP 403 while the application identity returns a digest match.
Front Door Private Link pending

The Front Door-managed connection remains Pending while the customer private endpoint is Approved.
Exact-match approval and edge recovery

Only the verified workshop request is approved, after which Front Door returns HTTP 200.
WAF, logs, and zero drift

A safe WAF marker returns HTTP 403, all required logs are present, and Terraform reports exit code 0.
Verified cleanup and protected baseline

Cleanup leaves zero workshop artifacts and every protected baseline category unchanged.
Comments