Home› Blog› Week 9 — Nothing Runs Until Someone Asks
Azure Weekly Lab Azure Terraform

Week 9 — Nothing Runs Until Someone Asks

Eight weeks of building the place things run, and nothing was actually running. This week deploys a real workload that serves traffic, costs nothing while idle, and sends one unlucky user the bill for waking it up.

Verified against current vendor documentation on 15 October 2026. Pricing, limits and API behaviour were checked against the official docs on that date. Cloud services change fast — if you are reading this much later, treat the specifics as a starting point and re-check the linked sources.
Container Apps Scale to Zero Managed Identity Diagnostic Settings Terraform
Azure Platform Engineering Lab · Week 9 of 52

Why — The Problem This Solves

An internal tool gets used nine times a day, all of them between 9am and 5pm, all of them by the same four people. On a virtual machine it costs the same at 3am as it does at noon. Most internal software looks like this, and most of it is billed as though it were busy.

The alternative is to run nothing when nobody is asking. That is free, and it is not free — somebody has to wait while a container starts. This week deploys that trade and measures both ends of it.

What You Need to Know — Skills & Tools

Azure Container Apps runs containers without you running the servers underneath them. You hand Azure an image and a few numbers; it decides how many copies to run and when to stop them. Four ideas carry the rest of this post:

  • A replica is one running copy of your container. You are billed per second for the CPU and memory each copy is allocated — so the replica count is the bill.
  • The environment is the box the apps live in — shared network, and one log destination for everything inside it. You create one per environment-per-region, not one per app, and it costs nothing by itself.
  • A scale rule is the signal Azure watches to decide it needs another replica: CPU, a queue, or here, how many HTTP requests are in flight at once. You give it a floor and a ceiling. A floor of zero means that when nobody is asking, nothing runs and nothing is charged.
  • A diagnostic setting is a forwarding rule — "send this resource's logs to that workspace". It authenticates as Azure Resource Manager, with no key. That matters here, because the other way to ship these logs needs a shared key this workspace no longer hands out.

Architecture — How It Fits Together

Diagram: a request from the internet reaches HTTPS-only ingress on a container app in sub-lab-dev. The app runs between zero and three replicas, scaling on concurrent requests, inside a Consumption-plan Container Apps environment. After about ten minutes idle it drops to zero replicas and bills nothing; the first request after that takes 16.9 seconds against a warm 0.13. The environment sends two log categories through a diagnostic setting across a subscription boundary into week 8's Log Analytics workspace in sub-management, which this week reads but does not own.

How We Built It — In Deployment Order

The goal: a workload that serves real traffic from the internet, runs nothing when idle, and reports to week 8's workspace across a subscription boundary. You need an Azure subscription, an HCP Terraform account, and a Log Analytics workspace to point at.

Five resources, and the shape of the week is in the last one:

ResourceCountIts job
Resource group1what dies together at teardown
User-assigned managed identity1the seam a private registry would authenticate through
Container Apps environment1the boundary — network and log destination
Container app1the workload — a Microsoft-published sample image, 0 to 3 replicas, HTTPS only
Diagnostic setting1routes logs into week 8's workspace, in another subscription

1. The HCP workspace, created first and deliberately

POST /api/v2/organizations/Katta/workspaces
{ "name": "azure-week-09-dev", "execution-mode": "local" }

created: azure-week-09-dev | execution-mode: local

A new HCP workspace defaults to remote execution, which runs the plan on HCP's servers where there is no Azure CLI. Setting it at birth is one line; discovering it afterwards costs a confusing failure on the wrong machine.

HCP Terraform workspace azure-week-09-dev in the Azure Platform Lab project showing 6 resources, 8 outputs, Terraform v1.16.6, execution mode Local, and the resource list including the container app environment, container app, user assigned identity, diagnostic setting and the Log Analytics workspace data source

Six resources there, not five — the sixth is the data source reading week 8's workspace. Terraform tracks what it reads as well as what it owns.

2. The Terraform

The Terraform file tree for week 9: terraform/ with a 41-line versions.tf carrying two provider blocks, a 70-line variables.tf, a 154-line main.tf holding the resource group, identity, environment and app, a 78-line logging.tf reading week 8's workspace across a subscription, and a 36-line outputs.tf; plus deploy.sh, a 282-line validate.sh and cleanup.sh, each annotated with its job and line count
Every file, and what it is for. main.tf answers "what runs, and how little of it runs when nobody is asking". logging.tf is separate because its answer lives in another subscription.

The replica floor is the week in one argument:

template {
  min_replicas = 0      # the whole point
  max_replicas = 3      # a ceiling, not a target

  cooldown_period_in_seconds = 300

  http_scale_rule {
    name                = "http-concurrency"
    concurrent_requests = 10
  }
}

3. Deploy

$ ./scripts/deploy.sh
   registering Microsoft.App (currently NotRegistered)
   attempt 1: Registered
...
azurerm_container_app_environment.workload: Creation complete after 14m55s
azurerm_container_app.hello: Creation complete after 18s

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

