Azure Compute

Deploy Your First Container App: HTTP Microservice with Scale-to-Zero and Managed Ingress

You have a container. It listens on a port, it serves HTTP, and it works on your laptop. Now you need it running in Azure — reachable on HTTPS, with a real certificate, scaling up when traffic arrives and, crucially, costing nothing when nobody is calling it. You do not want to manage a Kubernetes cluster to get that, you do not want to babysit VMs, and you do not want a fixed monthly bill for an idle API. Azure Container Apps is the service built for exactly this: a serverless container platform that runs your image on a managed Kubernetes-plus-Envoy-plus-KEDA fabric you never see, gives you HTTPS ingress for free, and scales your app between zero and many replicas based on incoming requests.

This article is a complete, copy-pasteable implementation guide. We deploy one real HTTP microservice from nothing to a working, publicly reachable, scale-to-zero endpoint — three ways: click-by-click in the Azure portal, command-by-command with the az containerapp CLI, and declaratively as a Bicep template you can commit and redeploy. Along the way you will set the target ingress port, write the KEDA HTTP scale rule that takes the app to zero when idle and back up under load, add liveness and readiness probes, push a second revision and split traffic, and read the logs that tell you what actually happened. Every step states the command, the portal path, and the output you should see, so you can tell success from a silent failure.

By the end you will have a running endpoint, a Bicep file under version control, and — more valuable — a clear mental model of the four moving parts (environment, app, revision, replica), the ingress contract, and the scale rule. That model is what takes you from “it deployed” to “I know why it scaled, why it cost what it cost, and where to look when it returns a 503.” Scope stays tight: one microservice, the Consumption plan, real numbers, and a teardown so you owe Azure nothing.

What problem this solves

The gap this fills is the awkward middle between “too little” and “too much” platform. Azure Functions is great for event glue but fights you when you want an arbitrary container, a long-lived process, or a framework expecting a normal HTTP server. Azure Kubernetes Service (AKS) gives total control and total responsibility — a control-plane bill, node pools, upgrades, the ingress controller, cert-manager, and the 3 a.m. page when a node goes NotReady. App Service runs containers well but is a fixed-instance PaaS: even its cheapest always-on tiers bill while idle. What many teams actually want — run my container, give it HTTPS, scale it from zero, send me a per-second bill — fell between those stools until Container Apps.

What breaks without it is mostly economic and operational. Teams over-provision: a B1 App Service plan or a two-node AKS cluster runs 24/7 to serve an internal API that gets forty requests a day, and the bill is identical whether the traffic is forty or forty thousand. Or they reach for AKS for a three-service app and sink the first two sprints into cluster plumbing — ingress, TLS, observability, RBAC — before shipping a feature. The cost of the wrong floor is paid every month; the cost of the wrong ceiling is paid in engineering time on infrastructure you did not need.

Who hits this: anyone shipping an HTTP microservice, an internal API, a background worker, or a small set of services that talk to each other, and who wants serverless economics with container flexibility. It bites hardest on cost-sensitive teams (scale-to-zero is the whole point), small teams without a platform engineer (no cluster to operate), and anyone migrating a Docker Compose app to the cloud. Container Apps is the answer to “I have a container and I want it on the internet, cheaply, without operating Kubernetes.”

To frame the field before the deep dive, here is what Container Apps gives you, what it deliberately does not, and where each gap sends you:

You want… Container Apps gives you If you need more… Go to
Run any container over HTTP Managed Envoy ingress, free TLS Raw kubectl, CRDs, operators AKS
Pay nothing when idle Scale-to-zero (min replicas 0) Always-warm, no cold start App Service / Dedicated profile
Event-driven autoscale KEDA built in (HTTP, queue, custom) Bespoke scheduling, GPUs at scale AKS
Service-to-service calls Internal ingress + optional Dapr Full service mesh (mTLS policy, etc.) AKS + Istio/Linkerd
Smallest step from Docker One image → one app → one URL Full control of the node OS VMs / AKS

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with containers at a basic level: you know what a Docker image is, that a container listens on a port, and roughly what a Dockerfile does. You do not need Kubernetes knowledge — that is the point of Container Apps. You need an Azure subscription (the Azure free account is enough for this lab), the Azure CLI installed locally or access to Cloud Shell, and permission to create resources in a resource group. For the Bicep path, the Deploy Your First Bicep File From Scratch walk-through covers the tooling if it is new to you.

This sits at the entry point of the Compute track for serverless containers. The decision of whether Container Apps is the right runtime — versus App Service or AKS — is made upstream in Azure App Service vs Container Apps vs AKS and, for the container-specific cut, AKS vs Container Apps vs Container Instances. Assume you have already decided Container Apps fits; this article is the build. Downstream of it, event-driven scaling beyond HTTP is covered in Event-Driven Autoscaling on AKS with KEDA (the same KEDA you use here, in its AKS form), and observability in Azure Monitor and Application Insights.

A quick map of where each moving part lives, so you know which layer to reach for when something misbehaves:

Layer What lives here You configure Failure it can cause
Environment Shared boundary, Log Analytics, VNet, profiles Region, logging, networking All apps unreachable; log gaps
Container app Ingress, scale rules, secrets, identity Port, min/max replicas, image 503, wrong port, pull failure
Revision Immutable snapshot of a config Traffic split, activation Bad rollout, traffic to wrong rev
Replica A running instance (1+ containers) CPU/memory, probes OOM, probe failures, restarts
Registry (ACR) Your image Pull credentials / identity UNAUTHORIZED, image not found

Core concepts

Five ideas make every later step obvious. Learn these and the CLI flags stop being magic.

The object model is four nested things. A Container Apps environment is the outer boundary — a secure perimeter apps share, owning the network (it can run in your VNet), the Log Analytics workspace, and the compute profiles. Inside it run container apps, each your service with its own ingress, scaling and secrets. Each app has revisions — immutable snapshots of its config and image; changing the image or a scale setting creates a new revision rather than mutating the old. Each revision runs replicas — the running instances. Scaling adds/removes replicas; deploying adds revisions; networking is set on the environment.

Ingress is managed Envoy, and the port contract is explicit. Enabling ingress runs an Envoy proxy that terminates TLS, gives a stable HTTPS FQDN, and load-balances across replicas. The one thing that matters most: the target port — the port inside your container your process listens on. Envoy takes public traffic on 443 and forwards to it. Get it wrong — target port 80 when your app listens on 8080 — and every request fails health-probing into a 503, even though the container is healthy. Ingress is external (public) or internal (service-to-service only); transport defaults to auto but can be http, http2, or tcp.

Scaling is KEDA, and zero is a first-class replica count. Autoscaling is powered by KEDA (Kubernetes Event-Driven Autoscaling). You define scale rules: an HTTP rule on concurrent requests, a TCP rule on connections, custom rules on queue length, event-hub backlog, CPU/memory, or any KEDA scaler — bounded by minReplicas and maxReplicas. Set minReplicas: 0 and when no requests arrive for a cooldown window, KEDA removes the last replica and the app scales to zero — no compute charge. The next request triggers a cold start (schedule a replica, pull the image, boot the app). Set minReplicas: 1+ to trade that cold start for an always-on idle cost.

Cost is per-second consumption, not per-instance. You are billed for what replicas consume — vCPU-seconds, GiB-seconds, and requests — plus a monthly free grant (180,000 vCPU-seconds, 360,000 GiB-seconds, 2,000,000 requests). Idle replicas bill at a reduced idle rate; a replica scaled to zero bills nothing. That is why scale-to-zero changes the economics: a 0.5 vCPU / 1 GiB API serving forty requests a day sits inside the free grant at effectively zero, where an always-on plan bills 24×30 hours regardless.

Revisions make deployments safe and reversible. A revision is immutable, so a deploy never edits a running thing — it creates a new revision and shifts traffic. Single mode keeps the latest at 100%; multiple mode runs several at once and you split traffic by weight (90/10 for a canary), rolling back by moving the weight. Blue-green and canary with no extra infrastructure — detailed below.

The vocabulary in one table

Before the build, pin every term down. The glossary at the end repeats these for lookup; this is the mental model side by side:

Term One-line definition Where it lives Why it matters
Environment Shared secure boundary for apps Resource group Network, logging, isolation unit
Container app Your service (ingress + scale + image) In an environment The thing you deploy and scale
Revision Immutable config+image snapshot In an app Safe deploys, traffic split, rollback
Replica A running instance of a revision On the fabric What scales; what you pay for
Ingress Managed Envoy front door (TLS, LB) App-level Gives the public/internal FQDN
Target port Port your container listens on Ingress config Wrong value → 503 forever
Scale rule KEDA condition that adds replicas App-level HTTP/queue/custom triggers
minReplicas Floor on replica count Scale config 0 = scale-to-zero (free idle)
maxReplicas Ceiling on replica count Scale config Caps cost and blast radius
Cold start First-request latency after zero Replica lifecycle The cost of scaling to zero
Consumption Serverless per-second compute profile Environment profile Default; up to 4 vCPU / 8 GiB
Dapr Optional microservice sidecar APIs Per app (opt-in) Service discovery, pub/sub, state

Core concept: the ingress and port contract

Ingress is the part first-time deployers get wrong most often, so it earns its own section. When you enable ingress on an app, three settings define the contract. The target port is the port inside the container: your process binds it, Envoy forwards to it, and you state it explicitly with --target-port (the CLI does not read your Dockerfile EXPOSE line). Exposure is external (a public *.<region>.azurecontainerapps.io FQDN) or internal (an *.internal.<region>.azurecontainerapps.io FQDN reachable only from inside the same environment). Transport tells Envoy how to speak to your app.

Here is every ingress setting, its values, default, and the gotcha that bites if you pick wrong:

Setting Values Default When to change Gotcha
Ingress enabled on / off off (CLI) Any HTTP/TCP service Off → no FQDN; app is reachable only by other means
Exposure external / internal external (when on) Internal-only services Internal apps get an internal.* FQDN; not public
Target port 1–65535 none (must set) Always — match your app Wrong port → all probes fail → 503
Transport auto / http / http2 / tcp auto gRPC (http2), raw TCP gRPC needs http2; tcp disables HTTP routing
Allow insecure on / off off Local/dev only On lets plain HTTP through; prefer off
Client certificate ignore / accept / require ignore mTLS to the app require rejects callers without a cert
IP restrictions allow / deny rules none (all allowed) Lock down by CIDR No rules = open to the internet (still TLS)
Sticky sessions none / sticky none Stateful UIs Affinity pins a client to a replica; hurts even scaling

The FQDN is formed from the app name, a unique environment token, and the region — e.g. app-orders-api.kindpond-1a2b3c4d.eastus.azurecontainerapps.io — and is stable for the life of the app. Internal services are called by their internal FQDN (or just the app name with Dapr). One subtlety: ingress balances across replicas, but the target port is per-container — with a sidecar, only the primary container binds the target port.

Core concept: scale rules and scale-to-zero

This is the headline feature, so we go a layer deeper. A container app’s scale block has three parts: the min, the max, and a list of rules. KEDA evaluates the rules and computes a desired replica count between min and max; the platform reconciles toward it.

The HTTP scale rule is the one you use first. Its one knob that matters is concurrent requests — the in-flight requests KEDA targets per replica, default 10. Set it to 50 with 200 requests in flight and KEDA wants ceil(200 / 50) = 4 replicas. Lower it and the app scales out sooner (more replicas, lower per-replica load, higher cost); raise it and it packs more per replica (fewer replicas, lower cost, more saturation risk). HTTP and TCP rules have no polling interval — they react to live concurrency; polling and cooldown apply to event-driven (custom) rules.

Scale-to-zero is minReplicas: 0 plus nothing keeping a replica alive. When the HTTP rule reports zero in-flight requests for the cooldown period (default 300 s), KEDA removes the final replica. The next request hits ingress, which holds it briefly while a replica starts — the cold start, whose cost depends on image size (pull, cached after first pull on a node), runtime boot, and your app’s startup work.

Here is the scale configuration laid out — every knob, its range, default and trade-off:

Setting Range / values Default Lower it to… Raise it to…
minReplicas 0–max 0 (CLI default is 0) (0 is the floor) scale to zero, free idle keep replicas warm, kill cold start
maxReplicas 1–1000 (workload profiles) 10 cap cost / blast radius absorb bigger spikes
HTTP concurrency ≥ 1 10 scale out sooner, lower latency pack more per replica, lower cost
TCP concurrency ≥ 1 10 (TCP services) same logic as HTTP
Cooldown (custom) seconds 300 scale down faster after a burst hold replicas longer post-burst
Polling (custom) seconds 30 react to events faster poll the source less often
CPU / memory rule % threshold n/a scale on resource pressure

Two numbers people misremember: the default maxReplicas is 10, not unlimited — a real spike plateaus at ten replicas and sheds load unless you raise it. And the Consumption per-replica ceiling is 4 vCPU / 8 GiB; need more and you move to a Dedicated workload profile (a different billing model, covered in Cost & sizing). CPU and memory follow a fixed ratio — memory in GiB ≈ 2× the vCPU (0.5 vCPU ↔ 1 GiB, 1 vCPU ↔ 2 GiB).

The allowed CPU/memory combinations on the Consumption profile are discrete — you cannot pick arbitrary values. The common ones:

vCPU Memory Typical use Note
0.25 0.5 GiB Tiny sidecar, health-only Smallest; cheapest idle
0.5 1.0 GiB Small API, default starting point Good first choice
0.75 1.5 GiB Moderate API
1.0 2.0 GiB Busy API / light worker
2.0 4.0 GiB CPU-heavy service
4.0 8.0 GiB Max on Consumption Beyond this → Dedicated profile

Health probes — readiness, liveness, startup

Probes are how the platform knows your replica is alive and ready, and they directly affect scaling and rollouts. Container Apps supports the three Kubernetes probe types, because underneath it is Kubernetes. A readiness probe gates traffic — a not-ready replica gets no requests from ingress (vital during cold start and rollout). A liveness probe restarts a wedged replica — repeated failure kills and recreates the container (rescues a deadlock). A startup probe gives slow-booting apps a grace window before liveness kicks in (so a 40-second JVM warm-up isn’t mistaken for a hang). Each probe can be HTTP, TCP, or a command (exec).

The knobs and sane defaults for an HTTP microservice:

Probe Purpose Key fields Sensible value If misconfigured
Readiness Gate traffic until ready path, initialDelaySeconds, periodSeconds, failureThreshold /healthz/ready, delay 3 s Too strict → replica never gets traffic → 503
Liveness Restart a hung replica path, periodSeconds, failureThreshold /healthz/live, period 10 s Too strict → healthy replica killed in a loop
Startup Grace window for slow boot path, failureThreshold, periodSeconds covers your boot time Too short → boot mistaken for failure → restart loop

Two rules from experience: keep readiness and liveness on separate, cheap endpoints — liveness must never check a database (a DB blip then restarts every replica), while readiness can check downstream health so traffic is held during a dependency outage. And size the startup probe’s failureThreshold × periodSeconds to comfortably exceed your worst cold-start boot, or scale-to-zero wake-ups trip into restart loops.

Revisions and traffic splitting

You met revisions in Core concepts; here is how to wield them. The app’s revision mode is single (default) or multiple. In single mode, every meaningful change (new image tag, env var, scale rule) retires the old revision and activates a new one at 100% — right for most services. In multiple mode, new revisions are created but get no traffic until you assign weights, so several serve at once. This is the canary/blue-green lever.

Mode New deploy behavior Traffic Use when
Single Old revision retired, new gets 100% Always 100% latest Most apps; simplest
Multiple New revision created, 0% until you assign You split by weight Canary, blue-green, A/B

A canary in multiple mode is three steps: deploy the new revision (it gets 0%), shift 10% to it, watch metrics, then shift to 100% — or back to 0% to roll back. Traffic can also target a revision label (a stable alias like canary you point at whichever revision you choose) so testers hit a fixed URL while the underlying revision changes. We do a real split in Step 8 of the lab.

Architecture at a glance

Follow the request left to right. A client resolves the app’s FQDN and opens an HTTPS connection that lands on the Container Apps environment’s managed Envoy ingress, which terminates TLS on 443, applies any IP rules, and forwards to the app’s target port (here 8080) on a healthy replica. KEDA watches the in-flight request count: above the rule’s threshold it adds replicas up to maxReplicas; if requests stop for the cooldown and minReplicas is 0 it removes the last replica and the app scales to zero — so the next request pays a brief cold start while a replica is scheduled and the image is pulled from Azure Container Registry. Every replica streams stdout/stderr and platform events to the environment’s Log Analytics workspace, where you confirm health and read crashes. The app authenticates to the registry (and any Azure service) with a managed identity, so no password lives in config.

The numbered badges mark where a first deploy fails: the target-port handshake (1) where a mismatch produces an endless 503; the registry pull (2) where a missing identity role gives UNAUTHORIZED; the scale-to-zero boundary (3) where the cold start lives; the scale-out ceiling (4) where maxReplicas caps a spike; and the log path (5) where the truth about a crash is recorded. Read the legend as a first-deploy checklist.

Left-to-right architecture of a first Azure Container Apps deployment: a public client over HTTPS reaches the Container Apps environment's managed Envoy ingress on port 443, which forwards to target port 8080 on app replicas; KEDA scales the replicas between zero and the max based on HTTP concurrency; replicas pull the image from Azure Container Registry using a managed identity and stream logs to a Log Analytics workspace, with numbered failure points on the ingress port handshake, the registry pull, the scale-to-zero cold start, the max-replica ceiling, and the log path.

Real-world scenario

Northwind Returns runs a customer-returns portal for a mid-size retailer. Their returns-api is a Node.js service: validate a return, look up the order in SQL, write a record, emit an event. Traffic is brutally spiky and time-zoned — near zero overnight, a sharp morning ramp, a lunchtime peak, a long quiet tail. On their old setup, returns-api ran on an App Service P1v2 plan sized for the lunchtime peak, billing 24/7. The monthly compute bill was about ₹11,000 (~US$130) to serve a workload that was genuinely idle for fourteen hours a day. Finance flagged it in a cost review; the team was told to cut it without hurting peak latency.

They moved returns-api to Container Apps on the Consumption profile, sized 0.5 vCPU / 1 GiB per replica, with minReplicas: 0, maxReplicas: 8, and an HTTP scale rule at concurrency 20. The first deploy 503’d for an hour: the container listened on 3000 but the engineer had set --target-port 8080 (copied from a tutorial). The platform’s readiness probe could not reach 3000, so ingress never marked a replica healthy. The fix took thirty seconds once they read the log stream — az containerapp logs show printed the probe failures against port 3000 — and re-set --target-port 3000. Lesson logged for the runbook: the target port is the container’s port, not a convention.

With the port right, behavior matched the model. Overnight the app sat at zero replicas, billing nothing. The first morning request paid a ~1.5-second cold start (small Node image, cached on the node after the first pull), acceptable here; at the lunchtime peak KEDA scaled to five or six replicas at concurrency 20, held p95 where it had been, then drained back toward zero. They kept minReplicas: 0; had the morning cold start been unacceptable, minReplicas: 1 would have removed it for one always-warm replica (~₹900/month).

The result: same workload, same peak latency, billed for what it consumed. Over the month returns-api used roughly 62,000 vCPU-seconds and 124,000 GiB-seconds — inside the free grant — and ~480,000 requests, well under the 2,000,000 free. The line item went from ₹11,000 to effectively ₹0. The architect’s takeaway: the win was not a cheaper instance — it was paying nothing for fourteen idle hours a day, which a fixed-instance plan can never do.

Advantages and disadvantages

Container Apps is a sharp tool with real edges. The two-column trade-off first, then when each side matters:

Advantages Disadvantages
Scale-to-zero — pay nothing when idle Cold start on the first request after zero
Managed HTTPS ingress (Envoy) — no cert/LB to run Less control than AKS (no raw kubectl, limited CRDs)
No cluster to operate — serverless Kubernetes Per-replica ceiling 4 vCPU / 8 GiB on Consumption
KEDA autoscale built in (HTTP, queue, custom) maxReplicas defaults to 10 — easy to under-cap
Revisions = free blue-green / canary Stateful workloads need external state (no durable local disk)
Per-second billing + generous free grant Fewer regions / features than AKS at the edges
Optional Dapr for microservice plumbing Networking is simpler but less flexible than full AKS
Fast from docker push to a live URL Some advanced mesh/policy needs (mTLS policy) push you to AKS

Advantages win for HTTP microservices and internal APIs with spiky traffic, small teams without a platform engineer, and cost-sensitive workloads where idle time is real — scale-to-zero economics alone justify it for the long tail of low-traffic services. Disadvantages bite on latency-critical paths that can’t tolerate any cold start (use minReplicas: 1+ or App Service), workloads needing >4 vCPU/8 GiB per replica, GPUs, custom schedulers or fine-grained network policy (go to AKS), and stateful services assuming durable local disk (replicas are ephemeral — externalize state). The decision is in Azure App Service vs Container Apps vs AKS.

Hands-on lab

This is the centerpiece. We deploy one real HTTP microservice end to end — first in the portal, then the az CLI, then as Bicep — and finish with validation and teardown. Any one path works; doing all three cements the model. Total time ~30 minutes, all inside the free grant. We use Microsoft’s Container Apps quickstart image mcr.microsoft.com/k8se/quickstart:latest, which listens on port 80 and returns a “Welcome to Azure Container Apps” page — ideal because there is no Dockerfile to fight, so you focus on the platform, then swap in a realistic registry image at the end.

Step 0 — Prerequisites and setup

You need the Azure CLI and the Container Apps extension. Run these once:

# Confirm you are logged in and on the right subscription
az account show --output table

# Install / update the Container Apps CLI extension
az extension add --name containerapp --upgrade

# Register the required resource providers (one-time per subscription)
az provider register --namespace Microsoft.App --wait
az provider register --namespace Microsoft.OperationalInsights --wait

Expected output: az account show prints your subscription name and ID. The provider register commands return to the prompt with no error (the --wait blocks until Registered). If az extension add warns it is already installed, that is fine.

Set reusable variables so every later command is copy-paste:

RG=rg-aca-lab
LOC=eastus
ENV=aca-env-lab
APP=app-hello-aca
IMAGE=mcr.microsoft.com/k8se/quickstart:latest

az group create --name $RG --location $LOC --output table

Expected output: a table row showing rg-aca-lab with provisioningState: Succeeded.

The naming and parameters we will reuse across all three paths:

Parameter Value Why
Resource group rg-aca-lab Single group → one-command teardown
Region eastus Broad feature availability
Environment aca-env-lab Shared boundary + Log Analytics
App name app-hello-aca Becomes part of the FQDN
Image mcr.microsoft.com/k8se/quickstart Public, listens on 80, no build
Target port 80 The port this image binds
Ingress external We want a public URL

Path A — The Azure portal (click-by-click)

A1. Create the Container App resource. In the portal, choose Create a resource → search Container AppCreate. On the Basics tab: pick your subscription, the resource group rg-aca-lab (or create it), set Container app name to app-hello-aca, Region East US. Under Container Apps Environment, click Create new, name it aca-env-lab, accept the default Consumption plan, and let it create a new Log Analytics workspace. Expected: the environment field now shows aca-env-lab.

