Autoscale a Container on Azure Container Apps
Deploy a containerized Node.js API to Azure Container Apps with HTTP autoscaling.
Introduction
30 Second Summary
Every time you order food during a lunch rush or stream a show that everyone is watching, the apps behind those services are quietly spinning up extra servers to handle the flood of requests. That automatic scaling is what keeps things running smoothly.
In this project, you will build a Node.js API, package it in a Docker container, and deploy it to Azure Container Apps with an HTTP autoscaling rule. You will then fire traffic at your live URL and watch replicas scale up and down in the Azure portal.
What You'll Build
You will fire a wave of traffic at your live API, open the Azure portal, and watch the replica count climb from 1 toward 5 in real time.
By the end of this project, you'll have:
- A live HTTPS URL serving your containerized API, where you can hit the /health and /work endpoints from any browser or terminal.
- A container image you built from scratch, pushed to your own Azure Container Registry, and deployed to the cloud without managing a single password.
- A proven autoscaling rule that spins up replicas under load and scales them back down when traffic stops, verified by a real load test you ran yourself.
- Secret Mission: Configure scale-to-zero so your app runs zero replicas when idle, then wake it with a single request and measure the cold-start latency.
Are there any prerequisites?
You need a free Azure account. You will also need Docker, Azure CLI, and Node.js installed on your machine.
Step 1 includes install tabs for each tool if you need to set any of them up.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Tools
Before you can build a container or deploy anything to Azure, you need three tools working on your machine: Docker for building and running containers, Azure CLI for managing cloud resources, and Node.js for running JavaScript on the server.
Verifying them now saves you from debugging tool issues when you are deep in the project. In this step, you will check that each tool is installed and at the right version, log into Azure, and register the providers that Container Apps needs.
In this step, get ready to:
- Verify Docker, Azure CLI, and Node.js are installed at the correct versions.
- Log in to Azure and confirm your subscription.
- Install the Container Apps CLI extension and register required providers.
Check your tool versions
Each tool has a minimum version requirement for this project. Let's check them one at a time.
Docker 20+ is required. Check your Docker version by running this command in your terminal:
docker --version
✔️ I see version 20 or higher
Docker is ready. Move on to the Azure CLI check below.
ⓧ I see an older version
You need to update Docker Desktop to get version 20 or higher.
- Open https://docs.docker.com/get-started/get-docker/ in your browser.
- Download and install the latest Docker Desktop for your operating system.
- Close and reopen your terminal after the install completes.
- Run docker --version again to confirm version 20 or higher.
ⓧ Command not found
Docker is not installed yet. Install Docker Desktop for your operating system.
- Open https://docs.docker.com/get-started/get-docker/ in your browser.
- Download and install Docker Desktop for your operating system (macOS, Windows, or Linux).
- Launch Docker Desktop after installation and complete the setup wizard.
- Close and reopen your terminal.
- Run docker --version to confirm Docker is installed and shows version 20 or higher.
Azure CLI 2.50+ is required. Check your Azure CLI version by running:
az version
✔️ I see version 2.50 or higher
Azure CLI is ready. Move on to the Node.js check below.
ⓧ I see an older version
You need to update Azure CLI to 2.50 or higher. The fastest way is to run the built-in upgrade command:
az upgrade
After the upgrade completes, close and reopen your terminal, then run az version again to confirm.
ⓧ Command not found
Azure CLI is not installed yet. Install it for your operating system:
macOS
- Install Azure CLI using Homebrew by running:
brew update && brew install azure-cli
Windows
- Install Azure CLI using WinGet by running this in PowerShell:
winget install -e --id Microsoft.AzureCLI
Close and reopen your terminal after the install finishes.
Linux
- Install Azure CLI by running:
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
After installing, run az version to confirm version 2.50 or higher is installed.
Node.js 18+ is required. Check your Node.js version by running:
node --version
✔️ I see version 18 or higher
Node.js is ready. All three tools are verified.
ⓧ I see an older version
You need Node.js 18 or higher. Download the latest LTS installer from the official site.
- Open https://nodejs.org/en/download in your browser.
- Download and run the LTS installer for your operating system.
- Close and reopen your terminal.
- Run node --version again to confirm version 18 or higher.
ⓧ Command not found
Node.js is not installed yet.
- Open https://nodejs.org/en/download in your browser.
- Download the LTS installer for your operating system (.msi for Windows, .pkg for macOS).
- Run the installer and follow the prompts.
- Close and reopen your terminal.
- Run node --version to confirm version 18 or higher.
Now confirm all three tools in a single command:
docker --version && az version && node --version
You should see version output for all three tools. Docker 20+, Azure CLI 2.50+, and Node.js 18+.
One of the commands failed?
If any tool still shows an error, make sure you closed and reopened your terminal after installing. New installs update your system PATH, but existing terminal sessions do not pick up those changes automatically.
help me troubleshoot a tool installation issue for Docker, Azure CLI, or Node.js
Log in to Azure
The Azure CLI needs to know which Azure subscription to use. Logging in links your terminal session to your Azure account.
- Log in to Azure by running:
az login
What does this command do?
Running az login opens your default browser with a Microsoft sign-in page. After you sign in, the terminal receives your credentials and stores them locally so that subsequent az commands can act on your account.
A browser window opens. Sign in with the Microsoft account that has your Azure subscription.
- Confirm your login succeeded by running:
az account show
You should see JSON output with your subscription name and ID. This confirms the CLI is authenticated and ready.
Login failed or no subscription shown?
If the browser does not open, try running az login --use-device-code instead. This gives you a code to enter at https://microsoft.com/devicelogin manually.
If az account show does not return a subscription, you may need to create a free Azure account first. Visit https://azure.microsoft.com/free to sign up.
help me troubleshoot Azure CLI login
Install the Container Apps CLI extension and register providers
Azure Container Apps requires a CLI extension and two resource providers that are not enabled by default. Registering them now ensures the deployment commands work later.
- Install the Container Apps extension by running:
az extension add --name containerapp --upgrade
What does this command do?
The --name containerapp flag tells Azure CLI to install the Container Apps extension. The --upgrade flag ensures you get the latest version if the extension is already installed.
- Register the two required resource providers by running these commands:
az provider register --namespace Microsoft.App
az provider register --namespace Microsoft.OperationalInsights
What are resource providers?
Azure organizes services into namespaces called resource providers. Microsoft.App covers Container Apps. Microsoft.OperationalInsights covers the Log Analytics workspace that Container Apps uses for logging.
Provider registration runs in the background. It can take a minute or two to complete, but you do not need to wait.
Seeing a permissions error?
Provider registration requires at least Contributor access on your subscription. If you see a permissions error, ask your subscription administrator to register the Microsoft.App and Microsoft.OperationalInsights namespaces for you.
help me troubleshoot Azure resource provider registration
Your development environment is fully set up. Next up, you will build the Express API and run it inside a Docker container.
Build and Run the API Locally
Your tools are verified and Azure is ready to go. Now it's time to build the API that you'll containerize and ship to the cloud.
Before packaging anything in Docker, you'll write a small Express API and run it locally first. This gives you a working baseline to compare against the containerized version later.
In this step, get ready to:
- Write an Express API with /health and /work endpoints.
- Package the API into a Docker container image.
- Run the container and verify both endpoints respond correctly.
Create the project and install Express
Every Node.js project starts with a package.json file that tracks dependencies. You'll create a project folder, initialize it, and install Express as your web framework.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir scale-api
cd scale-api
- Initialize the project and install Express by running these commands:
npm init -y
npm install express@5.2.1
What do these commands do?
npm init -y creates a package.json file with default settings. This file tracks your project's name, version, and dependencies.
npm install express@5.2.1 downloads Express (version 5.2.1) and records it as a dependency. It also creates a package-lock.json file that locks the exact installed version.
- Confirm your project files were created by running:
ls
You should see node_modules, package.json, and package-lock.json in the output.
Not seeing these files?
Make sure you ran npm init -y and npm install express@5.2.1 from inside the scale-api folder, not from ~/Desktop.
help me debug missing project files after npm init
Write the Express API
Your API needs two endpoints. The first is a quick health check that confirms the server is alive. The second simulates heavy CPU work so requests stay in-flight long enough to trigger autoscaling later.
- Open your code editor and open the scale-api folder on your Desktop.
- Create a new file called server.js.
- Copy the following code into server.js:
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
// Health check endpoint
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Simulates CPU-bound work for ~500ms
app.get("/work", (req, res) => {
const start = Date.now();
// Simulate CPU-bound work for ~500ms
while (Date.now() - start < 500) {
Math.sqrt(Math.random() * 999999);
}
res.json({
message: "Work complete",
durationMs: Date.now() - start,
hostname: require("os").hostname(),
});
});
app.listen(PORT, () => {
console.log(`scale-api listening on port ${PORT}`);
});
What does this code do?
- /health returns a JSON object with a status and timestamp. It acts as a quick "is the server alive?" check.
- /work runs a busy-wait loop that blocks the CPU for about 500 milliseconds. This keeps each request in-flight, which is key for triggering the HTTP concurrency scaler later.
- hostname in the response reports which machine (or container) handled the request. When multiple replicas run later, this field shows which replica served you.
Why a busy-wait instead of setTimeout?
The HTTP autoscaler counts how many requests are being processed at the same time. A setTimeout delay would free the event loop immediately and the request would not count as "in-flight." The busy-wait keeps the request genuinely processing so the scaler can see the concurrency spike.
- Save server.js.
- Return to your terminal and start the server by running:
node server.js
You should see scale-api listening on port 3000 in the terminal. The server is now running and accepting requests.
- Open a second terminal tab or window.
- Test the health endpoint by running:
curl http://localhost:3000/health
You should see a JSON response containing "status":"ok" and a timestamp value.
- Go back to the first terminal (where node server.js is running) and press Ctrl+C to stop the server.
Server not starting?
- If you see an error about a missing module, make sure you ran npm install express@5.2.1 inside the scale-api folder.
- If port 3000 is already in use, stop whatever process is using it. You can find the process with lsof -i :3000 and kill it with kill <PID>.
help me debug why my Express server won't start
✔️ Awesome, I've got everything!
Great. Make sure you've saved server.js and stopped the local server with Ctrl+C before moving on.
ⓧ I'd like to double check the full code
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.get("/work", (req, res) => {
const start = Date.now();
// Simulate CPU-bound work for ~500ms
while (Date.now() - start < 500) {
Math.sqrt(Math.random() * 999999);
}
res.json({
message: "Work complete",
durationMs: Date.now() - start,
hostname: require("os").hostname(),
});
});
app.listen(PORT, () => {
console.log(`scale-api listening on port ${PORT}`);
});
Build and run the Docker image
Your API works when you run it directly with Node.js. Now you'll package it into a Docker container so it runs the same way on any machine, including Azure.
- In your code editor, create a new file called Dockerfile (no file extension) in the scale-api folder.
- Copy the following code into Dockerfile:
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
What does this Dockerfile do?
- FROM node:24-alpine starts from a lightweight Node.js 24 base image built on Alpine Linux. It's small (about 50 MB) which keeps your container lean.
- COPY package.json package-lock.json and RUN npm ci --omit=dev install dependencies first. Because Docker caches each layer, your dependencies are only re-installed when package.json changes.
- COPY server.js comes after the install step so that code changes don't invalidate the dependency cache.
- CMD ["node", "server.js"] tells Docker what command to run when the container starts.
- Create another new file called .dockerignore in the scale-api folder.
- Copy the following content into .dockerignore:
node_modules
npm-debug.log
Why a .dockerignore file?
Without this file, Docker would copy your local node_modules folder into the image. That's wasteful because the Dockerfile already runs npm ci to install fresh dependencies inside the container.
- Save both files.
- Return to your terminal (make sure you're inside the scale-api folder) and build the Docker image by running:
docker build -t scale-api:v1 .
What does this command do?
-t scale-api:v1 tags the image with the name scale-api and version v1. The . at the end tells Docker to use the current directory as the build context.
Docker downloads the node:24-alpine base image and runs each Dockerfile instruction. This takes a minute or two the first time.
- Once the build finishes, run the container by running:
docker run -d -p 3000:3000 scale-api:v1
What do these flags mean?
- -d runs the container in detached mode (in the background), so your terminal stays free for other commands.
- -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container. Requests to localhost:3000 are forwarded into the container.
You'll see a long string of characters printed to the terminal. That's the container ID. Your API is now running inside Docker.
Before you test, what do you expect to see when you hit /health? Will the response look any different from when you ran the server directly with node?
- Test the health endpoint by running:
curl http://localhost:3000/health
You should see {"status":"ok","timestamp":"..."}. The same response as before, but now it's coming from inside the Docker container.
- Test the work endpoint by running:
curl http://localhost:3000/work
After a brief pause (about 500 milliseconds), you should see a response with "message":"Work complete", a durationMs value near 500, and a hostname value. The hostname is the Docker container's ID, not your machine's hostname.
Getting 'connection refused'?
- Make sure you stopped the local node server.js process with Ctrl+C before running docker run. Two processes can't share port 3000.
- Check that the container is actually running with docker ps. You should see scale-api:v1 listed. If it's not listed, the container crashed at startup.
- If the container isn't listed in docker ps, check the logs with docker logs $(docker ps -a -q --filter ancestor=scale-api:v1) to see what went wrong.
help me debug why my Docker container isn't responding
✔️ Awesome, I've got everything!
Both endpoints are responding from the Docker container. You're ready to push this image to Azure.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
Here is the complete .dockerignore:
node_modules
npm-debug.log
Your API is running inside a Docker container on your machine. Next, you'll push this image to Azure Container Registry so it can be deployed to the cloud.
Push to Azure Container Registry
Your API is running locally in Docker, and both endpoints respond as expected. But Azure Container Apps pulls images from a container registry, not from your laptop.
To bridge the gap between your local build and a cloud deployment, you need Azure Container Registry (ACR). In this step, you will create an ACR instance and push your image to it.
In this step, get ready to:
- Create an Azure resource group and container registry.
- Tag and push your Docker image to ACR.
Create a resource group and container registry
Every Azure resource lives inside a resource group. You will create one to hold your registry and the Container Apps resources that come later.
- Set up environment variables for this step by running these commands:
export RG_NAME=scale-api-rg
export LOCATION=eastus
export ACR_NAME=[[YOUR_ACR_NAME="a unique name like scaleapijane"]]
What do these variables do?
- RG_NAME is the name of the resource group that will hold all your Azure resources for this project.
- LOCATION is the Azure region where your resources will be created.
- ACR_NAME is your registry name. It must be globally unique across all of Azure, 5 to 50 characters, and contain only lowercase letters and numbers (no dashes or underscores).
- Confirm the variables are set by running:
echo $RG_NAME $LOCATION $ACR_NAME
You should see scale-api-rg, eastus, and the unique ACR name you chose.
- Create the resource group by running:
az group create --name $RG_NAME --location $LOCATION
You should see a JSON response with "provisioningState": "Succeeded".
- Create the Azure Container Registry by running:
az acr create --resource-group $RG_NAME --name $ACR_NAME --sku Standard
This takes about one to two minutes. When it finishes, you should see a JSON response with your registry details.
Why Standard SKU?
The Standard tier gives you 100 GB of storage and 10 webhooks. For new Azure accounts, a Standard-tier ACR is included free for 12 months.
Seeing a naming error?
- ACR names must be globally unique. If you see a name-conflict error, pick a different name and re-export ACR_NAME before running the create command again.
- Make sure your name uses only lowercase letters and numbers. Dashes, underscores, and uppercase letters are not allowed.
Help me fix my ACR naming error.
Tag and push your image to ACR
Docker images need a registry prefix in their tag before you can push them. You will retag your local scale-api:v1 image with your ACR login server address so Docker knows where to push it.
- Get your ACR login server address and store it in a variable by running:
ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --resource-group $RG_NAME --query loginServer --output tsv)
echo $ACR_LOGIN_SERVER
You should see something like scaleapijane.azurecr.io printed (with your own ACR name).
- Tag your local image with the ACR prefix and log in to the registry by running these commands:
docker tag scale-api:v1 $ACR_LOGIN_SERVER/scale-api:v1
az acr login --name $ACR_NAME
You should see Login Succeeded in the terminal output.
What does tagging do?
Tagging does not copy the image. It adds a second name pointing to the same image layers on disk. The new name includes your registry address, which tells docker push where to send the layers.
- Push the image to ACR by running:
docker push $ACR_LOGIN_SERVER/scale-api:v1
You will see each layer being pushed, followed by a digest and size summary. This confirms the image is now in your registry.
Push failing with an auth error?
- Run az acr login --name $ACR_NAME again. Login tokens expire after a short time.
- Make sure ACR_LOGIN_SERVER is not empty. Run echo $ACR_LOGIN_SERVER to check. If it is blank, re-run the az acr show command from earlier.
Help me fix my Docker push to ACR.
Before you run the next command, what do you think you will see when you list the repositories in your registry?
- Verify the image is in ACR by running:
az acr repository list --name $ACR_NAME --output table
You should see scale-api listed as a repository. That confirms your image made it from your laptop into the cloud.
Your image is live in Azure Container Registry. Next up, you will deploy it to Azure Container Apps and get a live public URL.
Deploy to Container Apps
Your container image is safely stored in Azure Container Registry, but it is not running anywhere. A registry stores images. It does not serve traffic.
In this step, you will deploy your image to Azure Container Apps and get a live public URL. You will deliberately lock the deployment to a single replica so you can measure what happens when real traffic hits a lone container.
In this step, get ready to:
- Create a Container Apps environment to host your app.
- Deploy your container app with a single replica and a live public URL.
- Install the hey load tester and run a baseline test that reveals the single-replica bottleneck.
Create the Container Apps environment
A Container Apps environment is the secure boundary that groups your container apps together. It manages networking, logging, and scaling infrastructure behind the scenes.
- Set an environment variable for the environment name by running this command:
ENV_NAME="scale-api-env"
- Create the Container Apps environment by running this command:
az containerapp env create \
--name $ENV_NAME \
--resource-group $RG_NAME \
--location $LOCATION
What does this command do?
This provisions a managed environment inside your resource group. The environment handles networking and log collection for every container app you deploy into it.
This command can take a few minutes to complete. Wait for it to finish before moving on.
When the command finishes, you will see JSON output confirming the environment was created with a provisioningState of Succeeded.
Command stuck or failing?
If the creation takes longer than 10 minutes, press Ctrl+C to cancel and re-run the same az containerapp env create command. Transient Azure delays sometimes resolve on a second attempt.
help me troubleshoot my Container Apps environment creation
Deploy the container app
With the environment ready, you can deploy your container image from ACR into a live container app. You will use a system-assigned managed identity so the app can pull images from your registry securely, without a username and password.
- Set an environment variable for your app name by running this command:
APP_NAME="scale-api-app"
- Deploy the container app by running this command:
az containerapp create \
--name $APP_NAME \
--resource-group $RG_NAME \
--environment $ENV_NAME \
--image "$ACR_NAME.azurecr.io/scale-api:v1" \
--registry-server "$ACR_NAME.azurecr.io" \
--registry-identity system \
--target-port 3000 \
--ingress external \
--min-replicas 1 \
--max-replicas 1
What does this command do?
This creates a container app from your ACR image and exposes it to the internet.
- --registry-identity system creates a system-assigned managed identity and automatically grants it the AcrPull role on your registry. No passwords needed.
- --target-port 3000 tells Container Apps which port your Express server listens on.
- --ingress external makes the app reachable from the public internet with an auto-generated HTTPS URL.
- --min-replicas 1 --max-replicas 1 locks the deployment to exactly one replica. This is intentional so you can feel the performance limit in the load test.
- Once the deployment finishes, grab your app's public URL by running this command:
FQDN=$(az containerapp show \
--name $APP_NAME \
--resource-group $RG_NAME \
--query properties.configuration.ingress.fqdn \
--output tsv)
echo "Your app is live at: https://$FQDN"
What is the FQDN?
FQDN stands for Fully Qualified Domain Name. It is the public HTTPS URL that Azure generates for your container app. You will use this URL to access your API and run load tests against it.
Before you test it, what do you expect to see when you hit the /health endpoint on this live Azure URL?
- Test your live deployment by running this command:
curl https://$FQDN/health
You should see {"status":"ok","timestamp":"..."} returned from Azure, just like your local container did.
Getting a connection error or empty response?
The deployment can take a minute to become fully ready after the CLI command returns. Wait 30 seconds and try the curl command again.
If echo $FQDN prints nothing, the variable was not captured. Re-run the az containerapp show command above to set it.
Make sure you are using https:// in the URL. Container Apps redirects HTTP to HTTPS by default, and curl does not follow redirects unless you add the -L flag.
help me debug why my Container Apps deployment is not responding
Install hey and run a baseline load test
Your app is live with a single replica. One replica means one container instance handles every incoming request.
To see how that feels under pressure, you will install hey, a lightweight HTTP load testing tool, and fire 200 requests at 50 concurrent connections.
- Download the hey binary and make it executable by running these commands:
curl -o hey https://storage.googleapis.com/hey-releases/hey_linux_amd64
chmod +x hey
- Confirm hey is working by running:
./hey -h
You should see usage output listing the available flags.
Permission denied or command not found?
Make sure you ran chmod +x hey to mark the binary as executable.
If you see an architecture error, confirm you are running Linux on an AMD64/x86_64 machine.
help me install the hey load testing tool
Now think about what happens when 50 users hit your /work endpoint at the same time. Each request blocks for 500ms inside a single-threaded Node.js process running on one replica. Do you think it will keep up?
- Run the baseline load test by running this command:
./hey -n 200 -c 50 https://$FQDN/work
What does this command do?
-n 200 sends 200 total requests. -c 50 sends them with 50 concurrent connections.
With 50 requests competing for one container and each request blocking for 500ms, most requests queue up and wait their turn. The result is high average latency across the board.
Look at the hey output summary. You will see high average latency and slow response times because every request queues behind one replica.
Why is the latency so high?
Your single replica processes requests one at a time because the /work endpoint blocks the Node.js event loop with CPU-bound computation. With 50 connections competing for one container, requests pile up in line.
The fix is not to rewrite the code. The fix is to add more replicas to share the load.
Your API is live on Azure, and you have hard evidence that one replica is not enough. Next up, you will add an autoscaling rule that spins up replicas automatically when traffic spikes.
Add Autoscaling and Load Test
Your container app is live on Azure with a public URL. But the baseline load test in the last step exposed a problem. With only one replica handling all 50 concurrent requests, latency spiked and response times suffered.
The fix is autoscaling. Azure Container Apps uses KEDA (Kubernetes Event-Driven Autoscaling) to monitor incoming traffic and add replicas when demand rises. In this step, you will add an HTTP concurrency rule and prove it works with a second load test.
In this step, get ready to:
- Add an HTTP concurrency autoscaling rule to the container app.
- Run a sustained load test and watch replica count climb in the Azure portal.
- Compare performance metrics against the single-replica baseline.
Add the HTTP scaling rule
The container app currently runs with --max-replicas 1. No matter how much traffic arrives, Container Apps never adds another instance. The az containerapp update command lets you attach a KEDA-based HTTP scaling rule that monitors concurrent requests per replica.
- Update the container app with an HTTP scaling rule by running this command:
az containerapp update \
--name $APP_NAME \
--resource-group $RG_NAME \
--min-replicas 1 \
--max-replicas 5 \
--scale-rule-name http-scale-rule \
--scale-rule-type http \
--scale-rule-http-concurrency 10
What does this command do?
- --max-replicas 5 raises the ceiling from 1 to 5. Container Apps can now run up to 5 copies of your container.
- --scale-rule-name http-scale-rule names the scaling rule so you can reference or update it later.
- --scale-rule-type http tells KEDA to monitor HTTP traffic specifically.
- --scale-rule-http-concurrency 10 sets the threshold. Every 15 seconds, KEDA calculates concurrent requests as the number of requests in that window divided by 15. When that average exceeds 10 per replica, Container Apps adds another replica.
- Wait for the command to finish. It outputs JSON showing the updated container app configuration.
- Confirm maxReplicas shows 5 in the output and the scaling rule name http-scale-rule appears.
Command failed or timed out?
- Make sure $APP_NAME and $RG_NAME are still set in your terminal. Run echo $APP_NAME $RG_NAME to check. If they are blank, re-export the values you used in Step 4.
- If you see a permission error, confirm you are still logged in by running az account show. If your session expired, run az login again.
- Still stuck? Help me debug this az containerapp update error
Fire the load test and observe scaling
The scaling rule is set. Now you will send sustained traffic for 60 seconds and watch the Azure portal's metrics blade in real time as Container Apps spins up replicas.
Before you run this, how many replicas do you think Container Apps will create? With 50 concurrent requests and a concurrency threshold of 10, what is your prediction?
- Fire a 60-second load test by running this command:
hey -z 60s -c 50 http://$FQDN/work
What does this command do?
- The -z 60s flag sends requests continuously for 60 seconds instead of a fixed count.
- The -c 50 flag maintains 50 concurrent connections throughout the test.
- With a concurrency threshold of 10, KEDA calculates that 50 concurrent requests need 5 replicas (50 / 10 = 5).
While hey runs (it takes a full minute), open the Azure portal to watch scaling happen live.
- Open your browser and go to https://portal.azure.com.
- Type your container app name in the search bar at the top and select it from the results.
- In the left sidebar, click Metrics (under the Monitoring section).
- In the Metric dropdown, select Replica Count.
- Set the time range to Last 30 minutes using the time picker in the top right of the chart.
You should see the Replica Count line climb from 1 toward 5 as KEDA responds to the sustained traffic.
Why does scale-down take so long?
After the hey command finishes, the load stops. Container Apps has a 300-second (5-minute) stabilization window before it scales replicas back down. This prevents flapping where replicas spin up and down rapidly during bursty traffic.
Wait 5 minutes after the test ends, then refresh the metrics chart. You should see replicas return to 1.
Not seeing replicas scale up?
- Metrics can take 1-2 minutes to appear in the chart. Refresh the page and make sure the time range covers the last 30 minutes.
- Confirm the scaling rule was applied by running az containerapp show --name $APP_NAME --resource-group $RG_NAME --query 'properties.template.scale'. You should see maxReplicas set to 5 and the rule name http-scale-rule.
- Still stuck? Help me figure out why my Container App replicas are not scaling up
Compare results against the baseline
Look at the hey summary that printed after the load test finished. Two lines in the Summary section tell the story.
- Find the Average line. This is the average latency per request in seconds. Compare it to the Average from your Step 4 baseline test.
- Find the Requests/sec line. This is total throughput. Compare it to the same line from the baseline.
Why is it faster?
In the baseline test, all 50 concurrent requests hit a single container. Each request blocks for 500ms in the /work endpoint, so requests queue up behind each other.
With 5 replicas, the load spreads across 5 containers. Each handles roughly 10 concurrent requests, so there is less queuing and faster responses.
Secret mission
Scale to Zero
Your app keeps at least one replica running at all times, even when nobody is sending requests. What if it could scale all the way down to zero when idle, saving every last cent of compute cost? In this secret mission, you will configure scale-to-zero, measure the cold-start penalty, and watch replicas go from 0 to N and back to 0 in the Azure portal.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Azure Container Apps and Azure Container Registry both have free monthly grants, but leaving resources running long-term beyond those grants can incur charges.
Cost warning
Azure Container Apps includes an always-free monthly grant of 180,000 vCPU-seconds, 360,000 GiB-seconds, and 2,000,000 HTTP requests per subscription. If your app is scaled to zero, you incur no compute charges.
Azure Container Registry Standard tier is free for 12 months on new Azure accounts. After that period, or on existing accounts, the Standard tier incurs storage and networking charges. If you are not planning to use these resources again soon, choose Pause or Delete below.
Resources you used:
- Azure resource group containing: Container Apps environment, Container App, Azure Container Registry (Standard tier), and Log Analytics workspace.
- Local scale-api project directory on your Desktop.
- Local Docker image scale-api:v1 (and the ACR-tagged copy).
- hey load testing binary.
Keep everything running
No action needed. Choose this if you are still actively building on this project or plan to extend it further.
- Your app stays live at its public FQDN. Container Apps bills per-second only while replicas are active.
- If you completed the Secret Mission, your app is already configured to scale to zero. You incur no compute charges when there is no traffic.
- ACR Standard tier is free for 12 months on new Azure accounts. After that, storage charges apply.
- Your local Docker image and project files remain on your machine and take up a small amount of disk space.
Pause - I'll come back to this later
Scale your container app to zero replicas so you pay nothing for compute while you are away. Your Azure resources and local files stay intact for when you return.
- If you completed the Secret Mission, your app is already at zero minimum replicas. No further action is needed for compute.
- If you did not complete the Secret Mission, scale to zero by running this command:
az containerapp update \
--name $APP_NAME \
--resource-group $RG_NAME \
--min-replicas 0
- With zero minimum replicas, no replicas run until the next request arrives. You only pay for active usage.
- ACR Standard tier continues running and may incur storage charges after the 12-month free period on new accounts.
- Optionally, stop the local Docker container to free up memory by running:
docker stop $(docker ps -q --filter ancestor=scale-api:v1)
Delete - I don't want to use this again
Remove all Azure resources and local project files to start completely fresh.
Delete all Azure resources (resource group, Container App, Container Apps environment, ACR, and Log Analytics workspace) in one command:
- Run this command to delete the entire resource group and everything inside it:
az group delete --name $RG_NAME --yes --no-wait
What does this command do?
The --yes flag skips the confirmation prompt. The --no-wait flag returns control to your terminal immediately while Azure deletes the resources in the background. Full deletion can take a few minutes.
Remove local Docker artifacts:
- Stop and remove any running containers from the project image, then delete the local images by running:
docker rm -f $(docker ps -a -q --filter ancestor=scale-api:v1)
docker rmi scale-api:v1
docker rmi $ACR_NAME.azurecr.io/scale-api:v1
Delete local project files:
- Remove the project directory and the hey binary by running:
rm -rf ~/Desktop/scale-api
rm ./hey_linux_amd64
Can't find the hey binary?
If you moved the hey binary to a different location, check the directory where you originally downloaded it. The file is named hey_linux_amd64 unless you renamed it.
What about Docker, Azure CLI, and Node.js?
These are general-purpose development tools you will likely use in future projects. There is no need to uninstall them unless you are sure you will not need them again.
That's a Wrap!
That's a Wrap!
Nice work! You just took a Node.js API from a local script all the way to a live, autoscaling deployment on Azure Container Apps, and you proved it scales under real traffic.
You've learned how to:
- Containerize a Node.js Express API with a Dockerfile, build a production image, and push it to Azure Container Registry.
- Deploy a container to Azure Container Apps with external ingress and a live public HTTPS URL.
- Configure HTTP concurrency autoscaling powered by KEDA, then load-test with hey and watch replicas scale up under traffic and back down when it stops.
- Secret Mission: Configured scale-to-zero, measured cold-start latency, and observed the full 0-to-N-to-0 replica lifecycle.
Ready to quiz yourself?