Skip to content
Learni
View all tutorials
Cloud Computing

How to Deploy a Scalable App on Azure App Service in 2026

Lire en français

Introduction

Azure App Service is the leading PaaS platform for hosting web, mobile, and API apps without managing the underlying infrastructure. In 2026, with the rise of AI and hybrid workloads, mastering advanced App Service means implementing zero-downtime deployments via slots, intelligent autoscaling with KEDA, and native GitHub CI/CD. This advanced tutorial guides a senior developer through building a Node.js Express app, deploying it with Azure CLI and Bicep, setting up slots for blue-green deployments, and scaling based on custom metrics.

Why it matters: Modern apps face unpredictable spikes; App Service shines with seamless integration to Azure Monitor, Application Insights, and Virtual Network. You'll save hours on ops by automating everything. At the end, your app will be production-ready, scalable to 1000+ instances, with proactive monitoring. Ready to go pro? (128 words)

Prerequisites

  • Azure account (free or paid with sufficient credit for Premium App Service Plan)
  • Azure CLI 2.65+ installed
  • Node.js 20+ and npm
  • GitHub account with a private repo
  • VS Code with Azure App Service and Bicep extensions
  • Advanced knowledge of TypeScript, DevOps, and ARM/Bicep

Create the Base Node.js Application

src/server.ts
import express, { Request, Response, NextFunction } from 'express';
import helmet from 'helmet';
import cors from 'cors';

const app = express();
const PORT = process.env.PORT || 8080;

app.use(helmet());
app.use(cors());
app.use(express.json());

app.get('/health', (req: Request, res: Response) => {
  res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
});

app.get('/api/metrics', (req: Request, res: Response) => {
  // Simulate CPU-intensive metric for scaling demo
  const load = Math.random() * 100;
  res.json({ cpuLoad: load, requests: process.env.REQUEST_COUNT || '0' });
});

app.post('/api/increment', (req: Request, res: Response) => {
  const count = (parseInt(process.env.REQUEST_COUNT || '0') + 1).toString();
  process.env.REQUEST_COUNT = count;
  res.json({ incremented: count });
});

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal Server Error' });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

This TypeScript Express server includes security (Helmet, CORS), a health check for liveness/readiness probes, and endpoints to simulate CPU load and requests. Env vars like REQUEST_COUNT persist via App Settings. Pitfall: Always type Request/Response to avoid runtime errors; test locally with npx ts-node src/server.ts.

Generate package.json and tsconfig.json

package.json
{
  "name": "azure-app-service-demo",
  "version": "1.0.0",
  "main": "dist/server.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "dev": "ts-node src/server.ts"
  },
  "dependencies": {
    "express": "^4.19.2",
    "helmet": "^7.1.0",
    "cors": "^2.8.5"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^20.11.19",
    "ts-node": "^10.9.2",
    "typescript": "^5.3.3"
  }
}

This package.json supports TS-to-JS build for Azure (dist/), with scripts for dev/prod. Install with npm i then npm run build. Pitfall: Don't forget @types for IntelliSense; Azure auto-detects Node via engine in package.json (add "engines": {"node": "20.x"} if needed).

Install Azure CLI and Create Resource Group

setup-azure.sh
#!/bin/bash
# Installer Azure CLI si pas présent
curl -sL https://aka.ms/InstallAzureCLIDeb | bash

# Login (utilisez az login --use-device-code pour headless)
az login

# Variables
RESOURCE_GROUP="rg-appservice-demo-$(date +%Y%m%d)"
LOCATION="westeurope"
APP_NAME="demo-app-$(date +%s)"

# Créer resource group
az group create --name $RESOURCE_GROUP --location $LOCATION

echo "Resource group créé: $RESOURCE_GROUP"
echo "App name généré: $APP_NAME"
export RESOURCE_GROUP=$RESOURCE_GROUP
export APP_NAME=$APP_NAME

This bash script installs Azure CLI, logs in, and creates a unique RG with timestamp. Run bash setup-azure.sh then source the exports. Pitfall: Use westeurope for low EU latency; check RG quotas with az group list.

Deploy App Service Plan and App via CLI

deploy-cli.sh
#!/bin/bash
source setup-azure.sh
exported

# Créer App Service Plan Premium V3 (autoscaling)
az appservice plan create \
  --name ${APP_NAME}-plan \
  --resource-group $RESOURCE_GROUP \
  --sku P1V3 \
  --is-linux

# Créer web app Node.js
az webapp create \
  --resource-group $RESOURCE_GROUP \
  --plan ${APP_NAME}-plan \
  --name $APP_NAME \
  --runtime "NODE|20-lts" \
  --deployment-local-git

# Config app settings pour scaling demo
az webapp config appsettings set \
  --resource-group $RESOURCE_GROUP \
  --name $APP_NAME \
  --settings REQUEST_COUNT=0 WEBSITE_NODE_DEFAULT_VERSION=20

# Build et ZIP deploy
npm run build
zip -r app.zip .
az webapp deployment source config-zip \
  --resource-group $RESOURCE_GROUP \
  --name $APP_NAME \
  --src app.zip

# URL de l'app
echo "App déployée: https://${APP_NAME}.azurewebsites.net/health"

This script creates a Premium V3 Linux plan (auto scale-out), Node 20 webapp, sets env vars, builds/zips, and deploys. Test /health. Pitfall: Premium V3 required for slots/custom scaling; ZIP max 2GB, use Git for larger.

Bicep Template for Infrastructure as Code

main.bicep
param location string = resourceGroup().location
param appName string

resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: '${appName}-plan'
  location: location
  sku: {
    name: 'P1V3'
    tier: 'PremiumV3'
  }
  kind: 'linux'
}

resource appService 'Microsoft.Web/sites@2023-01-01' = {
  name: appName
  location: location
  properties: {
    serverFarmId: appServicePlan.id
    siteConfig: {
      linuxFxVersion: 'NODE|20-lts'
      appSettings: [
        { name: 'REQUEST_COUNT', value: '0' }
        { name: 'WEBSITE_RUN_FROM_PACKAGE', value: '1' }
      ]
    }
  }
}

output appUrl string = 'https://${appName}.azurewebsites.net'

This Bicep template declares the Plan and App Service with settings. Deploy with az deployment group create --resource-group $RESOURCE_GROUP --template-file main.bicep --parameters appName=$APP_NAME. Pitfall: WEBSITE_RUN_FROM_PACKAGE=1 for run-from-zip without warmup; idempotent and reusable in pipelines.

GitHub Actions Workflow for CI/CD

.github/workflows/deploy.yml
name: Deploy to Azure App Service

on:
  push:
    branches: [ main ]

env:
  AZURE_WEBAPP_NAME: ${{ secrets.AZURE_WEBAPP_NAME }}
  AZURE_WEBAPP_PACKAGE_PATH: 'app.zip'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - name: Setup Node
      uses: actions/setup-node@v4
      with:
        node-version: '20'
    - run: npm ci
    - run: npm run build
    - run: zip -r ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} .
    - uses: actions/upload-artifact@v4
      with:
        name: app
        path: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
    - uses: azure/login@v2
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}
    - download: current
      name: Download
      uses: actions/download-artifact@v4
      with:
        name: app
    - uses: azure/webapps-deploy@v3
      with:
        app-name: ${{ env.AZURE_WEBAPP_NAME }}
        package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}

This workflow builds/zips on main push, uses OIDC Azure login (via AZURE_CREDENTIALS JSON secret), and deploys ZIP. Add GitHub secrets. Pitfall: ZIP enables run-from-package; test locally with act.

Create Staging Slot and Swap

slots.sh
#!/bin/bash
source setup-azure.sh
exported

# Créer production slot (défaut)
# Créer staging slot
az webapp deployment slot create \
  --name $APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --slot staging

# Déployer sur staging (même ZIP)
zip -r staging.zip .
az webapp deployment source config-zip \
  --resource-group $RESOURCE_GROUP \
  --name $APP_NAME \
  --slot staging \
  --src staging.zip

# Test staging
curl https://${APP_NAME}-staging.azurewebsites.net/health

# Swap zero-downtime
az webapp deployment slot swap \
  --name $APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --slot staging \
  --target-slot production

echo "Swappé ! Prod: https://${APP_NAME}.azurewebsites.net/health"

Slots enable blue-green deployments: deploy/test on staging, atomic swap. Pitfall: Configure traffic % with az webapp traffic ...; auto warmup on Premium.

Best Practices

  • Always use slots for zero-downtime and A/B testing.
  • Implement KEDA scaling on custom metrics (CPU >80%, requests/sec) via az monitor autoscale ....
  • Integrate Application Insights: az webapp config appsettings set APPINSIGHTS_INSTRUMENTATIONKEY=....
  • VNet integration for private endpoints: az webapp vnet-integration add.
  • Secrets in Key Vault: Reference via @Microsoft.KeyVault(...) in Bicep.

Common Errors to Avoid

  • Forgetting Premium Plan: Basic/Shared block slots/autoscale; upgrade early.
  • ZIP too large: >2GB fails; switch to Git/Containers.
  • No health checks: Probes fail, app marked unhealthy; expose /health.
  • Hardcoded secrets: Use App Settings or KV, never in code.

Next Steps