A2. Configure the container. On the Container tab, uncheck “Use quickstart image” (we want to be explicit). Set Name hello, Image source = Docker Hub or other registries, Image type = Public, Registry login server mcr.microsoft.com, Image and tag k8se/quickstart:latest. Set CPU and Memory to 0.5 CPU / 1 Gi. Expected: no validation error on the image fields.

A3. Configure ingress. On the Ingress tab, toggle Ingress = Enabled. Set Ingress traffic = Accepting traffic from anywhere (external). Ingress type = HTTP. Set Target port = 80 (this image’s port). Leave transport on Auto. Expected: the target-port field accepts 80.

A4. Review and create. Click Review + create, then Create. Deployment takes 1–3 minutes. Expected: “Your deployment is complete.”

A5. Get the URL and test. Go to the resource → Overview → copy the Application Url (an https://app-hello-aca.<token>.eastus.azurecontainerapps.io). Open it in a browser. Expected: the “Welcome to Azure Container Apps” page. The first hit may take a second or two (cold start, since the portal default is minReplicas: 0).

A6. Set scale-to-zero explicitly. Go to ApplicationScale → edit the active revision’s scale. Set Min replicas = 0, Max replicas = 5. Add an HTTP scale rule named http-rule with Concurrent requests = 20. Save — this creates a new revision. Expected: a new revision appears under Revisions, status Running, replica count dropping to 0 after the cooldown when idle.

You now have a public, scale-to-zero microservice from the portal. The portal is great for learning and one-offs; for anything repeatable, use the CLI or Bicep below.


Path B — The az CLI (the way you will actually do it)

This is the path you will use day to day. It also makes the port and scale settings explicit, which the portal hides.

B1. Create the environment. The environment provisions a Log Analytics workspace automatically:

az containerapp env create \
  --name $ENV \
  --resource-group $RG \
  --location $LOC

Expected output: JSON ending with "provisioningState": "Succeeded". This step is the slow one (2–4 minutes) because it stands up the shared infrastructure. If it returns instantly with an error about providers, re-run the az provider register commands from Step 0.

B2. Deploy the app with ingress and scale-to-zero in one command. This single command creates the app, enables external ingress on the right port, and sets the scale bounds:

az containerapp create \
  --name $APP \
  --resource-group $RG \
  --environment $ENV \
  --image $IMAGE \
  --target-port 80 \
  --ingress external \
  --cpu 0.5 --memory 1.0Gi \
  --min-replicas 0 \
  --max-replicas 5 \
  --query properties.configuration.ingress.fqdn \
  --output tsv

Expected output: a single line — the FQDN, e.g. app-hello-aca.kindpond-1a2b3c4d.eastus.azurecontainerapps.io. The --query ... --output tsv trick prints just the hostname so you can pipe it straight into curl.

B3. Add the HTTP scale rule. The create set the bounds; now define why it scales — a concurrency rule. Updating scale creates a new revision:

az containerapp update \
  --name $APP \
  --resource-group $RG \
  --scale-rule-name http-rule \
  --scale-rule-type http \
  --scale-rule-http-concurrency 20

Expected output: JSON with the new revision and "http": { "metadata": { "concurrentRequests": "20" } } under the scale rules.

B4. Test the endpoint. Capture the FQDN and call it:

FQDN=$(az containerapp show --name $APP --resource-group $RG \
  --query properties.configuration.ingress.fqdn --output tsv)

curl -s -o /dev/null -w "%{http_code}\n" https://$FQDN
curl -s https://$FQDN | head -n 5

Expected output: 200 from the first curl, and the first few lines of the “Welcome to Azure Container Apps” HTML from the second. The very first request after an idle period may take ~1–2 seconds (cold start); subsequent requests are immediate.

B5. Watch it scale to zero. Confirm the replica count and watch it drain:

# Show current replicas for the latest revision
az containerapp replica list --name $APP --resource-group $RG \
  --query "[].name" --output tsv

Expected output: right after a request you see one (or more) replica names; after ~5 minutes of no traffic the list is empty — the app has scaled to zero and is billing nothing for compute. Hit the URL again and a replica reappears.

B6. Read the logs. This is the command you reach for when something is wrong:

# Stream live logs from the running replica
az containerapp logs show --name $APP --resource-group $RG --follow

Expected output: the container’s stdout (HTTP access lines as you curl it). Press Ctrl-C to stop. If the app is scaled to zero, this waits for a replica; trigger one with a curl in another terminal.

The most useful CLI flags for containerapp create/update, so you are not guessing:

Flag What it sets Example
--image Container image (with tag) myreg.azurecr.io/api:1.4.2
--target-port Container’s listening port --target-port 8080
--ingress external / internal --ingress internal
--cpu / --memory Per-replica size --cpu 1.0 --memory 2.0Gi
--min-replicas / --max-replicas Scale bounds --min-replicas 0 --max-replicas 8
--scale-rule-http-concurrency HTTP rule target --scale-rule-http-concurrency 50
--env-vars Environment variables --env-vars 'LOG_LEVEL=info'
--secrets Named secrets --secrets 'cs=...'
--registry-server / --registry-identity ACR + identity for pull --registry-identity system
--revision-suffix Name the new revision --revision-suffix v2

Path C — Bicep (the version-controlled way)

For anything that outlives a demo, the deployment should be code. This Bicep stands up the Log Analytics workspace, the environment, and the app with ingress and a scale-to-zero HTTP rule — the same result as Path B, but reviewable and redeployable. Save as main.bicep:

@description('Location for all resources')
param location string = resourceGroup().location

@description('Container Apps environment name')
param envName string = 'aca-env-lab'

@description('Container app name')
param appName string = 'app-hello-aca'

@description('Container image to run')
param image string = 'mcr.microsoft.com/k8se/quickstart:latest'

@description('Port the container listens on')
param targetPort int = 80

// Log Analytics workspace the environment logs to
resource law 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: '${envName}-law'
  location: location
  properties: {
    sku: { name: 'PerGB2018' }
    retentionInDays: 30
  }
}