The environment took 14 minutes 55 seconds. The app on top of it took 18. Provider registration lives in the deploy script rather than in Terraform, because registration is subscription-wide shared state and one week's destroy must not unregister what another week still needs.

4. It answers — and only over HTTPS

Terminal output: curl -i against the app over HTTPS returns HTTP/1.1 200 OK with content-length 4331 and the Azure Container Apps default page; the same request on port 80 returns HTTP/1.1 301 Moved Permanently with a location header pointing at the HTTPS URL
200 over HTTPS, 301 on port 80. With allow_insecure_connections left at its default the ingress also answers port 80 with a 200 — and a client that gets a 200 in the clear has no reason to ever upgrade.

5. And then it stops

Azure portal Revisions and replicas blade for ca-hello-dev-scus-001 showing one active revision with Running status Scaled to 0, traffic at 100 percent, and the Replicas column reading 0
Zero replicas. The app still exists, still has a URL, still has a revision. There is simply nothing running behind it, and nothing being billed.

6. The logs land in last week's workspace

Azure portal Logs blade on workspace log-platform-prod-scus-001 showing two ContainerAppConsoleLogs rows for ca-hello-dev-scus-001, each with the revision name, the mcr.microsoft.com/k8se/quickstart image, stream stderr and a Listening on :80 message — one at 01:52 and one at 02:08 after the restart
Week 8's workspace, in a different subscription. Two timestamps: the original start, and the restart after the app had scaled to zero. The cold start is visible in the logs as a container booting again.

What production does differently

  • A private registry. This pulls a Microsoft sample anonymously. Production builds its own image into ACR — which adds a registry block pointing at that managed identity, not a password.
  • Builds in CI, on week 7's federated credential, not on a laptop.
  • Something in front of the ingress. External ingress straight to the app is right for a demonstration and rare in production.

How It's Tested

validate.sh — 6 checks, 8 assertions, 8 passed / 0 failed:

WhatMeasured
HTTPS GET /200
Plain HTTP on port 80301 — never served in the clear
Warm request, average of 30.13 s
Idle before replicas reached 0602 s
First request after idle16.94 s, still a 200
The penalty, stated plainly16.81 s on one request
Log rows in week 8's workspace3
Rows in shared AzureDiagnostics0

That last pair is the test that the logs went to dedicated tables rather than the shared one — which is what makes week 8's per-table cost design apply to them at all.

Challenges — What Actually Went Wrong

The default logging path was impossible. The default destination wires the environment to a workspace using that workspace's shared key. Week 8 disabled local authentication, so there is no key to hand over. The fix is azure-monitor plus a diagnostic setting, which authenticates as ARM. A security decision made one week became an architecture constraint the next.

A configuration that never converged. The obvious way to get dedicated tables is log_analytics_destination_type = "Dedicated". ARM does not persist that field here, so it returns null and every plan wants to set it again, forever. Removed — these logs are resource-specific anyway, proven by AzureDiagnostics holding zero rows. A plan that is never empty trains you to ignore the diff that matters.

The cooldown is not the whole wait. cooldown_period_in_seconds was 300; replicas reached zero at 602 seconds. The setting governs when the scaler starts counting, not when the last replica goes.

Security — Controls at Every Layer

  • No password exists. The app carries a user-assigned managed identity, and a managed identity cannot hold a secret — that is a property of the object, not a discipline anyone keeps.
  • HTTPS only, proven by a 301 rather than assumed from a setting.
  • Named log categories, not allLogs — which is defined by Azure and silently grows when Azure adds a category.
  • The image comes from MCR, so there is no registry credential to hold in the first place.

Cost

The free grant is 180,000 vCPU-seconds, 360,000 GiB-seconds and 2 million requests per subscription per month. This week used roughly 600 vCPU-seconds. A Consumption-only environment carries no fixed hourly fee — though private endpoints and planned maintenance do attract a management charge on either plan, and this week uses neither.

Measured: $0.00. Read from Cost Management after teardown, not from a rate card — sub-lab-dev shows no charges at all for the days this ran. The free grant absorbed the whole week.

Idle replicas bill eight times cheaper than active — but only if the floor is above zero. A floor of zero has no idle state to bill. Keeping one replica warm is the other defensible answer, and the one to pick if 17 seconds is unacceptable.

Cleanup

$ ./scripts/cleanup.sh
   resource groups named rg-containerapps-lab-dev-scus-001 : 0
   Container Apps environments                             : 0
   container apps                                          : 0
   wk09 managed identities                                 : 0
   week 08's workspace still there                         : log-platform-prod-scus-001

That last line is deliberate. Week 8's workspace is read through a data source, never owned, so this week's teardown cannot destroy it — and the teardown checks that it didn't.

References

Key Takeaways

  • Scale-to-zero is not free, it is cheap and slow. 16.81 seconds, paid by one user, invisible in any average. Put that number in front of whoever decides whether it is acceptable.
  • A ceiling matters as much as a floor. Zero protects you from idle cost; max_replicas protects you from a runaway one.
  • Last week's security decision is this week's constraint — which is what good security does, and better found in week 9 than in an incident.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent