Deploy a Node.js TypeScript app to App Service on Linux without publishing passwords

Run a TypeScript HTTP server on App Service for Linux — a plan and web app in Bicep (Microsoft.Web 2025-03-01) with a user-assigned identity, Node 22 or 24, PM2 in the foreground, a health check path, TLS 1.2, FTP off and basic publishing credentials disabled — a server that listens on PORT and answers health checks, a zip built locally, and deployment with az webapp deploy under your Entra sign-in.

markdownguidev1QTDZ-GN4Rstage: buildtrust: officialservice: app-serviceinterface: iacinterface: clilanguages: typescriptlanguages: bicepdeploy Node.js App Service LinuxApp Service BicepMicrosoft.Web/sites@2025-03-01 applinuxlinuxFxVersion NODE|22-ltsNODE|24-ltsappCommandLine pm2 --no-daemonhealthCheckPath BicepbasicPublishingCredentialsPolicies scm ftp allow falseftpsState DisabledkeyVaultReferenceIdentity BicepAZURE_CLIENT_ID App Serviceaz webapp deploy --type zipSCM_DO_BUILD_DURING_DEPLOYMENT falseprocess.env.PORT TypeScriptWEBSITE_INSTANCE_ID

Created 21 Oct 2026 · Modified 23 Oct 2026 · 1 version

Deploy a Node.js TypeScript app to App Service on Linux

Microsoft.Web 2025-03-01 is the newest API version with Bicep 0.48.1 types (later ones give BCP081). Check the runtime strings live with az webapp list-runtimes --os-type linux.

1. Infrastructure (main.bicep)

@description('Web app name: globally unique.')
param appName string

param location string = resourceGroup().location

@description('App Service plan SKU, for example B1, P0v3 or P1v3.')
param skuName string = 'P0v3'

@allowed(['NODE|22-lts', 'NODE|24-lts'])
param linuxFxVersion string = 'NODE|22-lts'

resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
  name: 'id-${appName}'
  location: location
}

resource plan 'Microsoft.Web/serverfarms@2025-03-01' = {
  name: 'plan-${appName}'
  location: location
  kind: 'linux'
  sku: {
    name: skuName
  }
  properties: {
    reserved: true
  }
}

resource app 'Microsoft.Web/sites@2025-03-01' = {
  name: appName
  location: location
  kind: 'app,linux'
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '${identity.id}': {}
    }
  }
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
    keyVaultReferenceIdentity: identity.id
    siteConfig: {
      linuxFxVersion: linuxFxVersion
      appCommandLine: 'pm2 start dist/server.js --no-daemon'
      healthCheckPath: '/healthz'
      minTlsVersion: '1.2'
      ftpsState: 'Disabled'
      alwaysOn: true
      appSettings: [
        {
          name: 'AZURE_CLIENT_ID'
          value: identity.properties.clientId
        }
        {
          name: 'SCM_DO_BUILD_DURING_DEPLOYMENT'
          value: 'false'
        }
      ]
    }
  }
}

// Turn off basic (user name and password) publishing for SCM and FTP
resource scmBasicAuth 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2025-03-01' = {
  parent: app
  name: 'scm'
  properties: {
    allow: false
  }
}

resource ftpBasicAuth 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2025-03-01' = {
  parent: app
  name: 'ftp'
  properties: {
    allow: false
  }
}

output appUrl string = 'https://${app.properties.defaultHostName}'
output principalId string = identity.properties.principalId

Deploy: az deployment group create -g <rg> -f main.bicep -p appName=<app>. Grant the identity's principalId the data roles it needs (guides/rbac/assign-roles.md); AZURE_CLIENT_ID makes DefaultAzureCredential pick this identity. alwaysOn needs Basic or higher.

2. The server (src/server.ts)

import { createServer } from "node:http";

// App Service sets PORT; 8080 is only a local default.
const port = Number(process.env.PORT ?? 8080);

const server = createServer((req, res) => {
  if (req.url === "/healthz") {
    // Health check: answer fast, no auth, no calls to dependencies.
    res.writeHead(200, { "content-type": "text/plain" }).end("ok");
    return;
  }
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ message: "hello", instance: process.env.WEBSITE_INSTANCE_ID ?? "local" }));
});

server.listen(port, () => console.log(`listening on ${port}`));

// Close cleanly when the container is stopped.
process.on("SIGTERM", () => server.close(() => process.exit(0)));

3. Build and deploy

npm ci && npm run build          # tsc → dist/server.js
npm prune --omit=dev             # ship production dependencies only
zip -r app.zip dist node_modules package.json
az webapp deploy -g <rg> -n <app> --src-path app.zip --type zip

Build automation is off (SCM_DO_BUILD_DURING_DEPLOYMENT=false), so the zip is run as-is: it must contain dist and node_modules. To build on the platform instead, set it to true and zip the sources; see reference/app-service/configuration-and-networking.md for what that build runs. az webapp deploy authenticates with your Entra sign-in, so it works with basic publishing credentials disabled; publish profiles and FTP don't.

4. Check it

az webapp log tail -g <rg> -n <app>
curl -sS https://<app>.azurewebsites.net/healthz

If the container never becomes ready, the cause is almost always the port, the startup command or a missing file in the zip: reference/app-service/errors.md. For CI, use GitHub Actions with OIDC: patterns/github-actions-to-azure.md. For traces and metrics: guides/monitor/instrument-node-app-with-opentelemetry.md.

Read this document as markdown — the same bytes an agent receives.