// The Container Apps environment (shared boundary)
resource env 'Microsoft.App/managedEnvironments@2024-03-01' = {
  name: envName
  location: location
  properties: {
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: law.properties.customerId
        sharedKey: law.listKeys().primarySharedKey
      }
    }
  }
}

// The container app: external ingress + scale-to-zero HTTP rule
resource app 'Microsoft.App/containerApps@2024-03-01' = {
  name: appName
  location: location
  properties: {
    managedEnvironmentId: env.id
    configuration: {
      ingress: {
        external: true
        targetPort: targetPort
        transport: 'auto'
        allowInsecure: false
      }
    }
    template: {
      containers: [
        {
          name: 'hello'
          image: image
          resources: {
            cpu: json('0.5')
            memory: '1.0Gi'
          }
          probes: [
            {
              type: 'Liveness'
              httpGet: { path: '/', port: targetPort }
              periodSeconds: 10
              failureThreshold: 3
            }
            {
              type: 'Readiness'
              httpGet: { path: '/', port: targetPort }
              initialDelaySeconds: 3
              periodSeconds: 5
            }
          ]
        }
      ]
      scale: {
        minReplicas: 0
        maxReplicas: 5
        rules: [
          {
            name: 'http-rule'
            http: { metadata: { concurrentRequests: '20' } }
          }
        ]
      }
    }
  }
}

@description('The public HTTPS URL of the app')
output appUrl string = 'https://${app.properties.configuration.ingress.fqdn}'

C1. Validate and deploy:

# Preview what will be created (no changes made)
az deployment group what-if \
  --resource-group $RG \
  --template-file main.bicep

# Deploy
az deployment group create \
  --resource-group $RG \
  --template-file main.bicep \
  --query properties.outputs.appUrl.value \
  --output tsv

Expected output: what-if prints a green + Create block for the workspace, environment and app. The create ends by printing the app URL on its own line. curl it as in B4 — same 200 and welcome page.

A few Bicep-specific notes that trip people up:

Pattern Why Gotcha
cpu: json('0.5') CPU is a decimal; Bicep needs json() Plain 0.5 fails type validation
memory: '1.0Gi' Memory is a string with unit Must pair with CPU per the ratio table
concurrentRequests: '20' Scale metadata values are strings 20 (int) is rejected
external: true Public ingress false = internal-only FQDN
law.listKeys().primarySharedKey Wires logs to the workspace Needs the workspace deployed first (dependency)

Step 7 — Swap in a private registry image (realistic version)

The quickstart image is public; your real image lives in Azure Container Registry (ACR), which requires authentication. The clean way is a managed identity the app uses to pull — no password in config. Assuming an ACR named myregistry and an image api:1.0.0:

# Give the app a system-assigned identity and grant it AcrPull, then point it at ACR
az containerapp identity assign --name $APP --resource-group $RG --system-assigned

PRINCIPAL=$(az containerapp identity show --name $APP --resource-group $RG \
  --query principalId --output tsv)
ACR_ID=$(az acr show --name myregistry --query id --output tsv)

az role assignment create --assignee $PRINCIPAL --role AcrPull --scope $ACR_ID

az containerapp registry set --name $APP --resource-group $RG \
  --server myregistry.azurecr.io --identity system

az containerapp update --name $APP --resource-group $RG \
  --image myregistry.azurecr.io/api:1.0.0 \
  --target-port 8080

Expected output: each command returns Succeeded/JSON; the final update creates a new revision running your image. If it fails with UNAUTHORIZED, the AcrPull role has not propagated yet (wait ~1 minute) or the identity is wrong — see troubleshooting. Note the deliberate --target-port 8080 change: your image’s port, not the quickstart’s 80. The full ACR hardening story is in Securing Azure Container Registry, and the identity model in Managed Identities Demystified.

Step 8 — Ship a second revision and split traffic (canary)

Switch to multiple-revision mode, deploy a new revision, and canary it:

# Enable multiple-revision mode
az containerapp revision set-mode --name $APP --resource-group $RG --mode multiple

# Deploy a new revision (gets 0% traffic in multiple mode)
az containerapp update --name $APP --resource-group $RG \
  --image $IMAGE --revision-suffix v2

# List revisions to get their names
az containerapp revision list --name $APP --resource-group $RG \
  --query "[].{name:name, active:properties.active, weight:properties.trafficWeight}" -o table

# Send 10% to v2, 90% to the previous revision (replace names)
az containerapp ingress traffic set --name $APP --resource-group $RG \
  --revision-weight app-hello-aca--v1=90 app-hello-aca--v2=10

Expected output: the revision list shows two active revisions; after the traffic set, weights read 90 and 10. Curl the URL repeatedly — ~1 in 10 responses comes from v2. Promote with ...=0 and ...=100, or roll back by reversing the weights.

Step 9 — Validate end to end

A quick checklist that the deployment is genuinely healthy, not just “created”:

Check Command Healthy result
App is provisioned az containerapp show -n $APP -g $RG --query properties.provisioningState -o tsv Succeeded
Ingress returns 200 curl -s -o /dev/null -w "%{http_code}" https://$FQDN 200
Replica exists under load az containerapp replica list -n $APP -g $RG -o table ≥1 row after a request
Scales to zero when idle same, after ~5 min idle empty (0 replicas)
Logs are flowing az containerapp logs show -n $APP -g $RG --tail 20 recent stdout lines
Revision is running az containerapp revision list -n $APP -g $RG -o table active revision Running

Step 10 — Teardown

Everything lived in one resource group, so cleanup is one command:

az group delete --name $RG --yes --no-wait

Expected output: returns immediately (--no-wait); the group and all its resources (app, environment, Log Analytics) delete in the background over a few minutes. Verify with az group exists --name rg-aca-lab → eventually false. With scale-to-zero you owed almost nothing even before deleting, but tearing down the environment stops the small fixed costs (Log Analytics ingestion) entirely.

Common mistakes & troubleshooting

The first deploy fails in a handful of predictable ways. For each: the symptom, the root cause, the exact command or blade to confirm it, and the fix.

# Symptom Root cause Confirm with Fix
1 URL returns 503 forever; app shows “Running” Target port ≠ container port — probes can’t reach the app az containerapp logs show → probe failures on the wrong port Set --target-port to the container’s real port; redeploy
2 Deploy fails / replica won’t start: UNAUTHORIZED App can’t pull from ACR (no/insufficient identity role) az containerapp logs show; system logs show pull error Assign identity + AcrPull on the ACR; registry set --identity system
3 “Where did my app go?” — 0 replicas Scaled to zero (working as designed) az containerapp replica list → empty Not a bug; a request wakes it. For no cold start, --min-replicas 1
4 App never scales to zero A minReplicas ≥ 1, or a always-on rule, or open connections az containerapp show --query properties.template.scale Set minReplicas: 0; remove keep-warm rules
5 Spike plateaus / sheds load at 10 replicas maxReplicas default is 10 check scale block Raise --max-replicas; revisit concurrency
6 Internal app unreachable from the internet Ingress is internal, not external --query properties.configuration.ingress.externalfalse Set --ingress external (if it should be public)
7 gRPC clients fail / fall back to HTTP/1.1 Transport is auto/http, not http2 check ingress.transport Set --transport http2
8 New image deployed but traffic still old Multiple-revision mode with 0% on the new revision revision list shows new rev at weight 0 ingress traffic set to shift weight, or use single mode
9 Replica restart loop right after cold start Startup/liveness probe too strict for boot time logs show → repeated restarts; probe failures Add a startup probe; widen failureThreshold × periodSeconds
10 Container OOMKilled under load Memory too low for the workload logs showOOMKilled; high memory metric Increase --memory (respect the 2:1 ratio with CPU)
11 No logs in Log Analytics Workspace not wired, or query lag az containerapp env show --query properties.appLogsConfiguration Ensure destination: log-analytics; wait for ingestion
12 First request after deploy is slow Cold start (image pull + boot) on a fresh node normal; compare first vs subsequent request time Smaller image, same-region ACR, or minReplicas: 1

The single most common first-deploy failure — by a wide margin — is #1, the target port. Internalize the rule: the target port is the port your process binds inside the container, full stop. When in doubt, the log stream tells you which port the probe tried and failed on.

Two diagnostic distinctions that save the most time:

Distinction The trap How to tell them apart
Scaled-to-zero vs broken “It’s down!” when it is just idle replica list empty + a curl brings it back in seconds = scaled to zero, not broken
Platform 503 vs app 503 Staring at app logs for a platform issue If logs show has no request matching the 503, the platform (ingress/probe) emitted it, not your code

Best practices

Production-grade rules, learned the hard way:

Security notes

Container Apps inherits a strong default posture, but a few choices are yours.

Identity, not secrets, for Azure access. Authenticate to ACR, Key Vault, SQL and storage with a managed identity, granted the minimum role — AcrPull for the registry, a scoped Key Vault role for secrets — and store no passwords in config; patterns in Managed Identities Demystified.

Secrets are first-class but reference Key Vault for rotation. The native secrets store is referenced from env vars, but for anything that rotates, back it with Azure Key Vault so rotation is centralized — see Azure Key Vault: Secrets, Keys and Certificates. Never bake secrets into the image.

Network isolation when you need it. For private workloads, deploy the environment into your VNet (an internal environment with a private ingress IP) and reach PaaS over Private Endpoints — see Azure Private Endpoint vs Service Endpoint. A workload-profiles environment needs a dedicated subnet of at least /27; the legacy Consumption-only environment needs /23.

Lock down ingress. Public ingress is TLS-only by default; keep allowInsecure: false. Add IP restriction rules to limit callers by CIDR, set client-certificate to require for mTLS where the caller can present one, and prefer internal ingress for service-to-service traffic. Run one concern per app, expose only the target port you need, and avoid running the container as root.

The security responsibilities, drawn cleanly:

Layer Azure handles You handle
Host / Kubernetes Patching, isolation, control plane Nothing
Ingress / TLS Certificate, Envoy, termination IP rules, mTLS, internal vs external
Image Registry storage Image contents, scanning, non-root
Identity Token issuance Which identity, which roles (least priv)
Secrets Encrypted store What you put there; prefer Key Vault refs
Network VNet integration mechanics Subnet sizing, Private Endpoints, NSGs

Cost & sizing

The bill is the reason many teams come to Container Apps, so be precise about it.

What you pay for (Consumption): the active resources your replicas consume — vCPU-seconds and GiB-seconds — plus requests. Replicas running above your minimum but idle between requests bill at a reduced idle rate; a replica scaled to zero bills nothing for compute. The environment’s Log Analytics workspace bills for ingested logs separately (small, but not zero).

The free grant resets monthly and covers a surprising amount of low-traffic work:

Resource Free grant / month What it roughly buys
vCPU-seconds 180,000 One 0.5-vCPU replica active ~100 hrs
GiB-seconds 360,000 One 1-GiB replica active ~100 hrs
Requests 2,000,000 2M HTTP requests

So a 0.5 vCPU / 1 GiB internal API active only a couple of hours a day, scaling to zero the rest, can land inside the free grant — effectively ₹0 compute, exactly the Northwind result. The lever is minReplicas: 0: a fixed-instance plan bills the idle 22 hours; Container Apps does not.

Sizing guidance — start small, measure, adjust:

Workload shape Start at Min replicas Max replicas HTTP concurrency
Low-traffic internal API 0.5 / 1 GiB 0 5 20
Public API, spiky 0.5 / 1 GiB 0–1 10–20 10–20
Latency-sensitive API 1.0 / 2 GiB 1+ 10+ 10
CPU-heavy service 2.0 / 4 GiB 0–1 10 5–10
Background worker (queue) 0.5 / 1 GiB 0 by backlog n/a (queue rule)

Consumption vs Dedicated. Consumption is per-second and caps at 4 vCPU / 8 GiB per replica. Need more per replica, GPUs, or steady-state at scale, and a Dedicated workload profile runs on reserved compute billed per node-hour (no scale-to-zero on dedicated nodes). For first microservices, Consumption is almost always right.

Profile Billing Per-replica max Scale-to-zero Use when
Consumption Per vCPU-s / GiB-s / request 4 vCPU / 8 GiB Yes (min 0) Variable/spiky, cost-sensitive
Dedicated (workload profile) Per node-hour (reserved) Larger; GPU options No (on dedicated nodes) Steady-state, big replicas, GPU

Two cost gotchas: a runaway scale-out (bad concurrency or a retry storm hitting maxReplicas) can bill real money fast — cap maxReplicas and alert on it. And always-on minReplicas quietly bills 24/7 at the idle rate — fine when you need it, wasteful when you set it by accident.

Interview & exam questions

Likely questions, with model answers. Maps to AZ-204 (Developing Solutions for Azure) and AZ-305 (architecture), where Container Apps appears under modern app hosting.

Q1. What is Azure Container Apps and how does it differ from AKS? A serverless container platform built on Kubernetes, KEDA and Envoy, where you run containers without operating the cluster. Versus AKS you get no kubectl or control-plane bill and far less to manage, in exchange for less control (no custom CRDs/operators, a 4 vCPU / 8 GiB per-replica cap on Consumption). Choose it for HTTP microservices wanting serverless economics; AKS when you need full cluster control.

Q2. Explain scale-to-zero and its trade-off. minReplicas: 0 lets KEDA remove the last replica after a cooldown with no traffic, so you pay nothing for compute while idle. The trade-off is a cold start on the next request — schedule a replica, pull the image, boot the app. Use it for variable/low-traffic workloads; set minReplicas: 1+ when cold start is unacceptable.

Q3. A container app returns 503 to every request though it shows “Running.” Most likely cause? A target-port mismatch: ingress forwards to a port the container isn’t on, so readiness probes fail and there is no healthy replica. Confirm in the log stream (probe failures on the wrong port); fix by setting the target port to the container’s actual listening port.

Q4. What is the object model — environment, app, revision, replica? The environment is the shared secure boundary (network, logging, profiles). A container app is your service with ingress and scaling. A revision is an immutable snapshot of config/image; deploys create new revisions. A replica is a running instance of a revision; scaling adds/removes replicas.

Q5. How do you do a canary deployment? Switch to multiple-revision mode, deploy the new revision (starts at 0% traffic), then use ingress traffic set to shift a small weight (e.g. 10%) to it. Watch metrics, promote to 100%, or roll back by moving the weight back. No extra infrastructure — the old revision is still running.

Q6. Which scaler powers autoscaling, and what triggers are available? KEDA. Out of the box: an HTTP rule (concurrent requests), a TCP rule (concurrent connections), and custom rules for any KEDA scaler — Service Bus / Storage Queue length, Event Hubs backlog, CPU/memory, cron. HTTP/TCP react to live concurrency; custom rules poll.

Q7. What’s the default maxReplicas, and why does it matter? 10 — a silent ceiling. A real spike plateaus at ten replicas and sheds load unless you raise it. Set maxReplicas to your worst-case spike, balanced against cost and downstream limits.

Q8. How should a container app authenticate to ACR and Key Vault? With a managed identity, granted least-privilege roles — AcrPull on the registry, a scoped role on Key Vault — and no passwords in config. For the registry, registry set --identity system; for Key Vault, reference secrets via the identity.

Q9. Liveness vs readiness probe — difference and a common mistake? Readiness gates traffic (a not-ready replica gets no requests); liveness restarts a hung replica. The classic mistake is a database check on liveness — a DB blip then restarts every replica in a loop. Keep liveness cheap and local; readiness may check downstream health to drain traffic.

Q10. How is Container Apps billed, and when is it effectively free? Per vCPU-second, GiB-second, and request, with a monthly free grant (180k vCPU-s, 360k GiB-s, 2M requests) and a reduced idle rate; a scaled-to-zero replica costs nothing for compute. A low-traffic app that scales to zero most of the day can sit inside the free grant — effectively ₹0.

Q11. When would you choose App Service over Container Apps? When you want a simple always-on PaaS without managing scale rules, web-tuned features like slots/easy auth, or your traffic is steady enough that scale-to-zero adds no value and you’d rather avoid cold starts.

Q12. What state can a Container Apps replica keep locally? None you can rely on — replicas are ephemeral and destroyed by scaling at any time, so local disk is not durable. Externalize state to Azure SQL, Cosmos DB, Blob, Redis, or a mounted Azure Files share. Treat every replica as disposable.

Quick check

  1. Your app’s container listens on port 3000. What must --target-port be, and what happens if it is wrong?
  2. What does minReplicas: 0 enable, and what is the one cost you pay for it?
  3. The default maxReplicas is what number, and why should you not leave it at the default for a high-traffic app?
  4. You deployed a new image in multiple-revision mode but all traffic still hits the old version. Why?
  5. Which probe gates traffic to a replica, and which one restarts a hung replica?

Answers

  1. --target-port 3000 — it must match the container’s listening port. If it is wrong, ingress probes fail, no replica is marked healthy, and every request returns 503 even though the container is “Running.”
  2. Scale-to-zero — the app drops to zero replicas when idle and bills nothing for compute. The cost you pay is a cold start (extra latency on the first request after idle) while a replica is scheduled and the image/app boots.
  3. 10. A high-traffic spike will plateau at ten replicas and start shedding load; set maxReplicas to your worst expected spike (balanced against budget and downstream limits).
  4. In multiple-revision mode, a new revision starts at 0% traffic — you must explicitly shift weight to it with ingress traffic set. (In single-revision mode it would have taken 100% automatically.)
  5. The readiness probe gates traffic (a not-ready replica receives no requests); the liveness probe restarts a replica that has failed repeatedly.

Glossary

Next steps

AzureContainer AppsServerlessKEDAMicroservicesScale to ZeroIngressBicep
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading